perf(db-mongodb): improve performance of all operations, up to 50% faster (#9594)

This PR improves speed and memory efficiency across all operations with
the Mongoose adapter.

### How?

- Removes Mongoose layer from all database calls, instead uses MongoDB
directly. (this doesn't remove building mongoose schema since it's still
needed for indexes + users in theory can use it)
- Replaces deep copying of read results using
`JSON.parse(JSON.stringify(data))` with the `transform` `operation:
'read'` function which converts Date's, ObjectID's in relationships /
joins to strings. As before, it also handles transformations for write
operations.
- Faster `hasNearConstraint` for potentially large `where`'s
- `traverseFields` now can accept `flattenedFields` which we use in
`transform`. Less recursive calls with tabs/rows/collapsible

Additional fixes
- Uses current transaction for querying nested relationships properties
in `buildQuery`, previously it wasn't used which could've led to wrong
results
- Allows to clear not required point fields with passing `null` from the
Local API. Previously it didn't work in both, MongoDB and Postgres

Benchmarks using this file
https://github.com/payloadcms/payload/blob/chore/db-benchmark/test/_community/int.spec.ts

### Small Dataset Performance

| Metric | Before Optimization | After Optimization | Improvement (%) |

|---------------------------|---------------------|--------------------|-----------------|
| Average FULL (ms) | 1170 | 844 | 27.86% |
| `payload.db.create` (ms) | 1413 | 691 | 51.12% |
| `payload.db.find` (ms) | 2856 | 2204 | 22.83% |
| `payload.db.deleteMany` (ms) | 15206 | 8439 | 44.53% |
| `payload.db.updateOne` (ms) | 21444 | 12162 | 43.30% |
| `payload.db.findOne` (ms) | 159 | 112 | 29.56% |
| `payload.db.deleteOne` (ms) | 3729 | 2578 | 30.89% |
| DB small FULL (ms) | 64473 | 46451 | 27.93% |

---

### Medium Dataset Performance

| Metric | Before Optimization | After Optimization | Improvement (%) |

|---------------------------|---------------------|--------------------|-----------------|
| Average FULL (ms) | 9407 | 6210 | 33.99% |
| `payload.db.create` (ms) | 10270 | 4321 | 57.93% |
| `payload.db.find` (ms) | 20814 | 16036 | 22.93% |
| `payload.db.deleteMany` (ms) | 126351 | 61789 | 51.11% |
| `payload.db.updateOne` (ms) | 201782 | 99943 | 50.49% |
| `payload.db.findOne` (ms) | 1081 | 817 | 24.43% |
| `payload.db.deleteOne` (ms) | 28534 | 23363 | 18.12% |
| DB medium FULL (ms) | 519518 | 342194 | 34.13% |

---

### Large Dataset Performance

| Metric | Before Optimization | After Optimization | Improvement (%) |

|---------------------------|---------------------|--------------------|-----------------|
| Average FULL (ms) | 26575 | 17509 | 34.14% |
| `payload.db.create` (ms) | 29085 | 12196 | 58.08% |
| `payload.db.find` (ms) | 58497 | 43838 | 25.04% |
| `payload.db.deleteMany` (ms) | 372195 | 173218 | 53.47% |
| `payload.db.updateOne` (ms) | 544089 | 288350 | 47.00% |
| `payload.db.findOne` (ms) | 3058 | 2197 | 28.14% |
| `payload.db.deleteOne` (ms) | 82444 | 64730 | 21.49% |
| DB large FULL (ms) | 1461097 | 969714 | 33.62% |
This commit is contained in:
Sasha
2024-12-19 20:20:39 +02:00
committed by GitHub
parent 034b442699
commit e468292039
46 changed files with 1338 additions and 940 deletions

View File

@@ -1,10 +1,9 @@
import type { CountOptions } from 'mongodb'
import type { Count } from 'payload'
import { flattenWhereToOperators } from 'payload'
import type { MongooseAdapter } from './index.js'
import { getHasNearConstraint } from './utilities/getHasNearConstraint.js'
import { getSession } from './utilities/getSession.js'
export const count: Count = async function count(
@@ -12,41 +11,37 @@ export const count: Count = async function count(
{ collection, locale, req, where },
) {
const Model = this.collections[collection]
const options: CountOptions = {
session: await getSession(this, req),
}
const session = await getSession(this, req)
let hasNearConstraint = false
if (where) {
const constraints = flattenWhereToOperators(where)
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
}
const hasNearConstraint = getHasNearConstraint(where)
const query = await Model.buildQuery({
locale,
payload: this.payload,
session,
where,
})
// useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters.
const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0
if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) {
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
// the correct indexed field
options.hint = {
_id: 1,
}
}
let result: number
if (useEstimatedCount) {
result = await Model.estimatedDocumentCount({ session: options.session })
result = await Model.collection.estimatedDocumentCount()
} else {
result = await Model.countDocuments(query, options)
const options: CountOptions = { session }
if (this.disableIndexHints !== true) {
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
// the correct indexed field
options.hint = {
_id: 1,
}
}
result = await Model.collection.countDocuments(query, options)
}
return {

View File

@@ -1,10 +1,9 @@
import type { CountOptions } from 'mongodb'
import type { CountGlobalVersions } from 'payload'
import { flattenWhereToOperators } from 'payload'
import type { MongooseAdapter } from './index.js'
import { getHasNearConstraint } from './utilities/getHasNearConstraint.js'
import { getSession } from './utilities/getSession.js'
export const countGlobalVersions: CountGlobalVersions = async function countGlobalVersions(
@@ -12,41 +11,37 @@ export const countGlobalVersions: CountGlobalVersions = async function countGlob
{ global, locale, req, where },
) {
const Model = this.versions[global]
const options: CountOptions = {
session: await getSession(this, req),
}
const session = await getSession(this, req)
let hasNearConstraint = false
if (where) {
const constraints = flattenWhereToOperators(where)
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
}
const hasNearConstraint = getHasNearConstraint(where)
const query = await Model.buildQuery({
locale,
payload: this.payload,
session,
where,
})
// useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters.
const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0
if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) {
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
// the correct indexed field
options.hint = {
_id: 1,
}
}
let result: number
if (useEstimatedCount) {
result = await Model.estimatedDocumentCount({ session: options.session })
result = await Model.collection.estimatedDocumentCount()
} else {
result = await Model.countDocuments(query, options)
const options: CountOptions = { session }
if (Object.keys(query).length === 0 && this.disableIndexHints !== true) {
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
// the correct indexed field
options.hint = {
_id: 1,
}
}
result = await Model.collection.countDocuments(query, options)
}
return {

View File

@@ -1,10 +1,9 @@
import type { CountOptions } from 'mongodb'
import type { CountVersions } from 'payload'
import { flattenWhereToOperators } from 'payload'
import type { MongooseAdapter } from './index.js'
import { getHasNearConstraint } from './utilities/getHasNearConstraint.js'
import { getSession } from './utilities/getSession.js'
export const countVersions: CountVersions = async function countVersions(
@@ -12,41 +11,37 @@ export const countVersions: CountVersions = async function countVersions(
{ collection, locale, req, where },
) {
const Model = this.versions[collection]
const options: CountOptions = {
session: await getSession(this, req),
}
const session = await getSession(this, req)
let hasNearConstraint = false
if (where) {
const constraints = flattenWhereToOperators(where)
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
}
const hasNearConstraint = getHasNearConstraint(where)
const query = await Model.buildQuery({
locale,
payload: this.payload,
session,
where,
})
// useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters.
const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0
if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) {
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
// the correct indexed field
options.hint = {
_id: 1,
}
}
let result: number
if (useEstimatedCount) {
result = await Model.estimatedDocumentCount({ session: options.session })
result = await Model.collection.estimatedDocumentCount()
} else {
result = await Model.countDocuments(query, options)
const options: CountOptions = { session }
if (this.disableIndexHints !== true) {
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
// the correct indexed field
options.hint = {
_id: 1,
}
}
result = await Model.collection.countDocuments(query, options)
}
return {

View File

@@ -1,48 +1,44 @@
import type { CreateOptions } from 'mongoose'
import type { Create, Document } from 'payload'
import type { Create } from 'payload'
import type { MongooseAdapter } from './index.js'
import { getSession } from './utilities/getSession.js'
import { handleError } from './utilities/handleError.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export const create: Create = async function create(
this: MongooseAdapter,
{ collection, data, req },
) {
const Model = this.collections[collection]
const options: CreateOptions = {
session: await getSession(this, req),
}
const session = await getSession(this, req)
let doc
const sanitizedData = sanitizeRelationshipIDs({
config: this.payload.config,
data,
fields: this.payload.collections[collection].config.fields,
})
const fields = this.payload.collections[collection].config.flattenedFields
if (this.payload.collections[collection].customIDType) {
sanitizedData._id = sanitizedData.id
data._id = data.id
}
transform({
adapter: this,
data,
fields,
operation: 'create',
})
try {
;[doc] = await Model.create([sanitizedData], options)
const { insertedId } = await Model.collection.insertOne(data, { session })
data._id = insertedId
transform({
adapter: this,
data,
fields,
operation: 'read',
})
return data
} catch (error) {
handleError({ collection, error, req })
}
// doc.toJSON does not do stuff like converting ObjectIds to string, or date strings to date objects. That's why we use JSON.parse/stringify here
const result: Document = JSON.parse(JSON.stringify(doc))
const verificationToken = doc._verificationToken
// custom id type reset
result.id = result._id
if (verificationToken) {
result._verificationToken = verificationToken
}
return result
}

View File

@@ -1,11 +1,9 @@
import type { CreateOptions } from 'mongoose'
import type { CreateGlobal } from 'payload'
import type { MongooseAdapter } from './index.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export const createGlobal: CreateGlobal = async function createGlobal(
this: MongooseAdapter,
@@ -13,26 +11,29 @@ export const createGlobal: CreateGlobal = async function createGlobal(
) {
const Model = this.globals
const global = sanitizeRelationshipIDs({
config: this.payload.config,
data: {
globalType: slug,
...data,
},
fields: this.payload.config.globals.find((globalConfig) => globalConfig.slug === slug).fields,
const fields = this.payload.config.globals.find(
(globalConfig) => globalConfig.slug === slug,
).flattenedFields
transform({
adapter: this,
data,
fields,
globalSlug: slug,
operation: 'create',
})
const options: CreateOptions = {
session: await getSession(this, req),
}
const session = await getSession(this, req)
let [result] = (await Model.create([global], options)) as any
const { insertedId } = await Model.collection.insertOne(data, { session })
;(data as any)._id = insertedId
result = JSON.parse(JSON.stringify(result))
transform({
adapter: this,
data,
fields,
operation: 'read',
})
// custom id type reset
result.id = result._id
result = sanitizeInternalFields(result)
return result
return data
}

View File

@@ -1,11 +1,9 @@
import type { CreateOptions } from 'mongoose'
import { buildVersionGlobalFields, type CreateGlobalVersion, type Document } from 'payload'
import { buildVersionGlobalFields, type CreateGlobalVersion } from 'payload'
import type { MongooseAdapter } from './index.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export const createGlobalVersion: CreateGlobalVersion = async function createGlobalVersion(
this: MongooseAdapter,
@@ -22,36 +20,41 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo
},
) {
const VersionModel = this.versions[globalSlug]
const options: CreateOptions = {
session: await getSession(this, req),
const session = await getSession(this, req)
const data = {
autosave,
createdAt,
latest: true,
parent,
publishedLocale,
snapshot,
updatedAt,
version: versionData,
}
const data = sanitizeRelationshipIDs({
config: this.payload.config,
data: {
autosave,
createdAt,
latest: true,
parent,
publishedLocale,
snapshot,
updatedAt,
version: versionData,
},
fields: buildVersionGlobalFields(
this.payload.config,
this.payload.config.globals.find((global) => global.slug === globalSlug),
),
const fields = buildVersionGlobalFields(
this.payload.config,
this.payload.config.globals.find((global) => global.slug === globalSlug),
true,
)
transform({
adapter: this,
data,
fields,
operation: 'create',
})
const [doc] = await VersionModel.create([data], options, req)
const { insertedId } = await VersionModel.collection.insertOne(data, { session })
;(data as any)._id = insertedId
await VersionModel.updateMany(
await VersionModel.collection.updateMany(
{
$and: [
{
_id: {
$ne: doc._id,
$ne: insertedId,
},
},
{
@@ -67,16 +70,15 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo
],
},
{ $unset: { latest: 1 } },
options,
{ session },
)
const result: Document = JSON.parse(JSON.stringify(doc))
const verificationToken = doc._verificationToken
transform({
adapter: this,
data,
fields,
operation: 'read',
})
// custom id type reset
result.id = result._id
if (verificationToken) {
result._verificationToken = verificationToken
}
return result
return data as any
}

View File

@@ -1,12 +1,10 @@
import type { CreateOptions } from 'mongoose'
import { Types } from 'mongoose'
import { buildVersionCollectionFields, type CreateVersion, type Document } from 'payload'
import { buildVersionCollectionFields, type CreateVersion } from 'payload'
import type { MongooseAdapter } from './index.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export const createVersion: CreateVersion = async function createVersion(
this: MongooseAdapter,
@@ -23,29 +21,34 @@ export const createVersion: CreateVersion = async function createVersion(
},
) {
const VersionModel = this.versions[collectionSlug]
const options: CreateOptions = {
session: await getSession(this, req),
const session = await getSession(this, req)
const data: any = {
autosave,
createdAt,
latest: true,
parent,
publishedLocale,
snapshot,
updatedAt,
version: versionData,
}
const data = sanitizeRelationshipIDs({
config: this.payload.config,
data: {
autosave,
createdAt,
latest: true,
parent,
publishedLocale,
snapshot,
updatedAt,
version: versionData,
},
fields: buildVersionCollectionFields(
this.payload.config,
this.payload.collections[collectionSlug].config,
),
const fields = buildVersionCollectionFields(
this.payload.config,
this.payload.collections[collectionSlug].config,
true,
)
transform({
adapter: this,
data,
fields,
operation: 'create',
})
const [doc] = await VersionModel.create([data], options, req)
const { insertedId } = await VersionModel.collection.insertOne(data, { session })
data._id = insertedId
const parentQuery = {
$or: [
@@ -56,7 +59,7 @@ export const createVersion: CreateVersion = async function createVersion(
},
],
}
if (data.parent instanceof Types.ObjectId) {
if ((data.parent as unknown) instanceof Types.ObjectId) {
parentQuery.$or.push({
parent: {
$eq: data.parent.toString(),
@@ -64,12 +67,12 @@ export const createVersion: CreateVersion = async function createVersion(
})
}
await VersionModel.updateMany(
await VersionModel.collection.updateMany(
{
$and: [
{
_id: {
$ne: doc._id,
$ne: insertedId,
},
},
parentQuery,
@@ -80,22 +83,21 @@ export const createVersion: CreateVersion = async function createVersion(
},
{
updatedAt: {
$lt: new Date(doc.updatedAt),
$lt: new Date(data.updatedAt),
},
},
],
},
{ $unset: { latest: 1 } },
options,
{ session },
)
const result: Document = JSON.parse(JSON.stringify(doc))
const verificationToken = doc._verificationToken
transform({
adapter: this,
data,
fields,
operation: 'read',
})
// custom id type reset
result.id = result._id
if (verificationToken) {
result._verificationToken = verificationToken
}
return result
return data
}

View File

@@ -1,4 +1,3 @@
import type { DeleteOptions } from 'mongodb'
import type { DeleteMany } from 'payload'
import type { MongooseAdapter } from './index.js'
@@ -10,14 +9,16 @@ export const deleteMany: DeleteMany = async function deleteMany(
{ collection, req, where },
) {
const Model = this.collections[collection]
const options: DeleteOptions = {
session: await getSession(this, req),
}
const session = await getSession(this, req)
const query = await Model.buildQuery({
payload: this.payload,
session,
where,
})
await Model.deleteMany(query, options)
await Model.collection.deleteMany(query, {
session,
})
}

View File

@@ -1,38 +1,41 @@
import type { QueryOptions } from 'mongoose'
import type { DeleteOne, Document } from 'payload'
import type { DeleteOne } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { transform } from './utilities/transform.js'
export const deleteOne: DeleteOne = async function deleteOne(
this: MongooseAdapter,
{ collection, req, select, where },
) {
const Model = this.collections[collection]
const options: QueryOptions = {
projection: buildProjectionFromSelect({
adapter: this,
fields: this.payload.collections[collection].config.flattenedFields,
select,
}),
session: await getSession(this, req),
}
const session = await getSession(this, req)
const query = await Model.buildQuery({
payload: this.payload,
session,
where,
})
const doc = await Model.findOneAndDelete(query, options).lean()
const fields = this.payload.collections[collection].config.flattenedFields
let result: Document = JSON.parse(JSON.stringify(doc))
const doc = await Model.collection.findOneAndDelete(query, {
projection: buildProjectionFromSelect({
adapter: this,
fields,
select,
}),
session,
})
// custom id type reset
result.id = result._id
result = sanitizeInternalFields(result)
transform({
adapter: this,
data: doc,
fields,
operation: 'read',
})
return result
return doc
}

View File

@@ -15,8 +15,11 @@ export const deleteVersions: DeleteVersions = async function deleteVersions(
const query = await VersionsModel.buildQuery({
locale,
payload: this.payload,
session,
where,
})
await VersionsModel.deleteMany(query, { session })
await VersionsModel.collection.deleteMany(query, {
session,
})
}

View File

@@ -1,15 +1,15 @@
import type { PaginateOptions } from 'mongoose'
import type { CollationOptions } from 'mongodb'
import type { Find } from 'payload'
import { flattenWhereToOperators } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildSortParam } from './queries/buildSortParam.js'
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { findMany } from './utilities/findMany.js'
import { getHasNearConstraint } from './utilities/getHasNearConstraint.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { transform } from './utilities/transform.js'
export const find: Find = async function find(
this: MongooseAdapter,
@@ -20,7 +20,6 @@ export const find: Find = async function find(
locale,
page,
pagination,
projection,
req,
select,
sort: sortArg,
@@ -29,21 +28,17 @@ export const find: Find = async function find(
) {
const Model = this.collections[collection]
const collectionConfig = this.payload.collections[collection].config
const session = await getSession(this, req)
let hasNearConstraint = false
const hasNearConstraint = getHasNearConstraint(where)
if (where) {
const constraints = flattenWhereToOperators(where)
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
}
const fields = collectionConfig.flattenedFields
let sort
if (!hasNearConstraint) {
sort = buildSortParam({
config: this.payload.config,
fields: collectionConfig.flattenedFields,
fields,
locale,
sort: sortArg || collectionConfig.defaultSort,
timestamps: true,
@@ -53,90 +48,51 @@ export const find: Find = async function find(
const query = await Model.buildQuery({
locale,
payload: this.payload,
session,
where,
})
// useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters.
const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0
const paginationOptions: PaginateOptions = {
lean: true,
leanWithId: true,
options: {
session,
},
page,
pagination,
projection,
sort,
useEstimatedCount,
}
if (select) {
paginationOptions.projection = buildProjectionFromSelect({
adapter: this,
fields: collectionConfig.flattenedFields,
select,
})
}
const projection = buildProjectionFromSelect({
adapter: this,
fields,
select,
})
if (this.collation) {
const defaultLocale = 'en'
paginationOptions.collation = {
locale: locale && locale !== 'all' && locale !== '*' ? locale : defaultLocale,
...this.collation,
}
}
const collation: CollationOptions | undefined = this.collation
? {
locale: locale && locale !== 'all' && locale !== '*' ? locale : 'en',
...this.collation,
}
: undefined
if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) {
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
// the correct indexed field
paginationOptions.useCustomCountFn = () => {
return Promise.resolve(
Model.countDocuments(query, {
hint: { _id: 1 },
session,
}),
)
}
}
if (limit >= 0) {
paginationOptions.limit = limit
// limit must also be set here, it's ignored when pagination is false
paginationOptions.options.limit = limit
// Disable pagination if limit is 0
if (limit === 0) {
paginationOptions.pagination = false
}
}
let result
const aggregate = await buildJoinAggregation({
const joinAgreggation = await buildJoinAggregation({
adapter: this,
collection,
collectionConfig,
joins,
locale,
query,
session,
})
// build join aggregation
if (aggregate) {
result = await Model.aggregatePaginate(Model.aggregate(aggregate), paginationOptions)
} else {
result = await Model.paginate(query, paginationOptions)
}
const docs = JSON.parse(JSON.stringify(result.docs))
const result = await findMany({
adapter: this,
collation,
collection: Model.collection,
joinAgreggation,
limit,
page,
pagination,
projection,
query,
session,
sort,
useEstimatedCount,
})
return {
...result,
docs: docs.map((doc) => {
doc.id = doc._id
return sanitizeInternalFields(doc)
}),
}
transform({ adapter: this, data: result.docs, fields, operation: 'read' })
return result
}

View File

@@ -1,4 +1,3 @@
import type { QueryOptions } from 'mongoose'
import type { FindGlobal } from 'payload'
import { combineQueries } from 'payload'
@@ -7,42 +6,40 @@ import type { MongooseAdapter } from './index.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { transform } from './utilities/transform.js'
export const findGlobal: FindGlobal = async function findGlobal(
this: MongooseAdapter,
{ slug, locale, req, select, where },
) {
const Model = this.globals
const options: QueryOptions = {
lean: true,
select: buildProjectionFromSelect({
adapter: this,
fields: this.payload.globals.config.find((each) => each.slug === slug).flattenedFields,
select,
}),
session: await getSession(this, req),
}
const session = await getSession(this, req)
const query = await Model.buildQuery({
globalSlug: slug,
locale,
payload: this.payload,
session,
where: combineQueries({ globalType: { equals: slug } }, where),
})
let doc = (await Model.findOne(query, {}, options)) as any
const fields = this.payload.globals.config.find((each) => each.slug === slug).flattenedFields
const doc = await Model.collection.findOne(query, {
projection: buildProjectionFromSelect({
adapter: this,
fields,
select,
}),
session: await getSession(this, req),
})
if (!doc) {
return null
}
if (doc._id) {
doc.id = doc._id
delete doc._id
}
doc = JSON.parse(JSON.stringify(doc))
doc = sanitizeInternalFields(doc)
transform({ adapter: this, data: doc, fields, operation: 'read' })
return doc
return doc as any
}

View File

@@ -1,14 +1,16 @@
import type { PaginateOptions, QueryOptions } from 'mongoose'
import type { CollationOptions } from 'mongodb'
import type { FindGlobalVersions } from 'payload'
import { buildVersionGlobalFields, flattenWhereToOperators } from 'payload'
import { buildVersionGlobalFields } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildSortParam } from './queries/buildSortParam.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { findMany } from './utilities/findMany.js'
import { getHasNearConstraint } from './utilities/getHasNearConstraint.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { transform } from './utilities/transform.js'
export const findGlobalVersions: FindGlobalVersions = async function findGlobalVersions(
this: MongooseAdapter,
@@ -21,19 +23,7 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV
true,
)
const session = await getSession(this, req)
const options: QueryOptions = {
limit,
session,
skip,
}
let hasNearConstraint = false
if (where) {
const constraints = flattenWhereToOperators(where)
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
}
const hasNearConstraint = getHasNearConstraint(where)
let sort
if (!hasNearConstraint) {
@@ -46,69 +36,49 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV
})
}
const session = await getSession(this, req)
const query = await Model.buildQuery({
globalSlug: global,
locale,
payload: this.payload,
session,
where,
})
// useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters.
const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0
const paginationOptions: PaginateOptions = {
lean: true,
leanWithId: true,
const projection = buildProjectionFromSelect({ adapter: this, fields: versionFields, select })
const collation: CollationOptions | undefined = this.collation
? {
locale: locale && locale !== 'all' && locale !== '*' ? locale : 'en',
...this.collation,
}
: undefined
const result = await findMany({
adapter: this,
collation,
collection: Model.collection,
limit,
options,
page,
pagination,
projection: buildProjectionFromSelect({ adapter: this, fields: versionFields, select }),
projection,
query,
session,
skip,
sort,
useEstimatedCount,
}
})
if (this.collation) {
const defaultLocale = 'en'
paginationOptions.collation = {
locale: locale && locale !== 'all' && locale !== '*' ? locale : defaultLocale,
...this.collation,
}
}
transform({
adapter: this,
data: result.docs,
fields: versionFields,
operation: 'read',
})
if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) {
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
// the correct indexed field
paginationOptions.useCustomCountFn = () => {
return Promise.resolve(
Model.countDocuments(query, {
hint: { _id: 1 },
session,
}),
)
}
}
if (limit >= 0) {
paginationOptions.limit = limit
// limit must also be set here, it's ignored when pagination is false
paginationOptions.options.limit = limit
// Disable pagination if limit is 0
if (limit === 0) {
paginationOptions.pagination = false
}
}
const result = await Model.paginate(query, paginationOptions)
const docs = JSON.parse(JSON.stringify(result.docs))
return {
...result,
docs: docs.map((doc) => {
doc.id = doc._id
return sanitizeInternalFields(doc)
}),
}
return result
}

View File

@@ -1,12 +1,11 @@
import type { AggregateOptions, QueryOptions } from 'mongoose'
import type { Document, FindOne } from 'payload'
import type { FindOne } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { transform } from './utilities/transform.js'
export const findOne: FindOne = async function findOne(
this: MongooseAdapter,
@@ -14,52 +13,64 @@ export const findOne: FindOne = async function findOne(
) {
const Model = this.collections[collection]
const collectionConfig = this.payload.collections[collection].config
const session = await getSession(this, req)
const options: AggregateOptions & QueryOptions = {
lean: true,
session,
}
const query = await Model.buildQuery({
locale,
payload: this.payload,
session,
where,
})
const fields = collectionConfig.flattenedFields
const projection = buildProjectionFromSelect({
adapter: this,
fields: collectionConfig.flattenedFields,
fields,
select,
})
const aggregate = await buildJoinAggregation({
const joinAggregation = await buildJoinAggregation({
adapter: this,
collection,
collectionConfig,
joins,
limit: 1,
locale,
projection,
query,
session,
})
let doc
if (aggregate) {
;[doc] = await Model.aggregate(aggregate, { session })
if (joinAggregation) {
const aggregation = Model.collection.aggregate(
[
{
$match: query,
},
],
{ session },
)
aggregation.limit(1)
for (const stage of joinAggregation) {
aggregation.addStage(stage)
}
;[doc] = await aggregation.toArray()
} else {
;(options as Record<string, unknown>).projection = projection
doc = await Model.findOne(query, {}, options)
doc = await Model.collection.findOne(query, { projection, session })
}
if (!doc) {
return null
}
let result: Document = JSON.parse(JSON.stringify(doc))
transform({
adapter: this,
data: doc,
fields,
operation: 'read',
})
// custom id type reset
result.id = result._id
result = sanitizeInternalFields(result)
return result
return doc
}

View File

@@ -1,14 +1,16 @@
import type { PaginateOptions, QueryOptions } from 'mongoose'
import type { CollationOptions } from 'mongodb'
import type { FindVersions } from 'payload'
import { buildVersionCollectionFields, flattenWhereToOperators } from 'payload'
import { buildVersionCollectionFields } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildSortParam } from './queries/buildSortParam.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { findMany } from './utilities/findMany.js'
import { getHasNearConstraint } from './utilities/getHasNearConstraint.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { transform } from './utilities/transform.js'
export const findVersions: FindVersions = async function findVersions(
this: MongooseAdapter,
@@ -16,19 +18,10 @@ export const findVersions: FindVersions = async function findVersions(
) {
const Model = this.versions[collection]
const collectionConfig = this.payload.collections[collection].config
const session = await getSession(this, req)
const options: QueryOptions = {
limit,
session,
skip,
}
let hasNearConstraint = false
if (where) {
const constraints = flattenWhereToOperators(where)
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
}
const hasNearConstraint = getHasNearConstraint(where)
let sort
if (!hasNearConstraint) {
@@ -44,69 +37,48 @@ export const findVersions: FindVersions = async function findVersions(
const query = await Model.buildQuery({
locale,
payload: this.payload,
session,
where,
})
const versionFields = buildVersionCollectionFields(this.payload.config, collectionConfig, true)
// useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters.
const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0
const paginationOptions: PaginateOptions = {
lean: true,
leanWithId: true,
const projection = buildProjectionFromSelect({
adapter: this,
fields: versionFields,
select,
})
const collation: CollationOptions | undefined = this.collation
? {
locale: locale && locale !== 'all' && locale !== '*' ? locale : 'en',
...this.collation,
}
: undefined
const result = await findMany({
adapter: this,
collation,
collection: Model.collection,
limit,
options,
page,
pagination,
projection: buildProjectionFromSelect({
adapter: this,
fields: buildVersionCollectionFields(this.payload.config, collectionConfig, true),
select,
}),
projection,
query,
session,
skip,
sort,
useEstimatedCount,
}
})
if (this.collation) {
const defaultLocale = 'en'
paginationOptions.collation = {
locale: locale && locale !== 'all' && locale !== '*' ? locale : defaultLocale,
...this.collation,
}
}
transform({
adapter: this,
data: result.docs,
fields: versionFields,
operation: 'read',
})
if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) {
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
// the correct indexed field
paginationOptions.useCustomCountFn = () => {
return Promise.resolve(
Model.countDocuments(query, {
hint: { _id: 1 },
session,
}),
)
}
}
if (limit >= 0) {
paginationOptions.limit = limit
// limit must also be set here, it's ignored when pagination is false
paginationOptions.options.limit = limit
// Disable pagination if limit is 0
if (limit === 0) {
paginationOptions.pagination = false
}
}
const result = await Model.paginate(query, paginationOptions)
const docs = JSON.parse(JSON.stringify(result.docs))
return {
...result,
docs: docs.map((doc) => {
doc.id = doc._id
return sanitizeInternalFields(doc)
}),
}
return result
}

View File

@@ -59,6 +59,8 @@ import { upsert } from './upsert.js'
export type { MigrateDownArgs, MigrateUpArgs } from './types.js'
export { transform } from './utilities/transform.js'
export interface Args {
/** Set to false to disable auto-pluralization of collection names, Defaults to true */
autoPluralization?: boolean

View File

@@ -1,5 +1,3 @@
import type { PayloadRequest } from 'payload'
import { commitTransaction, initTransaction, killTransaction, readMigrationFiles } from 'payload'
import prompts from 'prompts'

View File

@@ -1,23 +1,23 @@
import type { ClientSession, Model } from 'mongoose'
import type { Field, PayloadRequest, SanitizedConfig } from 'payload'
import type { Field, FlattenedField, PayloadRequest } from 'payload'
import { buildVersionCollectionFields, buildVersionGlobalFields } from 'payload'
import type { MongooseAdapter } from '../index.js'
import { getSession } from '../utilities/getSession.js'
import { sanitizeRelationshipIDs } from '../utilities/sanitizeRelationshipIDs.js'
import { transform } from '../utilities/transform.js'
const migrateModelWithBatching = async ({
adapter,
batchSize,
config,
fields,
Model,
session,
}: {
adapter: MongooseAdapter
batchSize: number
config: SanitizedConfig
fields: Field[]
fields: FlattenedField[]
Model: Model<any>
session: ClientSession
}): Promise<void> => {
@@ -47,7 +47,7 @@ const migrateModelWithBatching = async ({
}
for (const doc of docs) {
sanitizeRelationshipIDs({ config, data: doc, fields })
transform({ adapter, data: doc, fields, operation: 'update', validateRelationships: false })
}
await Model.collection.bulkWrite(
@@ -115,9 +115,9 @@ export async function migrateRelationshipsV2_V3({
payload.logger.info(`Migrating collection "${collection.slug}"`)
await migrateModelWithBatching({
adapter: db,
batchSize,
config,
fields: collection.fields,
fields: collection.flattenedFields,
Model: db.collections[collection.slug],
session,
})
@@ -128,9 +128,9 @@ export async function migrateRelationshipsV2_V3({
payload.logger.info(`Migrating collection versions "${collection.slug}"`)
await migrateModelWithBatching({
adapter: db,
batchSize,
config,
fields: buildVersionCollectionFields(config, collection),
fields: buildVersionCollectionFields(config, collection, true),
Model: db.versions[collection.slug],
session,
})
@@ -156,7 +156,13 @@ export async function migrateRelationshipsV2_V3({
// in case if the global doesn't exist in the database yet (not saved)
if (doc) {
sanitizeRelationshipIDs({ config, data: doc, fields: global.fields })
transform({
adapter: db,
data: doc,
fields: global.flattenedFields,
operation: 'update',
validateRelationships: false,
})
await GlobalsModel.collection.updateOne(
{
@@ -173,9 +179,9 @@ export async function migrateRelationshipsV2_V3({
payload.logger.info(`Migrating global versions "${global.slug}"`)
await migrateModelWithBatching({
adapter: db,
batchSize,
config,
fields: buildVersionGlobalFields(config, global),
fields: buildVersionGlobalFields(config, global, true),
Model: db.versions[global.slug],
session,
})

View File

@@ -1,3 +1,4 @@
import type { ClientSession } from 'mongodb'
import type { FlattenedField, Payload, Where } from 'payload'
import { parseParams } from './parseParams.js'
@@ -8,6 +9,7 @@ export async function buildAndOrConditions({
globalSlug,
locale,
payload,
session,
where,
}: {
collectionSlug?: string
@@ -15,6 +17,7 @@ export async function buildAndOrConditions({
globalSlug?: string
locale?: string
payload: Payload
session?: ClientSession
where: Where[]
}): Promise<Record<string, unknown>[]> {
const completedConditions = []
@@ -30,6 +33,7 @@ export async function buildAndOrConditions({
globalSlug,
locale,
payload,
session,
where: condition,
})
if (Object.keys(result).length > 0) {

View File

@@ -1,7 +1,6 @@
import type { ClientSession } from 'mongodb'
import type { FlattenedField, Payload, Where } from 'payload'
import { QueryError } from 'payload'
import { parseParams } from './parseParams.js'
type GetBuildQueryPluginArgs = {
@@ -13,6 +12,7 @@ export type BuildQueryArgs = {
globalSlug?: string
locale?: string
payload: Payload
session?: ClientSession
where: Where
}
@@ -28,6 +28,7 @@ export const getBuildQueryPlugin = ({
globalSlug,
locale,
payload,
session,
where,
}: BuildQueryArgs): Promise<Record<string, unknown>> {
let fields = versionsFields
@@ -41,20 +42,17 @@ export const getBuildQueryPlugin = ({
fields = collectionConfig.flattenedFields
}
}
const errors = []
const result = await parseParams({
collectionSlug,
fields,
globalSlug,
locale,
payload,
session,
where,
})
if (errors.length > 0) {
throw new QueryError(errors)
}
return result
}
modifiedSchema.statics.buildQuery = buildQuery

View File

@@ -1,3 +1,4 @@
import type { ClientSession, FindOptions } from 'mongodb'
import type { FlattenedField, Operator, PathToQuery, Payload } from 'payload'
import { Types } from 'mongoose'
@@ -15,9 +16,11 @@ type SearchParam = {
value?: unknown
}
const subQueryOptions = {
lean: true,
const subQueryOptions: FindOptions = {
limit: 50,
projection: {
_id: true,
},
}
/**
@@ -31,6 +34,7 @@ export async function buildSearchParam({
locale,
operator,
payload,
session,
val,
}: {
collectionSlug?: string
@@ -40,6 +44,7 @@ export async function buildSearchParam({
locale?: string
operator: string
payload: Payload
session?: ClientSession
val: unknown
}): Promise<SearchParam> {
// Replace GraphQL nested field double underscore formatting
@@ -134,17 +139,14 @@ export async function buildSearchParam({
},
})
const result = await SubModel.find(subQuery, subQueryOptions)
const result = await SubModel.collection
.find(subQuery, { session, ...subQueryOptions })
.toArray()
const $in: unknown[] = []
result.forEach((doc) => {
const stringID = doc._id.toString()
$in.push(stringID)
if (Types.ObjectId.isValid(stringID)) {
$in.push(doc._id)
}
$in.push(doc._id)
})
if (pathsToQuery.length === 1) {
@@ -162,7 +164,9 @@ export async function buildSearchParam({
}
const subQuery = priorQueryResult.value
const result = await SubModel.find(subQuery, subQueryOptions)
const result = await SubModel.collection
.find(subQuery, { session, ...subQueryOptions })
.toArray()
const $in = result.map((doc) => doc._id)

View File

@@ -11,20 +11,13 @@ type Args = {
timestamps: boolean
}
export type SortArgs = {
direction: SortDirection
property: string
}[]
export type SortDirection = 'asc' | 'desc'
export const buildSortParam = ({
config,
fields,
locale,
sort,
timestamps,
}: Args): PaginateOptions['sort'] => {
}: Args): Record<string, -1 | 1> => {
if (!sort) {
if (timestamps) {
sort = '-createdAt'
@@ -37,15 +30,15 @@ export const buildSortParam = ({
sort = [sort]
}
const sorting = sort.reduce<PaginateOptions['sort']>((acc, item) => {
const sorting = sort.reduce<Record<string, -1 | 1>>((acc, item) => {
let sortProperty: string
let sortDirection: SortDirection
let sortDirection: -1 | 1
if (item.indexOf('-') === 0) {
sortProperty = item.substring(1)
sortDirection = 'desc'
sortDirection = -1
} else {
sortProperty = item
sortDirection = 'asc'
sortDirection = 1
}
if (sortProperty === 'id') {
acc['_id'] = sortDirection

View File

@@ -1,6 +1,6 @@
import type { FlattenedField, SanitizedConfig } from 'payload'
import { fieldAffectsData, fieldIsPresentationalOnly } from 'payload/shared'
import { fieldAffectsData } from 'payload/shared'
type Args = {
config: SanitizedConfig
@@ -33,7 +33,7 @@ export const getLocalizedSortProperty = ({
(field) => fieldAffectsData(field) && field.name === firstSegment,
)
if (matchedField && !fieldIsPresentationalOnly(matchedField)) {
if (matchedField) {
let nextFields: FlattenedField[]
const remainingSegments = [...segments]
let localizedSegment = matchedField.name

View File

@@ -1,3 +1,4 @@
import type { ClientSession } from 'mongodb'
import type { FilterQuery } from 'mongoose'
import type { FlattenedField, Operator, Payload, Where } from 'payload'
@@ -13,6 +14,7 @@ export async function parseParams({
globalSlug,
locale,
payload,
session,
where,
}: {
collectionSlug?: string
@@ -20,6 +22,7 @@ export async function parseParams({
globalSlug?: string
locale: string
payload: Payload
session?: ClientSession
where: Where
}): Promise<Record<string, unknown>> {
let result = {} as FilterQuery<any>
@@ -62,6 +65,7 @@ export async function parseParams({
locale,
operator,
payload,
session,
val: pathOperators[operator],
})

View File

@@ -37,6 +37,22 @@ const buildExistsQuery = (formattedValue, path, treatEmptyString = true) => {
}
}
const sanitizeCoordinates = (coordinates: unknown[]): unknown[] => {
const result: unknown[] = []
for (const value of coordinates) {
if (typeof value === 'string') {
result.push(Number(value))
} else if (Array.isArray(value)) {
result.push(sanitizeCoordinates(value))
} else {
result.push(value)
}
}
return result
}
// returns nestedField Field object from blocks.nestedField path because getLocalizedPaths splits them only for relationships
const getFieldFromSegments = ({
field,
@@ -359,6 +375,14 @@ export const sanitizeQueryValue = ({
}
if (operator === 'within' || operator === 'intersects') {
if (
formattedValue &&
typeof formattedValue === 'object' &&
Array.isArray(formattedValue.coordinates)
) {
formattedValue.coordinates = sanitizeCoordinates(formattedValue.coordinates)
}
formattedValue = {
$geometry: formattedValue,
}

View File

@@ -1,15 +1,17 @@
import type { PaginateOptions, QueryOptions } from 'mongoose'
import type { CollationOptions } from 'mongodb'
import type { QueryDrafts } from 'payload'
import { buildVersionCollectionFields, combineQueries, flattenWhereToOperators } from 'payload'
import { buildVersionCollectionFields, combineQueries } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildSortParam } from './queries/buildSortParam.js'
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { findMany } from './utilities/findMany.js'
import { getHasNearConstraint } from './utilities/getHasNearConstraint.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { transform } from './utilities/transform.js'
export const queryDrafts: QueryDrafts = async function queryDrafts(
this: MongooseAdapter,
@@ -17,18 +19,11 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(
) {
const VersionModel = this.versions[collection]
const collectionConfig = this.payload.collections[collection].config
const options: QueryOptions = {
session: await getSession(this, req),
}
const session = await getSession(this, req)
let hasNearConstraint
const hasNearConstraint = getHasNearConstraint(where)
let sort
if (where) {
const constraints = flattenWhereToOperators(where)
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
}
if (!hasNearConstraint) {
sort = buildSortParam({
config: this.payload.config,
@@ -44,95 +39,65 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(
const versionQuery = await VersionModel.buildQuery({
locale,
payload: this.payload,
session,
where: combinedWhere,
})
const versionFields = buildVersionCollectionFields(this.payload.config, collectionConfig, true)
const projection = buildProjectionFromSelect({
adapter: this,
fields: buildVersionCollectionFields(this.payload.config, collectionConfig, true),
fields: versionFields,
select,
})
// useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters.
const useEstimatedCount =
hasNearConstraint || !versionQuery || Object.keys(versionQuery).length === 0
const paginationOptions: PaginateOptions = {
lean: true,
leanWithId: true,
options,
page,
pagination,
projection,
sort,
useEstimatedCount,
}
if (this.collation) {
const defaultLocale = 'en'
paginationOptions.collation = {
locale: locale && locale !== 'all' && locale !== '*' ? locale : defaultLocale,
...this.collation,
}
}
const collation: CollationOptions | undefined = this.collation
? {
locale: locale && locale !== 'all' && locale !== '*' ? locale : 'en',
...this.collation,
}
: undefined
if (
!useEstimatedCount &&
Object.keys(versionQuery).length === 0 &&
this.disableIndexHints !== true
) {
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
// the correct indexed field
paginationOptions.useCustomCountFn = () => {
return Promise.resolve(
VersionModel.countDocuments(versionQuery, {
hint: { _id: 1 },
}),
)
}
}
if (limit > 0) {
paginationOptions.limit = limit
// limit must also be set here, it's ignored when pagination is false
paginationOptions.options.limit = limit
}
let result
const aggregate = await buildJoinAggregation({
const joinAgreggation = await buildJoinAggregation({
adapter: this,
collection,
collectionConfig,
joins,
locale,
projection,
query: versionQuery,
session,
versions: true,
})
// build join aggregation
if (aggregate) {
result = await VersionModel.aggregatePaginate(
VersionModel.aggregate(aggregate),
paginationOptions,
)
} else {
result = await VersionModel.paginate(versionQuery, paginationOptions)
const result = await findMany({
adapter: this,
collation,
collection: VersionModel.collection,
joinAgreggation,
limit,
page,
pagination,
projection,
query: versionQuery,
session,
sort,
useEstimatedCount,
})
transform({
adapter: this,
data: result.docs,
fields: versionFields,
operation: 'read',
})
for (let i = 0; i < result.docs.length; i++) {
const id = result.docs[i].parent
result.docs[i] = result.docs[i].version
result.docs[i].id = id
}
const docs = JSON.parse(JSON.stringify(result.docs))
return {
...result,
docs: docs.map((doc) => {
doc = {
_id: doc.parent,
id: doc.parent,
...doc.version,
}
return sanitizeInternalFields(doc)
}),
}
return result
}

View File

@@ -1,47 +1,45 @@
import type { QueryOptions } from 'mongoose'
import type { UpdateGlobal } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export const updateGlobal: UpdateGlobal = async function updateGlobal(
this: MongooseAdapter,
{ slug, data, options: optionsArgs = {}, req, select },
) {
const Model = this.globals
const fields = this.payload.config.globals.find((global) => global.slug === slug).fields
const fields = this.payload.config.globals.find((global) => global.slug === slug).flattenedFields
const options: QueryOptions = {
...optionsArgs,
lean: true,
new: true,
projection: buildProjectionFromSelect({
adapter: this,
fields: this.payload.config.globals.find((global) => global.slug === slug).flattenedFields,
select,
}),
session: await getSession(this, req),
}
const session = await getSession(this, req)
let result
const sanitizedData = sanitizeRelationshipIDs({
config: this.payload.config,
transform({
adapter: this,
data,
fields,
operation: 'update',
timestamps: optionsArgs.timestamps !== false,
})
result = await Model.findOneAndUpdate({ globalType: slug }, sanitizedData, options)
const result: any = await Model.collection.findOneAndUpdate(
{ globalType: slug },
{ $set: data },
{
...optionsArgs,
projection: buildProjectionFromSelect({ adapter: this, fields, select }),
returnDocument: 'after',
session,
},
)
result = JSON.parse(JSON.stringify(result))
// custom id type reset
result.id = result._id
result = sanitizeInternalFields(result)
transform({
adapter: this,
data: result,
fields,
operation: 'read',
})
return result
}

View File

@@ -1,12 +1,10 @@
import type { QueryOptions } from 'mongoose'
import { buildVersionGlobalFields, type TypeWithID, type UpdateGlobalVersionArgs } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export async function updateGlobalVersion<T extends TypeWithID>(
this: MongooseAdapter,
@@ -23,44 +21,50 @@ export async function updateGlobalVersion<T extends TypeWithID>(
) {
const VersionModel = this.versions[globalSlug]
const whereToUse = where || { id: { equals: id } }
const fields = buildVersionGlobalFields(
this.payload.config,
this.payload.config.globals.find((global) => global.slug === globalSlug),
true,
)
const currentGlobal = this.payload.config.globals.find((global) => global.slug === globalSlug)
const fields = buildVersionGlobalFields(this.payload.config, currentGlobal)
const options: QueryOptions = {
...optionsArgs,
lean: true,
new: true,
projection: buildProjectionFromSelect({
adapter: this,
fields: buildVersionGlobalFields(this.payload.config, currentGlobal, true),
select,
}),
session: await getSession(this, req),
}
const session = await getSession(this, req)
const query = await VersionModel.buildQuery({
locale,
payload: this.payload,
session,
where: whereToUse,
})
const sanitizedData = sanitizeRelationshipIDs({
config: this.payload.config,
transform({
adapter: this,
data: versionData,
fields,
operation: 'update',
timestamps: optionsArgs.timestamps !== false,
})
const doc = await VersionModel.findOneAndUpdate(query, sanitizedData, options)
const doc: any = await VersionModel.collection.findOneAndUpdate(
query,
{ $set: versionData },
{
...optionsArgs,
projection: buildProjectionFromSelect({
adapter: this,
fields,
select,
}),
returnDocument: 'after',
session,
},
)
const result = JSON.parse(JSON.stringify(doc))
transform({
adapter: this,
data: doc,
fields,
operation: 'read',
})
const verificationToken = doc._verificationToken
// custom id type reset
result.id = result._id
if (verificationToken) {
result._verificationToken = verificationToken
}
return result
return doc
}

View File

@@ -1,4 +1,3 @@
import type { QueryOptions } from 'mongoose'
import type { UpdateOne } from 'payload'
import type { MongooseAdapter } from './index.js'
@@ -6,8 +5,7 @@ import type { MongooseAdapter } from './index.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { handleError } from './utilities/handleError.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export const updateOne: UpdateOne = async function updateOne(
this: MongooseAdapter,
@@ -15,42 +13,45 @@ export const updateOne: UpdateOne = async function updateOne(
) {
const where = id ? { id: { equals: id } } : whereArg
const Model = this.collections[collection]
const fields = this.payload.collections[collection].config.fields
const options: QueryOptions = {
...optionsArgs,
lean: true,
new: true,
projection: buildProjectionFromSelect({
adapter: this,
fields: this.payload.collections[collection].config.flattenedFields,
select,
}),
session: await getSession(this, req),
}
const fields = this.payload.collections[collection].config.flattenedFields
const session = await getSession(this, req)
const query = await Model.buildQuery({
locale,
payload: this.payload,
session,
where,
})
let result
const sanitizedData = sanitizeRelationshipIDs({
config: this.payload.config,
transform({
adapter: this,
data,
fields,
operation: 'update',
timestamps: optionsArgs.timestamps !== false,
})
try {
result = await Model.findOneAndUpdate(query, sanitizedData, options)
const result = await Model.collection.findOneAndUpdate(
query,
{ $set: data },
{
...optionsArgs,
projection: buildProjectionFromSelect({ adapter: this, fields, select }),
returnDocument: 'after',
session,
},
)
transform({
adapter: this,
data: result,
fields,
operation: 'read',
})
return result
} catch (error) {
handleError({ collection, error, req })
}
result = JSON.parse(JSON.stringify(result))
result.id = result._id
result = sanitizeInternalFields(result)
return result
}

View File

@@ -1,12 +1,10 @@
import type { QueryOptions } from 'mongoose'
import { buildVersionCollectionFields, type UpdateVersion } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export const updateVersion: UpdateVersion = async function updateVersion(
this: MongooseAdapter,
@@ -17,46 +15,51 @@ export const updateVersion: UpdateVersion = async function updateVersion(
const fields = buildVersionCollectionFields(
this.payload.config,
this.payload.collections[collection].config,
true,
)
const options: QueryOptions = {
...optionsArgs,
lean: true,
new: true,
projection: buildProjectionFromSelect({
adapter: this,
fields: buildVersionCollectionFields(
this.payload.config,
this.payload.collections[collection].config,
true,
),
select,
}),
session: await getSession(this, req),
}
const session = await getSession(this, req)
const query = await VersionModel.buildQuery({
locale,
payload: this.payload,
session,
where: whereToUse,
})
const sanitizedData = sanitizeRelationshipIDs({
config: this.payload.config,
transform({
adapter: this,
data: versionData,
fields,
operation: 'update',
timestamps: optionsArgs.timestamps !== false,
})
const doc = await VersionModel.findOneAndUpdate(query, sanitizedData, options)
const doc = await VersionModel.collection.findOneAndUpdate(
query,
{ $set: versionData },
{
...optionsArgs,
projection: buildProjectionFromSelect({
adapter: this,
fields: buildVersionCollectionFields(
this.payload.config,
this.payload.collections[collection].config,
true,
),
select,
}),
returnDocument: 'after',
session,
},
)
const result = JSON.parse(JSON.stringify(doc))
transform({
adapter: this,
data: doc,
fields,
operation: 'read',
})
const verificationToken = doc._verificationToken
// custom id type reset
result.id = result._id
if (verificationToken) {
result._verificationToken = verificationToken
}
return result
return doc as any
}

View File

@@ -1,3 +1,4 @@
import type { ClientSession } from 'mongodb'
import type { PipelineStage } from 'mongoose'
import type { CollectionSlug, JoinQuery, SanitizedCollectionConfig, Where } from 'payload'
@@ -10,12 +11,9 @@ type BuildJoinAggregationArgs = {
collection: CollectionSlug
collectionConfig: SanitizedCollectionConfig
joins: JoinQuery
// the number of docs to get at the top collection level
limit?: number
locale: string
projection?: Record<string, true>
// the where clause for the top collection
query?: Where
session?: ClientSession
/** whether the query is from drafts */
versions?: boolean
}
@@ -25,10 +23,9 @@ export const buildJoinAggregation = async ({
collection,
collectionConfig,
joins,
limit,
locale,
projection,
query,
session,
versions,
}: BuildJoinAggregationArgs): Promise<PipelineStage[] | undefined> => {
if (Object.keys(collectionConfig.joins).length === 0 || joins === false) {
@@ -36,23 +33,7 @@ export const buildJoinAggregation = async ({
}
const joinConfig = adapter.payload.collections[collection].config.joins
const aggregate: PipelineStage[] = [
{
$sort: { createdAt: -1 },
},
]
if (query) {
aggregate.push({
$match: query,
})
}
if (limit) {
aggregate.push({
$limit: limit,
})
}
const aggregate: PipelineStage[] = []
for (const slug of Object.keys(joinConfig)) {
for (const join of joinConfig[slug]) {
@@ -72,26 +53,25 @@ export const buildJoinAggregation = async ({
where: whereJoin,
} = joins?.[join.joinPath] || {}
const sort = buildSortParam({
const $sort = buildSortParam({
config: adapter.payload.config,
fields: adapter.payload.collections[slug].config.flattenedFields,
locale,
sort: sortJoin,
timestamps: true,
})
const sortProperty = Object.keys(sort)[0]
const sortDirection = sort[sortProperty] === 'asc' ? 1 : -1
const $match = await joinModel.buildQuery({
locale,
payload: adapter.payload,
session,
where: whereJoin,
})
const pipeline: Exclude<PipelineStage, PipelineStage.Merge | PipelineStage.Out>[] = [
{ $match },
{
$sort: { [sortProperty]: sortDirection },
$sort,
},
]
@@ -184,8 +164,8 @@ export const buildJoinAggregation = async ({
}
}
if (projection) {
aggregate.push({ $project: projection })
if (!aggregate.length) {
return
}
return aggregate

View File

@@ -1,4 +1,4 @@
import type { FieldAffectingData, FlattenedField, SelectMode, SelectType } from 'payload'
import type { Field, FieldAffectingData, FlattenedField, SelectMode, SelectType } from 'payload'
import { deepCopyObjectSimple, fieldAffectsData, getSelectMode } from 'payload/shared'
@@ -29,6 +29,11 @@ const addFieldToProjection = ({
}
}
const blockTypeField: Field = {
name: 'blockType',
type: 'text',
}
const traverseFields = ({
adapter,
databaseSchemaPath = '',
@@ -128,6 +133,14 @@ const traverseFields = ({
(selectMode === 'include' && blocksSelect[block.slug] === true) ||
(selectMode === 'exclude' && typeof blocksSelect[block.slug] === 'undefined')
) {
addFieldToProjection({
adapter,
databaseSchemaPath: fieldDatabaseSchemaPath,
field: blockTypeField,
projection,
withinLocalizedField: fieldWithinLocalizedField,
})
traverseFields({
adapter,
databaseSchemaPath: fieldDatabaseSchemaPath,
@@ -153,7 +166,13 @@ const traverseFields = ({
if (blockSelectMode === 'include') {
blocksSelect[block.slug]['id'] = true
blocksSelect[block.slug]['blockType'] = true
addFieldToProjection({
adapter,
databaseSchemaPath: fieldDatabaseSchemaPath,
field: blockTypeField,
projection,
withinLocalizedField: fieldWithinLocalizedField,
})
}
traverseFields({

View File

@@ -0,0 +1,128 @@
import type { ClientSession, CollationOptions, Collection, Document } from 'mongodb'
import type { PipelineStage } from 'mongoose'
import type { PaginatedDocs } from 'payload'
import type { MongooseAdapter } from '../index.js'
export const findMany = async ({
adapter,
collation,
collection,
joinAgreggation,
limit,
page = 1,
pagination,
projection,
query = {},
session,
skip,
sort,
useEstimatedCount,
}: {
adapter: MongooseAdapter
collation?: CollationOptions
collection: Collection
joinAgreggation?: PipelineStage[]
limit?: number
page?: number
pagination?: boolean
projection?: Record<string, unknown>
query?: Record<string, unknown>
session?: ClientSession
skip?: number
sort?: Record<string, -1 | 1>
useEstimatedCount?: boolean
}): Promise<PaginatedDocs> => {
if (!skip) {
skip = (page - 1) * (limit ?? 0)
}
let docsPromise: Promise<Document[]>
let countPromise: Promise<null | number> = Promise.resolve(null)
if (joinAgreggation) {
const aggregation = collection.aggregate(
[
{
$match: query,
},
],
{ collation, session },
)
if (sort) {
aggregation.sort(sort)
}
if (skip) {
aggregation.skip(skip)
}
if (limit) {
aggregation.limit(limit)
}
for (const stage of joinAgreggation) {
aggregation.addStage(stage)
}
if (projection) {
aggregation.project(projection)
}
docsPromise = aggregation.toArray()
} else {
docsPromise = collection
.find(query, {
collation,
limit,
projection,
session,
skip,
sort,
})
.toArray()
}
if (pagination !== false && limit) {
if (useEstimatedCount) {
countPromise = collection.estimatedDocumentCount()
} else {
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
// the correct indexed field
const hint = adapter.disableIndexHints !== true ? { _id: 1 } : undefined
countPromise = collection.countDocuments(query, { collation, hint, session })
}
}
const [docs, countResult] = await Promise.all([docsPromise, countPromise])
const count = countResult === null ? docs.length : countResult
const totalPages =
pagination !== false && typeof limit === 'number' && limit !== 0 ? Math.ceil(count / limit) : 1
const hasPrevPage = pagination !== false && page > 1
const hasNextPage = pagination !== false && totalPages > page
const pagingCounter =
pagination !== false && typeof limit === 'number' ? (page - 1) * limit + 1 : 1
const result = {
docs,
hasNextPage,
hasPrevPage,
limit,
nextPage: hasNextPage ? page + 1 : null,
page,
pagingCounter,
prevPage: hasPrevPage ? page - 1 : null,
totalDocs: count,
totalPages,
} as PaginatedDocs<Record<string, unknown>>
return result
}

View File

@@ -0,0 +1,27 @@
import type { Where } from 'payload'
export const getHasNearConstraint = (where?: Where): boolean => {
if (!where) {
return false
}
for (const key in where) {
const value = where[key]
if (Array.isArray(value) && ['AND', 'OR'].includes(key.toUpperCase())) {
for (const where of value) {
if (getHasNearConstraint(where)) {
return true
}
}
}
for (const key in value) {
if (key === 'near') {
return true
}
}
}
return false
}

View File

@@ -1,20 +0,0 @@
const internalFields = ['__v']
export const sanitizeInternalFields = <T extends Record<string, unknown>>(incomingDoc: T): T =>
Object.entries(incomingDoc).reduce((newDoc, [key, val]): T => {
if (key === '_id') {
return {
...newDoc,
id: val,
}
}
if (internalFields.indexOf(key) > -1) {
return newDoc
}
return {
...newDoc,
[key]: val,
}
}, {} as T)

View File

@@ -1,156 +0,0 @@
import type { CollectionConfig, Field, SanitizedConfig, TraverseFieldsCallback } from 'payload'
import { Types } from 'mongoose'
import { APIError, traverseFields } from 'payload'
import { fieldAffectsData } from 'payload/shared'
type Args = {
config: SanitizedConfig
data: Record<string, unknown>
fields: Field[]
}
interface RelationObject {
relationTo: string
value: number | string
}
function isValidRelationObject(value: unknown): value is RelationObject {
return typeof value === 'object' && value !== null && 'relationTo' in value && 'value' in value
}
const convertValue = ({
relatedCollection,
value,
}: {
relatedCollection: CollectionConfig
value: number | string
}): number | string | Types.ObjectId => {
const customIDField = relatedCollection.fields.find(
(field) => fieldAffectsData(field) && field.name === 'id',
)
if (customIDField) {
return value
}
try {
return new Types.ObjectId(value)
} catch {
return value
}
}
const sanitizeRelationship = ({ config, field, locale, ref, value }) => {
let relatedCollection: CollectionConfig | undefined
let result = value
const hasManyRelations = typeof field.relationTo !== 'string'
if (!hasManyRelations) {
relatedCollection = config.collections?.find(({ slug }) => slug === field.relationTo)
}
if (Array.isArray(value)) {
result = value.map((val) => {
// Handle has many
if (relatedCollection && val && (typeof val === 'string' || typeof val === 'number')) {
return convertValue({
relatedCollection,
value: val,
})
}
// Handle has many - polymorphic
if (isValidRelationObject(val)) {
const relatedCollectionForSingleValue = config.collections?.find(
({ slug }) => slug === val.relationTo,
)
if (relatedCollectionForSingleValue) {
return {
relationTo: val.relationTo,
value: convertValue({
relatedCollection: relatedCollectionForSingleValue,
value: val.value,
}),
}
}
}
return val
})
}
// Handle has one - polymorphic
if (isValidRelationObject(value)) {
relatedCollection = config.collections?.find(({ slug }) => slug === value.relationTo)
if (relatedCollection) {
result = {
relationTo: value.relationTo,
value: convertValue({ relatedCollection, value: value.value }),
}
}
}
// Handle has one
if (relatedCollection && value && (typeof value === 'string' || typeof value === 'number')) {
result = convertValue({
relatedCollection,
value,
})
}
if (locale) {
ref[locale] = result
} else {
ref[field.name] = result
}
}
export const sanitizeRelationshipIDs = ({
config,
data,
fields,
}: Args): Record<string, unknown> => {
const sanitize: TraverseFieldsCallback = ({ field, ref }) => {
if (!ref || typeof ref !== 'object') {
return
}
if (field.type === 'relationship' || field.type === 'upload') {
if (!ref[field.name]) {
return
}
// handle localized relationships
if (config.localization && field.localized) {
const locales = config.localization.locales
const fieldRef = ref[field.name]
if (typeof fieldRef !== 'object') {
return
}
for (const { code } of locales) {
const value = ref[field.name][code]
if (value) {
sanitizeRelationship({ config, field, locale: code, ref: fieldRef, value })
}
}
} else {
// handle non-localized relationships
sanitizeRelationship({
config,
field,
locale: undefined,
ref,
value: ref[field.name],
})
}
}
}
traverseFields({ callback: sanitize, fields, fillEmpty: false, ref: data })
return data
}

View File

@@ -1,8 +1,9 @@
import type { Field, SanitizedConfig } from 'payload'
import { flattenAllFields, type Field, type SanitizedConfig } from 'payload'
import { Types } from 'mongoose'
import { sanitizeRelationshipIDs } from './sanitizeRelationshipIDs.js'
import { transform } from './transform.js'
import { MongooseAdapter } from '..'
const flattenRelationshipValues = (obj: Record<string, any>, prefix = ''): Record<string, any> => {
return Object.keys(obj).reduce(
@@ -271,8 +272,8 @@ const relsData = {
},
}
describe('sanitizeRelationshipIDs', () => {
it('should sanitize relationships', () => {
describe('transform', () => {
it('should sanitize relationships with transform write', () => {
const data = {
...relsData,
array: [
@@ -348,12 +349,19 @@ describe('sanitizeRelationshipIDs', () => {
}
const flattenValuesBefore = Object.values(flattenRelationshipValues(data))
sanitizeRelationshipIDs({ config, data, fields: config.collections[0].fields })
const mockAdapter = { payload: { config } } as MongooseAdapter
const fields = flattenAllFields({ fields: config.collections[0].fields })
transform({ type: 'write', adapter: mockAdapter, data, fields })
const flattenValuesAfter = Object.values(flattenRelationshipValues(data))
flattenValuesAfter.forEach((value, i) => {
expect(value).toBeInstanceOf(Types.ObjectId)
expect(flattenValuesBefore[i]).toBe(value.toHexString())
})
transform({ type: 'read', adapter: mockAdapter, data, fields })
})
})

View File

@@ -0,0 +1,385 @@
import type {
CollectionConfig,
DateField,
FlattenedField,
JoinField,
RelationshipField,
SanitizedConfig,
TraverseFlattenedFieldsCallback,
UploadField,
} from 'payload'
import { Types } from 'mongoose'
import { traverseFields } from 'payload'
import { fieldAffectsData, fieldIsVirtual } from 'payload/shared'
import type { MongooseAdapter } from '../index.js'
type Args = {
adapter: MongooseAdapter
data: Record<string, unknown> | Record<string, unknown>[]
fields: FlattenedField[]
globalSlug?: string
operation: 'create' | 'read' | 'update'
/**
* Set updatedAt and createdAt
* @default true
*/
timestamps?: boolean
/**
* Throw errors on invalid relationships
* @default true
*/
validateRelationships?: boolean
}
interface RelationObject {
relationTo: string
value: number | string
}
function isValidRelationObject(value: unknown): value is RelationObject {
return typeof value === 'object' && value !== null && 'relationTo' in value && 'value' in value
}
const convertRelationshipValue = ({
operation,
relatedCollection,
validateRelationships,
value,
}: {
operation: Args['operation']
relatedCollection: CollectionConfig
validateRelationships?: boolean
value: unknown
}) => {
const customIDField = relatedCollection.fields.find(
(field) => fieldAffectsData(field) && field.name === 'id',
)
if (operation === 'read') {
if (value instanceof Types.ObjectId) {
return value.toHexString()
}
return value
}
if (customIDField) {
return value
}
if (typeof value === 'string') {
try {
return new Types.ObjectId(value)
} catch (e) {
if (validateRelationships) {
throw e
}
return value
}
}
return value
}
const sanitizeRelationship = ({
config,
field,
locale,
operation,
ref,
validateRelationships,
value,
}: {
config: SanitizedConfig
field: JoinField | RelationshipField | UploadField
locale?: string
operation: Args['operation']
ref: Record<string, unknown>
validateRelationships?: boolean
value?: unknown
}) => {
if (field.type === 'join') {
if (
operation === 'read' &&
value &&
typeof value === 'object' &&
'docs' in value &&
Array.isArray(value.docs)
) {
for (let i = 0; i < value.docs.length; i++) {
const item = value.docs[i]
if (item instanceof Types.ObjectId) {
value.docs[i] = item.toHexString()
}
}
}
return value
}
let relatedCollection: CollectionConfig | undefined
let result = value
const hasManyRelations = typeof field.relationTo !== 'string'
if (!hasManyRelations) {
relatedCollection = config.collections?.find(({ slug }) => slug === field.relationTo)
}
if (Array.isArray(value)) {
result = value.map((val) => {
// Handle has many - polymorphic
if (isValidRelationObject(val)) {
const relatedCollectionForSingleValue = config.collections?.find(
({ slug }) => slug === val.relationTo,
)
if (relatedCollectionForSingleValue) {
return {
relationTo: val.relationTo,
value: convertRelationshipValue({
operation,
relatedCollection: relatedCollectionForSingleValue,
validateRelationships,
value: val.value,
}),
}
}
}
if (relatedCollection) {
return convertRelationshipValue({
operation,
relatedCollection,
validateRelationships,
value: val,
})
}
return val
})
}
// Handle has one - polymorphic
else if (isValidRelationObject(value)) {
relatedCollection = config.collections?.find(({ slug }) => slug === value.relationTo)
if (relatedCollection) {
result = {
relationTo: value.relationTo,
value: convertRelationshipValue({
operation,
relatedCollection,
validateRelationships,
value: value.value,
}),
}
}
}
// Handle has one
else if (relatedCollection) {
result = convertRelationshipValue({
operation,
relatedCollection,
validateRelationships,
value,
})
}
if (locale) {
ref[locale] = result
} else {
ref[field.name] = result
}
}
/**
* When sending data to Payload - convert Date to string.
* Vice versa when sending data to MongoDB so dates are stored properly.
*/
const sanitizeDate = ({
field,
locale,
operation,
ref,
value,
}: {
field: DateField
locale?: string
operation: Args['operation']
ref: Record<string, unknown>
value: unknown
}) => {
if (!value) {
return
}
if (operation === 'read') {
if (value instanceof Date) {
value = value.toISOString()
}
} else {
if (typeof value === 'string') {
value = new Date(value)
}
}
if (locale) {
ref[locale] = value
} else {
ref[field.name] = value
}
}
/**
* @experimental This API can be changed without a major version bump.
*/
export const transform = ({
adapter,
data,
fields,
globalSlug,
operation,
timestamps = true,
validateRelationships = true,
}: Args) => {
if (Array.isArray(data)) {
for (let i = 0; i < data.length; i++) {
transform({ adapter, data: data[i], fields, globalSlug, operation, validateRelationships })
}
return
}
const {
payload: { config },
} = adapter
if (operation === 'read') {
delete data['__v']
data.id = data._id
delete data['_id']
if (data.id instanceof Types.ObjectId) {
data.id = data.id.toHexString()
}
}
if (operation !== 'read') {
if (timestamps) {
if (operation === 'create' && !data.createdAt) {
data.createdAt = new Date()
}
data.updatedAt = new Date()
}
if (globalSlug) {
data.globalType = globalSlug
}
}
const sanitize: TraverseFlattenedFieldsCallback = ({ field, ref }) => {
if (!ref || typeof ref !== 'object') {
return
}
if (operation !== 'read') {
if (
typeof ref[field.name] === 'undefined' &&
typeof field.defaultValue !== 'undefined' &&
typeof field.defaultValue !== 'function'
) {
if (field.type === 'point') {
ref[field.name] = {
type: 'Point',
coordinates: field.defaultValue,
}
} else {
ref[field.name] = field.defaultValue
}
}
if (fieldIsVirtual(field)) {
delete ref[field.name]
return
}
}
if (field.type === 'date') {
if (config.localization && field.localized) {
const fieldRef = ref[field.name]
if (!fieldRef || typeof fieldRef !== 'object') {
return
}
for (const locale of config.localization.localeCodes) {
sanitizeDate({
field,
operation,
ref: fieldRef,
value: fieldRef[locale],
})
}
} else {
sanitizeDate({
field,
operation,
ref: ref as Record<string, unknown>,
value: ref[field.name],
})
}
}
if (
field.type === 'relationship' ||
field.type === 'upload' ||
(operation === 'read' && field.type === 'join')
) {
// sanitize passed undefined in objects to null
if (operation !== 'read' && field.name in ref && ref[field.name] === undefined) {
ref[field.name] = null
}
if (!ref[field.name]) {
return
}
// handle localized relationships
if (config.localization && field.localized) {
const locales = config.localization.locales
const fieldRef = ref[field.name]
if (typeof fieldRef !== 'object') {
return
}
for (const { code } of locales) {
const value = ref[field.name][code]
if (value) {
sanitizeRelationship({
config,
field,
locale: code,
operation,
ref: fieldRef,
validateRelationships,
value,
})
}
}
} else {
// handle non-localized relationships
sanitizeRelationship({
config,
field,
locale: undefined,
operation,
ref: ref as Record<string, unknown>,
validateRelationships,
value: ref[field.name],
})
}
}
}
traverseFields({ callback: sanitize, fillEmpty: false, flattenedFields: fields, ref: data })
}

View File

@@ -126,6 +126,11 @@ export const promise = async <T>({
case 'point': {
if (Array.isArray(siblingData[field.name])) {
if ((siblingData[field.name] as string[]).some((val) => val === null || val === '')) {
siblingData[field.name] = null
break
}
siblingData[field.name] = (siblingData[field.name] as string[]).map((coordinate, i) => {
if (typeof coordinate === 'string') {
const value = siblingData[field.name][i] as string

View File

@@ -875,7 +875,12 @@ export type PointFieldValidation = Validate<
PointField
>
export const point: PointFieldValidation = (value = ['', ''], { req: { t }, required }) => {
export const point: PointFieldValidation = (value, { req: { t }, required }) => {
// Allow to pass null to clear the field
if (!value) {
value = ['', '']
}
const lng = parseFloat(String(value[0]))
const lat = parseFloat(String(value[1]))
if (

View File

@@ -1362,7 +1362,10 @@ export { sanitizeJoinParams } from './utilities/sanitizeJoinParams.js'
export { sanitizePopulateParam } from './utilities/sanitizePopulateParam.js'
export { sanitizeSelectParam } from './utilities/sanitizeSelectParam.js'
export { traverseFields } from './utilities/traverseFields.js'
export type { TraverseFieldsCallback } from './utilities/traverseFields.js'
export type {
TraverseFieldsCallback,
TraverseFlattenedFieldsCallback,
} from './utilities/traverseFields.js'
export { buildVersionCollectionFields } from './versions/buildCollectionFields.js'
export { buildVersionGlobalFields } from './versions/buildGlobalFields.js'
export { versionDefaults } from './versions/defaults.js'

View File

@@ -1,4 +1,12 @@
import type { ArrayField, BlocksField, Field, TabAsField } from '../fields/config/types.js'
import type {
ArrayField,
BlocksField,
Field,
FlattenedArrayField,
FlattenedBlock,
FlattenedField,
TabAsField,
} from '../fields/config/types.js'
import { fieldHasSubFields } from '../fields/config/types.js'
@@ -12,7 +20,7 @@ const traverseArrayOrBlocksField = ({
callback: TraverseFieldsCallback
data: Record<string, unknown>[]
field: ArrayField | BlocksField
fillEmpty: boolean
fillEmpty?: boolean
parentRef?: unknown
}) => {
if (fillEmpty) {
@@ -28,20 +36,23 @@ const traverseArrayOrBlocksField = ({
}
for (const ref of data) {
let fields: Field[]
let flattenedFields: FlattenedField[]
if (field.type === 'blocks' && typeof ref?.blockType === 'string') {
const block = field.blocks.find((block) => block.slug === ref.blockType)
const block = field.blocks.find((block) => block.slug === ref.blockType) as FlattenedBlock
fields = block?.fields
flattenedFields = block?.flattenedFields
} else if (field.type === 'array') {
fields = field.fields
flattenedFields = (field as FlattenedArrayField)?.flattenedFields
}
if (fields) {
traverseFields({ callback, fields, fillEmpty, parentRef, ref })
if (flattenedFields || fields) {
traverseFields({ callback, fields, fillEmpty, flattenedFields, parentRef, ref })
}
}
}
export type TraverseFieldsCallback = (args: {
type TraverseFieldsCallbackArgs = {
/**
* The current field
*/
@@ -58,12 +69,45 @@ export type TraverseFieldsCallback = (args: {
* The current reference object
*/
ref?: Record<string, unknown> | unknown
}
export type TraverseFieldsCallback = (args: TraverseFieldsCallbackArgs) => boolean | void
export type TraverseFlattenedFieldsCallback = (args: {
/**
* The current field
*/
field: FlattenedField
/**
* Function that when called will skip the current field and continue to the next
*/
next?: () => void
/**
* The parent reference object
*/
parentRef?: Record<string, unknown> | unknown
/**
* The current reference object
*/
ref?: Record<string, unknown> | unknown
}) => boolean | void
type TraverseFlattenedFieldsArgs = {
callback: TraverseFlattenedFieldsCallback
fields?: Field[]
/** fill empty properties to use this without data */
fillEmpty?: boolean
flattenedFields: FlattenedField[]
parentRef?: Record<string, unknown> | unknown
ref?: Record<string, unknown> | unknown
}
type TraverseFieldsArgs = {
callback: TraverseFieldsCallback
fields: (Field | TabAsField)[]
fields: (Field | FlattenedField | TabAsField)[]
/** fill empty properties to use this without data */
fillEmpty?: boolean
flattenedFields?: FlattenedField[]
parentRef?: Record<string, unknown> | unknown
ref?: Record<string, unknown> | unknown
}
@@ -81,10 +125,11 @@ export const traverseFields = ({
callback,
fields,
fillEmpty = true,
flattenedFields,
parentRef = {},
ref = {},
}: TraverseFieldsArgs): void => {
fields.some((field) => {
}: TraverseFieldsArgs | TraverseFlattenedFieldsArgs): void => {
;(flattenedFields ?? fields).some((field) => {
let skip = false
const next = () => {
skip = true
@@ -94,7 +139,16 @@ export const traverseFields = ({
return
}
if (callback && callback({ field, next, parentRef, ref })) {
if (
callback &&
callback({
// @ts-expect-error compatibillity Field | FlattenedField
field,
next,
parentRef,
ref,
})
) {
return true
}
@@ -139,6 +193,7 @@ export const traverseFields = ({
if (
callback &&
callback({
// @ts-expect-error compatibillity Field | FlattenedField
field: { ...tab, type: 'tab' },
next,
parentRef: currentParentRef,
@@ -160,12 +215,15 @@ export const traverseFields = ({
return
}
if (field.type !== 'tab' && (fieldHasSubFields(field) || field.type === 'blocks')) {
if (
(flattenedFields || field.type !== 'tab') &&
(fieldHasSubFields(field as Field) || field.type === 'tab' || field.type === 'blocks')
) {
if ('name' in field && field.name) {
currentParentRef = currentRef
if (!ref[field.name]) {
if (fillEmpty) {
if (field.type === 'group') {
if (field.type === 'group' || field.type === 'tab') {
ref[field.name] = {}
} else if (field.type === 'array' || field.type === 'blocks') {
if (field.localized) {
@@ -182,7 +240,7 @@ export const traverseFields = ({
}
if (
field.type === 'group' &&
(field.type === 'group' || field.type === 'tab') &&
field.localized &&
currentRef &&
typeof currentRef === 'object'
@@ -193,9 +251,10 @@ export const traverseFields = ({
callback,
fields: field.fields,
fillEmpty,
flattenedFields: 'flattenedFields' in field ? field.flattenedFields : undefined,
parentRef: currentParentRef,
ref: currentRef[key],
})
} as TraverseFieldsArgs)
}
}
return
@@ -239,6 +298,7 @@ export const traverseFields = ({
callback,
fields: field.fields,
fillEmpty,
flattenedFields: 'flattenedFields' in field ? field.flattenedFields : undefined,
parentRef: currentParentRef,
ref: currentRef,
})

View File

@@ -53,10 +53,12 @@ describe('Relationship Fields', () => {
collection: versionedRelationshipFieldSlug,
data: {
title: 'Version 1 Title',
relationshipField: {
value: relatedDoc.id,
relationTo: collection1Slug,
},
relationshipField: [
{
value: relatedDoc.id,
relationTo: collection1Slug,
},
],
},
})

View File

@@ -1134,6 +1134,30 @@ describe('Fields', () => {
expect(doc.localized).toEqual(localized)
expect(doc.group).toMatchObject(group)
})
it('should clear a point field', async () => {
if (payload.db.name === 'sqlite') {
return
}
const doc = await payload.create({
collection: 'point-fields',
data: {
point: [7, -7],
group: {
point: [7, -7],
},
},
})
const res = await payload.update({
collection: 'point-fields',
id: doc.id,
data: { group: { point: null } },
})
expect(res.group.point).toBeFalsy()
})
})
describe('unique indexes', () => {

View File

@@ -154,7 +154,10 @@ describe('Joins Field', () => {
collection: categoriesSlug,
})
expect(Object.keys(categoryWithPosts)).toStrictEqual(['id', 'group'])
expect(categoryWithPosts).toStrictEqual({
id: categoryWithPosts.id,
group: categoryWithPosts.group,
})
expect(categoryWithPosts.group.relatedPosts.docs).toHaveLength(10)
expect(categoryWithPosts.group.relatedPosts.docs[0]).toHaveProperty('id')

View File

@@ -1633,7 +1633,10 @@ describe('Select', () => {
},
})
expect(Object.keys(res)).toStrictEqual(['id', 'text'])
expect(res).toStrictEqual({
id: res.id,
text: res.text,
})
})
it('should apply select with updateByID', async () => {
@@ -1646,13 +1649,18 @@ describe('Select', () => {
select: { text: true },
})
expect(Object.keys(res)).toStrictEqual(['id', 'text'])
expect(res).toStrictEqual({
id: res.id,
text: res.text,
})
})
it('should apply select with updateBulk', async () => {
const post = await createPost()
const res = await payload.update({
const {
docs: [res],
} = await payload.update({
collection: 'posts',
where: {
id: {
@@ -1663,7 +1671,10 @@ describe('Select', () => {
select: { text: true },
})
expect(Object.keys(res.docs[0])).toStrictEqual(['id', 'text'])
expect(res).toStrictEqual({
id: res.id,
text: res.text,
})
})
it('should apply select with deleteByID', async () => {
@@ -1675,13 +1686,18 @@ describe('Select', () => {
select: { text: true },
})
expect(Object.keys(res)).toStrictEqual(['id', 'text'])
expect(res).toStrictEqual({
id: res.id,
text: res.text,
})
})
it('should apply select with deleteBulk', async () => {
const post = await createPost()
const res = await payload.delete({
const {
docs: [res],
} = await payload.delete({
collection: 'posts',
where: {
id: {
@@ -1691,7 +1707,10 @@ describe('Select', () => {
select: { text: true },
})
expect(Object.keys(res.docs[0])).toStrictEqual(['id', 'text'])
expect(res).toStrictEqual({
id: res.id,
text: res.text,
})
})
it('should apply select with duplicate', async () => {
@@ -1703,7 +1722,10 @@ describe('Select', () => {
select: { text: true },
})
expect(Object.keys(res)).toStrictEqual(['id', 'text'])
expect(res).toStrictEqual({
id: res.id,
text: res.text,
})
})
})