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 ### Update by ID#collection-update-by-id
```js ```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 { deleteVersions } from './deleteVersions.js'
import { destroy } from './destroy.js' import { destroy } from './destroy.js'
import { find } from './find.js' import { find } from './find.js'
import { findDistinct } from './findDistinct.js'
import { findGlobal } from './findGlobal.js' import { findGlobal } from './findGlobal.js'
import { findGlobalVersions } from './findGlobalVersions.js' import { findGlobalVersions } from './findGlobalVersions.js'
import { findOne } from './findOne.js' import { findOne } from './findOne.js'
@@ -297,6 +298,7 @@ export function mongooseAdapter({
destroy, destroy,
disableFallbackSort, disableFallbackSort,
find, find,
findDistinct,
findGlobal, findGlobal,
findGlobalVersions, findGlobalVersions,
findOne, findOne,

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import {
deleteVersions, deleteVersions,
destroy, destroy,
find, find,
findDistinct,
findGlobal, findGlobal,
findGlobalVersions, findGlobalVersions,
findMigrationDir, findMigrationDir,
@@ -101,6 +102,7 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj<SQLiteAdapter> {
json: true, json: true,
}, },
fieldConstraints: {}, fieldConstraints: {},
findDistinct,
generateSchema: createSchemaGenerator({ generateSchema: createSchemaGenerator({
columnToCodeConverter, columnToCodeConverter,
corePackageSuffix: 'sqlite-core', 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 { LibSQLDatabase } from 'drizzle-orm/libsql'
import type { import type {
AnySQLiteColumn, AnySQLiteColumn,
SQLiteColumn,
SQLiteInsertOnConflictDoUpdateConfig, SQLiteInsertOnConflictDoUpdateConfig,
SQLiteTableWithColumns, SQLiteTableWithColumns,
SQLiteTransactionConfig, SQLiteTransactionConfig,
@@ -87,6 +88,7 @@ export type GenericTable = SQLiteTableWithColumns<{
export type GenericRelation = Relations<string, Record<string, Relation<string>>> export type GenericRelation = Relations<string, Record<string, Relation<string>>>
export type CountDistinct = (args: { export type CountDistinct = (args: {
column?: SQLiteColumn<any>
db: LibSQLDatabase db: LibSQLDatabase
joins: BuildQueryJoinAliases joins: BuildQueryJoinAliases
tableName: string tableName: string

View File

@@ -18,6 +18,7 @@ import {
deleteVersions, deleteVersions,
destroy, destroy,
find, find,
findDistinct,
findGlobal, findGlobal,
findGlobalVersions, findGlobalVersions,
findMigrationDir, findMigrationDir,
@@ -174,6 +175,7 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<Verce
dropDatabase, dropDatabase,
execute, execute,
find, find,
findDistinct,
findGlobal, findGlobal,
findGlobalVersions, findGlobalVersions,
readReplicaOptions: args.readReplicas, 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 { destroy } from './destroy.js'
export { find } from './find.js' export { find } from './find.js'
export { chainMethods } from './find/chainMethods.js' export { chainMethods } from './find/chainMethods.js'
export { findDistinct } from './findDistinct.js'
export { findGlobal } from './findGlobal.js' export { findGlobal } from './findGlobal.js'
export { findGlobalVersions } from './findGlobalVersions.js' export { findGlobalVersions } from './findGlobalVersions.js'
export { findMigrationDir } from './findMigrationDir.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( export const countDistinct: CountDistinct = async function countDistinct(
this: BasePostgresAdapter, this: BasePostgresAdapter,
{ db, joins, tableName, where }, { column, db, joins, tableName, where },
) { ) {
// When we don't have any joins - use a simple COUNT(*) query. // When we don't have any joins - use a simple COUNT(*) query.
if (joins.length === 0) { if (joins.length === 0) {
const countResult = await db const countResult = await db
.select({ .select({
count: count(), count: column ? count(sql`DISTINCT ${column}`) : count(),
}) })
.from(this.tables[tableName]) .from(this.tables[tableName])
.where(where) .where(where)
@@ -26,7 +26,7 @@ export const countDistinct: CountDistinct = async function countDistinct(
}) })
.from(this.tables[tableName]) .from(this.tables[tableName])
.where(where) .where(where)
.groupBy(this.tables[tableName].id) .groupBy(column || this.tables[tableName].id)
.limit(1) .limit(1)
.$dynamic() .$dynamic()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -82,6 +82,7 @@ export type HookOperationType =
| 'forgotPassword' | 'forgotPassword'
| 'login' | 'login'
| 'read' | 'read'
| 'readDistinct'
| 'refresh' | 'refresh'
| 'resetPassword' | 'resetPassword'
| 'update' | '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 { duplicateHandler } from './duplicate.js'
import { findHandler } from './find.js' import { findHandler } from './find.js'
import { findByIDHandler } from './findByID.js' import { findByIDHandler } from './findByID.js'
import { findDistinctHandler } from './findDistinct.js'
import { findVersionByIDHandler } from './findVersionByID.js' import { findVersionByIDHandler } from './findVersionByID.js'
import { findVersionsHandler } from './findVersions.js' import { findVersionsHandler } from './findVersions.js'
import { previewHandler } from './preview.js' import { previewHandler } from './preview.js'
@@ -48,6 +49,12 @@ export const defaultCollectionEndpoints: Endpoint[] = [
method: 'get', method: 'get',
path: '/versions', path: '/versions',
}, },
// Might be uncommented in the future
// {
// handler: findDistinctHandler,
// method: 'get',
// path: '/distinct',
// },
{ {
handler: duplicateHandler, handler: duplicateHandler,
method: 'post', 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 { deleteByIDOperation } from './deleteByID.js'
import type { findOperation } from './find.js' import type { findOperation } from './find.js'
import type { findByIDOperation } from './findByID.js' import type { findByIDOperation } from './findByID.js'
import type { findDistinctOperation } from './findDistinct.js'
import type { updateOperation } from './update.js' import type { updateOperation } from './update.js'
import type { updateByIDOperation } from './updateByID.js' import type { updateByIDOperation } from './updateByID.js'
@@ -30,6 +31,7 @@ export type AfterOperationMap<TOperationGeneric extends CollectionSlug> = {
boolean, boolean,
SelectFromCollectionSlug<TOperationGeneric> SelectFromCollectionSlug<TOperationGeneric>
> >
findDistinct: typeof findDistinctOperation
forgotPassword: typeof forgotPasswordOperation forgotPassword: typeof forgotPasswordOperation
login: typeof loginOperation<TOperationGeneric> login: typeof loginOperation<TOperationGeneric>
refresh: typeof refreshOperation refresh: typeof refreshOperation
@@ -81,6 +83,11 @@ export type AfterOperationArg<TOperationGeneric extends CollectionSlug> = {
operation: 'findByID' operation: 'findByID'
result: Awaited<ReturnType<AfterOperationMap<TOperationGeneric>['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] args: Parameters<AfterOperationMap<TOperationGeneric>['forgotPassword']>[0]
operation: 'forgotPassword' operation: 'forgotPassword'

View File

@@ -63,6 +63,8 @@ export interface BaseDatabaseAdapter {
find: Find find: Find
findDistinct: FindDistinct
findGlobal: FindGlobal findGlobal: FindGlobal
findGlobalVersions: FindGlobalVersions findGlobalVersions: FindGlobalVersions
@@ -82,16 +84,15 @@ export interface BaseDatabaseAdapter {
* Run any migration up functions that have not yet been performed and update the status * Run any migration up functions that have not yet been performed and update the status
*/ */
migrate: (args?: { migrations?: Migration[] }) => Promise<void> migrate: (args?: { migrations?: Migration[] }) => Promise<void>
/** /**
* Run any migration down functions that have been performed * Run any migration down functions that have been performed
*/ */
migrateDown: () => Promise<void> migrateDown: () => Promise<void>
/** /**
* Drop the current database and run all migrate up functions * Drop the current database and run all migrate up functions
*/ */
migrateFresh: (args: { forceAcceptWarning?: boolean }) => Promise<void> migrateFresh: (args: { forceAcceptWarning?: boolean }) => Promise<void>
/** /**
* Run all migration down functions before running up * 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 * Read the current state of migrations and output the result to show which have been run
*/ */
migrateStatus: () => Promise<void> migrateStatus: () => Promise<void>
/** /**
* Path to read and write migration files from * Path to read and write migration files from
*/ */
@@ -113,7 +115,6 @@ export interface BaseDatabaseAdapter {
* The name of the database adapter * The name of the database adapter
*/ */
name: string name: string
/** /**
* Full package name of the database adapter * Full package name of the database adapter
* *
@@ -124,6 +125,7 @@ export interface BaseDatabaseAdapter {
* reference to the instance of payload * reference to the instance of payload
*/ */
payload: Payload payload: Payload
queryDrafts: QueryDrafts queryDrafts: QueryDrafts
/** /**
@@ -151,7 +153,6 @@ export interface BaseDatabaseAdapter {
updateMany: UpdateMany updateMany: UpdateMany
updateOne: UpdateOne updateOne: UpdateOne
updateVersion: UpdateVersion updateVersion: UpdateVersion
upsert: Upsert upsert: Upsert
} }
@@ -481,6 +482,34 @@ export type CreateArgs = {
select?: SelectType 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 Create = (args: CreateArgs) => Promise<Document>
export type UpdateOneArgs = { export type UpdateOneArgs = {

View File

@@ -40,7 +40,7 @@ import {
} from './auth/operations/local/verifyEmail.js' } from './auth/operations/local/verifyEmail.js'
export type { FieldState } from './admin/forms/Form.js' export type { FieldState } from './admin/forms/Form.js'
import type { InitOptions, SanitizedConfig } from './config/types.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 { InitializedEmailAdapter } from './email/types.js'
import type { DataFromGlobalSlug, Globals, SelectFromGlobalSlug } from './globals/config/types.js' import type { DataFromGlobalSlug, Globals, SelectFromGlobalSlug } from './globals/config/types.js'
import type { import type {
@@ -72,6 +72,10 @@ import {
findByIDLocal, findByIDLocal,
type Options as FindByIDOptions, type Options as FindByIDOptions,
} from './collections/operations/local/findByID.js' } from './collections/operations/local/findByID.js'
import {
findDistinct as findDistinctLocal,
type Options as FindDistinctOptions,
} from './collections/operations/local/findDistinct.js'
import { import {
findVersionByIDLocal, findVersionByIDLocal,
type Options as FindVersionByIDOptions, type Options as FindVersionByIDOptions,
@@ -464,6 +468,20 @@ export class BasePayload {
return findByIDLocal<TSlug, TDisableErrors, TSelect>(this, options) 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>>( findGlobal = async <TSlug extends GlobalSlug, TSelect extends SelectFromGlobalSlug<TSlug>>(
options: FindGlobalOptions<TSlug, TSelect>, options: FindGlobalOptions<TSlug, TSelect>,
): Promise<TransformGlobalWithSelect<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 { updateByIDOperation } from './collections/operations/updateByID.js'
export { buildConfig } from './config/build.js' export { buildConfig } from './config/build.js'
export { export {
type ClientConfig, type ClientConfig,
createClientConfig, createClientConfig,
@@ -1183,6 +1200,7 @@ export {
type UnsanitizedClientConfig, type UnsanitizedClientConfig,
} from './config/client.js' } from './config/client.js'
export { defaults } from './config/defaults.js' export { defaults } from './config/defaults.js'
export { type OrderableEndpointBody } from './config/orderable/index.js' export { type OrderableEndpointBody } from './config/orderable/index.js'
export { sanitizeConfig } from './config/sanitize.js' export { sanitizeConfig } from './config/sanitize.js'
export type * from './config/types.js' export type * from './config/types.js'
@@ -1237,6 +1255,7 @@ export type {
Destroy, Destroy,
Find, Find,
FindArgs, FindArgs,
FindDistinct,
FindGlobal, FindGlobal,
FindGlobalArgs, FindGlobalArgs,
FindGlobalVersions, FindGlobalVersions,
@@ -1250,6 +1269,7 @@ export type {
Migration, Migration,
MigrationData, MigrationData,
MigrationTemplateArgs, MigrationTemplateArgs,
PaginatedDistinctDocs,
PaginatedDocs, PaginatedDocs,
QueryDrafts, QueryDrafts,
QueryDraftsArgs, 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', () => { describe('Compound Indexes', () => {
beforeEach(async () => { beforeEach(async () => {
await payload.delete({ collection: 'compound-indexes', where: {} }) 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", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "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 = [ export const migrations = [
{ {
up: migration_20250707_123508.up, up: migration_20250714_201659.up,
down: migration_20250707_123508.down, down: migration_20250714_201659.down,
name: '20250707_123508', name: '20250714_201659'
}, },
] ];

View File

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