From 466dcd7189dc1518a5bb5ab9be317a8fc85968f6 Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Thu, 10 Apr 2025 22:30:40 +0300 Subject: [PATCH] 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 --- docs/fields/join.mdx | 15 --- .../src/queries/buildSearchParams.ts | 68 ++++++++-- .../src/queries/getTableColumnFromPath.ts | 118 +++++++++++++++++- .../payload/src/database/getLocalizedPaths.ts | 36 ++++-- test/joins/int.spec.ts | 45 +++++++ 5 files changed, 242 insertions(+), 40 deletions(-) diff --git a/docs/fields/join.mdx b/docs/fields/join.mdx index fee384181..f0e03befb 100644 --- a/docs/fields/join.mdx +++ b/docs/fields/join.mdx @@ -271,21 +271,6 @@ const result = await payload.find({ and blocks. - - 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. - - ### Rest API The REST API supports the same query options as the Local API. You can use the `joins` query parameter to customize the diff --git a/packages/db-mongodb/src/queries/buildSearchParams.ts b/packages/db-mongodb/src/queries/buildSearchParams.ts index 0e79fd65d..3d0359c41 100644 --- a/packages/db-mongodb/src/queries/buildSearchParams.ts +++ b/packages/db-mongodb/src/queries/buildSearchParams.ts @@ -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 = { + _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 || 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 }, } } diff --git a/packages/drizzle/src/queries/getTableColumnFromPath.ts b/packages/drizzle/src/queries/getTableColumnFromPath.ts index 66de4ca7f..ce1393403 100644 --- a/packages/drizzle/src/queries/getTableColumnFromPath.ts +++ b/packages/drizzle/src/queries/getTableColumnFromPath.ts @@ -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 } diff --git a/packages/payload/src/database/getLocalizedPaths.ts b/packages/payload/src/database/getLocalizedPaths.ts index 1547f476a..1d2d373eb 100644 --- a/packages/payload/src/database/getLocalizedPaths.ts +++ b/packages/payload/src/database/getLocalizedPaths.ts @@ -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) { diff --git a/test/joins/int.spec.ts b/test/joins/int.spec.ts index a59e4d7a0..f38248388 100644 --- a/test/joins/int.spec.ts +++ b/test/joins/int.spec.ts @@ -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, locale?: Config['locale']) {