From e4f8478e36ec80871b476c9dc01fce0a3d55a3c2 Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Mon, 6 Oct 2025 22:44:14 +0300 Subject: [PATCH] feat: support any depth for relationships in `findDistinct` (#14090) Follow up to https://github.com/payloadcms/payload/pull/14026 ```ts // Supported before const relationResult = await payload.findDistinct({ collection: 'posts', field: 'relation1.title' }) // Supported now const relationResult = await payload.findDistinct({ collection: 'posts', field: 'relation1.relation2.title' }) const relationResult = await payload.findDistinct({ collection: 'posts', field: 'relation1.relation2.relation3.title' }) ``` --- packages/db-mongodb/src/findDistinct.ts | 92 ++++++++------- packages/drizzle/src/findDistinct.ts | 2 + .../collections/operations/findDistinct.ts | 2 + .../payload/src/utilities/getFieldByPath.ts | 21 ++++ test/database/getConfig.ts | 10 ++ test/database/int.spec.ts | 108 ++++++++++++++++++ test/database/payload-types.ts | 26 +++-- 7 files changed, 210 insertions(+), 51 deletions(-) diff --git a/packages/db-mongodb/src/findDistinct.ts b/packages/db-mongodb/src/findDistinct.ts index 0cabcaf7e..713d7d3d8 100644 --- a/packages/db-mongodb/src/findDistinct.ts +++ b/packages/db-mongodb/src/findDistinct.ts @@ -1,7 +1,7 @@ import type { PipelineStage } from 'mongoose' import type { FindDistinct, FlattenedField } from 'payload' -import { APIError, getFieldByPath } from 'payload' +import { getFieldByPath } from 'payload' import type { MongooseAdapter } from './index.js' @@ -20,7 +20,7 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter, const { where = {} } = args - const sortAggregation: PipelineStage[] = [] + let sortAggregation: PipelineStage[] = [] const sort = buildSortParam({ adapter: this, @@ -41,7 +41,9 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter, }) const fieldPathResult = getFieldByPath({ + config: this.payload.config, fields: collectionConfig.flattenedFields, + includeRelationships: true, path: args.field, }) let fieldPath = args.field @@ -55,32 +57,34 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter, 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 = '' + + let rels: { + fieldPath: string + relationTo: string + }[] = [] + + let tempPath = '' + let insideRelation = false for (const segment of args.field.split('.')) { const field = currentFields.find((e) => e.name === segment) + if (rels.length) { + insideRelation = true + } if (!field) { break } - if (relationTo) { - foundFieldPath = `${foundFieldPath}${field?.name}` + if (tempPath) { + tempPath = `${tempPath}.${field.name}` } else { - relationFieldPath = `${relationFieldPath}${field.name}` + tempPath = field.name } if ('flattenedFields' in field) { currentFields = field.flattenedFields - - if (relationTo) { - foundFieldPath = `${foundFieldPath}.` - } else { - relationFieldPath = `${relationFieldPath}.` - } continue } @@ -88,43 +92,51 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter, (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 + rels.push({ fieldPath: tempPath, 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 }) + let relationLookup: null | PipelineStage[] = null - relationLookup = { - $lookup: { - as: relationFieldPath, - foreignField: '_id', - from: foreignModel.collection.name, - localField: relationFieldPath, - }, + if (!insideRelation) { + rels = [] + } + + if (rels.length) { + if (sortProperty.startsWith('_')) { + const sortWithoutRelationPrefix = sortProperty.replace(/^_+/, '') + const lastFieldPath = rels.at(-1)?.fieldPath as string + if (sortWithoutRelationPrefix.startsWith(lastFieldPath)) { + sortProperty = sortWithoutRelationPrefix + } } + relationLookup = rels.reduce((acc, { fieldPath, relationTo }) => { + sortAggregation = sortAggregation.filter((each) => { + if ('$lookup' in each && each.$lookup.as.replace(/^_+/, '') === fieldPath) { + return false + } + + return true + }) + const { Model: foreignModel } = getCollection({ adapter: this, collectionSlug: relationTo }) + acc.push({ + $lookup: { + as: fieldPath, + foreignField: '_id', + from: foreignModel.collection.name, + localField: fieldPath, + }, + }) + acc.push({ $unwind: `$${fieldPath}` }) + return acc + }, []) } let $unwind: any = '' @@ -164,7 +176,7 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter, $match: query, }, ...(sortAggregation.length > 0 ? sortAggregation : []), - ...(relationLookup ? [relationLookup, { $unwind: `$${relationFieldPath}` }] : []), + ...(relationLookup?.length ? relationLookup : []), ...($unwind ? [ { diff --git a/packages/drizzle/src/findDistinct.ts b/packages/drizzle/src/findDistinct.ts index 408b426f0..a7876f366 100644 --- a/packages/drizzle/src/findDistinct.ts +++ b/packages/drizzle/src/findDistinct.ts @@ -64,7 +64,9 @@ export const findDistinct: FindDistinct = async function (this: DrizzleAdapter, }) const field = getFieldByPath({ + config: this.payload.config, fields: collectionConfig.flattenedFields, + includeRelationships: true, path: args.field, })?.field diff --git a/packages/payload/src/collections/operations/findDistinct.ts b/packages/payload/src/collections/operations/findDistinct.ts index a2812e918..ae603813d 100644 --- a/packages/payload/src/collections/operations/findDistinct.ts +++ b/packages/payload/src/collections/operations/findDistinct.ts @@ -118,7 +118,9 @@ export const findDistinctOperation = async ( }) const fieldResult = getFieldByPath({ + config: payload.config, fields: collectionConfig.flattenedFields, + includeRelationships: true, path: args.field, }) diff --git a/packages/payload/src/utilities/getFieldByPath.ts b/packages/payload/src/utilities/getFieldByPath.ts index 7760eaceb..2fb6c18f8 100644 --- a/packages/payload/src/utilities/getFieldByPath.ts +++ b/packages/payload/src/utilities/getFieldByPath.ts @@ -1,3 +1,4 @@ +import type { SanitizedConfig } from '../config/types.js' import type { FlattenedField } from '../fields/config/types.js' /** @@ -7,11 +8,15 @@ import type { FlattenedField } from '../fields/config/types.js' * group..title // group is localized here */ export const getFieldByPath = ({ + config, fields, + includeRelationships = false, localizedPath = '', path, }: { + config?: SanitizedConfig fields: FlattenedField[] + includeRelationships?: boolean localizedPath?: string path: string }): { @@ -45,10 +50,26 @@ export const getFieldByPath = ({ currentFields = field.flattenedFields } + if ( + config && + includeRelationships && + (field.type === 'relationship' || field.type === 'upload') && + !Array.isArray(field.relationTo) + ) { + const flattenedFields = config.collections.find( + (e) => e.slug === field.relationTo, + )?.flattenedFields + if (flattenedFields) { + currentFields = flattenedFields + } + } + if ('blocks' in field) { for (const block of field.blocks) { const maybeField = getFieldByPath({ + config, fields: block.flattenedFields, + includeRelationships, localizedPath, path: [...segments].join('.'), }) diff --git a/test/database/getConfig.ts b/test/database/getConfig.ts index 9a1006b1e..943ae8169 100644 --- a/test/database/getConfig.ts +++ b/test/database/getConfig.ts @@ -54,6 +54,11 @@ export const getConfig: () => Partial = () => ({ type: 'text', name: 'title', }, + { + name: 'simple', + type: 'relationship', + relationTo: 'simple', + }, { type: 'tabs', tabs: [ @@ -131,6 +136,11 @@ export const getConfig: () => Partial = () => ({ name: 'categoryTitle', virtual: 'category.title', }, + { + type: 'text', + name: 'categorySimpleText', + virtual: 'category.simple.text', + }, { type: 'relationship', relationTo: 'categories', diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index e932ab992..c99409ae3 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -1190,6 +1190,114 @@ describe('database', () => { ]) }) + it('should find distinct values with field nested to a 2x relationship', async () => { + await payload.delete({ collection: 'posts', where: {} }) + await payload.delete({ collection: 'categories', where: {} }) + await payload.delete({ collection: 'simple', where: {} }) + + const simple_1 = await payload.create({ collection: 'simple', data: { text: 'simple_1' } }) + const simple_2 = await payload.create({ collection: 'simple', data: { text: 'simple_2' } }) + const simple_3 = await payload.create({ collection: 'simple', data: { text: 'simple_3' } }) + + const category_1 = await payload.create({ + collection: 'categories', + data: { title: 'category_1', simple: simple_1 }, + }) + const category_2 = await payload.create({ + collection: 'categories', + data: { title: 'category_2', simple: simple_2 }, + }) + const category_3 = await payload.create({ + collection: 'categories', + data: { title: 'category_3', simple: simple_3 }, + }) + const category_4 = await payload.create({ + collection: 'categories', + data: { title: 'category_4', simple: simple_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 } }) + await payload.create({ collection: 'posts', data: { title: 'post', category: category_4 } }) + + const res = await payload.findDistinct({ + collection: 'posts', + field: 'category.simple.text', + }) + + expect(res.values).toEqual([ + { + 'category.simple.text': 'simple_1', + }, + { + 'category.simple.text': 'simple_2', + }, + { + 'category.simple.text': 'simple_3', + }, + ]) + }) + + it('should find distinct values with virtual field linked to a 2x relationship', async () => { + await payload.delete({ collection: 'posts', where: {} }) + await payload.delete({ collection: 'categories', where: {} }) + await payload.delete({ collection: 'simple', where: {} }) + + const simple_1 = await payload.create({ collection: 'simple', data: { text: 'simple_1' } }) + const simple_2 = await payload.create({ collection: 'simple', data: { text: 'simple_2' } }) + const simple_3 = await payload.create({ collection: 'simple', data: { text: 'simple_3' } }) + + const category_1 = await payload.create({ + collection: 'categories', + data: { title: 'category_1', simple: simple_1 }, + }) + const category_2 = await payload.create({ + collection: 'categories', + data: { title: 'category_2', simple: simple_2 }, + }) + const category_3 = await payload.create({ + collection: 'categories', + data: { title: 'category_3', simple: simple_3 }, + }) + const category_4 = await payload.create({ + collection: 'categories', + data: { title: 'category_4', simple: simple_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 } }) + await payload.create({ collection: 'posts', data: { title: 'post', category: category_4 } }) + + const res = await payload.findDistinct({ + collection: 'posts', + field: 'categorySimpleText', + }) + + expect(res.values).toEqual([ + { + categorySimpleText: 'simple_1', + }, + { + categorySimpleText: 'simple_2', + }, + { + categorySimpleText: 'simple_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 cf606a9d9..32845f170 100644 --- a/test/database/payload-types.ts +++ b/test/database/payload-types.ts @@ -180,6 +180,7 @@ export interface NoTimeStamp { export interface Category { id: string; title?: string | null; + simple?: (string | null) | Simple; hideout?: { camera1?: { time1Image?: (string | null) | Post; @@ -189,6 +190,17 @@ export interface Category { createdAt: string; _status?: ('draft' | 'published') | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "simple". + */ +export interface Simple { + id: string; + text?: string | null; + number?: number | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "posts". @@ -198,6 +210,7 @@ export interface Post { title: string; category?: (string | null) | Category; categoryTitle?: string | null; + categorySimpleText?: string | null; categories?: (string | Category)[] | null; categoryPoly?: { relationTo: 'categories'; @@ -318,17 +331,6 @@ export interface CategoriesCustomId { createdAt: string; _status?: ('draft' | 'published') | null; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "simple". - */ -export interface Simple { - id: string; - text?: string | null; - number?: number | null; - updatedAt: string; - createdAt: string; -} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "error-on-unnamed-fields". @@ -838,6 +840,7 @@ export interface NoTimeStampsSelect { */ export interface CategoriesSelect { title?: T; + simple?: T; hideout?: | T | { @@ -879,6 +882,7 @@ export interface PostsSelect { title?: T; category?: T; categoryTitle?: T; + categorySimpleText?: T; categories?: T; categoryPoly?: T; categoryPolyMany?: T;