Skip to content

Commit 41cff6d

Browse files
elliott-wclauder1tsuu
authored
fix(db-mongodb): improve compatibility with Firestore database (#12763)
### What? Adds four more arguments to the `mongooseAdapter`: ```typescript useJoinAggregations?: boolean /* The big one */ useAlternativeDropDatabase?: boolean useBigIntForNumberIDs?: boolean usePipelineInSortLookup?: boolean ``` Also export a new `compatabilityOptions` object from `@payloadcms/db-mongodb` where each key is a mongo-compatible database and the value is the recommended `mongooseAdapter` settings for compatability. ### Why? When using firestore and visiting `/admin/collections/media/payload-folders`, we get: ``` MongoServerError: invalid field(s) in lookup: [let, pipeline], only lookup(from, localField, foreignField, as) is supported ``` Firestore doesn't support the full MongoDB aggregation API used by Payload which gets used when building aggregations for populating join fields. There are several other compatability issues with Firestore: - The invalid `pipeline` property is used in the `$lookup` aggregation in `buildSortParams` - Firestore only supports number IDs of type `Long`, but Mongoose converts custom ID fields of type number to `Double` - Firestore does not support the `dropDatabase` command - Firestore does not support the `createIndex` command (not addressed in this PR) ### How? ```typescript useJoinAggregations?: boolean /* The big one */ ``` When this is `false` we skip the `buildJoinAggregation()` pipeline and resolve the join fields through multiple queries. This can potentially be used with AWS DocumentDB and Azure Cosmos DB to support join fields, but I have not tested with either of these databases. ```typescript useAlternativeDropDatabase?: boolean ``` When `true`, monkey-patch (replace) the `dropDatabase` function so that it calls `collection.deleteMany({})` on every collection instead of sending a single `dropDatabase` command to the database ```typescript useBigIntForNumberIDs?: boolean ``` When `true`, use `mongoose.Schema.Types.BigInt` for custom ID fields of type `number` which converts to a firestore `Long` behind the scenes ```typescript usePipelineInSortLookup?: boolean ``` When `false`, modify the sortAggregation pipeline in `buildSortParams()` so that we don't use the `pipeline` property in the `$lookup` aggregation. Results in slightly worse performance when sorting by relationship properties. ### Limitations This PR does not add support for transactions or creating indexes in firestore. ### Fixes Fixed a bug (and added a test) where you weren't able to sort by multiple properties on a relationship field. ### Future work 1. Firestore supports simple `$lookup` aggregations but other databases might not. Could add a `useSortAggregations` property which can be used to disable aggregations in sorting. --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Sasha <[email protected]>
1 parent e6da384 commit 41cff6d

20 files changed

+937
-39
lines changed

.github/workflows/main.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ jobs:
153153
matrix:
154154
database:
155155
- mongodb
156+
- firestore
156157
- postgres
157158
- postgres-custom-schema
158159
- postgres-uuid

docs/database/mongodb.mdx

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,22 @@ export default buildConfig({
3030

3131
## Options
3232

33-
| Option | Description |
34-
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
35-
| `autoPluralization` | Tell Mongoose to auto-pluralize any collection names if it encounters any singular words used as collection `slug`s. |
36-
| `connectOptions` | Customize MongoDB connection options. Payload will connect to your MongoDB database using default options which you can override and extend to include all the [options](https://mongoosejs.com/docs/connections.html#options) available to mongoose. |
37-
| `collectionsSchemaOptions` | Customize Mongoose schema options for collections. |
38-
| `disableIndexHints` | Set to true to disable hinting to MongoDB to use 'id' as index. This is currently done when counting documents for pagination, as it increases the speed of the count function used in that query. Disabling this optimization might fix some problems with AWS DocumentDB. Defaults to false |
39-
| `migrationDir` | Customize the directory that migrations are stored. |
40-
| `transactionOptions` | An object with configuration properties used in [transactions](https://www.mongodb.com/docs/manual/core/transactions/) or `false` which will disable the use of transactions. |
41-
| `collation` | Enable language-specific string comparison with customizable options. Available on MongoDB 3.4+. Defaults locale to "en". Example: `{ strength: 3 }`. For a full list of collation options and their definitions, see the [MongoDB documentation](https://www.mongodb.com/docs/manual/reference/collation/). |
42-
| `allowAdditionalKeys` | By default, Payload strips all additional keys from MongoDB data that don't exist in the Payload schema. If you have some data that you want to include to the result but it doesn't exist in Payload, you can set this to `true`. Be careful as Payload access control _won't_ work for this data. |
43-
| `allowIDOnCreate` | Set to `true` to use the `id` passed in data on the create API operations without using a custom ID field. |
44-
| `disableFallbackSort` | Set to `true` to disable the adapter adding a fallback sort when sorting by non-unique fields, this can affect performance in some cases but it ensures a consistent order of results. |
33+
| Option | Description |
34+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
35+
| `autoPluralization` | Tell Mongoose to auto-pluralize any collection names if it encounters any singular words used as collection `slug`s. |
36+
| `connectOptions` | Customize MongoDB connection options. Payload will connect to your MongoDB database using default options which you can override and extend to include all the [options](https://mongoosejs.com/docs/connections.html#options) available to mongoose. |
37+
| `collectionsSchemaOptions` | Customize Mongoose schema options for collections. |
38+
| `disableIndexHints` | Set to true to disable hinting to MongoDB to use 'id' as index. This is currently done when counting documents for pagination, as it increases the speed of the count function used in that query. Disabling this optimization might fix some problems with AWS DocumentDB. Defaults to false |
39+
| `migrationDir` | Customize the directory that migrations are stored. |
40+
| `transactionOptions` | An object with configuration properties used in [transactions](https://www.mongodb.com/docs/manual/core/transactions/) or `false` which will disable the use of transactions. |
41+
| `collation` | Enable language-specific string comparison with customizable options. Available on MongoDB 3.4+. Defaults locale to "en". Example: `{ strength: 3 }`. For a full list of collation options and their definitions, see the [MongoDB documentation](https://www.mongodb.com/docs/manual/reference/collation/). |
42+
| `allowAdditionalKeys` | By default, Payload strips all additional keys from MongoDB data that don't exist in the Payload schema. If you have some data that you want to include to the result but it doesn't exist in Payload, you can set this to `true`. Be careful as Payload access control _won't_ work for this data. |
43+
| `allowIDOnCreate` | Set to `true` to use the `id` passed in data on the create API operations without using a custom ID field. |
44+
| `disableFallbackSort` | Set to `true` to disable the adapter adding a fallback sort when sorting by non-unique fields, this can affect performance in some cases but it ensures a consistent order of results. |
45+
| `useAlternativeDropDatabase` | Set to `true` to use an alternative `dropDatabase` implementation that calls `collection.deleteMany({})` on every collection instead of sending a raw `dropDatabase` command. Payload only uses `dropDatabase` for testing purposes. Defaults to `false`. |
46+
| `useBigIntForNumberIDs` | Set to `true` to use `BigInt` for custom ID fields of type `'number'`. Useful for databases that don't support `double` or `int32` IDs. Defaults to `false`. |
47+
| `useJoinAggregations` | Set to `false` to disable join aggregations (which use correlated subqueries) and instead populate join fields via multiple `find` queries. Defaults to `true`. |
48+
| `usePipelineInSortLookup` | Set to `false` to disable the use of `pipeline` in the `$lookup` aggregation in sorting. Defaults to `true`. |
4549

4650
## Access to Mongoose models
4751

@@ -56,9 +60,21 @@ You can access Mongoose models as follows:
5660

5761
## Using other MongoDB implementations
5862

59-
Limitations with [DocumentDB](https://aws.amazon.com/documentdb/) and [Azure Cosmos DB](https://azure.microsoft.com/en-us/products/cosmos-db):
63+
You can import the `compatabilityOptions` object to get the recommended settings for other MongoDB implementations. Since these databases aren't officially supported by payload, you may still encounter issues even with these settings (please create an issue or PR if you believe these options should be updated):
6064

61-
- For Azure Cosmos DB you must pass `transactionOptions: false` to the adapter options. Azure Cosmos DB does not support transactions that update two and more documents in different collections, which is a common case when using Payload (via hooks).
62-
- For Azure Cosmos DB the root config property `indexSortableFields` must be set to `true`.
63-
- The [Join Field](../fields/join) is not supported in DocumentDB and Azure Cosmos DB, as we internally use MongoDB aggregations to query data for that field, which are limited there. This can be changed in the future.
64-
- For DocumentDB pass `disableIndexHints: true` to disable hinting to the DB to use `id` as index which can cause problems with DocumentDB.
65+
```ts
66+
import { mongooseAdapter, compatabilityOptions } from '@payloadcms/db-mongodb'
67+
68+
export default buildConfig({
69+
db: mongooseAdapter({
70+
url: process.env.DATABASE_URI,
71+
// For example, if you're using firestore:
72+
...compatabilityOptions.firestore,
73+
}),
74+
})
75+
```
76+
77+
We export compatability options for [DocumentDB](https://aws.amazon.com/documentdb/), [Azure Cosmos DB](https://azure.microsoft.com/en-us/products/cosmos-db) and [Firestore](https://cloud.google.com/firestore/mongodb-compatibility/docs/overview). Known limitations:
78+
79+
- Azure Cosmos DB does not support transactions that update two or more documents in different collections, which is a common case when using Payload (via hooks).
80+
- Azure Cosmos DB the root config property `indexSortableFields` must be set to `true`.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@
112112
"test:e2e:prod:ci": "pnpm prepare-run-test-against-prod:ci && pnpm runts ./test/runE2E.ts --prod",
113113
"test:e2e:prod:ci:noturbo": "pnpm prepare-run-test-against-prod:ci && pnpm runts ./test/runE2E.ts --prod --no-turbo",
114114
"test:int": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" NODE_NO_WARNINGS=1 DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=test/jest.config.js --runInBand",
115+
"test:int:firestore": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" NODE_NO_WARNINGS=1 PAYLOAD_DATABASE=firestore DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=test/jest.config.js --runInBand",
115116
"test:int:postgres": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" NODE_NO_WARNINGS=1 PAYLOAD_DATABASE=postgres DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=test/jest.config.js --runInBand",
116117
"test:int:sqlite": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" NODE_NO_WARNINGS=1 PAYLOAD_DATABASE=sqlite DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=test/jest.config.js --runInBand",
117118
"test:types": "tstyche",

packages/db-mongodb/src/connect.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,25 @@ export const connect: Connect = async function connect(
3636

3737
try {
3838
this.connection = (await mongoose.connect(urlToConnect, connectionOptions)).connection
39+
if (this.useAlternativeDropDatabase) {
40+
if (this.connection.db) {
41+
// Firestore doesn't support dropDatabase, so we monkey patch
42+
// dropDatabase to delete all documents from all collections instead
43+
this.connection.db.dropDatabase = async function (): Promise<boolean> {
44+
const existingCollections = await this.listCollections().toArray()
45+
await Promise.all(
46+
existingCollections.map(async (collectionInfo) => {
47+
const collection = this.collection(collectionInfo.name)
48+
await collection.deleteMany({})
49+
}),
50+
)
51+
return true
52+
}
53+
this.connection.dropDatabase = async function () {
54+
await this.db?.dropDatabase()
55+
}
56+
}
57+
}
3958

4059
// If we are running a replica set with MongoDB Memory Server,
4160
// wait until the replica set elects a primary before proceeding

packages/db-mongodb/src/find.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
1212
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
1313
import { getCollection } from './utilities/getEntity.js'
1414
import { getSession } from './utilities/getSession.js'
15+
import { resolveJoins } from './utilities/resolveJoins.js'
1516
import { transform } from './utilities/transform.js'
1617

1718
export const find: Find = async function find(
@@ -155,6 +156,16 @@ export const find: Find = async function find(
155156
result = await Model.paginate(query, paginationOptions)
156157
}
157158

159+
if (!this.useJoinAggregations) {
160+
await resolveJoins({
161+
adapter: this,
162+
collectionSlug,
163+
docs: result.docs as Record<string, unknown>[],
164+
joins,
165+
locale,
166+
})
167+
}
168+
158169
transform({
159170
adapter: this,
160171
data: result.docs,

packages/db-mongodb/src/findOne.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
1010
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
1111
import { getCollection } from './utilities/getEntity.js'
1212
import { getSession } from './utilities/getSession.js'
13+
import { resolveJoins } from './utilities/resolveJoins.js'
1314
import { transform } from './utilities/transform.js'
1415

1516
export const findOne: FindOne = async function findOne(
@@ -67,6 +68,16 @@ export const findOne: FindOne = async function findOne(
6768
doc = await Model.findOne(query, {}, options)
6869
}
6970

71+
if (doc && !this.useJoinAggregations) {
72+
await resolveJoins({
73+
adapter: this,
74+
collectionSlug,
75+
docs: [doc] as Record<string, unknown>[],
76+
joins,
77+
locale,
78+
})
79+
}
80+
7081
if (!doc) {
7182
return null
7283
}

packages/db-mongodb/src/index.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,29 @@ export interface Args {
143143

144144
/** The URL to connect to MongoDB or false to start payload and prevent connecting */
145145
url: false | string
146+
147+
/**
148+
* Set to `true` to use an alternative `dropDatabase` implementation that calls `collection.deleteMany({})` on every collection instead of sending a raw `dropDatabase` command.
149+
* Payload only uses `dropDatabase` for testing purposes.
150+
* @default false
151+
*/
152+
useAlternativeDropDatabase?: boolean
153+
/**
154+
* Set to `true` to use `BigInt` for custom ID fields of type `'number'`.
155+
* Useful for databases that don't support `double` or `int32` IDs.
156+
* @default false
157+
*/
158+
useBigIntForNumberIDs?: boolean
159+
/**
160+
* Set to `false` to disable join aggregations (which use correlated subqueries) and instead populate join fields via multiple `find` queries.
161+
* @default true
162+
*/
163+
useJoinAggregations?: boolean
164+
/**
165+
* Set to `false` to disable the use of `pipeline` in the `$lookup` aggregation in sorting.
166+
* @default true
167+
*/
168+
usePipelineInSortLookup?: boolean
146169
}
147170

148171
export type MongooseAdapter = {
@@ -159,6 +182,10 @@ export type MongooseAdapter = {
159182
up: (args: MigrateUpArgs) => Promise<void>
160183
}[]
161184
sessions: Record<number | string, ClientSession>
185+
useAlternativeDropDatabase: boolean
186+
useBigIntForNumberIDs: boolean
187+
useJoinAggregations: boolean
188+
usePipelineInSortLookup: boolean
162189
versions: {
163190
[slug: string]: CollectionModel
164191
}
@@ -194,6 +221,10 @@ declare module 'payload' {
194221
updateVersion: <T extends TypeWithID = TypeWithID>(
195222
args: { options?: QueryOptions } & UpdateVersionArgs<T>,
196223
) => Promise<TypeWithVersion<T>>
224+
useAlternativeDropDatabase: boolean
225+
useBigIntForNumberIDs: boolean
226+
useJoinAggregations: boolean
227+
usePipelineInSortLookup: boolean
197228
versions: {
198229
[slug: string]: CollectionModel
199230
}
@@ -214,6 +245,10 @@ export function mongooseAdapter({
214245
prodMigrations,
215246
transactionOptions = {},
216247
url,
248+
useAlternativeDropDatabase = false,
249+
useBigIntForNumberIDs = false,
250+
useJoinAggregations = true,
251+
usePipelineInSortLookup = true,
217252
}: Args): DatabaseAdapterObj {
218253
function adapter({ payload }: { payload: Payload }) {
219254
const migrationDir = findMigrationDir(migrationDirArg)
@@ -279,6 +314,10 @@ export function mongooseAdapter({
279314
updateOne,
280315
updateVersion,
281316
upsert,
317+
useAlternativeDropDatabase,
318+
useBigIntForNumberIDs,
319+
useJoinAggregations,
320+
usePipelineInSortLookup,
282321
})
283322
}
284323

@@ -290,6 +329,8 @@ export function mongooseAdapter({
290329
}
291330
}
292331

332+
export { compatabilityOptions } from './utilities/compatabilityOptions.js'
333+
293334
/**
294335
* Attempt to find migrations directory.
295336
*

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,12 @@ export const buildSchema = (args: {
143143
const idField = schemaFields.find((field) => fieldAffectsData(field) && field.name === 'id')
144144
if (idField) {
145145
fields = {
146-
_id: idField.type === 'number' ? Number : String,
146+
_id:
147+
idField.type === 'number'
148+
? payload.db.useBigIntForNumberIDs
149+
? mongoose.Schema.Types.BigInt
150+
: Number
151+
: String,
147152
}
148153
schemaFields = schemaFields.filter(
149154
(field) => !(fieldAffectsData(field) && field.name === 'id'),
@@ -900,7 +905,11 @@ const getRelationshipValueType = (field: RelationshipField | UploadField, payloa
900905
}
901906

902907
if (customIDType === 'number') {
903-
return mongoose.Schema.Types.Number
908+
if (payload.db.useBigIntForNumberIDs) {
909+
return mongoose.Schema.Types.BigInt
910+
} else {
911+
return mongoose.Schema.Types.Number
912+
}
904913
}
905914

906915
return mongoose.Schema.Types.String

0 commit comments

Comments
 (0)