feat: support where querying by join fields (#12075)

### What?
This PR adds support for `where` querying by the join field (don't
confuse with `where` querying of related docs via `joins.where`)

Previously, this didn't work:
```
const categories = await payload.find({
  collection: 'categories',
  where: { 'relatedPosts.title': { equals: 'my-title' } },
})
```

### Why?
This is crucial for bi-directional relationships, can be used for access
control.

### How?
Implements `where` handling for join fields the same as we do for
relationships. In MongoDB it's not as efficient as it can be, the old PR
that improves it and can be updated later is here
https://github.com/payloadcms/payload/pull/8858

Fixes https://github.com/payloadcms/payload/discussions/9683
This commit is contained in:
Sasha
2025-04-10 22:30:40 +03:00
committed by GitHub
parent a72fa869f3
commit 466dcd7189
5 changed files with 242 additions and 40 deletions

View File

@@ -271,21 +271,6 @@ const result = await payload.find({
and blocks.
</Banner>
<Banner type="warning">
Currently, querying by the Join Field itself is not supported, meaning:
```ts
payload.find({
collection: 'categories',
where: {
'relatedPosts.title': { // relatedPosts is a join field
equals: "post"
}
}
})
```
does not work yet.
</Banner>
### Rest API
The REST API supports the same query options as the Local API. You can use the `joins` query parameter to customize the

View File

@@ -2,7 +2,7 @@ import type { FilterQuery } from 'mongoose'
import type { FlattenedField, Operator, PathToQuery, Payload } from 'payload'
import { Types } from 'mongoose'
import { APIError, getLocalizedPaths } from 'payload'
import { APIError, getFieldByPath, getLocalizedPaths } from 'payload'
import { validOperatorSet } from 'payload/shared'
import type { MongooseAdapter } from '../index.js'
@@ -138,7 +138,7 @@ export async function buildSearchParam({
throw new APIError(`Collection with the slug ${collectionSlug} was not found.`)
}
const { Model: SubModel } = getCollection({
const { collectionConfig, Model: SubModel } = getCollection({
adapter: payload.db as MongooseAdapter,
collectionSlug,
})
@@ -154,22 +154,72 @@ export async function buildSearchParam({
},
})
const result = await SubModel.find(subQuery, subQueryOptions)
const field = paths[0].field
const select: Record<string, boolean> = {
_id: true,
}
let joinPath: null | string = null
if (field.type === 'join') {
const relationshipField = getFieldByPath({
fields: collectionConfig.flattenedFields,
path: field.on,
})
if (!relationshipField) {
throw new APIError('Relationship field was not found')
}
let path = relationshipField.localizedPath
if (relationshipField.pathHasLocalized && payload.config.localization) {
path = path.replace('<locale>', locale || payload.config.localization.defaultLocale)
}
select[path] = true
joinPath = path
}
if (joinPath) {
select[joinPath] = true
}
const result = await SubModel.find(subQuery).lean().limit(50).select(select)
const $in: unknown[] = []
result.forEach((doc) => {
const stringID = doc._id.toString()
$in.push(stringID)
result.forEach((doc: any) => {
if (joinPath) {
let ref = doc
if (Types.ObjectId.isValid(stringID)) {
$in.push(doc._id)
for (const segment of joinPath.split('.')) {
if (typeof ref === 'object' && ref) {
ref = ref[segment]
}
}
if (Array.isArray(ref)) {
for (const item of ref) {
if (item instanceof Types.ObjectId) {
$in.push(item)
}
}
} else if (ref instanceof Types.ObjectId) {
$in.push(ref)
}
} else {
const stringID = doc._id.toString()
$in.push(stringID)
if (Types.ObjectId.isValid(stringID)) {
$in.push(doc._id)
}
}
})
if (pathsToQuery.length === 1) {
return {
path,
path: joinPath ? '_id' : path,
value: { $in },
}
}

View File

@@ -1,10 +1,16 @@
import type { SQL } from 'drizzle-orm'
import type { SQLiteTableWithColumns } from 'drizzle-orm/sqlite-core'
import type { FlattenedBlock, FlattenedField, NumberField, TextField } from 'payload'
import type {
FlattenedBlock,
FlattenedField,
NumberField,
RelationshipField,
TextField,
} from 'payload'
import { and, eq, like, sql } from 'drizzle-orm'
import { type PgTableWithColumns } from 'drizzle-orm/pg-core'
import { APIError } from 'payload'
import { APIError, getFieldByPath } from 'payload'
import { fieldShouldBeLocalized, tabHasName } from 'payload/shared'
import toSnakeCase from 'to-snake-case'
import { validate as uuidValidate } from 'uuid'
@@ -338,6 +344,112 @@ export const getTableColumnFromPath = ({
})
}
case 'join': {
if (Array.isArray(field.collection)) {
throw new APIError('Not supported')
}
const newCollectionPath = pathSegments.slice(1).join('.')
if (field.hasMany) {
const relationTableName = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${adapter.relationshipsSuffix}`
const { newAliasTable: aliasRelationshipTable } = getTableAlias({
adapter,
tableName: relationTableName,
})
const relationshipField = getFieldByPath({
fields: adapter.payload.collections[field.collection].config.flattenedFields,
path: field.on,
})
if (!relationshipField) {
throw new APIError('Relationship was not found')
}
addJoinTable({
condition: and(
eq(
adapter.tables[rootTableName].id,
aliasRelationshipTable[
`${(relationshipField.field as RelationshipField).relationTo as string}ID`
],
),
like(aliasRelationshipTable.path, field.on),
),
joins,
queryPath: field.on,
table: aliasRelationshipTable,
})
const relationshipConfig = adapter.payload.collections[field.collection].config
const relationshipTableName = adapter.tableNameMap.get(
toSnakeCase(relationshipConfig.slug),
)
// parent to relationship join table
const relationshipFields = relationshipConfig.flattenedFields
const { newAliasTable: relationshipTable } = getTableAlias({
adapter,
tableName: relationshipTableName,
})
joins.push({
condition: eq(aliasRelationshipTable.parent, relationshipTable.id),
table: relationshipTable,
})
return getTableColumnFromPath({
adapter,
aliasTable: relationshipTable,
collectionPath: newCollectionPath,
constraints,
// relationshipFields are fields from a different collection => no parentIsLocalized
fields: relationshipFields,
joins,
locale,
parentIsLocalized: false,
pathSegments: pathSegments.slice(1),
rootTableName: relationshipTableName,
selectFields,
selectLocale,
tableName: relationshipTableName,
value,
})
}
const newTableName = adapter.tableNameMap.get(
toSnakeCase(adapter.payload.collections[field.collection].config.slug),
)
const { newAliasTable } = getTableAlias({ adapter, tableName: newTableName })
joins.push({
condition: eq(
newAliasTable[field.on.replaceAll('.', '_')],
aliasTable ? aliasTable.id : adapter.tables[tableName].id,
),
table: newAliasTable,
})
return getTableColumnFromPath({
adapter,
aliasTable: newAliasTable,
collectionPath: newCollectionPath,
constraintPath: '',
constraints,
fields: adapter.payload.collections[field.collection].config.flattenedFields,
joins,
locale,
parentIsLocalized: parentIsLocalized || field.localized,
pathSegments: pathSegments.slice(1),
selectFields,
tableName: newTableName,
value,
})
break
}
case 'number':
case 'text': {
if (field.hasMany) {
@@ -381,7 +493,6 @@ export const getTableColumnFromPath = ({
}
break
}
case 'relationship':
case 'upload': {
const newCollectionPath = pathSegments.slice(1).join('.')
@@ -645,6 +756,7 @@ export const getTableColumnFromPath = ({
value,
})
}
break
}

View File

@@ -1,6 +1,7 @@
import type { Payload } from '../index.js'
import type { PathToQuery } from './queryValidation/types.js'
import { APIError, type Payload, type SanitizedCollectionConfig } from '../index.js'
// @ts-strict-ignore
import {
type Field,
@@ -151,21 +152,12 @@ export function getLocalizedPaths({
}
switch (matchedField.type) {
case 'json':
case 'richText': {
const upcomingSegments = pathSegments.slice(i + 1).join('.')
lastIncompletePath.complete = true
lastIncompletePath.path = upcomingSegments
? `${currentPath}.${upcomingSegments}`
: currentPath
return paths
}
case 'join':
case 'relationship':
case 'upload': {
// If this is a polymorphic relation,
// We only support querying directly (no nested querying)
if (typeof matchedField.relationTo !== 'string') {
if (matchedField.type !== 'join' && typeof matchedField.relationTo !== 'string') {
const lastSegmentIsValid =
['relationTo', 'value'].includes(pathSegments[pathSegments.length - 1]) ||
pathSegments.length === 1 ||
@@ -188,7 +180,16 @@ export function getLocalizedPaths({
.join('.')
if (nestedPathToQuery) {
const relatedCollection = payload.collections[matchedField.relationTo].config
let relatedCollection: SanitizedCollectionConfig
if (matchedField.type === 'join') {
if (Array.isArray(matchedField.collection)) {
throw new APIError('Not supported')
}
relatedCollection = payload.collections[matchedField.collection].config
} else {
relatedCollection = payload.collections[matchedField.relationTo as string].config
}
const remainingPaths = getLocalizedPaths({
collectionSlug: relatedCollection.slug,
@@ -208,6 +209,15 @@ export function getLocalizedPaths({
break
}
case 'json':
case 'richText': {
const upcomingSegments = pathSegments.slice(i + 1).join('.')
lastIncompletePath.complete = true
lastIncompletePath.path = upcomingSegments
? `${currentPath}.${upcomingSegments}`
: currentPath
return paths
}
default: {
if (i + 1 === pathSegments.length) {

View File

@@ -1459,6 +1459,51 @@ describe('Joins Field', () => {
expect(parent.children?.totalDocs).toBe(1)
})
})
it('should support where querying by a top level join field', async () => {
const category = await payload.create({ collection: 'categories', data: {} })
await payload.create({
collection: 'posts',
data: { category: category.id, title: 'my-title' },
})
const found = await payload.find({
collection: 'categories',
where: { 'relatedPosts.title': { equals: 'my-title' } },
})
expect(found.docs).toHaveLength(1)
expect(found.docs[0].id).toBe(category.id)
})
it('should support where querying by a join field with hasMany relationship', async () => {
const category = await payload.create({ collection: 'categories', data: {} })
await payload.create({
collection: 'posts',
data: { categories: [category.id], title: 'my-title' },
})
const found = await payload.find({
collection: 'categories',
where: { 'hasManyPosts.title': { equals: 'my-title' } },
})
expect(found.docs).toHaveLength(1)
expect(found.docs[0].id).toBe(category.id)
})
it('should support where querying by a join field with relationship nested to a group', async () => {
const category = await payload.create({ collection: 'categories', data: {} })
await payload.create({
collection: 'posts',
data: { group: { category: category.id }, title: 'my-category-title' },
})
const found = await payload.find({
collection: 'categories',
where: { 'group.relatedPosts.title': { equals: 'my-category-title' } },
})
expect(found.docs).toHaveLength(1)
expect(found.docs[0].id).toBe(category.id)
})
})
async function createPost(overrides?: Partial<Post>, locale?: Config['locale']) {