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

@@ -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;