feat: add join field config where property (#8973)
### What? Makes it possible to filter join documents using a `where` added directly in the config. ### Why? It makes the join field more powerful for adding contextual meaning to the documents being returned. For example, maybe you have a `requiresAction` field that you set and you can have a join that automatically filters the documents to those that need attention. ### How? In the database adapter, we merge the requested `where` to the `where` defined on the field. On the frontend the results are filtered using the `filterOptions` property in the component. Fixes https://github.com/payloadcms/payload/discussions/8936 https://github.com/payloadcms/payload/discussions/8937 --------- Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com>
This commit is contained in:
@@ -126,12 +126,13 @@ powerful Admin UI.
|
|||||||
| **`name`** \* | To be used as the property name when retrieved from the database. [More](/docs/fields/overview#field-names) |
|
| **`name`** \* | To be used as the property name when retrieved from the database. [More](/docs/fields/overview#field-names) |
|
||||||
| **`collection`** \* | The `slug`s having the relationship field. |
|
| **`collection`** \* | The `slug`s having the relationship field. |
|
||||||
| **`on`** \* | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. |
|
| **`on`** \* | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. |
|
||||||
|
| **`where`** \* | A `Where` query to hide related documents from appearing. Will be merged with any `where` specified in the request. |
|
||||||
| **`maxDepth`** | Default is 1, Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](/docs/getting-started/concepts#field-level-max-depth). |
|
| **`maxDepth`** | Default is 1, Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](/docs/getting-started/concepts#field-level-max-depth). |
|
||||||
| **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. |
|
| **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. |
|
||||||
| **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). |
|
| **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). |
|
||||||
| **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). |
|
| **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). |
|
||||||
| **`defaultLimit`** | The number of documents to return. Set to 0 to return all related documents. |
|
| **`defaultLimit`** | The number of documents to return. Set to 0 to return all related documents. |
|
||||||
| **`defaultSort`** | The field name used to specify the order the joined documents are returned. |
|
| **`defaultSort`** | The field name used to specify the order the joined documents are returned. |
|
||||||
| **`admin`** | Admin-specific configuration. [More details](#admin-config-options). |
|
| **`admin`** | Admin-specific configuration. [More details](#admin-config-options). |
|
||||||
| **`custom`** | Extension point for adding custom data (e.g. for plugins). |
|
| **`custom`** | Extension point for adding custom data (e.g. for plugins). |
|
||||||
| **`typescriptSchema`** | Override field type generation with providing a JSON schema. |
|
| **`typescriptSchema`** | Override field type generation with providing a JSON schema. |
|
||||||
@@ -182,11 +183,11 @@ returning. This is useful for performance reasons when you don't need the relate
|
|||||||
|
|
||||||
The following query options are supported:
|
The following query options are supported:
|
||||||
|
|
||||||
| Property | Description |
|
| Property | Description |
|
||||||
|-------------|--------------------------------------------------------------|
|
|-------------|-----------------------------------------------------------------------------------------------------|
|
||||||
| **`limit`** | The maximum related documents to be returned, default is 10. |
|
| **`limit`** | The maximum related documents to be returned, default is 10. |
|
||||||
| **`where`** | An optional `Where` query to filter joined documents. |
|
| **`where`** | An optional `Where` query to filter joined documents. Will be merged with the field `where` object. |
|
||||||
| **`sort`** | A string used to order related results |
|
| **`sort`** | A string used to order related results |
|
||||||
|
|
||||||
These can be applied to the local API, GraphQL, and REST API.
|
These can be applied to the local API, GraphQL, and REST API.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { PipelineStage } from 'mongoose'
|
import type { PipelineStage } from 'mongoose'
|
||||||
import type { CollectionSlug, JoinQuery, SanitizedCollectionConfig, Where } from 'payload'
|
import type { CollectionSlug, JoinQuery, SanitizedCollectionConfig, Where } from 'payload'
|
||||||
|
|
||||||
|
import { combineQueries } from 'payload'
|
||||||
|
|
||||||
import type { MongooseAdapter } from '../index.js'
|
import type { MongooseAdapter } from '../index.js'
|
||||||
|
|
||||||
import { buildSortParam } from '../queries/buildSortParam.js'
|
import { buildSortParam } from '../queries/buildSortParam.js'
|
||||||
@@ -62,6 +64,10 @@ export const buildJoinAggregation = async ({
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (joins?.[join.schemaPath] === false) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
limit: limitJoin = join.field.defaultLimit ?? 10,
|
limit: limitJoin = join.field.defaultLimit ?? 10,
|
||||||
sort: sortJoin = join.field.defaultSort || collectionConfig.defaultSort,
|
sort: sortJoin = join.field.defaultSort || collectionConfig.defaultSort,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|||||||
import type { Field, JoinQuery, SelectMode, SelectType, TabAsField } from 'payload'
|
import type { Field, JoinQuery, SelectMode, SelectType, TabAsField } from 'payload'
|
||||||
|
|
||||||
import { and, eq, sql } from 'drizzle-orm'
|
import { and, eq, sql } from 'drizzle-orm'
|
||||||
|
import { combineQueries } from 'payload'
|
||||||
import { fieldAffectsData, fieldIsVirtual, tabHasName } from 'payload/shared'
|
import { fieldAffectsData, fieldIsVirtual, tabHasName } from 'payload/shared'
|
||||||
import toSnakeCase from 'to-snake-case'
|
import toSnakeCase from 'to-snake-case'
|
||||||
|
|
||||||
@@ -402,11 +403,17 @@ export const traverseFields = ({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const joinSchemaPath = `${path.replaceAll('_', '.')}${field.name}`
|
||||||
|
|
||||||
|
if (joinQuery[joinSchemaPath] === false) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
limit: limitArg = field.defaultLimit ?? 10,
|
limit: limitArg = field.defaultLimit ?? 10,
|
||||||
sort = field.defaultSort,
|
sort = field.defaultSort,
|
||||||
where,
|
where,
|
||||||
} = joinQuery[`${path.replaceAll('_', '.')}${field.name}`] || {}
|
} = joinQuery[joinSchemaPath] || {}
|
||||||
let limit = limitArg
|
let limit = limitArg
|
||||||
|
|
||||||
if (limit !== 0) {
|
if (limit !== 0) {
|
||||||
|
|||||||
@@ -9,21 +9,27 @@ import { isNumber } from 'payload/shared'
|
|||||||
export const sanitizeJoinParams = (
|
export const sanitizeJoinParams = (
|
||||||
joins:
|
joins:
|
||||||
| {
|
| {
|
||||||
[schemaPath: string]: {
|
[schemaPath: string]:
|
||||||
limit?: unknown
|
| {
|
||||||
sort?: string
|
limit?: unknown
|
||||||
where?: unknown
|
sort?: string
|
||||||
}
|
where?: unknown
|
||||||
|
}
|
||||||
|
| false
|
||||||
}
|
}
|
||||||
| false = {},
|
| false = {},
|
||||||
): JoinQuery => {
|
): JoinQuery => {
|
||||||
const joinQuery = {}
|
const joinQuery = {}
|
||||||
|
|
||||||
Object.keys(joins).forEach((schemaPath) => {
|
Object.keys(joins).forEach((schemaPath) => {
|
||||||
joinQuery[schemaPath] = {
|
if (joins[schemaPath] === 'false' || joins[schemaPath] === false) {
|
||||||
limit: isNumber(joins[schemaPath]?.limit) ? Number(joins[schemaPath].limit) : undefined,
|
joinQuery[schemaPath] = false
|
||||||
sort: joins[schemaPath]?.sort ? joins[schemaPath].sort : undefined,
|
} else {
|
||||||
where: joins[schemaPath]?.where ? joins[schemaPath].where : undefined,
|
joinQuery[schemaPath] = {
|
||||||
|
limit: isNumber(joins[schemaPath]?.limit) ? Number(joins[schemaPath].limit) : undefined,
|
||||||
|
sort: joins[schemaPath]?.sort ? joins[schemaPath].sort : undefined,
|
||||||
|
where: joins[schemaPath]?.where ? joins[schemaPath].where : undefined,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
import executeAccess from '../../auth/executeAccess.js'
|
import executeAccess from '../../auth/executeAccess.js'
|
||||||
import { combineQueries } from '../../database/combineQueries.js'
|
import { combineQueries } from '../../database/combineQueries.js'
|
||||||
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js'
|
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js'
|
||||||
|
import { sanitizeJoinQuery } from '../../database/sanitizeJoinQuery.js'
|
||||||
import { afterRead } from '../../fields/hooks/afterRead/index.js'
|
import { afterRead } from '../../fields/hooks/afterRead/index.js'
|
||||||
import { killTransaction } from '../../utilities/killTransaction.js'
|
import { killTransaction } from '../../utilities/killTransaction.js'
|
||||||
import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js'
|
import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js'
|
||||||
@@ -129,6 +130,13 @@ export const findOperation = async <
|
|||||||
|
|
||||||
let fullWhere = combineQueries(where, accessResult)
|
let fullWhere = combineQueries(where, accessResult)
|
||||||
|
|
||||||
|
const sanitizedJoins = await sanitizeJoinQuery({
|
||||||
|
collectionConfig,
|
||||||
|
joins,
|
||||||
|
overrideAccess,
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
|
||||||
if (collectionConfig.versions?.drafts && draftsEnabled) {
|
if (collectionConfig.versions?.drafts && draftsEnabled) {
|
||||||
fullWhere = appendVersionToQueryKey(fullWhere)
|
fullWhere = appendVersionToQueryKey(fullWhere)
|
||||||
|
|
||||||
@@ -142,7 +150,7 @@ export const findOperation = async <
|
|||||||
|
|
||||||
result = await payload.db.queryDrafts<DataFromCollectionSlug<TSlug>>({
|
result = await payload.db.queryDrafts<DataFromCollectionSlug<TSlug>>({
|
||||||
collection: collectionConfig.slug,
|
collection: collectionConfig.slug,
|
||||||
joins: req.payloadAPI === 'GraphQL' ? false : joins,
|
joins: req.payloadAPI === 'GraphQL' ? false : sanitizedJoins,
|
||||||
limit: sanitizedLimit,
|
limit: sanitizedLimit,
|
||||||
locale,
|
locale,
|
||||||
page: sanitizedPage,
|
page: sanitizedPage,
|
||||||
@@ -162,7 +170,7 @@ export const findOperation = async <
|
|||||||
|
|
||||||
result = await payload.db.find<DataFromCollectionSlug<TSlug>>({
|
result = await payload.db.find<DataFromCollectionSlug<TSlug>>({
|
||||||
collection: collectionConfig.slug,
|
collection: collectionConfig.slug,
|
||||||
joins: req.payloadAPI === 'GraphQL' ? false : joins,
|
joins: req.payloadAPI === 'GraphQL' ? false : sanitizedJoins,
|
||||||
limit: sanitizedLimit,
|
limit: sanitizedLimit,
|
||||||
locale,
|
locale,
|
||||||
page: sanitizedPage,
|
page: sanitizedPage,
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import type {
|
|||||||
|
|
||||||
import executeAccess from '../../auth/executeAccess.js'
|
import executeAccess from '../../auth/executeAccess.js'
|
||||||
import { combineQueries } from '../../database/combineQueries.js'
|
import { combineQueries } from '../../database/combineQueries.js'
|
||||||
|
import { sanitizeJoinQuery } from '../../database/sanitizeJoinQuery.js'
|
||||||
import { NotFound } from '../../errors/index.js'
|
import { NotFound } from '../../errors/index.js'
|
||||||
import { afterRead } from '../../fields/hooks/afterRead/index.js'
|
import { afterRead } from '../../fields/hooks/afterRead/index.js'
|
||||||
|
import { validateQueryPaths } from '../../index.js'
|
||||||
import { killTransaction } from '../../utilities/killTransaction.js'
|
import { killTransaction } from '../../utilities/killTransaction.js'
|
||||||
import replaceWithDraftIfAvailable from '../../versions/drafts/replaceWithDraftIfAvailable.js'
|
import replaceWithDraftIfAvailable from '../../versions/drafts/replaceWithDraftIfAvailable.js'
|
||||||
import { buildAfterOperation } from './utils.js'
|
import { buildAfterOperation } from './utils.js'
|
||||||
@@ -91,17 +93,33 @@ export const findByIDOperation = async <
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const where = combineQueries({ id: { equals: id } }, accessResult)
|
||||||
|
|
||||||
|
const sanitizedJoins = await sanitizeJoinQuery({
|
||||||
|
collectionConfig,
|
||||||
|
joins,
|
||||||
|
overrideAccess,
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
|
||||||
const findOneArgs: FindOneArgs = {
|
const findOneArgs: FindOneArgs = {
|
||||||
collection: collectionConfig.slug,
|
collection: collectionConfig.slug,
|
||||||
joins: req.payloadAPI === 'GraphQL' ? false : joins,
|
joins: req.payloadAPI === 'GraphQL' ? false : sanitizedJoins,
|
||||||
locale,
|
locale,
|
||||||
req: {
|
req: {
|
||||||
transactionID: req.transactionID,
|
transactionID: req.transactionID,
|
||||||
} as PayloadRequest,
|
} as PayloadRequest,
|
||||||
select,
|
select,
|
||||||
where: combineQueries({ id: { equals: id } }, accessResult),
|
where,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await validateQueryPaths({
|
||||||
|
collectionConfig,
|
||||||
|
overrideAccess,
|
||||||
|
req,
|
||||||
|
where,
|
||||||
|
})
|
||||||
|
|
||||||
// /////////////////////////////////////
|
// /////////////////////////////////////
|
||||||
// Find by ID
|
// Find by ID
|
||||||
// /////////////////////////////////////
|
// /////////////////////////////////////
|
||||||
|
|||||||
92
packages/payload/src/database/sanitizeJoinQuery.ts
Normal file
92
packages/payload/src/database/sanitizeJoinQuery.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import type { SanitizedCollectionConfig } from '../collections/config/types.js'
|
||||||
|
import type { JoinQuery, PayloadRequest } from '../types/index.js'
|
||||||
|
|
||||||
|
import executeAccess from '../auth/executeAccess.js'
|
||||||
|
import { QueryError } from '../errors/QueryError.js'
|
||||||
|
import { combineQueries } from './combineQueries.js'
|
||||||
|
import { validateQueryPaths } from './queryValidation/validateQueryPaths.js'
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
collectionConfig: SanitizedCollectionConfig
|
||||||
|
joins?: JoinQuery
|
||||||
|
overrideAccess: boolean
|
||||||
|
req: PayloadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* * Validates `where` for each join
|
||||||
|
* * Combines the access result for joined collection
|
||||||
|
* * Combines the default join's `where`
|
||||||
|
*/
|
||||||
|
export const sanitizeJoinQuery = async ({
|
||||||
|
collectionConfig,
|
||||||
|
joins: joinsQuery,
|
||||||
|
overrideAccess,
|
||||||
|
req,
|
||||||
|
}: Args) => {
|
||||||
|
if (joinsQuery === false) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!joinsQuery) {
|
||||||
|
joinsQuery = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: { path: string }[] = []
|
||||||
|
const promises: Promise<void>[] = []
|
||||||
|
|
||||||
|
for (const collectionSlug in collectionConfig.joins) {
|
||||||
|
for (const { field, schemaPath } of collectionConfig.joins[collectionSlug]) {
|
||||||
|
if (joinsQuery[schemaPath] === false) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinCollectionConfig = req.payload.collections[collectionSlug].config
|
||||||
|
|
||||||
|
const accessResult = !overrideAccess
|
||||||
|
? await executeAccess({ disableErrors: true, req }, joinCollectionConfig.access.read)
|
||||||
|
: true
|
||||||
|
|
||||||
|
if (accessResult === false) {
|
||||||
|
joinsQuery[schemaPath] = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!joinsQuery[schemaPath]) {
|
||||||
|
joinsQuery[schemaPath] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinQuery = joinsQuery[schemaPath]
|
||||||
|
|
||||||
|
if (!joinQuery.where) {
|
||||||
|
joinQuery.where = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.where) {
|
||||||
|
joinQuery.where = combineQueries(joinQuery.where, field.where)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof accessResult === 'object') {
|
||||||
|
joinQuery.where = combineQueries(joinQuery.where, accessResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
promises.push(
|
||||||
|
validateQueryPaths({
|
||||||
|
collectionConfig: joinCollectionConfig,
|
||||||
|
errors,
|
||||||
|
overrideAccess,
|
||||||
|
req,
|
||||||
|
where: joinQuery.where,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises)
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new QueryError(errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinsQuery
|
||||||
|
}
|
||||||
@@ -1477,6 +1477,7 @@ export type JoinField = {
|
|||||||
on: string
|
on: string
|
||||||
type: 'join'
|
type: 'join'
|
||||||
validate?: never
|
validate?: never
|
||||||
|
where?: Where
|
||||||
} & FieldBase
|
} & FieldBase
|
||||||
|
|
||||||
export type JoinFieldClient = {
|
export type JoinFieldClient = {
|
||||||
@@ -1488,7 +1489,7 @@ export type JoinFieldClient = {
|
|||||||
} & AdminClient &
|
} & AdminClient &
|
||||||
Pick<JoinField['admin'], 'disableBulkEdit' | 'readOnly'>
|
Pick<JoinField['admin'], 'disableBulkEdit' | 'readOnly'>
|
||||||
} & FieldBaseClient &
|
} & FieldBaseClient &
|
||||||
Pick<JoinField, 'collection' | 'index' | 'maxDepth' | 'on' | 'type'>
|
Pick<JoinField, 'collection' | 'index' | 'maxDepth' | 'on' | 'type' | 'where'>
|
||||||
|
|
||||||
export type Field =
|
export type Field =
|
||||||
| ArrayField
|
| ArrayField
|
||||||
|
|||||||
@@ -124,11 +124,13 @@ export type Sort = Array<string> | string
|
|||||||
*/
|
*/
|
||||||
export type JoinQuery =
|
export type JoinQuery =
|
||||||
| {
|
| {
|
||||||
[schemaPath: string]: {
|
[schemaPath: string]:
|
||||||
limit?: number
|
| {
|
||||||
sort?: string
|
limit?: number
|
||||||
where?: Where
|
sort?: string
|
||||||
}
|
where?: Where
|
||||||
|
}
|
||||||
|
| false
|
||||||
}
|
}
|
||||||
| false
|
| false
|
||||||
|
|
||||||
|
|||||||
@@ -36,14 +36,19 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
|
|||||||
path: pathFromContext ?? pathFromProps ?? name,
|
path: pathFromContext ?? pathFromProps ?? name,
|
||||||
})
|
})
|
||||||
|
|
||||||
const filterOptions: Where = useMemo(
|
const filterOptions: Where = useMemo(() => {
|
||||||
() => ({
|
const where = {
|
||||||
[on]: {
|
[on]: {
|
||||||
in: [docID || null],
|
in: [docID || null],
|
||||||
},
|
},
|
||||||
}),
|
}
|
||||||
[docID, on],
|
if (field.where) {
|
||||||
)
|
return {
|
||||||
|
and: [where, field.where],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return where
|
||||||
|
}, [docID, on, field.where])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={[fieldBaseClass, 'join'].filter(Boolean).join(' ')}>
|
<div className={[fieldBaseClass, 'join'].filter(Boolean).join(' ')}>
|
||||||
|
|||||||
@@ -341,10 +341,6 @@ export const createClientField = ({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// case 'joins': {
|
|
||||||
//
|
|
||||||
// }
|
|
||||||
|
|
||||||
case 'select':
|
case 'select':
|
||||||
case 'radio': {
|
case 'radio': {
|
||||||
const field = clientField as RadioFieldClient | SelectFieldClient
|
const field = clientField as RadioFieldClient | SelectFieldClient
|
||||||
|
|||||||
@@ -41,8 +41,12 @@ const openAccess = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PublicReadabilityAccess: FieldAccess = ({ req: { user }, siblingData }) => {
|
const PublicReadabilityAccess: FieldAccess = ({ req: { user }, siblingData }) => {
|
||||||
if (user) return true
|
if (user) {
|
||||||
if (siblingData?.allowPublicReadability) return true
|
return true
|
||||||
|
}
|
||||||
|
if (siblingData?.allowPublicReadability) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -187,6 +191,23 @@ export default buildConfigWithDefaults({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: 'relation-restricted',
|
||||||
|
access: {
|
||||||
|
read: () => true,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'post',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: slug,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
slug: fullyRestrictedSlug,
|
slug: fullyRestrictedSlug,
|
||||||
access: {
|
access: {
|
||||||
@@ -261,7 +282,9 @@ export default buildConfigWithDefaults({
|
|||||||
slug: restrictedVersionsSlug,
|
slug: restrictedVersionsSlug,
|
||||||
access: {
|
access: {
|
||||||
read: ({ req: { user } }) => {
|
read: ({ req: { user } }) => {
|
||||||
if (user) return true
|
if (user) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hidden: {
|
hidden: {
|
||||||
@@ -270,7 +293,9 @@ export default buildConfigWithDefaults({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
readVersions: ({ req: { user } }) => {
|
readVersions: ({ req: { user } }) => {
|
||||||
if (user) return true
|
if (user) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'version.hidden': {
|
'version.hidden': {
|
||||||
@@ -428,7 +453,9 @@ export default buildConfigWithDefaults({
|
|||||||
slug: hiddenAccessSlug,
|
slug: hiddenAccessSlug,
|
||||||
access: {
|
access: {
|
||||||
read: ({ req: { user } }) => {
|
read: ({ req: { user } }) => {
|
||||||
if (user) return true
|
if (user) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hidden: {
|
hidden: {
|
||||||
@@ -454,7 +481,9 @@ export default buildConfigWithDefaults({
|
|||||||
slug: hiddenAccessCountSlug,
|
slug: hiddenAccessCountSlug,
|
||||||
access: {
|
access: {
|
||||||
read: ({ req: { user } }) => {
|
read: ({ req: { user } }) => {
|
||||||
if (user) return true
|
if (user) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hidden: {
|
hidden: {
|
||||||
|
|||||||
@@ -175,6 +175,47 @@ describe('Access Control', () => {
|
|||||||
expect(retrievedDoc.restrictedField).toBeUndefined()
|
expect(retrievedDoc.restrictedField).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should error when querying field without read access', async () => {
|
||||||
|
const { id } = await createDoc({ restrictedField: 'restricted' })
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
async () =>
|
||||||
|
await payload.find({
|
||||||
|
collection: slug,
|
||||||
|
overrideAccess: false,
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{
|
||||||
|
id: { equals: id },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
restrictedField: {
|
||||||
|
equals: 'restricted',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('The following path cannot be queried: restrictedField')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respect access control for join request where queries of relationship properties', async () => {
|
||||||
|
const post = await createDoc({})
|
||||||
|
await createDoc({ post: post.id, name: 'test' }, 'relation-restricted')
|
||||||
|
await expect(
|
||||||
|
async () =>
|
||||||
|
await payload.find({
|
||||||
|
collection: 'relation-restricted',
|
||||||
|
overrideAccess: false,
|
||||||
|
where: {
|
||||||
|
'post.restrictedField': {
|
||||||
|
equals: 'restricted',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('The following path cannot be queried: restrictedField')
|
||||||
|
})
|
||||||
|
|
||||||
it('field without read access should not show when overrideAccess: true', async () => {
|
it('field without read access should not show when overrideAccess: true', async () => {
|
||||||
const { id, restrictedField } = await createDoc({ restrictedField: 'restricted' })
|
const { id, restrictedField } = await createDoc({ restrictedField: 'restricted' })
|
||||||
|
|
||||||
|
|||||||
@@ -90,5 +90,14 @@ export const Categories: CollectionConfig = {
|
|||||||
collection: singularSlug,
|
collection: singularSlug,
|
||||||
on: 'category',
|
on: 'category',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'filtered',
|
||||||
|
type: 'join',
|
||||||
|
collection: postsSlug,
|
||||||
|
on: 'category',
|
||||||
|
where: {
|
||||||
|
isFiltered: { not_equals: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,23 @@ export const Posts: CollectionConfig = {
|
|||||||
name: 'title',
|
name: 'title',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'isFiltered',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: false,
|
||||||
|
admin: {
|
||||||
|
position: 'sidebar',
|
||||||
|
description: 'Hides posts for the `filtered` join field in categories',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'restrictedField',
|
||||||
|
type: 'text',
|
||||||
|
access: {
|
||||||
|
read: () => false,
|
||||||
|
update: () => false,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'upload',
|
name: 'upload',
|
||||||
type: 'upload',
|
type: 'upload',
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import { Singular } from './collections/Singular.js'
|
|||||||
import { Uploads } from './collections/Uploads.js'
|
import { Uploads } from './collections/Uploads.js'
|
||||||
import { Versions } from './collections/Versions.js'
|
import { Versions } from './collections/Versions.js'
|
||||||
import { seed } from './seed.js'
|
import { seed } from './seed.js'
|
||||||
import { localizedCategoriesSlug, localizedPostsSlug } from './shared.js'
|
import {
|
||||||
|
localizedCategoriesSlug,
|
||||||
|
localizedPostsSlug,
|
||||||
|
postsSlug,
|
||||||
|
restrictedCategoriesSlug,
|
||||||
|
restrictedPostsSlug,
|
||||||
|
} from './shared.js'
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
@@ -60,6 +66,53 @@ export default buildConfigWithDefaults({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: restrictedCategoriesSlug,
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'name',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// this field is misconfigured to have `where` constraint using a restricted field
|
||||||
|
name: 'restrictedPosts',
|
||||||
|
type: 'join',
|
||||||
|
collection: postsSlug,
|
||||||
|
on: 'category',
|
||||||
|
where: {
|
||||||
|
restrictedField: { equals: 'restricted' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: restrictedPostsSlug,
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'title',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'restrictedField',
|
||||||
|
type: 'text',
|
||||||
|
access: {
|
||||||
|
read: () => false,
|
||||||
|
update: () => false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'category',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: restrictedCategoriesSlug,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
localization: {
|
localization: {
|
||||||
locales: ['en', 'es'],
|
locales: ['en', 'es'],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Payload, TypeWithID } from 'payload'
|
import type { Payload } from 'payload'
|
||||||
|
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { getFileByPath } from 'payload'
|
import { getFileByPath } from 'payload'
|
||||||
@@ -10,7 +10,13 @@ import type { Category, Config, Post, Singular } from './payload-types.js'
|
|||||||
import { devUser } from '../credentials.js'
|
import { devUser } from '../credentials.js'
|
||||||
import { idToString } from '../helpers/idToString.js'
|
import { idToString } from '../helpers/idToString.js'
|
||||||
import { initPayloadInt } from '../helpers/initPayloadInt.js'
|
import { initPayloadInt } from '../helpers/initPayloadInt.js'
|
||||||
import { categoriesSlug, uploadsSlug } from './shared.js'
|
import {
|
||||||
|
categoriesSlug,
|
||||||
|
postsSlug,
|
||||||
|
restrictedCategoriesSlug,
|
||||||
|
restrictedPostsSlug,
|
||||||
|
uploadsSlug,
|
||||||
|
} from './shared.js'
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
@@ -25,6 +31,7 @@ describe('Joins Field', () => {
|
|||||||
let category: Category
|
let category: Category
|
||||||
let otherCategory: Category
|
let otherCategory: Category
|
||||||
let categoryID
|
let categoryID
|
||||||
|
let user
|
||||||
// --__--__--__--__--__--__--__--__--__
|
// --__--__--__--__--__--__--__--__--__
|
||||||
// Boilerplate test setup/teardown
|
// Boilerplate test setup/teardown
|
||||||
// --__--__--__--__--__--__--__--__--__
|
// --__--__--__--__--__--__--__--__--__
|
||||||
@@ -41,6 +48,7 @@ describe('Joins Field', () => {
|
|||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
|
|
||||||
token = data.token
|
token = data.token
|
||||||
|
user = data.user
|
||||||
|
|
||||||
category = await payload.create({
|
category = await payload.create({
|
||||||
collection: categoriesSlug,
|
collection: categoriesSlug,
|
||||||
@@ -103,7 +111,7 @@ describe('Joins Field', () => {
|
|||||||
sort: '-title',
|
sort: '-title',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
collection: 'categories',
|
collection: categoriesSlug,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(categoryWithPosts.group.relatedPosts.docs).toHaveLength(10)
|
expect(categoryWithPosts.group.relatedPosts.docs).toHaveLength(10)
|
||||||
@@ -121,7 +129,7 @@ describe('Joins Field', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: {},
|
select: {},
|
||||||
collection: 'categories',
|
collection: categoriesSlug,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(Object.keys(categoryWithPosts)).toStrictEqual(['id'])
|
expect(Object.keys(categoryWithPosts)).toStrictEqual(['id'])
|
||||||
@@ -140,7 +148,7 @@ describe('Joins Field', () => {
|
|||||||
relatedPosts: true,
|
relatedPosts: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
collection: 'categories',
|
collection: categoriesSlug,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(Object.keys(categoryWithPosts)).toStrictEqual(['id', 'group'])
|
expect(Object.keys(categoryWithPosts)).toStrictEqual(['id', 'group'])
|
||||||
@@ -154,7 +162,7 @@ describe('Joins Field', () => {
|
|||||||
it('should populate relationships in joins', async () => {
|
it('should populate relationships in joins', async () => {
|
||||||
const { docs } = await payload.find({
|
const { docs } = await payload.find({
|
||||||
limit: 1,
|
limit: 1,
|
||||||
collection: 'posts',
|
collection: postsSlug,
|
||||||
depth: 2,
|
depth: 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -166,7 +174,7 @@ describe('Joins Field', () => {
|
|||||||
it('should populate relationships in joins with camelCase names', async () => {
|
it('should populate relationships in joins with camelCase names', async () => {
|
||||||
const { docs } = await payload.find({
|
const { docs } = await payload.find({
|
||||||
limit: 1,
|
limit: 1,
|
||||||
collection: 'posts',
|
collection: postsSlug,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(docs[0].group.camelCaseCategory.id).toBeDefined()
|
expect(docs[0].group.camelCaseCategory.id).toBeDefined()
|
||||||
@@ -177,7 +185,7 @@ describe('Joins Field', () => {
|
|||||||
it('should populate uploads in joins', async () => {
|
it('should populate uploads in joins', async () => {
|
||||||
const { docs } = await payload.find({
|
const { docs } = await payload.find({
|
||||||
limit: 1,
|
limit: 1,
|
||||||
collection: 'posts',
|
collection: postsSlug,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(docs[0].upload.id).toBeDefined()
|
expect(docs[0].upload.id).toBeDefined()
|
||||||
@@ -197,7 +205,7 @@ describe('Joins Field', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
collection: 'categories',
|
collection: categoriesSlug,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(categoryWithPosts.relatedPosts.docs).toHaveLength(1)
|
expect(categoryWithPosts.relatedPosts.docs).toHaveLength(1)
|
||||||
@@ -206,7 +214,7 @@ describe('Joins Field', () => {
|
|||||||
|
|
||||||
it('should populate joins using find', async () => {
|
it('should populate joins using find', async () => {
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'categories',
|
collection: categoriesSlug,
|
||||||
where: {
|
where: {
|
||||||
id: { equals: category.id },
|
id: { equals: category.id },
|
||||||
},
|
},
|
||||||
@@ -221,13 +229,13 @@ describe('Joins Field', () => {
|
|||||||
|
|
||||||
it('should populate joins using find with hasMany relationships', async () => {
|
it('should populate joins using find with hasMany relationships', async () => {
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'categories',
|
collection: categoriesSlug,
|
||||||
where: {
|
where: {
|
||||||
id: { equals: category.id },
|
id: { equals: category.id },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const otherResult = await payload.find({
|
const otherResult = await payload.find({
|
||||||
collection: 'categories',
|
collection: categoriesSlug,
|
||||||
where: {
|
where: {
|
||||||
id: { equals: otherCategory.id },
|
id: { equals: otherCategory.id },
|
||||||
},
|
},
|
||||||
@@ -270,13 +278,13 @@ describe('Joins Field', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const resultEn = await payload.find({
|
const resultEn = await payload.find({
|
||||||
collection: 'categories',
|
collection: categoriesSlug,
|
||||||
where: {
|
where: {
|
||||||
id: { equals: category.id },
|
id: { equals: category.id },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const otherResultEn = await payload.find({
|
const otherResultEn = await payload.find({
|
||||||
collection: 'categories',
|
collection: categoriesSlug,
|
||||||
where: {
|
where: {
|
||||||
id: { equals: otherCategory.id },
|
id: { equals: otherCategory.id },
|
||||||
},
|
},
|
||||||
@@ -293,14 +301,14 @@ describe('Joins Field', () => {
|
|||||||
expect(otherCategoryWithPostsEn.hasManyPostsLocalized.docs[0].title).toBe('test 14')
|
expect(otherCategoryWithPostsEn.hasManyPostsLocalized.docs[0].title).toBe('test 14')
|
||||||
|
|
||||||
const resultEs = await payload.find({
|
const resultEs = await payload.find({
|
||||||
collection: 'categories',
|
collection: categoriesSlug,
|
||||||
locale: 'es',
|
locale: 'es',
|
||||||
where: {
|
where: {
|
||||||
id: { equals: category.id },
|
id: { equals: category.id },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const otherResultEs = await payload.find({
|
const otherResultEs = await payload.find({
|
||||||
collection: 'categories',
|
collection: categoriesSlug,
|
||||||
locale: 'es',
|
locale: 'es',
|
||||||
where: {
|
where: {
|
||||||
id: { equals: otherCategory.id },
|
id: { equals: otherCategory.id },
|
||||||
@@ -318,7 +326,7 @@ describe('Joins Field', () => {
|
|||||||
|
|
||||||
// clean up
|
// clean up
|
||||||
await payload.delete({
|
await payload.delete({
|
||||||
collection: 'posts',
|
collection: postsSlug,
|
||||||
where: {
|
where: {
|
||||||
id: {
|
id: {
|
||||||
in: [post_1.id, post_2.id],
|
in: [post_1.id, post_2.id],
|
||||||
@@ -329,18 +337,18 @@ describe('Joins Field', () => {
|
|||||||
|
|
||||||
it('should not error when deleting documents with joins', async () => {
|
it('should not error when deleting documents with joins', async () => {
|
||||||
const category = await payload.create({
|
const category = await payload.create({
|
||||||
collection: 'categories',
|
collection: categoriesSlug,
|
||||||
data: {
|
data: {
|
||||||
name: 'category with post',
|
name: 'category with post',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const post = await createPost({
|
await createPost({
|
||||||
category: category.id,
|
category: category.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await payload.delete({
|
const result = await payload.delete({
|
||||||
collection: 'categories',
|
collection: categoriesSlug,
|
||||||
// id: category.id,
|
// id: category.id,
|
||||||
where: {
|
where: {
|
||||||
id: { equals: category.id },
|
id: { equals: category.id },
|
||||||
@@ -350,6 +358,55 @@ describe('Joins Field', () => {
|
|||||||
expect(result.docs[0].id).toStrictEqual(category.id)
|
expect(result.docs[0].id).toStrictEqual(category.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('`where` filters', () => {
|
||||||
|
let categoryWithFilteredPost
|
||||||
|
beforeAll(async () => {
|
||||||
|
categoryWithFilteredPost = await payload.create({
|
||||||
|
collection: categoriesSlug,
|
||||||
|
data: {
|
||||||
|
name: 'category with filtered post',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await createPost({
|
||||||
|
title: 'filtered post',
|
||||||
|
category: categoryWithFilteredPost.id,
|
||||||
|
isFiltered: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await createPost({
|
||||||
|
title: 'unfiltered post',
|
||||||
|
category: categoryWithFilteredPost.id,
|
||||||
|
isFiltered: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
categoryWithFilteredPost = await payload.findByID({
|
||||||
|
id: categoryWithFilteredPost.id,
|
||||||
|
collection: categoriesSlug,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter joins using where from field config', () => {
|
||||||
|
expect(categoryWithFilteredPost.filtered.docs).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter joins using where from field config and the requested filter', async () => {
|
||||||
|
categoryWithFilteredPost = await payload.findByID({
|
||||||
|
id: categoryWithFilteredPost.id,
|
||||||
|
collection: categoriesSlug,
|
||||||
|
joins: {
|
||||||
|
filtered: {
|
||||||
|
where: {
|
||||||
|
title: { not_equals: 'unfiltered post' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(categoryWithFilteredPost.filtered.docs).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Joins with localization', () => {
|
describe('Joins with localization', () => {
|
||||||
let localizedCategory: Category
|
let localizedCategory: Category
|
||||||
|
|
||||||
@@ -468,6 +525,48 @@ describe('Joins Field', () => {
|
|||||||
expect(unlimited.docs[0].relatedPosts.hasNextPage).toStrictEqual(false)
|
expect(unlimited.docs[0].relatedPosts.hasNextPage).toStrictEqual(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should respect access control for join request `where` queries', async () => {
|
||||||
|
await expect(async () => {
|
||||||
|
await payload.findByID({
|
||||||
|
id: category.id,
|
||||||
|
collection: categoriesSlug,
|
||||||
|
overrideAccess: false,
|
||||||
|
user,
|
||||||
|
joins: {
|
||||||
|
relatedPosts: {
|
||||||
|
where: {
|
||||||
|
restrictedField: { equals: 'restricted' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}).rejects.toThrow('The following path cannot be queried: restrictedField')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respect access control of join field configured `where` queries', async () => {
|
||||||
|
const restrictedCategory = await payload.create({
|
||||||
|
collection: restrictedCategoriesSlug,
|
||||||
|
data: {
|
||||||
|
name: 'restricted category',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const post = await createPost({
|
||||||
|
collection: restrictedPostsSlug,
|
||||||
|
data: {
|
||||||
|
title: 'restricted post',
|
||||||
|
category: restrictedCategory.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await expect(async () => {
|
||||||
|
await payload.findByID({
|
||||||
|
id: category.id,
|
||||||
|
collection: restrictedCategoriesSlug,
|
||||||
|
overrideAccess: false,
|
||||||
|
user,
|
||||||
|
})
|
||||||
|
}).rejects.toThrow('The following path cannot be queried: restrictedField')
|
||||||
|
})
|
||||||
|
|
||||||
it('should sort joins', async () => {
|
it('should sort joins', async () => {
|
||||||
const response = await restClient
|
const response = await restClient
|
||||||
.GET(`/categories/${category.id}?joins[relatedPosts][sort]=-title`)
|
.GET(`/categories/${category.id}?joins[relatedPosts][sort]=-title`)
|
||||||
@@ -651,7 +750,7 @@ describe('Joins Field', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should work id.in command delimited querying with joins', async () => {
|
it('should work id.in command delimited querying with joins', async () => {
|
||||||
const allCategories = await payload.find({ collection: 'categories', pagination: false })
|
const allCategories = await payload.find({ collection: categoriesSlug, pagination: false })
|
||||||
|
|
||||||
const allCategoriesByIds = await restClient
|
const allCategoriesByIds = await restClient
|
||||||
.GET(`/categories`, {
|
.GET(`/categories`, {
|
||||||
@@ -671,22 +770,72 @@ describe('Joins Field', () => {
|
|||||||
it('should join with singular collection name', async () => {
|
it('should join with singular collection name', async () => {
|
||||||
const {
|
const {
|
||||||
docs: [category],
|
docs: [category],
|
||||||
} = await payload.find({ collection: 'categories', limit: 1, depth: 0 })
|
} = await payload.find({ collection: categoriesSlug, limit: 1, depth: 0 })
|
||||||
|
|
||||||
const singular = await payload.create({
|
const singular = await payload.create({
|
||||||
collection: 'singular',
|
collection: 'singular',
|
||||||
data: { category: category.id },
|
data: { category: category.id },
|
||||||
})
|
})
|
||||||
|
|
||||||
const categoryWithJoins = await payload.findByID({ collection: 'categories', id: category.id })
|
const categoryWithJoins = await payload.findByID({
|
||||||
|
collection: categoriesSlug,
|
||||||
|
id: category.id,
|
||||||
|
})
|
||||||
|
|
||||||
expect((categoryWithJoins.singulars.docs[0] as Singular).id).toBe(singular.id)
|
expect((categoryWithJoins.singulars.docs[0] as Singular).id).toBe(singular.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('local API should not populate individual join by providing schemaPath=false', async () => {
|
||||||
|
const {
|
||||||
|
docs: [res],
|
||||||
|
} = await payload.find({
|
||||||
|
collection: categoriesSlug,
|
||||||
|
where: {
|
||||||
|
id: { equals: category.id },
|
||||||
|
},
|
||||||
|
joins: {
|
||||||
|
relatedPosts: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// removed from the result
|
||||||
|
expect(res.relatedPosts).toBeUndefined()
|
||||||
|
|
||||||
|
expect(res.hasManyPosts.docs).toBeDefined()
|
||||||
|
expect(res.hasManyPostsLocalized.docs).toBeDefined()
|
||||||
|
expect(res.group.relatedPosts.docs).toBeDefined()
|
||||||
|
expect(res.group.camelCasePosts.docs).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rEST API should not populate individual join by providing schemaPath=false', async () => {
|
||||||
|
const {
|
||||||
|
docs: [res],
|
||||||
|
} = await restClient
|
||||||
|
.GET(`/${categoriesSlug}`, {
|
||||||
|
query: {
|
||||||
|
where: {
|
||||||
|
id: { equals: category.id },
|
||||||
|
},
|
||||||
|
joins: {
|
||||||
|
relatedPosts: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
|
||||||
|
// removed from the result
|
||||||
|
expect(res.relatedPosts).toBeUndefined()
|
||||||
|
|
||||||
|
expect(res.hasManyPosts.docs).toBeDefined()
|
||||||
|
expect(res.hasManyPostsLocalized.docs).toBeDefined()
|
||||||
|
expect(res.group.relatedPosts.docs).toBeDefined()
|
||||||
|
expect(res.group.camelCasePosts.docs).toBeDefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function createPost(overrides?: Partial<Post>, locale?: Config['locale']) {
|
async function createPost(overrides?: Partial<Post>, locale?: Config['locale']) {
|
||||||
return payload.create({
|
return payload.create({
|
||||||
collection: 'posts',
|
collection: postsSlug,
|
||||||
locale,
|
locale,
|
||||||
data: {
|
data: {
|
||||||
title: 'test',
|
title: 'test',
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface Config {
|
|||||||
singular: Singular;
|
singular: Singular;
|
||||||
'localized-posts': LocalizedPost;
|
'localized-posts': LocalizedPost;
|
||||||
'localized-categories': LocalizedCategory;
|
'localized-categories': LocalizedCategory;
|
||||||
|
'restricted-categories': RestrictedCategory;
|
||||||
|
'restricted-posts': RestrictedPost;
|
||||||
users: User;
|
users: User;
|
||||||
'payload-locked-documents': PayloadLockedDocument;
|
'payload-locked-documents': PayloadLockedDocument;
|
||||||
'payload-preferences': PayloadPreference;
|
'payload-preferences': PayloadPreference;
|
||||||
@@ -30,8 +32,11 @@ export interface Config {
|
|||||||
uploads: UploadsSelect<false> | UploadsSelect<true>;
|
uploads: UploadsSelect<false> | UploadsSelect<true>;
|
||||||
versions: VersionsSelect<false> | VersionsSelect<true>;
|
versions: VersionsSelect<false> | VersionsSelect<true>;
|
||||||
'categories-versions': CategoriesVersionsSelect<false> | CategoriesVersionsSelect<true>;
|
'categories-versions': CategoriesVersionsSelect<false> | CategoriesVersionsSelect<true>;
|
||||||
|
singular: SingularSelect<false> | SingularSelect<true>;
|
||||||
'localized-posts': LocalizedPostsSelect<false> | LocalizedPostsSelect<true>;
|
'localized-posts': LocalizedPostsSelect<false> | LocalizedPostsSelect<true>;
|
||||||
'localized-categories': LocalizedCategoriesSelect<false> | LocalizedCategoriesSelect<true>;
|
'localized-categories': LocalizedCategoriesSelect<false> | LocalizedCategoriesSelect<true>;
|
||||||
|
'restricted-categories': RestrictedCategoriesSelect<false> | RestrictedCategoriesSelect<true>;
|
||||||
|
'restricted-posts': RestrictedPostsSelect<false> | RestrictedPostsSelect<true>;
|
||||||
users: UsersSelect<false> | UsersSelect<true>;
|
users: UsersSelect<false> | UsersSelect<true>;
|
||||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
@@ -46,6 +51,10 @@ export interface Config {
|
|||||||
user: User & {
|
user: User & {
|
||||||
collection: 'users';
|
collection: 'users';
|
||||||
};
|
};
|
||||||
|
jobs?: {
|
||||||
|
tasks: unknown;
|
||||||
|
workflows?: unknown;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export interface UserAuthOperations {
|
export interface UserAuthOperations {
|
||||||
forgotPassword: {
|
forgotPassword: {
|
||||||
@@ -72,6 +81,8 @@ export interface UserAuthOperations {
|
|||||||
export interface Post {
|
export interface Post {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
|
isFiltered?: boolean | null;
|
||||||
|
restrictedField?: string | null;
|
||||||
upload?: (string | null) | Upload;
|
upload?: (string | null) | Upload;
|
||||||
category?: (string | null) | Category;
|
category?: (string | null) | Category;
|
||||||
categories?: (string | Category)[] | null;
|
categories?: (string | Category)[] | null;
|
||||||
@@ -138,6 +149,10 @@ export interface Category {
|
|||||||
docs?: (string | Singular)[] | null;
|
docs?: (string | Singular)[] | null;
|
||||||
hasNextPage?: boolean | null;
|
hasNextPage?: boolean | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
filtered?: {
|
||||||
|
docs?: (string | Post)[] | null;
|
||||||
|
hasNextPage?: boolean | null;
|
||||||
|
} | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -202,6 +217,32 @@ export interface LocalizedCategory {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "restricted-categories".
|
||||||
|
*/
|
||||||
|
export interface RestrictedCategory {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
restrictedPosts?: {
|
||||||
|
docs?: (string | Post)[] | null;
|
||||||
|
hasNextPage?: boolean | null;
|
||||||
|
} | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "restricted-posts".
|
||||||
|
*/
|
||||||
|
export interface RestrictedPost {
|
||||||
|
id: string;
|
||||||
|
title?: string | null;
|
||||||
|
restrictedField?: string | null;
|
||||||
|
category?: (string | null) | RestrictedCategory;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "users".
|
* via the `definition` "users".
|
||||||
@@ -258,6 +299,14 @@ export interface PayloadLockedDocument {
|
|||||||
relationTo: 'localized-categories';
|
relationTo: 'localized-categories';
|
||||||
value: string | LocalizedCategory;
|
value: string | LocalizedCategory;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'restricted-categories';
|
||||||
|
value: string | RestrictedCategory;
|
||||||
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'restricted-posts';
|
||||||
|
value: string | RestrictedPost;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'users';
|
relationTo: 'users';
|
||||||
value: string | User;
|
value: string | User;
|
||||||
@@ -310,6 +359,8 @@ export interface PayloadMigration {
|
|||||||
*/
|
*/
|
||||||
export interface PostsSelect<T extends boolean = true> {
|
export interface PostsSelect<T extends boolean = true> {
|
||||||
title?: T;
|
title?: T;
|
||||||
|
isFiltered?: T;
|
||||||
|
restrictedField?: T;
|
||||||
upload?: T;
|
upload?: T;
|
||||||
category?: T;
|
category?: T;
|
||||||
categories?: T;
|
categories?: T;
|
||||||
@@ -338,6 +389,8 @@ export interface CategoriesSelect<T extends boolean = true> {
|
|||||||
relatedPosts?: T;
|
relatedPosts?: T;
|
||||||
camelCasePosts?: T;
|
camelCasePosts?: T;
|
||||||
};
|
};
|
||||||
|
singulars?: T;
|
||||||
|
filtered?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
@@ -380,6 +433,15 @@ export interface CategoriesVersionsSelect<T extends boolean = true> {
|
|||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
_status?: T;
|
_status?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "singular_select".
|
||||||
|
*/
|
||||||
|
export interface SingularSelect<T extends boolean = true> {
|
||||||
|
category?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "localized-posts_select".
|
* via the `definition` "localized-posts_select".
|
||||||
@@ -400,6 +462,27 @@ export interface LocalizedCategoriesSelect<T extends boolean = true> {
|
|||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "restricted-categories_select".
|
||||||
|
*/
|
||||||
|
export interface RestrictedCategoriesSelect<T extends boolean = true> {
|
||||||
|
name?: T;
|
||||||
|
restrictedPosts?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "restricted-posts_select".
|
||||||
|
*/
|
||||||
|
export interface RestrictedPostsSelect<T extends boolean = true> {
|
||||||
|
title?: T;
|
||||||
|
restrictedField?: T;
|
||||||
|
category?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "users_select".
|
* via the `definition` "users_select".
|
||||||
|
|||||||
@@ -8,9 +8,15 @@ export const localizedPostsSlug = 'localized-posts'
|
|||||||
|
|
||||||
export const localizedCategoriesSlug = 'localized-categories'
|
export const localizedCategoriesSlug = 'localized-categories'
|
||||||
|
|
||||||
|
export const restrictedPostsSlug = 'restricted-posts'
|
||||||
|
|
||||||
|
export const restrictedCategoriesSlug = 'restricted-categories'
|
||||||
|
|
||||||
export const collectionSlugs = [
|
export const collectionSlugs = [
|
||||||
categoriesSlug,
|
categoriesSlug,
|
||||||
postsSlug,
|
postsSlug,
|
||||||
localizedPostsSlug,
|
localizedPostsSlug,
|
||||||
localizedCategoriesSlug,
|
localizedCategoriesSlug,
|
||||||
|
restrictedPostsSlug,
|
||||||
|
restrictedCategoriesSlug,
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user