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 { getLocalizedPaths } from 'payload'
import { validOperators } from 'payload/shared'
import { validOperatorSet } from 'payload/shared'
import type { MongooseAdapter } from '../index.js'
@@ -187,7 +187,7 @@ export async function buildSearchParam({
return relationshipQuery
}
if (formattedOperator && validOperators.includes(formattedOperator as Operator)) {
if (formattedOperator && validOperatorSet.has(formattedOperator as Operator)) {
const operatorKey = operatorMap[formattedOperator]
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 { deepMergeWithCombinedArrays } from 'payload'
import { validOperators } from 'payload/shared'
import { validOperatorSet } from 'payload/shared'
import { buildAndOrConditions } from './buildAndOrConditions.js'
import { buildSearchParam } from './buildSearchParams.js'
@@ -53,7 +53,7 @@ export async function parseParams({
const pathOperators = where[relationOrPath]
if (typeof pathOperators === 'object') {
for (const operator of Object.keys(pathOperators)) {
if (validOperators.includes(operator as Operator)) {
if (validOperatorSet.has(operator as Operator)) {
const searchParam = await buildSearchParam({
collectionSlug,
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 { PgUUID } from 'drizzle-orm/pg-core'
import { QueryError } from 'payload'
import { validOperators } from 'payload/shared'
import { validOperatorSet } from 'payload/shared'
import type { DrizzleAdapter, GenericColumn } from '../types.js'
import type { BuildQueryJoinAliases } from './buildQuery.js'
@@ -73,7 +73,7 @@ export function parseParams({
const pathOperators = where[relationOrPath]
if (typeof pathOperators === 'object') {
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 {

View File

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

View File

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

View File

@@ -87,10 +87,8 @@ export const findVersionsOperation = async <TData extends TypeWithVersion<TData>
// /////////////////////////////////////
// beforeRead - Collection
// /////////////////////////////////////
let result = {
...paginatedDocs,
docs: await Promise.all(
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
@@ -112,19 +110,14 @@ export const findVersionsOperation = async <TData extends TypeWithVersion<TData>
return docRef
}),
),
} as PaginatedDocs<TData>
)) as TData[]
// /////////////////////////////////////
// afterRead - Fields
// /////////////////////////////////////
result = {
...result,
docs: await Promise.all(
result.docs.map(async (data) => ({
...data,
version: await afterRead({
result.docs = await Promise.all(
result.docs.map(async (data) => {
data.version = await afterRead({
collection: collectionConfig,
context: req.context,
depth,
@@ -139,18 +132,17 @@ export const findVersionsOperation = async <TData extends TypeWithVersion<TData>
req,
select: typeof select?.version === 'object' ? select.version : undefined,
showHiddenFields,
})
return data
}),
})),
),
}
)
// /////////////////////////////////////
// afterRead - Collection
// /////////////////////////////////////
result = {
...result,
docs: await Promise.all(
if (collectionConfig.hooks.afterRead?.length) {
result.docs = await Promise.all(
result.docs.map(async (doc) => {
const docRef = doc
@@ -170,17 +162,13 @@ export const findVersionsOperation = async <TData extends TypeWithVersion<TData>
return docRef
}),
),
)
}
// /////////////////////////////////////
// Return results
// /////////////////////////////////////
result = {
...result,
docs: result.docs.map((doc) => sanitizeInternalFields<TData>(doc)),
}
result.docs = result.docs.map((doc) => sanitizeInternalFields<TData>(doc))
return result
} 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 { QueryError } from '../../errors/QueryError.js'
import { validOperators } from '../../types/constants.js'
import { validOperatorSet } from '../../types/constants.js'
import { validateSearchParam } from './validateSearchParams.js'
type Args = {
@@ -58,10 +58,11 @@ export async function validateQueryPaths({
const whereFields = flattenWhere(where)
// We need to determine if the whereKey is an AND, OR, or a schema path
const promises = []
void whereFields.map((constraint) => {
void Object.keys(constraint).map((path) => {
void Object.entries(constraint[path]).map(([operator, val]) => {
if (validOperators.includes(operator as Operator)) {
for (const constraint of whereFields) {
for (const path in constraint) {
for (const operator in constraint[path]) {
const val = constraint[path][operator]
if (validOperatorSet.has(operator as Operator)) {
promises.push(
validateSearchParam({
collectionConfig,
@@ -78,9 +79,10 @@ export async function validateQueryPaths({
}),
)
}
})
})
})
}
}
}
await Promise.all(promises)
if (errors.length > 0) {
throw new QueryError(errors)

View File

@@ -37,7 +37,7 @@ export { getFieldPaths } from '../fields/getFieldPaths.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'

View File

@@ -17,6 +17,8 @@ export const deleteUserPreferences = async ({ collectionConfig, ids, payload, re
collection: 'payload-preferences',
req,
where: {
or: [
{
and: [
{
'user.value': { in: ids },
@@ -26,8 +28,13 @@ export const deleteUserPreferences = async ({ collectionConfig, ids, payload, re
},
],
},
{
key: { in: ids.map((id) => `collection-${collectionConfig.slug}-${id}`) },
},
],
},
})
}
} else {
await payload.db.deleteMany({
collection: 'payload-preferences',
req,
@@ -35,4 +42,5 @@ export const deleteUserPreferences = async ({ collectionConfig, ids, payload, re
key: { in: ids.map((id) => `collection-${collectionConfig.slug}-${id}`) },
},
})
}
}

View File

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

View File

@@ -18,7 +18,7 @@ import type {
TypedLocale,
TypedUser,
} 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 CustomPayloadRequestProperties = {
@@ -101,7 +101,7 @@ export type PayloadRequest = CustomPayloadRequestProperties &
PayloadRequestData &
Required<Pick<Request, 'headers'>>
export type Operator = (typeof validOperators)[number]
export type { Operator }
// 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

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 =>
Object.entries(incomingDoc).reduce((newDoc, [key, val]): T => {
for (const key in incomingDoc) {
const val = incomingDoc[key]
if (key === '_id') {
return {
...newDoc,
id: val,
newDoc['id'] = val
} else if (key !== '__v') {
newDoc[key] = val
}
}
if (internalFields.indexOf(key) > -1) {
return newDoc
}
return {
...newDoc,
[key]: val,
}
}, {} as T)
return newDoc as T
}
export default sanitizeInternalFields

View File

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