diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 908d138a94..9f3e7f5028 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -247,6 +247,7 @@ export const createOperation = async < let doc const select = sanitizeSelect({ + fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/collections/operations/delete.ts b/packages/payload/src/collections/operations/delete.ts index 824c5a7af6..68a9fecbc3 100644 --- a/packages/payload/src/collections/operations/delete.ts +++ b/packages/payload/src/collections/operations/delete.ts @@ -110,6 +110,7 @@ export const deleteOperation = async < const fullWhere = combineQueries(where, accessResult) const select = sanitizeSelect({ + fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/collections/operations/deleteByID.ts b/packages/payload/src/collections/operations/deleteByID.ts index 6225700e42..add2bd8445 100644 --- a/packages/payload/src/collections/operations/deleteByID.ts +++ b/packages/payload/src/collections/operations/deleteByID.ts @@ -168,6 +168,7 @@ export const deleteByIDOperation = async ( // ///////////////////////////////////// const select = sanitizeSelect({ + fields: buildVersionCollectionFields(payload.config, collectionConfig, true), forceSelect: getQueryDraftsSelect({ select: collectionConfig.forceSelect }), select: incomingSelect, + versions: true, }) const versionsQuery = await payload.db.findVersions({ diff --git a/packages/payload/src/collections/operations/findVersions.ts b/packages/payload/src/collections/operations/findVersions.ts index fb1e59cdf3..029af417f1 100644 --- a/packages/payload/src/collections/operations/findVersions.ts +++ b/packages/payload/src/collections/operations/findVersions.ts @@ -72,8 +72,10 @@ export const findVersionsOperation = async const fullWhere = combineQueries(where, accessResults) const select = sanitizeSelect({ + fields: buildVersionCollectionFields(payload.config, collectionConfig, true), forceSelect: getQueryDraftsSelect({ select: collectionConfig.forceSelect }), select: incomingSelect, + versions: true, }) // ///////////////////////////////////// diff --git a/packages/payload/src/collections/operations/restoreVersion.ts b/packages/payload/src/collections/operations/restoreVersion.ts index f43e56e943..b671e30300 100644 --- a/packages/payload/src/collections/operations/restoreVersion.ts +++ b/packages/payload/src/collections/operations/restoreVersion.ts @@ -117,6 +117,7 @@ export const restoreVersionOperation = async ( // ///////////////////////////////////// const select = sanitizeSelect({ + fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index ab2e2308fa..31e412e436 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -201,6 +201,7 @@ export const updateOperation = async < try { const select = sanitizeSelect({ + fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index a340ea307f..c80487686c 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -161,6 +161,7 @@ export const updateByIDOperation = async < }) const select = sanitizeSelect({ + fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/globals/operations/findOne.ts b/packages/payload/src/globals/operations/findOne.ts index b076fc5ae3..f341cc019d 100644 --- a/packages/payload/src/globals/operations/findOne.ts +++ b/packages/payload/src/globals/operations/findOne.ts @@ -53,6 +53,7 @@ export const findOneOperation = async >( } const select = sanitizeSelect({ + fields: globalConfig.flattenedFields, forceSelect: globalConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/globals/operations/findVersionByID.ts b/packages/payload/src/globals/operations/findVersionByID.ts index 06a00c12e6..37b457551a 100644 --- a/packages/payload/src/globals/operations/findVersionByID.ts +++ b/packages/payload/src/globals/operations/findVersionByID.ts @@ -11,6 +11,8 @@ import { afterRead } from '../../fields/hooks/afterRead/index.js' import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' import { killTransaction } from '../../utilities/killTransaction.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' +import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js' +import { buildVersionGlobalFields } from '../../versions/buildGlobalFields.js' import { getQueryDraftsSelect } from '../../versions/drafts/getQueryDraftsSelect.js' export type Arguments = { @@ -60,8 +62,10 @@ export const findVersionByIDOperation = async = an const hasWhereAccess = typeof accessResults === 'object' const select = sanitizeSelect({ + fields: buildVersionGlobalFields(payload.config, globalConfig, true), forceSelect: getQueryDraftsSelect({ select: globalConfig.forceSelect }), select: incomingSelect, + versions: true, }) const findGlobalVersionsArgs: FindGlobalVersionsArgs = { diff --git a/packages/payload/src/globals/operations/findVersions.ts b/packages/payload/src/globals/operations/findVersions.ts index 2f59b44097..57bcbf7099 100644 --- a/packages/payload/src/globals/operations/findVersions.ts +++ b/packages/payload/src/globals/operations/findVersions.ts @@ -70,8 +70,10 @@ export const findVersionsOperation = async >( const fullWhere = combineQueries(where, accessResults) const select = sanitizeSelect({ + fields: buildVersionGlobalFields(payload.config, globalConfig, true), forceSelect: getQueryDraftsSelect({ select: globalConfig.forceSelect }), select: incomingSelect, + versions: true, }) // ///////////////////////////////////// diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 859a04342f..c1f32a7b1d 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -246,6 +246,7 @@ export const updateOperation = async < // ///////////////////////////////////// const select = sanitizeSelect({ + fields: globalConfig.flattenedFields, forceSelect: globalConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/utilities/sanitizeSelect.ts b/packages/payload/src/utilities/sanitizeSelect.ts index 9d18bcbb51..3e7bf9d56c 100644 --- a/packages/payload/src/utilities/sanitizeSelect.ts +++ b/packages/payload/src/utilities/sanitizeSelect.ts @@ -1,17 +1,129 @@ import { deepMergeSimple } from '@payloadcms/translations/utilities' -import type { SelectType } from '../types/index.js' +import type { FlattenedField } from '../fields/config/types.js' +import type { SelectIncludeType, SelectType } from '../types/index.js' import { getSelectMode } from './getSelectMode.js' +// Transform post.title -> post, post.category.title -> post +const stripVirtualPathToCurrentCollection = ({ + fields, + path, + versions, +}: { + fields: FlattenedField[] + path: string + versions: boolean +}) => { + const resultSegments: string[] = [] + + if (versions) { + resultSegments.push('version') + const versionField = fields.find((each) => each.name === 'version') + + if (versionField && versionField.type === 'group') { + fields = versionField.flattenedFields + } + } + + for (const segment of path.split('.')) { + const field = fields.find((each) => each.name === segment) + + if (!field) { + continue + } + + resultSegments.push(segment) + + if (field.type === 'relationship' || field.type === 'upload') { + return resultSegments.join('.') + } + } + + return resultSegments.join('.') +} + +const getAllVirtualRelations = ({ fields }: { fields: FlattenedField[] }) => { + const result: string[] = [] + + for (const field of fields) { + if ('virtual' in field && typeof field.virtual === 'string') { + result.push(field.virtual) + } else if (field.type === 'group' || field.type === 'tab') { + const nestedResult = getAllVirtualRelations({ fields: field.flattenedFields }) + + for (const nestedItem of nestedResult) { + result.push(nestedItem) + } + } + } + + return result +} + +const resolveVirtualRelationsToSelect = ({ + fields, + selectValue, + topLevelFields, + versions, +}: { + fields: FlattenedField[] + selectValue: SelectIncludeType | true + topLevelFields: FlattenedField[] + versions: boolean +}) => { + const result: string[] = [] + if (selectValue === true) { + for (const item of getAllVirtualRelations({ fields })) { + result.push( + stripVirtualPathToCurrentCollection({ fields: topLevelFields, path: item, versions }), + ) + } + } else { + for (const fieldName in selectValue) { + const field = fields.find((each) => each.name === fieldName) + if (!field) { + continue + } + + if ('virtual' in field && typeof field.virtual === 'string') { + result.push( + stripVirtualPathToCurrentCollection({ + fields: topLevelFields, + path: field.virtual, + versions, + }), + ) + } else if (field.type === 'group' || field.type === 'tab') { + for (const item of resolveVirtualRelationsToSelect({ + fields: field.flattenedFields, + selectValue: selectValue[fieldName], + topLevelFields, + versions, + })) { + result.push( + stripVirtualPathToCurrentCollection({ fields: topLevelFields, path: item, versions }), + ) + } + } + } + } + + return result +} + export const sanitizeSelect = ({ + fields, forceSelect, select, + versions, }: { + fields: FlattenedField[] forceSelect?: SelectType select?: SelectType + versions?: boolean }): SelectType | undefined => { - if (!forceSelect || !select) { + if (!select) { return select } @@ -21,5 +133,36 @@ export const sanitizeSelect = ({ return select } - return deepMergeSimple(select, forceSelect) + if (forceSelect) { + select = deepMergeSimple(select, forceSelect) + } + + if (select) { + const virtualRelations = resolveVirtualRelationsToSelect({ + fields, + selectValue: select as SelectIncludeType, + topLevelFields: fields, + versions: versions ?? false, + }) + + for (const path of virtualRelations) { + let currentRef = select + const segments = path.split('.') + for (let i = 0; i < segments.length; i++) { + const isLast = segments.length - 1 === i + const segment = segments[i] + + if (isLast) { + currentRef[segment] = true + } else { + if (!(segment in currentRef)) { + currentRef[segment] = {} + currentRef = currentRef[segment] + } + } + } + } + } + + return select } diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index ad45a88f40..f6ff086f6a 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -1997,6 +1997,23 @@ describe('database', () => { expect(draft.docs[0]?.postTitle).toBe('my-title') }) + it('should not break when using select', async () => { + const post = await payload.create({ collection: 'posts', data: { title: 'my-title-10' } }) + const { id } = await payload.create({ + collection: 'virtual-relations', + depth: 0, + data: { post: post.id }, + }) + + const doc = await payload.findByID({ + collection: 'virtual-relations', + depth: 0, + id, + select: { postTitle: true }, + }) + expect(doc.postTitle).toBe('my-title-10') + }) + it('should allow virtual field as reference to ID', async () => { const post = await payload.create({ collection: 'posts', data: { title: 'my-title' } }) const { id } = await payload.create({ @@ -2129,6 +2146,26 @@ describe('database', () => { expect(doc.postCategoryTitle).toBe('1-category') }) + it('should not break when using select 2x deep', 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 docWithSelect = await payload.findByID({ + collection: 'virtual-relations', + depth: 0, + id: doc.id, + select: { postCategoryTitle: true }, + }) + expect(docWithSelect.postCategoryTitle).toBe('3-category') + }) + it('should allow to query by virtual field 2x deep', async () => { const category = await payload.create({ collection: 'categories', diff --git a/test/plugin-import-export/collections/Pages.ts b/test/plugin-import-export/collections/Pages.ts index 3f3aa112d5..0c4865dbec 100644 --- a/test/plugin-import-export/collections/Pages.ts +++ b/test/plugin-import-export/collections/Pages.ts @@ -98,6 +98,19 @@ export const Pages: CollectionConfig = { type: 'relationship', relationTo: 'users', }, + { + name: 'virtualRelationship', + type: 'text', + virtual: 'author.name', + }, + { + name: 'virtual', + type: 'text', + virtual: true, + hooks: { + afterRead: [() => 'virtual value'], + }, + }, { name: 'hasManyNumber', type: 'number', diff --git a/test/plugin-import-export/collections/Users.ts b/test/plugin-import-export/collections/Users.ts index b29c9debff..d50cca6cd6 100644 --- a/test/plugin-import-export/collections/Users.ts +++ b/test/plugin-import-export/collections/Users.ts @@ -10,6 +10,10 @@ export const Users: CollectionConfig = { read: () => true, }, fields: [ + { + name: 'name', + type: 'text', + }, // Email added by default // Add more fields as needed ], diff --git a/test/plugin-import-export/int.spec.ts b/test/plugin-import-export/int.spec.ts index b0f27f80d1..c61ee38831 100644 --- a/test/plugin-import-export/int.spec.ts +++ b/test/plugin-import-export/int.spec.ts @@ -255,6 +255,35 @@ describe('@payloadcms/plugin-import-export', () => { expect(str.indexOf('createdAt')).toBeLessThan(str.indexOf('updatedAt')) }) + it('should create a CSV file with virtual fields', async () => { + const fields = ['id', 'virtual', 'virtualRelationship'] + const doc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields, + format: 'csv', + where: { + title: { contains: 'Virtual ' }, + }, + }, + }) + + const exportDoc = await payload.findByID({ + collection: 'exports', + id: doc.id, + }) + + expect(exportDoc.filename).toBeDefined() + const expectedPath = path.join(dirname, './uploads', exportDoc.filename as string) + const data = await readCSV(expectedPath) + + // Assert that the csv file contains the expected virtual fields + expect(data[0].virtual).toStrictEqual('virtual value') + expect(data[0].virtualRelationship).toStrictEqual('name value') + }) + it('should create a file for collection csv from array.subfield', async () => { let doc = await payload.create({ collection: 'exports', diff --git a/test/plugin-import-export/payload-types.ts b/test/plugin-import-export/payload-types.ts index 740160e7db..fbfe1504cc 100644 --- a/test/plugin-import-export/payload-types.ts +++ b/test/plugin-import-export/payload-types.ts @@ -131,6 +131,7 @@ export interface UserAuthOperations { */ export interface User { id: string; + name?: string | null; updatedAt: string; createdAt: string; email: string; @@ -199,6 +200,8 @@ export interface Page { )[] | null; author?: (string | null) | User; + virtualRelationship?: string | null; + virtual?: string | null; hasManyNumber?: number[] | null; relationship?: (string | null) | User; excerpt?: string | null; @@ -444,6 +447,7 @@ export interface PayloadMigration { * via the `definition` "users_select". */ export interface UsersSelect { + name?: T; updatedAt?: T; createdAt?: T; email?: T; @@ -500,6 +504,8 @@ export interface PagesSelect { }; }; author?: T; + virtualRelationship?: T; + virtual?: T; hasManyNumber?: T; relationship?: T; excerpt?: T; diff --git a/test/plugin-import-export/seed/index.ts b/test/plugin-import-export/seed/index.ts index bbc8dd3222..652af40a0f 100644 --- a/test/plugin-import-export/seed/index.ts +++ b/test/plugin-import-export/seed/index.ts @@ -6,11 +6,12 @@ import { richTextData } from './richTextData.js' export const seed = async (payload: Payload): Promise => { payload.logger.info('Seeding data...') try { - await payload.create({ + const user = await payload.create({ collection: 'users', data: { email: devUser.email, password: devUser.password, + name: 'name value', }, }) // create pages @@ -80,6 +81,16 @@ export const seed = async (payload: Payload): Promise => { }) } + for (let i = 0; i < 5; i++) { + await payload.create({ + collection: 'pages', + data: { + author: user.id, + title: `Virtual ${i}`, + }, + }) + } + for (let i = 0; i < 5; i++) { await payload.create({ collection: 'pages',