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:
@@ -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
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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('.'),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: {} })
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user