From 93a55d1075d180266f6488c3b759ed7ce74a751f Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Wed, 6 Nov 2024 10:06:25 -0500 Subject: [PATCH] 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> --- docs/fields/join.mdx | 15 +- .../src/utilities/buildJoinAggregation.ts | 6 + packages/drizzle/src/find/traverseFields.ts | 9 +- .../rest/utilities/sanitizeJoinParams.ts | 24 ++- .../src/collections/operations/find.ts | 12 +- .../src/collections/operations/findByID.ts | 22 +- .../payload/src/database/sanitizeJoinQuery.ts | 92 ++++++++ packages/payload/src/fields/config/types.ts | 3 +- packages/payload/src/types/index.ts | 12 +- packages/ui/src/fields/Join/index.tsx | 15 +- .../Config/createClientConfig/fields.tsx | 4 - test/access-control/config.ts | 41 +++- test/access-control/int.spec.ts | 41 ++++ test/joins/collections/Categories.ts | 9 + test/joins/collections/Posts.ts | 17 ++ test/joins/config.ts | 55 ++++- test/joins/int.spec.ts | 197 +++++++++++++++--- test/joins/payload-types.ts | 83 ++++++++ test/joins/shared.ts | 6 + 19 files changed, 596 insertions(+), 67 deletions(-) create mode 100644 packages/payload/src/database/sanitizeJoinQuery.ts diff --git a/docs/fields/join.mdx b/docs/fields/join.mdx index 7ad4d7107..21659b887 100644 --- a/docs/fields/join.mdx +++ b/docs/fields/join.mdx @@ -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) | | **`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'. | +| **`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). | | **`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). | | **`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. | -| **`defaultSort`** | The field name used to specify the order the joined documents are returned. | +| **`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. | | **`admin`** | Admin-specific configuration. [More details](#admin-config-options). | | **`custom`** | Extension point for adding custom data (e.g. for plugins). | | **`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: -| Property | Description | -|-------------|--------------------------------------------------------------| -| **`limit`** | The maximum related documents to be returned, default is 10. | -| **`where`** | An optional `Where` query to filter joined documents. | -| **`sort`** | A string used to order related results | +| Property | Description | +|-------------|-----------------------------------------------------------------------------------------------------| +| **`limit`** | The maximum related documents to be returned, default is 10. | +| **`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 | These can be applied to the local API, GraphQL, and REST API. diff --git a/packages/db-mongodb/src/utilities/buildJoinAggregation.ts b/packages/db-mongodb/src/utilities/buildJoinAggregation.ts index 99065d252..5cec7a529 100644 --- a/packages/db-mongodb/src/utilities/buildJoinAggregation.ts +++ b/packages/db-mongodb/src/utilities/buildJoinAggregation.ts @@ -1,6 +1,8 @@ import type { PipelineStage } from 'mongoose' import type { CollectionSlug, JoinQuery, SanitizedCollectionConfig, Where } from 'payload' +import { combineQueries } from 'payload' + import type { MongooseAdapter } from '../index.js' import { buildSortParam } from '../queries/buildSortParam.js' @@ -62,6 +64,10 @@ export const buildJoinAggregation = async ({ continue } + if (joins?.[join.schemaPath] === false) { + continue + } + const { limit: limitJoin = join.field.defaultLimit ?? 10, sort: sortJoin = join.field.defaultSort || collectionConfig.defaultSort, diff --git a/packages/drizzle/src/find/traverseFields.ts b/packages/drizzle/src/find/traverseFields.ts index 1b3ea8b47..9ea828d70 100644 --- a/packages/drizzle/src/find/traverseFields.ts +++ b/packages/drizzle/src/find/traverseFields.ts @@ -2,6 +2,7 @@ import type { LibSQLDatabase } from 'drizzle-orm/libsql' import type { Field, JoinQuery, SelectMode, SelectType, TabAsField } from 'payload' import { and, eq, sql } from 'drizzle-orm' +import { combineQueries } from 'payload' import { fieldAffectsData, fieldIsVirtual, tabHasName } from 'payload/shared' import toSnakeCase from 'to-snake-case' @@ -402,11 +403,17 @@ export const traverseFields = ({ break } + const joinSchemaPath = `${path.replaceAll('_', '.')}${field.name}` + + if (joinQuery[joinSchemaPath] === false) { + break + } + const { limit: limitArg = field.defaultLimit ?? 10, sort = field.defaultSort, where, - } = joinQuery[`${path.replaceAll('_', '.')}${field.name}`] || {} + } = joinQuery[joinSchemaPath] || {} let limit = limitArg if (limit !== 0) { diff --git a/packages/next/src/routes/rest/utilities/sanitizeJoinParams.ts b/packages/next/src/routes/rest/utilities/sanitizeJoinParams.ts index 148404bb3..cdbd843c3 100644 --- a/packages/next/src/routes/rest/utilities/sanitizeJoinParams.ts +++ b/packages/next/src/routes/rest/utilities/sanitizeJoinParams.ts @@ -9,21 +9,27 @@ import { isNumber } from 'payload/shared' export const sanitizeJoinParams = ( joins: | { - [schemaPath: string]: { - limit?: unknown - sort?: string - where?: unknown - } + [schemaPath: string]: + | { + limit?: unknown + sort?: string + where?: unknown + } + | false } | false = {}, ): JoinQuery => { const joinQuery = {} Object.keys(joins).forEach((schemaPath) => { - 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, + if (joins[schemaPath] === 'false' || joins[schemaPath] === false) { + joinQuery[schemaPath] = false + } else { + 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, + } } }) diff --git a/packages/payload/src/collections/operations/find.ts b/packages/payload/src/collections/operations/find.ts index dac6ffbcc..a0c3b684f 100644 --- a/packages/payload/src/collections/operations/find.ts +++ b/packages/payload/src/collections/operations/find.ts @@ -17,6 +17,7 @@ import type { import executeAccess from '../../auth/executeAccess.js' import { combineQueries } from '../../database/combineQueries.js' import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js' +import { sanitizeJoinQuery } from '../../database/sanitizeJoinQuery.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' import { killTransaction } from '../../utilities/killTransaction.js' import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js' @@ -129,6 +130,13 @@ export const findOperation = async < let fullWhere = combineQueries(where, accessResult) + const sanitizedJoins = await sanitizeJoinQuery({ + collectionConfig, + joins, + overrideAccess, + req, + }) + if (collectionConfig.versions?.drafts && draftsEnabled) { fullWhere = appendVersionToQueryKey(fullWhere) @@ -142,7 +150,7 @@ export const findOperation = async < result = await payload.db.queryDrafts>({ collection: collectionConfig.slug, - joins: req.payloadAPI === 'GraphQL' ? false : joins, + joins: req.payloadAPI === 'GraphQL' ? false : sanitizedJoins, limit: sanitizedLimit, locale, page: sanitizedPage, @@ -162,7 +170,7 @@ export const findOperation = async < result = await payload.db.find>({ collection: collectionConfig.slug, - joins: req.payloadAPI === 'GraphQL' ? false : joins, + joins: req.payloadAPI === 'GraphQL' ? false : sanitizedJoins, limit: sanitizedLimit, locale, page: sanitizedPage, diff --git a/packages/payload/src/collections/operations/findByID.ts b/packages/payload/src/collections/operations/findByID.ts index 408b24db7..d6a67ffb4 100644 --- a/packages/payload/src/collections/operations/findByID.ts +++ b/packages/payload/src/collections/operations/findByID.ts @@ -14,8 +14,10 @@ import type { import executeAccess from '../../auth/executeAccess.js' import { combineQueries } from '../../database/combineQueries.js' +import { sanitizeJoinQuery } from '../../database/sanitizeJoinQuery.js' import { NotFound } from '../../errors/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' +import { validateQueryPaths } from '../../index.js' import { killTransaction } from '../../utilities/killTransaction.js' import replaceWithDraftIfAvailable from '../../versions/drafts/replaceWithDraftIfAvailable.js' import { buildAfterOperation } from './utils.js' @@ -91,17 +93,33 @@ export const findByIDOperation = async < return null } + const where = combineQueries({ id: { equals: id } }, accessResult) + + const sanitizedJoins = await sanitizeJoinQuery({ + collectionConfig, + joins, + overrideAccess, + req, + }) + const findOneArgs: FindOneArgs = { collection: collectionConfig.slug, - joins: req.payloadAPI === 'GraphQL' ? false : joins, + joins: req.payloadAPI === 'GraphQL' ? false : sanitizedJoins, locale, req: { transactionID: req.transactionID, } as PayloadRequest, select, - where: combineQueries({ id: { equals: id } }, accessResult), + where, } + await validateQueryPaths({ + collectionConfig, + overrideAccess, + req, + where, + }) + // ///////////////////////////////////// // Find by ID // ///////////////////////////////////// diff --git a/packages/payload/src/database/sanitizeJoinQuery.ts b/packages/payload/src/database/sanitizeJoinQuery.ts new file mode 100644 index 000000000..e3f8725bf --- /dev/null +++ b/packages/payload/src/database/sanitizeJoinQuery.ts @@ -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[] = [] + + 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 +} diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 8e3f19b38..cf204a254 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -1477,6 +1477,7 @@ export type JoinField = { on: string type: 'join' validate?: never + where?: Where } & FieldBase export type JoinFieldClient = { @@ -1488,7 +1489,7 @@ export type JoinFieldClient = { } & AdminClient & Pick } & FieldBaseClient & - Pick + Pick export type Field = | ArrayField diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index 278c826f8..3a5badc90 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -124,11 +124,13 @@ export type Sort = Array | string */ export type JoinQuery = | { - [schemaPath: string]: { - limit?: number - sort?: string - where?: Where - } + [schemaPath: string]: + | { + limit?: number + sort?: string + where?: Where + } + | false } | false diff --git a/packages/ui/src/fields/Join/index.tsx b/packages/ui/src/fields/Join/index.tsx index 7765a10f0..7d6f1d048 100644 --- a/packages/ui/src/fields/Join/index.tsx +++ b/packages/ui/src/fields/Join/index.tsx @@ -36,14 +36,19 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => { path: pathFromContext ?? pathFromProps ?? name, }) - const filterOptions: Where = useMemo( - () => ({ + const filterOptions: Where = useMemo(() => { + const where = { [on]: { in: [docID || null], }, - }), - [docID, on], - ) + } + if (field.where) { + return { + and: [where, field.where], + } + } + return where + }, [docID, on, field.where]) return (
diff --git a/packages/ui/src/providers/Config/createClientConfig/fields.tsx b/packages/ui/src/providers/Config/createClientConfig/fields.tsx index e5cc281bb..0e03c163f 100644 --- a/packages/ui/src/providers/Config/createClientConfig/fields.tsx +++ b/packages/ui/src/providers/Config/createClientConfig/fields.tsx @@ -341,10 +341,6 @@ export const createClientField = ({ break } - // case 'joins': { - // - // } - case 'select': case 'radio': { const field = clientField as RadioFieldClient | SelectFieldClient diff --git a/test/access-control/config.ts b/test/access-control/config.ts index c9b1bdabd..b9e79116c 100644 --- a/test/access-control/config.ts +++ b/test/access-control/config.ts @@ -41,8 +41,12 @@ const openAccess = { } const PublicReadabilityAccess: FieldAccess = ({ req: { user }, siblingData }) => { - if (user) return true - if (siblingData?.allowPublicReadability) return true + if (user) { + return true + } + if (siblingData?.allowPublicReadability) { + return true + } 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, access: { @@ -261,7 +282,9 @@ export default buildConfigWithDefaults({ slug: restrictedVersionsSlug, access: { read: ({ req: { user } }) => { - if (user) return true + if (user) { + return true + } return { hidden: { @@ -270,7 +293,9 @@ export default buildConfigWithDefaults({ } }, readVersions: ({ req: { user } }) => { - if (user) return true + if (user) { + return true + } return { 'version.hidden': { @@ -428,7 +453,9 @@ export default buildConfigWithDefaults({ slug: hiddenAccessSlug, access: { read: ({ req: { user } }) => { - if (user) return true + if (user) { + return true + } return { hidden: { @@ -454,7 +481,9 @@ export default buildConfigWithDefaults({ slug: hiddenAccessCountSlug, access: { read: ({ req: { user } }) => { - if (user) return true + if (user) { + return true + } return { hidden: { diff --git a/test/access-control/int.spec.ts b/test/access-control/int.spec.ts index 0fb1140e0..0b11c43ec 100644 --- a/test/access-control/int.spec.ts +++ b/test/access-control/int.spec.ts @@ -175,6 +175,47 @@ describe('Access Control', () => { 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 () => { const { id, restrictedField } = await createDoc({ restrictedField: 'restricted' }) diff --git a/test/joins/collections/Categories.ts b/test/joins/collections/Categories.ts index 45ae14a07..f663cad22 100644 --- a/test/joins/collections/Categories.ts +++ b/test/joins/collections/Categories.ts @@ -90,5 +90,14 @@ export const Categories: CollectionConfig = { collection: singularSlug, on: 'category', }, + { + name: 'filtered', + type: 'join', + collection: postsSlug, + on: 'category', + where: { + isFiltered: { not_equals: true }, + }, + }, ], } diff --git a/test/joins/collections/Posts.ts b/test/joins/collections/Posts.ts index 2da610a06..4e9f068bb 100644 --- a/test/joins/collections/Posts.ts +++ b/test/joins/collections/Posts.ts @@ -13,6 +13,23 @@ export const Posts: CollectionConfig = { name: 'title', 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', type: 'upload', diff --git a/test/joins/config.ts b/test/joins/config.ts index 1c7c70c18..dc9a1af8d 100644 --- a/test/joins/config.ts +++ b/test/joins/config.ts @@ -9,7 +9,13 @@ import { Singular } from './collections/Singular.js' import { Uploads } from './collections/Uploads.js' import { Versions } from './collections/Versions.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 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: { locales: ['en', 'es'], diff --git a/test/joins/int.spec.ts b/test/joins/int.spec.ts index a89dac2c4..762b14a43 100644 --- a/test/joins/int.spec.ts +++ b/test/joins/int.spec.ts @@ -1,4 +1,4 @@ -import type { Payload, TypeWithID } from 'payload' +import type { Payload } from 'payload' import path from 'path' import { getFileByPath } from 'payload' @@ -10,7 +10,13 @@ import type { Category, Config, Post, Singular } from './payload-types.js' import { devUser } from '../credentials.js' import { idToString } from '../helpers/idToString.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 dirname = path.dirname(filename) @@ -25,6 +31,7 @@ describe('Joins Field', () => { let category: Category let otherCategory: Category let categoryID + let user // --__--__--__--__--__--__--__--__--__ // Boilerplate test setup/teardown // --__--__--__--__--__--__--__--__--__ @@ -41,6 +48,7 @@ describe('Joins Field', () => { .then((res) => res.json()) token = data.token + user = data.user category = await payload.create({ collection: categoriesSlug, @@ -103,7 +111,7 @@ describe('Joins Field', () => { sort: '-title', }, }, - collection: 'categories', + collection: categoriesSlug, }) expect(categoryWithPosts.group.relatedPosts.docs).toHaveLength(10) @@ -121,7 +129,7 @@ describe('Joins Field', () => { }, }, select: {}, - collection: 'categories', + collection: categoriesSlug, }) expect(Object.keys(categoryWithPosts)).toStrictEqual(['id']) @@ -140,7 +148,7 @@ describe('Joins Field', () => { relatedPosts: true, }, }, - collection: 'categories', + collection: categoriesSlug, }) expect(Object.keys(categoryWithPosts)).toStrictEqual(['id', 'group']) @@ -154,7 +162,7 @@ describe('Joins Field', () => { it('should populate relationships in joins', async () => { const { docs } = await payload.find({ limit: 1, - collection: 'posts', + collection: postsSlug, depth: 2, }) @@ -166,7 +174,7 @@ describe('Joins Field', () => { it('should populate relationships in joins with camelCase names', async () => { const { docs } = await payload.find({ limit: 1, - collection: 'posts', + collection: postsSlug, }) expect(docs[0].group.camelCaseCategory.id).toBeDefined() @@ -177,7 +185,7 @@ describe('Joins Field', () => { it('should populate uploads in joins', async () => { const { docs } = await payload.find({ limit: 1, - collection: 'posts', + collection: postsSlug, }) expect(docs[0].upload.id).toBeDefined() @@ -197,7 +205,7 @@ describe('Joins Field', () => { }, }, }, - collection: 'categories', + collection: categoriesSlug, }) expect(categoryWithPosts.relatedPosts.docs).toHaveLength(1) @@ -206,7 +214,7 @@ describe('Joins Field', () => { it('should populate joins using find', async () => { const result = await payload.find({ - collection: 'categories', + collection: categoriesSlug, where: { id: { equals: category.id }, }, @@ -221,13 +229,13 @@ describe('Joins Field', () => { it('should populate joins using find with hasMany relationships', async () => { const result = await payload.find({ - collection: 'categories', + collection: categoriesSlug, where: { id: { equals: category.id }, }, }) const otherResult = await payload.find({ - collection: 'categories', + collection: categoriesSlug, where: { id: { equals: otherCategory.id }, }, @@ -270,13 +278,13 @@ describe('Joins Field', () => { ) const resultEn = await payload.find({ - collection: 'categories', + collection: categoriesSlug, where: { id: { equals: category.id }, }, }) const otherResultEn = await payload.find({ - collection: 'categories', + collection: categoriesSlug, where: { id: { equals: otherCategory.id }, }, @@ -293,14 +301,14 @@ describe('Joins Field', () => { expect(otherCategoryWithPostsEn.hasManyPostsLocalized.docs[0].title).toBe('test 14') const resultEs = await payload.find({ - collection: 'categories', + collection: categoriesSlug, locale: 'es', where: { id: { equals: category.id }, }, }) const otherResultEs = await payload.find({ - collection: 'categories', + collection: categoriesSlug, locale: 'es', where: { id: { equals: otherCategory.id }, @@ -318,7 +326,7 @@ describe('Joins Field', () => { // clean up await payload.delete({ - collection: 'posts', + collection: postsSlug, where: { 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 () => { const category = await payload.create({ - collection: 'categories', + collection: categoriesSlug, data: { name: 'category with post', }, }) - const post = await createPost({ + await createPost({ category: category.id, }) const result = await payload.delete({ - collection: 'categories', + collection: categoriesSlug, // id: category.id, where: { id: { equals: category.id }, @@ -350,6 +358,55 @@ describe('Joins Field', () => { 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', () => { let localizedCategory: Category @@ -468,6 +525,48 @@ describe('Joins Field', () => { 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 () => { const response = await restClient .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 () => { - const allCategories = await payload.find({ collection: 'categories', pagination: false }) + const allCategories = await payload.find({ collection: categoriesSlug, pagination: false }) const allCategoriesByIds = await restClient .GET(`/categories`, { @@ -671,22 +770,72 @@ describe('Joins Field', () => { it('should join with singular collection name', async () => { const { 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({ collection: 'singular', 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) }) + + 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, locale?: Config['locale']) { return payload.create({ - collection: 'posts', + collection: postsSlug, locale, data: { title: 'test', diff --git a/test/joins/payload-types.ts b/test/joins/payload-types.ts index d05931e88..f9fb71726 100644 --- a/test/joins/payload-types.ts +++ b/test/joins/payload-types.ts @@ -19,6 +19,8 @@ export interface Config { singular: Singular; 'localized-posts': LocalizedPost; 'localized-categories': LocalizedCategory; + 'restricted-categories': RestrictedCategory; + 'restricted-posts': RestrictedPost; users: User; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; @@ -30,8 +32,11 @@ export interface Config { uploads: UploadsSelect | UploadsSelect; versions: VersionsSelect | VersionsSelect; 'categories-versions': CategoriesVersionsSelect | CategoriesVersionsSelect; + singular: SingularSelect | SingularSelect; 'localized-posts': LocalizedPostsSelect | LocalizedPostsSelect; 'localized-categories': LocalizedCategoriesSelect | LocalizedCategoriesSelect; + 'restricted-categories': RestrictedCategoriesSelect | RestrictedCategoriesSelect; + 'restricted-posts': RestrictedPostsSelect | RestrictedPostsSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; @@ -46,6 +51,10 @@ export interface Config { user: User & { collection: 'users'; }; + jobs?: { + tasks: unknown; + workflows?: unknown; + }; } export interface UserAuthOperations { forgotPassword: { @@ -72,6 +81,8 @@ export interface UserAuthOperations { export interface Post { id: string; title?: string | null; + isFiltered?: boolean | null; + restrictedField?: string | null; upload?: (string | null) | Upload; category?: (string | null) | Category; categories?: (string | Category)[] | null; @@ -138,6 +149,10 @@ export interface Category { docs?: (string | Singular)[] | null; hasNextPage?: boolean | null; } | null; + filtered?: { + docs?: (string | Post)[] | null; + hasNextPage?: boolean | null; + } | null; updatedAt: string; createdAt: string; } @@ -202,6 +217,32 @@ export interface LocalizedCategory { updatedAt: 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 * via the `definition` "users". @@ -258,6 +299,14 @@ export interface PayloadLockedDocument { relationTo: 'localized-categories'; value: string | LocalizedCategory; } | null) + | ({ + relationTo: 'restricted-categories'; + value: string | RestrictedCategory; + } | null) + | ({ + relationTo: 'restricted-posts'; + value: string | RestrictedPost; + } | null) | ({ relationTo: 'users'; value: string | User; @@ -310,6 +359,8 @@ export interface PayloadMigration { */ export interface PostsSelect { title?: T; + isFiltered?: T; + restrictedField?: T; upload?: T; category?: T; categories?: T; @@ -338,6 +389,8 @@ export interface CategoriesSelect { relatedPosts?: T; camelCasePosts?: T; }; + singulars?: T; + filtered?: T; updatedAt?: T; createdAt?: T; } @@ -380,6 +433,15 @@ export interface CategoriesVersionsSelect { createdAt?: T; _status?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "singular_select". + */ +export interface SingularSelect { + category?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "localized-posts_select". @@ -400,6 +462,27 @@ export interface LocalizedCategoriesSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "restricted-categories_select". + */ +export interface RestrictedCategoriesSelect { + 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 { + title?: T; + restrictedField?: T; + category?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users_select". diff --git a/test/joins/shared.ts b/test/joins/shared.ts index 0cce98b0e..d1f59c510 100644 --- a/test/joins/shared.ts +++ b/test/joins/shared.ts @@ -8,9 +8,15 @@ export const localizedPostsSlug = 'localized-posts' export const localizedCategoriesSlug = 'localized-categories' +export const restrictedPostsSlug = 'restricted-posts' + +export const restrictedCategoriesSlug = 'restricted-categories' + export const collectionSlugs = [ categoriesSlug, postsSlug, localizedPostsSlug, localizedCategoriesSlug, + restrictedPostsSlug, + restrictedCategoriesSlug, ]