Skip to content

Commit 93a55d1

Browse files
DanRibbensr1tsuu
andauthored
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 #8936 #8937 --------- Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com>
1 parent cdcefa8 commit 93a55d1

File tree

19 files changed

+596
-67
lines changed

19 files changed

+596
-67
lines changed

docs/fields/join.mdx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,13 @@ powerful Admin UI.
126126
| **`name`** \* | To be used as the property name when retrieved from the database. [More](/docs/fields/overview#field-names) |
127127
| **`collection`** \* | The `slug`s having the relationship field. |
128128
| **`on`** \* | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. |
129+
| **`where`** \* | A `Where` query to hide related documents from appearing. Will be merged with any `where` specified in the request. |
129130
| **`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). |
130131
| **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. |
131132
| **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). |
132133
| **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). |
133-
| **`defaultLimit`** | The number of documents to return. Set to 0 to return all related documents. |
134-
| **`defaultSort`** | The field name used to specify the order the joined documents are returned. |
134+
| **`defaultLimit`** | The number of documents to return. Set to 0 to return all related documents. |
135+
| **`defaultSort`** | The field name used to specify the order the joined documents are returned. |
135136
| **`admin`** | Admin-specific configuration. [More details](#admin-config-options). |
136137
| **`custom`** | Extension point for adding custom data (e.g. for plugins). |
137138
| **`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
182183

183184
The following query options are supported:
184185

185-
| Property | Description |
186-
|-------------|--------------------------------------------------------------|
187-
| **`limit`** | The maximum related documents to be returned, default is 10. |
188-
| **`where`** | An optional `Where` query to filter joined documents. |
189-
| **`sort`** | A string used to order related results |
186+
| Property | Description |
187+
|-------------|-----------------------------------------------------------------------------------------------------|
188+
| **`limit`** | The maximum related documents to be returned, default is 10. |
189+
| **`where`** | An optional `Where` query to filter joined documents. Will be merged with the field `where` object. |
190+
| **`sort`** | A string used to order related results |
190191

191192
These can be applied to the local API, GraphQL, and REST API.
192193

packages/db-mongodb/src/utilities/buildJoinAggregation.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { PipelineStage } from 'mongoose'
22
import type { CollectionSlug, JoinQuery, SanitizedCollectionConfig, Where } from 'payload'
33

4+
import { combineQueries } from 'payload'
5+
46
import type { MongooseAdapter } from '../index.js'
57

68
import { buildSortParam } from '../queries/buildSortParam.js'
@@ -62,6 +64,10 @@ export const buildJoinAggregation = async ({
6264
continue
6365
}
6466

67+
if (joins?.[join.schemaPath] === false) {
68+
continue
69+
}
70+
6571
const {
6672
limit: limitJoin = join.field.defaultLimit ?? 10,
6773
sort: sortJoin = join.field.defaultSort || collectionConfig.defaultSort,

packages/drizzle/src/find/traverseFields.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
22
import type { Field, JoinQuery, SelectMode, SelectType, TabAsField } from 'payload'
33

44
import { and, eq, sql } from 'drizzle-orm'
5+
import { combineQueries } from 'payload'
56
import { fieldAffectsData, fieldIsVirtual, tabHasName } from 'payload/shared'
67
import toSnakeCase from 'to-snake-case'
78

@@ -402,11 +403,17 @@ export const traverseFields = ({
402403
break
403404
}
404405

406+
const joinSchemaPath = `${path.replaceAll('_', '.')}${field.name}`
407+
408+
if (joinQuery[joinSchemaPath] === false) {
409+
break
410+
}
411+
405412
const {
406413
limit: limitArg = field.defaultLimit ?? 10,
407414
sort = field.defaultSort,
408415
where,
409-
} = joinQuery[`${path.replaceAll('_', '.')}${field.name}`] || {}
416+
} = joinQuery[joinSchemaPath] || {}
410417
let limit = limitArg
411418

412419
if (limit !== 0) {

packages/next/src/routes/rest/utilities/sanitizeJoinParams.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,27 @@ import { isNumber } from 'payload/shared'
99
export const sanitizeJoinParams = (
1010
joins:
1111
| {
12-
[schemaPath: string]: {
13-
limit?: unknown
14-
sort?: string
15-
where?: unknown
16-
}
12+
[schemaPath: string]:
13+
| {
14+
limit?: unknown
15+
sort?: string
16+
where?: unknown
17+
}
18+
| false
1719
}
1820
| false = {},
1921
): JoinQuery => {
2022
const joinQuery = {}
2123

2224
Object.keys(joins).forEach((schemaPath) => {
23-
joinQuery[schemaPath] = {
24-
limit: isNumber(joins[schemaPath]?.limit) ? Number(joins[schemaPath].limit) : undefined,
25-
sort: joins[schemaPath]?.sort ? joins[schemaPath].sort : undefined,
26-
where: joins[schemaPath]?.where ? joins[schemaPath].where : undefined,
25+
if (joins[schemaPath] === 'false' || joins[schemaPath] === false) {
26+
joinQuery[schemaPath] = false
27+
} else {
28+
joinQuery[schemaPath] = {
29+
limit: isNumber(joins[schemaPath]?.limit) ? Number(joins[schemaPath].limit) : undefined,
30+
sort: joins[schemaPath]?.sort ? joins[schemaPath].sort : undefined,
31+
where: joins[schemaPath]?.where ? joins[schemaPath].where : undefined,
32+
}
2733
}
2834
})
2935

packages/payload/src/collections/operations/find.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
import executeAccess from '../../auth/executeAccess.js'
1818
import { combineQueries } from '../../database/combineQueries.js'
1919
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js'
20+
import { sanitizeJoinQuery } from '../../database/sanitizeJoinQuery.js'
2021
import { afterRead } from '../../fields/hooks/afterRead/index.js'
2122
import { killTransaction } from '../../utilities/killTransaction.js'
2223
import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js'
@@ -129,6 +130,13 @@ export const findOperation = async <
129130

130131
let fullWhere = combineQueries(where, accessResult)
131132

133+
const sanitizedJoins = await sanitizeJoinQuery({
134+
collectionConfig,
135+
joins,
136+
overrideAccess,
137+
req,
138+
})
139+
132140
if (collectionConfig.versions?.drafts && draftsEnabled) {
133141
fullWhere = appendVersionToQueryKey(fullWhere)
134142

@@ -142,7 +150,7 @@ export const findOperation = async <
142150

143151
result = await payload.db.queryDrafts<DataFromCollectionSlug<TSlug>>({
144152
collection: collectionConfig.slug,
145-
joins: req.payloadAPI === 'GraphQL' ? false : joins,
153+
joins: req.payloadAPI === 'GraphQL' ? false : sanitizedJoins,
146154
limit: sanitizedLimit,
147155
locale,
148156
page: sanitizedPage,
@@ -162,7 +170,7 @@ export const findOperation = async <
162170

163171
result = await payload.db.find<DataFromCollectionSlug<TSlug>>({
164172
collection: collectionConfig.slug,
165-
joins: req.payloadAPI === 'GraphQL' ? false : joins,
173+
joins: req.payloadAPI === 'GraphQL' ? false : sanitizedJoins,
166174
limit: sanitizedLimit,
167175
locale,
168176
page: sanitizedPage,

packages/payload/src/collections/operations/findByID.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import type {
1414

1515
import executeAccess from '../../auth/executeAccess.js'
1616
import { combineQueries } from '../../database/combineQueries.js'
17+
import { sanitizeJoinQuery } from '../../database/sanitizeJoinQuery.js'
1718
import { NotFound } from '../../errors/index.js'
1819
import { afterRead } from '../../fields/hooks/afterRead/index.js'
20+
import { validateQueryPaths } from '../../index.js'
1921
import { killTransaction } from '../../utilities/killTransaction.js'
2022
import replaceWithDraftIfAvailable from '../../versions/drafts/replaceWithDraftIfAvailable.js'
2123
import { buildAfterOperation } from './utils.js'
@@ -91,17 +93,33 @@ export const findByIDOperation = async <
9193
return null
9294
}
9395

96+
const where = combineQueries({ id: { equals: id } }, accessResult)
97+
98+
const sanitizedJoins = await sanitizeJoinQuery({
99+
collectionConfig,
100+
joins,
101+
overrideAccess,
102+
req,
103+
})
104+
94105
const findOneArgs: FindOneArgs = {
95106
collection: collectionConfig.slug,
96-
joins: req.payloadAPI === 'GraphQL' ? false : joins,
107+
joins: req.payloadAPI === 'GraphQL' ? false : sanitizedJoins,
97108
locale,
98109
req: {
99110
transactionID: req.transactionID,
100111
} as PayloadRequest,
101112
select,
102-
where: combineQueries({ id: { equals: id } }, accessResult),
113+
where,
103114
}
104115

116+
await validateQueryPaths({
117+
collectionConfig,
118+
overrideAccess,
119+
req,
120+
where,
121+
})
122+
105123
// /////////////////////////////////////
106124
// Find by ID
107125
// /////////////////////////////////////
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { SanitizedCollectionConfig } from '../collections/config/types.js'
2+
import type { JoinQuery, PayloadRequest } from '../types/index.js'
3+
4+
import executeAccess from '../auth/executeAccess.js'
5+
import { QueryError } from '../errors/QueryError.js'
6+
import { combineQueries } from './combineQueries.js'
7+
import { validateQueryPaths } from './queryValidation/validateQueryPaths.js'
8+
9+
type Args = {
10+
collectionConfig: SanitizedCollectionConfig
11+
joins?: JoinQuery
12+
overrideAccess: boolean
13+
req: PayloadRequest
14+
}
15+
16+
/**
17+
* * Validates `where` for each join
18+
* * Combines the access result for joined collection
19+
* * Combines the default join's `where`
20+
*/
21+
export const sanitizeJoinQuery = async ({
22+
collectionConfig,
23+
joins: joinsQuery,
24+
overrideAccess,
25+
req,
26+
}: Args) => {
27+
if (joinsQuery === false) {
28+
return false
29+
}
30+
31+
if (!joinsQuery) {
32+
joinsQuery = {}
33+
}
34+
35+
const errors: { path: string }[] = []
36+
const promises: Promise<void>[] = []
37+
38+
for (const collectionSlug in collectionConfig.joins) {
39+
for (const { field, schemaPath } of collectionConfig.joins[collectionSlug]) {
40+
if (joinsQuery[schemaPath] === false) {
41+
continue
42+
}
43+
44+
const joinCollectionConfig = req.payload.collections[collectionSlug].config
45+
46+
const accessResult = !overrideAccess
47+
? await executeAccess({ disableErrors: true, req }, joinCollectionConfig.access.read)
48+
: true
49+
50+
if (accessResult === false) {
51+
joinsQuery[schemaPath] = false
52+
continue
53+
}
54+
55+
if (!joinsQuery[schemaPath]) {
56+
joinsQuery[schemaPath] = {}
57+
}
58+
59+
const joinQuery = joinsQuery[schemaPath]
60+
61+
if (!joinQuery.where) {
62+
joinQuery.where = {}
63+
}
64+
65+
if (field.where) {
66+
joinQuery.where = combineQueries(joinQuery.where, field.where)
67+
}
68+
69+
if (typeof accessResult === 'object') {
70+
joinQuery.where = combineQueries(joinQuery.where, accessResult)
71+
}
72+
73+
promises.push(
74+
validateQueryPaths({
75+
collectionConfig: joinCollectionConfig,
76+
errors,
77+
overrideAccess,
78+
req,
79+
where: joinQuery.where,
80+
}),
81+
)
82+
}
83+
}
84+
85+
await Promise.all(promises)
86+
87+
if (errors.length > 0) {
88+
throw new QueryError(errors)
89+
}
90+
91+
return joinsQuery
92+
}

packages/payload/src/fields/config/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1477,6 +1477,7 @@ export type JoinField = {
14771477
on: string
14781478
type: 'join'
14791479
validate?: never
1480+
where?: Where
14801481
} & FieldBase
14811482

14821483
export type JoinFieldClient = {
@@ -1488,7 +1489,7 @@ export type JoinFieldClient = {
14881489
} & AdminClient &
14891490
Pick<JoinField['admin'], 'disableBulkEdit' | 'readOnly'>
14901491
} & FieldBaseClient &
1491-
Pick<JoinField, 'collection' | 'index' | 'maxDepth' | 'on' | 'type'>
1492+
Pick<JoinField, 'collection' | 'index' | 'maxDepth' | 'on' | 'type' | 'where'>
14921493

14931494
export type Field =
14941495
| ArrayField

packages/payload/src/types/index.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,13 @@ export type Sort = Array<string> | string
124124
*/
125125
export type JoinQuery =
126126
| {
127-
[schemaPath: string]: {
128-
limit?: number
129-
sort?: string
130-
where?: Where
131-
}
127+
[schemaPath: string]:
128+
| {
129+
limit?: number
130+
sort?: string
131+
where?: Where
132+
}
133+
| false
132134
}
133135
| false
134136

packages/ui/src/fields/Join/index.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,19 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
3636
path: pathFromContext ?? pathFromProps ?? name,
3737
})
3838

39-
const filterOptions: Where = useMemo(
40-
() => ({
39+
const filterOptions: Where = useMemo(() => {
40+
const where = {
4141
[on]: {
4242
in: [docID || null],
4343
},
44-
}),
45-
[docID, on],
46-
)
44+
}
45+
if (field.where) {
46+
return {
47+
and: [where, field.where],
48+
}
49+
}
50+
return where
51+
}, [docID, on, field.where])
4752

4853
return (
4954
<div className={[fieldBaseClass, 'join'].filter(Boolean).join(' ')}>

0 commit comments

Comments
 (0)