Skip to content

Commit 0f67215

Browse files
authored
fix(drizzle): pagination applied incorrectly when sorting by a field inside an array field (#15908)
Fixes #14124 This PR changes `select distinct` usage in `findMany` to `group by` with `min` and `max` Specifically for cases when the sort query includes columns inside arrays to avoid duplicative results and so breaking the pagination.
1 parent 3fa834a commit 0f67215

File tree

5 files changed

+135
-16
lines changed

5 files changed

+135
-16
lines changed

packages/drizzle/src/find/findMany.ts

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { FindArgs, FlattenedField, TypeWithID } from 'payload'
22

3-
import { inArray } from 'drizzle-orm'
3+
import { asc, desc, inArray, max, min } from 'drizzle-orm'
44

55
import type { DrizzleAdapter } from '../types.js'
66

@@ -96,20 +96,59 @@ export const findMany = async function find({
9696

9797
const db = await getTransaction(adapter, req)
9898

99-
const selectDistinctResult = await selectDistinct({
100-
adapter,
101-
db,
102-
joins,
103-
query: ({ query }) => {
104-
if (orderBy) {
105-
query = query.orderBy(() => orderBy.map(({ column, order }) => order(column)))
106-
}
107-
return query.offset(offset).limit(limit)
108-
},
109-
selectFields,
110-
tableName,
111-
where,
112-
})
99+
const oneToManyJoinedTableNames = new Set(
100+
joins.filter((j) => j.isOneToMany).map((j) => getNameFromDrizzleTable(j.table)),
101+
)
102+
103+
const hasSortOnOneToMany =
104+
oneToManyJoinedTableNames.size > 0 &&
105+
orderBy?.some(({ column }) =>
106+
oneToManyJoinedTableNames.has(getNameFromDrizzleTable(column.table)),
107+
)
108+
109+
let selectDistinctResult: { id: number | string }[] | undefined
110+
111+
// avoid duplicate results by using a group query instead of select distinct when there is a sort on a one-to-many joined table
112+
if (hasSortOnOneToMany) {
113+
const mainTable = adapter.tables[tableName]
114+
let groupQuery = (db as any).select({ id: mainTable.id }).from(mainTable).$dynamic()
115+
116+
if (where) {
117+
groupQuery = groupQuery.where(where)
118+
}
119+
120+
joins.forEach(({ type, condition, table }) => {
121+
groupQuery = groupQuery[type ?? 'leftJoin'](table, condition)
122+
})
123+
124+
groupQuery = groupQuery.groupBy(mainTable.id)
125+
126+
groupQuery = groupQuery.orderBy(() =>
127+
orderBy.map(({ column, order }) => {
128+
if (oneToManyJoinedTableNames.has(getNameFromDrizzleTable(column.table))) {
129+
return order === asc ? asc(min(column)) : desc(max(column))
130+
}
131+
return order(column)
132+
}),
133+
)
134+
135+
selectDistinctResult = await groupQuery.offset(offset).limit(limit)
136+
} else {
137+
selectDistinctResult = await selectDistinct({
138+
adapter,
139+
db,
140+
joins,
141+
query: ({ query }) => {
142+
if (orderBy) {
143+
query = query.orderBy(() => orderBy.map(({ column, order }) => order(column)))
144+
}
145+
return query.offset(offset).limit(limit)
146+
},
147+
selectFields,
148+
tableName,
149+
where,
150+
})
151+
}
113152

114153
if (selectDistinctResult) {
115154
if (selectDistinctResult.length === 0) {

packages/drizzle/src/queries/addJoinTable.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import { getNameFromDrizzleTable } from '../utilities/getNameFromDrizzleTable.js
99
export const addJoinTable = ({
1010
type,
1111
condition,
12+
isOneToMany,
1213
joins,
1314
queryPath,
1415
table,
1516
}: {
1617
condition: SQL
18+
isOneToMany?: boolean
1719
joins: BuildQueryJoinAliases
1820
queryPath?: string
1921
table: GenericTable | PgTableWithColumns<any>
@@ -22,6 +24,6 @@ export const addJoinTable = ({
2224
const name = getNameFromDrizzleTable(table)
2325

2426
if (!joins.some((eachJoin) => getNameFromDrizzleTable(eachJoin.table) === name)) {
25-
joins.push({ type, condition, queryPath, table })
27+
joins.push({ type, condition, isOneToMany, queryPath, table })
2628
}
2729
}

packages/drizzle/src/queries/buildQuery.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { parseParams } from './parseParams.js'
1010

1111
export type BuildQueryJoinAliases = {
1212
condition: SQL
13+
// with parent
14+
isOneToMany?: boolean
1315
queryPath?: string
1416
table: GenericTable | PgTableWithColumns<any>
1517
type?: 'innerJoin' | 'leftJoin' | 'rightJoin'

packages/drizzle/src/queries/getTableColumnFromPath.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,14 @@ export const getTableColumnFromPath = ({
158158
}
159159
addJoinTable({
160160
condition: and(...conditions),
161+
isOneToMany: true,
161162
joins,
162163
table: adapter.tables[newTableName],
163164
})
164165
} else {
165166
addJoinTable({
166167
condition: eq(arrayParentTable.id, adapter.tables[newTableName]._parentID),
168+
isOneToMany: true,
167169
joins,
168170
table: adapter.tables[newTableName],
169171
})

test/database/int.spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1518,6 +1518,80 @@ describe('database', () => {
15181518
})
15191519
})
15201520

1521+
it('should return the correct number of docs per page when sorting on an array sub-field', async () => {
1522+
const createdIds: string[] = []
1523+
const TOTAL = 10
1524+
const ITEMS_PER_DOC = 3
1525+
const LIMIT = 5
1526+
1527+
const testPrefix = `SortArraySubField-${Date.now()}`
1528+
1529+
// Each post has ITEMS_PER_DOC array items with distinct text values so the JOIN
1530+
// produces TOTAL * ITEMS_PER_DOC rows — enough to expose the LIMIT-before-dedup bug.
1531+
for (let i = 0; i < TOTAL; i++) {
1532+
const doc = await payload.create({
1533+
collection: postsSlug,
1534+
data: {
1535+
arrayWithIDs: Array.from({ length: ITEMS_PER_DOC }, (_, j) => ({
1536+
text: `${testPrefix}-doc${String(i).padStart(2, '0')}-item${j}`,
1537+
})),
1538+
title: `${testPrefix}-${i}`,
1539+
},
1540+
})
1541+
1542+
createdIds.push(String(doc.id))
1543+
}
1544+
1545+
const page1 = await payload.find({
1546+
collection: postsSlug,
1547+
limit: LIMIT,
1548+
page: 1,
1549+
sort: 'arrayWithIDs.text',
1550+
where: { title: { contains: testPrefix } },
1551+
})
1552+
1553+
const page2 = await payload.find({
1554+
collection: postsSlug,
1555+
limit: LIMIT,
1556+
page: 2,
1557+
sort: 'arrayWithIDs.text',
1558+
where: { title: { contains: testPrefix } },
1559+
})
1560+
1561+
expect(page1.totalDocs).toBe(TOTAL)
1562+
expect(page1.totalPages).toBe(TOTAL / LIMIT)
1563+
expect(page1.docs).toHaveLength(LIMIT)
1564+
expect(page2.docs).toHaveLength(LIMIT)
1565+
1566+
// No document should appear in both pages
1567+
const page1Ids = new Set(page1.docs.map((d) => d.id))
1568+
const duplicates = page2.docs.filter((d) => page1Ids.has(d.id))
1569+
expect(duplicates).toHaveLength(0)
1570+
1571+
// Verify sort order: each doc's minimum array text is `${testPrefix}-docXX-item0`,
1572+
// so ascending sort should place doc-00 first and doc-09 last.
1573+
// Collect all docs across pages and verify they are in non-decreasing text order.
1574+
const allDocs = [...page1.docs, ...page2.docs]
1575+
const minTexts = allDocs.map((d) => {
1576+
const texts = (d.arrayWithIDs ?? []).map((item) => item.text).filter(Boolean)
1577+
return texts.length > 0 ? texts.sort()[0] : ''
1578+
})
1579+
1580+
for (let i = 1; i < minTexts.length; i++) {
1581+
expect(minTexts[i - 1]! <= minTexts[i]!).toBe(true)
1582+
}
1583+
1584+
// Page 1 docs should all have smaller sort keys than page 2 docs
1585+
const page1MaxText = minTexts.slice(0, LIMIT).at(-1)!
1586+
const page2MinText = minTexts.slice(LIMIT)[0]!
1587+
expect(page1MaxText <= page2MinText).toBe(true)
1588+
1589+
await payload.delete({
1590+
collection: postsSlug,
1591+
where: { id: { in: createdIds } },
1592+
})
1593+
})
1594+
15211595
describe('Compound Indexes', () => {
15221596
beforeEach(async () => {
15231597
await payload.delete({ collection: 'compound-indexes', where: {} })

0 commit comments

Comments
 (0)