feat: allow findDistinct on fields nested to relationships and on virtual fields (#14026)

This adds support for using `findDistinct`
https://github.com/payloadcms/payload/pull/13102 on fields:
* Nested to a relationship, for example `category.title`
* Virtual fields that are linked to relationships, for example
`categoryTitle`


```tsx
const Category: CollectionConfig = {
  slug: 'categories',
  fields: [
    {
      name: 'title',
      type: 'text',
    },
  ],
}

const Posts: CollectionConfig = {
  slug: 'posts',
  fields: [
    {
      name: 'category',
      type: 'relationship',
      relationTo: 'categories',
    },
    {
      name: 'categoryTitle',
      type: 'text',
      virtual: 'category.title',
    },
  ],
}

// Supported now
const relationResult = await payload.findDistinct({ collection: 'posts', field: 'category.title' })
// Supported now
const virtualResult = await payload.findDistinct({ collection: 'posts', field: 'categoryTitle' })
```
This commit is contained in:
Sasha
2025-10-02 05:36:30 +03:00
committed by GitHub
parent 95bdffd11f
commit 9d6cae0445
6 changed files with 271 additions and 36 deletions

View File

@@ -1,6 +1,7 @@
import type { PipelineStage } from 'mongoose' import type { PipelineStage } from 'mongoose'
import type { FindDistinct, FlattenedField } from 'payload'
import { type FindDistinct, getFieldByPath } from 'payload' import { APIError, getFieldByPath } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
@@ -48,14 +49,84 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter,
fieldPath = fieldPathResult.localizedPath.replace('<locale>', args.locale) fieldPath = fieldPathResult.localizedPath.replace('<locale>', args.locale)
} }
const isHasManyValue =
fieldPathResult && 'hasMany' in fieldPathResult.field && fieldPathResult.field.hasMany
const page = args.page || 1 const page = args.page || 1
const sortProperty = Object.keys(sort)[0]! // assert because buildSortParam always returns at least 1 key. let sortProperty = Object.keys(sort)[0]! // assert because buildSortParam always returns at least 1 key.
const sortDirection = sort[sortProperty] === 'asc' ? 1 : -1 const sortDirection = sort[sortProperty] === 'asc' ? 1 : -1
let currentFields = collectionConfig.flattenedFields
let relationTo: null | string = null
let foundField: FlattenedField | null = null
let foundFieldPath = ''
let relationFieldPath = ''
for (const segment of args.field.split('.')) {
const field = currentFields.find((e) => e.name === segment)
if (!field) {
break
}
if (relationTo) {
foundFieldPath = `${foundFieldPath}${field?.name}`
} else {
relationFieldPath = `${relationFieldPath}${field.name}`
}
if ('flattenedFields' in field) {
currentFields = field.flattenedFields
if (relationTo) {
foundFieldPath = `${foundFieldPath}.`
} else {
relationFieldPath = `${relationFieldPath}.`
}
continue
}
if (
(field.type === 'relationship' || field.type === 'upload') &&
typeof field.relationTo === 'string'
) {
if (relationTo) {
throw new APIError(
`findDistinct for fields nested to relationships supported 1 level only, errored field: ${args.field}`,
)
}
relationTo = field.relationTo
currentFields = this.payload.collections[field.relationTo]?.config
.flattenedFields as FlattenedField[]
continue
}
foundField = field
if (
sortAggregation.some(
(stage) => '$lookup' in stage && stage.$lookup.localField === relationFieldPath,
)
) {
sortProperty = sortProperty.replace('__', '')
sortAggregation.pop()
}
}
const resolvedField = foundField || fieldPathResult?.field
const isHasManyValue = resolvedField && 'hasMany' in resolvedField && resolvedField
let relationLookup: null | PipelineStage = null
if (relationTo && foundFieldPath && relationFieldPath) {
const { Model: foreignModel } = getCollection({ adapter: this, collectionSlug: relationTo })
relationLookup = {
$lookup: {
as: relationFieldPath,
foreignField: '_id',
from: foreignModel.collection.name,
localField: relationFieldPath,
},
}
}
let $unwind: any = '' let $unwind: any = ''
let $group: any = null let $group: any = null
if ( if (
@@ -93,6 +164,7 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter,
$match: query, $match: query,
}, },
...(sortAggregation.length > 0 ? sortAggregation : []), ...(sortAggregation.length > 0 ? sortAggregation : []),
...(relationLookup ? [relationLookup, { $unwind: `$${relationFieldPath}` }] : []),
...($unwind ...($unwind
? [ ? [
{ {

View File

@@ -2,6 +2,7 @@ import httpStatus from 'http-status'
import type { AccessResult } from '../../config/types.js' import type { AccessResult } from '../../config/types.js'
import type { PaginatedDistinctDocs } from '../../database/types.js' import type { PaginatedDistinctDocs } from '../../database/types.js'
import type { FlattenedField } from '../../fields/config/types.js'
import type { PayloadRequest, PopulateType, Sort, Where } from '../../types/index.js' import type { PayloadRequest, PopulateType, Sort, Where } from '../../types/index.js'
import type { Collection } from '../config/types.js' import type { Collection } from '../config/types.js'
@@ -139,6 +140,56 @@ export const findDistinctOperation = async (
} }
} }
if ('virtual' in fieldResult.field && fieldResult.field.virtual) {
if (typeof fieldResult.field.virtual !== 'string') {
throw new APIError(
`Cannot findDistinct by a virtual field that isn't linked to a relationship field.`,
)
}
let relationPath: string = ''
let currentFields: FlattenedField[] = collectionConfig.flattenedFields
const fieldPathSegments = fieldResult.field.virtual.split('.')
for (const segment of fieldResult.field.virtual.split('.')) {
relationPath = `${relationPath}${segment}`
fieldPathSegments.shift()
const field = currentFields.find((e) => e.name === segment)!
if (
(field.type === 'relationship' || field.type === 'upload') &&
typeof field.relationTo === 'string'
) {
break
}
if ('flattenedFields' in field) {
currentFields = field.flattenedFields
}
}
const path = `${relationPath}.${fieldPathSegments.join('.')}`
const result = await payload.findDistinct({
collection: collectionConfig.slug,
depth: args.depth,
disableErrors,
field: path,
locale,
overrideAccess,
populate,
req,
showHiddenFields,
sort: args.sort,
trash,
where,
})
for (const val of result.values) {
val[args.field] = val[path]
delete val[path]
}
return result
}
let result = await payload.db.findDistinct({ let result = await payload.db.findDistinct({
collection: collectionConfig.slug, collection: collectionConfig.slug,
field: args.field, field: args.field,

View File

@@ -42,7 +42,7 @@ export type Options<
/** /**
* The field to get distinct values for * The field to get distinct values for
*/ */
field: TField field: ({} & string) | TField
/** /**
* The maximum distinct field values to be returned. * The maximum distinct field values to be returned.
* By default the operation returns all the values. * By default the operation returns all the values.

View File

@@ -126,6 +126,11 @@ export const getConfig: () => Partial<Config> = () => ({
relationTo: 'categories', relationTo: 'categories',
name: 'category', name: 'category',
}, },
{
type: 'text',
name: 'categoryTitle',
virtual: 'category.title',
},
{ {
type: 'relationship', type: 'relationship',
relationTo: 'categories', relationTo: 'categories',

View File

@@ -1102,6 +1102,94 @@ describe('database', () => {
expect(result.values.some((v) => v.categoryPolyMany === null)).toBe(true) expect(result.values.some((v) => v.categoryPolyMany === null)).toBe(true)
}) })
it('should find distinct values with field nested to a relationship', async () => {
await payload.delete({ collection: 'posts', where: {} })
await payload.delete({ collection: 'categories', 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 category_3 = await payload.create({
collection: 'categories',
data: { title: 'category_3' },
})
await payload.create({ collection: 'posts', data: { title: 'post', category: category_1 } })
await payload.create({ collection: 'posts', data: { title: 'post', category: category_2 } })
await payload.create({ collection: 'posts', data: { title: 'post', category: category_2 } })
await payload.create({ collection: 'posts', data: { title: 'post', category: category_2 } })
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
const res = await payload.findDistinct({
collection: 'posts',
field: 'category.title',
})
expect(res.values).toEqual([
{
'category.title': 'category_1',
},
{
'category.title': 'category_2',
},
{
'category.title': 'category_3',
},
])
})
it('should find distinct values with virtual field linked to a relationship', async () => {
await payload.delete({ collection: 'posts', where: {} })
await payload.delete({ collection: 'categories', 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 category_3 = await payload.create({
collection: 'categories',
data: { title: 'category_3' },
})
await payload.create({ collection: 'posts', data: { title: 'post', category: category_1 } })
await payload.create({ collection: 'posts', data: { title: 'post', category: category_2 } })
await payload.create({ collection: 'posts', data: { title: 'post', category: category_2 } })
await payload.create({ collection: 'posts', data: { title: 'post', category: category_2 } })
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
const res = await payload.findDistinct({
collection: 'posts',
field: 'categoryTitle',
})
expect(res.values).toEqual([
{
categoryTitle: 'category_1',
},
{
categoryTitle: 'category_2',
},
{
categoryTitle: 'category_3',
},
])
})
describe('Compound Indexes', () => { describe('Compound Indexes', () => {
beforeEach(async () => { beforeEach(async () => {
await payload.delete({ collection: 'compound-indexes', where: {} }) await payload.delete({ collection: 'compound-indexes', where: {} })

View File

@@ -197,6 +197,7 @@ export interface Post {
id: string; id: string;
title: string; title: string;
category?: (string | null) | Category; category?: (string | null) | Category;
categoryTitle?: string | null;
categories?: (string | Category)[] | null; categories?: (string | Category)[] | null;
categoryPoly?: { categoryPoly?: {
relationTo: 'categories'; relationTo: 'categories';
@@ -210,22 +211,28 @@ export interface Post {
| null; | null;
categoryCustomID?: (number | null) | CategoriesCustomId; categoryCustomID?: (number | null) | CategoriesCustomId;
polymorphicRelations?: polymorphicRelations?:
| ({ | (
relationTo: 'categories'; | {
value: string | Category; relationTo: 'categories';
} | { value: string | Category;
relationTo: 'simple'; }
value: string | Simple; | {
})[] relationTo: 'simple';
value: string | Simple;
}
)[]
| null; | null;
localizedPolymorphicRelations?: localizedPolymorphicRelations?:
| ({ | (
relationTo: 'categories'; | {
value: string | Category; relationTo: 'categories';
} | { value: string | Category;
relationTo: 'simple'; }
value: string | Simple; | {
})[] relationTo: 'simple';
value: string | Simple;
}
)[]
| null; | null;
localized?: string | null; localized?: string | null;
text?: string | null; text?: string | null;
@@ -245,6 +252,23 @@ export interface Post {
blockType: 'block-third'; blockType: 'block-third';
}[] }[]
| null; | null;
testNestedGroup?: {
nestedLocalizedPolymorphicRelation?:
| (
| {
relationTo: 'categories';
value: string | Category;
}
| {
relationTo: 'simple';
value: string | Simple;
}
)[]
| null;
nestedLocalizedText?: string | null;
nestedText1?: string | null;
nestedText2?: string | null;
};
D1?: { D1?: {
D2?: { D2?: {
D3?: { D3?: {
@@ -252,20 +276,6 @@ export interface Post {
}; };
}; };
}; };
testNestedGroup?: {
nestedLocalizedPolymorphicRelation?:
| ({
relationTo: 'categories';
value: string | Category;
} | {
relationTo: 'simple';
value: string | Simple;
})[]
| null;
nestedLocalizedText?: string | null;
nestedText1?: string | null;
nestedText2?: string | null;
};
hasTransaction?: boolean | null; hasTransaction?: boolean | null;
throwAfterChange?: boolean | null; throwAfterChange?: boolean | null;
arrayWithIDs?: arrayWithIDs?:
@@ -868,6 +878,7 @@ export interface CategoriesCustomIdSelect<T extends boolean = true> {
export interface PostsSelect<T extends boolean = true> { export interface PostsSelect<T extends boolean = true> {
title?: T; title?: T;
category?: T; category?: T;
categoryTitle?: T;
categories?: T; categories?: T;
categoryPoly?: T; categoryPoly?: T;
categoryPolyMany?: T; categoryPolyMany?: T;
@@ -898,6 +909,14 @@ export interface PostsSelect<T extends boolean = true> {
blockName?: T; blockName?: T;
}; };
}; };
testNestedGroup?:
| T
| {
nestedLocalizedPolymorphicRelation?: T;
nestedLocalizedText?: T;
nestedText1?: T;
nestedText2?: T;
};
D1?: D1?:
| T | T
| { | {
@@ -1455,6 +1474,6 @@ export interface Auth {
declare module 'payload' { declare module 'payload' {
// @ts-ignore // @ts-ignore
export interface GeneratedTypes extends Config {} export interface GeneratedTypes extends Config {}
} }