feat: join field support relationships inside arrays (#9773)
### What? Allow the join field to have a configuration `on` relationships inside of an array, ie `on: 'myArray.myRelationship'`. ### Why? This is a more powerful and expressive way to use the join field and not be limited by usage of array data. For example, if you have a roles array for multinant sites, you could add a join field on the sites to show who the admins are. ### How? This fixes the traverseFields function to allow the configuration to pass sanitization. In addition, the function for querying the drizzle tables needed to be ehanced. Additional changes from https://github.com/payloadcms/payload/pull/9995: - Significantly improves traverseFields and the 'join' case with a raw query injection pattern, right now it's internal but we could expose it at some point, for example for querying vectors. - Fixes potential issues with not passed locale to traverseFields (it was undefined always) - Adds an empty array fallback for joins with localized relationships Fixes # https://github.com/payloadcms/payload/discussions/9643 --------- Co-authored-by: Because789 <thomas@because789.ch> Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com>
This commit is contained in:
@@ -125,8 +125,8 @@ powerful Admin UI.
|
||||
|------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **`name`** \* | To be used as the property name when retrieved from the database. [More](/docs/fields/overview#field-names) |
|
||||
| **`collection`** \* | The `slug`s having the relationship field. |
|
||||
| **`on`** \* | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. |
|
||||
| **`where`** \* | A `Where` query to hide related documents from appearing. Will be merged with any `where` specified in the request. |
|
||||
| **`on`** \* | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. |
|
||||
| **`where`** | A `Where` query to hide related documents from appearing. Will be merged with any `where` specified in the request. |
|
||||
| **`maxDepth`** | Default is 1, Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](/docs/getting-started/concepts#field-level-max-depth). |
|
||||
| **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. |
|
||||
| **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). |
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { QueryPromise } from 'drizzle-orm'
|
||||
|
||||
export type ChainedMethods = {
|
||||
args: unknown[]
|
||||
method: string
|
||||
@@ -10,7 +8,7 @@ export type ChainedMethods = {
|
||||
* @param methods
|
||||
* @param query
|
||||
*/
|
||||
const chainMethods = <T>({ methods, query }): QueryPromise<T> => {
|
||||
const chainMethods = <T>({ methods, query }: { methods: ChainedMethods; query: T }): T => {
|
||||
return methods.reduce((query, { args, method }) => {
|
||||
return query[method](...args)
|
||||
}, query)
|
||||
|
||||
@@ -73,6 +73,7 @@ export const findMany = async function find({
|
||||
fields,
|
||||
joinQuery,
|
||||
joins,
|
||||
locale,
|
||||
select,
|
||||
tableName,
|
||||
versions,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
||||
import type { FlattenedField, JoinQuery, SelectMode, SelectType } from 'payload'
|
||||
import type { FlattenedField, JoinQuery, SelectMode, SelectType, Where } from 'payload'
|
||||
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { fieldIsVirtual } from 'payload/shared'
|
||||
import toSnakeCase from 'to-snake-case'
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { BuildQueryJoinAliases, ChainedMethods, DrizzleAdapter } from '../t
|
||||
import type { Result } from './buildFindManyArgs.js'
|
||||
|
||||
import buildQuery from '../queries/buildQuery.js'
|
||||
import { jsonAggBuildObject } from '../utilities/json.js'
|
||||
import { rawConstraint } from '../utilities/rawConstraint.js'
|
||||
import { chainMethods } from './chainMethods.js'
|
||||
|
||||
type TraverseFieldArgs = {
|
||||
@@ -145,6 +147,7 @@ export const traverseFields = ({
|
||||
depth,
|
||||
fields: field.flattenedFields,
|
||||
joinQuery,
|
||||
locale,
|
||||
path: '',
|
||||
select: typeof arraySelect === 'object' ? arraySelect : undefined,
|
||||
selectMode,
|
||||
@@ -254,6 +257,7 @@ export const traverseFields = ({
|
||||
depth,
|
||||
fields: block.flattenedFields,
|
||||
joinQuery,
|
||||
locale,
|
||||
path: '',
|
||||
select: typeof blockSelect === 'object' ? blockSelect : undefined,
|
||||
selectMode: blockSelectMode,
|
||||
@@ -294,6 +298,7 @@ export const traverseFields = ({
|
||||
fields: field.flattenedFields,
|
||||
joinQuery,
|
||||
joins,
|
||||
locale,
|
||||
path: `${path}${field.name}_`,
|
||||
select: typeof fieldSelect === 'object' ? fieldSelect : undefined,
|
||||
selectAllOnCurrentLevel:
|
||||
@@ -348,92 +353,37 @@ export const traverseFields = ({
|
||||
|
||||
const joins: BuildQueryJoinAliases = []
|
||||
|
||||
const buildQueryResult = buildQuery({
|
||||
adapter,
|
||||
fields,
|
||||
joins,
|
||||
locale,
|
||||
sort,
|
||||
tableName: joinCollectionTableName,
|
||||
where,
|
||||
})
|
||||
|
||||
let subQueryWhere = buildQueryResult.where
|
||||
const orderBy = buildQueryResult.orderBy
|
||||
|
||||
let joinLocalesCollectionTableName: string | undefined
|
||||
|
||||
const currentIDColumn = versions
|
||||
? adapter.tables[currentTableName].parent
|
||||
: adapter.tables[currentTableName].id
|
||||
|
||||
// Handle hasMany _rels table
|
||||
if (field.hasMany) {
|
||||
const joinRelsCollectionTableName = `${joinCollectionTableName}${adapter.relationshipsSuffix}`
|
||||
let joinQueryWhere: Where = {
|
||||
[field.on]: {
|
||||
equals: rawConstraint(currentIDColumn),
|
||||
},
|
||||
}
|
||||
|
||||
if (field.localized) {
|
||||
joinLocalesCollectionTableName = joinRelsCollectionTableName
|
||||
}
|
||||
|
||||
let columnReferenceToCurrentID: string
|
||||
|
||||
if (versions) {
|
||||
columnReferenceToCurrentID = `${topLevelTableName
|
||||
.replace('_', '')
|
||||
.replace(new RegExp(`${adapter.versionsSuffix}$`), '')}_id`
|
||||
} else {
|
||||
columnReferenceToCurrentID = `${topLevelTableName}_id`
|
||||
}
|
||||
|
||||
joins.push({
|
||||
type: 'innerJoin',
|
||||
condition: and(
|
||||
eq(
|
||||
adapter.tables[joinRelsCollectionTableName].parent,
|
||||
adapter.tables[joinCollectionTableName].id,
|
||||
),
|
||||
eq(
|
||||
sql.raw(`"${joinRelsCollectionTableName}"."${columnReferenceToCurrentID}"`),
|
||||
currentIDColumn,
|
||||
),
|
||||
eq(adapter.tables[joinRelsCollectionTableName].path, field.on),
|
||||
),
|
||||
table: adapter.tables[joinRelsCollectionTableName],
|
||||
})
|
||||
} else {
|
||||
// Handle localized without hasMany
|
||||
|
||||
const foreignColumn = field.on.replaceAll('.', '_')
|
||||
|
||||
if (field.localized) {
|
||||
joinLocalesCollectionTableName = `${joinCollectionTableName}${adapter.localesSuffix}`
|
||||
|
||||
joins.push({
|
||||
type: 'innerJoin',
|
||||
condition: and(
|
||||
eq(
|
||||
adapter.tables[joinLocalesCollectionTableName]._parentID,
|
||||
adapter.tables[joinCollectionTableName].id,
|
||||
),
|
||||
eq(adapter.tables[joinLocalesCollectionTableName][foreignColumn], currentIDColumn),
|
||||
),
|
||||
table: adapter.tables[joinLocalesCollectionTableName],
|
||||
})
|
||||
// Handle without localized and without hasMany, just a condition append to where. With localized the inner join handles eq.
|
||||
} else {
|
||||
const constraint = eq(
|
||||
adapter.tables[joinCollectionTableName][foreignColumn],
|
||||
currentIDColumn,
|
||||
)
|
||||
|
||||
if (subQueryWhere) {
|
||||
subQueryWhere = and(subQueryWhere, constraint)
|
||||
} else {
|
||||
subQueryWhere = constraint
|
||||
}
|
||||
if (where) {
|
||||
joinQueryWhere = {
|
||||
and: [joinQueryWhere, where],
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
orderBy,
|
||||
selectFields,
|
||||
where: subQueryWhere,
|
||||
} = buildQuery({
|
||||
adapter,
|
||||
fields,
|
||||
joins,
|
||||
locale,
|
||||
selectLocale: true,
|
||||
sort,
|
||||
tableName: joinCollectionTableName,
|
||||
where: joinQueryWhere,
|
||||
})
|
||||
|
||||
const chainedMethods: ChainedMethods = []
|
||||
|
||||
joins.forEach(({ type, condition, table }) => {
|
||||
@@ -452,49 +402,29 @@ export const traverseFields = ({
|
||||
|
||||
const db = adapter.drizzle as LibSQLDatabase
|
||||
|
||||
const columnName = `${path.replaceAll('.', '_')}${field.name}`
|
||||
|
||||
const subQueryAlias = `${columnName}_alias`
|
||||
|
||||
const subQuery = chainMethods({
|
||||
methods: chainedMethods,
|
||||
query: db
|
||||
.select({
|
||||
id: adapter.tables[joinCollectionTableName].id,
|
||||
...(joinLocalesCollectionTableName && {
|
||||
locale:
|
||||
adapter.tables[joinLocalesCollectionTableName].locale ||
|
||||
adapter.tables[joinLocalesCollectionTableName]._locale,
|
||||
}),
|
||||
})
|
||||
.select(selectFields as any)
|
||||
.from(adapter.tables[joinCollectionTableName])
|
||||
.where(subQueryWhere)
|
||||
.orderBy(() => orderBy.map(({ column, order }) => order(column))),
|
||||
})
|
||||
}).as(subQueryAlias)
|
||||
|
||||
const columnName = `${path.replaceAll('.', '_')}${field.name}`
|
||||
|
||||
const jsonObjectSelect = field.localized
|
||||
? sql.raw(
|
||||
`'_parentID', "id", '_locale', "${adapter.tables[joinLocalesCollectionTableName].locale ? 'locale' : '_locale'}"`,
|
||||
)
|
||||
: sql.raw(`'id', "id"`)
|
||||
|
||||
if (adapter.name === 'sqlite') {
|
||||
currentArgs.extras[columnName] = sql`
|
||||
COALESCE((
|
||||
SELECT json_group_array(json_object(${jsonObjectSelect}))
|
||||
FROM (
|
||||
${subQuery}
|
||||
) AS ${sql.raw(`${columnName}_sub`)}
|
||||
), '[]')
|
||||
`.as(columnName)
|
||||
} else {
|
||||
currentArgs.extras[columnName] = sql`
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object(${jsonObjectSelect}))
|
||||
FROM (
|
||||
${subQuery}
|
||||
) AS ${sql.raw(`${columnName}_sub`)}
|
||||
), '[]'::json)
|
||||
`.as(columnName)
|
||||
}
|
||||
currentArgs.extras[columnName] = sql`${db
|
||||
.select({
|
||||
result: jsonAggBuildObject(adapter, {
|
||||
id: sql.raw(`"${subQueryAlias}".id`),
|
||||
...(selectFields._locale && {
|
||||
locale: sql.raw(`"${subQueryAlias}".${selectFields._locale.name}`),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.from(sql`${subQuery}`)}`.as(columnName)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export function buildAndOrConditions({
|
||||
joins,
|
||||
locale,
|
||||
selectFields,
|
||||
selectLocale,
|
||||
tableName,
|
||||
where,
|
||||
}: {
|
||||
@@ -22,6 +23,7 @@ export function buildAndOrConditions({
|
||||
joins: BuildQueryJoinAliases
|
||||
locale?: string
|
||||
selectFields: Record<string, GenericColumn>
|
||||
selectLocale?: boolean
|
||||
tableName: string
|
||||
where: Where[]
|
||||
}): SQL[] {
|
||||
@@ -38,6 +40,7 @@ export function buildAndOrConditions({
|
||||
joins,
|
||||
locale,
|
||||
selectFields,
|
||||
selectLocale,
|
||||
tableName,
|
||||
where: condition,
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ type BuildQueryArgs = {
|
||||
fields: FlattenedField[]
|
||||
joins?: BuildQueryJoinAliases
|
||||
locale?: string
|
||||
selectLocale?: boolean
|
||||
sort?: Sort
|
||||
tableName: string
|
||||
where: Where
|
||||
@@ -37,6 +38,7 @@ const buildQuery = function buildQuery({
|
||||
fields,
|
||||
joins = [],
|
||||
locale,
|
||||
selectLocale,
|
||||
sort,
|
||||
tableName,
|
||||
where: incomingWhere,
|
||||
@@ -64,6 +66,7 @@ const buildQuery = function buildQuery({
|
||||
joins,
|
||||
locale,
|
||||
selectFields,
|
||||
selectLocale,
|
||||
tableName,
|
||||
where: incomingWhere,
|
||||
})
|
||||
|
||||
@@ -49,6 +49,7 @@ type Args = {
|
||||
pathSegments: string[]
|
||||
rootTableName?: string
|
||||
selectFields: Record<string, GenericColumn>
|
||||
selectLocale?: boolean
|
||||
tableName: string
|
||||
/**
|
||||
* If creating a new table name for arrays and blocks, this suffix should be appended to the table name
|
||||
@@ -77,6 +78,7 @@ export const getTableColumnFromPath = ({
|
||||
pathSegments: incomingSegments,
|
||||
rootTableName: incomingRootTableName,
|
||||
selectFields,
|
||||
selectLocale,
|
||||
tableName,
|
||||
tableNameSuffix = '',
|
||||
value,
|
||||
@@ -130,6 +132,10 @@ export const getTableColumnFromPath = ({
|
||||
if (locale && field.localized && adapter.payload.config.localization) {
|
||||
const conditions = [eq(arrayParentTable.id, adapter.tables[newTableName]._parentID)]
|
||||
|
||||
if (selectLocale) {
|
||||
selectFields._locale = adapter.tables[newTableName]._locale
|
||||
}
|
||||
|
||||
if (locale !== 'all') {
|
||||
conditions.push(eq(adapter.tables[newTableName]._locale, locale))
|
||||
}
|
||||
@@ -156,6 +162,7 @@ export const getTableColumnFromPath = ({
|
||||
pathSegments: pathSegments.slice(1),
|
||||
rootTableName,
|
||||
selectFields,
|
||||
selectLocale,
|
||||
tableName: newTableName,
|
||||
value,
|
||||
})
|
||||
@@ -213,6 +220,7 @@ export const getTableColumnFromPath = ({
|
||||
pathSegments: pathSegments.slice(1),
|
||||
rootTableName,
|
||||
selectFields: blockSelectFields,
|
||||
selectLocale,
|
||||
tableName: newTableName,
|
||||
value,
|
||||
})
|
||||
@@ -294,6 +302,7 @@ export const getTableColumnFromPath = ({
|
||||
pathSegments: pathSegments.slice(1),
|
||||
rootTableName,
|
||||
selectFields,
|
||||
selectLocale,
|
||||
tableName: newTableName,
|
||||
tableNameSuffix: `${tableNameSuffix}${toSnakeCase(field.name)}_`,
|
||||
value,
|
||||
@@ -347,6 +356,7 @@ export const getTableColumnFromPath = ({
|
||||
case 'relationship':
|
||||
case 'upload': {
|
||||
const newCollectionPath = pathSegments.slice(1).join('.')
|
||||
|
||||
if (Array.isArray(field.relationTo) || field.hasMany) {
|
||||
let relationshipFields
|
||||
const relationTableName = `${rootTableName}${adapter.relationshipsSuffix}`
|
||||
@@ -355,6 +365,10 @@ export const getTableColumnFromPath = ({
|
||||
newAliasTableName: aliasRelationshipTableName,
|
||||
} = getTableAlias({ adapter, tableName: relationTableName })
|
||||
|
||||
if (selectLocale && field.localized && adapter.payload.config.localization) {
|
||||
selectFields._locale = aliasRelationshipTable.locale
|
||||
}
|
||||
|
||||
// Join in the relationships table
|
||||
if (locale && field.localized && adapter.payload.config.localization) {
|
||||
const conditions = [
|
||||
@@ -365,6 +379,7 @@ export const getTableColumnFromPath = ({
|
||||
if (locale !== 'all') {
|
||||
conditions.push(eq(aliasRelationshipTable.locale, locale))
|
||||
}
|
||||
|
||||
joins.push({
|
||||
condition: and(...conditions),
|
||||
table: aliasRelationshipTable,
|
||||
@@ -523,6 +538,7 @@ export const getTableColumnFromPath = ({
|
||||
pathSegments: pathSegments.slice(1),
|
||||
rootTableName: newTableName,
|
||||
selectFields,
|
||||
selectLocale,
|
||||
tableName: newTableName,
|
||||
value,
|
||||
})
|
||||
@@ -545,6 +561,10 @@ export const getTableColumnFromPath = ({
|
||||
|
||||
const condtions = [eq(aliasLocaleTable._parentID, adapter.tables[rootTableName].id)]
|
||||
|
||||
if (selectLocale) {
|
||||
selectFields._locale = aliasLocaleTable._locale
|
||||
}
|
||||
|
||||
if (locale !== 'all') {
|
||||
condtions.push(eq(aliasLocaleTable._locale, locale))
|
||||
}
|
||||
@@ -643,6 +663,7 @@ export const getTableColumnFromPath = ({
|
||||
pathSegments: pathSegments.slice(1),
|
||||
rootTableName,
|
||||
selectFields,
|
||||
selectLocale,
|
||||
tableName: newTableName,
|
||||
tableNameSuffix: `${tableNameSuffix}${toSnakeCase(field.name)}_`,
|
||||
value,
|
||||
@@ -661,6 +682,7 @@ export const getTableColumnFromPath = ({
|
||||
pathSegments: pathSegments.slice(1),
|
||||
rootTableName,
|
||||
selectFields,
|
||||
selectLocale,
|
||||
tableName: newTableName,
|
||||
tableNameSuffix,
|
||||
value,
|
||||
@@ -689,6 +711,10 @@ export const getTableColumnFromPath = ({
|
||||
condition = and(condition, eq(newTable._locale, locale))
|
||||
}
|
||||
|
||||
if (selectLocale) {
|
||||
selectFields._locale = newTable._locale
|
||||
}
|
||||
|
||||
addJoinTable({
|
||||
condition,
|
||||
joins,
|
||||
|
||||
@@ -19,6 +19,7 @@ type Args = {
|
||||
joins: BuildQueryJoinAliases
|
||||
locale: string
|
||||
selectFields: Record<string, GenericColumn>
|
||||
selectLocale?: boolean
|
||||
tableName: string
|
||||
where: Where
|
||||
}
|
||||
@@ -29,6 +30,7 @@ export function parseParams({
|
||||
joins,
|
||||
locale,
|
||||
selectFields,
|
||||
selectLocale,
|
||||
tableName,
|
||||
where,
|
||||
}: Args): SQL {
|
||||
@@ -53,6 +55,7 @@ export function parseParams({
|
||||
joins,
|
||||
locale,
|
||||
selectFields,
|
||||
selectLocale,
|
||||
tableName,
|
||||
where: condition,
|
||||
})
|
||||
@@ -86,6 +89,7 @@ export function parseParams({
|
||||
locale,
|
||||
pathSegments: relationOrPath.replace(/__/g, '.').split('.'),
|
||||
selectFields,
|
||||
selectLocale,
|
||||
tableName,
|
||||
value: val,
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { DrizzleAdapter } from '../types.js'
|
||||
|
||||
import { getCollectionIdType } from '../utilities/getCollectionIdType.js'
|
||||
import { isPolymorphicRelationship } from '../utilities/isPolymorphicRelationship.js'
|
||||
import { isRawConstraint } from '../utilities/rawConstraint.js'
|
||||
|
||||
type SanitizeQueryValueArgs = {
|
||||
adapter: DrizzleAdapter
|
||||
@@ -48,6 +49,9 @@ export const sanitizeQueryValue = ({
|
||||
return { operator, value: formattedValue }
|
||||
}
|
||||
|
||||
if (isRawConstraint(val)) {
|
||||
return { operator, value: val.value }
|
||||
}
|
||||
if (
|
||||
(field.type === 'relationship' || field.type === 'upload') &&
|
||||
!relationOrPath.endsWith('relationTo') &&
|
||||
|
||||
@@ -389,20 +389,25 @@ export const traverseFields = <T extends Record<string, unknown>>({
|
||||
| { docs: unknown[]; hasNextPage: boolean }
|
||||
| Record<string, { docs: unknown[]; hasNextPage: boolean }>
|
||||
if (Array.isArray(fieldData)) {
|
||||
if (field.localized) {
|
||||
fieldResult = fieldData.reduce((joinResult, row) => {
|
||||
if (typeof row._locale === 'string') {
|
||||
if (!joinResult[row._locale]) {
|
||||
joinResult[row._locale] = {
|
||||
docs: [],
|
||||
hasNextPage: false,
|
||||
}
|
||||
if (field.localized && adapter.payload.config.localization) {
|
||||
fieldResult = fieldData.reduce(
|
||||
(joinResult, row) => {
|
||||
if (typeof row.locale === 'string') {
|
||||
joinResult[row.locale].docs.push(row.id)
|
||||
}
|
||||
joinResult[row._locale].docs.push(row._parentID)
|
||||
}
|
||||
|
||||
return joinResult
|
||||
}, {})
|
||||
return joinResult
|
||||
},
|
||||
|
||||
// initialize with defaults so empty won't be undefined
|
||||
adapter.payload.config.localization.localeCodes.reduce((acc, code) => {
|
||||
acc[code] = {
|
||||
docs: [],
|
||||
hasNextPage: false,
|
||||
}
|
||||
return acc
|
||||
}, {}),
|
||||
)
|
||||
Object.keys(fieldResult).forEach((locale) => {
|
||||
fieldResult[locale].hasNextPage = fieldResult[locale].docs.length > limit
|
||||
fieldResult[locale].docs = fieldResult[locale].docs.slice(0, limit)
|
||||
|
||||
44
packages/drizzle/src/utilities/json.ts
Normal file
44
packages/drizzle/src/utilities/json.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Column, SQL } from 'drizzle-orm'
|
||||
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
import type { DrizzleAdapter } from '../types.js'
|
||||
|
||||
export function jsonAgg(adapter: DrizzleAdapter, expression: SQL) {
|
||||
if (adapter.name === 'sqlite') {
|
||||
return sql`coalesce(json_group_array(${expression}), '[]')`
|
||||
}
|
||||
|
||||
return sql`coalesce(json_agg(${expression}), '[]'::json)`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param shape Potential for SQL injections, so you shouldn't allow user-specified key names
|
||||
*/
|
||||
export function jsonBuildObject<T extends Record<string, Column | SQL>>(
|
||||
adapter: DrizzleAdapter,
|
||||
shape: T,
|
||||
) {
|
||||
const chunks: SQL[] = []
|
||||
|
||||
Object.entries(shape).forEach(([key, value]) => {
|
||||
if (chunks.length > 0) {
|
||||
chunks.push(sql.raw(','))
|
||||
}
|
||||
chunks.push(sql.raw(`'${key}',`))
|
||||
chunks.push(sql`${value}`)
|
||||
})
|
||||
|
||||
if (adapter.name === 'sqlite') {
|
||||
return sql`json_object(${sql.join(chunks)})`
|
||||
}
|
||||
|
||||
return sql`json_build_object(${sql.join(chunks)})`
|
||||
}
|
||||
|
||||
export const jsonAggBuildObject = <T extends Record<string, Column | SQL>>(
|
||||
adapter: DrizzleAdapter,
|
||||
shape: T,
|
||||
) => {
|
||||
return jsonAgg(adapter, jsonBuildObject(adapter, shape))
|
||||
}
|
||||
13
packages/drizzle/src/utilities/rawConstraint.ts
Normal file
13
packages/drizzle/src/utilities/rawConstraint.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const RawConstraintSymbol = Symbol('RawConstraint')
|
||||
|
||||
/**
|
||||
* You can use this to inject a raw query to where
|
||||
*/
|
||||
export const rawConstraint = (value: unknown) => ({
|
||||
type: RawConstraintSymbol,
|
||||
value,
|
||||
})
|
||||
|
||||
export const isRawConstraint = (value: unknown): value is ReturnType<typeof rawConstraint> => {
|
||||
return value && typeof value === 'object' && 'type' in value && value.type === RawConstraintSymbol
|
||||
}
|
||||
@@ -6,13 +6,26 @@ const traverseArrayOrBlocksField = ({
|
||||
callback,
|
||||
data,
|
||||
field,
|
||||
fillEmpty,
|
||||
parentRef,
|
||||
}: {
|
||||
callback: TraverseFieldsCallback
|
||||
data: Record<string, unknown>[]
|
||||
field: ArrayField | BlocksField
|
||||
fillEmpty: boolean
|
||||
parentRef?: unknown
|
||||
}) => {
|
||||
if (fillEmpty) {
|
||||
if (field.type === 'array') {
|
||||
traverseFields({ callback, fields: field.fields, parentRef })
|
||||
}
|
||||
if (field.type === 'blocks') {
|
||||
field.blocks.forEach((block) => {
|
||||
traverseFields({ callback, fields: block.fields, parentRef })
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const ref of data) {
|
||||
let fields: Field[]
|
||||
if (field.type === 'blocks' && typeof ref?.blockType === 'string') {
|
||||
@@ -23,7 +36,7 @@ const traverseArrayOrBlocksField = ({
|
||||
}
|
||||
|
||||
if (fields) {
|
||||
traverseFields({ callback, fields, parentRef, ref })
|
||||
traverseFields({ callback, fields, fillEmpty, parentRef, ref })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +63,6 @@ export type TraverseFieldsCallback = (args: {
|
||||
type TraverseFieldsArgs = {
|
||||
callback: TraverseFieldsCallback
|
||||
fields: (Field | TabAsField)[]
|
||||
/** fill empty properties to use this without data */
|
||||
fillEmpty?: boolean
|
||||
parentRef?: Record<string, unknown> | unknown
|
||||
ref?: Record<string, unknown> | unknown
|
||||
@@ -61,8 +73,9 @@ type TraverseFieldsArgs = {
|
||||
*
|
||||
* @param fields
|
||||
* @param callback callback called for each field, discontinue looping if callback returns truthy
|
||||
* @param ref
|
||||
* @param parentRef
|
||||
* @param fillEmpty fill empty properties to use this without data
|
||||
* @param ref the data or any artifacts assigned in the callback during field recursion
|
||||
* @param parentRef the data or any artifacts assigned in the callback during field recursion one level up
|
||||
*/
|
||||
export const traverseFields = ({
|
||||
callback,
|
||||
@@ -113,6 +126,7 @@ export const traverseFields = ({
|
||||
traverseFields({
|
||||
callback,
|
||||
fields: tab.fields,
|
||||
fillEmpty,
|
||||
parentRef: currentParentRef,
|
||||
ref: tabRef[key],
|
||||
})
|
||||
@@ -137,6 +151,7 @@ export const traverseFields = ({
|
||||
traverseFields({
|
||||
callback,
|
||||
fields: tab.fields,
|
||||
fillEmpty,
|
||||
parentRef: currentParentRef,
|
||||
ref: tabRef,
|
||||
})
|
||||
@@ -177,6 +192,7 @@ export const traverseFields = ({
|
||||
traverseFields({
|
||||
callback,
|
||||
fields: field.fields,
|
||||
fillEmpty,
|
||||
parentRef: currentParentRef,
|
||||
ref: currentRef[key],
|
||||
})
|
||||
@@ -205,6 +221,7 @@ export const traverseFields = ({
|
||||
callback,
|
||||
data: localeData,
|
||||
field,
|
||||
fillEmpty,
|
||||
parentRef: currentParentRef,
|
||||
})
|
||||
}
|
||||
@@ -213,6 +230,7 @@ export const traverseFields = ({
|
||||
callback,
|
||||
data: currentRef as Record<string, unknown>[],
|
||||
field,
|
||||
fillEmpty,
|
||||
parentRef: currentParentRef,
|
||||
})
|
||||
}
|
||||
@@ -220,6 +238,7 @@ export const traverseFields = ({
|
||||
traverseFields({
|
||||
callback,
|
||||
fields: field.fields,
|
||||
fillEmpty,
|
||||
parentRef: currentParentRef,
|
||||
ref: currentRef,
|
||||
})
|
||||
|
||||
@@ -1,18 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import type { JoinFieldClient, JoinFieldClientComponent, PaginatedDocs, Where } from 'payload'
|
||||
import type {
|
||||
ClientField,
|
||||
JoinFieldClient,
|
||||
JoinFieldClientComponent,
|
||||
PaginatedDocs,
|
||||
Where,
|
||||
} from 'payload'
|
||||
|
||||
import ObjectIdImport from 'bson-objectid'
|
||||
import { flattenTopLevelFields } from 'payload/shared'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
import { RelationshipTable } from '../../elements/RelationshipTable/index.js'
|
||||
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
|
||||
import { useField } from '../../forms/useField/index.js'
|
||||
import { withCondition } from '../../forms/withCondition/index.js'
|
||||
import { useConfig } from '../../providers/Config/index.js'
|
||||
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
|
||||
import { FieldDescription } from '../FieldDescription/index.js'
|
||||
import { FieldLabel } from '../FieldLabel/index.js'
|
||||
import { fieldBaseClass } from '../index.js'
|
||||
|
||||
const ObjectId = (ObjectIdImport.default ||
|
||||
ObjectIdImport) as unknown as typeof ObjectIdImport.default
|
||||
|
||||
/**
|
||||
* Recursively builds the default data for joined collection
|
||||
*/
|
||||
const getInitialDrawerData = ({
|
||||
docID,
|
||||
fields,
|
||||
segments,
|
||||
}: {
|
||||
docID: number | string
|
||||
fields: ClientField[]
|
||||
segments: string[]
|
||||
}) => {
|
||||
const flattenedFields = flattenTopLevelFields(fields)
|
||||
|
||||
const path = segments[0]
|
||||
|
||||
const field = flattenedFields.find((field) => field.name === path)
|
||||
|
||||
if (field.type === 'relationship' || field.type === 'upload') {
|
||||
return {
|
||||
// TODO: Handle polymorphic https://github.com/payloadcms/payload/pull/9990
|
||||
[field.name]: field.hasMany ? [docID] : docID,
|
||||
}
|
||||
}
|
||||
|
||||
const nextSegments = segments.slice(1, segments.length)
|
||||
|
||||
if (field.type === 'tab' || field.type === 'group') {
|
||||
return {
|
||||
[field.name]: getInitialDrawerData({ docID, fields: field.fields, segments: nextSegments }),
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === 'array') {
|
||||
const initialData = getInitialDrawerData({
|
||||
docID,
|
||||
fields: field.fields,
|
||||
segments: nextSegments,
|
||||
})
|
||||
|
||||
initialData.id = ObjectId().toHexString()
|
||||
|
||||
return {
|
||||
[field.name]: [initialData],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const JoinFieldComponent: JoinFieldClientComponent = (props) => {
|
||||
const {
|
||||
field,
|
||||
@@ -29,6 +89,10 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
|
||||
|
||||
const { id: docID } = useDocumentInfo()
|
||||
|
||||
const {
|
||||
config: { collections },
|
||||
} = useConfig()
|
||||
|
||||
const { customComponents: { AfterInput, BeforeInput, Description, Label } = {}, value } =
|
||||
useField<PaginatedDocs>({
|
||||
path,
|
||||
@@ -54,6 +118,16 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
|
||||
return where
|
||||
}, [docID, on, field.where])
|
||||
|
||||
const initialDrawerData = useMemo(() => {
|
||||
const relatedCollection = collections.find((collection) => collection.slug === field.collection)
|
||||
|
||||
return getInitialDrawerData({
|
||||
docID,
|
||||
fields: relatedCollection.fields,
|
||||
segments: field.on.split('.'),
|
||||
})
|
||||
}, [collections, field.on, docID, field.collection])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[fieldBaseClass, 'join'].filter(Boolean).join(' ')}
|
||||
@@ -67,9 +141,7 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
|
||||
field={field as JoinFieldClient}
|
||||
filterOptions={filterOptions}
|
||||
initialData={docID && value ? value : ({ docs: [] } as PaginatedDocs)}
|
||||
initialDrawerData={{
|
||||
[on]: docID,
|
||||
}}
|
||||
initialDrawerData={initialDrawerData}
|
||||
Label={
|
||||
<h4 style={{ margin: 0 }}>
|
||||
{Label || (
|
||||
|
||||
@@ -103,6 +103,12 @@ export const Categories: CollectionConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'arrayPosts',
|
||||
type: 'join',
|
||||
collection: 'posts',
|
||||
on: 'array.category',
|
||||
},
|
||||
{
|
||||
name: 'singulars',
|
||||
type: 'join',
|
||||
|
||||
@@ -69,5 +69,16 @@ export const Posts: CollectionConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'array',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'category',
|
||||
type: 'relationship',
|
||||
relationTo: categoriesSlug,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('Joins Field', () => {
|
||||
category: category.id,
|
||||
camelCaseCategory: category.id,
|
||||
},
|
||||
array: [{ category: category.id }],
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -183,6 +184,15 @@ describe('Joins Field', () => {
|
||||
expect(docs[0].group.camelCaseCategory.group.camelCasePosts.docs).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('should populate joins with array relationships', async () => {
|
||||
const categoryWithPosts = await payload.findByID({
|
||||
id: category.id,
|
||||
collection: categoriesSlug,
|
||||
})
|
||||
|
||||
expect(categoryWithPosts.arrayPosts.docs).toBeDefined()
|
||||
})
|
||||
|
||||
it('should populate uploads in joins', async () => {
|
||||
const { docs } = await payload.find({
|
||||
limit: 1,
|
||||
|
||||
Reference in New Issue
Block a user