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:
Dan Ribbens
2024-12-17 13:14:43 -05:00
committed by GitHub
parent eb037a0cc6
commit b0b2fc6c47
17 changed files with 290 additions and 141 deletions

View File

@@ -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) | | **`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. | | **`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'. | | **`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. | | **`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). | | **`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. | | **`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). | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). |

View File

@@ -1,5 +1,3 @@
import type { QueryPromise } from 'drizzle-orm'
export type ChainedMethods = { export type ChainedMethods = {
args: unknown[] args: unknown[]
method: string method: string
@@ -10,7 +8,7 @@ export type ChainedMethods = {
* @param methods * @param methods
* @param query * @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 methods.reduce((query, { args, method }) => {
return query[method](...args) return query[method](...args)
}, query) }, query)

View File

@@ -73,6 +73,7 @@ export const findMany = async function find({
fields, fields,
joinQuery, joinQuery,
joins, joins,
locale,
select, select,
tableName, tableName,
versions, versions,

View File

@@ -1,7 +1,7 @@
import type { LibSQLDatabase } from 'drizzle-orm/libsql' 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 { fieldIsVirtual } from 'payload/shared'
import toSnakeCase from 'to-snake-case' import toSnakeCase from 'to-snake-case'
@@ -9,6 +9,8 @@ import type { BuildQueryJoinAliases, ChainedMethods, DrizzleAdapter } from '../t
import type { Result } from './buildFindManyArgs.js' import type { Result } from './buildFindManyArgs.js'
import buildQuery from '../queries/buildQuery.js' import buildQuery from '../queries/buildQuery.js'
import { jsonAggBuildObject } from '../utilities/json.js'
import { rawConstraint } from '../utilities/rawConstraint.js'
import { chainMethods } from './chainMethods.js' import { chainMethods } from './chainMethods.js'
type TraverseFieldArgs = { type TraverseFieldArgs = {
@@ -145,6 +147,7 @@ export const traverseFields = ({
depth, depth,
fields: field.flattenedFields, fields: field.flattenedFields,
joinQuery, joinQuery,
locale,
path: '', path: '',
select: typeof arraySelect === 'object' ? arraySelect : undefined, select: typeof arraySelect === 'object' ? arraySelect : undefined,
selectMode, selectMode,
@@ -254,6 +257,7 @@ export const traverseFields = ({
depth, depth,
fields: block.flattenedFields, fields: block.flattenedFields,
joinQuery, joinQuery,
locale,
path: '', path: '',
select: typeof blockSelect === 'object' ? blockSelect : undefined, select: typeof blockSelect === 'object' ? blockSelect : undefined,
selectMode: blockSelectMode, selectMode: blockSelectMode,
@@ -294,6 +298,7 @@ export const traverseFields = ({
fields: field.flattenedFields, fields: field.flattenedFields,
joinQuery, joinQuery,
joins, joins,
locale,
path: `${path}${field.name}_`, path: `${path}${field.name}_`,
select: typeof fieldSelect === 'object' ? fieldSelect : undefined, select: typeof fieldSelect === 'object' ? fieldSelect : undefined,
selectAllOnCurrentLevel: selectAllOnCurrentLevel:
@@ -348,92 +353,37 @@ export const traverseFields = ({
const joins: BuildQueryJoinAliases = [] 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 const currentIDColumn = versions
? adapter.tables[currentTableName].parent ? adapter.tables[currentTableName].parent
: adapter.tables[currentTableName].id : adapter.tables[currentTableName].id
// Handle hasMany _rels table let joinQueryWhere: Where = {
if (field.hasMany) { [field.on]: {
const joinRelsCollectionTableName = `${joinCollectionTableName}${adapter.relationshipsSuffix}` equals: rawConstraint(currentIDColumn),
},
}
if (field.localized) { if (where) {
joinLocalesCollectionTableName = joinRelsCollectionTableName joinQueryWhere = {
} and: [joinQueryWhere, where],
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
}
} }
} }
const {
orderBy,
selectFields,
where: subQueryWhere,
} = buildQuery({
adapter,
fields,
joins,
locale,
selectLocale: true,
sort,
tableName: joinCollectionTableName,
where: joinQueryWhere,
})
const chainedMethods: ChainedMethods = [] const chainedMethods: ChainedMethods = []
joins.forEach(({ type, condition, table }) => { joins.forEach(({ type, condition, table }) => {
@@ -452,49 +402,29 @@ export const traverseFields = ({
const db = adapter.drizzle as LibSQLDatabase const db = adapter.drizzle as LibSQLDatabase
const columnName = `${path.replaceAll('.', '_')}${field.name}`
const subQueryAlias = `${columnName}_alias`
const subQuery = chainMethods({ const subQuery = chainMethods({
methods: chainedMethods, methods: chainedMethods,
query: db query: db
.select({ .select(selectFields as any)
id: adapter.tables[joinCollectionTableName].id,
...(joinLocalesCollectionTableName && {
locale:
adapter.tables[joinLocalesCollectionTableName].locale ||
adapter.tables[joinLocalesCollectionTableName]._locale,
}),
})
.from(adapter.tables[joinCollectionTableName]) .from(adapter.tables[joinCollectionTableName])
.where(subQueryWhere) .where(subQueryWhere)
.orderBy(() => orderBy.map(({ column, order }) => order(column))), .orderBy(() => orderBy.map(({ column, order }) => order(column))),
}) }).as(subQueryAlias)
const columnName = `${path.replaceAll('.', '_')}${field.name}` currentArgs.extras[columnName] = sql`${db
.select({
const jsonObjectSelect = field.localized result: jsonAggBuildObject(adapter, {
? sql.raw( id: sql.raw(`"${subQueryAlias}".id`),
`'_parentID', "id", '_locale', "${adapter.tables[joinLocalesCollectionTableName].locale ? 'locale' : '_locale'}"`, ...(selectFields._locale && {
) locale: sql.raw(`"${subQueryAlias}".${selectFields._locale.name}`),
: sql.raw(`'id', "id"`) }),
}),
if (adapter.name === 'sqlite') { })
currentArgs.extras[columnName] = sql` .from(sql`${subQuery}`)}`.as(columnName)
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)
}
break break
} }

View File

@@ -12,6 +12,7 @@ export function buildAndOrConditions({
joins, joins,
locale, locale,
selectFields, selectFields,
selectLocale,
tableName, tableName,
where, where,
}: { }: {
@@ -22,6 +23,7 @@ export function buildAndOrConditions({
joins: BuildQueryJoinAliases joins: BuildQueryJoinAliases
locale?: string locale?: string
selectFields: Record<string, GenericColumn> selectFields: Record<string, GenericColumn>
selectLocale?: boolean
tableName: string tableName: string
where: Where[] where: Where[]
}): SQL[] { }): SQL[] {
@@ -38,6 +40,7 @@ export function buildAndOrConditions({
joins, joins,
locale, locale,
selectFields, selectFields,
selectLocale,
tableName, tableName,
where: condition, where: condition,
}) })

View File

@@ -18,6 +18,7 @@ type BuildQueryArgs = {
fields: FlattenedField[] fields: FlattenedField[]
joins?: BuildQueryJoinAliases joins?: BuildQueryJoinAliases
locale?: string locale?: string
selectLocale?: boolean
sort?: Sort sort?: Sort
tableName: string tableName: string
where: Where where: Where
@@ -37,6 +38,7 @@ const buildQuery = function buildQuery({
fields, fields,
joins = [], joins = [],
locale, locale,
selectLocale,
sort, sort,
tableName, tableName,
where: incomingWhere, where: incomingWhere,
@@ -64,6 +66,7 @@ const buildQuery = function buildQuery({
joins, joins,
locale, locale,
selectFields, selectFields,
selectLocale,
tableName, tableName,
where: incomingWhere, where: incomingWhere,
}) })

View File

@@ -49,6 +49,7 @@ type Args = {
pathSegments: string[] pathSegments: string[]
rootTableName?: string rootTableName?: string
selectFields: Record<string, GenericColumn> selectFields: Record<string, GenericColumn>
selectLocale?: boolean
tableName: string tableName: string
/** /**
* If creating a new table name for arrays and blocks, this suffix should be appended to the table name * 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, pathSegments: incomingSegments,
rootTableName: incomingRootTableName, rootTableName: incomingRootTableName,
selectFields, selectFields,
selectLocale,
tableName, tableName,
tableNameSuffix = '', tableNameSuffix = '',
value, value,
@@ -130,6 +132,10 @@ export const getTableColumnFromPath = ({
if (locale && field.localized && adapter.payload.config.localization) { if (locale && field.localized && adapter.payload.config.localization) {
const conditions = [eq(arrayParentTable.id, adapter.tables[newTableName]._parentID)] const conditions = [eq(arrayParentTable.id, adapter.tables[newTableName]._parentID)]
if (selectLocale) {
selectFields._locale = adapter.tables[newTableName]._locale
}
if (locale !== 'all') { if (locale !== 'all') {
conditions.push(eq(adapter.tables[newTableName]._locale, locale)) conditions.push(eq(adapter.tables[newTableName]._locale, locale))
} }
@@ -156,6 +162,7 @@ export const getTableColumnFromPath = ({
pathSegments: pathSegments.slice(1), pathSegments: pathSegments.slice(1),
rootTableName, rootTableName,
selectFields, selectFields,
selectLocale,
tableName: newTableName, tableName: newTableName,
value, value,
}) })
@@ -213,6 +220,7 @@ export const getTableColumnFromPath = ({
pathSegments: pathSegments.slice(1), pathSegments: pathSegments.slice(1),
rootTableName, rootTableName,
selectFields: blockSelectFields, selectFields: blockSelectFields,
selectLocale,
tableName: newTableName, tableName: newTableName,
value, value,
}) })
@@ -294,6 +302,7 @@ export const getTableColumnFromPath = ({
pathSegments: pathSegments.slice(1), pathSegments: pathSegments.slice(1),
rootTableName, rootTableName,
selectFields, selectFields,
selectLocale,
tableName: newTableName, tableName: newTableName,
tableNameSuffix: `${tableNameSuffix}${toSnakeCase(field.name)}_`, tableNameSuffix: `${tableNameSuffix}${toSnakeCase(field.name)}_`,
value, value,
@@ -347,6 +356,7 @@ export const getTableColumnFromPath = ({
case 'relationship': case 'relationship':
case 'upload': { case 'upload': {
const newCollectionPath = pathSegments.slice(1).join('.') const newCollectionPath = pathSegments.slice(1).join('.')
if (Array.isArray(field.relationTo) || field.hasMany) { if (Array.isArray(field.relationTo) || field.hasMany) {
let relationshipFields let relationshipFields
const relationTableName = `${rootTableName}${adapter.relationshipsSuffix}` const relationTableName = `${rootTableName}${adapter.relationshipsSuffix}`
@@ -355,6 +365,10 @@ export const getTableColumnFromPath = ({
newAliasTableName: aliasRelationshipTableName, newAliasTableName: aliasRelationshipTableName,
} = getTableAlias({ adapter, tableName: relationTableName }) } = getTableAlias({ adapter, tableName: relationTableName })
if (selectLocale && field.localized && adapter.payload.config.localization) {
selectFields._locale = aliasRelationshipTable.locale
}
// Join in the relationships table // Join in the relationships table
if (locale && field.localized && adapter.payload.config.localization) { if (locale && field.localized && adapter.payload.config.localization) {
const conditions = [ const conditions = [
@@ -365,6 +379,7 @@ export const getTableColumnFromPath = ({
if (locale !== 'all') { if (locale !== 'all') {
conditions.push(eq(aliasRelationshipTable.locale, locale)) conditions.push(eq(aliasRelationshipTable.locale, locale))
} }
joins.push({ joins.push({
condition: and(...conditions), condition: and(...conditions),
table: aliasRelationshipTable, table: aliasRelationshipTable,
@@ -523,6 +538,7 @@ export const getTableColumnFromPath = ({
pathSegments: pathSegments.slice(1), pathSegments: pathSegments.slice(1),
rootTableName: newTableName, rootTableName: newTableName,
selectFields, selectFields,
selectLocale,
tableName: newTableName, tableName: newTableName,
value, value,
}) })
@@ -545,6 +561,10 @@ export const getTableColumnFromPath = ({
const condtions = [eq(aliasLocaleTable._parentID, adapter.tables[rootTableName].id)] const condtions = [eq(aliasLocaleTable._parentID, adapter.tables[rootTableName].id)]
if (selectLocale) {
selectFields._locale = aliasLocaleTable._locale
}
if (locale !== 'all') { if (locale !== 'all') {
condtions.push(eq(aliasLocaleTable._locale, locale)) condtions.push(eq(aliasLocaleTable._locale, locale))
} }
@@ -643,6 +663,7 @@ export const getTableColumnFromPath = ({
pathSegments: pathSegments.slice(1), pathSegments: pathSegments.slice(1),
rootTableName, rootTableName,
selectFields, selectFields,
selectLocale,
tableName: newTableName, tableName: newTableName,
tableNameSuffix: `${tableNameSuffix}${toSnakeCase(field.name)}_`, tableNameSuffix: `${tableNameSuffix}${toSnakeCase(field.name)}_`,
value, value,
@@ -661,6 +682,7 @@ export const getTableColumnFromPath = ({
pathSegments: pathSegments.slice(1), pathSegments: pathSegments.slice(1),
rootTableName, rootTableName,
selectFields, selectFields,
selectLocale,
tableName: newTableName, tableName: newTableName,
tableNameSuffix, tableNameSuffix,
value, value,
@@ -689,6 +711,10 @@ export const getTableColumnFromPath = ({
condition = and(condition, eq(newTable._locale, locale)) condition = and(condition, eq(newTable._locale, locale))
} }
if (selectLocale) {
selectFields._locale = newTable._locale
}
addJoinTable({ addJoinTable({
condition, condition,
joins, joins,

View File

@@ -19,6 +19,7 @@ type Args = {
joins: BuildQueryJoinAliases joins: BuildQueryJoinAliases
locale: string locale: string
selectFields: Record<string, GenericColumn> selectFields: Record<string, GenericColumn>
selectLocale?: boolean
tableName: string tableName: string
where: Where where: Where
} }
@@ -29,6 +30,7 @@ export function parseParams({
joins, joins,
locale, locale,
selectFields, selectFields,
selectLocale,
tableName, tableName,
where, where,
}: Args): SQL { }: Args): SQL {
@@ -53,6 +55,7 @@ export function parseParams({
joins, joins,
locale, locale,
selectFields, selectFields,
selectLocale,
tableName, tableName,
where: condition, where: condition,
}) })
@@ -86,6 +89,7 @@ export function parseParams({
locale, locale,
pathSegments: relationOrPath.replace(/__/g, '.').split('.'), pathSegments: relationOrPath.replace(/__/g, '.').split('.'),
selectFields, selectFields,
selectLocale,
tableName, tableName,
value: val, value: val,
}) })

View File

@@ -8,6 +8,7 @@ import type { DrizzleAdapter } from '../types.js'
import { getCollectionIdType } from '../utilities/getCollectionIdType.js' import { getCollectionIdType } from '../utilities/getCollectionIdType.js'
import { isPolymorphicRelationship } from '../utilities/isPolymorphicRelationship.js' import { isPolymorphicRelationship } from '../utilities/isPolymorphicRelationship.js'
import { isRawConstraint } from '../utilities/rawConstraint.js'
type SanitizeQueryValueArgs = { type SanitizeQueryValueArgs = {
adapter: DrizzleAdapter adapter: DrizzleAdapter
@@ -48,6 +49,9 @@ export const sanitizeQueryValue = ({
return { operator, value: formattedValue } return { operator, value: formattedValue }
} }
if (isRawConstraint(val)) {
return { operator, value: val.value }
}
if ( if (
(field.type === 'relationship' || field.type === 'upload') && (field.type === 'relationship' || field.type === 'upload') &&
!relationOrPath.endsWith('relationTo') && !relationOrPath.endsWith('relationTo') &&

View File

@@ -389,20 +389,25 @@ export const traverseFields = <T extends Record<string, unknown>>({
| { docs: unknown[]; hasNextPage: boolean } | { docs: unknown[]; hasNextPage: boolean }
| Record<string, { docs: unknown[]; hasNextPage: boolean }> | Record<string, { docs: unknown[]; hasNextPage: boolean }>
if (Array.isArray(fieldData)) { if (Array.isArray(fieldData)) {
if (field.localized) { if (field.localized && adapter.payload.config.localization) {
fieldResult = fieldData.reduce((joinResult, row) => { fieldResult = fieldData.reduce(
if (typeof row._locale === 'string') { (joinResult, row) => {
if (!joinResult[row._locale]) { if (typeof row.locale === 'string') {
joinResult[row._locale] = { joinResult[row.locale].docs.push(row.id)
docs: [],
hasNextPage: false,
}
} }
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) => { Object.keys(fieldResult).forEach((locale) => {
fieldResult[locale].hasNextPage = fieldResult[locale].docs.length > limit fieldResult[locale].hasNextPage = fieldResult[locale].docs.length > limit
fieldResult[locale].docs = fieldResult[locale].docs.slice(0, limit) fieldResult[locale].docs = fieldResult[locale].docs.slice(0, limit)

View 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))
}

View 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
}

View File

@@ -6,13 +6,26 @@ const traverseArrayOrBlocksField = ({
callback, callback,
data, data,
field, field,
fillEmpty,
parentRef, parentRef,
}: { }: {
callback: TraverseFieldsCallback callback: TraverseFieldsCallback
data: Record<string, unknown>[] data: Record<string, unknown>[]
field: ArrayField | BlocksField field: ArrayField | BlocksField
fillEmpty: boolean
parentRef?: unknown 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) { for (const ref of data) {
let fields: Field[] let fields: Field[]
if (field.type === 'blocks' && typeof ref?.blockType === 'string') { if (field.type === 'blocks' && typeof ref?.blockType === 'string') {
@@ -23,7 +36,7 @@ const traverseArrayOrBlocksField = ({
} }
if (fields) { if (fields) {
traverseFields({ callback, fields, parentRef, ref }) traverseFields({ callback, fields, fillEmpty, parentRef, ref })
} }
} }
} }
@@ -50,7 +63,6 @@ export type TraverseFieldsCallback = (args: {
type TraverseFieldsArgs = { type TraverseFieldsArgs = {
callback: TraverseFieldsCallback callback: TraverseFieldsCallback
fields: (Field | TabAsField)[] fields: (Field | TabAsField)[]
/** fill empty properties to use this without data */
fillEmpty?: boolean fillEmpty?: boolean
parentRef?: Record<string, unknown> | unknown parentRef?: Record<string, unknown> | unknown
ref?: Record<string, unknown> | unknown ref?: Record<string, unknown> | unknown
@@ -61,8 +73,9 @@ type TraverseFieldsArgs = {
* *
* @param fields * @param fields
* @param callback callback called for each field, discontinue looping if callback returns truthy * @param callback callback called for each field, discontinue looping if callback returns truthy
* @param ref * @param fillEmpty fill empty properties to use this without data
* @param parentRef * @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 = ({ export const traverseFields = ({
callback, callback,
@@ -113,6 +126,7 @@ export const traverseFields = ({
traverseFields({ traverseFields({
callback, callback,
fields: tab.fields, fields: tab.fields,
fillEmpty,
parentRef: currentParentRef, parentRef: currentParentRef,
ref: tabRef[key], ref: tabRef[key],
}) })
@@ -137,6 +151,7 @@ export const traverseFields = ({
traverseFields({ traverseFields({
callback, callback,
fields: tab.fields, fields: tab.fields,
fillEmpty,
parentRef: currentParentRef, parentRef: currentParentRef,
ref: tabRef, ref: tabRef,
}) })
@@ -177,6 +192,7 @@ export const traverseFields = ({
traverseFields({ traverseFields({
callback, callback,
fields: field.fields, fields: field.fields,
fillEmpty,
parentRef: currentParentRef, parentRef: currentParentRef,
ref: currentRef[key], ref: currentRef[key],
}) })
@@ -205,6 +221,7 @@ export const traverseFields = ({
callback, callback,
data: localeData, data: localeData,
field, field,
fillEmpty,
parentRef: currentParentRef, parentRef: currentParentRef,
}) })
} }
@@ -213,6 +230,7 @@ export const traverseFields = ({
callback, callback,
data: currentRef as Record<string, unknown>[], data: currentRef as Record<string, unknown>[],
field, field,
fillEmpty,
parentRef: currentParentRef, parentRef: currentParentRef,
}) })
} }
@@ -220,6 +238,7 @@ export const traverseFields = ({
traverseFields({ traverseFields({
callback, callback,
fields: field.fields, fields: field.fields,
fillEmpty,
parentRef: currentParentRef, parentRef: currentParentRef,
ref: currentRef, ref: currentRef,
}) })

View File

@@ -1,18 +1,78 @@
'use client' '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 React, { useMemo } from 'react'
import { RelationshipTable } from '../../elements/RelationshipTable/index.js' import { RelationshipTable } from '../../elements/RelationshipTable/index.js'
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js' import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
import { useField } from '../../forms/useField/index.js' import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js' import { withCondition } from '../../forms/withCondition/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { FieldDescription } from '../FieldDescription/index.js' import { FieldDescription } from '../FieldDescription/index.js'
import { FieldLabel } from '../FieldLabel/index.js' import { FieldLabel } from '../FieldLabel/index.js'
import { fieldBaseClass } from '../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 JoinFieldComponent: JoinFieldClientComponent = (props) => {
const { const {
field, field,
@@ -29,6 +89,10 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
const { id: docID } = useDocumentInfo() const { id: docID } = useDocumentInfo()
const {
config: { collections },
} = useConfig()
const { customComponents: { AfterInput, BeforeInput, Description, Label } = {}, value } = const { customComponents: { AfterInput, BeforeInput, Description, Label } = {}, value } =
useField<PaginatedDocs>({ useField<PaginatedDocs>({
path, path,
@@ -54,6 +118,16 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
return where return where
}, [docID, on, field.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 ( return (
<div <div
className={[fieldBaseClass, 'join'].filter(Boolean).join(' ')} className={[fieldBaseClass, 'join'].filter(Boolean).join(' ')}
@@ -67,9 +141,7 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
field={field as JoinFieldClient} field={field as JoinFieldClient}
filterOptions={filterOptions} filterOptions={filterOptions}
initialData={docID && value ? value : ({ docs: [] } as PaginatedDocs)} initialData={docID && value ? value : ({ docs: [] } as PaginatedDocs)}
initialDrawerData={{ initialDrawerData={initialDrawerData}
[on]: docID,
}}
Label={ Label={
<h4 style={{ margin: 0 }}> <h4 style={{ margin: 0 }}>
{Label || ( {Label || (

View File

@@ -103,6 +103,12 @@ export const Categories: CollectionConfig = {
}, },
], ],
}, },
{
name: 'arrayPosts',
type: 'join',
collection: 'posts',
on: 'array.category',
},
{ {
name: 'singulars', name: 'singulars',
type: 'join', type: 'join',

View File

@@ -69,5 +69,16 @@ export const Posts: CollectionConfig = {
}, },
], ],
}, },
{
name: 'array',
type: 'array',
fields: [
{
name: 'category',
type: 'relationship',
relationTo: categoriesSlug,
},
],
},
], ],
} }

View File

@@ -94,6 +94,7 @@ describe('Joins Field', () => {
category: category.id, category: category.id,
camelCaseCategory: 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) 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 () => { it('should populate uploads in joins', async () => {
const { docs } = await payload.find({ const { docs } = await payload.find({
limit: 1, limit: 1,