fix: querying hasMany: true select fields inside polymorphic joins (#13334)

This PR fixes queries like this:

```ts
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'], // previously this didn't work
            },
          },
        ],
      },
    },
  },
})
```

Additionally, this PR potentially fixes querying JSON fields by the top
level path, for example if your JSON field has a value like: `[1, 2]`,
previously `where: { json: { equals: 1 } }` didn't work, however with a
value like `{ nested: [1, 2] }` and a query `where: { 'json.nested': {
equals: 1 } }`it did.

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
This commit is contained in:
Sasha
2025-07-30 22:30:20 +03:00
committed by GitHub
parent 3114b89d4c
commit b26a73be4a
5 changed files with 137 additions and 13 deletions

View File

@@ -60,6 +60,10 @@ const createConstraint = ({
formattedOperator = '=' formattedOperator = '='
} }
if (pathSegments.length === 1) {
return `EXISTS (SELECT 1 FROM json_each("${pathSegments[0]}") AS ${newAlias} WHERE ${newAlias}.value ${formattedOperator} '${formattedValue}')`
}
return `EXISTS ( return `EXISTS (
SELECT 1 SELECT 1
FROM json_each(${alias}.value -> '${pathSegments[0]}') AS ${newAlias} FROM json_each(${alias}.value -> '${pathSegments[0]}') AS ${newAlias}
@@ -68,21 +72,38 @@ const createConstraint = ({
} }
export const createJSONQuery = ({ export const createJSONQuery = ({
column,
operator, operator,
pathSegments, pathSegments,
rawColumn,
table, table,
treatAsArray, treatAsArray,
treatRootAsArray,
value, value,
}: CreateJSONQueryArgs): string => { }: 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) { if (treatAsArray?.includes(pathSegments[1]!) && table) {
return fromArray({ return fromArray({
operator, operator,
pathSegments, pathSegments,
table, table,
treatAsArray, 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'],
})
} }

View File

@@ -1,12 +1,14 @@
import type { SQL } from 'drizzle-orm'
import type { LibSQLDatabase } from 'drizzle-orm/libsql' import type { LibSQLDatabase } from 'drizzle-orm/libsql'
import type { SQLiteSelect, SQLiteSelectBase } from 'drizzle-orm/sqlite-core' 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 { import {
appendVersionToQueryKey, appendVersionToQueryKey,
buildVersionCollectionFields, buildVersionCollectionFields,
combineQueries, combineQueries,
type FlattenedField, type FlattenedField,
getFieldByPath,
getQueryDraftsSort, getQueryDraftsSort,
type JoinQuery, type JoinQuery,
type SelectMode, type SelectMode,
@@ -31,7 +33,7 @@ import {
resolveBlockTableName, resolveBlockTableName,
} from '../utilities/validateExistingBlockIsIdentical.js' } from '../utilities/validateExistingBlockIsIdentical.js'
const flattenAllWherePaths = (where: Where, paths: string[]) => { const flattenAllWherePaths = (where: Where, paths: { path: string; ref: any }[]) => {
for (const k in where) { for (const k in where) {
if (['AND', 'OR'].includes(k.toUpperCase())) { if (['AND', 'OR'].includes(k.toUpperCase())) {
if (Array.isArray(where[k])) { if (Array.isArray(where[k])) {
@@ -41,7 +43,7 @@ const flattenAllWherePaths = (where: Where, paths: string[]) => {
} }
} else { } else {
// TODO: explore how to support arrays/relationship querying. // 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 { } else {
const payloadOperator = Object.keys(where[k])[0] const payloadOperator = Object.keys(where[k])[0]
const value = where[k][payloadOperator] const value = where[k][payloadOperator]
if (payloadOperator === '$raw') {
return sql.raw(value)
}
return operatorMap[payloadOperator](sql.raw(`"${alias}"."${k.split('.').join('_')}"`), value) return operatorMap[payloadOperator](sql.raw(`"${alias}"."${k.split('.').join('_')}"`), value)
} }
@@ -472,7 +478,7 @@ export const traverseFields = ({
const sortPath = sanitizedSort.split('.').join('_') const sortPath = sanitizedSort.split('.').join('_')
const wherePaths: string[] = [] const wherePaths: { path: string; ref: any }[] = []
if (where) { if (where) {
flattenAllWherePaths(where, wherePaths) flattenAllWherePaths(where, wherePaths)
@@ -492,9 +498,50 @@ export const traverseFields = ({
sortPath: sql`${sortColumn ? sortColumn : null}`.as('sortPath'), sortPath: sql`${sortColumn ? sortColumn : null}`.as('sortPath'),
} }
const collectionQueryWhere: any[] = []
// Select for WHERE and Fallback NULL // Select for WHERE and Fallback NULL
for (const path of wherePaths) { for (const { path, ref } of wherePaths) {
if (adapter.tables[joinCollectionTableName][path]) { 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) selectFields[path] = sql`${adapter.tables[joinCollectionTableName][path]}`.as(path)
// Allow to filter by collectionSlug // Allow to filter by collectionSlug
} else if (path !== 'relationTo') { } 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) { if (currentQuery === null) {
currentQuery = query as unknown as SQLSelect currentQuery = query as unknown as SQLSelect
} else { } else {

View File

@@ -28,6 +28,8 @@ export const createJSONQuery = ({ column, operator, pathSegments, value }: Creat
}) })
.join('.') .join('.')
const fullPath = pathSegments.length === 1 ? '$[*]' : `$.${jsonPaths}`
let sql = '' let sql = ''
if (['in', 'not_in'].includes(operator) && Array.isArray(value)) { 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'} `}` 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') { } 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)) { } else if (['not_like'].includes(operator)) {
const mappedOperator = operatorMap[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 { } 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 return sql

View File

@@ -161,10 +161,11 @@ export type CreateJSONQueryArgs = {
column?: Column | string column?: Column | string
operator: string operator: string
pathSegments: string[] pathSegments: string[]
rawColumn?: SQL<unknown>
table?: string table?: string
treatAsArray?: string[] treatAsArray?: string[]
treatRootAsArray?: boolean treatRootAsArray?: boolean
value: boolean | number | string value: boolean | number | number[] | string | string[]
} }
/** /**

View File

@@ -356,6 +356,56 @@ describe('Joins Field', () => {
expect(result.docs[0]?.documentsAndFolders.docs).toHaveLength(1) 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 () => { it('should filter joins using where query', async () => {
const categoryWithPosts = await payload.findByID({ const categoryWithPosts = await payload.findByID({
id: category.id, id: category.id,