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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -81,6 +81,7 @@ export const sanitizeGlobal = async (
|
||||
global.fields = await sanitizeFields({
|
||||
config,
|
||||
fields: global.fields,
|
||||
globalConfig: global,
|
||||
parentIsLocalized: false,
|
||||
richTextSanitizationPromises,
|
||||
validRelationships,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user