Skip to content

Commit d4d2bf4

Browse files
authored
perf(db-mongodb): faster join field aggregation by replacing mongoose-aggregate-paginate-v2 with a custom implementation (#10936)
Fixes #10165 (reply in thread) As described in the discussion, we have an incorrect order of aggregation pipeline when using aggregations with the join field. We must use `$sort`, `$skip`, `$limit` before the `$lookup` or otherwise mongodb scans all the docs, applies `$lookup` for them and only after applies `$limit`, `$skip`. Replaces `mongoose-aggregate-paginate-v2` with a custom `aggregatePaginate` because we need a custom solution here. This was considered in #9594 but it was reverted as for now. Fixes #11187
1 parent 8b5bc3d commit d4d2bf4

File tree

11 files changed

+156
-92
lines changed

11 files changed

+156
-92
lines changed

packages/db-mongodb/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,12 @@
4747
},
4848
"dependencies": {
4949
"mongoose": "8.9.5",
50-
"mongoose-aggregate-paginate-v2": "1.1.2",
5150
"mongoose-paginate-v2": "1.8.5",
5251
"prompts": "2.4.2",
5352
"uuid": "10.0.0"
5453
},
5554
"devDependencies": {
5655
"@payloadcms/eslint-config": "workspace:*",
57-
"@types/mongoose-aggregate-paginate-v2": "1.0.12",
5856
"mongodb": "6.12.0",
5957
"mongodb-memory-server": "^10",
6058
"payload": "workspace:*"

packages/db-mongodb/src/find.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { MongooseAdapter } from './index.js'
77

88
import { buildQuery } from './queries/buildQuery.js'
99
import { buildSortParam } from './queries/buildSortParam.js'
10+
import { aggregatePaginate } from './utilities/aggregatePaginate.js'
1011
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
1112
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
1213
import { getSession } from './utilities/getSession.js'
@@ -128,7 +129,20 @@ export const find: Find = async function find(
128129
})
129130
// build join aggregation
130131
if (aggregate) {
131-
result = await Model.aggregatePaginate(Model.aggregate(aggregate), paginationOptions)
132+
result = await aggregatePaginate({
133+
adapter: this,
134+
collation: paginationOptions.collation,
135+
joinAggregation: aggregate,
136+
limit: paginationOptions.limit,
137+
Model,
138+
page: paginationOptions.page,
139+
pagination: paginationOptions.pagination,
140+
projection: paginationOptions.projection,
141+
query,
142+
session: paginationOptions.options?.session,
143+
sort: paginationOptions.sort as object,
144+
useEstimatedCount: paginationOptions.useEstimatedCount,
145+
})
132146
} else {
133147
result = await Model.paginate(query, paginationOptions)
134148
}

packages/db-mongodb/src/findOne.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { FindOne } from 'payload'
44
import type { MongooseAdapter } from './index.js'
55

66
import { buildQuery } from './queries/buildQuery.js'
7+
import { aggregatePaginate } from './utilities/aggregatePaginate.js'
78
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
89
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
910
import { getSession } from './utilities/getSession.js'
@@ -40,15 +41,24 @@ export const findOne: FindOne = async function findOne(
4041
collection,
4142
collectionConfig,
4243
joins,
43-
limit: 1,
4444
locale,
4545
projection,
4646
query,
4747
})
4848

4949
let doc
5050
if (aggregate) {
51-
;[doc] = await Model.aggregate(aggregate, { session })
51+
const { docs } = await aggregatePaginate({
52+
adapter: this,
53+
joinAggregation: aggregate,
54+
limit: 1,
55+
Model,
56+
pagination: false,
57+
projection,
58+
query,
59+
session,
60+
})
61+
doc = docs[0]
5262
} else {
5363
;(options as Record<string, unknown>).projection = projection
5464
doc = await Model.findOne(query, {}, options)

packages/db-mongodb/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,10 @@ export interface Args {
9292

9393
/** Extra configuration options */
9494
connectOptions?: {
95-
/** Set false to disable $facet aggregation in non-supporting databases, Defaults to true */
95+
/**
96+
* Set false to disable $facet aggregation in non-supporting databases, Defaults to true
97+
* @deprecated Payload doesn't use `$facet` anymore anywhere.
98+
*/
9699
useFacet?: boolean
97100
} & ConnectOptions
98101
/** Set to true to disable hinting to MongoDB to use 'id' as index. This is currently done when counting documents for pagination. Disabling this optimization might fix some problems with AWS DocumentDB. Defaults to false */

packages/db-mongodb/src/init.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { PaginateOptions } from 'mongoose'
22
import type { Init, SanitizedCollectionConfig } from 'payload'
33

44
import mongoose from 'mongoose'
5-
import mongooseAggregatePaginate from 'mongoose-aggregate-paginate-v2'
65
import paginate from 'mongoose-paginate-v2'
76
import { buildVersionCollectionFields, buildVersionGlobalFields } from 'payload'
87

@@ -48,25 +47,21 @@ export const init: Init = function init(this: MongooseAdapter) {
4847
}),
4948
)
5049

51-
if (Object.keys(collection.joins).length > 0) {
52-
versionSchema.plugin(mongooseAggregatePaginate)
53-
}
54-
5550
const versionCollectionName =
5651
this.autoPluralization === true && !collection.dbName ? undefined : versionModelName
5752

5853
this.versions[collection.slug] = mongoose.model(
5954
versionModelName,
6055
versionSchema,
6156
versionCollectionName,
62-
) as CollectionModel
57+
) as unknown as CollectionModel
6358
}
6459

6560
const modelName = getDBName({ config: collection })
6661
const collectionName =
6762
this.autoPluralization === true && !collection.dbName ? undefined : modelName
6863

69-
this.collections[collection.slug] = mongoose.model(
64+
this.collections[collection.slug] = mongoose.model<any>(
7065
modelName,
7166
schema,
7267
collectionName,
@@ -101,7 +96,7 @@ export const init: Init = function init(this: MongooseAdapter) {
10196
}),
10297
)
10398

104-
this.versions[global.slug] = mongoose.model(
99+
this.versions[global.slug] = mongoose.model<any>(
105100
versionModelName,
106101
versionSchema,
107102
versionModelName,

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

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { PaginateOptions, Schema } from 'mongoose'
22
import type { Payload, SanitizedCollectionConfig } from 'payload'
33

4-
import mongooseAggregatePaginate from 'mongoose-aggregate-paginate-v2'
54
import paginate from 'mongoose-paginate-v2'
65

76
import { getBuildQueryPlugin } from '../queries/getBuildQueryPlugin.js'
@@ -44,12 +43,5 @@ export const buildCollectionSchema = (
4443
.plugin<any, PaginateOptions>(paginate, { useEstimatedCount: true })
4544
.plugin(getBuildQueryPlugin({ collectionSlug: collection.slug }))
4645

47-
if (
48-
Object.keys(collection.joins).length > 0 ||
49-
Object.keys(collection.polymorphicJoins).length > 0
50-
) {
51-
schema.plugin(mongooseAggregatePaginate)
52-
}
53-
5446
return schema
5547
}

packages/db-mongodb/src/queryDrafts.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { MongooseAdapter } from './index.js'
77

88
import { buildQuery } from './queries/buildQuery.js'
99
import { buildSortParam } from './queries/buildSortParam.js'
10+
import { aggregatePaginate } from './utilities/aggregatePaginate.js'
1011
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
1112
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
1213
import { getSession } from './utilities/getSession.js'
@@ -116,10 +117,20 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(
116117

117118
// build join aggregation
118119
if (aggregate) {
119-
result = await VersionModel.aggregatePaginate(
120-
VersionModel.aggregate(aggregate),
121-
paginationOptions,
122-
)
120+
result = await aggregatePaginate({
121+
adapter: this,
122+
collation: paginationOptions.collation,
123+
joinAggregation: aggregate,
124+
limit: paginationOptions.limit,
125+
Model: VersionModel,
126+
page: paginationOptions.page,
127+
pagination: paginationOptions.pagination,
128+
projection: paginationOptions.projection,
129+
query: versionQuery,
130+
session: paginationOptions.options?.session,
131+
sort: paginationOptions.sort as object,
132+
useEstimatedCount: paginationOptions.useEstimatedCount,
133+
})
123134
} else {
124135
result = await VersionModel.paginate(versionQuery, paginationOptions)
125136
}

packages/db-mongodb/src/types.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
11
import type { ClientSession } from 'mongodb'
2-
import type {
3-
AggregatePaginateModel,
4-
IndexDefinition,
5-
IndexOptions,
6-
Model,
7-
PaginateModel,
8-
SchemaOptions,
9-
} from 'mongoose'
2+
import type { IndexDefinition, IndexOptions, Model, PaginateModel, SchemaOptions } from 'mongoose'
103
import type {
114
ArrayField,
125
BlocksField,
@@ -37,10 +30,7 @@ import type {
3730

3831
import type { BuildQueryArgs } from './queries/getBuildQueryPlugin.js'
3932

40-
export interface CollectionModel
41-
extends Model<any>,
42-
PaginateModel<any>,
43-
AggregatePaginateModel<any> {
33+
export interface CollectionModel extends Model<any>, PaginateModel<any> {
4434
/** buildQuery is used to transform payload's where operator into what can be used by mongoose (e.g. id => _id) */
4535
buildQuery: (args: BuildQueryArgs) => Promise<Record<string, unknown>> // TODO: Delete this
4636
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { CollationOptions } from 'mongodb'
2+
import type { ClientSession, Model, PipelineStage } from 'mongoose'
3+
import type { PaginatedDocs } from 'payload'
4+
5+
import type { MongooseAdapter } from '../index.js'
6+
7+
export const aggregatePaginate = async ({
8+
adapter,
9+
collation,
10+
joinAggregation,
11+
limit,
12+
Model,
13+
page,
14+
pagination,
15+
projection,
16+
query,
17+
session,
18+
sort,
19+
useEstimatedCount,
20+
}: {
21+
adapter: MongooseAdapter
22+
collation?: CollationOptions
23+
joinAggregation?: PipelineStage[]
24+
limit?: number
25+
Model: Model<any>
26+
page?: number
27+
pagination?: boolean
28+
projection?: Record<string, boolean>
29+
query: Record<string, unknown>
30+
session?: ClientSession
31+
sort?: object
32+
useEstimatedCount?: boolean
33+
}): Promise<PaginatedDocs<any>> => {
34+
const aggregation: PipelineStage[] = [{ $match: query }]
35+
36+
if (sort) {
37+
const $sort: Record<string, -1 | 1> = {}
38+
39+
Object.entries(sort).forEach(([key, value]) => {
40+
$sort[key] = value === 'desc' ? -1 : 1
41+
})
42+
43+
aggregation.push({ $sort })
44+
}
45+
46+
if (page) {
47+
aggregation.push({ $skip: (page - 1) * (limit ?? 0) })
48+
}
49+
50+
if (limit) {
51+
aggregation.push({ $limit: limit })
52+
}
53+
54+
if (joinAggregation) {
55+
for (const stage of joinAggregation) {
56+
aggregation.push(stage)
57+
}
58+
}
59+
60+
if (projection) {
61+
aggregation.push({ $project: projection })
62+
}
63+
64+
let countPromise: Promise<null | number> = Promise.resolve(null)
65+
66+
if (pagination !== false && limit) {
67+
if (useEstimatedCount) {
68+
countPromise = Model.estimatedDocumentCount(query)
69+
} else {
70+
const hint = adapter.disableIndexHints !== true ? { _id: 1 } : undefined
71+
countPromise = Model.countDocuments(query, { collation, hint, session })
72+
}
73+
}
74+
75+
const [docs, countResult] = await Promise.all([
76+
Model.aggregate(aggregation, { collation, session }),
77+
countPromise,
78+
])
79+
80+
const count = countResult === null ? docs.length : countResult
81+
82+
const totalPages =
83+
pagination !== false && typeof limit === 'number' && limit !== 0 ? Math.ceil(count / limit) : 1
84+
85+
const hasPrevPage = pagination !== false && page > 1
86+
const hasNextPage = pagination !== false && totalPages > page
87+
const pagingCounter =
88+
pagination !== false && typeof limit === 'number' ? (page - 1) * limit + 1 : 1
89+
90+
const result: PaginatedDocs = {
91+
docs,
92+
hasNextPage,
93+
hasPrevPage,
94+
limit,
95+
nextPage: hasNextPage ? page + 1 : null,
96+
page,
97+
pagingCounter,
98+
prevPage: hasPrevPage ? page - 1 : null,
99+
totalDocs: count,
100+
totalPages,
101+
}
102+
103+
return result
104+
}

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

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ type BuildJoinAggregationArgs = {
1919
collection: CollectionSlug
2020
collectionConfig: SanitizedCollectionConfig
2121
joins: JoinQuery
22-
// the number of docs to get at the top collection level
23-
limit?: number
2422
locale: string
2523
projection?: Record<string, true>
2624
// the where clause for the top collection
@@ -34,10 +32,8 @@ export const buildJoinAggregation = async ({
3432
collection,
3533
collectionConfig,
3634
joins,
37-
limit,
3835
locale,
3936
projection,
40-
query,
4137
versions,
4238
}: BuildJoinAggregationArgs): Promise<PipelineStage[] | undefined> => {
4339
if (
@@ -49,24 +45,8 @@ export const buildJoinAggregation = async ({
4945
}
5046

5147
const joinConfig = adapter.payload.collections[collection].config.joins
48+
const aggregate: PipelineStage[] = []
5249
const polymorphicJoinsConfig = adapter.payload.collections[collection].config.polymorphicJoins
53-
const aggregate: PipelineStage[] = [
54-
{
55-
$sort: { createdAt: -1 },
56-
},
57-
]
58-
59-
if (query) {
60-
aggregate.push({
61-
$match: query,
62-
})
63-
}
64-
65-
if (limit) {
66-
aggregate.push({
67-
$limit: limit,
68-
})
69-
}
7050

7151
for (const join of polymorphicJoinsConfig) {
7252
if (projection && !projection[join.joinPath]) {
@@ -448,9 +428,5 @@ export const buildJoinAggregation = async ({
448428
}
449429
}
450430

451-
if (projection) {
452-
aggregate.push({ $project: projection })
453-
}
454-
455431
return aggregate
456432
}

0 commit comments

Comments
 (0)