Skip to content

Commit 40081f4

Browse files
fix(db-mongodb): find id field from flattened fields (#15110)
Fixes #14927 ## Summary Fixes custom ID fields that are nested within tabs/groups to work correctly with MongoDB adapter. ## Problem When a custom id field was nested inside tabs or other unnamed grouping fields, MongoDB's schema builder couldn't find it because it only searched top-level fields. This caused the custom ID to be ignored and MongoDB would fall back to auto-generating ObjectIDs. ## Solution - MongoDB adapter: Pass flattenedFields to schema builder so it can find custom ID fields at the flattened top level - afterRead hooks: Track field depth and prevent hidden custom ID fields from being removed when they're at the top level (depth 0), since they need to be accessible even if marked as hidden ## Testing Added new CustomIDNested collection and comprehensive tests covering: - Creating documents with numeric custom IDs nested in tabs - Retrieving documents by custom ID - Updating documents with custom ID
1 parent 5ebda61 commit 40081f4

11 files changed

Lines changed: 241 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,47 @@ Payload is a monorepo structured around Next.js, containing the core CMS platfor
8282

8383
## Testing
8484

85+
### Writing Tests - Required Practices
86+
87+
**Tests MUST be self-contained and clean up after themselves:**
88+
89+
- If you create a database record in a test, you MUST delete it before the test completes
90+
- For multiple tests with similar cleanup needs, use `afterEach` to centralize cleanup logic
91+
- Track created resources (IDs, files, etc.) in a shared array within the `describe` block
92+
93+
**Example pattern:**
94+
95+
```typescript
96+
describe('My Feature', () => {
97+
const createdIDs: number[] = []
98+
99+
afterEach(async () => {
100+
for (const id of createdIDs) {
101+
await payload.delete({ collection: 'my-collection', id })
102+
}
103+
createdIDs.length = 0
104+
})
105+
106+
it('should create a record', async () => {
107+
const id = 123
108+
createdIDs.push(id)
109+
110+
await payload.create({ collection: 'my-collection', data: { id, title: 'Test' } })
111+
// assertions...
112+
})
113+
})
114+
```
115+
116+
**Additional test guidelines:**
117+
118+
- Use descriptive test names starting with "should" (e.g., "should create document with custom ID")
119+
- Add blank lines after variable declarations to improve readability
120+
- Collection and global slugs should be kept in a shared file and re-used i.e. on relationship fields `relationTo: collectionSlug`
121+
- One test should verify one behavior - keep tests focused
122+
- When adding a new collection for testing, add it to both `collections/` directory and the config file import statements
123+
124+
### How to run tests
125+
85126
- `pnpm run test` - Run all tests (integration + components + e2e)
86127
- `pnpm run test:int` - Integration tests (MongoDB, recommended)
87128
- `pnpm run test:int <dir>` - Specific test suite (e.g. `fields`)

packages/db-mongodb/src/models/buildCollectionSchema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const buildCollectionSchema = (
2525
},
2626
compoundIndexes: collection.sanitizedIndexes,
2727
configFields: collection.fields,
28+
flattenedFields: collection.flattenedFields,
2829
payload,
2930
})
3031

packages/db-mongodb/src/models/buildSchema.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type EmailField,
1212
type Field,
1313
type FieldAffectingData,
14+
type FlattenedField,
1415
type GroupField,
1516
type JSONField,
1617
type NonPresentationalField,
@@ -130,17 +131,26 @@ export const buildSchema = (args: {
130131
buildSchemaOptions: BuildSchemaOptions
131132
compoundIndexes?: SanitizedCompoundIndex[]
132133
configFields: Field[]
134+
flattenedFields?: FlattenedField[]
133135
parentIsLocalized?: boolean
134136
payload: Payload
135137
}): Schema => {
136-
const { buildSchemaOptions = {}, configFields, parentIsLocalized, payload } = args
138+
const {
139+
buildSchemaOptions = {},
140+
configFields,
141+
flattenedFields,
142+
parentIsLocalized,
143+
payload,
144+
} = args
137145
const { allowIDField, options } = buildSchemaOptions
138146
let fields = {}
139147

140148
let schemaFields = configFields
141149

142150
if (!allowIDField) {
143-
const idField = schemaFields.find((field) => fieldAffectsData(field) && field.name === 'id')
151+
// Use flattenedFields if available to find custom id field regardless of nesting
152+
const fieldsToSearch = flattenedFields || schemaFields
153+
const idField = fieldsToSearch.find((field) => fieldAffectsData(field) && field.name === 'id')
144154
if (idField) {
145155
fields = {
146156
_id:

packages/payload/src/fields/hooks/afterRead/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export async function afterRead<T extends JsonObject>(args: AfterReadArgs<T>): P
8282
doc: incomingDoc,
8383
draft,
8484
fallbackLocale,
85+
fieldDepth: 0,
8586
fieldPromises,
8687
fields: (collection?.fields || global?.fields)!,
8788
findMany: findMany!,

packages/payload/src/fields/hooks/afterRead/promise.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ type Args = {
3535
draft: boolean
3636
fallbackLocale: TypedFallbackLocale
3737
field: Field | TabAsField
38+
/**
39+
* The depth of the current field being processed.
40+
* Fields without names (i.e. rows, collapsibles, unnamed groups)
41+
* simply pass this value through
42+
*
43+
* @default 0
44+
*/
45+
fieldDepth: number
3846
fieldIndex: number
3947
/**
4048
* fieldPromises are used for things like field hooks. They should be awaited before awaiting populationPromises
@@ -81,6 +89,7 @@ export const promise = async ({
8189
draft,
8290
fallbackLocale,
8391
field,
92+
fieldDepth,
8493
fieldIndex,
8594
fieldPromises,
8695
findMany,
@@ -117,11 +126,14 @@ export const promise = async ({
117126
const indexPathSegments = indexPath ? indexPath.split('-').filter(Boolean)?.map(Number) : []
118127
let removedFieldValue = false
119128

129+
const isTopLevelIDField = fieldAffectsDataResult && field.name === 'id' && fieldDepth === 0
130+
120131
if (
121132
fieldAffectsDataResult &&
122133
field.hidden &&
123134
typeof siblingDoc[field.name!] !== 'undefined' &&
124-
!showHiddenFields
135+
!showHiddenFields &&
136+
!isTopLevelIDField
125137
) {
126138
removedFieldValue = true
127139
delete siblingDoc[field.name!]
@@ -438,6 +450,7 @@ export const promise = async ({
438450
doc,
439451
draft,
440452
fallbackLocale,
453+
fieldDepth: fieldDepth + 1,
441454
fieldPromises,
442455
fields: field.fields,
443456
findMany,
@@ -473,6 +486,7 @@ export const promise = async ({
473486
doc,
474487
draft,
475488
fallbackLocale,
489+
fieldDepth: fieldDepth + 1,
476490
fieldPromises,
477491
fields: field.fields,
478492
findMany,
@@ -534,6 +548,7 @@ export const promise = async ({
534548
doc,
535549
draft,
536550
fallbackLocale,
551+
fieldDepth: fieldDepth + 1,
537552
fieldPromises,
538553
fields: block.fields,
539554
findMany,
@@ -579,6 +594,7 @@ export const promise = async ({
579594
doc,
580595
draft,
581596
fallbackLocale,
597+
fieldDepth: fieldDepth + 1,
582598
fieldPromises,
583599
fields: block.fields,
584600
findMany,
@@ -622,6 +638,7 @@ export const promise = async ({
622638
doc,
623639
draft,
624640
fallbackLocale,
641+
fieldDepth,
625642
fieldPromises,
626643
fields: field.fields,
627644
findMany,
@@ -665,6 +682,7 @@ export const promise = async ({
665682
doc,
666683
draft,
667684
fallbackLocale,
685+
fieldDepth: fieldDepth + 1,
668686
fieldPromises,
669687
fields: field.fields,
670688
findMany,
@@ -697,6 +715,7 @@ export const promise = async ({
697715
doc,
698716
draft,
699717
fallbackLocale,
718+
fieldDepth: fieldDepth + 1,
700719
fieldPromises,
701720
fields: field.fields,
702721
findMany,
@@ -729,6 +748,7 @@ export const promise = async ({
729748
doc,
730749
draft,
731750
fallbackLocale,
751+
fieldDepth,
732752
fieldPromises,
733753
fields: field.fields,
734754
findMany,
@@ -871,6 +891,7 @@ export const promise = async ({
871891
doc,
872892
draft,
873893
fallbackLocale,
894+
fieldDepth: fieldDepth + 1,
874895
fieldPromises,
875896
fields: field.fields,
876897
findMany,
@@ -903,6 +924,7 @@ export const promise = async ({
903924
doc,
904925
draft,
905926
fallbackLocale,
927+
fieldDepth: fieldDepth + 1,
906928
fieldPromises,
907929
fields: field.fields,
908930
findMany,
@@ -935,6 +957,7 @@ export const promise = async ({
935957
doc,
936958
draft,
937959
fallbackLocale,
960+
fieldDepth,
938961
fieldPromises,
939962
fields: field.fields,
940963
findMany,
@@ -971,6 +994,7 @@ export const promise = async ({
971994
doc,
972995
draft,
973996
fallbackLocale,
997+
fieldDepth,
974998
fieldPromises,
975999
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
9761000
findMany,

packages/payload/src/fields/hooks/afterRead/traverseFields.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ type Args = {
2424
doc: JsonObject
2525
draft: boolean
2626
fallbackLocale: TypedFallbackLocale
27+
/**
28+
* The depth of the current field being processed.
29+
* Fields without names (i.e. rows, collapsibles, unnamed groups)
30+
* simply pass this value through
31+
*
32+
* @default 0
33+
*/
34+
fieldDepth?: number
2735
/**
2836
* fieldPromises are used for things like field hooks. They should be awaited before awaiting populationPromises
2937
*/
@@ -61,6 +69,7 @@ export const traverseFields = ({
6169
doc,
6270
draft,
6371
fallbackLocale,
72+
fieldDepth = 0,
6473
fieldPromises,
6574
fields,
6675
findMany,
@@ -94,6 +103,7 @@ export const traverseFields = ({
94103
draft,
95104
fallbackLocale,
96105
field,
106+
fieldDepth,
97107
fieldIndex,
98108
fieldPromises,
99109
findMany,

test/fields/baseConfig.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import ConditionalLogic from './collections/ConditionalLogic/index.js'
1515
import { CustomRowID } from './collections/CustomID/CustomRowID.js'
1616
import { CustomTabID } from './collections/CustomID/CustomTabID.js'
1717
import { CustomID } from './collections/CustomID/index.js'
18+
import { CustomIDNested } from './collections/CustomIDNested/index.js'
1819
import DateFields from './collections/Date/index.js'
1920
import EmailFields from './collections/Email/index.js'
2021
import GroupFields from './collections/Group/index.js'
@@ -65,6 +66,7 @@ export const collections: CollectionConfig[] = [
6566
CollapsibleFields,
6667
ConditionalLogic,
6768
CustomID,
69+
CustomIDNested,
6870
CustomTabID,
6971
CustomRowID,
7072
DateFields,
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import { customIDNestedSlug } from '../../slugs.js'
4+
5+
export const CustomIDNested: CollectionConfig = {
6+
slug: customIDNestedSlug,
7+
admin: {
8+
useAsTitle: 'title',
9+
},
10+
fields: [
11+
{
12+
type: 'tabs',
13+
tabs: [
14+
{
15+
fields: [
16+
{
17+
name: 'id',
18+
type: 'number',
19+
admin: {
20+
description: 'Custom numeric ID nested in an unnamed tab',
21+
},
22+
defaultValue: () => Math.floor(Math.random() * 1000000),
23+
},
24+
{
25+
name: 'title',
26+
type: 'text',
27+
required: true,
28+
},
29+
],
30+
label: 'Main',
31+
},
32+
],
33+
},
34+
{
35+
name: 'description',
36+
type: 'text',
37+
},
38+
],
39+
}

0 commit comments

Comments
 (0)