diff --git a/docs/local-api/overview.mdx b/docs/local-api/overview.mdx index cb2e74f4f..4d39424b5 100644 --- a/docs/local-api/overview.mdx +++ b/docs/local-api/overview.mdx @@ -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 diff --git a/packages/db-mongodb/src/findDistinct.ts b/packages/db-mongodb/src/findDistinct.ts new file mode 100644 index 000000000..bc77a8cab --- /dev/null +++ b/packages/db-mongodb/src/findDistinct.ts @@ -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('', 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, + } +} diff --git a/packages/db-mongodb/src/index.ts b/packages/db-mongodb/src/index.ts index f2f152533..08c8e6cb6 100644 --- a/packages/db-mongodb/src/index.ts +++ b/packages/db-mongodb/src/index.ts @@ -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, diff --git a/packages/db-postgres/src/index.ts b/packages/db-postgres/src/index.ts index a6769cb73..df431424b 100644 --- a/packages/db-postgres/src/index.ts +++ b/packages/db-postgres/src/index.ts @@ -17,6 +17,7 @@ import { deleteVersions, destroy, find, + findDistinct, findGlobal, findGlobalVersions, findMigrationDir, @@ -120,6 +121,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj json: true, }, fieldConstraints: {}, + findDistinct, generateSchema: createSchemaGenerator({ columnToCodeConverter, corePackageSuffix: 'pg-core', diff --git a/packages/db-sqlite/src/countDistinct.ts b/packages/db-sqlite/src/countDistinct.ts index ae729138f..cbb51cee1 100644 --- a/packages/db-sqlite/src/countDistinct.ts +++ b/packages/db-sqlite/src/countDistinct.ts @@ -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() diff --git a/packages/db-sqlite/src/index.ts b/packages/db-sqlite/src/index.ts index 015ce9ba9..0cae31968 100644 --- a/packages/db-sqlite/src/index.ts +++ b/packages/db-sqlite/src/index.ts @@ -18,6 +18,7 @@ import { deleteVersions, destroy, find, + findDistinct, findGlobal, findGlobalVersions, findMigrationDir, @@ -101,6 +102,7 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj { json: true, }, fieldConstraints: {}, + findDistinct, generateSchema: createSchemaGenerator({ columnToCodeConverter, corePackageSuffix: 'sqlite-core', diff --git a/packages/db-sqlite/src/types.ts b/packages/db-sqlite/src/types.ts index 568f3a4dc..5aa84c993 100644 --- a/packages/db-sqlite/src/types.ts +++ b/packages/db-sqlite/src/types.ts @@ -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>> export type CountDistinct = (args: { + column?: SQLiteColumn db: LibSQLDatabase joins: BuildQueryJoinAliases tableName: string diff --git a/packages/db-vercel-postgres/src/index.ts b/packages/db-vercel-postgres/src/index.ts index a9fd65f63..155bdc2a2 100644 --- a/packages/db-vercel-postgres/src/index.ts +++ b/packages/db-vercel-postgres/src/index.ts @@ -18,6 +18,7 @@ import { deleteVersions, destroy, find, + findDistinct, findGlobal, findGlobalVersions, findMigrationDir, @@ -174,6 +175,7 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj { + 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, + tableName, + where, + }) + + const values = selectDistinctResult.map((each) => ({ + [args.field]: (each as Record)._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, + } +} diff --git a/packages/drizzle/src/index.ts b/packages/drizzle/src/index.ts index 6650b2617..dd1055bdf 100644 --- a/packages/drizzle/src/index.ts +++ b/packages/drizzle/src/index.ts @@ -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' diff --git a/packages/drizzle/src/postgres/countDistinct.ts b/packages/drizzle/src/postgres/countDistinct.ts index 04d7559fc..55f4ea8ad 100644 --- a/packages/drizzle/src/postgres/countDistinct.ts +++ b/packages/drizzle/src/postgres/countDistinct.ts @@ -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() diff --git a/packages/drizzle/src/postgres/types.ts b/packages/drizzle/src/postgres/types.ts index 696d13797..60ed3a074 100644 --- a/packages/drizzle/src/postgres/types.ts +++ b/packages/drizzle/src/postgres/types.ts @@ -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> export type PostgresDB = NodePgDatabase> export type CountDistinct = (args: { + column?: PgColumn | SQLiteColumn db: PostgresDB | TransactionPg joins: BuildQueryJoinAliases tableName: string diff --git a/packages/drizzle/src/queries/parseParams.ts b/packages/drizzle/src/queries/parseParams.ts index a5b88d4d7..b43dad70a 100644 --- a/packages/drizzle/src/queries/parseParams.ts +++ b/packages/drizzle/src/queries/parseParams.ts @@ -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( diff --git a/packages/drizzle/src/queries/selectDistinct.ts b/packages/drizzle/src/queries/selectDistinct.ts index 7cb6b5fc0..25bf75ba4 100644 --- a/packages/drizzle/src/queries/selectDistinct.ts +++ b/packages/drizzle/src/queries/selectDistinct.ts @@ -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 @@ -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> => { - if (Object.keys(joins).length > 0) { + if (forceRun || Object.keys(joins).length > 0) { let query: SQLiteSelect const table = adapter.tables[tableName] diff --git a/packages/drizzle/src/types.ts b/packages/drizzle/src/types.ts index 42f01b7ce..84dd5f1e7 100644 --- a/packages/drizzle/src/types.ts +++ b/packages/drizzle/src/types.ts @@ -89,6 +89,7 @@ export type TransactionPg = PgTransaction< export type DrizzleTransaction = TransactionPg | TransactionSQLite export type CountDistinct = (args: { + column?: PgColumn | SQLiteColumn db: DrizzleTransaction | LibSQLDatabase | PostgresDB joins: BuildQueryJoinAliases tableName: string diff --git a/packages/drizzle/src/utilities/rawConstraint.ts b/packages/drizzle/src/utilities/rawConstraint.ts index f47ceed9c..2105532e3 100644 --- a/packages/drizzle/src/utilities/rawConstraint.ts +++ b/packages/drizzle/src/utilities/rawConstraint.ts @@ -1,5 +1,7 @@ const RawConstraintSymbol = Symbol('RawConstraint') +export const DistinctSymbol = Symbol('DistinctSymbol') + /** * You can use this to inject a raw query to where */ diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 43f80ae91..441471554 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -82,6 +82,7 @@ export type HookOperationType = | 'forgotPassword' | 'login' | 'read' + | 'readDistinct' | 'refresh' | 'resetPassword' | 'update' diff --git a/packages/payload/src/collections/endpoints/findDistinct.ts b/packages/payload/src/collections/endpoints/findDistinct.ts new file mode 100644 index 000000000..3a7eb4b92 --- /dev/null +++ b/packages/payload/src/collections/endpoints/findDistinct.ts @@ -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, + }) +} diff --git a/packages/payload/src/collections/endpoints/index.ts b/packages/payload/src/collections/endpoints/index.ts index bab76e2db..368cd58eb 100644 --- a/packages/payload/src/collections/endpoints/index.ts +++ b/packages/payload/src/collections/endpoints/index.ts @@ -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', diff --git a/packages/payload/src/collections/operations/findDistinct.ts b/packages/payload/src/collections/operations/findDistinct.ts new file mode 100644 index 000000000..2c814f5f4 --- /dev/null +++ b/packages/payload/src/collections/operations/findDistinct.ts @@ -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>> => { + 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[] = [] + 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 + } +} diff --git a/packages/payload/src/collections/operations/local/findDistinct.ts b/packages/payload/src/collections/operations/local/findDistinct.ts new file mode 100644 index 000000000..2a0ca5cd7 --- /dev/null +++ b/packages/payload/src/collections/operations/local/findDistinct.ts @@ -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, +> = { + /** + * 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 + /** + * 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 & string, +>( + payload: Payload, + options: Options, +): Promise[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[TField]>>> +} diff --git a/packages/payload/src/collections/operations/utils.ts b/packages/payload/src/collections/operations/utils.ts index 6ea849724..faade92d4 100644 --- a/packages/payload/src/collections/operations/utils.ts +++ b/packages/payload/src/collections/operations/utils.ts @@ -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 = { boolean, SelectFromCollectionSlug > + findDistinct: typeof findDistinctOperation forgotPassword: typeof forgotPasswordOperation login: typeof loginOperation refresh: typeof refreshOperation @@ -81,6 +83,11 @@ export type AfterOperationArg = { operation: 'findByID' result: Awaited['findByID']>> } + | { + args: Parameters['findDistinct']>[0] + operation: 'findDistinct' + result: Awaited['findDistinct']>> + } | { args: Parameters['forgotPassword']>[0] operation: 'forgotPassword' diff --git a/packages/payload/src/database/types.ts b/packages/payload/src/database/types.ts index ca94f7699..c88b8324c 100644 --- a/packages/payload/src/database/types.ts +++ b/packages/payload/src/database/types.ts @@ -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 - /** * Run any migration down functions that have been performed */ migrateDown: () => Promise + /** * Drop the current database and run all migrate up functions */ migrateFresh: (args: { forceAcceptWarning?: boolean }) => Promise - /** * 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 + /** * 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 + sort?: Sort + where?: Where +} + +export type PaginatedDistinctDocs> = { + 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>> + export type Create = (args: CreateArgs) => Promise export type UpdateOneArgs = { diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 0c2ea2680..5909e20dd 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -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(this, options) } + /** + * @description Find distinct field values + * @param options + * @returns result with distinct field values + */ + findDistinct = async < + TSlug extends CollectionSlug, + TField extends keyof DataFromCollectionSlug & string, + >( + options: FindDistinctOptions, + ): Promise[TField]>>> => { + return findDistinctLocal(this, options) + } + findGlobal = async >( options: FindGlobalOptions, ): Promise> => { @@ -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, diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index 9bd4ae541..8cc74b84f 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -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: {} }) diff --git a/test/database/up-down-migration/migrations/20250707_123508.json b/test/database/up-down-migration/migrations/20250714_201659.json similarity index 99% rename from test/database/up-down-migration/migrations/20250707_123508.json rename to test/database/up-down-migration/migrations/20250714_201659.json index f54134521..18d7fcf69 100644 --- a/test/database/up-down-migration/migrations/20250707_123508.json +++ b/test/database/up-down-migration/migrations/20250714_201659.json @@ -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", diff --git a/test/database/up-down-migration/migrations/20250707_123508.ts b/test/database/up-down-migration/migrations/20250714_201659.ts similarity index 100% rename from test/database/up-down-migration/migrations/20250707_123508.ts rename to test/database/up-down-migration/migrations/20250714_201659.ts diff --git a/test/database/up-down-migration/migrations/index.ts b/test/database/up-down-migration/migrations/index.ts index 0c0f71044..fea58e46c 100644 --- a/test/database/up-down-migration/migrations/index.ts +++ b/test/database/up-down-migration/migrations/index.ts @@ -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' }, -] +]; diff --git a/test/helpers/NextRESTClient.ts b/test/helpers/NextRESTClient.ts index d49d11f33..30c65eec5 100644 --- a/test/helpers/NextRESTClient.ts +++ b/test/helpers/NextRESTClient.ts @@ -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