feat: support hasMany virtual relationship fields (#13879)

This PR adds support for the following configuration:
```ts
const config = {
  collections: [
    {
      slug: 'categories',
      fields: [
        {
          name: 'title',
          type: 'text',
        },
      ],
    },
    {
      slug: 'posts',
      fields: [
        {
          name: 'title',
          type: 'text',
        },
        {
          name: 'categories',
          type: 'relationship',
          relationTo: 'categories',
          hasMany: true,
        },
      ],
    },
    {
      slug: 'examples',
      fields: [
        {
          name: 'postCategoriesTitles',
          type: 'text',
          virtual: 'post.categories.title',
          // hasMany: true - added automatically during the sanitization
        },
        {
          type: 'relationship',
          relationTo: 'posts',
          name: 'post',
        },
        {
          name: 'postsTitles',
          type: 'text',
          virtual: 'posts.title',
          // hasMany: true - added automatically during the sanitization
        },
        {
          type: 'relationship',
          relationTo: 'posts',
          name: 'posts',
          hasMany: true,
        },
      ],
    },
  ],
}
```

In the result:
`postsTitles` - will be always populated with an array of posts titles.
`postCategoriesTitles` - will be always populated with an array of the
categories titles that are related to this post

The virtual `text` field is sanitizated to `hasMany: true`
automatically, but you can specify that manually as well.
This commit is contained in:
Sasha
2025-09-19 21:04:07 +03:00
committed by GitHub
parent 207caa570c
commit 1072171f97
6 changed files with 232 additions and 23 deletions

View File

@@ -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'
@@ -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.
*/
@@ -72,6 +82,7 @@ export const sanitizeFields = async ({
config,
existingFieldNames = new Set(),
fields,
globalConfig,
isTopLevelField = true,
joinPath = '',
joins,
@@ -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

View File

@@ -8,6 +8,7 @@ export const virtualFieldPopulationPromise = async ({
draft,
fallbackLocale,
fields,
hasMany,
locale,
overrideAccess,
ref,
@@ -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> => {
@@ -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
}
@@ -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
@@ -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,
@@ -137,6 +214,7 @@ export const virtualFieldPopulationPromise = async ({
draft,
fallbackLocale,
fields: req.payload.collections[currentField.relationTo]!.config.flattenedFields,
hasMany,
locale,
overrideAccess,
ref: populatedDoc,

View File

@@ -81,6 +81,7 @@ export const sanitizeGlobal = async (
global.fields = await sanitizeFields({
config,
fields: global.fields,
globalConfig: global,
parentIsLocalized: false,
richTextSanitizationPromises,
validRelationships,

View File

@@ -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',
@@ -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',

View File

@@ -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 () => {

View File

@@ -327,7 +327,7 @@ export interface RelationA {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -353,7 +353,7 @@ export interface RelationB {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -450,6 +450,8 @@ export interface Place {
export interface VirtualRelation {
id: string;
postTitle?: string | null;
postsTitles?: string[] | null;
postCategoriesTitles?: string[] | null;
postTitleHidden?: string | null;
postCategoryTitle?: string | null;
postCategoryID?:
@@ -473,6 +475,7 @@ export interface VirtualRelation {
| null;
postLocalized?: string | null;
post?: (string | null) | Post;
posts?: (string | Post)[] | null;
customID?: (string | null) | CustomId;
customIDValue?: string | null;
updatedAt: string;
@@ -1046,6 +1049,8 @@ export interface PlacesSelect<T extends boolean = true> {
*/
export interface VirtualRelationsSelect<T extends boolean = true> {
postTitle?: T;
postsTitles?: T;
postCategoriesTitles?: T;
postTitleHidden?: T;
postCategoryTitle?: T;
postCategoryID?: T;
@@ -1053,6 +1058,7 @@ export interface VirtualRelationsSelect<T extends boolean = true> {
postID?: T;
postLocalized?: T;
post?: T;
posts?: T;
customID?: T;
customIDValue?: T;
updatedAt?: T;