Skip to content

Commit fe36dde

Browse files
authored
fix(drizzle): correctly apply query limit on polymorphic joins (#15652)
### What? Fixes an issue where querying folders with nested subfolders returned incorrect results when using SQL databases. ### Why? When resolving nested folder relationships, the generated polymorphic `UNION` query applied `LIMIT` at the global level. This caused the limit to be enforced across the entire result set before rows were correlated back to their respective parent folders. As a result, some parents received incomplete or incorrect child data. ### How? The polymorphic join query has been restructured so that parent correlation occurs **before** applying the `LIMIT`. This ensures: - The `LIMIT` is enforced **per parent** - Nested subfolder queries return accurate data #### Testing - Added tests under `folders > int`
1 parent 3fb10e1 commit fe36dde

2 files changed

Lines changed: 235 additions & 14 deletions

File tree

packages/drizzle/src/find/traverseFields.ts

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -599,26 +599,38 @@ export const traverseFields = ({
599599

600600
currentQuery = currentQuery.orderBy(sortOrder(sql`"sortPath"`)) as SQLSelect
601601

602+
const sortedUnionAlias = `${columnName}_sorted`
603+
604+
let limitOffsetSQL = sql.empty()
605+
if (limit) {
606+
limitOffsetSQL = sql` LIMIT ${limit}`
607+
}
602608
if (page && limit !== 0) {
603609
const offset = (page - 1) * limit
604610
if (offset > 0) {
605-
currentQuery = currentQuery.offset(offset) as SQLSelect
611+
limitOffsetSQL = sql`${limitOffsetSQL} OFFSET ${offset}`
606612
}
607613
}
608614

609-
if (limit) {
610-
currentQuery = currentQuery.limit(limit) as SQLSelect
611-
}
612-
613-
currentArgs.extras[columnName] = sql`${db
614-
.select({
615-
id: jsonAggBuildObject(adapter, {
616-
id: sql.raw(`"${subQueryAlias}"."id"`),
617-
relationTo: sql.raw(`"${subQueryAlias}"."relationTo"`),
618-
}),
619-
})
620-
.from(sql`${currentQuery.as(subQueryAlias)}`)
621-
.where(sqlWhere)}`.as(columnName)
615+
// Correlate to parent row + apply any join where filters
616+
let innerWhere = sql.raw(`"${sortedUnionAlias}"."${onPath}" = "${currentTableName}"."id"`)
617+
if (where && Object.keys(where).length > 0) {
618+
const additionalWhere = buildSQLWhere(where, sortedUnionAlias)
619+
innerWhere = sql`${innerWhere} AND ${additionalWhere}`
620+
}
621+
622+
// IMPORTANT: For polymorphic joins, LIMIT must be applied AFTER correlating to the parent row.
623+
// Otherwise, the limit applies globally across ALL parents, not per-parent.
624+
currentArgs.extras[columnName] = sql`(
625+
SELECT ${jsonAggBuildObject(adapter, {
626+
id: sql.raw(`"${subQueryAlias}"."id"`),
627+
relationTo: sql.raw(`"${subQueryAlias}"."relationTo"`),
628+
})}
629+
FROM (
630+
SELECT * FROM ${sql`${currentQuery.as(sortedUnionAlias)}`}
631+
WHERE ${innerWhere}${limitOffsetSQL}
632+
) AS ${sql.raw(`"${subQueryAlias}"`)}
633+
)`.as(columnName)
622634
} else {
623635
const useDrafts =
624636
(versions || draftsEnabled) &&

test/folders/int.spec.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,215 @@ describe('folders', () => {
4141
})
4242
})
4343

44+
describe('nested folder population with depth', () => {
45+
it('should populate all nested subfolders for multiple root folders when queried with depth', async () => {
46+
const ROOT_FOLDER_COUNT = 8
47+
const NESTED_FOLDER_COUNT = 10
48+
49+
// Create root folders
50+
const rootFolders: { id: number | string; name: string }[] = []
51+
for (let i = 1; i <= ROOT_FOLDER_COUNT; i++) {
52+
const rootFolder = await payload.create({
53+
collection: 'payload-folders',
54+
data: {
55+
name: `Root Folder ${i}`,
56+
folderType: ['posts'],
57+
},
58+
})
59+
rootFolders.push({ id: rootFolder.id, name: rootFolder.name })
60+
61+
// Create nested subfolders for each root folder
62+
for (let j = 1; j <= NESTED_FOLDER_COUNT; j++) {
63+
await payload.create({
64+
collection: 'payload-folders',
65+
data: {
66+
name: `Root ${i} - Subfolder ${j}`,
67+
folder: rootFolder.id,
68+
folderType: ['posts'],
69+
},
70+
})
71+
}
72+
}
73+
74+
// Query root folders (folder: { exists: false }) with depth
75+
const result = await payload.find({
76+
collection: 'payload-folders',
77+
depth: 3,
78+
limit: 10000,
79+
where: {
80+
and: [{ folderType: { contains: 'posts' } }, { folder: { exists: false } }],
81+
},
82+
})
83+
84+
// Should return all 8 root folders
85+
expect(result.docs).toHaveLength(ROOT_FOLDER_COUNT)
86+
87+
// Each root folder should have all 10 nested subfolders populated
88+
for (const rootFolder of result.docs) {
89+
const nestedFolders = rootFolder.documentsAndFolders?.docs?.filter(
90+
(doc: any) => doc.relationTo === 'payload-folders',
91+
)
92+
93+
expect(nestedFolders).toHaveLength(NESTED_FOLDER_COUNT)
94+
}
95+
96+
// Verify total nested folder count
97+
const totalNestedFolders = result.docs.reduce((acc, folder) => {
98+
const nested =
99+
folder.documentsAndFolders?.docs?.filter(
100+
(doc: any) => doc.relationTo === 'payload-folders',
101+
) || []
102+
return acc + nested.length
103+
}, 0)
104+
105+
expect(totalNestedFolders).toBe(ROOT_FOLDER_COUNT * NESTED_FOLDER_COUNT)
106+
})
107+
108+
it('should populate nested subfolders consistently regardless of query order', async () => {
109+
// Create 4 root folders with 5 subfolders each
110+
const rootFolders: { id: number | string }[] = []
111+
for (let i = 1; i <= 4; i++) {
112+
const rootFolder = await payload.create({
113+
collection: 'payload-folders',
114+
data: {
115+
name: `Root ${i}`,
116+
folderType: ['posts'],
117+
},
118+
})
119+
rootFolders.push({ id: rootFolder.id })
120+
121+
for (let j = 1; j <= 5; j++) {
122+
await payload.create({
123+
collection: 'payload-folders',
124+
data: {
125+
name: `Root ${i} - Sub ${j}`,
126+
folder: rootFolder.id,
127+
folderType: ['posts'],
128+
},
129+
})
130+
}
131+
}
132+
133+
// Query with different sort orders and verify consistent results
134+
const ascResult = await payload.find({
135+
collection: 'payload-folders',
136+
depth: 2,
137+
limit: 100,
138+
sort: 'name',
139+
where: {
140+
folder: { exists: false },
141+
folderType: { contains: 'posts' },
142+
},
143+
})
144+
145+
const descResult = await payload.find({
146+
collection: 'payload-folders',
147+
depth: 2,
148+
limit: 100,
149+
sort: '-name',
150+
where: {
151+
folder: { exists: false },
152+
folderType: { contains: 'posts' },
153+
},
154+
})
155+
156+
// Both queries should return 4 root folders
157+
expect(ascResult.docs).toHaveLength(4)
158+
expect(descResult.docs).toHaveLength(4)
159+
160+
// Both should have same total nested folders
161+
const ascNestedCount = ascResult.docs.reduce(
162+
(acc, f) =>
163+
acc +
164+
(f.documentsAndFolders?.docs?.filter((d: any) => d.relationTo === 'payload-folders')
165+
?.length || 0),
166+
0,
167+
)
168+
const descNestedCount = descResult.docs.reduce(
169+
(acc, f) =>
170+
acc +
171+
(f.documentsAndFolders?.docs?.filter((d: any) => d.relationTo === 'payload-folders')
172+
?.length || 0),
173+
0,
174+
)
175+
176+
expect(ascNestedCount).toBe(20) // 4 roots * 5 subfolders
177+
expect(descNestedCount).toBe(20)
178+
})
179+
180+
it('should correctly paginate nested subfolders within polymorphic joins', async () => {
181+
// Create a folder with 12 subfolders to test pagination
182+
const parentFolder = await payload.create({
183+
collection: 'payload-folders',
184+
data: {
185+
name: 'Parent with many subfolders',
186+
folderType: ['posts'],
187+
},
188+
})
189+
190+
// Create 12 subfolders with zero-padded names for consistent sorting
191+
for (let i = 1; i <= 12; i++) {
192+
await payload.create({
193+
collection: 'payload-folders',
194+
data: {
195+
name: `Subfolder ${String(i).padStart(2, '0')}`,
196+
folder: parentFolder.id,
197+
folderType: ['posts'],
198+
},
199+
})
200+
}
201+
202+
// Query with limit of 5 on the join - should get first 5 subfolders
203+
const page1Result = await payload.findByID({
204+
id: parentFolder.id,
205+
collection: 'payload-folders',
206+
joins: {
207+
documentsAndFolders: {
208+
limit: 5,
209+
sort: 'name',
210+
},
211+
},
212+
})
213+
214+
expect(page1Result.documentsAndFolders?.docs?.length).toBeLessThanOrEqual(5)
215+
expect(page1Result.documentsAndFolders?.docs?.length).toBeGreaterThan(0)
216+
217+
// Query page 2 - should get next batch of subfolders
218+
const page2Result = await payload.findByID({
219+
id: parentFolder.id,
220+
collection: 'payload-folders',
221+
joins: {
222+
documentsAndFolders: {
223+
limit: 5,
224+
page: 2,
225+
sort: 'name',
226+
},
227+
},
228+
})
229+
230+
expect(page2Result.documentsAndFolders?.docs?.length).toBeGreaterThan(0)
231+
expect(page2Result.documentsAndFolders?.docs?.length).toBeLessThanOrEqual(5)
232+
233+
// Verify no overlap between pages by checking names
234+
const page1Names = page1Result.documentsAndFolders?.docs?.map(
235+
(d: any) => d.value?.name,
236+
) as string[]
237+
const page2Names = page2Result.documentsAndFolders?.docs?.map(
238+
(d: any) => d.value?.name,
239+
) as string[]
240+
241+
// Page 1 and page 2 should have no overlap
242+
const page1Set = new Set(page1Names)
243+
const hasOverlap = page2Names.some((name) => page1Set.has(name))
244+
expect(hasOverlap).toBe(false)
245+
246+
// Page 1 names should come before page 2 names alphabetically
247+
const lastPage1Name = page1Names[page1Names.length - 1]
248+
const firstPage2Name = page2Names[0]
249+
expect(lastPage1Name < firstPage2Name).toBe(true)
250+
})
251+
})
252+
44253
describe('folder > subfolder querying', () => {
45254
it('should populate subfolders for folder by ID', async () => {
46255
const parentFolder = await payload.create({

0 commit comments

Comments
 (0)