diff --git a/packages/db-sqlite/src/createJSONQuery/index.ts b/packages/db-sqlite/src/createJSONQuery/index.ts index 435ca62ce6..abcb709d44 100644 --- a/packages/db-sqlite/src/createJSONQuery/index.ts +++ b/packages/db-sqlite/src/createJSONQuery/index.ts @@ -60,6 +60,10 @@ const createConstraint = ({ formattedOperator = '=' } + if (pathSegments.length === 1) { + return `EXISTS (SELECT 1 FROM json_each("${pathSegments[0]}") AS ${newAlias} WHERE ${newAlias}.value ${formattedOperator} '${formattedValue}')` + } + return `EXISTS ( SELECT 1 FROM json_each(${alias}.value -> '${pathSegments[0]}') AS ${newAlias} @@ -68,21 +72,38 @@ const createConstraint = ({ } export const createJSONQuery = ({ + column, operator, pathSegments, + rawColumn, table, treatAsArray, + treatRootAsArray, value, }: CreateJSONQueryArgs): string => { + if ((operator === 'in' || operator === 'not_in') && Array.isArray(value)) { + let sql = '' + for (const [i, v] of value.entries()) { + sql = `${sql}${createJSONQuery({ column, operator: operator === 'in' ? 'equals' : 'not_equals', pathSegments, rawColumn, table, treatAsArray, treatRootAsArray, value: v })} ${i === value.length - 1 ? '' : ` ${operator === 'in' ? 'OR' : 'AND'} `}` + } + return sql + } + if (treatAsArray?.includes(pathSegments[1]!) && table) { return fromArray({ operator, pathSegments, table, treatAsArray, - value, + value: value as CreateConstraintArgs['value'], }) } - return createConstraint({ alias: table, operator, pathSegments, treatAsArray, value }) + return createConstraint({ + alias: table, + operator, + pathSegments, + treatAsArray, + value: value as CreateConstraintArgs['value'], + }) } diff --git a/packages/drizzle/src/find/traverseFields.ts b/packages/drizzle/src/find/traverseFields.ts index f81b729258..208bd93c87 100644 --- a/packages/drizzle/src/find/traverseFields.ts +++ b/packages/drizzle/src/find/traverseFields.ts @@ -1,12 +1,14 @@ +import type { SQL } from 'drizzle-orm' import type { LibSQLDatabase } from 'drizzle-orm/libsql' import type { SQLiteSelect, SQLiteSelectBase } from 'drizzle-orm/sqlite-core' -import { and, asc, count, desc, eq, or, sql } from 'drizzle-orm' +import { and, asc, count, desc, eq, getTableName, or, sql } from 'drizzle-orm' import { appendVersionToQueryKey, buildVersionCollectionFields, combineQueries, type FlattenedField, + getFieldByPath, getQueryDraftsSort, type JoinQuery, type SelectMode, @@ -31,7 +33,7 @@ import { resolveBlockTableName, } from '../utilities/validateExistingBlockIsIdentical.js' -const flattenAllWherePaths = (where: Where, paths: string[]) => { +const flattenAllWherePaths = (where: Where, paths: { path: string; ref: any }[]) => { for (const k in where) { if (['AND', 'OR'].includes(k.toUpperCase())) { if (Array.isArray(where[k])) { @@ -41,7 +43,7 @@ const flattenAllWherePaths = (where: Where, paths: string[]) => { } } else { // TODO: explore how to support arrays/relationship querying. - paths.push(k.split('.').join('_')) + paths.push({ path: k.split('.').join('_'), ref: where }) } } } @@ -59,7 +61,11 @@ const buildSQLWhere = (where: Where, alias: string) => { } } else { const payloadOperator = Object.keys(where[k])[0] + const value = where[k][payloadOperator] + if (payloadOperator === '$raw') { + return sql.raw(value) + } return operatorMap[payloadOperator](sql.raw(`"${alias}"."${k.split('.').join('_')}"`), value) } @@ -472,7 +478,7 @@ export const traverseFields = ({ const sortPath = sanitizedSort.split('.').join('_') - const wherePaths: string[] = [] + const wherePaths: { path: string; ref: any }[] = [] if (where) { flattenAllWherePaths(where, wherePaths) @@ -492,9 +498,50 @@ export const traverseFields = ({ sortPath: sql`${sortColumn ? sortColumn : null}`.as('sortPath'), } + const collectionQueryWhere: any[] = [] // Select for WHERE and Fallback NULL - for (const path of wherePaths) { - if (adapter.tables[joinCollectionTableName][path]) { + for (const { path, ref } of wherePaths) { + const collectioConfig = adapter.payload.collections[collection].config + const field = getFieldByPath({ fields: collectioConfig.flattenedFields, path }) + + if (field && field.field.type === 'select' && field.field.hasMany) { + let tableName = adapter.tableNameMap.get( + `${toSnakeCase(collection)}_${toSnakeCase(path)}`, + ) + let parentTable = getTableName(table) + + if (adapter.schemaName) { + tableName = `"${adapter.schemaName}"."${tableName}"` + parentTable = `"${adapter.schemaName}"."${parentTable}"` + } + + if (adapter.name === 'postgres') { + selectFields[path] = sql + .raw( + `(select jsonb_agg(${tableName}.value) from ${tableName} where ${tableName}.parent_id = ${parentTable}.id)`, + ) + .as(path) + } else { + selectFields[path] = sql + .raw( + `(select json_group_array(${tableName}.value) from ${tableName} where ${tableName}.parent_id = ${parentTable}.id)`, + ) + .as(path) + } + + const constraint = ref[path] + const operator = Object.keys(constraint)[0] + const value: any = Object.values(constraint)[0] + + const query = adapter.createJSONQuery({ + column: `"${path}"`, + operator, + pathSegments: [field.field.name], + table: parentTable, + value, + }) + ref[path] = { $raw: query } + } else if (adapter.tables[joinCollectionTableName][path]) { selectFields[path] = sql`${adapter.tables[joinCollectionTableName][path]}`.as(path) // Allow to filter by collectionSlug } else if (path !== 'relationTo') { @@ -502,7 +549,10 @@ export const traverseFields = ({ } } - const query = db.select(selectFields).from(adapter.tables[joinCollectionTableName]) + let query: any = db.select(selectFields).from(adapter.tables[joinCollectionTableName]) + if (collectionQueryWhere.length) { + query = query.where(and(...collectionQueryWhere)) + } if (currentQuery === null) { currentQuery = query as unknown as SQLSelect } else { diff --git a/packages/drizzle/src/postgres/createJSONQuery/index.ts b/packages/drizzle/src/postgres/createJSONQuery/index.ts index 88ac57b4ae..86d532cca3 100644 --- a/packages/drizzle/src/postgres/createJSONQuery/index.ts +++ b/packages/drizzle/src/postgres/createJSONQuery/index.ts @@ -28,6 +28,8 @@ export const createJSONQuery = ({ column, operator, pathSegments, value }: Creat }) .join('.') + const fullPath = pathSegments.length === 1 ? '$[*]' : `$.${jsonPaths}` + let sql = '' if (['in', 'not_in'].includes(operator) && Array.isArray(value)) { @@ -35,13 +37,13 @@ export const createJSONQuery = ({ column, operator, pathSegments, value }: Creat sql = `${sql}${createJSONQuery({ column, operator: operator === 'in' ? 'equals' : 'not_equals', pathSegments, value: item })}${i === value.length - 1 ? '' : ` ${operator === 'in' ? 'OR' : 'AND'} `}` }) } else if (operator === 'exists') { - sql = `${value === false ? 'NOT ' : ''}jsonb_path_exists(${columnName}, '$.${jsonPaths}')` + sql = `${value === false ? 'NOT ' : ''}jsonb_path_exists(${columnName}, '${fullPath}')` } else if (['not_like'].includes(operator)) { const mappedOperator = operatorMap[operator] - sql = `NOT jsonb_path_exists(${columnName}, '$.${jsonPaths} ? (@ ${mappedOperator.substring(1)} ${sanitizeValue(value, operator)})')` + sql = `NOT jsonb_path_exists(${columnName}, '${fullPath} ? (@ ${mappedOperator.substring(1)} ${sanitizeValue(value, operator)})')` } else { - sql = `jsonb_path_exists(${columnName}, '$.${jsonPaths} ? (@ ${operatorMap[operator]} ${sanitizeValue(value, operator)})')` + sql = `jsonb_path_exists(${columnName}, '${fullPath} ? (@ ${operatorMap[operator]} ${sanitizeValue(value, operator)})')` } return sql diff --git a/packages/drizzle/src/types.ts b/packages/drizzle/src/types.ts index 84dd5f1e74..9e34cb23f6 100644 --- a/packages/drizzle/src/types.ts +++ b/packages/drizzle/src/types.ts @@ -161,10 +161,11 @@ export type CreateJSONQueryArgs = { column?: Column | string operator: string pathSegments: string[] + rawColumn?: SQL table?: string treatAsArray?: string[] treatRootAsArray?: boolean - value: boolean | number | string + value: boolean | number | number[] | string | string[] } /** diff --git a/test/joins/int.spec.ts b/test/joins/int.spec.ts index dccb033e30..4715c6bb67 100644 --- a/test/joins/int.spec.ts +++ b/test/joins/int.spec.ts @@ -356,6 +356,56 @@ describe('Joins Field', () => { expect(result.docs[0]?.documentsAndFolders.docs).toHaveLength(1) }) + it('should allow join where query on hasMany select fields', async () => { + const folderDoc = await payload.create({ + collection: 'payload-folders', + data: { + name: 'scopedFolder', + folderType: ['folderPoly1', 'folderPoly2'], + }, + }) + + await payload.create({ + collection: 'payload-folders', + data: { + name: 'childFolder', + folderType: ['folderPoly1'], + folder: folderDoc.id, + }, + }) + + const findFolder = await payload.find({ + collection: 'payload-folders', + where: { + id: { + equals: folderDoc.id, + }, + }, + joins: { + documentsAndFolders: { + limit: 100_000, + sort: 'name', + where: { + and: [ + { + relationTo: { + equals: 'payload-folders', + }, + }, + { + folderType: { + in: ['folderPoly1'], + }, + }, + ], + }, + }, + }, + }) + + expect(findFolder?.docs[0]?.documentsAndFolders?.docs).toHaveLength(1) + }) + it('should filter joins using where query', async () => { const categoryWithPosts = await payload.findByID({ id: category.id,