feat: allow findDistinct on fields nested to relationships and on virtual fields (#14026)
This adds support for using `findDistinct` https://github.com/payloadcms/payload/pull/13102 on fields: * Nested to a relationship, for example `category.title` * Virtual fields that are linked to relationships, for example `categoryTitle` ```tsx const Category: CollectionConfig = { slug: 'categories', fields: [ { name: 'title', type: 'text', }, ], } const Posts: CollectionConfig = { slug: 'posts', fields: [ { name: 'category', type: 'relationship', relationTo: 'categories', }, { name: 'categoryTitle', type: 'text', virtual: 'category.title', }, ], } // Supported now const relationResult = await payload.findDistinct({ collection: 'posts', field: 'category.title' }) // Supported now const virtualResult = await payload.findDistinct({ collection: 'posts', field: 'categoryTitle' }) ```
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { PipelineStage } from 'mongoose'
|
||||
import type { FindDistinct, FlattenedField } from 'payload'
|
||||
|
||||
import { type FindDistinct, getFieldByPath } from 'payload'
|
||||
import { APIError, getFieldByPath } from 'payload'
|
||||
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
|
||||
@@ -48,14 +49,84 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter,
|
||||
fieldPath = fieldPathResult.localizedPath.replace('<locale>', args.locale)
|
||||
}
|
||||
|
||||
const isHasManyValue =
|
||||
fieldPathResult && 'hasMany' in fieldPathResult.field && fieldPathResult.field.hasMany
|
||||
|
||||
const page = args.page || 1
|
||||
|
||||
const sortProperty = Object.keys(sort)[0]! // assert because buildSortParam always returns at least 1 key.
|
||||
let sortProperty = Object.keys(sort)[0]! // assert because buildSortParam always returns at least 1 key.
|
||||
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 = ''
|
||||
|
||||
for (const segment of args.field.split('.')) {
|
||||
const field = currentFields.find((e) => e.name === segment)
|
||||
|
||||
if (!field) {
|
||||
break
|
||||
}
|
||||
|
||||
if (relationTo) {
|
||||
foundFieldPath = `${foundFieldPath}${field?.name}`
|
||||
} else {
|
||||
relationFieldPath = `${relationFieldPath}${field.name}`
|
||||
}
|
||||
|
||||
if ('flattenedFields' in field) {
|
||||
currentFields = field.flattenedFields
|
||||
|
||||
if (relationTo) {
|
||||
foundFieldPath = `${foundFieldPath}.`
|
||||
} else {
|
||||
relationFieldPath = `${relationFieldPath}.`
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
(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
|
||||
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 })
|
||||
|
||||
relationLookup = {
|
||||
$lookup: {
|
||||
as: relationFieldPath,
|
||||
foreignField: '_id',
|
||||
from: foreignModel.collection.name,
|
||||
localField: relationFieldPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let $unwind: any = ''
|
||||
let $group: any = null
|
||||
if (
|
||||
@@ -93,6 +164,7 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter,
|
||||
$match: query,
|
||||
},
|
||||
...(sortAggregation.length > 0 ? sortAggregation : []),
|
||||
...(relationLookup ? [relationLookup, { $unwind: `$${relationFieldPath}` }] : []),
|
||||
...($unwind
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ import httpStatus from 'http-status'
|
||||
|
||||
import type { AccessResult } from '../../config/types.js'
|
||||
import type { PaginatedDistinctDocs } from '../../database/types.js'
|
||||
import type { FlattenedField } from '../../fields/config/types.js'
|
||||
import type { PayloadRequest, PopulateType, Sort, Where } from '../../types/index.js'
|
||||
import type { Collection } from '../config/types.js'
|
||||
|
||||
@@ -139,6 +140,56 @@ export const findDistinctOperation = async (
|
||||
}
|
||||
}
|
||||
|
||||
if ('virtual' in fieldResult.field && fieldResult.field.virtual) {
|
||||
if (typeof fieldResult.field.virtual !== 'string') {
|
||||
throw new APIError(
|
||||
`Cannot findDistinct by a virtual field that isn't linked to a relationship field.`,
|
||||
)
|
||||
}
|
||||
|
||||
let relationPath: string = ''
|
||||
let currentFields: FlattenedField[] = collectionConfig.flattenedFields
|
||||
const fieldPathSegments = fieldResult.field.virtual.split('.')
|
||||
for (const segment of fieldResult.field.virtual.split('.')) {
|
||||
relationPath = `${relationPath}${segment}`
|
||||
fieldPathSegments.shift()
|
||||
const field = currentFields.find((e) => e.name === segment)!
|
||||
if (
|
||||
(field.type === 'relationship' || field.type === 'upload') &&
|
||||
typeof field.relationTo === 'string'
|
||||
) {
|
||||
break
|
||||
}
|
||||
if ('flattenedFields' in field) {
|
||||
currentFields = field.flattenedFields
|
||||
}
|
||||
}
|
||||
|
||||
const path = `${relationPath}.${fieldPathSegments.join('.')}`
|
||||
|
||||
const result = await payload.findDistinct({
|
||||
collection: collectionConfig.slug,
|
||||
depth: args.depth,
|
||||
disableErrors,
|
||||
field: path,
|
||||
locale,
|
||||
overrideAccess,
|
||||
populate,
|
||||
req,
|
||||
showHiddenFields,
|
||||
sort: args.sort,
|
||||
trash,
|
||||
where,
|
||||
})
|
||||
|
||||
for (const val of result.values) {
|
||||
val[args.field] = val[path]
|
||||
delete val[path]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
let result = await payload.db.findDistinct({
|
||||
collection: collectionConfig.slug,
|
||||
field: args.field,
|
||||
|
||||
@@ -42,7 +42,7 @@ export type Options<
|
||||
/**
|
||||
* The field to get distinct values for
|
||||
*/
|
||||
field: TField
|
||||
field: ({} & string) | TField
|
||||
/**
|
||||
* The maximum distinct field values to be returned.
|
||||
* By default the operation returns all the values.
|
||||
|
||||
@@ -126,6 +126,11 @@ export const getConfig: () => Partial<Config> = () => ({
|
||||
relationTo: 'categories',
|
||||
name: 'category',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'categoryTitle',
|
||||
virtual: 'category.title',
|
||||
},
|
||||
{
|
||||
type: 'relationship',
|
||||
relationTo: 'categories',
|
||||
|
||||
@@ -1102,6 +1102,94 @@ describe('database', () => {
|
||||
expect(result.values.some((v) => v.categoryPolyMany === null)).toBe(true)
|
||||
})
|
||||
|
||||
it('should find distinct values with field nested to a relationship', 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' },
|
||||
})
|
||||
|
||||
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 } })
|
||||
|
||||
const res = await payload.findDistinct({
|
||||
collection: 'posts',
|
||||
field: 'category.title',
|
||||
})
|
||||
|
||||
expect(res.values).toEqual([
|
||||
{
|
||||
'category.title': 'category_1',
|
||||
},
|
||||
{
|
||||
'category.title': 'category_2',
|
||||
},
|
||||
{
|
||||
'category.title': 'category_3',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should find distinct values with virtual field linked to a relationship', 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' },
|
||||
})
|
||||
|
||||
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 } })
|
||||
|
||||
const res = await payload.findDistinct({
|
||||
collection: 'posts',
|
||||
field: 'categoryTitle',
|
||||
})
|
||||
|
||||
expect(res.values).toEqual([
|
||||
{
|
||||
categoryTitle: 'category_1',
|
||||
},
|
||||
{
|
||||
categoryTitle: 'category_2',
|
||||
},
|
||||
{
|
||||
categoryTitle: 'category_3',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('Compound Indexes', () => {
|
||||
beforeEach(async () => {
|
||||
await payload.delete({ collection: 'compound-indexes', where: {} })
|
||||
|
||||
@@ -197,6 +197,7 @@ export interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
category?: (string | null) | Category;
|
||||
categoryTitle?: string | null;
|
||||
categories?: (string | Category)[] | null;
|
||||
categoryPoly?: {
|
||||
relationTo: 'categories';
|
||||
@@ -210,22 +211,28 @@ export interface Post {
|
||||
| null;
|
||||
categoryCustomID?: (number | null) | CategoriesCustomId;
|
||||
polymorphicRelations?:
|
||||
| ({
|
||||
relationTo: 'categories';
|
||||
value: string | Category;
|
||||
} | {
|
||||
relationTo: 'simple';
|
||||
value: string | Simple;
|
||||
})[]
|
||||
| (
|
||||
| {
|
||||
relationTo: 'categories';
|
||||
value: string | Category;
|
||||
}
|
||||
| {
|
||||
relationTo: 'simple';
|
||||
value: string | Simple;
|
||||
}
|
||||
)[]
|
||||
| null;
|
||||
localizedPolymorphicRelations?:
|
||||
| ({
|
||||
relationTo: 'categories';
|
||||
value: string | Category;
|
||||
} | {
|
||||
relationTo: 'simple';
|
||||
value: string | Simple;
|
||||
})[]
|
||||
| (
|
||||
| {
|
||||
relationTo: 'categories';
|
||||
value: string | Category;
|
||||
}
|
||||
| {
|
||||
relationTo: 'simple';
|
||||
value: string | Simple;
|
||||
}
|
||||
)[]
|
||||
| null;
|
||||
localized?: string | null;
|
||||
text?: string | null;
|
||||
@@ -245,6 +252,23 @@ export interface Post {
|
||||
blockType: 'block-third';
|
||||
}[]
|
||||
| null;
|
||||
testNestedGroup?: {
|
||||
nestedLocalizedPolymorphicRelation?:
|
||||
| (
|
||||
| {
|
||||
relationTo: 'categories';
|
||||
value: string | Category;
|
||||
}
|
||||
| {
|
||||
relationTo: 'simple';
|
||||
value: string | Simple;
|
||||
}
|
||||
)[]
|
||||
| null;
|
||||
nestedLocalizedText?: string | null;
|
||||
nestedText1?: string | null;
|
||||
nestedText2?: string | null;
|
||||
};
|
||||
D1?: {
|
||||
D2?: {
|
||||
D3?: {
|
||||
@@ -252,20 +276,6 @@ export interface Post {
|
||||
};
|
||||
};
|
||||
};
|
||||
testNestedGroup?: {
|
||||
nestedLocalizedPolymorphicRelation?:
|
||||
| ({
|
||||
relationTo: 'categories';
|
||||
value: string | Category;
|
||||
} | {
|
||||
relationTo: 'simple';
|
||||
value: string | Simple;
|
||||
})[]
|
||||
| null;
|
||||
nestedLocalizedText?: string | null;
|
||||
nestedText1?: string | null;
|
||||
nestedText2?: string | null;
|
||||
};
|
||||
hasTransaction?: boolean | null;
|
||||
throwAfterChange?: boolean | null;
|
||||
arrayWithIDs?:
|
||||
@@ -868,6 +878,7 @@ export interface CategoriesCustomIdSelect<T extends boolean = true> {
|
||||
export interface PostsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
category?: T;
|
||||
categoryTitle?: T;
|
||||
categories?: T;
|
||||
categoryPoly?: T;
|
||||
categoryPolyMany?: T;
|
||||
@@ -898,6 +909,14 @@ export interface PostsSelect<T extends boolean = true> {
|
||||
blockName?: T;
|
||||
};
|
||||
};
|
||||
testNestedGroup?:
|
||||
| T
|
||||
| {
|
||||
nestedLocalizedPolymorphicRelation?: T;
|
||||
nestedLocalizedText?: T;
|
||||
nestedText1?: T;
|
||||
nestedText2?: T;
|
||||
};
|
||||
D1?:
|
||||
| T
|
||||
| {
|
||||
@@ -1455,6 +1474,6 @@ export interface Auth {
|
||||
|
||||
|
||||
declare module 'payload' {
|
||||
// @ts-ignore
|
||||
// @ts-ignore
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user