Skip to content

Commit 9d6cae0

Browse files
authored
feat: allow findDistinct on fields nested to relationships and on virtual fields (#14026)
This adds support for using `findDistinct` #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' }) ```
1 parent 95bdffd commit 9d6cae0

File tree

6 files changed

+271
-36
lines changed

6 files changed

+271
-36
lines changed

packages/db-mongodb/src/findDistinct.ts

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { PipelineStage } from 'mongoose'
2+
import type { FindDistinct, FlattenedField } from 'payload'
23

3-
import { type FindDistinct, getFieldByPath } from 'payload'
4+
import { APIError, getFieldByPath } from 'payload'
45

56
import type { MongooseAdapter } from './index.js'
67

@@ -48,14 +49,84 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter,
4849
fieldPath = fieldPathResult.localizedPath.replace('<locale>', args.locale)
4950
}
5051

51-
const isHasManyValue =
52-
fieldPathResult && 'hasMany' in fieldPathResult.field && fieldPathResult.field.hasMany
53-
5452
const page = args.page || 1
5553

56-
const sortProperty = Object.keys(sort)[0]! // assert because buildSortParam always returns at least 1 key.
54+
let sortProperty = Object.keys(sort)[0]! // assert because buildSortParam always returns at least 1 key.
5755
const sortDirection = sort[sortProperty] === 'asc' ? 1 : -1
5856

57+
let currentFields = collectionConfig.flattenedFields
58+
let relationTo: null | string = null
59+
let foundField: FlattenedField | null = null
60+
let foundFieldPath = ''
61+
let relationFieldPath = ''
62+
63+
for (const segment of args.field.split('.')) {
64+
const field = currentFields.find((e) => e.name === segment)
65+
66+
if (!field) {
67+
break
68+
}
69+
70+
if (relationTo) {
71+
foundFieldPath = `${foundFieldPath}${field?.name}`
72+
} else {
73+
relationFieldPath = `${relationFieldPath}${field.name}`
74+
}
75+
76+
if ('flattenedFields' in field) {
77+
currentFields = field.flattenedFields
78+
79+
if (relationTo) {
80+
foundFieldPath = `${foundFieldPath}.`
81+
} else {
82+
relationFieldPath = `${relationFieldPath}.`
83+
}
84+
continue
85+
}
86+
87+
if (
88+
(field.type === 'relationship' || field.type === 'upload') &&
89+
typeof field.relationTo === 'string'
90+
) {
91+
if (relationTo) {
92+
throw new APIError(
93+
`findDistinct for fields nested to relationships supported 1 level only, errored field: ${args.field}`,
94+
)
95+
}
96+
relationTo = field.relationTo
97+
currentFields = this.payload.collections[field.relationTo]?.config
98+
.flattenedFields as FlattenedField[]
99+
continue
100+
}
101+
foundField = field
102+
103+
if (
104+
sortAggregation.some(
105+
(stage) => '$lookup' in stage && stage.$lookup.localField === relationFieldPath,
106+
)
107+
) {
108+
sortProperty = sortProperty.replace('__', '')
109+
sortAggregation.pop()
110+
}
111+
}
112+
113+
const resolvedField = foundField || fieldPathResult?.field
114+
const isHasManyValue = resolvedField && 'hasMany' in resolvedField && resolvedField
115+
116+
let relationLookup: null | PipelineStage = null
117+
if (relationTo && foundFieldPath && relationFieldPath) {
118+
const { Model: foreignModel } = getCollection({ adapter: this, collectionSlug: relationTo })
119+
120+
relationLookup = {
121+
$lookup: {
122+
as: relationFieldPath,
123+
foreignField: '_id',
124+
from: foreignModel.collection.name,
125+
localField: relationFieldPath,
126+
},
127+
}
128+
}
129+
59130
let $unwind: any = ''
60131
let $group: any = null
61132
if (
@@ -93,6 +164,7 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter,
93164
$match: query,
94165
},
95166
...(sortAggregation.length > 0 ? sortAggregation : []),
167+
...(relationLookup ? [relationLookup, { $unwind: `$${relationFieldPath}` }] : []),
96168
...($unwind
97169
? [
98170
{

packages/payload/src/collections/operations/findDistinct.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import httpStatus from 'http-status'
22

33
import type { AccessResult } from '../../config/types.js'
44
import type { PaginatedDistinctDocs } from '../../database/types.js'
5+
import type { FlattenedField } from '../../fields/config/types.js'
56
import type { PayloadRequest, PopulateType, Sort, Where } from '../../types/index.js'
67
import type { Collection } from '../config/types.js'
78

@@ -139,6 +140,56 @@ export const findDistinctOperation = async (
139140
}
140141
}
141142

143+
if ('virtual' in fieldResult.field && fieldResult.field.virtual) {
144+
if (typeof fieldResult.field.virtual !== 'string') {
145+
throw new APIError(
146+
`Cannot findDistinct by a virtual field that isn't linked to a relationship field.`,
147+
)
148+
}
149+
150+
let relationPath: string = ''
151+
let currentFields: FlattenedField[] = collectionConfig.flattenedFields
152+
const fieldPathSegments = fieldResult.field.virtual.split('.')
153+
for (const segment of fieldResult.field.virtual.split('.')) {
154+
relationPath = `${relationPath}${segment}`
155+
fieldPathSegments.shift()
156+
const field = currentFields.find((e) => e.name === segment)!
157+
if (
158+
(field.type === 'relationship' || field.type === 'upload') &&
159+
typeof field.relationTo === 'string'
160+
) {
161+
break
162+
}
163+
if ('flattenedFields' in field) {
164+
currentFields = field.flattenedFields
165+
}
166+
}
167+
168+
const path = `${relationPath}.${fieldPathSegments.join('.')}`
169+
170+
const result = await payload.findDistinct({
171+
collection: collectionConfig.slug,
172+
depth: args.depth,
173+
disableErrors,
174+
field: path,
175+
locale,
176+
overrideAccess,
177+
populate,
178+
req,
179+
showHiddenFields,
180+
sort: args.sort,
181+
trash,
182+
where,
183+
})
184+
185+
for (const val of result.values) {
186+
val[args.field] = val[path]
187+
delete val[path]
188+
}
189+
190+
return result
191+
}
192+
142193
let result = await payload.db.findDistinct({
143194
collection: collectionConfig.slug,
144195
field: args.field,

packages/payload/src/collections/operations/local/findDistinct.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export type Options<
4242
/**
4343
* The field to get distinct values for
4444
*/
45-
field: TField
45+
field: ({} & string) | TField
4646
/**
4747
* The maximum distinct field values to be returned.
4848
* By default the operation returns all the values.

test/database/getConfig.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ export const getConfig: () => Partial<Config> = () => ({
126126
relationTo: 'categories',
127127
name: 'category',
128128
},
129+
{
130+
type: 'text',
131+
name: 'categoryTitle',
132+
virtual: 'category.title',
133+
},
129134
{
130135
type: 'relationship',
131136
relationTo: 'categories',

test/database/int.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,94 @@ describe('database', () => {
11021102
expect(result.values.some((v) => v.categoryPolyMany === null)).toBe(true)
11031103
})
11041104

1105+
it('should find distinct values with field nested to a relationship', async () => {
1106+
await payload.delete({ collection: 'posts', where: {} })
1107+
await payload.delete({ collection: 'categories', where: {} })
1108+
1109+
const category_1 = await payload.create({
1110+
collection: 'categories',
1111+
data: { title: 'category_1' },
1112+
})
1113+
const category_2 = await payload.create({
1114+
collection: 'categories',
1115+
data: { title: 'category_2' },
1116+
})
1117+
const category_3 = await payload.create({
1118+
collection: 'categories',
1119+
data: { title: 'category_3' },
1120+
})
1121+
1122+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_1 } })
1123+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_2 } })
1124+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_2 } })
1125+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_2 } })
1126+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
1127+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
1128+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
1129+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
1130+
1131+
const res = await payload.findDistinct({
1132+
collection: 'posts',
1133+
field: 'category.title',
1134+
})
1135+
1136+
expect(res.values).toEqual([
1137+
{
1138+
'category.title': 'category_1',
1139+
},
1140+
{
1141+
'category.title': 'category_2',
1142+
},
1143+
{
1144+
'category.title': 'category_3',
1145+
},
1146+
])
1147+
})
1148+
1149+
it('should find distinct values with virtual field linked to a relationship', async () => {
1150+
await payload.delete({ collection: 'posts', where: {} })
1151+
await payload.delete({ collection: 'categories', where: {} })
1152+
1153+
const category_1 = await payload.create({
1154+
collection: 'categories',
1155+
data: { title: 'category_1' },
1156+
})
1157+
const category_2 = await payload.create({
1158+
collection: 'categories',
1159+
data: { title: 'category_2' },
1160+
})
1161+
const category_3 = await payload.create({
1162+
collection: 'categories',
1163+
data: { title: 'category_3' },
1164+
})
1165+
1166+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_1 } })
1167+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_2 } })
1168+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_2 } })
1169+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_2 } })
1170+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
1171+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
1172+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
1173+
await payload.create({ collection: 'posts', data: { title: 'post', category: category_3 } })
1174+
1175+
const res = await payload.findDistinct({
1176+
collection: 'posts',
1177+
field: 'categoryTitle',
1178+
})
1179+
1180+
expect(res.values).toEqual([
1181+
{
1182+
categoryTitle: 'category_1',
1183+
},
1184+
{
1185+
categoryTitle: 'category_2',
1186+
},
1187+
{
1188+
categoryTitle: 'category_3',
1189+
},
1190+
])
1191+
})
1192+
11051193
describe('Compound Indexes', () => {
11061194
beforeEach(async () => {
11071195
await payload.delete({ collection: 'compound-indexes', where: {} })

0 commit comments

Comments
 (0)