feat: add findDistinct operation (#13102)

Adds a new operation findDistinct that can give you distinct values of a
field for a given collection
Example:
Assume you have a collection posts with multiple documents, and some of
them share the same title:
```js
// Example dataset (some titles appear multiple times)
[
  { title: 'title-1' },
  { title: 'title-2' },
  { title: 'title-1' },
  { title: 'title-3' },
  { title: 'title-2' },
  { title: 'title-4' },
  { title: 'title-5' },
  { title: 'title-6' },
  { title: 'title-7' },
  { title: 'title-8' },
  { title: 'title-9' },
]
```
You can now retrieve all unique title values using findDistinct:
```js
const result = await payload.findDistinct({
  collection: 'posts',
  field: 'title',
})

console.log(result.values)
// Output:
// [
//   'title-1',
//   'title-2',
//   'title-3',
//   'title-4',
//   'title-5',
//   'title-6',
//   'title-7',
//   'title-8',
//   'title-9'
// ]
```
You can also limit the number of distinct results:
```js
const limitedResult = await payload.findDistinct({
  collection: 'posts',
  field: 'title',
  sortOrder: 'desc',
  limit: 3,
})

console.log(limitedResult.values)
// Output:
// [
//   'title-1',
//   'title-2',
//   'title-3'
// ]
```

You can also pass a `where` query to filter the documents.
This commit is contained in:
Sasha
2025-07-17 00:18:14 +03:00
committed by GitHub
parent cab7ba4a8a
commit a20b43624b
29 changed files with 869 additions and 26 deletions

View File

@@ -0,0 +1,141 @@
import type { PipelineStage } from 'mongoose'
import { type FindDistinct, getFieldByPath } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildSortParam } from './queries/buildSortParam.js'
import { getCollection } from './utilities/getEntity.js'
import { getSession } from './utilities/getSession.js'
export const findDistinct: FindDistinct = async function (this: MongooseAdapter, args) {
const { collectionConfig, Model } = getCollection({
adapter: this,
collectionSlug: args.collection,
})
const session = await getSession(this, args.req)
const { where = {} } = args
const sortAggregation: PipelineStage[] = []
const sort = buildSortParam({
adapter: this,
config: this.payload.config,
fields: collectionConfig.flattenedFields,
locale: args.locale,
sort: args.sort ?? args.field,
sortAggregation,
timestamps: true,
})
const query = await buildQuery({
adapter: this,
collectionSlug: args.collection,
fields: collectionConfig.flattenedFields,
locale: args.locale,
where,
})
const fieldPathResult = getFieldByPath({
fields: collectionConfig.flattenedFields,
path: args.field,
})
let fieldPath = args.field
if (fieldPathResult?.pathHasLocalized && args.locale) {
fieldPath = fieldPathResult.localizedPath.replace('<locale>', args.locale)
}
const page = args.page || 1
const sortProperty = Object.keys(sort)[0]! // assert because buildSortParam always returns at least 1 key.
const sortDirection = sort[sortProperty] === 'asc' ? 1 : -1
const pipeline: PipelineStage[] = [
{
$match: query,
},
...(sortAggregation.length > 0 ? sortAggregation : []),
{
$group: {
_id: {
_field: `$${fieldPath}`,
...(sortProperty === fieldPath
? {}
: {
_sort: `$${sortProperty}`,
}),
},
},
},
{
$sort: {
[sortProperty === fieldPath ? '_id._field' : '_id._sort']: sortDirection,
},
},
]
const getValues = async () => {
return Model.aggregate(pipeline, { session }).then((res) =>
res.map((each) => ({
[args.field]: JSON.parse(JSON.stringify(each._id._field)),
})),
)
}
if (args.limit) {
pipeline.push({
$skip: (page - 1) * args.limit,
})
pipeline.push({ $limit: args.limit })
const totalDocs = await Model.aggregate(
[
{
$match: query,
},
{
$group: {
_id: `$${fieldPath}`,
},
},
{ $count: 'count' },
],
{
session,
},
).then((res) => res[0]?.count ?? 0)
const totalPages = Math.ceil(totalDocs / args.limit)
const hasPrevPage = page > 1
const hasNextPage = totalPages > page
const pagingCounter = (page - 1) * args.limit + 1
return {
hasNextPage,
hasPrevPage,
limit: args.limit,
nextPage: hasNextPage ? page + 1 : null,
page,
pagingCounter,
prevPage: hasPrevPage ? page - 1 : null,
totalDocs,
totalPages,
values: await getValues(),
}
}
const values = await getValues()
return {
hasNextPage: false,
hasPrevPage: false,
limit: 0,
page: 1,
pagingCounter: 1,
totalDocs: values.length,
totalPages: 1,
values,
}
}

View File

@@ -42,6 +42,7 @@ import { deleteOne } from './deleteOne.js'
import { deleteVersions } from './deleteVersions.js'
import { destroy } from './destroy.js'
import { find } from './find.js'
import { findDistinct } from './findDistinct.js'
import { findGlobal } from './findGlobal.js'
import { findGlobalVersions } from './findGlobalVersions.js'
import { findOne } from './findOne.js'
@@ -297,6 +298,7 @@ export function mongooseAdapter({
destroy,
disableFallbackSort,
find,
findDistinct,
findGlobal,
findGlobalVersions,
findOne,