From bc9b501e283507628c7b63fcdce13d2b6f8313bc Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Tue, 24 Jun 2025 05:18:49 +0300 Subject: [PATCH] fix: querying virtual fields deeply with `draft: true` (#12868) Fixes an issue when querying deeply new relationship virtual fields with `draft: true`. Changes the method for `where` sanitization, before it was done in `validateSearchParam` which didn't work with versions properly, now there's a separate `sanitizeWhereQuery` function that does this. --- .../src/collections/operations/count.ts | 2 + .../collections/operations/countVersions.ts | 3 + .../src/collections/operations/delete.ts | 3 + .../src/collections/operations/find.ts | 2 + .../src/collections/operations/findByID.ts | 7 ++ .../collections/operations/findVersions.ts | 4 +- .../src/collections/operations/update.ts | 5 +- .../queryValidation/validateSearchParams.ts | 3 - .../src/database/sanitizeWhereQuery.ts | 64 +++++++++++++++++++ test/database/config.ts | 1 + test/database/int.spec.ts | 19 ++++++ 11 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 packages/payload/src/database/sanitizeWhereQuery.ts diff --git a/packages/payload/src/collections/operations/count.ts b/packages/payload/src/collections/operations/count.ts index bbc96f9554..d415e3c99e 100644 --- a/packages/payload/src/collections/operations/count.ts +++ b/packages/payload/src/collections/operations/count.ts @@ -6,6 +6,7 @@ import type { Collection } from '../config/types.js' import executeAccess from '../../auth/executeAccess.js' import { combineQueries } from '../../database/combineQueries.js' import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js' +import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js' import { killTransaction } from '../../utilities/killTransaction.js' import { buildAfterOperation } from './utils.js' @@ -71,6 +72,7 @@ export const countOperation = async ( let result: { totalDocs: number } const fullWhere = combineQueries(where!, accessResult!) + sanitizeWhereQuery({ fields: collectionConfig.flattenedFields, payload, where: fullWhere }) await validateQueryPaths({ collectionConfig, diff --git a/packages/payload/src/collections/operations/countVersions.ts b/packages/payload/src/collections/operations/countVersions.ts index 8318cab0e1..02b37b5697 100644 --- a/packages/payload/src/collections/operations/countVersions.ts +++ b/packages/payload/src/collections/operations/countVersions.ts @@ -5,6 +5,7 @@ import type { Collection } from '../config/types.js' import executeAccess from '../../auth/executeAccess.js' import { combineQueries } from '../../database/combineQueries.js' import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js' +import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js' import { buildVersionCollectionFields, type CollectionSlug } from '../../index.js' import { killTransaction } from '../../utilities/killTransaction.js' import { buildAfterOperation } from './utils.js' @@ -77,6 +78,8 @@ export const countVersionsOperation = async ( const versionFields = buildVersionCollectionFields(payload.config, collectionConfig, true) + sanitizeWhereQuery({ fields: versionFields, payload, where: fullWhere }) + await validateQueryPaths({ collectionConfig, overrideAccess: overrideAccess!, diff --git a/packages/payload/src/collections/operations/delete.ts b/packages/payload/src/collections/operations/delete.ts index 9480da99b7..9a814366a9 100644 --- a/packages/payload/src/collections/operations/delete.ts +++ b/packages/payload/src/collections/operations/delete.ts @@ -13,6 +13,7 @@ import type { import executeAccess from '../../auth/executeAccess.js' import { combineQueries } from '../../database/combineQueries.js' import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js' +import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js' import { APIError } from '../../errors/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' import { deleteUserPreferences } from '../../preferences/deleteUserPreferences.js' @@ -107,6 +108,8 @@ export const deleteOperation = async < const fullWhere = combineQueries(where, accessResult!) + sanitizeWhereQuery({ fields: collectionConfig.flattenedFields, payload, where: fullWhere }) + const select = sanitizeSelect({ fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, diff --git a/packages/payload/src/collections/operations/find.ts b/packages/payload/src/collections/operations/find.ts index 0481b38416..d32426af43 100644 --- a/packages/payload/src/collections/operations/find.ts +++ b/packages/payload/src/collections/operations/find.ts @@ -19,6 +19,7 @@ import executeAccess from '../../auth/executeAccess.js' import { combineQueries } from '../../database/combineQueries.js' import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js' import { sanitizeJoinQuery } from '../../database/sanitizeJoinQuery.js' +import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' import { lockedDocumentsCollectionSlug } from '../../locked-documents/config.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -144,6 +145,7 @@ export const findOperation = async < let result: PaginatedDocs> let fullWhere = combineQueries(where!, accessResult!) + sanitizeWhereQuery({ fields: collectionConfig.flattenedFields, payload, where: fullWhere }) const sort = sanitizeSortQuery({ fields: collection.config.flattenedFields, diff --git a/packages/payload/src/collections/operations/findByID.ts b/packages/payload/src/collections/operations/findByID.ts index df39944370..6a74d2fbe2 100644 --- a/packages/payload/src/collections/operations/findByID.ts +++ b/packages/payload/src/collections/operations/findByID.ts @@ -18,6 +18,7 @@ import type { import executeAccess from '../../auth/executeAccess.js' import { combineQueries } from '../../database/combineQueries.js' import { sanitizeJoinQuery } from '../../database/sanitizeJoinQuery.js' +import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js' import { NotFound } from '../../errors/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' import { validateQueryPaths } from '../../index.js' @@ -110,6 +111,12 @@ export const findByIDOperation = async < const fullWhere = combineQueries(where, accessResult) + sanitizeWhereQuery({ + fields: collectionConfig.flattenedFields, + payload: args.req.payload, + where: fullWhere, + }) + const sanitizedJoins = await sanitizeJoinQuery({ collectionConfig, joins, diff --git a/packages/payload/src/collections/operations/findVersions.ts b/packages/payload/src/collections/operations/findVersions.ts index beb1208092..3961f6ca34 100644 --- a/packages/payload/src/collections/operations/findVersions.ts +++ b/packages/payload/src/collections/operations/findVersions.ts @@ -7,6 +7,7 @@ import type { Collection } from '../config/types.js' import executeAccess from '../../auth/executeAccess.js' import { combineQueries } from '../../database/combineQueries.js' import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js' +import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' import { killTransaction } from '../../utilities/killTransaction.js' import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js' @@ -71,9 +72,10 @@ export const findVersionsOperation = async }) const fullWhere = combineQueries(where!, accessResults) + sanitizeWhereQuery({ fields: versionFields, payload, where: fullWhere }) const select = sanitizeSelect({ - fields: buildVersionCollectionFields(payload.config, collectionConfig, true), + fields: versionFields, forceSelect: getQueryDraftsSelect({ select: collectionConfig.forceSelect }), select: incomingSelect, versions: true, diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index 9b836e0d5e..9ba767c014 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -15,7 +15,8 @@ import type { import executeAccess from '../../auth/executeAccess.js' import { combineQueries } from '../../database/combineQueries.js' import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js' -import { APIError, Forbidden } from '../../errors/index.js' +import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js' +import { APIError } from '../../errors/index.js' import { type CollectionSlug, deepCopyObjectSimple } from '../../index.js' import { generateFileData } from '../../uploads/generateFileData.js' import { unlinkTempFiles } from '../../uploads/unlinkTempFiles.js' @@ -140,6 +141,8 @@ export const updateOperation = async < const fullWhere = combineQueries(where, accessResult!) + sanitizeWhereQuery({ fields: collectionConfig.flattenedFields, payload, where: fullWhere }) + const sort = sanitizeSortQuery({ fields: collection.config.flattenedFields, sort: incomingSort, diff --git a/packages/payload/src/database/queryValidation/validateSearchParams.ts b/packages/payload/src/database/queryValidation/validateSearchParams.ts index 69e3809bf7..3348208159 100644 --- a/packages/payload/src/database/queryValidation/validateSearchParams.ts +++ b/packages/payload/src/database/queryValidation/validateSearchParams.ts @@ -113,9 +113,6 @@ export async function validateSearchParam({ if ('virtual' in field && field.virtual) { if (field.virtual === true) { errors.push({ path }) - } else { - constraint[`${field.virtual}` as keyof WhereField] = constraint[path as keyof WhereField] - delete constraint[path as keyof WhereField] } } diff --git a/packages/payload/src/database/sanitizeWhereQuery.ts b/packages/payload/src/database/sanitizeWhereQuery.ts new file mode 100644 index 0000000000..c018bd827a --- /dev/null +++ b/packages/payload/src/database/sanitizeWhereQuery.ts @@ -0,0 +1,64 @@ +import type { FlattenedField } from '../fields/config/types.js' +import type { Payload, Where } from '../types/index.js' + +/** + * Currently used only for virtual fields linked with relationships + */ +export const sanitizeWhereQuery = ({ + fields, + payload, + where, +}: { + fields: FlattenedField[] + payload: Payload + where: Where +}) => { + for (const key in where) { + const value = where[key] + + if (['and', 'or'].includes(key.toLowerCase()) && Array.isArray(value)) { + for (const where of value) { + sanitizeWhereQuery({ fields, payload, where }) + } + continue + } + + const paths = key.split('.') + let pathHasChanged = false + + let currentFields = fields + + for (let i = 0; i < paths.length; i++) { + const path = paths[i]! + const field = currentFields.find((each) => each.name === path) + + if (!field) { + break + } + + if ('virtual' in field && field.virtual && typeof field.virtual === 'string') { + paths[i] = field.virtual + pathHasChanged = true + } + + if ('flattenedFields' in field) { + currentFields = field.flattenedFields + } + + if ( + (field.type === 'relationship' || field.type === 'upload') && + typeof field.relationTo === 'string' + ) { + const relatedCollection = payload.collections[field.relationTo] + if (relatedCollection) { + currentFields = relatedCollection.config.flattenedFields + } + } + } + + if (pathHasChanged) { + where[paths.join('.')] = where[key]! + delete where[key] + } + } +} diff --git a/test/database/config.ts b/test/database/config.ts index 5306d9373c..fe42546d81 100644 --- a/test/database/config.ts +++ b/test/database/config.ts @@ -38,6 +38,7 @@ export default buildConfigWithDefaults({ collections: [ { slug: 'categories', + versions: { drafts: true }, fields: [ { type: 'text', diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index f765b508d2..de708935b9 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -2222,6 +2222,25 @@ describe('database', () => { expect(found.docs[0].id).toBe(doc.id) }) + it('should allow to query by virtual field 2x deep with draft:true', async () => { + const category = await payload.create({ + collection: 'categories', + data: { title: '3-category' }, + }) + const post = await payload.create({ + collection: 'posts', + data: { title: '3-post', category: category.id }, + }) + const doc = await payload.create({ collection: 'virtual-relations', data: { post: post.id } }) + const found = await payload.find({ + collection: 'virtual-relations', + where: { postCategoryTitle: { equals: '3-category' } }, + draft: true, + }) + expect(found.docs).toHaveLength(1) + expect(found.docs[0].id).toBe(doc.id) + }) + it('should allow referenced virtual field in globals', async () => { const post = await payload.create({ collection: 'posts', data: { title: 'post' } }) const globalData = await payload.updateGlobal({