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>
This commit is contained in:
Sasha
2025-09-22 22:23:17 +03:00
committed by GitHub
parent 82d98ab375
commit e99e054d7c
6 changed files with 254 additions and 7 deletions

View File

@@ -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 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 $unwind: string = '' let $unwind: any = ''
let $group: any let $group: any = null
if ( if (
isHasManyValue && isHasManyValue &&
sortAggregation.length && sortAggregation.length &&
sortAggregation[0] && sortAggregation[0] &&
'$lookup' in sortAggregation[0] '$lookup' in sortAggregation[0]
) { ) {
$unwind = `$${sortAggregation[0].$lookup.as}` $unwind = { path: `$${sortAggregation[0].$lookup.as}`, preserveNullAndEmptyArrays: true }
$group = { $group = {
_id: { _id: {
_field: `$${sortAggregation[0].$lookup.as}._id`, _field: `$${sortAggregation[0].$lookup.as}._id`,
_sort: `$${sortProperty}`, _sort: `$${sortProperty}`,
}, },
} }
} else { } else if (isHasManyValue) {
$unwind = { path: `$${args.field}`, preserveNullAndEmptyArrays: true }
}
if (!$group) {
$group = { $group = {
_id: { _id: {
_field: `$${fieldPath}`, _field: `$${fieldPath}`,

View File

@@ -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 toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter, GenericColumn } from './types.js' import type { DrizzleAdapter, GenericColumn } from './types.js'
@@ -57,12 +56,32 @@ export const findDistinct: FindDistinct = async function (this: DrizzleAdapter,
}, },
selectFields: { selectFields: {
_selected: selectFields['_selected'], _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<string, GenericColumn>, } as Record<string, GenericColumn>,
tableName, tableName,
where, 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) => ({ const values = selectDistinctResult.map((each) => ({
[args.field]: (each as Record<string, any>)._selected, [args.field]: (each as Record<string, any>)._selected,
})) }))

View File

@@ -19,6 +19,8 @@ import type { DrizzleAdapter, GenericColumn } from '../types.js'
import type { BuildQueryJoinAliases } from './buildQuery.js' import type { BuildQueryJoinAliases } from './buildQuery.js'
import { isPolymorphicRelationship } from '../utilities/isPolymorphicRelationship.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 { resolveBlockTableName } from '../utilities/validateExistingBlockIsIdentical.js'
import { addJoinTable } from './addJoinTable.js' import { addJoinTable } from './addJoinTable.js'
import { getTableAlias } from './getTableAlias.js' import { getTableAlias } from './getTableAlias.js'
@@ -722,6 +724,28 @@ export const getTableColumnFromPath = ({
rawColumn: sql.raw(`"${aliasRelationshipTableName}"."${relationTableName}_id"`), rawColumn: sql.raw(`"${aliasRelationshipTableName}"."${relationTableName}_id"`),
table: aliasRelationshipTable, table: aliasRelationshipTable,
} }
} else if (value === DistinctSymbol) {
const obj: Record<string, SQL> = {}
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 { } else {
throw new APIError('Not supported') throw new APIError('Not supported')
} }

View File

@@ -132,6 +132,17 @@ export const getConfig: () => Partial<Config> = () => ({
hasMany: true, hasMany: true,
name: 'categories', name: 'categories',
}, },
{
type: 'relationship',
relationTo: ['categories'],
name: 'categoryPoly',
},
{
type: 'relationship',
relationTo: ['categories'],
hasMany: true,
name: 'categoryPolyMany',
},
{ {
type: 'relationship', type: 'relationship',
relationTo: 'categories-custom-id', relationTo: 'categories-custom-id',

View File

@@ -923,6 +923,183 @@ describe('database', () => {
expect(fromRes.categories.title).toBe(title) expect(fromRes.categories.title).toBe(title)
expect(fromRes.categories.id).toBe(id) 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', () => { describe('Compound Indexes', () => {

View File

@@ -198,6 +198,16 @@ export interface Post {
title: string; title: string;
category?: (string | null) | Category; category?: (string | null) | Category;
categories?: (string | Category)[] | null; categories?: (string | Category)[] | null;
categoryPoly?: {
relationTo: 'categories';
value: string | Category;
} | null;
categoryPolyMany?:
| {
relationTo: 'categories';
value: string | Category;
}[]
| null;
categoryCustomID?: (number | null) | CategoriesCustomId; categoryCustomID?: (number | null) | CategoriesCustomId;
localized?: string | null; localized?: string | null;
text?: string | null; text?: string | null;
@@ -827,6 +837,8 @@ export interface PostsSelect<T extends boolean = true> {
title?: T; title?: T;
category?: T; category?: T;
categories?: T; categories?: T;
categoryPoly?: T;
categoryPolyMany?: T;
categoryCustomID?: T; categoryCustomID?: T;
localized?: T; localized?: T;
text?: T; text?: T;