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 { FindDistinct, FlattenedField } from 'payload'
import { type FindDistinct, getFieldByPath } from 'payload'
import { APIError, getFieldByPath } from 'payload'
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)
}
const isHasManyValue =
fieldPathResult && 'hasMany' in fieldPathResult.field && fieldPathResult.field.hasMany
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
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 $group: any = null
if (
@@ -93,6 +164,7 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter,
$match: query,
},
...(sortAggregation.length > 0 ? sortAggregation : []),
...(relationLookup ? [relationLookup, { $unwind: `$${relationFieldPath}` }] : []),
...($unwind
? [
{

View File

@@ -2,6 +2,7 @@ import httpStatus from 'http-status'
import type { AccessResult } from '../../config/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 { 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({
collection: collectionConfig.slug,
field: args.field,

View File

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

View File

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

View File

@@ -1102,6 +1102,94 @@ describe('database', () => {
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', () => {
beforeEach(async () => {
await payload.delete({ collection: 'compound-indexes', where: {} })

View File

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