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:
@@ -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
|
||||||
|
|||||||
141
packages/db-mongodb/src/findDistinct.ts
Normal file
141
packages/db-mongodb/src/findDistinct.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
108
packages/drizzle/src/findDistinct.ts
Normal file
108
packages/drizzle/src/findDistinct.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export type HookOperationType =
|
|||||||
| 'forgotPassword'
|
| 'forgotPassword'
|
||||||
| 'login'
|
| 'login'
|
||||||
| 'read'
|
| 'read'
|
||||||
|
| 'readDistinct'
|
||||||
| 'refresh'
|
| 'refresh'
|
||||||
| 'resetPassword'
|
| 'resetPassword'
|
||||||
| 'update'
|
| 'update'
|
||||||
|
|||||||
46
packages/payload/src/collections/endpoints/findDistinct.ts
Normal file
46
packages/payload/src/collections/endpoints/findDistinct.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
189
packages/payload/src/collections/operations/findDistinct.ts
Normal file
189
packages/payload/src/collections/operations/findDistinct.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]>>>
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {} })
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -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'
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user