Skip to content

Commit 1072171

Browse files
authored
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.
1 parent 207caa5 commit 1072171

File tree

6 files changed

+232
-23
lines changed

6 files changed

+232
-23
lines changed

packages/payload/src/fields/config/sanitize.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,21 @@ import type {
77
SanitizedJoins,
88
} from '../../collections/config/types.js'
99
import type { Config, SanitizedConfig } from '../../config/types.js'
10+
import type { GlobalConfig } from '../../globals/config/types.js'
1011
import type { Field } from './types.js'
1112

1213
import {
1314
DuplicateFieldName,
15+
InvalidConfiguration,
1416
InvalidFieldName,
1517
InvalidFieldRelationship,
1618
MissingEditorProp,
1719
MissingFieldType,
1820
} from '../../errors/index.js'
1921
import { ReservedFieldName } from '../../errors/ReservedFieldName.js'
22+
import { flattenAllFields } from '../../utilities/flattenAllFields.js'
2023
import { formatLabels, toWords } from '../../utilities/formatLabels.js'
24+
import { getFieldByPath } from '../../utilities/getFieldByPath.js'
2125
import { baseBlockFields } from '../baseFields/baseBlockFields.js'
2226
import { baseIDField } from '../baseFields/baseIDField.js'
2327
import { baseTimezoneField } from '../baseFields/timezone/baseField.js'
@@ -31,13 +35,19 @@ import {
3135
reservedVerifyFieldNames,
3236
} from './reservedFieldNames.js'
3337
import { sanitizeJoinField } from './sanitizeJoinField.js'
34-
import { fieldAffectsData as _fieldAffectsData, fieldIsLocalized, tabHasName } from './types.js'
38+
import {
39+
fieldAffectsData as _fieldAffectsData,
40+
fieldIsLocalized,
41+
fieldIsVirtual,
42+
tabHasName,
43+
} from './types.js'
3544

3645
type Args = {
3746
collectionConfig?: CollectionConfig
3847
config: Config
3948
existingFieldNames?: Set<string>
4049
fields: Field[]
50+
globalConfig?: GlobalConfig
4151
/**
4252
* Used to prevent unnecessary sanitization of fields that are not top-level.
4353
*/
@@ -72,6 +82,7 @@ export const sanitizeFields = async ({
7282
config,
7383
existingFieldNames = new Set(),
7484
fields,
85+
globalConfig,
7586
isTopLevelField = true,
7687
joinPath = '',
7788
joins,
@@ -416,6 +427,51 @@ export const sanitizeFields = async ({
416427

417428
fields.splice(++i, 0, timezoneField)
418429
}
430+
431+
if ('virtual' in field && typeof field.virtual === 'string') {
432+
const virtualField = field
433+
const fields = (collectionConfig || globalConfig)?.fields
434+
if (fields) {
435+
let flattenFields = flattenAllFields({ fields })
436+
const paths = field.virtual.split('.')
437+
let isHasMany = false
438+
439+
for (const [i, segment] of paths.entries()) {
440+
const field = flattenFields.find((e) => e.name === segment)
441+
if (!field) {
442+
break
443+
}
444+
445+
if (field.type === 'group' || field.type === 'tab' || field.type === 'array') {
446+
flattenFields = field.flattenedFields
447+
} else if (
448+
(field.type === 'relationship' || field.type === 'upload') &&
449+
i !== paths.length - 1 &&
450+
typeof field.relationTo === 'string'
451+
) {
452+
if (
453+
field.hasMany &&
454+
(virtualField.type === 'text' ||
455+
virtualField.type === 'number' ||
456+
virtualField.type === 'select')
457+
) {
458+
if (isHasMany) {
459+
throw new InvalidConfiguration(
460+
`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.`,
461+
)
462+
}
463+
464+
isHasMany = true
465+
virtualField.hasMany = true
466+
}
467+
const relatedCollection = config.collections?.find((e) => e.slug === field.relationTo)
468+
if (relatedCollection) {
469+
flattenFields = flattenAllFields({ fields: relatedCollection.fields })
470+
}
471+
}
472+
}
473+
}
474+
}
419475
}
420476

421477
return fields

packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts

Lines changed: 98 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const virtualFieldPopulationPromise = async ({
88
draft,
99
fallbackLocale,
1010
fields,
11+
hasMany,
1112
locale,
1213
overrideAccess,
1314
ref,
@@ -19,12 +20,14 @@ export const virtualFieldPopulationPromise = async ({
1920
draft: boolean
2021
fallbackLocale: string
2122
fields: FlattenedField[]
23+
hasMany?: boolean
2224
locale: string
2325
name: string
2426
overrideAccess: boolean
2527
ref: any
2628
req: PayloadRequest
2729
segments: string[]
30+
shift?: boolean
2831
showHiddenFields: boolean
2932
siblingDoc: Record<string, unknown>
3033
}): Promise<void> => {
@@ -42,7 +45,14 @@ export const virtualFieldPopulationPromise = async ({
4245

4346
// Final step
4447
if (segments.length === 0) {
45-
siblingDoc[name] = currentValue
48+
if (hasMany) {
49+
if (!Array.isArray(siblingDoc[name])) {
50+
siblingDoc[name] = []
51+
}
52+
;(siblingDoc[name] as any[]).push(currentValue)
53+
} else {
54+
siblingDoc[name] = currentValue
55+
}
4656
return
4757
}
4858

@@ -74,26 +84,8 @@ export const virtualFieldPopulationPromise = async ({
7484

7585
if (
7686
(currentField.type === 'relationship' || currentField.type === 'upload') &&
77-
typeof currentField.relationTo === 'string' &&
78-
!currentField.hasMany
87+
typeof currentField.relationTo === 'string'
7988
) {
80-
let docID: number | string
81-
82-
if (typeof currentValue === 'object' && currentValue) {
83-
docID = currentValue.id
84-
} else {
85-
docID = currentValue
86-
}
87-
88-
if (segments[0] === 'id' && segments.length === 0) {
89-
siblingDoc[name] = docID
90-
return
91-
}
92-
93-
if (typeof docID !== 'string' && typeof docID !== 'number') {
94-
return
95-
}
96-
9789
const select = {}
9890
let currentSelectRef: any = select
9991
const currentFields = req.payload.collections[currentField.relationTo]?.config.flattenedFields
@@ -112,6 +104,91 @@ export const virtualFieldPopulationPromise = async ({
112104
}
113105
}
114106

107+
if (currentField.hasMany) {
108+
if (!Array.isArray(currentValue)) {
109+
return
110+
}
111+
112+
const docIDs = currentValue
113+
.map((e) => {
114+
if (!e) {
115+
return null
116+
}
117+
if (typeof e === 'object') {
118+
return e.id
119+
}
120+
return e
121+
})
122+
.filter((e) => typeof e === 'string' || typeof e === 'number')
123+
124+
if (segments[0] === 'id' && segments.length === 0) {
125+
siblingDoc[name] = docIDs
126+
return
127+
}
128+
129+
const collectionSlug = currentField.relationTo
130+
131+
const populatedDocs = await Promise.all(
132+
docIDs.map((docID) => {
133+
return req.payloadDataLoader.load(
134+
createDataloaderCacheKey({
135+
collectionSlug,
136+
currentDepth: 0,
137+
depth: 0,
138+
docID,
139+
draft,
140+
fallbackLocale,
141+
locale,
142+
overrideAccess,
143+
select,
144+
showHiddenFields,
145+
transactionID: req.transactionID as number,
146+
}),
147+
)
148+
}),
149+
)
150+
151+
for (const doc of populatedDocs) {
152+
if (!doc) {
153+
continue
154+
}
155+
156+
await virtualFieldPopulationPromise({
157+
name,
158+
draft,
159+
fallbackLocale,
160+
fields: req.payload.collections[currentField.relationTo]!.config.flattenedFields,
161+
hasMany: true,
162+
locale,
163+
overrideAccess,
164+
ref: doc,
165+
req,
166+
segments: [...segments],
167+
showHiddenFields,
168+
siblingDoc,
169+
})
170+
}
171+
172+
return
173+
}
174+
175+
let docID: number | string
176+
177+
if (typeof currentValue === 'object' && currentValue) {
178+
docID = currentValue.id
179+
} else {
180+
docID = currentValue
181+
}
182+
183+
if (segments[0] === 'id' && segments.length === 0) {
184+
siblingDoc[name] = docID
185+
return
186+
}
187+
188+
if (typeof docID !== 'string' && typeof docID !== 'number') {
189+
return
190+
}
191+
115192
const populatedDoc = await req.payloadDataLoader.load(
116193
createDataloaderCacheKey({
117194
collectionSlug: currentField.relationTo,
@@ -137,6 +214,7 @@ export const virtualFieldPopulationPromise = async ({
137214
draft,
138215
fallbackLocale,
139216
fields: req.payload.collections[currentField.relationTo]!.config.flattenedFields,
217+
hasMany,
140218
locale,
141219
overrideAccess,
142220
ref: populatedDoc,

packages/payload/src/globals/config/sanitize.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export const sanitizeGlobal = async (
8181
global.fields = await sanitizeFields({
8282
config,
8383
fields: global.fields,
84+
globalConfig: global,
8485
parentIsLocalized: false,
8586
richTextSanitizationPromises,
8687
validRelationships,

test/database/getConfig.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,16 @@ export const getConfig: () => Partial<Config> = () => ({
607607
type: 'text',
608608
virtual: 'post.title',
609609
},
610+
{
611+
name: 'postsTitles',
612+
type: 'text',
613+
virtual: 'posts.title',
614+
},
615+
{
616+
name: 'postCategoriesTitles',
617+
type: 'text',
618+
virtual: 'post.categories.title',
619+
},
610620
{
611621
name: 'postTitleHidden',
612622
type: 'text',
@@ -643,6 +653,12 @@ export const getConfig: () => Partial<Config> = () => ({
643653
type: 'relationship',
644654
relationTo: 'posts',
645655
},
656+
{
657+
name: 'posts',
658+
type: 'relationship',
659+
relationTo: 'posts',
660+
hasMany: true,
661+
},
646662
{
647663
name: 'customID',
648664
type: 'relationship',

test/database/int.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2988,6 +2988,58 @@ describe('database', () => {
29882988
})
29892989
expect(docs).toHaveLength(1)
29902990
})
2991+
2992+
it('should automatically add hasMany: true to a virtual field that references a hasMany relationship', () => {
2993+
const field = payload.collections['virtual-relations'].config.fields.find(
2994+
// eslint-disable-next-line jest/no-conditional-in-test
2995+
(each) => 'name' in each && each.name === 'postsTitles',
2996+
)!
2997+
2998+
// eslint-disable-next-line jest/no-conditional-in-test
2999+
expect('hasMany' in field && field.hasMany).toBe(true)
3000+
})
3001+
3002+
it('should the value populate with hasMany: true relationship field', async () => {
3003+
await payload.delete({ collection: 'categories', where: {} })
3004+
await payload.delete({ collection: 'posts', where: {} })
3005+
await payload.delete({ collection: 'virtual-relations', where: {} })
3006+
3007+
const post1 = await payload.create({ collection: 'posts', data: { title: 'post 1' } })
3008+
const post2 = await payload.create({ collection: 'posts', data: { title: 'post 2' } })
3009+
3010+
const res = await payload.create({
3011+
collection: 'virtual-relations',
3012+
depth: 0,
3013+
data: { posts: [post1.id, post2.id] },
3014+
})
3015+
expect(res.postsTitles).toEqual(['post 1', 'post 2'])
3016+
})
3017+
3018+
it('should the value populate with nested hasMany: true relationship field', async () => {
3019+
await payload.delete({ collection: 'categories', where: {} })
3020+
await payload.delete({ collection: 'posts', where: {} })
3021+
await payload.delete({ collection: 'virtual-relations', where: {} })
3022+
3023+
const category_1 = await payload.create({
3024+
collection: 'categories',
3025+
data: { title: 'category 1' },
3026+
})
3027+
const category_2 = await payload.create({
3028+
collection: 'categories',
3029+
data: { title: 'category 2' },
3030+
})
3031+
const post1 = await payload.create({
3032+
collection: 'posts',
3033+
data: { title: 'post 1', categories: [category_1.id, category_2.id] },
3034+
})
3035+
3036+
const res = await payload.create({
3037+
collection: 'virtual-relations',
3038+
depth: 0,
3039+
data: { post: post1.id },
3040+
})
3041+
expect(res.postCategoriesTitles).toEqual(['category 1', 'category 2'])
3042+
})
29913043
})
29923044

29933045
it('should convert numbers to text', async () => {

0 commit comments

Comments
 (0)