Skip to content

Commit 3436fb1

Browse files
authored
feat: allow to count related docs for join fields (#11395)
### What? For the join field query adds ability to specify `count: true`, example: ```ts const result = await payload.find({ joins: { 'group.relatedPosts': { sort: '-title', count: true, }, }, collection: "categories", }) result.group?.relatedPosts?.totalDocs // available ``` ### Why? Can be useful to implement full pagination / show total related documents count in the UI. ### How? Implements the logic in database adapters. In MongoDB it's additional `$lookup` that has `$count` in the pipeline. In SQL, it's additional subquery with `COUNT(*)`. Preserves the current behavior by default, since counting introduces overhead. Additionally, fixes a typescript generation error for join fields. Before, `docs` and `hasNextPage` were marked as nullable, which is not true, these fields cannot be `null`. Additionally, fixes threading of `joinQuery` in `transform/read/traverseFields` for group / tab fields recursive calls.
1 parent bcc6857 commit 3436fb1

File tree

9 files changed

+345
-139
lines changed

9 files changed

+345
-139
lines changed

docs/fields/join.mdx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ object with:
158158

159159
- `docs` an array of related documents or only IDs if the depth is reached
160160
- `hasNextPage` a boolean indicating if there are additional documents
161+
- `totalDocs` a total number of documents, exists only if `count: true` is passed to the join query
161162

162163
```json
163164
{
@@ -171,7 +172,8 @@ object with:
171172
}
172173
// { ... }
173174
],
174-
"hasNextPage": false
175+
"hasNextPage": false,
176+
"totalDocs": 10, // if count: true is passed
175177
}
176178
// other fields...
177179
}
@@ -184,6 +186,7 @@ object with:
184186

185187
- `docs` an array of `relationTo` - the collection slug of the document and `value` - the document itself or the ID if the depth is reached
186188
- `hasNextPage` a boolean indicating if there are additional documents
189+
- `totalDocs` a total number of documents, exists only if `count: true` is passed to the join query
187190

188191
```json
189192
{
@@ -200,7 +203,8 @@ object with:
200203
}
201204
// { ... }
202205
],
203-
"hasNextPage": false
206+
"hasNextPage": false,
207+
"totalDocs": 10, // if count: true is passed
204208
}
205209
// other fields...
206210
}
@@ -215,10 +219,11 @@ returning. This is useful for performance reasons when you don't need the relate
215219
The following query options are supported:
216220

217221
| Property | Description |
218-
|-------------|-----------------------------------------------------------------------------------------------------|
222+
| ----------- | --------------------------------------------------------------------------------------------------- |
219223
| **`limit`** | The maximum related documents to be returned, default is 10. |
220224
| **`where`** | An optional `Where` query to filter joined documents. Will be merged with the field `where` object. |
221225
| **`sort`** | A string used to order related results |
226+
| **`count`** | Whether include the count of related documents or not. Not included by default |
222227

223228
These can be applied to the local API, GraphQL, and REST API.
224229

packages/db-mongodb/src/utilities/buildJoinAggregation.ts

Lines changed: 95 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export const buildJoinAggregation = async ({
7878
}
7979

8080
const {
81+
count = false,
8182
limit: limitJoin = join.field.defaultLimit ?? 10,
8283
page,
8384
sort: sortJoin = join.field.defaultSort || collectionConfig.defaultSort,
@@ -121,6 +122,28 @@ export const buildJoinAggregation = async ({
121122
const alias = `${as}.docs.${collectionSlug}`
122123
aliases.push(alias)
123124

125+
const basePipeline = [
126+
{
127+
$addFields: {
128+
relationTo: {
129+
$literal: collectionSlug,
130+
},
131+
},
132+
},
133+
{
134+
$match: {
135+
$and: [
136+
{
137+
$expr: {
138+
$eq: [`$${join.field.on}`, '$$root_id_'],
139+
},
140+
},
141+
$match,
142+
],
143+
},
144+
},
145+
]
146+
124147
aggregate.push({
125148
$lookup: {
126149
as: alias,
@@ -129,25 +152,7 @@ export const buildJoinAggregation = async ({
129152
root_id_: '$_id',
130153
},
131154
pipeline: [
132-
{
133-
$addFields: {
134-
relationTo: {
135-
$literal: collectionSlug,
136-
},
137-
},
138-
},
139-
{
140-
$match: {
141-
$and: [
142-
{
143-
$expr: {
144-
$eq: [`$${join.field.on}`, '$$root_id_'],
145-
},
146-
},
147-
$match,
148-
],
149-
},
150-
},
155+
...basePipeline,
151156
{
152157
$sort: {
153158
[sortProperty]: sortDirection,
@@ -169,6 +174,24 @@ export const buildJoinAggregation = async ({
169174
],
170175
},
171176
})
177+
178+
if (count) {
179+
aggregate.push({
180+
$lookup: {
181+
as: `${as}.totalDocs.${alias}`,
182+
from: adapter.collections[collectionSlug].collection.name,
183+
let: {
184+
root_id_: '$_id',
185+
},
186+
pipeline: [
187+
...basePipeline,
188+
{
189+
$count: 'result',
190+
},
191+
],
192+
},
193+
})
194+
}
172195
}
173196

174197
aggregate.push({
@@ -179,6 +202,23 @@ export const buildJoinAggregation = async ({
179202
},
180203
})
181204

205+
if (count) {
206+
aggregate.push({
207+
$addFields: {
208+
[`${as}.totalDocs`]: {
209+
$add: aliases.map((alias) => ({
210+
$ifNull: [
211+
{
212+
$first: `$${as}.totalDocs.${alias}.result`,
213+
},
214+
0,
215+
],
216+
})),
217+
},
218+
},
219+
})
220+
}
221+
182222
aggregate.push({
183223
$set: {
184224
[`${as}.docs`]: {
@@ -222,6 +262,7 @@ export const buildJoinAggregation = async ({
222262
}
223263

224264
const {
265+
count,
225266
limit: limitJoin = join.field.defaultLimit ?? 10,
226267
page,
227268
sort: sortJoin = join.field.defaultSort || collectionConfig.defaultSort,
@@ -274,6 +315,31 @@ export const buildJoinAggregation = async ({
274315
polymorphicSuffix = '.value'
275316
}
276317

318+
const addTotalDocsAggregation = (as: string, foreignField: string) =>
319+
aggregate.push(
320+
{
321+
$lookup: {
322+
as: `${as}.totalDocs`,
323+
foreignField,
324+
from: adapter.collections[slug].collection.name,
325+
localField: versions ? 'parent' : '_id',
326+
pipeline: [
327+
{
328+
$match,
329+
},
330+
{
331+
$count: 'result',
332+
},
333+
],
334+
},
335+
},
336+
{
337+
$addFields: {
338+
[`${as}.totalDocs`]: { $ifNull: [{ $first: `$${as}.totalDocs.result` }, 0] },
339+
},
340+
},
341+
)
342+
277343
if (adapter.payload.config.localization && locale === 'all') {
278344
adapter.payload.config.localization.localeCodes.forEach((code) => {
279345
const as = `${versions ? `version.${join.joinPath}` : join.joinPath}${code}`
@@ -304,6 +370,7 @@ export const buildJoinAggregation = async ({
304370
},
305371
},
306372
)
373+
307374
if (limitJoin > 0) {
308375
aggregate.push({
309376
$addFields: {
@@ -313,6 +380,10 @@ export const buildJoinAggregation = async ({
313380
},
314381
})
315382
}
383+
384+
if (count) {
385+
addTotalDocsAggregation(as, `${join.field.on}${code}${polymorphicSuffix}`)
386+
}
316387
})
317388
} else {
318389
const localeSuffix =
@@ -359,6 +430,11 @@ export const buildJoinAggregation = async ({
359430
},
360431
},
361432
)
433+
434+
if (count) {
435+
addTotalDocsAggregation(as, foreignField)
436+
}
437+
362438
if (limitJoin > 0) {
363439
aggregate.push({
364440
$addFields: {

packages/drizzle/src/find/traverseFields.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
22
import type { SQLiteSelectBase } from 'drizzle-orm/sqlite-core'
33
import type { FlattenedField, JoinQuery, SelectMode, SelectType, Where } from 'payload'
44

5-
import { and, asc, desc, eq, or, sql } from 'drizzle-orm'
5+
import { and, asc, count, desc, eq, or, sql } from 'drizzle-orm'
66
import { fieldIsVirtual, fieldShouldBeLocalized } from 'payload/shared'
77
import toSnakeCase from 'to-snake-case'
88

@@ -386,6 +386,7 @@ export const traverseFields = ({
386386
}
387387

388388
const {
389+
count: shouldCount = false,
389390
limit: limitArg = field.defaultLimit ?? 10,
390391
page,
391392
sort = field.defaultSort,
@@ -480,6 +481,13 @@ export const traverseFields = ({
480481
sqlWhere = and(sqlWhere, buildSQLWhere(where, subQueryAlias))
481482
}
482483

484+
if (shouldCount) {
485+
currentArgs.extras[`${columnName}_count`] = sql`${db
486+
.select({ count: count() })
487+
.from(sql`${currentQuery.as(subQueryAlias)}`)
488+
.where(sqlWhere)}`.as(`${columnName}_count`)
489+
}
490+
483491
currentQuery = currentQuery.orderBy(sortOrder(sql`"sortPath"`)) as SQLSelect
484492

485493
if (page && limit !== 0) {
@@ -611,6 +619,20 @@ export const traverseFields = ({
611619
.orderBy(() => orderBy.map(({ column, order }) => order(column))),
612620
}).as(subQueryAlias)
613621

622+
if (shouldCount) {
623+
currentArgs.extras[`${columnName}_count`] = sql`${db
624+
.select({
625+
count: count(),
626+
})
627+
.from(
628+
sql`${db
629+
.select(selectFields as any)
630+
.from(newAliasTable)
631+
.where(subQueryWhere)
632+
.as(`${subQueryAlias}_count_subquery`)}`,
633+
)}`.as(`${subQueryAlias}_count`)
634+
}
635+
614636
currentArgs.extras[columnName] = sql`${db
615637
.select({
616638
result: jsonAggBuildObject(adapter, {

packages/drizzle/src/transform/read/traverseFields.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { FlattenedBlock, FlattenedField, JoinQuery, SanitizedConfig } from 'payload'
22

33
import { fieldIsVirtual, fieldShouldBeLocalized } from 'payload/shared'
4+
import toSnakeCase from 'to-snake-case'
45

56
import type { DrizzleAdapter } from '../../types.js'
67
import type { BlocksMap } from '../../utilities/createBlocksMap.js'
@@ -398,7 +399,7 @@ export const traverseFields = <T extends Record<string, unknown>>({
398399
}
399400

400401
if (field.type === 'join') {
401-
const { limit = field.defaultLimit ?? 10 } =
402+
const { count, limit = field.defaultLimit ?? 10 } =
402403
joinQuery?.[`${fieldPrefix.replaceAll('_', '.')}${field.name}`] || {}
403404

404405
// raw hasMany results from SQLite
@@ -407,8 +408,8 @@ export const traverseFields = <T extends Record<string, unknown>>({
407408
}
408409

409410
let fieldResult:
410-
| { docs: unknown[]; hasNextPage: boolean }
411-
| Record<string, { docs: unknown[]; hasNextPage: boolean }>
411+
| { docs: unknown[]; hasNextPage: boolean; totalDocs?: number }
412+
| Record<string, { docs: unknown[]; hasNextPage: boolean; totalDocs?: number }>
412413
if (Array.isArray(fieldData)) {
413414
if (isLocalized && adapter.payload.config.localization) {
414415
fieldResult = fieldData.reduce(
@@ -449,6 +450,17 @@ export const traverseFields = <T extends Record<string, unknown>>({
449450
}
450451
}
451452

453+
if (count) {
454+
const countPath = `${fieldName}_count`
455+
if (typeof table[countPath] !== 'undefined') {
456+
let value = Number(table[countPath])
457+
if (Number.isNaN(value)) {
458+
value = 0
459+
}
460+
fieldResult.totalDocs = value
461+
}
462+
}
463+
452464
result[field.name] = fieldResult
453465
return result
454466
}
@@ -607,6 +619,7 @@ export const traverseFields = <T extends Record<string, unknown>>({
607619
deletions,
608620
fieldPrefix: groupFieldPrefix,
609621
fields: field.flattenedFields,
622+
joinQuery,
610623
numbers,
611624
parentIsLocalized: parentIsLocalized || field.localized,
612625
path: `${sanitizedPath}${field.name}`,

packages/payload/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export type JoinQuery<TSlug extends CollectionSlug = string> =
145145
| Partial<{
146146
[K in keyof TypedCollectionJoins[TSlug]]:
147147
| {
148+
count?: boolean
148149
limit?: number
149150
page?: number
150151
sort?: string

packages/payload/src/utilities/configToJSONSchema.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -434,14 +434,15 @@ export function fieldsToJSONSchema(
434434

435435
fieldSchema = {
436436
...baseFieldSchema,
437-
type: withNullableJSONSchemaType('object', false),
437+
type: 'object',
438438
additionalProperties: false,
439439
properties: {
440440
docs: {
441-
type: withNullableJSONSchemaType('array', false),
441+
type: 'array',
442442
items,
443443
},
444-
hasNextPage: { type: withNullableJSONSchemaType('boolean', false) },
444+
hasNextPage: { type: 'boolean' },
445+
totalDocs: { type: 'number' },
445446
},
446447
}
447448
break

packages/payload/src/utilities/sanitizeJoinParams.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const sanitizeJoinParams = (
2727
joinQuery[schemaPath] = false
2828
} else {
2929
joinQuery[schemaPath] = {
30+
count: joins[schemaPath].count === 'true',
3031
limit: isNumber(joins[schemaPath]?.limit) ? Number(joins[schemaPath].limit) : undefined,
3132
page: isNumber(joins[schemaPath]?.page) ? Number(joins[schemaPath].page) : undefined,
3233
sort: joins[schemaPath]?.sort ? joins[schemaPath].sort : undefined,

0 commit comments

Comments
 (0)