From 9d6cae04459ad885dc2116a6d4c4c5b5cefea4ed Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Thu, 2 Oct 2025 05:36:30 +0300 Subject: [PATCH] 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' }) ``` --- packages/db-mongodb/src/findDistinct.ts | 82 +++++++++++++++-- .../collections/operations/findDistinct.ts | 51 +++++++++++ .../operations/local/findDistinct.ts | 2 +- test/database/getConfig.ts | 5 ++ test/database/int.spec.ts | 88 +++++++++++++++++++ test/database/payload-types.ts | 79 ++++++++++------- 6 files changed, 271 insertions(+), 36 deletions(-) diff --git a/packages/db-mongodb/src/findDistinct.ts b/packages/db-mongodb/src/findDistinct.ts index 18e9558fa..0cabcaf7e 100644 --- a/packages/db-mongodb/src/findDistinct.ts +++ b/packages/db-mongodb/src/findDistinct.ts @@ -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('', 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 ? [ { diff --git a/packages/payload/src/collections/operations/findDistinct.ts b/packages/payload/src/collections/operations/findDistinct.ts index 5df99f845..a2812e918 100644 --- a/packages/payload/src/collections/operations/findDistinct.ts +++ b/packages/payload/src/collections/operations/findDistinct.ts @@ -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, diff --git a/packages/payload/src/collections/operations/local/findDistinct.ts b/packages/payload/src/collections/operations/local/findDistinct.ts index 0de10c325..8b30abd0a 100644 --- a/packages/payload/src/collections/operations/local/findDistinct.ts +++ b/packages/payload/src/collections/operations/local/findDistinct.ts @@ -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. diff --git a/test/database/getConfig.ts b/test/database/getConfig.ts index c6f31c422..9a1006b1e 100644 --- a/test/database/getConfig.ts +++ b/test/database/getConfig.ts @@ -126,6 +126,11 @@ export const getConfig: () => Partial = () => ({ relationTo: 'categories', name: 'category', }, + { + type: 'text', + name: 'categoryTitle', + virtual: 'category.title', + }, { type: 'relationship', relationTo: 'categories', diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index 1ee888d43..e932ab992 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -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: {} }) diff --git a/test/database/payload-types.ts b/test/database/payload-types.ts index 838faed07..cf606a9d9 100644 --- a/test/database/payload-types.ts +++ b/test/database/payload-types.ts @@ -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 { export interface PostsSelect { title?: T; category?: T; + categoryTitle?: T; categories?: T; categoryPoly?: T; categoryPolyMany?: T; @@ -898,6 +909,14 @@ export interface PostsSelect { 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 {} -} +} \ No newline at end of file