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 { 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'
|
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)
|
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.
|
let 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 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 $unwind: any = ''
|
||||||
let $group: any = null
|
let $group: any = null
|
||||||
if (
|
if (
|
||||||
@@ -93,6 +164,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}` }] : []),
|
||||||
...($unwind
|
...($unwind
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import httpStatus from 'http-status'
|
|||||||
|
|
||||||
import type { AccessResult } from '../../config/types.js'
|
import type { AccessResult } from '../../config/types.js'
|
||||||
import type { PaginatedDistinctDocs } from '../../database/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 { PayloadRequest, PopulateType, Sort, Where } from '../../types/index.js'
|
||||||
import type { Collection } from '../config/types.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({
|
let result = await payload.db.findDistinct({
|
||||||
collection: collectionConfig.slug,
|
collection: collectionConfig.slug,
|
||||||
field: args.field,
|
field: args.field,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export type Options<
|
|||||||
/**
|
/**
|
||||||
* The field to get distinct values for
|
* The field to get distinct values for
|
||||||
*/
|
*/
|
||||||
field: TField
|
field: ({} & string) | TField
|
||||||
/**
|
/**
|
||||||
* The maximum distinct field values to be returned.
|
* The maximum distinct field values to be returned.
|
||||||
* By default the operation returns all the values.
|
* By default the operation returns all the values.
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ export const getConfig: () => Partial<Config> = () => ({
|
|||||||
relationTo: 'categories',
|
relationTo: 'categories',
|
||||||
name: 'category',
|
name: 'category',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'categoryTitle',
|
||||||
|
virtual: 'category.title',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'relationship',
|
type: 'relationship',
|
||||||
relationTo: 'categories',
|
relationTo: 'categories',
|
||||||
|
|||||||
@@ -1102,6 +1102,94 @@ describe('database', () => {
|
|||||||
expect(result.values.some((v) => v.categoryPolyMany === null)).toBe(true)
|
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', () => {
|
describe('Compound Indexes', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await payload.delete({ collection: 'compound-indexes', where: {} })
|
await payload.delete({ collection: 'compound-indexes', where: {} })
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export interface Post {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
category?: (string | null) | Category;
|
category?: (string | null) | Category;
|
||||||
|
categoryTitle?: string | null;
|
||||||
categories?: (string | Category)[] | null;
|
categories?: (string | Category)[] | null;
|
||||||
categoryPoly?: {
|
categoryPoly?: {
|
||||||
relationTo: 'categories';
|
relationTo: 'categories';
|
||||||
@@ -210,22 +211,28 @@ export interface Post {
|
|||||||
| null;
|
| null;
|
||||||
categoryCustomID?: (number | null) | CategoriesCustomId;
|
categoryCustomID?: (number | null) | CategoriesCustomId;
|
||||||
polymorphicRelations?:
|
polymorphicRelations?:
|
||||||
| ({
|
| (
|
||||||
relationTo: 'categories';
|
| {
|
||||||
value: string | Category;
|
relationTo: 'categories';
|
||||||
} | {
|
value: string | Category;
|
||||||
relationTo: 'simple';
|
}
|
||||||
value: string | Simple;
|
| {
|
||||||
})[]
|
relationTo: 'simple';
|
||||||
|
value: string | Simple;
|
||||||
|
}
|
||||||
|
)[]
|
||||||
| null;
|
| null;
|
||||||
localizedPolymorphicRelations?:
|
localizedPolymorphicRelations?:
|
||||||
| ({
|
| (
|
||||||
relationTo: 'categories';
|
| {
|
||||||
value: string | Category;
|
relationTo: 'categories';
|
||||||
} | {
|
value: string | Category;
|
||||||
relationTo: 'simple';
|
}
|
||||||
value: string | Simple;
|
| {
|
||||||
})[]
|
relationTo: 'simple';
|
||||||
|
value: string | Simple;
|
||||||
|
}
|
||||||
|
)[]
|
||||||
| null;
|
| null;
|
||||||
localized?: string | null;
|
localized?: string | null;
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
@@ -245,6 +252,23 @@ export interface Post {
|
|||||||
blockType: 'block-third';
|
blockType: 'block-third';
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
|
testNestedGroup?: {
|
||||||
|
nestedLocalizedPolymorphicRelation?:
|
||||||
|
| (
|
||||||
|
| {
|
||||||
|
relationTo: 'categories';
|
||||||
|
value: string | Category;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
relationTo: 'simple';
|
||||||
|
value: string | Simple;
|
||||||
|
}
|
||||||
|
)[]
|
||||||
|
| null;
|
||||||
|
nestedLocalizedText?: string | null;
|
||||||
|
nestedText1?: string | null;
|
||||||
|
nestedText2?: string | null;
|
||||||
|
};
|
||||||
D1?: {
|
D1?: {
|
||||||
D2?: {
|
D2?: {
|
||||||
D3?: {
|
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;
|
hasTransaction?: boolean | null;
|
||||||
throwAfterChange?: boolean | null;
|
throwAfterChange?: boolean | null;
|
||||||
arrayWithIDs?:
|
arrayWithIDs?:
|
||||||
@@ -868,6 +878,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;
|
||||||
|
categoryTitle?: T;
|
||||||
categories?: T;
|
categories?: T;
|
||||||
categoryPoly?: T;
|
categoryPoly?: T;
|
||||||
categoryPolyMany?: T;
|
categoryPolyMany?: T;
|
||||||
@@ -898,6 +909,14 @@ export interface PostsSelect<T extends boolean = true> {
|
|||||||
blockName?: T;
|
blockName?: T;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
testNestedGroup?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
nestedLocalizedPolymorphicRelation?: T;
|
||||||
|
nestedLocalizedText?: T;
|
||||||
|
nestedText1?: T;
|
||||||
|
nestedText2?: T;
|
||||||
|
};
|
||||||
D1?:
|
D1?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
@@ -1455,6 +1474,6 @@ export interface Auth {
|
|||||||
|
|
||||||
|
|
||||||
declare module 'payload' {
|
declare module 'payload' {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export interface GeneratedTypes extends Config {}
|
export interface GeneratedTypes extends Config {}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user