fix: ensures generated IDs persist on create (#10089)

IDs that are supplied directly through the API, such as client-side
generated IDs when adding new blocks and array rows, are overwritten on
create. This is because when adding blocks or array rows on the client,
their IDs are generated first before being sent to the server for
processing. Then when the server receives this data, it incorrectly
overrides them to ensure they are unique when using relational DBs. But
this only needs to happen when no ID was supplied on create, or
specifically when duplicating documents via the `beforeDuplicate` hook.
This commit is contained in:
Jacob Fletcher
2024-12-20 15:14:23 -05:00
committed by GitHub
parent 4e953530df
commit 957867f6e2
7 changed files with 291 additions and 402 deletions

View File

@@ -13,23 +13,8 @@ export const baseIDField: TextField = {
}, },
defaultValue: () => new ObjectId().toHexString(), defaultValue: () => new ObjectId().toHexString(),
hooks: { hooks: {
beforeChange: [ beforeChange: [({ value }) => value || new ObjectId().toHexString()],
({ operation, value }) => { beforeDuplicate: [() => new ObjectId().toHexString()],
// If creating new doc, need to disregard any
// ids that have been passed in because they will cause
// primary key unique conflicts in relational DBs
if (!value || (operation === 'create' && value)) {
return new ObjectId().toHexString()
}
return value
},
],
beforeDuplicate: [
() => {
return new ObjectId().toHexString()
},
],
}, },
label: 'ID', label: 'ID',
} }

View File

@@ -54,6 +54,31 @@ export default buildConfigWithDefaults({
], ],
}, },
}, },
{
name: 'arrayWithIDs',
type: 'array',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
name: 'blocksWithIDs',
type: 'blocks',
blocks: [
{
slug: 'block',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
},
], ],
hooks: { hooks: {
beforeOperation: [ beforeOperation: [

View File

@@ -7,7 +7,7 @@ import {
migrateRelationshipsV2_V3, migrateRelationshipsV2_V3,
migrateVersionsV1_V2, migrateVersionsV1_V2,
} from '@payloadcms/db-mongodb/migration-utils' } from '@payloadcms/db-mongodb/migration-utils'
import { desc, type Table } from 'drizzle-orm' import { type Table } from 'drizzle-orm'
import * as drizzlePg from 'drizzle-orm/pg-core' import * as drizzlePg from 'drizzle-orm/pg-core'
import * as drizzleSqlite from 'drizzle-orm/sqlite-core' import * as drizzleSqlite from 'drizzle-orm/sqlite-core'
import fs from 'fs' import fs from 'fs'
@@ -136,6 +136,63 @@ describe('database', () => {
it('should not accidentally treat nested id fields as custom id', () => { it('should not accidentally treat nested id fields as custom id', () => {
expect(payload.collections['fake-custom-ids'].customIDType).toBeUndefined() expect(payload.collections['fake-custom-ids'].customIDType).toBeUndefined()
}) })
it('should not overwrite supplied block and array row IDs on create', async () => {
const arrayRowID = '67648ed5c72f13be6eacf24e'
const blockID = '6764de9af79a863575c5f58c'
const doc = await payload.create({
collection: 'posts',
data: {
title: 'test',
arrayWithIDs: [
{
id: arrayRowID,
},
],
blocksWithIDs: [
{
blockType: 'block',
id: blockID,
},
],
},
})
expect(doc.arrayWithIDs[0].id).toStrictEqual(arrayRowID)
expect(doc.blocksWithIDs[0].id).toStrictEqual(blockID)
})
it('should overwrite supplied block and array row IDs on duplicate', async () => {
const arrayRowID = '6764deb5201e9e36aeba3b6c'
const blockID = '6764dec58c68f337a758180c'
const doc = await payload.create({
collection: 'posts',
data: {
title: 'test',
arrayWithIDs: [
{
id: arrayRowID,
},
],
blocksWithIDs: [
{
blockType: 'block',
id: blockID,
},
],
},
})
const duplicate = await payload.duplicate({
collection: 'posts',
id: doc.id,
})
expect(duplicate.arrayWithIDs[0].id).not.toStrictEqual(arrayRowID)
expect(duplicate.blocksWithIDs[0].id).not.toStrictEqual(blockID)
})
}) })
describe('timestamps', () => { describe('timestamps', () => {

View File

@@ -90,6 +90,20 @@ export interface Post {
title: string; title: string;
hasTransaction?: boolean | null; hasTransaction?: boolean | null;
throwAfterChange?: boolean | null; throwAfterChange?: boolean | null;
arrayWithIDs?:
| {
text?: string | null;
id?: string | null;
}[]
| null;
blocksWithIDs?:
| {
text?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'block';
}[]
| null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -428,6 +442,23 @@ export interface PostsSelect<T extends boolean = true> {
title?: T; title?: T;
hasTransaction?: T; hasTransaction?: T;
throwAfterChange?: T; throwAfterChange?: T;
arrayWithIDs?:
| T
| {
text?: T;
id?: T;
};
blocksWithIDs?:
| T
| {
block?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }

View File

@@ -2285,23 +2285,6 @@ describe('Fields', () => {
expect(blockFieldsFail.docs).toHaveLength(0) expect(blockFieldsFail.docs).toHaveLength(0)
}) })
it('should create when existing block ids are used', async () => {
const blockFields = await payload.find({
collection: 'block-fields',
limit: 1,
})
const [doc] = blockFields.docs
const result = await payload.create({
collection: 'block-fields',
data: {
...doc,
},
})
expect(result.id).toBeDefined()
})
it('should filter based on nested block fields', async () => { it('should filter based on nested block fields', async () => {
await payload.create({ await payload.create({
collection: 'block-fields', collection: 'block-fields',

View File

@@ -2157,257 +2157,42 @@ export interface BlockFieldsSelect<T extends boolean = true> {
blocks?: blocks?:
| T | T
| { | {
content?: content?: T | ContentBlockSelect<T>;
| T number?: T | NumberBlockSelect<T>;
| { subBlocks?: T | SubBlocksBlockSelect<T>;
text?: T; tabs?: T | TabsBlockSelect<T>;
richText?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
subBlocks?:
| T
| {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
};
tabs?:
| T
| {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
};
}; };
duplicate?: duplicate?:
| T | T
| { | {
content?: content?: T | ContentBlockSelect<T>;
| T number?: T | NumberBlockSelect<T>;
| { subBlocks?: T | SubBlocksBlockSelect<T>;
text?: T; tabs?: T | TabsBlockSelect<T>;
richText?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
subBlocks?:
| T
| {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
};
tabs?:
| T
| {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
};
}; };
collapsedByDefaultBlocks?: collapsedByDefaultBlocks?:
| T | T
| { | {
localizedContent?: localizedContent?: T | LocalizedContentBlockSelect<T>;
| T localizedNumber?: T | LocalizedNumberBlockSelect<T>;
| { localizedSubBlocks?: T | LocalizedSubBlocksBlockSelect<T>;
text?: T; localizedTabs?: T | LocalizedTabsBlockSelect<T>;
richText?: T;
id?: T;
blockName?: T;
};
localizedNumber?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
localizedSubBlocks?:
| T
| {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
};
localizedTabs?:
| T
| {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
};
}; };
disableSort?: disableSort?:
| T | T
| { | {
localizedContent?: localizedContent?: T | LocalizedContentBlockSelect<T>;
| T localizedNumber?: T | LocalizedNumberBlockSelect<T>;
| { localizedSubBlocks?: T | LocalizedSubBlocksBlockSelect<T>;
text?: T; localizedTabs?: T | LocalizedTabsBlockSelect<T>;
richText?: T;
id?: T;
blockName?: T;
};
localizedNumber?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
localizedSubBlocks?:
| T
| {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
};
localizedTabs?:
| T
| {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
};
}; };
localizedBlocks?: localizedBlocks?:
| T | T
| { | {
localizedContent?: localizedContent?: T | LocalizedContentBlockSelect<T>;
| T localizedNumber?: T | LocalizedNumberBlockSelect<T>;
| { localizedSubBlocks?: T | LocalizedSubBlocksBlockSelect<T>;
text?: T; localizedTabs?: T | LocalizedTabsBlockSelect<T>;
richText?: T;
id?: T;
blockName?: T;
};
localizedNumber?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
localizedSubBlocks?:
| T
| {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
};
localizedTabs?:
| T
| {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
};
}; };
i18nBlocks?: i18nBlocks?:
| T | T
@@ -2545,6 +2330,116 @@ export interface BlockFieldsSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ContentBlock_select".
*/
export interface ContentBlockSelect<T extends boolean = true> {
text?: T;
richText?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "NumberBlock_select".
*/
export interface NumberBlockSelect<T extends boolean = true> {
number?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "SubBlocksBlock_select".
*/
export interface SubBlocksBlockSelect<T extends boolean = true> {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "TabsBlock_select".
*/
export interface TabsBlockSelect<T extends boolean = true> {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localizedContentBlock_select".
*/
export interface LocalizedContentBlockSelect<T extends boolean = true> {
text?: T;
richText?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localizedNumberBlock_select".
*/
export interface LocalizedNumberBlockSelect<T extends boolean = true> {
number?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localizedSubBlocksBlock_select".
*/
export interface LocalizedSubBlocksBlockSelect<T extends boolean = true> {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localizedTabsBlock_select".
*/
export interface LocalizedTabsBlockSelect<T extends boolean = true> {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "checkbox-fields_select". * via the `definition` "checkbox-fields_select".
@@ -3127,53 +3022,10 @@ export interface TabsFieldsSelect<T extends boolean = true> {
blocks?: blocks?:
| T | T
| { | {
content?: content?: T | ContentBlockSelect<T>;
| T number?: T | NumberBlockSelect<T>;
| { subBlocks?: T | SubBlocksBlockSelect<T>;
text?: T; tabs?: T | TabsBlockSelect<T>;
richText?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
subBlocks?:
| T
| {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
};
tabs?:
| T
| {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
};
}; };
group?: group?:
| T | T
@@ -3183,24 +3035,7 @@ export interface TabsFieldsSelect<T extends boolean = true> {
textInRow?: T; textInRow?: T;
numberInRow?: T; numberInRow?: T;
json?: T; json?: T;
tab?: tab?: T | TabWithNameSelect<T>;
| T
| {
array?:
| T
| {
text?: T;
id?: T;
};
text?: T;
defaultValue?: T;
arrayInRow?:
| T
| {
textInArrayInRow?: T;
id?: T;
};
};
namedTabWithDefaultValue?: namedTabWithDefaultValue?:
| T | T
| { | {
@@ -3250,6 +3085,26 @@ export interface TabsFieldsSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "TabWithName_select".
*/
export interface TabWithNameSelect<T extends boolean = true> {
array?:
| T
| {
text?: T;
id?: T;
};
text?: T;
defaultValue?: T;
arrayInRow?:
| T
| {
textInArrayInRow?: T;
id?: T;
};
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "text-fields_select". * via the `definition` "text-fields_select".

View File

@@ -12,21 +12,13 @@
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"jsx": "preserve", "jsx": "preserve",
"lib": [ "lib": ["DOM", "DOM.Iterable", "ES2022"],
"DOM",
"DOM.Iterable",
"ES2022"
],
"outDir": "${configDir}/dist", "outDir": "${configDir}/dist",
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"sourceMap": true, "sourceMap": true,
"types": [ "types": ["jest", "node", "@types/jest"],
"jest",
"node",
"@types/jest"
],
"incremental": true, "incremental": true,
"isolatedModules": true, "isolatedModules": true,
"strict": false, "strict": false,
@@ -36,72 +28,33 @@
} }
], ],
"paths": { "paths": {
"@payload-config": [ "@payload-config": ["./test/database/config.ts"],
"./test/_community/config.ts" "@payloadcms/live-preview": ["./packages/live-preview/src"],
], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
"@payloadcms/live-preview": [ "@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],
"./packages/live-preview/src" "@payloadcms/ui": ["./packages/ui/src/exports/client/index.ts"],
], "@payloadcms/ui/shared": ["./packages/ui/src/exports/shared/index.ts"],
"@payloadcms/live-preview-react": [ "@payloadcms/ui/scss": ["./packages/ui/src/scss.scss"],
"./packages/live-preview-react/src/index.ts" "@payloadcms/ui/scss/app.scss": ["./packages/ui/src/scss/app.scss"],
], "@payloadcms/next/*": ["./packages/next/src/exports/*.ts"],
"@payloadcms/live-preview-vue": [
"./packages/live-preview-vue/src/index.ts"
],
"@payloadcms/ui": [
"./packages/ui/src/exports/client/index.ts"
],
"@payloadcms/ui/shared": [
"./packages/ui/src/exports/shared/index.ts"
],
"@payloadcms/ui/scss": [
"./packages/ui/src/scss.scss"
],
"@payloadcms/ui/scss/app.scss": [
"./packages/ui/src/scss/app.scss"
],
"@payloadcms/next/*": [
"./packages/next/src/exports/*.ts"
],
"@payloadcms/richtext-lexical/client": [ "@payloadcms/richtext-lexical/client": [
"./packages/richtext-lexical/src/exports/client/index.ts" "./packages/richtext-lexical/src/exports/client/index.ts"
], ],
"@payloadcms/richtext-lexical/rsc": [ "@payloadcms/richtext-lexical/rsc": ["./packages/richtext-lexical/src/exports/server/rsc.ts"],
"./packages/richtext-lexical/src/exports/server/rsc.ts" "@payloadcms/richtext-slate/rsc": ["./packages/richtext-slate/src/exports/server/rsc.ts"],
],
"@payloadcms/richtext-slate/rsc": [
"./packages/richtext-slate/src/exports/server/rsc.ts"
],
"@payloadcms/richtext-slate/client": [ "@payloadcms/richtext-slate/client": [
"./packages/richtext-slate/src/exports/client/index.ts" "./packages/richtext-slate/src/exports/client/index.ts"
], ],
"@payloadcms/plugin-seo/client": [ "@payloadcms/plugin-seo/client": ["./packages/plugin-seo/src/exports/client.ts"],
"./packages/plugin-seo/src/exports/client.ts" "@payloadcms/plugin-sentry/client": ["./packages/plugin-sentry/src/exports/client.ts"],
], "@payloadcms/plugin-stripe/client": ["./packages/plugin-stripe/src/exports/client.ts"],
"@payloadcms/plugin-sentry/client": [ "@payloadcms/plugin-search/client": ["./packages/plugin-search/src/exports/client.ts"],
"./packages/plugin-sentry/src/exports/client.ts"
],
"@payloadcms/plugin-stripe/client": [
"./packages/plugin-stripe/src/exports/client.ts"
],
"@payloadcms/plugin-search/client": [
"./packages/plugin-search/src/exports/client.ts"
],
"@payloadcms/plugin-form-builder/client": [ "@payloadcms/plugin-form-builder/client": [
"./packages/plugin-form-builder/src/exports/client.ts" "./packages/plugin-form-builder/src/exports/client.ts"
], ],
"@payloadcms/next": [ "@payloadcms/next": ["./packages/next/src/exports/*"]
"./packages/next/src/exports/*"
]
} }
}, },
"include": [ "include": ["${configDir}/src"],
"${configDir}/src" "exclude": ["${configDir}/dist", "${configDir}/build", "${configDir}/temp", "**/*.spec.ts"]
],
"exclude": [
"${configDir}/dist",
"${configDir}/build",
"${configDir}/temp",
"**/*.spec.ts"
]
} }