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

@@ -194,6 +194,27 @@ const result = await payload.count({
})
```
### FindDistinct#collection-find-distinct
```js
// Result will be an object with:
// {
// values: ['value-1', 'value-2'], // array of distinct values,
// field: 'title', // the field
// totalDocs: 10, // count of the distinct values satisfies query,
// perPage: 10, // count of distinct values per page (based on provided limit)
// }
const result = await payload.findDistinct({
collection: 'posts', // required
locale: 'en',
where: {}, // pass a `where` query here
user: dummyUser,
overrideAccess: false,
field: 'title',
sort: 'title',
})
```
### Update by ID#collection-update-by-id
```js

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,

View File

@@ -17,6 +17,7 @@ import {
deleteVersions,
destroy,
find,
findDistinct,
findGlobal,
findGlobalVersions,
findMigrationDir,
@@ -120,6 +121,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
json: true,
},
fieldConstraints: {},
findDistinct,
generateSchema: createSchemaGenerator({
columnToCodeConverter,
corePackageSuffix: 'pg-core',

View File

@@ -6,13 +6,13 @@ import type { CountDistinct, SQLiteAdapter } from './types.js'
export const countDistinct: CountDistinct = async function countDistinct(
this: SQLiteAdapter,
{ db, joins, tableName, where },
{ column, db, joins, tableName, where },
) {
// When we don't have any joins - use a simple COUNT(*) query.
if (joins.length === 0) {
const countResult = await db
.select({
count: count(),
count: column ? count(sql`DISTINCT ${column}`) : count(),
})
.from(this.tables[tableName])
.where(where)
@@ -25,7 +25,7 @@ export const countDistinct: CountDistinct = async function countDistinct(
})
.from(this.tables[tableName])
.where(where)
.groupBy(this.tables[tableName].id)
.groupBy(column ?? this.tables[tableName].id)
.limit(1)
.$dynamic()

View File

@@ -18,6 +18,7 @@ import {
deleteVersions,
destroy,
find,
findDistinct,
findGlobal,
findGlobalVersions,
findMigrationDir,
@@ -101,6 +102,7 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj<SQLiteAdapter> {
json: true,
},
fieldConstraints: {},
findDistinct,
generateSchema: createSchemaGenerator({
columnToCodeConverter,
corePackageSuffix: 'sqlite-core',

View File

@@ -5,6 +5,7 @@ import type { DrizzleConfig, Relation, Relations, SQL } from 'drizzle-orm'
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
import type {
AnySQLiteColumn,
SQLiteColumn,
SQLiteInsertOnConflictDoUpdateConfig,
SQLiteTableWithColumns,
SQLiteTransactionConfig,
@@ -87,6 +88,7 @@ export type GenericTable = SQLiteTableWithColumns<{
export type GenericRelation = Relations<string, Record<string, Relation<string>>>
export type CountDistinct = (args: {
column?: SQLiteColumn<any>
db: LibSQLDatabase
joins: BuildQueryJoinAliases
tableName: string

View File

@@ -18,6 +18,7 @@ import {
deleteVersions,
destroy,
find,
findDistinct,
findGlobal,
findGlobalVersions,
findMigrationDir,
@@ -174,6 +175,7 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<Verce
dropDatabase,
execute,
find,
findDistinct,
findGlobal,
findGlobalVersions,
readReplicaOptions: args.readReplicas,

View File

@@ -0,0 +1,108 @@
import type { FindDistinct, SanitizedCollectionConfig } from 'payload'
import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter, GenericColumn } from './types.js'
import { buildQuery } from './queries/buildQuery.js'
import { selectDistinct } from './queries/selectDistinct.js'
import { getTransaction } from './utilities/getTransaction.js'
import { DistinctSymbol } from './utilities/rawConstraint.js'
export const findDistinct: FindDistinct = async function (this: DrizzleAdapter, args) {
const db = await getTransaction(this, args.req)
const collectionConfig: SanitizedCollectionConfig =
this.payload.collections[args.collection].config
const page = args.page || 1
const offset = args.limit ? (page - 1) * args.limit : undefined
const tableName = this.tableNameMap.get(toSnakeCase(collectionConfig.slug))
const { joins, orderBy, selectFields, where } = buildQuery({
adapter: this,
fields: collectionConfig.flattenedFields,
locale: args.locale,
sort: args.sort ?? args.field,
tableName,
where: {
and: [
args.where ?? {},
{
[args.field]: {
equals: DistinctSymbol,
},
},
],
},
})
orderBy.pop()
const selectDistinctResult = await selectDistinct({
adapter: this,
db,
forceRun: true,
joins,
query: ({ query }) => {
query = query.orderBy(() => orderBy.map(({ column, order }) => order(column)))
if (args.limit) {
if (offset) {
query = query.offset(offset)
}
query = query.limit(args.limit)
}
return query
},
selectFields: {
_selected: selectFields['_selected'],
...(orderBy[0].column === selectFields['_selected'] ? {} : { _order: orderBy[0].column }),
} as Record<string, GenericColumn>,
tableName,
where,
})
const values = selectDistinctResult.map((each) => ({
[args.field]: (each as Record<string, any>)._selected,
}))
if (args.limit) {
const totalDocs = await this.countDistinct({
column: selectFields['_selected'],
db,
joins,
tableName,
where,
})
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,
}
}
return {
hasNextPage: false,
hasPrevPage: false,
limit: 0,
page: 1,
pagingCounter: 1,
totalDocs: values.length,
totalPages: 1,
values,
}
}

View File

@@ -12,6 +12,7 @@ export { deleteVersions } from './deleteVersions.js'
export { destroy } from './destroy.js'
export { find } from './find.js'
export { chainMethods } from './find/chainMethods.js'
export { findDistinct } from './findDistinct.js'
export { findGlobal } from './findGlobal.js'
export { findGlobalVersions } from './findGlobalVersions.js'
export { findMigrationDir } from './findMigrationDir.js'

View File

@@ -6,13 +6,13 @@ import type { BasePostgresAdapter, CountDistinct } from './types.js'
export const countDistinct: CountDistinct = async function countDistinct(
this: BasePostgresAdapter,
{ db, joins, tableName, where },
{ column, db, joins, tableName, where },
) {
// When we don't have any joins - use a simple COUNT(*) query.
if (joins.length === 0) {
const countResult = await db
.select({
count: count(),
count: column ? count(sql`DISTINCT ${column}`) : count(),
})
.from(this.tables[tableName])
.where(where)
@@ -26,7 +26,7 @@ export const countDistinct: CountDistinct = async function countDistinct(
})
.from(this.tables[tableName])
.where(where)
.groupBy(this.tables[tableName].id)
.groupBy(column || this.tables[tableName].id)
.limit(1)
.$dynamic()

View File

@@ -20,6 +20,7 @@ import type {
UniqueConstraintBuilder,
} from 'drizzle-orm/pg-core'
import type { PgTableFn } from 'drizzle-orm/pg-core/table'
import type { SQLiteColumn } from 'drizzle-orm/sqlite-core'
import type { Payload, PayloadRequest } from 'payload'
import type { ClientConfig, QueryResult } from 'pg'
@@ -64,6 +65,7 @@ export type GenericRelation = Relations<string, Record<string, Relation<string>>
export type PostgresDB = NodePgDatabase<Record<string, unknown>>
export type CountDistinct = (args: {
column?: PgColumn<any> | SQLiteColumn<any>
db: PostgresDB | TransactionPg
joins: BuildQueryJoinAliases
tableName: string

View File

@@ -10,6 +10,7 @@ import type { DrizzleAdapter, GenericColumn } from '../types.js'
import type { BuildQueryJoinAliases } from './buildQuery.js'
import { getNameFromDrizzleTable } from '../utilities/getNameFromDrizzleTable.js'
import { DistinctSymbol } from '../utilities/rawConstraint.js'
import { buildAndOrConditions } from './buildAndOrConditions.js'
import { getTableColumnFromPath } from './getTableColumnFromPath.js'
import { sanitizeQueryValue } from './sanitizeQueryValue.js'
@@ -108,6 +109,17 @@ export function parseParams({
value: val,
})
const resolvedColumn =
rawColumn ||
(aliasTable && tableName === getNameFromDrizzleTable(table)
? aliasTable[columnName]
: table[columnName])
if (val === DistinctSymbol) {
selectFields['_selected'] = resolvedColumn
break
}
queryConstraints.forEach(({ columnName: col, table: constraintTable, value }) => {
if (typeof value === 'string' && value.indexOf('%') > -1) {
constraints.push(adapter.operators.like(constraintTable[col], value))
@@ -281,12 +293,6 @@ export function parseParams({
break
}
const resolvedColumn =
rawColumn ||
(aliasTable && tableName === getNameFromDrizzleTable(table)
? aliasTable[columnName]
: table[columnName])
if (queryOperator === 'not_equals' && queryValue !== null) {
constraints.push(
or(

View File

@@ -14,6 +14,7 @@ import type { BuildQueryJoinAliases } from './buildQuery.js'
type Args = {
adapter: DrizzleAdapter
db: DrizzleAdapter['drizzle'] | DrizzleTransaction
forceRun?: boolean
joins: BuildQueryJoinAliases
query?: (args: { query: SQLiteSelect }) => SQLiteSelect
selectFields: Record<string, GenericColumn>
@@ -27,13 +28,14 @@ type Args = {
export const selectDistinct = ({
adapter,
db,
forceRun,
joins,
query: queryModifier = ({ query }) => query,
selectFields,
tableName,
where,
}: Args): QueryPromise<{ id: number | string }[] & Record<string, GenericColumn>> => {
if (Object.keys(joins).length > 0) {
if (forceRun || Object.keys(joins).length > 0) {
let query: SQLiteSelect
const table = adapter.tables[tableName]

View File

@@ -89,6 +89,7 @@ export type TransactionPg = PgTransaction<
export type DrizzleTransaction = TransactionPg | TransactionSQLite
export type CountDistinct = (args: {
column?: PgColumn<any> | SQLiteColumn<any>
db: DrizzleTransaction | LibSQLDatabase | PostgresDB
joins: BuildQueryJoinAliases
tableName: string

View File

@@ -1,5 +1,7 @@
const RawConstraintSymbol = Symbol('RawConstraint')
export const DistinctSymbol = Symbol('DistinctSymbol')
/**
* You can use this to inject a raw query to where
*/

View File

@@ -82,6 +82,7 @@ export type HookOperationType =
| 'forgotPassword'
| 'login'
| 'read'
| 'readDistinct'
| 'refresh'
| 'resetPassword'
| 'update'

View File

@@ -0,0 +1,46 @@
import { status as httpStatus } from 'http-status'
import type { PayloadHandler } from '../../config/types.js'
import type { Where } from '../../types/index.js'
import { APIError } from '../../errors/APIError.js'
import { getRequestCollection } from '../../utilities/getRequestEntity.js'
import { headersWithCors } from '../../utilities/headersWithCors.js'
import { isNumber } from '../../utilities/isNumber.js'
import { findDistinctOperation } from '../operations/findDistinct.js'
export const findDistinctHandler: PayloadHandler = async (req) => {
const collection = getRequestCollection(req)
const { depth, field, limit, page, sort, where } = req.query as {
depth?: string
field?: string
limit?: string
page?: string
sort?: string
sortOrder?: string
where?: Where
}
if (!field) {
throw new APIError('field must be specified', httpStatus.BAD_REQUEST)
}
const result = await findDistinctOperation({
collection,
depth: isNumber(depth) ? Number(depth) : undefined,
field,
limit: isNumber(limit) ? Number(limit) : undefined,
page: isNumber(page) ? Number(page) : undefined,
req,
sort: typeof sort === 'string' ? sort.split(',') : undefined,
where,
})
return Response.json(result, {
headers: headersWithCors({
headers: new Headers(),
req,
}),
status: httpStatus.OK,
})
}

View File

@@ -9,6 +9,7 @@ import { docAccessHandler } from './docAccess.js'
import { duplicateHandler } from './duplicate.js'
import { findHandler } from './find.js'
import { findByIDHandler } from './findByID.js'
import { findDistinctHandler } from './findDistinct.js'
import { findVersionByIDHandler } from './findVersionByID.js'
import { findVersionsHandler } from './findVersions.js'
import { previewHandler } from './preview.js'
@@ -48,6 +49,12 @@ export const defaultCollectionEndpoints: Endpoint[] = [
method: 'get',
path: '/versions',
},
// Might be uncommented in the future
// {
// handler: findDistinctHandler,
// method: 'get',
// path: '/distinct',
// },
{
handler: duplicateHandler,
method: 'post',

View File

@@ -0,0 +1,189 @@
import httpStatus from 'http-status'
import type { AccessResult } from '../../config/types.js'
import type { PaginatedDistinctDocs } from '../../database/types.js'
import type { PayloadRequest, PopulateType, Sort, Where } from '../../types/index.js'
import type { Collection } from '../config/types.js'
import { executeAccess } from '../../auth/executeAccess.js'
import { combineQueries } from '../../database/combineQueries.js'
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js'
import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js'
import { APIError } from '../../errors/APIError.js'
import { Forbidden } from '../../errors/Forbidden.js'
import { relationshipPopulationPromise } from '../../fields/hooks/afterRead/relationshipPopulationPromise.js'
import { getFieldByPath } from '../../utilities/getFieldByPath.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { buildAfterOperation } from './utils.js'
export type Arguments = {
collection: Collection
depth?: number
disableErrors?: boolean
field: string
limit?: number
locale?: string
overrideAccess?: boolean
page?: number
populate?: PopulateType
req?: PayloadRequest
showHiddenFields?: boolean
sort?: Sort
where?: Where
}
export const findDistinctOperation = async (
incomingArgs: Arguments,
): Promise<PaginatedDistinctDocs<Record<string, unknown>>> => {
let args = incomingArgs
try {
// /////////////////////////////////////
// beforeOperation - Collection
// /////////////////////////////////////
if (args.collection.config.hooks?.beforeOperation?.length) {
for (const hook of args.collection.config.hooks.beforeOperation) {
args =
(await hook({
args,
collection: args.collection.config,
context: args.req!.context,
operation: 'readDistinct',
req: args.req!,
})) || args
}
}
const {
collection: { config: collectionConfig },
disableErrors,
overrideAccess,
populate,
showHiddenFields = false,
where,
} = args
const req = args.req!
const { locale, payload } = req
// /////////////////////////////////////
// Access
// /////////////////////////////////////
let accessResult: AccessResult
if (!overrideAccess) {
accessResult = await executeAccess({ disableErrors, req }, collectionConfig.access.read)
// If errors are disabled, and access returns false, return empty results
if (accessResult === false) {
return {
hasNextPage: false,
hasPrevPage: false,
limit: args.limit || 0,
nextPage: null,
page: 1,
pagingCounter: 1,
prevPage: null,
totalDocs: 0,
totalPages: 0,
values: [],
}
}
}
// /////////////////////////////////////
// Find Distinct
// /////////////////////////////////////
const fullWhere = combineQueries(where!, accessResult!)
sanitizeWhereQuery({ fields: collectionConfig.flattenedFields, payload, where: fullWhere })
await validateQueryPaths({
collectionConfig,
overrideAccess: overrideAccess!,
req,
where: where ?? {},
})
const fieldResult = getFieldByPath({
fields: collectionConfig.flattenedFields,
path: args.field,
})
if (!fieldResult) {
throw new APIError(
`Field ${args.field} was not found in the collection ${collectionConfig.slug}`,
httpStatus.BAD_REQUEST,
)
}
if (fieldResult.field.hidden && !showHiddenFields) {
throw new Forbidden(req.t)
}
if (fieldResult.field.access?.read) {
const hasAccess = await fieldResult.field.access.read({ req })
if (!hasAccess) {
throw new Forbidden(req.t)
}
}
let result = await payload.db.findDistinct({
collection: collectionConfig.slug,
field: args.field,
limit: args.limit,
locale: locale!,
page: args.page,
req,
sort: args.sort,
where: fullWhere,
})
if (
(fieldResult.field.type === 'relationship' || fieldResult.field.type === 'upload') &&
args.depth
) {
const populationPromises: Promise<void>[] = []
for (const doc of result.values) {
populationPromises.push(
relationshipPopulationPromise({
currentDepth: 0,
depth: args.depth,
draft: false,
fallbackLocale: req.fallbackLocale || null,
field: fieldResult.field,
locale: req.locale || null,
overrideAccess: args.overrideAccess ?? true,
parentIsLocalized: false,
populate,
req,
showHiddenFields: false,
siblingDoc: doc,
}),
)
}
await Promise.all(populationPromises)
}
// /////////////////////////////////////
// afterOperation - Collection
// /////////////////////////////////////
result = await buildAfterOperation({
args,
collection: collectionConfig,
operation: 'findDistinct',
result,
})
// /////////////////////////////////////
// Return results
// /////////////////////////////////////
return result
} catch (error: unknown) {
await killTransaction(args.req!)
throw error
}
}

View File

@@ -0,0 +1,138 @@
import type {
CollectionSlug,
DataFromCollectionSlug,
Document,
PaginatedDistinctDocs,
Payload,
PayloadRequest,
PopulateType,
RequestContext,
Sort,
TypedLocale,
Where,
} from '../../../index.js'
import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js'
import { APIError, createLocalReq } from '../../../index.js'
import { findDistinctOperation } from '../findDistinct.js'
export type Options<
TSlug extends CollectionSlug,
TField extends keyof DataFromCollectionSlug<TSlug>,
> = {
/**
* the Collection slug to operate against.
*/
collection: TSlug
/**
* [Context](https://payloadcms.com/docs/hooks/context), which will then be passed to `context` and `req.context`,
* which can be read by hooks. Useful if you want to pass additional information to the hooks which
* shouldn't be necessarily part of the document, for example a `triggerBeforeChange` option which can be read by the BeforeChange hook
* to determine if it should run or not.
*/
context?: RequestContext
/**
* [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields.
*/
depth?: number
/**
* When set to `true`, errors will not be thrown.
*/
disableErrors?: boolean
/**
* The field to get distinct values for
*/
field: TField
/**
* The maximum distinct field values to be returned.
* By default the operation returns all the values.
*/
limit?: number
/**
* Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents.
*/
locale?: 'all' | TypedLocale
/**
* Skip access control.
* Set to `false` if you want to respect Access Control for the operation, for example when fetching data for the fron-end.
* @default true
*/
overrideAccess?: boolean
/**
* Get a specific page number (if limit is specified)
* @default 1
*/
page?: number
/**
* Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents.
*/
populate?: PopulateType
/**
* The `PayloadRequest` object. You can pass it to thread the current [transaction](https://payloadcms.com/docs/database/transactions), user and locale to the operation.
* Recommended to pass when using the Local API from hooks, as usually you want to execute the operation within the current transaction.
*/
req?: Partial<PayloadRequest>
/**
* Opt-in to receiving hidden fields. By default, they are hidden from returned documents in accordance to your config.
* @default false
*/
showHiddenFields?: boolean
/**
* Sort the documents, can be a string or an array of strings
* @example '-createdAt' // Sort DESC by createdAt
* @example ['group', '-createdAt'] // sort by 2 fields, ASC group and DESC createdAt
*/
sort?: Sort
/**
* If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks.
*/
user?: Document
/**
* A filter [query](https://payloadcms.com/docs/queries/overview)
*/
where?: Where
}
export async function findDistinct<
TSlug extends CollectionSlug,
TField extends keyof DataFromCollectionSlug<TSlug> & string,
>(
payload: Payload,
options: Options<TSlug, TField>,
): Promise<PaginatedDistinctDocs<Record<TField, DataFromCollectionSlug<TSlug>[TField]>>> {
const {
collection: collectionSlug,
depth = 0,
disableErrors,
field,
limit,
overrideAccess = true,
page,
populate,
showHiddenFields,
sort,
where,
} = options
const collection = payload.collections[collectionSlug]
if (!collection) {
throw new APIError(
`The collection with slug ${String(collectionSlug)} can't be found. Find Operation.`,
)
}
return findDistinctOperation({
collection,
depth,
disableErrors,
field,
limit,
overrideAccess,
page,
populate,
req: await createLocalReq(options as CreateLocalReqOptions, payload),
showHiddenFields,
sort,
where,
}) as Promise<PaginatedDistinctDocs<Record<TField, DataFromCollectionSlug<TSlug>[TField]>>>
}

View File

@@ -12,6 +12,7 @@ import type { deleteOperation } from './delete.js'
import type { deleteByIDOperation } from './deleteByID.js'
import type { findOperation } from './find.js'
import type { findByIDOperation } from './findByID.js'
import type { findDistinctOperation } from './findDistinct.js'
import type { updateOperation } from './update.js'
import type { updateByIDOperation } from './updateByID.js'
@@ -30,6 +31,7 @@ export type AfterOperationMap<TOperationGeneric extends CollectionSlug> = {
boolean,
SelectFromCollectionSlug<TOperationGeneric>
>
findDistinct: typeof findDistinctOperation
forgotPassword: typeof forgotPasswordOperation
login: typeof loginOperation<TOperationGeneric>
refresh: typeof refreshOperation
@@ -81,6 +83,11 @@ export type AfterOperationArg<TOperationGeneric extends CollectionSlug> = {
operation: 'findByID'
result: Awaited<ReturnType<AfterOperationMap<TOperationGeneric>['findByID']>>
}
| {
args: Parameters<AfterOperationMap<TOperationGeneric>['findDistinct']>[0]
operation: 'findDistinct'
result: Awaited<ReturnType<AfterOperationMap<TOperationGeneric>['findDistinct']>>
}
| {
args: Parameters<AfterOperationMap<TOperationGeneric>['forgotPassword']>[0]
operation: 'forgotPassword'

View File

@@ -63,6 +63,8 @@ export interface BaseDatabaseAdapter {
find: Find
findDistinct: FindDistinct
findGlobal: FindGlobal
findGlobalVersions: FindGlobalVersions
@@ -82,16 +84,15 @@ export interface BaseDatabaseAdapter {
* Run any migration up functions that have not yet been performed and update the status
*/
migrate: (args?: { migrations?: Migration[] }) => Promise<void>
/**
* Run any migration down functions that have been performed
*/
migrateDown: () => Promise<void>
/**
* Drop the current database and run all migrate up functions
*/
migrateFresh: (args: { forceAcceptWarning?: boolean }) => Promise<void>
/**
* Run all migration down functions before running up
*/
@@ -104,6 +105,7 @@ export interface BaseDatabaseAdapter {
* Read the current state of migrations and output the result to show which have been run
*/
migrateStatus: () => Promise<void>
/**
* Path to read and write migration files from
*/
@@ -113,7 +115,6 @@ export interface BaseDatabaseAdapter {
* The name of the database adapter
*/
name: string
/**
* Full package name of the database adapter
*
@@ -124,6 +125,7 @@ export interface BaseDatabaseAdapter {
* reference to the instance of payload
*/
payload: Payload
queryDrafts: QueryDrafts
/**
@@ -151,7 +153,6 @@ export interface BaseDatabaseAdapter {
updateMany: UpdateMany
updateOne: UpdateOne
updateVersion: UpdateVersion
upsert: Upsert
}
@@ -481,6 +482,34 @@ export type CreateArgs = {
select?: SelectType
}
export type FindDistinctArgs = {
collection: CollectionSlug
field: string
limit?: number
locale?: string
page?: number
req?: Partial<PayloadRequest>
sort?: Sort
where?: Where
}
export type PaginatedDistinctDocs<T extends Record<string, unknown>> = {
hasNextPage: boolean
hasPrevPage: boolean
limit: number
nextPage?: null | number | undefined
page: number
pagingCounter: number
prevPage?: null | number | undefined
totalDocs: number
totalPages: number
values: T[]
}
export type FindDistinct = (
args: FindDistinctArgs,
) => Promise<PaginatedDistinctDocs<Record<string, any>>>
export type Create = (args: CreateArgs) => Promise<Document>
export type UpdateOneArgs = {

View File

@@ -40,7 +40,7 @@ import {
} from './auth/operations/local/verifyEmail.js'
export type { FieldState } from './admin/forms/Form.js'
import type { InitOptions, SanitizedConfig } from './config/types.js'
import type { BaseDatabaseAdapter, PaginatedDocs } from './database/types.js'
import type { BaseDatabaseAdapter, PaginatedDistinctDocs, PaginatedDocs } from './database/types.js'
import type { InitializedEmailAdapter } from './email/types.js'
import type { DataFromGlobalSlug, Globals, SelectFromGlobalSlug } from './globals/config/types.js'
import type {
@@ -72,6 +72,10 @@ import {
findByIDLocal,
type Options as FindByIDOptions,
} from './collections/operations/local/findByID.js'
import {
findDistinct as findDistinctLocal,
type Options as FindDistinctOptions,
} from './collections/operations/local/findDistinct.js'
import {
findVersionByIDLocal,
type Options as FindVersionByIDOptions,
@@ -464,6 +468,20 @@ export class BasePayload {
return findByIDLocal<TSlug, TDisableErrors, TSelect>(this, options)
}
/**
* @description Find distinct field values
* @param options
* @returns result with distinct field values
*/
findDistinct = async <
TSlug extends CollectionSlug,
TField extends keyof DataFromCollectionSlug<TSlug> & string,
>(
options: FindDistinctOptions<TSlug, TField>,
): Promise<PaginatedDistinctDocs<Record<TField, DataFromCollectionSlug<TSlug>[TField]>>> => {
return findDistinctLocal(this, options)
}
findGlobal = async <TSlug extends GlobalSlug, TSelect extends SelectFromGlobalSlug<TSlug>>(
options: FindGlobalOptions<TSlug, TSelect>,
): Promise<TransformGlobalWithSelect<TSlug, TSelect>> => {
@@ -1174,7 +1192,6 @@ export { updateOperation } from './collections/operations/update.js'
export { updateByIDOperation } from './collections/operations/updateByID.js'
export { buildConfig } from './config/build.js'
export {
type ClientConfig,
createClientConfig,
@@ -1183,6 +1200,7 @@ export {
type UnsanitizedClientConfig,
} from './config/client.js'
export { defaults } from './config/defaults.js'
export { type OrderableEndpointBody } from './config/orderable/index.js'
export { sanitizeConfig } from './config/sanitize.js'
export type * from './config/types.js'
@@ -1237,6 +1255,7 @@ export type {
Destroy,
Find,
FindArgs,
FindDistinct,
FindGlobal,
FindGlobalArgs,
FindGlobalVersions,
@@ -1250,6 +1269,7 @@ export type {
Migration,
MigrationData,
MigrationTemplateArgs,
PaginatedDistinctDocs,
PaginatedDocs,
QueryDrafts,
QueryDraftsArgs,

View File

@@ -385,6 +385,118 @@ describe('database', () => {
})
})
it('should find distinct field values of the collection', async () => {
await payload.delete({ collection: 'posts', where: {} })
const titles = [
'title-1',
'title-2',
'title-3',
'title-4',
'title-5',
'title-6',
'title-7',
'title-8',
'title-9',
].map((title) => ({ title }))
for (const { title } of titles) {
// eslint-disable-next-line jest/no-conditional-in-test
const docsCount = Math.random() > 0.5 ? 3 : Math.random() > 0.5 ? 2 : 1
for (let i = 0; i < docsCount; i++) {
await payload.create({ collection: 'posts', data: { title } })
}
}
const res = await payload.findDistinct({
collection: 'posts',
field: 'title',
})
expect(res.values).toStrictEqual(titles)
// const resREST = await restClient
// .GET('/posts/distinct', {
// headers: {
// Authorization: `Bearer ${token}`,
// },
// query: { sortOrder: 'asc', field: 'title' },
// })
// .then((res) => res.json())
// expect(resREST.values).toEqual(titles)
const resLimit = await payload.findDistinct({
collection: 'posts',
field: 'title',
limit: 3,
})
expect(resLimit.values).toStrictEqual(
['title-1', 'title-2', 'title-3'].map((title) => ({ title })),
)
// count is still 9
expect(resLimit.totalDocs).toBe(9)
const resDesc = await payload.findDistinct({
collection: 'posts',
sort: '-title',
field: 'title',
})
expect(resDesc.values).toStrictEqual(titles.toReversed())
const resAscDefault = await payload.findDistinct({
collection: 'posts',
field: 'title',
})
expect(resAscDefault.values).toStrictEqual(titles)
})
it('should populate distinct relationships when depth>0', async () => {
await payload.delete({ collection: 'posts', where: {} })
const categories = ['category-1', 'category-2', 'category-3', 'category-4'].map((title) => ({
title,
}))
const categoriesIDS: { category: string }[] = []
for (const { title } of categories) {
const doc = await payload.create({ collection: 'categories', data: { title } })
categoriesIDS.push({ category: doc.id })
}
for (const { category } of categoriesIDS) {
// eslint-disable-next-line jest/no-conditional-in-test
const docsCount = Math.random() > 0.5 ? 3 : Math.random() > 0.5 ? 2 : 1
for (let i = 0; i < docsCount; i++) {
await payload.create({ collection: 'posts', data: { title: randomUUID(), category } })
}
}
const resultDepth0 = await payload.findDistinct({
collection: 'posts',
sort: 'category.title',
field: 'category',
})
expect(resultDepth0.values).toStrictEqual(categoriesIDS)
const resultDepth1 = await payload.findDistinct({
depth: 1,
collection: 'posts',
field: 'category',
sort: 'category.title',
})
for (let i = 0; i < resultDepth1.values.length; i++) {
const fromRes = resultDepth1.values[i] as any
const id = categoriesIDS[i].category as any
const title = categories[i]?.title
expect(fromRes.category.title).toBe(title)
expect(fromRes.category.id).toBe(id)
}
})
describe('Compound Indexes', () => {
beforeEach(async () => {
await payload.delete({ collection: 'compound-indexes', where: {} })

View File

@@ -1,5 +1,5 @@
{
"id": "bf183b76-944c-4e83-bd58-4aa993885106",
"id": "80e7a0d2-ffb3-4f22-8597-0442b3ab8102",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",

View File

@@ -1,9 +1,9 @@
import * as migration_20250707_123508 from './20250707_123508.js'
import * as migration_20250714_201659 from './20250714_201659.js';
export const migrations = [
{
up: migration_20250707_123508.up,
down: migration_20250707_123508.down,
name: '20250707_123508',
up: migration_20250714_201659.up,
down: migration_20250714_201659.down,
name: '20250714_201659'
},
]
];

View File

@@ -16,7 +16,7 @@ import { devUser } from '../credentials.js'
type ValidPath = `/${string}`
type RequestOptions = {
auth?: boolean
query?: {
query?: { [key: string]: unknown } & {
depth?: number
fallbackLocale?: string
joins?: JoinQuery