From e99e054d7c3f3e1b603e20dddfd5bb607e17ed14 Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Mon, 22 Sep 2025 22:23:17 +0300 Subject: [PATCH] fix: `findDistinct` with polymorphic relationships (#13875) Fixes `findDistinct` with polymorphic relationships and also fixes a bug from https://github.com/payloadcms/payload/pull/13840 when `findDistinct` didn't work properly for `hasMany` relationships in mongodb if `sort` is the same as `field` --------- Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> --- packages/db-mongodb/src/findDistinct.ts | 12 +- packages/drizzle/src/findDistinct.ts | 25 ++- .../src/queries/getTableColumnFromPath.ts | 24 +++ test/database/getConfig.ts | 11 ++ test/database/int.spec.ts | 177 ++++++++++++++++++ test/database/payload-types.ts | 12 ++ 6 files changed, 254 insertions(+), 7 deletions(-) diff --git a/packages/db-mongodb/src/findDistinct.ts b/packages/db-mongodb/src/findDistinct.ts index 8ec994932..18e9558fa 100644 --- a/packages/db-mongodb/src/findDistinct.ts +++ b/packages/db-mongodb/src/findDistinct.ts @@ -56,22 +56,26 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter, const sortProperty = Object.keys(sort)[0]! // assert because buildSortParam always returns at least 1 key. const sortDirection = sort[sortProperty] === 'asc' ? 1 : -1 - let $unwind: string = '' - let $group: any + let $unwind: any = '' + let $group: any = null if ( isHasManyValue && sortAggregation.length && sortAggregation[0] && '$lookup' in sortAggregation[0] ) { - $unwind = `$${sortAggregation[0].$lookup.as}` + $unwind = { path: `$${sortAggregation[0].$lookup.as}`, preserveNullAndEmptyArrays: true } $group = { _id: { _field: `$${sortAggregation[0].$lookup.as}._id`, _sort: `$${sortProperty}`, }, } - } else { + } else if (isHasManyValue) { + $unwind = { path: `$${args.field}`, preserveNullAndEmptyArrays: true } + } + + if (!$group) { $group = { _id: { _field: `$${fieldPath}`, diff --git a/packages/drizzle/src/findDistinct.ts b/packages/drizzle/src/findDistinct.ts index d19e0c1c7..408b426f0 100644 --- a/packages/drizzle/src/findDistinct.ts +++ b/packages/drizzle/src/findDistinct.ts @@ -1,5 +1,4 @@ -import type { FindDistinct, SanitizedCollectionConfig } from 'payload' - +import { type FindDistinct, getFieldByPath, type SanitizedCollectionConfig } from 'payload' import toSnakeCase from 'to-snake-case' import type { DrizzleAdapter, GenericColumn } from './types.js' @@ -57,12 +56,32 @@ export const findDistinct: FindDistinct = async function (this: DrizzleAdapter, }, selectFields: { _selected: selectFields['_selected'], - ...(orderBy[0].column === selectFields['_selected'] ? {} : { _order: orderBy[0].column }), + ...(orderBy.length && + (orderBy[0].column === selectFields['_selected'] ? {} : { _order: orderBy[0]?.column })), } as Record, tableName, where, }) + const field = getFieldByPath({ + fields: collectionConfig.flattenedFields, + path: args.field, + })?.field + + if (field && 'relationTo' in field && Array.isArray(field.relationTo)) { + for (const row of selectDistinctResult as any) { + const json = JSON.parse(row._selected) + const relationTo = Object.keys(json).find((each) => Boolean(json[each])) + const value = json[relationTo] + + if (!value) { + row._selected = null + } else { + row._selected = { relationTo, value } + } + } + } + const values = selectDistinctResult.map((each) => ({ [args.field]: (each as Record)._selected, })) diff --git a/packages/drizzle/src/queries/getTableColumnFromPath.ts b/packages/drizzle/src/queries/getTableColumnFromPath.ts index ae5979541..39f970329 100644 --- a/packages/drizzle/src/queries/getTableColumnFromPath.ts +++ b/packages/drizzle/src/queries/getTableColumnFromPath.ts @@ -19,6 +19,8 @@ import type { DrizzleAdapter, GenericColumn } from '../types.js' import type { BuildQueryJoinAliases } from './buildQuery.js' import { isPolymorphicRelationship } from '../utilities/isPolymorphicRelationship.js' +import { jsonBuildObject } from '../utilities/json.js' +import { DistinctSymbol } from '../utilities/rawConstraint.js' import { resolveBlockTableName } from '../utilities/validateExistingBlockIsIdentical.js' import { addJoinTable } from './addJoinTable.js' import { getTableAlias } from './getTableAlias.js' @@ -722,6 +724,28 @@ export const getTableColumnFromPath = ({ rawColumn: sql.raw(`"${aliasRelationshipTableName}"."${relationTableName}_id"`), table: aliasRelationshipTable, } + } else if (value === DistinctSymbol) { + const obj: Record = {} + + field.relationTo.forEach((relationTo) => { + const relationTableName = adapter.tableNameMap.get( + toSnakeCase(adapter.payload.collections[relationTo].config.slug), + ) + + obj[relationTo] = sql.raw(`"${aliasRelationshipTableName}"."${relationTableName}_id"`) + }) + + let rawColumn = jsonBuildObject(adapter, obj) + if (adapter.name === 'postgres') { + rawColumn = sql`${rawColumn}::text` + } + + return { + constraints, + field, + rawColumn, + table: aliasRelationshipTable, + } } else { throw new APIError('Not supported') } diff --git a/test/database/getConfig.ts b/test/database/getConfig.ts index b4face455..5353ca5bd 100644 --- a/test/database/getConfig.ts +++ b/test/database/getConfig.ts @@ -132,6 +132,17 @@ export const getConfig: () => Partial = () => ({ hasMany: true, name: 'categories', }, + { + type: 'relationship', + relationTo: ['categories'], + name: 'categoryPoly', + }, + { + type: 'relationship', + relationTo: ['categories'], + hasMany: true, + name: 'categoryPolyMany', + }, { type: 'relationship', relationTo: 'categories-custom-id', diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index ee75016b6..27a79b596 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -923,6 +923,183 @@ describe('database', () => { expect(fromRes.categories.title).toBe(title) expect(fromRes.categories.id).toBe(id) } + + // Non-consistent sorting by ID + // eslint-disable-next-line jest/no-conditional-in-test + if (process.env.PAYLOAD_DATABASE?.includes('uuid')) { + return + } + + const resultDepth1NoSort = await payload.findDistinct({ + depth: 1, + collection: 'posts', + field: 'categories', + }) + + for (let i = 0; i < resultDepth1NoSort.values.length; i++) { + const fromRes = resultDepth1NoSort.values[i] as any + const id = categoriesIDS[i].categories as any + const title = categories[i]?.title + expect(fromRes.categories.title).toBe(title) + expect(fromRes.categories.id).toBe(id) + } + }) + + it('should populate distinct relationships of polymorphic when depth>0', 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' }, + }) + + const post_1 = await payload.create({ + collection: 'posts', + data: { title: 'post_1', categoryPoly: { relationTo: 'categories', value: category_1.id } }, + }) + const post_2 = await payload.create({ + collection: 'posts', + data: { title: 'post_2', categoryPoly: { relationTo: 'categories', value: category_1.id } }, + }) + const post_3 = await payload.create({ + collection: 'posts', + data: { title: 'post_3', categoryPoly: { relationTo: 'categories', value: category_2.id } }, + }) + const post_4 = await payload.create({ + collection: 'posts', + data: { title: 'post_4', categoryPoly: { relationTo: 'categories', value: category_3.id } }, + }) + const post_5 = await payload.create({ + collection: 'posts', + data: { title: 'post_5', categoryPoly: { relationTo: 'categories', value: category_3.id } }, + }) + + const result = await payload.findDistinct({ + depth: 0, + collection: 'posts', + field: 'categoryPoly', + }) + + expect(result.values).toHaveLength(3) + expect( + result.values.some( + (v) => + v.categoryPoly?.relationTo === 'categories' && v.categoryPoly.value === category_1.id, + ), + ).toBe(true) + expect( + result.values.some( + (v) => + v.categoryPoly?.relationTo === 'categories' && v.categoryPoly.value === category_2.id, + ), + ).toBe(true) + expect( + result.values.some( + (v) => + v.categoryPoly?.relationTo === 'categories' && v.categoryPoly.value === category_3.id, + ), + ).toBe(true) + }) + + it('should populate distinct relationships of hasMany polymorphic when depth>0', 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' }, + }) + + const post_1 = await payload.create({ + collection: 'posts', + data: { + title: 'post_1', + categoryPolyMany: [{ relationTo: 'categories', value: category_1.id }], + }, + }) + const post_2 = await payload.create({ + collection: 'posts', + data: { + title: 'post_2', + categoryPolyMany: [{ relationTo: 'categories', value: category_1.id }], + }, + }) + const post_3 = await payload.create({ + collection: 'posts', + data: { + title: 'post_3', + categoryPolyMany: [{ relationTo: 'categories', value: category_2.id }], + }, + }) + const post_4 = await payload.create({ + collection: 'posts', + data: { + title: 'post_4', + categoryPolyMany: [{ relationTo: 'categories', value: category_3.id }], + }, + }) + const post_5 = await payload.create({ + collection: 'posts', + data: { + title: 'post_5', + categoryPolyMany: [{ relationTo: 'categories', value: category_3.id }], + }, + }) + + const post_6 = await payload.create({ + collection: 'posts', + data: { + title: 'post_6', + categoryPolyMany: null, + }, + }) + + const result = await payload.findDistinct({ + depth: 0, + collection: 'posts', + field: 'categoryPolyMany', + }) + + expect(result.values).toHaveLength(4) + expect( + result.values.some( + (v) => + v.categoryPolyMany?.relationTo === 'categories' && + v.categoryPolyMany.value === category_1.id, + ), + ).toBe(true) + expect( + result.values.some( + (v) => + v.categoryPolyMany?.relationTo === 'categories' && + v.categoryPolyMany.value === category_2.id, + ), + ).toBe(true) + expect( + result.values.some( + (v) => + v.categoryPolyMany?.relationTo === 'categories' && + v.categoryPolyMany.value === category_3.id, + ), + ).toBe(true) + expect(result.values.some((v) => v.categoryPolyMany === null)).toBe(true) }) describe('Compound Indexes', () => { diff --git a/test/database/payload-types.ts b/test/database/payload-types.ts index 3fa9fa2a1..01889cafe 100644 --- a/test/database/payload-types.ts +++ b/test/database/payload-types.ts @@ -198,6 +198,16 @@ export interface Post { title: string; category?: (string | null) | Category; categories?: (string | Category)[] | null; + categoryPoly?: { + relationTo: 'categories'; + value: string | Category; + } | null; + categoryPolyMany?: + | { + relationTo: 'categories'; + value: string | Category; + }[] + | null; categoryCustomID?: (number | null) | CategoriesCustomId; localized?: string | null; text?: string | null; @@ -827,6 +837,8 @@ export interface PostsSelect { title?: T; category?: T; categories?: T; + categoryPoly?: T; + categoryPolyMany?: T; categoryCustomID?: T; localized?: T; text?: T;