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' })
```
This commit is contained in:
Sasha
2025-10-06 22:44:14 +03:00
committed by GitHub
parent ef84b20f26
commit e4f8478e36
7 changed files with 210 additions and 51 deletions

View File

@@ -1,7 +1,7 @@
import type { PipelineStage } from 'mongoose' import type { PipelineStage } from 'mongoose'
import type { FindDistinct, FlattenedField } from 'payload' import type { FindDistinct, FlattenedField } from 'payload'
import { APIError, getFieldByPath } from 'payload' import { getFieldByPath } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
@@ -20,7 +20,7 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter,
const { where = {} } = args const { where = {} } = args
const sortAggregation: PipelineStage[] = [] let sortAggregation: PipelineStage[] = []
const sort = buildSortParam({ const sort = buildSortParam({
adapter: this, adapter: this,
@@ -41,7 +41,9 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter,
}) })
const fieldPathResult = getFieldByPath({ const fieldPathResult = getFieldByPath({
config: this.payload.config,
fields: collectionConfig.flattenedFields, fields: collectionConfig.flattenedFields,
includeRelationships: true,
path: args.field, path: args.field,
}) })
let fieldPath = 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 const sortDirection = sort[sortProperty] === 'asc' ? 1 : -1
let currentFields = collectionConfig.flattenedFields let currentFields = collectionConfig.flattenedFields
let relationTo: null | string = null
let foundField: FlattenedField | null = 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('.')) { for (const segment of args.field.split('.')) {
const field = currentFields.find((e) => e.name === segment) const field = currentFields.find((e) => e.name === segment)
if (rels.length) {
insideRelation = true
}
if (!field) { if (!field) {
break break
} }
if (relationTo) { if (tempPath) {
foundFieldPath = `${foundFieldPath}${field?.name}` tempPath = `${tempPath}.${field.name}`
} else { } else {
relationFieldPath = `${relationFieldPath}${field.name}` tempPath = field.name
} }
if ('flattenedFields' in field) { if ('flattenedFields' in field) {
currentFields = field.flattenedFields currentFields = field.flattenedFields
if (relationTo) {
foundFieldPath = `${foundFieldPath}.`
} else {
relationFieldPath = `${relationFieldPath}.`
}
continue continue
} }
@@ -88,43 +92,51 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter,
(field.type === 'relationship' || field.type === 'upload') && (field.type === 'relationship' || field.type === 'upload') &&
typeof field.relationTo === 'string' typeof field.relationTo === 'string'
) { ) {
if (relationTo) { rels.push({ fieldPath: tempPath, relationTo: field.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 currentFields = this.payload.collections[field.relationTo]?.config
.flattenedFields as FlattenedField[] .flattenedFields as FlattenedField[]
continue continue
} }
foundField = field foundField = field
if (
sortAggregation.some(
(stage) => '$lookup' in stage && stage.$lookup.localField === relationFieldPath,
)
) {
sortProperty = sortProperty.replace('__', '')
sortAggregation.pop()
}
} }
const resolvedField = foundField || fieldPathResult?.field const resolvedField = foundField || fieldPathResult?.field
const isHasManyValue = resolvedField && 'hasMany' in resolvedField && resolvedField const isHasManyValue = resolvedField && 'hasMany' in resolvedField && resolvedField
let relationLookup: null | PipelineStage = null let relationLookup: null | PipelineStage[] = null
if (relationTo && foundFieldPath && relationFieldPath) {
const { Model: foreignModel } = getCollection({ adapter: this, collectionSlug: relationTo })
relationLookup = { if (!insideRelation) {
$lookup: { rels = []
as: relationFieldPath, }
foreignField: '_id',
from: foreignModel.collection.name, if (rels.length) {
localField: relationFieldPath, if (sortProperty.startsWith('_')) {
}, const sortWithoutRelationPrefix = sortProperty.replace(/^_+/, '')
const lastFieldPath = rels.at(-1)?.fieldPath as string
if (sortWithoutRelationPrefix.startsWith(lastFieldPath)) {
sortProperty = sortWithoutRelationPrefix
}
} }
relationLookup = rels.reduce<PipelineStage[]>((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 = '' let $unwind: any = ''
@@ -164,7 +176,7 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter,
$match: query, $match: query,
}, },
...(sortAggregation.length > 0 ? sortAggregation : []), ...(sortAggregation.length > 0 ? sortAggregation : []),
...(relationLookup ? [relationLookup, { $unwind: `$${relationFieldPath}` }] : []), ...(relationLookup?.length ? relationLookup : []),
...($unwind ...($unwind
? [ ? [
{ {

View File

@@ -64,7 +64,9 @@ export const findDistinct: FindDistinct = async function (this: DrizzleAdapter,
}) })
const field = getFieldByPath({ const field = getFieldByPath({
config: this.payload.config,
fields: collectionConfig.flattenedFields, fields: collectionConfig.flattenedFields,
includeRelationships: true,
path: args.field, path: args.field,
})?.field })?.field

View File

@@ -118,7 +118,9 @@ export const findDistinctOperation = async (
}) })
const fieldResult = getFieldByPath({ const fieldResult = getFieldByPath({
config: payload.config,
fields: collectionConfig.flattenedFields, fields: collectionConfig.flattenedFields,
includeRelationships: true,
path: args.field, path: args.field,
}) })

View File

@@ -1,3 +1,4 @@
import type { SanitizedConfig } from '../config/types.js'
import type { FlattenedField } from '../fields/config/types.js' import type { FlattenedField } from '../fields/config/types.js'
/** /**
@@ -7,11 +8,15 @@ import type { FlattenedField } from '../fields/config/types.js'
* group.<locale>.title // group is localized here * group.<locale>.title // group is localized here
*/ */
export const getFieldByPath = ({ export const getFieldByPath = ({
config,
fields, fields,
includeRelationships = false,
localizedPath = '', localizedPath = '',
path, path,
}: { }: {
config?: SanitizedConfig
fields: FlattenedField[] fields: FlattenedField[]
includeRelationships?: boolean
localizedPath?: string localizedPath?: string
path: string path: string
}): { }): {
@@ -45,10 +50,26 @@ export const getFieldByPath = ({
currentFields = field.flattenedFields 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) { if ('blocks' in field) {
for (const block of field.blocks) { for (const block of field.blocks) {
const maybeField = getFieldByPath({ const maybeField = getFieldByPath({
config,
fields: block.flattenedFields, fields: block.flattenedFields,
includeRelationships,
localizedPath, localizedPath,
path: [...segments].join('.'), path: [...segments].join('.'),
}) })

View File

@@ -54,6 +54,11 @@ export const getConfig: () => Partial<Config> = () => ({
type: 'text', type: 'text',
name: 'title', name: 'title',
}, },
{
name: 'simple',
type: 'relationship',
relationTo: 'simple',
},
{ {
type: 'tabs', type: 'tabs',
tabs: [ tabs: [
@@ -131,6 +136,11 @@ export const getConfig: () => Partial<Config> = () => ({
name: 'categoryTitle', name: 'categoryTitle',
virtual: 'category.title', virtual: 'category.title',
}, },
{
type: 'text',
name: 'categorySimpleText',
virtual: 'category.simple.text',
},
{ {
type: 'relationship', type: 'relationship',
relationTo: 'categories', relationTo: 'categories',

View File

@@ -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', () => { describe('Compound Indexes', () => {
beforeEach(async () => { beforeEach(async () => {
await payload.delete({ collection: 'compound-indexes', where: {} }) await payload.delete({ collection: 'compound-indexes', where: {} })

View File

@@ -180,6 +180,7 @@ export interface NoTimeStamp {
export interface Category { export interface Category {
id: string; id: string;
title?: string | null; title?: string | null;
simple?: (string | null) | Simple;
hideout?: { hideout?: {
camera1?: { camera1?: {
time1Image?: (string | null) | Post; time1Image?: (string | null) | Post;
@@ -189,6 +190,17 @@ export interface Category {
createdAt: string; createdAt: string;
_status?: ('draft' | 'published') | null; _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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts". * via the `definition` "posts".
@@ -198,6 +210,7 @@ export interface Post {
title: string; title: string;
category?: (string | null) | Category; category?: (string | null) | Category;
categoryTitle?: string | null; categoryTitle?: string | null;
categorySimpleText?: string | null;
categories?: (string | Category)[] | null; categories?: (string | Category)[] | null;
categoryPoly?: { categoryPoly?: {
relationTo: 'categories'; relationTo: 'categories';
@@ -318,17 +331,6 @@ export interface CategoriesCustomId {
createdAt: string; createdAt: string;
_status?: ('draft' | 'published') | null; _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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "error-on-unnamed-fields". * via the `definition` "error-on-unnamed-fields".
@@ -838,6 +840,7 @@ export interface NoTimeStampsSelect<T extends boolean = true> {
*/ */
export interface CategoriesSelect<T extends boolean = true> { export interface CategoriesSelect<T extends boolean = true> {
title?: T; title?: T;
simple?: T;
hideout?: hideout?:
| T | T
| { | {
@@ -879,6 +882,7 @@ export interface PostsSelect<T extends boolean = true> {
title?: T; title?: T;
category?: T; category?: T;
categoryTitle?: T; categoryTitle?: T;
categorySimpleText?: T;
categories?: T; categories?: T;
categoryPoly?: T; categoryPoly?: T;
categoryPolyMany?: T; categoryPolyMany?: T;