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:
Dan Ribbens
2024-11-06 10:06:25 -05:00
committed by GitHub
parent cdcefa88f2
commit 93a55d1075
19 changed files with 596 additions and 67 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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,
}
} }
}) })

View File

@@ -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,

View File

@@ -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
// ///////////////////////////////////// // /////////////////////////////////////

View 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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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(' ')}>

View File

@@ -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

View File

@@ -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: {

View File

@@ -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' })

View File

@@ -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 },
},
},
], ],
} }

View File

@@ -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',

View File

@@ -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'],

View File

@@ -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',

View File

@@ -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".

View File

@@ -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,
] ]