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:
@@ -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'],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user