Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion packages/payload/src/fields/config/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,21 @@ import type {
SanitizedJoins,
} from '../../collections/config/types.js'
import type { Config, SanitizedConfig } from '../../config/types.js'
import type { GlobalConfig } from '../../globals/config/types.js'
import type { Field } from './types.js'

import {
DuplicateFieldName,
InvalidConfiguration,
InvalidFieldName,
InvalidFieldRelationship,
MissingEditorProp,
MissingFieldType,
} from '../../errors/index.js'
import { ReservedFieldName } from '../../errors/ReservedFieldName.js'
import { flattenAllFields } from '../../utilities/flattenAllFields.js'
import { formatLabels, toWords } from '../../utilities/formatLabels.js'
import { getFieldByPath } from '../../utilities/getFieldByPath.js'
import { baseBlockFields } from '../baseFields/baseBlockFields.js'
import { baseIDField } from '../baseFields/baseIDField.js'
import { baseTimezoneField } from '../baseFields/timezone/baseField.js'
Expand All @@ -31,13 +35,19 @@ import {
reservedVerifyFieldNames,
} from './reservedFieldNames.js'
import { sanitizeJoinField } from './sanitizeJoinField.js'
import { fieldAffectsData as _fieldAffectsData, fieldIsLocalized, tabHasName } from './types.js'
import {
fieldAffectsData as _fieldAffectsData,
fieldIsLocalized,
fieldIsVirtual,
tabHasName,
} from './types.js'

type Args = {
collectionConfig?: CollectionConfig
config: Config
existingFieldNames?: Set<string>
fields: Field[]
globalConfig?: GlobalConfig
/**
* Used to prevent unnecessary sanitization of fields that are not top-level.
*/
Expand Down Expand Up @@ -72,6 +82,7 @@ export const sanitizeFields = async ({
config,
existingFieldNames = new Set(),
fields,
globalConfig,
isTopLevelField = true,
joinPath = '',
joins,
Expand Down Expand Up @@ -416,6 +427,51 @@ export const sanitizeFields = async ({

fields.splice(++i, 0, timezoneField)
}

if ('virtual' in field && typeof field.virtual === 'string') {
const virtualField = field
const fields = (collectionConfig || globalConfig)?.fields
if (fields) {
let flattenFields = flattenAllFields({ fields })
const paths = field.virtual.split('.')
let isHasMany = false

for (const [i, segment] of paths.entries()) {
const field = flattenFields.find((e) => e.name === segment)
if (!field) {
break
}

if (field.type === 'group' || field.type === 'tab' || field.type === 'array') {
flattenFields = field.flattenedFields
} else if (
(field.type === 'relationship' || field.type === 'upload') &&
i !== paths.length - 1 &&
typeof field.relationTo === 'string'
) {
if (
field.hasMany &&
(virtualField.type === 'text' ||
virtualField.type === 'number' ||
virtualField.type === 'select')
) {
if (isHasMany) {
throw new InvalidConfiguration(
`Virtual field ${virtualField.name} in ${globalConfig ? `global ${globalConfig.slug}` : `collection ${collectionConfig?.slug}`} references 2 or more hasMany relationships on the path ${virtualField.virtual} which is not allowed.`,
)
}

isHasMany = true
virtualField.hasMany = true
}
const relatedCollection = config.collections?.find((e) => e.slug === field.relationTo)
if (relatedCollection) {
flattenFields = flattenAllFields({ fields: relatedCollection.fields })
}
}
}
}
}
}

return fields
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const virtualFieldPopulationPromise = async ({
draft,
fallbackLocale,
fields,
hasMany,
locale,
overrideAccess,
ref,
Expand All @@ -19,12 +20,14 @@ export const virtualFieldPopulationPromise = async ({
draft: boolean
fallbackLocale: string
fields: FlattenedField[]
hasMany?: boolean
locale: string
name: string
overrideAccess: boolean
ref: any
req: PayloadRequest
segments: string[]
shift?: boolean
showHiddenFields: boolean
siblingDoc: Record<string, unknown>
}): Promise<void> => {
Expand All @@ -42,7 +45,14 @@ export const virtualFieldPopulationPromise = async ({

// Final step
if (segments.length === 0) {
siblingDoc[name] = currentValue
if (hasMany) {
if (!Array.isArray(siblingDoc[name])) {
siblingDoc[name] = []
}
;(siblingDoc[name] as any[]).push(currentValue)
} else {
siblingDoc[name] = currentValue
}
return
}

Expand Down Expand Up @@ -74,26 +84,8 @@ export const virtualFieldPopulationPromise = async ({

if (
(currentField.type === 'relationship' || currentField.type === 'upload') &&
typeof currentField.relationTo === 'string' &&
!currentField.hasMany
typeof currentField.relationTo === 'string'
) {
let docID: number | string

if (typeof currentValue === 'object' && currentValue) {
docID = currentValue.id
} else {
docID = currentValue
}

if (segments[0] === 'id' && segments.length === 0) {
siblingDoc[name] = docID
return
}

if (typeof docID !== 'string' && typeof docID !== 'number') {
return
}

const select = {}
let currentSelectRef: any = select
const currentFields = req.payload.collections[currentField.relationTo]?.config.flattenedFields
Expand All @@ -112,6 +104,91 @@ export const virtualFieldPopulationPromise = async ({
}
}

if (currentField.hasMany) {
if (!Array.isArray(currentValue)) {
return
}

const docIDs = currentValue
.map((e) => {
if (!e) {
return null
}
if (typeof e === 'object') {
return e.id
}
return e
})
.filter((e) => typeof e === 'string' || typeof e === 'number')

if (segments[0] === 'id' && segments.length === 0) {
siblingDoc[name] = docIDs
return
}

const collectionSlug = currentField.relationTo

const populatedDocs = await Promise.all(
docIDs.map((docID) => {
return req.payloadDataLoader.load(
createDataloaderCacheKey({
collectionSlug,
currentDepth: 0,
depth: 0,
docID,
draft,
fallbackLocale,
locale,
overrideAccess,
select,
showHiddenFields,
transactionID: req.transactionID as number,
}),
)
}),
)

for (const doc of populatedDocs) {
if (!doc) {
continue
}

await virtualFieldPopulationPromise({
name,
draft,
fallbackLocale,
fields: req.payload.collections[currentField.relationTo]!.config.flattenedFields,
hasMany: true,
locale,
overrideAccess,
ref: doc,
req,
segments: [...segments],
showHiddenFields,
siblingDoc,
})
}

return
}

let docID: number | string

if (typeof currentValue === 'object' && currentValue) {
docID = currentValue.id
} else {
docID = currentValue
}

if (segments[0] === 'id' && segments.length === 0) {
siblingDoc[name] = docID
return
}

if (typeof docID !== 'string' && typeof docID !== 'number') {
return
}

const populatedDoc = await req.payloadDataLoader.load(
createDataloaderCacheKey({
collectionSlug: currentField.relationTo,
Expand All @@ -137,6 +214,7 @@ export const virtualFieldPopulationPromise = async ({
draft,
fallbackLocale,
fields: req.payload.collections[currentField.relationTo]!.config.flattenedFields,
hasMany,
locale,
overrideAccess,
ref: populatedDoc,
Expand Down
1 change: 1 addition & 0 deletions packages/payload/src/globals/config/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export const sanitizeGlobal = async (
global.fields = await sanitizeFields({
config,
fields: global.fields,
globalConfig: global,
parentIsLocalized: false,
richTextSanitizationPromises,
validRelationships,
Expand Down
16 changes: 16 additions & 0 deletions test/database/getConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,16 @@ export const getConfig: () => Partial<Config> = () => ({
type: 'text',
virtual: 'post.title',
},
{
name: 'postsTitles',
type: 'text',
virtual: 'posts.title',
},
{
name: 'postCategoriesTitles',
type: 'text',
virtual: 'post.categories.title',
},
{
name: 'postTitleHidden',
type: 'text',
Expand Down Expand Up @@ -643,6 +653,12 @@ export const getConfig: () => Partial<Config> = () => ({
type: 'relationship',
relationTo: 'posts',
},
{
name: 'posts',
type: 'relationship',
relationTo: 'posts',
hasMany: true,
},
{
name: 'customID',
type: 'relationship',
Expand Down
52 changes: 52 additions & 0 deletions test/database/int.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2988,6 +2988,58 @@ describe('database', () => {
})
expect(docs).toHaveLength(1)
})

it('should automatically add hasMany: true to a virtual field that references a hasMany relationship', () => {
const field = payload.collections['virtual-relations'].config.fields.find(
// eslint-disable-next-line jest/no-conditional-in-test
(each) => 'name' in each && each.name === 'postsTitles',
)!

// eslint-disable-next-line jest/no-conditional-in-test
expect('hasMany' in field && field.hasMany).toBe(true)
})

it('should the value populate with hasMany: true relationship field', async () => {
await payload.delete({ collection: 'categories', where: {} })
await payload.delete({ collection: 'posts', where: {} })
await payload.delete({ collection: 'virtual-relations', where: {} })

const post1 = await payload.create({ collection: 'posts', data: { title: 'post 1' } })
const post2 = await payload.create({ collection: 'posts', data: { title: 'post 2' } })

const res = await payload.create({
collection: 'virtual-relations',
depth: 0,
data: { posts: [post1.id, post2.id] },
})
expect(res.postsTitles).toEqual(['post 1', 'post 2'])
})

it('should the value populate with nested hasMany: true relationship field', async () => {
await payload.delete({ collection: 'categories', where: {} })
await payload.delete({ collection: 'posts', where: {} })
await payload.delete({ collection: 'virtual-relations', where: {} })

const category_1 = await payload.create({
collection: 'categories',
data: { title: 'category 1' },
})
const category_2 = await payload.create({
collection: 'categories',
data: { title: 'category 2' },
})
const post1 = await payload.create({
collection: 'posts',
data: { title: 'post 1', categories: [category_1.id, category_2.id] },
})

const res = await payload.create({
collection: 'virtual-relations',
depth: 0,
data: { post: post1.id },
})
expect(res.postCategoriesTitles).toEqual(['category 1', 'category 2'])
})
})

it('should convert numbers to text', async () => {
Expand Down
Loading
Loading