fix: support hasMany: true relationships in findDistinct (#13840)

Previously, the `findDistinct` operation didn't work correctly for
relationships with `hasMany: true`. This PR fixes it.
This commit is contained in:
Sasha
2025-09-17 18:21:24 +03:00
committed by GitHub
parent a26d8d9554
commit d0543a463f
6 changed files with 139 additions and 21 deletions

View File

@@ -48,28 +48,56 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter,
fieldPath = fieldPathResult.localizedPath.replace('<locale>', args.locale) fieldPath = fieldPathResult.localizedPath.replace('<locale>', args.locale)
} }
const isHasManyValue =
fieldPathResult && 'hasMany' in fieldPathResult.field && fieldPathResult.field.hasMany
const page = args.page || 1 const page = args.page || 1
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 $group: any
if (
isHasManyValue &&
sortAggregation.length &&
sortAggregation[0] &&
'$lookup' in sortAggregation[0]
) {
$unwind = `$${sortAggregation[0].$lookup.as}`
$group = {
_id: {
_field: `$${sortAggregation[0].$lookup.as}._id`,
_sort: `$${sortProperty}`,
},
}
} else {
$group = {
_id: {
_field: `$${fieldPath}`,
...(sortProperty === fieldPath
? {}
: {
_sort: `$${sortProperty}`,
}),
},
}
}
const pipeline: PipelineStage[] = [ const pipeline: PipelineStage[] = [
{ {
$match: query, $match: query,
}, },
...(sortAggregation.length > 0 ? sortAggregation : []), ...(sortAggregation.length > 0 ? sortAggregation : []),
...($unwind
? [
{
$unwind,
},
]
: []),
{ {
$group: { $group,
_id: {
_field: `$${fieldPath}`,
...(sortProperty === fieldPath
? {}
: {
_sort: `$${sortProperty}`,
}),
},
},
}, },
{ {
$sort: { $sort: {

View File

@@ -1,4 +1,4 @@
import type { SQL } from 'drizzle-orm' import type { SQL, Table } from 'drizzle-orm'
import type { SQLiteTableWithColumns } from 'drizzle-orm/sqlite-core' import type { SQLiteTableWithColumns } from 'drizzle-orm/sqlite-core'
import type { import type {
FlattenedBlock, FlattenedBlock,
@@ -8,7 +8,7 @@ import type {
TextField, TextField,
} from 'payload' } from 'payload'
import { and, eq, like, sql } from 'drizzle-orm' import { and, eq, getTableName, like, sql } from 'drizzle-orm'
import { type PgTableWithColumns } from 'drizzle-orm/pg-core' import { type PgTableWithColumns } from 'drizzle-orm/pg-core'
import { APIError, getFieldByPath } from 'payload' import { APIError, getFieldByPath } from 'payload'
import { fieldShouldBeLocalized, tabHasName } from 'payload/shared' import { fieldShouldBeLocalized, tabHasName } from 'payload/shared'
@@ -537,13 +537,22 @@ export const getTableColumnFromPath = ({
if (Array.isArray(field.relationTo) || field.hasMany) { if (Array.isArray(field.relationTo) || field.hasMany) {
let relationshipFields: FlattenedField[] let relationshipFields: FlattenedField[]
const relationTableName = `${rootTableName}${adapter.relationshipsSuffix}` const relationTableName = `${rootTableName}${adapter.relationshipsSuffix}`
const {
newAliasTable: aliasRelationshipTable, const existingJoin = joins.find((e) => e.queryPath === `${constraintPath}.${field.name}`)
newAliasTableName: aliasRelationshipTableName,
} = getTableAlias({ let aliasRelationshipTable: PgTableWithColumns<any> | SQLiteTableWithColumns<any>
adapter, let aliasRelationshipTableName: string
tableName: relationTableName, if (existingJoin) {
}) aliasRelationshipTable = existingJoin.table
aliasRelationshipTableName = getTableName(existingJoin.table)
} else {
const res = getTableAlias({
adapter,
tableName: relationTableName,
})
aliasRelationshipTable = res.newAliasTable
aliasRelationshipTableName = res.newAliasTableName
}
if (selectLocale && isFieldLocalized && adapter.payload.config.localization) { if (selectLocale && isFieldLocalized && adapter.payload.config.localization) {
selectFields._locale = aliasRelationshipTable.locale selectFields._locale = aliasRelationshipTable.locale

View File

@@ -155,6 +155,10 @@ export const findDistinctOperation = async (
args.depth args.depth
) { ) {
const populationPromises: Promise<void>[] = [] const populationPromises: Promise<void>[] = []
const sanitizedField = { ...fieldResult.field }
if (fieldResult.field.hasMany) {
sanitizedField.hasMany = false
}
for (const doc of result.values) { for (const doc of result.values) {
populationPromises.push( populationPromises.push(
relationshipPopulationPromise({ relationshipPopulationPromise({
@@ -162,7 +166,7 @@ export const findDistinctOperation = async (
depth: args.depth, depth: args.depth,
draft: false, draft: false,
fallbackLocale: req.fallbackLocale || null, fallbackLocale: req.fallbackLocale || null,
field: fieldResult.field, field: sanitizedField,
locale: req.locale || null, locale: req.locale || null,
overrideAccess: args.overrideAccess ?? true, overrideAccess: args.overrideAccess ?? true,
parentIsLocalized: false, parentIsLocalized: false,

View File

@@ -126,6 +126,12 @@ export const getConfig: () => Partial<Config> = () => ({
relationTo: 'categories', relationTo: 'categories',
name: 'category', name: 'category',
}, },
{
type: 'relationship',
relationTo: 'categories',
hasMany: true,
name: 'categories',
},
{ {
type: 'relationship', type: 'relationship',
relationTo: 'categories-custom-id', relationTo: 'categories-custom-id',

View File

@@ -856,6 +856,75 @@ describe('database', () => {
} }
}) })
it('should populate distinct relationships of hasMany: true when depth>0', async () => {
await payload.delete({ collection: 'posts', where: {} })
await payload.delete({ collection: 'categories', where: {} })
const categories = ['category-1', 'category-2', 'category-3', 'category-4'].map((title) => ({
title,
}))
const categoriesIDS: { categories: string }[] = []
for (const { title } of categories) {
const doc = await payload.create({ collection: 'categories', data: { title } })
categoriesIDS.push({ categories: doc.id })
}
await payload.create({
collection: 'posts',
data: {
title: '1',
categories: [categoriesIDS[0]?.categories, categoriesIDS[1]?.categories],
},
})
await payload.create({
collection: 'posts',
data: {
title: '2',
categories: [
categoriesIDS[0]?.categories,
categoriesIDS[2]?.categories,
categoriesIDS[3]?.categories,
],
},
})
await payload.create({
collection: 'posts',
data: {
title: '3',
categories: [
categoriesIDS[0]?.categories,
categoriesIDS[3]?.categories,
categoriesIDS[1]?.categories,
],
},
})
const resultDepth0 = await payload.findDistinct({
collection: 'posts',
sort: 'categories.title',
field: 'categories',
})
expect(resultDepth0.values).toStrictEqual(categoriesIDS)
const resultDepth1 = await payload.findDistinct({
depth: 1,
collection: 'posts',
field: 'categories',
sort: 'categories.title',
})
for (let i = 0; i < resultDepth1.values.length; i++) {
const fromRes = resultDepth1.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)
}
})
describe('Compound Indexes', () => { describe('Compound Indexes', () => {
beforeEach(async () => { beforeEach(async () => {
await payload.delete({ collection: 'compound-indexes', where: {} }) await payload.delete({ collection: 'compound-indexes', where: {} })

View File

@@ -197,6 +197,7 @@ export interface Post {
id: string; id: string;
title: string; title: string;
category?: (string | null) | Category; category?: (string | null) | Category;
categories?: (string | Category)[] | null;
categoryCustomID?: (number | null) | CategoriesCustomId; categoryCustomID?: (number | null) | CategoriesCustomId;
localized?: string | null; localized?: string | null;
text?: string | null; text?: string | null;
@@ -822,6 +823,7 @@ export interface CategoriesCustomIdSelect<T extends boolean = true> {
export interface PostsSelect<T extends boolean = true> { export interface PostsSelect<T extends boolean = true> {
title?: T; title?: T;
category?: T; category?: T;
categories?: T;
categoryCustomID?: T; categoryCustomID?: T;
localized?: T; localized?: T;
text?: T; text?: T;