perf: operations performance optimizations (#10609)

- reduces unnecessary shallow copying within operations by removing
unnecessary spreads or .map()'s
- removes unnecessary `deleteMany` call in `deleteUserPreferences` for
auth-enabled collections
- replaces all instances of `validOperators.includes` with
`validOperatorMap[]`. O(n) => O(1)
- optimizes the `sanitizeInternalFields` function. Previously, it was
doing a **lot** of shallow copying
This commit is contained in:
Alessio Gravili
2025-01-16 10:07:35 -07:00
committed by GitHub
parent 116fd9919e
commit 42382b6f6f
13 changed files with 146 additions and 159 deletions

View File

@@ -2,7 +2,7 @@ import type { FlattenedField, Operator, PathToQuery, Payload } from 'payload'
import { Types } from 'mongoose' import { Types } from 'mongoose'
import { getLocalizedPaths } from 'payload' import { getLocalizedPaths } from 'payload'
import { validOperators } from 'payload/shared' import { validOperatorSet } from 'payload/shared'
import type { MongooseAdapter } from '../index.js' import type { MongooseAdapter } from '../index.js'
@@ -187,7 +187,7 @@ export async function buildSearchParam({
return relationshipQuery return relationshipQuery
} }
if (formattedOperator && validOperators.includes(formattedOperator as Operator)) { if (formattedOperator && validOperatorSet.has(formattedOperator as Operator)) {
const operatorKey = operatorMap[formattedOperator] const operatorKey = operatorMap[formattedOperator]
if (field.type === 'relationship' || field.type === 'upload') { if (field.type === 'relationship' || field.type === 'upload') {

View File

@@ -2,7 +2,7 @@ import type { FilterQuery } from 'mongoose'
import type { FlattenedField, Operator, Payload, Where } from 'payload' import type { FlattenedField, Operator, Payload, Where } from 'payload'
import { deepMergeWithCombinedArrays } from 'payload' import { deepMergeWithCombinedArrays } from 'payload'
import { validOperators } from 'payload/shared' import { validOperatorSet } from 'payload/shared'
import { buildAndOrConditions } from './buildAndOrConditions.js' import { buildAndOrConditions } from './buildAndOrConditions.js'
import { buildSearchParam } from './buildSearchParams.js' import { buildSearchParam } from './buildSearchParams.js'
@@ -53,7 +53,7 @@ export async function parseParams({
const pathOperators = where[relationOrPath] const pathOperators = where[relationOrPath]
if (typeof pathOperators === 'object') { if (typeof pathOperators === 'object') {
for (const operator of Object.keys(pathOperators)) { for (const operator of Object.keys(pathOperators)) {
if (validOperators.includes(operator as Operator)) { if (validOperatorSet.has(operator as Operator)) {
const searchParam = await buildSearchParam({ const searchParam = await buildSearchParam({
collectionSlug, collectionSlug,
fields, fields,

View File

@@ -4,7 +4,7 @@ import type { FlattenedField, Operator, Where } from 'payload'
import { and, isNotNull, isNull, ne, notInArray, or, sql } from 'drizzle-orm' import { and, isNotNull, isNull, ne, notInArray, or, sql } from 'drizzle-orm'
import { PgUUID } from 'drizzle-orm/pg-core' import { PgUUID } from 'drizzle-orm/pg-core'
import { QueryError } from 'payload' import { QueryError } from 'payload'
import { validOperators } from 'payload/shared' import { validOperatorSet } from 'payload/shared'
import type { DrizzleAdapter, GenericColumn } from '../types.js' import type { DrizzleAdapter, GenericColumn } from '../types.js'
import type { BuildQueryJoinAliases } from './buildQuery.js' import type { BuildQueryJoinAliases } from './buildQuery.js'
@@ -73,7 +73,7 @@ export function parseParams({
const pathOperators = where[relationOrPath] const pathOperators = where[relationOrPath]
if (typeof pathOperators === 'object') { if (typeof pathOperators === 'object') {
for (let operator of Object.keys(pathOperators)) { for (let operator of Object.keys(pathOperators)) {
if (validOperators.includes(operator as Operator)) { if (validOperatorSet.has(operator as Operator)) {
const val = where[relationOrPath][operator] const val = where[relationOrPath][operator]
const { const {

View File

@@ -10,7 +10,10 @@ type GetAccessResultsArgs = {
export async function getAccessResults({ export async function getAccessResults({
req, req,
}: GetAccessResultsArgs): Promise<SanitizedPermissions> { }: GetAccessResultsArgs): Promise<SanitizedPermissions> {
const results = {} as Permissions const results = {
collections: {},
globals: {},
} as Permissions
const { payload, user } = req const { payload, user } = req
const isLoggedIn = !!user const isLoggedIn = !!user
@@ -49,10 +52,7 @@ export async function getAccessResults({
operations: collectionOperations, operations: collectionOperations,
req, req,
}) })
results.collections = { results.collections[collection.slug] = collectionPolicy
...results.collections,
[collection.slug]: collectionPolicy,
}
}), }),
) )
@@ -70,10 +70,7 @@ export async function getAccessResults({
operations: globalOperations, operations: globalOperations,
req, req,
}) })
results.globals = { results.globals[global.slug] = globalPolicy
...results.globals,
[global.slug]: globalPolicy,
}
}), }),
) )

View File

@@ -47,6 +47,8 @@ export type Arguments = {
where?: Where where?: Where
} }
const lockDurationDefault = 300 // Default 5 minutes in seconds
export const findOperation = async < export const findOperation = async <
TSlug extends CollectionSlug, TSlug extends CollectionSlug,
TSelect extends SelectFromCollectionSlug<TSlug>, TSelect extends SelectFromCollectionSlug<TSlug>,
@@ -189,11 +191,12 @@ export const findOperation = async <
try { try {
const lockDocumentsProp = collectionConfig?.lockDocuments const lockDocumentsProp = collectionConfig?.lockDocuments
const lockDurationDefault = 300 // Default 5 minutes in seconds
const lockDuration = const lockDuration =
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
const lockDurationInMilliseconds = lockDuration * 1000 const lockDurationInMilliseconds = lockDuration * 1000
const now = new Date().getTime()
const lockedDocuments = await payload.find({ const lockedDocuments = await payload.find({
collection: 'payload-locked-documents', collection: 'payload-locked-documents',
depth: 1, depth: 1,
@@ -216,14 +219,13 @@ export const findOperation = async <
// Query where the lock is newer than the current time minus lock time // Query where the lock is newer than the current time minus lock time
{ {
updatedAt: { updatedAt: {
greater_than: new Date(new Date().getTime() - lockDurationInMilliseconds), greater_than: new Date(now - lockDurationInMilliseconds),
}, },
}, },
], ],
}, },
}) })
const now = new Date().getTime()
const lockedDocs = Array.isArray(lockedDocuments?.docs) ? lockedDocuments.docs : [] const lockedDocs = Array.isArray(lockedDocuments?.docs) ? lockedDocuments.docs : []
// Filter out stale locks // Filter out stale locks
@@ -232,20 +234,16 @@ export const findOperation = async <
return lastEditedAt + lockDurationInMilliseconds > now return lastEditedAt + lockDurationInMilliseconds > now
}) })
result.docs = result.docs.map((doc) => { for (const doc of result.docs) {
const lockedDoc = validLockedDocs.find((lock) => lock?.document?.value === doc.id) const lockedDoc = validLockedDocs.find((lock) => lock?.document?.value === doc.id)
return { doc._isLocked = !!lockedDoc
...doc, doc._userEditing = lockedDoc ? lockedDoc?.user?.value : null
_isLocked: !!lockedDoc, }
_userEditing: lockedDoc ? lockedDoc?.user?.value : null,
}
})
} catch (error) { } catch (error) {
result.docs = result.docs.map((doc) => ({ for (const doc of result.docs) {
...doc, doc._isLocked = false
_isLocked: false, doc._userEditing = null
_userEditing: null, }
}))
} }
} }
@@ -253,9 +251,8 @@ export const findOperation = async <
// beforeRead - Collection // beforeRead - Collection
// ///////////////////////////////////// // /////////////////////////////////////
result = { if (collectionConfig?.hooks?.beforeRead?.length) {
...result, result.docs = await Promise.all(
docs: await Promise.all(
result.docs.map(async (doc) => { result.docs.map(async (doc) => {
let docRef = doc let docRef = doc
@@ -274,45 +271,41 @@ export const findOperation = async <
return docRef return docRef
}), }),
), )
} }
// ///////////////////////////////////// // /////////////////////////////////////
// afterRead - Fields // afterRead - Fields
// ///////////////////////////////////// // /////////////////////////////////////
result = { result.docs = await Promise.all(
...result, result.docs.map(async (doc) =>
docs: await Promise.all( afterRead<DataFromCollectionSlug<TSlug>>({
result.docs.map(async (doc) => collection: collectionConfig,
afterRead<DataFromCollectionSlug<TSlug>>({ context: req.context,
collection: collectionConfig, currentDepth,
context: req.context, depth,
currentDepth, doc,
depth, draft: draftsEnabled,
doc, fallbackLocale,
draft: draftsEnabled, findMany: true,
fallbackLocale, global: null,
findMany: true, locale,
global: null, overrideAccess,
locale, populate,
overrideAccess, req,
populate, select,
req, showHiddenFields,
select, }),
showHiddenFields,
}),
),
), ),
} )
// ///////////////////////////////////// // /////////////////////////////////////
// afterRead - Collection // afterRead - Collection
// ///////////////////////////////////// // /////////////////////////////////////
result = { if (collectionConfig?.hooks?.afterRead?.length) {
...result, result.docs = await Promise.all(
docs: await Promise.all(
result.docs.map(async (doc) => { result.docs.map(async (doc) => {
let docRef = doc let docRef = doc
@@ -332,7 +325,7 @@ export const findOperation = async <
return docRef return docRef
}), }),
), )
} }
// ///////////////////////////////////// // /////////////////////////////////////

View File

@@ -87,70 +87,62 @@ export const findVersionsOperation = async <TData extends TypeWithVersion<TData>
// ///////////////////////////////////// // /////////////////////////////////////
// beforeRead - Collection // beforeRead - Collection
// ///////////////////////////////////// // /////////////////////////////////////
const result: PaginatedDocs<TData> = paginatedDocs as unknown as PaginatedDocs<TData>
result.docs = (await Promise.all(
paginatedDocs.docs.map(async (doc) => {
const docRef = doc
// Fallback if not selected
if (!docRef.version) {
;(docRef as any).version = {}
}
await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => {
await priorHook
let result = { docRef.version =
...paginatedDocs, (await hook({
docs: await Promise.all( collection: collectionConfig,
paginatedDocs.docs.map(async (doc) => { context: req.context,
const docRef = doc doc: docRef.version,
// Fallback if not selected query: fullWhere,
if (!docRef.version) { req,
;(docRef as any).version = {} })) || docRef.version
} }, Promise.resolve())
await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => {
await priorHook
docRef.version =
(await hook({
collection: collectionConfig,
context: req.context,
doc: docRef.version,
query: fullWhere,
req,
})) || docRef.version
}, Promise.resolve())
return docRef
}),
),
} as PaginatedDocs<TData>
return docRef
}),
)) as TData[]
// ///////////////////////////////////// // /////////////////////////////////////
// afterRead - Fields // afterRead - Fields
// ///////////////////////////////////// // /////////////////////////////////////
result = { result.docs = await Promise.all(
...result, result.docs.map(async (data) => {
docs: await Promise.all( data.version = await afterRead({
result.docs.map(async (data) => ({ collection: collectionConfig,
...data, context: req.context,
version: await afterRead({ depth,
collection: collectionConfig, doc: data.version,
context: req.context, draft: undefined,
depth, fallbackLocale,
doc: data.version, findMany: true,
draft: undefined, global: null,
fallbackLocale, locale,
findMany: true, overrideAccess,
global: null, populate,
locale, req,
overrideAccess, select: typeof select?.version === 'object' ? select.version : undefined,
populate, showHiddenFields,
req, })
select: typeof select?.version === 'object' ? select.version : undefined, return data
showHiddenFields, }),
}), )
})),
),
}
// ///////////////////////////////////// // /////////////////////////////////////
// afterRead - Collection // afterRead - Collection
// ///////////////////////////////////// // /////////////////////////////////////
result = { if (collectionConfig.hooks.afterRead?.length) {
...result, result.docs = await Promise.all(
docs: await Promise.all(
result.docs.map(async (doc) => { result.docs.map(async (doc) => {
const docRef = doc const docRef = doc
@@ -170,17 +162,13 @@ export const findVersionsOperation = async <TData extends TypeWithVersion<TData>
return docRef return docRef
}), }),
), )
} }
// ///////////////////////////////////// // /////////////////////////////////////
// Return results // Return results
// ///////////////////////////////////// // /////////////////////////////////////
result.docs = result.docs.map((doc) => sanitizeInternalFields<TData>(doc))
result = {
...result,
docs: result.docs.map((doc) => sanitizeInternalFields<TData>(doc)),
}
return result return result
} catch (error: unknown) { } catch (error: unknown) {

View File

@@ -5,7 +5,7 @@ import type { Operator, PayloadRequest, Where, WhereField } from '../../types/in
import type { EntityPolicies } from './types.js' import type { EntityPolicies } from './types.js'
import { QueryError } from '../../errors/QueryError.js' import { QueryError } from '../../errors/QueryError.js'
import { validOperators } from '../../types/constants.js' import { validOperatorSet } from '../../types/constants.js'
import { validateSearchParam } from './validateSearchParams.js' import { validateSearchParam } from './validateSearchParams.js'
type Args = { type Args = {
@@ -58,10 +58,11 @@ export async function validateQueryPaths({
const whereFields = flattenWhere(where) const whereFields = flattenWhere(where)
// We need to determine if the whereKey is an AND, OR, or a schema path // We need to determine if the whereKey is an AND, OR, or a schema path
const promises = [] const promises = []
void whereFields.map((constraint) => { for (const constraint of whereFields) {
void Object.keys(constraint).map((path) => { for (const path in constraint) {
void Object.entries(constraint[path]).map(([operator, val]) => { for (const operator in constraint[path]) {
if (validOperators.includes(operator as Operator)) { const val = constraint[path][operator]
if (validOperatorSet.has(operator as Operator)) {
promises.push( promises.push(
validateSearchParam({ validateSearchParam({
collectionConfig, collectionConfig,
@@ -78,9 +79,10 @@ export async function validateQueryPaths({
}), }),
) )
} }
}) }
}) }
}) }
await Promise.all(promises) await Promise.all(promises)
if (errors.length > 0) { if (errors.length > 0) {
throw new QueryError(errors) throw new QueryError(errors)

View File

@@ -37,7 +37,7 @@ export { getFieldPaths } from '../fields/getFieldPaths.js'
export * from '../fields/validations.js' export * from '../fields/validations.js'
export { validOperators } from '../types/constants.js' export { validOperators, validOperatorSet } from '../types/constants.js'
export { formatFilesize } from '../uploads/formatFilesize.js' export { formatFilesize } from '../uploads/formatFilesize.js'

View File

@@ -17,22 +17,30 @@ export const deleteUserPreferences = async ({ collectionConfig, ids, payload, re
collection: 'payload-preferences', collection: 'payload-preferences',
req, req,
where: { where: {
and: [ or: [
{ {
'user.value': { in: ids }, and: [
{
'user.value': { in: ids },
},
{
'user.relationTo': { equals: collectionConfig.slug },
},
],
}, },
{ {
'user.relationTo': { equals: collectionConfig.slug }, key: { in: ids.map((id) => `collection-${collectionConfig.slug}-${id}`) },
}, },
], ],
}, },
}) })
} else {
await payload.db.deleteMany({
collection: 'payload-preferences',
req,
where: {
key: { in: ids.map((id) => `collection-${collectionConfig.slug}-${id}`) },
},
})
} }
await payload.db.deleteMany({
collection: 'payload-preferences',
req,
where: {
key: { in: ids.map((id) => `collection-${collectionConfig.slug}-${id}`) },
},
})
} }

View File

@@ -15,3 +15,7 @@ export const validOperators = [
'intersects', 'intersects',
'near', 'near',
] as const ] as const
export type Operator = (typeof validOperators)[number]
export const validOperatorSet = new Set<Operator>(validOperators)

View File

@@ -18,7 +18,7 @@ import type {
TypedLocale, TypedLocale,
TypedUser, TypedUser,
} from '../index.js' } from '../index.js'
import type { validOperators } from './constants.js' import type { Operator } from './constants.js'
export type { Payload as Payload } from '../index.js' export type { Payload as Payload } from '../index.js'
export type CustomPayloadRequestProperties = { export type CustomPayloadRequestProperties = {
@@ -101,7 +101,7 @@ export type PayloadRequest = CustomPayloadRequestProperties &
PayloadRequestData & PayloadRequestData &
Required<Pick<Request, 'headers'>> Required<Pick<Request, 'headers'>>
export type Operator = (typeof validOperators)[number] export type { Operator }
// Makes it so things like passing new Date() will error // Makes it so things like passing new Date() will error
export type JsonValue = JsonArray | JsonObject | unknown //Date | JsonArray | JsonObject | boolean | null | number | string // TODO: Evaluate proper, strong type for this export type JsonValue = JsonArray | JsonObject | unknown //Date | JsonArray | JsonObject | boolean | null | number | string // TODO: Evaluate proper, strong type for this

View File

@@ -1,22 +1,17 @@
const internalFields = ['__v'] const sanitizeInternalFields = <T extends Record<string, unknown>>(incomingDoc: T): T => {
// Create a new object to hold the sanitized fields
const newDoc: Record<string, unknown> = {}
const sanitizeInternalFields = <T extends Record<string, unknown>>(incomingDoc: T): T => for (const key in incomingDoc) {
Object.entries(incomingDoc).reduce((newDoc, [key, val]): T => { const val = incomingDoc[key]
if (key === '_id') { if (key === '_id') {
return { newDoc['id'] = val
...newDoc, } else if (key !== '__v') {
id: val, newDoc[key] = val
}
} }
}
if (internalFields.indexOf(key) > -1) { return newDoc as T
return newDoc }
}
return {
...newDoc,
[key]: val,
}
}, {} as T)
export default sanitizeInternalFields export default sanitizeInternalFields

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import type { Operator, Where } from 'payload' import type { Operator, Where } from 'payload'
import { validOperators } from 'payload/shared' import { validOperatorSet } from 'payload/shared'
const validateWhereQuery = (whereQuery): whereQuery is Where => { const validateWhereQuery = (whereQuery): whereQuery is Where => {
if ( if (
@@ -27,7 +27,7 @@ const validateWhereQuery = (whereQuery): whereQuery is Where => {
for (const key of andKeys) { for (const key of andKeys) {
const operator = Object.keys(andQuery[key])[0] const operator = Object.keys(andQuery[key])[0]
// Check if the key is a valid Operator. // Check if the key is a valid Operator.
if (!operator || !validOperators.includes(operator as Operator)) { if (!operator || !validOperatorSet.has(operator as Operator)) {
return false return false
} }
} }