feat: join field works with hasMany relationships (#8493)
Join field works on relationships and uploads having `hasMany: true` --------- Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com>
This commit is contained in:
@@ -70,6 +70,7 @@ export const init: Init = async function init(this: SQLiteAdapter) {
|
|||||||
disableNotNull: !!collection?.versions?.drafts,
|
disableNotNull: !!collection?.versions?.drafts,
|
||||||
disableUnique: false,
|
disableUnique: false,
|
||||||
fields: collection.fields,
|
fields: collection.fields,
|
||||||
|
joins: collection.joins,
|
||||||
locales,
|
locales,
|
||||||
tableName,
|
tableName,
|
||||||
timestamps: collection.timestamps,
|
timestamps: collection.timestamps,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
SQLiteTableWithColumns,
|
SQLiteTableWithColumns,
|
||||||
UniqueConstraintBuilder,
|
UniqueConstraintBuilder,
|
||||||
} from 'drizzle-orm/sqlite-core'
|
} from 'drizzle-orm/sqlite-core'
|
||||||
import type { Field } from 'payload'
|
import type { Field, SanitizedJoins } from 'payload'
|
||||||
|
|
||||||
import { createTableName } from '@payloadcms/drizzle'
|
import { createTableName } from '@payloadcms/drizzle'
|
||||||
import { relations, sql } from 'drizzle-orm'
|
import { relations, sql } from 'drizzle-orm'
|
||||||
@@ -58,6 +58,7 @@ type Args = {
|
|||||||
disableNotNull: boolean
|
disableNotNull: boolean
|
||||||
disableUnique: boolean
|
disableUnique: boolean
|
||||||
fields: Field[]
|
fields: Field[]
|
||||||
|
joins?: SanitizedJoins
|
||||||
locales?: [string, ...string[]]
|
locales?: [string, ...string[]]
|
||||||
rootRelationships?: Set<string>
|
rootRelationships?: Set<string>
|
||||||
rootRelationsToBuild?: RelationMap
|
rootRelationsToBuild?: RelationMap
|
||||||
@@ -89,6 +90,7 @@ export const buildTable = ({
|
|||||||
disableNotNull,
|
disableNotNull,
|
||||||
disableUnique = false,
|
disableUnique = false,
|
||||||
fields,
|
fields,
|
||||||
|
joins,
|
||||||
locales,
|
locales,
|
||||||
rootRelationships,
|
rootRelationships,
|
||||||
rootRelationsToBuild,
|
rootRelationsToBuild,
|
||||||
@@ -134,6 +136,7 @@ export const buildTable = ({
|
|||||||
disableUnique,
|
disableUnique,
|
||||||
fields,
|
fields,
|
||||||
indexes,
|
indexes,
|
||||||
|
joins,
|
||||||
locales,
|
locales,
|
||||||
localesColumns,
|
localesColumns,
|
||||||
localesIndexes,
|
localesIndexes,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Relation } from 'drizzle-orm'
|
import type { Relation } from 'drizzle-orm'
|
||||||
import type { IndexBuilder, SQLiteColumnBuilder } from 'drizzle-orm/sqlite-core'
|
import type { IndexBuilder, SQLiteColumnBuilder } from 'drizzle-orm/sqlite-core'
|
||||||
import type { Field, TabAsField } from 'payload'
|
import type { Field, SanitizedJoins, TabAsField } from 'payload'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createTableName,
|
createTableName,
|
||||||
@@ -41,6 +41,7 @@ type Args = {
|
|||||||
fields: (Field | TabAsField)[]
|
fields: (Field | TabAsField)[]
|
||||||
forceLocalized?: boolean
|
forceLocalized?: boolean
|
||||||
indexes: Record<string, (cols: GenericColumns) => IndexBuilder>
|
indexes: Record<string, (cols: GenericColumns) => IndexBuilder>
|
||||||
|
joins?: SanitizedJoins
|
||||||
locales: [string, ...string[]]
|
locales: [string, ...string[]]
|
||||||
localesColumns: Record<string, SQLiteColumnBuilder>
|
localesColumns: Record<string, SQLiteColumnBuilder>
|
||||||
localesIndexes: Record<string, (cols: GenericColumns) => IndexBuilder>
|
localesIndexes: Record<string, (cols: GenericColumns) => IndexBuilder>
|
||||||
@@ -78,6 +79,7 @@ export const traverseFields = ({
|
|||||||
fields,
|
fields,
|
||||||
forceLocalized,
|
forceLocalized,
|
||||||
indexes,
|
indexes,
|
||||||
|
joins,
|
||||||
locales,
|
locales,
|
||||||
localesColumns,
|
localesColumns,
|
||||||
localesIndexes,
|
localesIndexes,
|
||||||
@@ -651,6 +653,7 @@ export const traverseFields = ({
|
|||||||
fields: field.fields,
|
fields: field.fields,
|
||||||
forceLocalized,
|
forceLocalized,
|
||||||
indexes,
|
indexes,
|
||||||
|
joins,
|
||||||
locales,
|
locales,
|
||||||
localesColumns,
|
localesColumns,
|
||||||
localesIndexes,
|
localesIndexes,
|
||||||
@@ -705,6 +708,7 @@ export const traverseFields = ({
|
|||||||
fields: field.fields,
|
fields: field.fields,
|
||||||
forceLocalized: field.localized,
|
forceLocalized: field.localized,
|
||||||
indexes,
|
indexes,
|
||||||
|
joins,
|
||||||
locales,
|
locales,
|
||||||
localesColumns,
|
localesColumns,
|
||||||
localesIndexes,
|
localesIndexes,
|
||||||
@@ -760,6 +764,7 @@ export const traverseFields = ({
|
|||||||
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
|
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
|
||||||
forceLocalized,
|
forceLocalized,
|
||||||
indexes,
|
indexes,
|
||||||
|
joins,
|
||||||
locales,
|
locales,
|
||||||
localesColumns,
|
localesColumns,
|
||||||
localesIndexes,
|
localesIndexes,
|
||||||
@@ -815,6 +820,7 @@ export const traverseFields = ({
|
|||||||
fields: field.fields,
|
fields: field.fields,
|
||||||
forceLocalized,
|
forceLocalized,
|
||||||
indexes,
|
indexes,
|
||||||
|
joins,
|
||||||
locales,
|
locales,
|
||||||
localesColumns,
|
localesColumns,
|
||||||
localesIndexes,
|
localesIndexes,
|
||||||
@@ -905,9 +911,18 @@ export const traverseFields = ({
|
|||||||
|
|
||||||
case 'join': {
|
case 'join': {
|
||||||
// fieldName could be 'posts' or 'group_posts'
|
// fieldName could be 'posts' or 'group_posts'
|
||||||
// using on as the key for the relation
|
// using `on` as the key for the relation
|
||||||
const localized = adapter.payload.config.localization && field.localized
|
const localized = adapter.payload.config.localization && field.localized
|
||||||
const target = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${localized ? adapter.localesSuffix : ''}`
|
const fieldSchemaPath = `${fieldPrefix || ''}${field.name}`
|
||||||
|
let target: string
|
||||||
|
const joinConfig = joins[field.collection].find(
|
||||||
|
({ schemaPath }) => fieldSchemaPath === schemaPath,
|
||||||
|
)
|
||||||
|
if (joinConfig.targetField.hasMany) {
|
||||||
|
target = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${adapter.relationshipsSuffix}`
|
||||||
|
} else {
|
||||||
|
target = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${localized ? adapter.localesSuffix : ''}`
|
||||||
|
}
|
||||||
relationsToBuild.set(fieldName, {
|
relationsToBuild.set(fieldName, {
|
||||||
type: 'many',
|
type: 'many',
|
||||||
// joins are not localized on the parent table
|
// joins are not localized on the parent table
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const buildFindManyArgs = ({
|
|||||||
tableName,
|
tableName,
|
||||||
}: BuildFindQueryArgs): Record<string, unknown> => {
|
}: BuildFindQueryArgs): Record<string, unknown> => {
|
||||||
const result: Result = {
|
const result: Result = {
|
||||||
|
extras: {},
|
||||||
with: {},
|
with: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export const buildFindManyArgs = ({
|
|||||||
id: false,
|
id: false,
|
||||||
_parentID: false,
|
_parentID: false,
|
||||||
},
|
},
|
||||||
|
extras: {},
|
||||||
with: {},
|
with: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { DBQueryConfig } from 'drizzle-orm'
|
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
||||||
import type { Field, JoinQuery } from 'payload'
|
import type { Field, JoinQuery } from 'payload'
|
||||||
|
|
||||||
|
import { and, type DBQueryConfig, eq, sql } from 'drizzle-orm'
|
||||||
import { fieldAffectsData, fieldIsVirtual, tabHasName } from 'payload/shared'
|
import { fieldAffectsData, fieldIsVirtual, tabHasName } from 'payload/shared'
|
||||||
import toSnakeCase from 'to-snake-case'
|
import toSnakeCase from 'to-snake-case'
|
||||||
|
|
||||||
import type { BuildQueryJoinAliases, DrizzleAdapter } from '../types.js'
|
import type { BuildQueryJoinAliases, ChainedMethods, DrizzleAdapter } from '../types.js'
|
||||||
import type { Result } from './buildFindManyArgs.js'
|
import type { Result } from './buildFindManyArgs.js'
|
||||||
|
|
||||||
import { buildOrderBy } from '../queries/buildOrderBy.js'
|
|
||||||
import buildQuery from '../queries/buildQuery.js'
|
import buildQuery from '../queries/buildQuery.js'
|
||||||
|
import { chainMethods } from './chainMethods.js'
|
||||||
|
|
||||||
type TraverseFieldArgs = {
|
type TraverseFieldArgs = {
|
||||||
_locales: Result
|
_locales: Result
|
||||||
@@ -241,24 +242,93 @@ export const traverseFields = ({
|
|||||||
// get an additional document and slice it later to determine if there is a next page
|
// get an additional document and slice it later to determine if there is a next page
|
||||||
limit += 1
|
limit += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields = adapter.payload.collections[field.collection].config.fields
|
const fields = adapter.payload.collections[field.collection].config.fields
|
||||||
|
const joinCollectionTableName = adapter.tableNameMap.get(toSnakeCase(field.collection))
|
||||||
const joinTableName = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${
|
const joinTableName = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${
|
||||||
field.localized && adapter.payload.config.localization ? adapter.localesSuffix : ''
|
field.localized && adapter.payload.config.localization ? adapter.localesSuffix : ''
|
||||||
}`
|
}`
|
||||||
const selectFields = {}
|
|
||||||
|
|
||||||
const orderBy = buildOrderBy({
|
if (!adapter.tables[joinTableName][field.on]) {
|
||||||
|
const db = adapter.drizzle as LibSQLDatabase
|
||||||
|
const joinTable = `${joinTableName}${adapter.relationshipsSuffix}`
|
||||||
|
|
||||||
|
const joins: BuildQueryJoinAliases = [
|
||||||
|
{
|
||||||
|
type: 'innerJoin',
|
||||||
|
condition: and(
|
||||||
|
eq(adapter.tables[joinTable].parent, adapter.tables[joinTableName].id),
|
||||||
|
eq(
|
||||||
|
sql.raw(`"${joinTable}"."${topLevelTableName}_id"`),
|
||||||
|
adapter.tables[currentTableName].id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
table: adapter.tables[joinTable],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const { orderBy, where: subQueryWhere } = buildQuery({
|
||||||
adapter,
|
adapter,
|
||||||
fields,
|
fields,
|
||||||
joins: [],
|
joins,
|
||||||
locale,
|
locale,
|
||||||
selectFields,
|
|
||||||
sort,
|
sort,
|
||||||
tableName: joinTableName,
|
tableName: joinCollectionTableName,
|
||||||
|
where: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const chainedMethods: ChainedMethods = []
|
||||||
|
|
||||||
|
joins.forEach(({ type, condition, table }) => {
|
||||||
|
chainedMethods.push({
|
||||||
|
args: [table, condition],
|
||||||
|
method: type ?? 'leftJoin',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const subQuery = chainMethods({
|
||||||
|
methods: chainedMethods,
|
||||||
|
query: db
|
||||||
|
.select({
|
||||||
|
id: adapter.tables[joinTableName].id,
|
||||||
|
})
|
||||||
|
.from(adapter.tables[joinTableName])
|
||||||
|
.where(subQueryWhere)
|
||||||
|
.orderBy(orderBy.order(orderBy.column))
|
||||||
|
.limit(11),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columnName = `${path.replaceAll('.', '_')}${field.name}`
|
||||||
|
|
||||||
|
const extras = field.localized ? _locales.extras : currentArgs.extras
|
||||||
|
|
||||||
|
if (adapter.name === 'sqlite') {
|
||||||
|
extras[columnName] = sql`
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_group_array("id")
|
||||||
|
FROM (
|
||||||
|
${subQuery}
|
||||||
|
) AS ${sql.raw(`${columnName}_sub`)}
|
||||||
|
), '[]')
|
||||||
|
`.as(columnName)
|
||||||
|
} else {
|
||||||
|
extras[columnName] = sql`
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg("id")
|
||||||
|
FROM (
|
||||||
|
${subQuery}
|
||||||
|
) AS ${sql.raw(`${columnName}_sub`)}
|
||||||
|
), '[]'::json)
|
||||||
|
`.as(columnName)
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectFields = {}
|
||||||
|
|
||||||
const withJoin: DBQueryConfig<'many', true, any, any> = {
|
const withJoin: DBQueryConfig<'many', true, any, any> = {
|
||||||
columns: selectFields,
|
columns: selectFields,
|
||||||
orderBy: () => [orderBy.order(orderBy.column)],
|
|
||||||
}
|
}
|
||||||
if (limit) {
|
if (limit) {
|
||||||
withJoin.limit = limit
|
withJoin.limit = limit
|
||||||
@@ -269,10 +339,9 @@ export const traverseFields = ({
|
|||||||
withJoin.columns._parentID = true
|
withJoin.columns._parentID = true
|
||||||
} else {
|
} else {
|
||||||
withJoin.columns.id = true
|
withJoin.columns.id = true
|
||||||
|
withJoin.columns.parent = true
|
||||||
}
|
}
|
||||||
|
const { orderBy, where: joinWhere } = buildQuery({
|
||||||
if (where) {
|
|
||||||
const { where: joinWhere } = buildQuery({
|
|
||||||
adapter,
|
adapter,
|
||||||
fields,
|
fields,
|
||||||
joins,
|
joins,
|
||||||
@@ -281,8 +350,10 @@ export const traverseFields = ({
|
|||||||
tableName: joinTableName,
|
tableName: joinTableName,
|
||||||
where,
|
where,
|
||||||
})
|
})
|
||||||
|
if (joinWhere) {
|
||||||
withJoin.where = () => joinWhere
|
withJoin.where = () => joinWhere
|
||||||
}
|
}
|
||||||
|
withJoin.orderBy = orderBy.order(orderBy.column)
|
||||||
currentArgs.with[`${path.replaceAll('.', '_')}${field.name}`] = withJoin
|
currentArgs.with[`${path.replaceAll('.', '_')}${field.name}`] = withJoin
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const init: Init = async function init(this: BasePostgresAdapter) {
|
|||||||
disableNotNull: !!collection?.versions?.drafts,
|
disableNotNull: !!collection?.versions?.drafts,
|
||||||
disableUnique: false,
|
disableUnique: false,
|
||||||
fields: collection.fields,
|
fields: collection.fields,
|
||||||
|
joins: collection.joins,
|
||||||
tableName,
|
tableName,
|
||||||
timestamps: collection.timestamps,
|
timestamps: collection.timestamps,
|
||||||
versions: false,
|
versions: false,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
PgColumnBuilder,
|
PgColumnBuilder,
|
||||||
PgTableWithColumns,
|
PgTableWithColumns,
|
||||||
} from 'drizzle-orm/pg-core'
|
} from 'drizzle-orm/pg-core'
|
||||||
import type { Field } from 'payload'
|
import type { Field, SanitizedJoins } from 'payload'
|
||||||
|
|
||||||
import { relations } from 'drizzle-orm'
|
import { relations } from 'drizzle-orm'
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +47,7 @@ type Args = {
|
|||||||
disableNotNull: boolean
|
disableNotNull: boolean
|
||||||
disableUnique: boolean
|
disableUnique: boolean
|
||||||
fields: Field[]
|
fields: Field[]
|
||||||
|
joins?: SanitizedJoins
|
||||||
rootRelationships?: Set<string>
|
rootRelationships?: Set<string>
|
||||||
rootRelationsToBuild?: RelationMap
|
rootRelationsToBuild?: RelationMap
|
||||||
rootTableIDColType?: string
|
rootTableIDColType?: string
|
||||||
@@ -77,6 +78,7 @@ export const buildTable = ({
|
|||||||
disableNotNull,
|
disableNotNull,
|
||||||
disableUnique = false,
|
disableUnique = false,
|
||||||
fields,
|
fields,
|
||||||
|
joins,
|
||||||
rootRelationships,
|
rootRelationships,
|
||||||
rootRelationsToBuild,
|
rootRelationsToBuild,
|
||||||
rootTableIDColType,
|
rootTableIDColType,
|
||||||
@@ -121,6 +123,7 @@ export const buildTable = ({
|
|||||||
disableUnique,
|
disableUnique,
|
||||||
fields,
|
fields,
|
||||||
indexes,
|
indexes,
|
||||||
|
joins,
|
||||||
localesColumns,
|
localesColumns,
|
||||||
localesIndexes,
|
localesIndexes,
|
||||||
newTableName: tableName,
|
newTableName: tableName,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Relation } from 'drizzle-orm'
|
import type { Relation } from 'drizzle-orm'
|
||||||
import type { IndexBuilder, PgColumnBuilder } from 'drizzle-orm/pg-core'
|
import type { IndexBuilder, PgColumnBuilder } from 'drizzle-orm/pg-core'
|
||||||
import type { Field, TabAsField } from 'payload'
|
import type { Field, SanitizedJoins, TabAsField } from 'payload'
|
||||||
|
|
||||||
import { relations } from 'drizzle-orm'
|
import { relations } from 'drizzle-orm'
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +48,7 @@ type Args = {
|
|||||||
fields: (Field | TabAsField)[]
|
fields: (Field | TabAsField)[]
|
||||||
forceLocalized?: boolean
|
forceLocalized?: boolean
|
||||||
indexes: Record<string, (cols: GenericColumns) => IndexBuilder>
|
indexes: Record<string, (cols: GenericColumns) => IndexBuilder>
|
||||||
|
joins?: SanitizedJoins
|
||||||
localesColumns: Record<string, PgColumnBuilder>
|
localesColumns: Record<string, PgColumnBuilder>
|
||||||
localesIndexes: Record<string, (cols: GenericColumns) => IndexBuilder>
|
localesIndexes: Record<string, (cols: GenericColumns) => IndexBuilder>
|
||||||
newTableName: string
|
newTableName: string
|
||||||
@@ -84,6 +85,7 @@ export const traverseFields = ({
|
|||||||
fields,
|
fields,
|
||||||
forceLocalized,
|
forceLocalized,
|
||||||
indexes,
|
indexes,
|
||||||
|
joins,
|
||||||
localesColumns,
|
localesColumns,
|
||||||
localesIndexes,
|
localesIndexes,
|
||||||
newTableName,
|
newTableName,
|
||||||
@@ -658,6 +660,7 @@ export const traverseFields = ({
|
|||||||
fields: field.fields,
|
fields: field.fields,
|
||||||
forceLocalized,
|
forceLocalized,
|
||||||
indexes,
|
indexes,
|
||||||
|
joins,
|
||||||
localesColumns,
|
localesColumns,
|
||||||
localesIndexes,
|
localesIndexes,
|
||||||
newTableName,
|
newTableName,
|
||||||
@@ -711,6 +714,7 @@ export const traverseFields = ({
|
|||||||
fields: field.fields,
|
fields: field.fields,
|
||||||
forceLocalized: field.localized,
|
forceLocalized: field.localized,
|
||||||
indexes,
|
indexes,
|
||||||
|
joins,
|
||||||
localesColumns,
|
localesColumns,
|
||||||
localesIndexes,
|
localesIndexes,
|
||||||
newTableName: `${parentTableName}_${columnName}`,
|
newTableName: `${parentTableName}_${columnName}`,
|
||||||
@@ -765,6 +769,7 @@ export const traverseFields = ({
|
|||||||
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
|
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
|
||||||
forceLocalized,
|
forceLocalized,
|
||||||
indexes,
|
indexes,
|
||||||
|
joins,
|
||||||
localesColumns,
|
localesColumns,
|
||||||
localesIndexes,
|
localesIndexes,
|
||||||
newTableName,
|
newTableName,
|
||||||
@@ -819,6 +824,7 @@ export const traverseFields = ({
|
|||||||
fields: field.fields,
|
fields: field.fields,
|
||||||
forceLocalized,
|
forceLocalized,
|
||||||
indexes,
|
indexes,
|
||||||
|
joins,
|
||||||
localesColumns,
|
localesColumns,
|
||||||
localesIndexes,
|
localesIndexes,
|
||||||
newTableName,
|
newTableName,
|
||||||
@@ -908,9 +914,18 @@ export const traverseFields = ({
|
|||||||
|
|
||||||
case 'join': {
|
case 'join': {
|
||||||
// fieldName could be 'posts' or 'group_posts'
|
// fieldName could be 'posts' or 'group_posts'
|
||||||
// using on as the key for the relation
|
// using `on` as the key for the relation
|
||||||
const localized = adapter.payload.config.localization && field.localized
|
const localized = adapter.payload.config.localization && field.localized
|
||||||
const target = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${localized ? adapter.localesSuffix : ''}`
|
const fieldSchemaPath = `${fieldPrefix || ''}${field.name}`
|
||||||
|
let target: string
|
||||||
|
const joinConfig = joins[field.collection].find(
|
||||||
|
({ schemaPath }) => fieldSchemaPath === schemaPath,
|
||||||
|
)
|
||||||
|
if (joinConfig.targetField.hasMany) {
|
||||||
|
target = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${adapter.relationshipsSuffix}`
|
||||||
|
} else {
|
||||||
|
target = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${localized ? adapter.localesSuffix : ''}`
|
||||||
|
}
|
||||||
relationsToBuild.set(fieldName, {
|
relationsToBuild.set(fieldName, {
|
||||||
type: 'many',
|
type: 'many',
|
||||||
// joins are not localized on the parent table
|
// joins are not localized on the parent table
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { parseParams } from './parseParams.js'
|
|||||||
export type BuildQueryJoinAliases = {
|
export type BuildQueryJoinAliases = {
|
||||||
condition: SQL
|
condition: SQL
|
||||||
table: GenericTable | PgTableWithColumns<any>
|
table: GenericTable | PgTableWithColumns<any>
|
||||||
|
type?: 'innerJoin' | 'leftJoin' | 'rightJoin'
|
||||||
}[]
|
}[]
|
||||||
|
|
||||||
type BuildQueryArgs = {
|
type BuildQueryArgs = {
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export const traverseFields = <T extends Record<string, unknown>>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fieldName = `${fieldPrefix || ''}${field.name}`
|
const fieldName = `${fieldPrefix || ''}${field.name}`
|
||||||
const fieldData = table[fieldName]
|
let fieldData = table[fieldName]
|
||||||
const localizedFieldData = {}
|
const localizedFieldData = {}
|
||||||
const valuesToTransform: {
|
const valuesToTransform: {
|
||||||
ref: Record<string, unknown>
|
ref: Record<string, unknown>
|
||||||
@@ -422,6 +422,11 @@ export const traverseFields = <T extends Record<string, unknown>>({
|
|||||||
if (field.type === 'join') {
|
if (field.type === 'join') {
|
||||||
const { limit = 10 } = joinQuery?.[`${fieldPrefix.replaceAll('_', '.')}${field.name}`] || {}
|
const { limit = 10 } = joinQuery?.[`${fieldPrefix.replaceAll('_', '.')}${field.name}`] || {}
|
||||||
|
|
||||||
|
// raw hasMany results from SQLite
|
||||||
|
if (typeof fieldData === 'string') {
|
||||||
|
fieldData = JSON.parse(fieldData)
|
||||||
|
}
|
||||||
|
|
||||||
let fieldResult:
|
let fieldResult:
|
||||||
| { docs: unknown[]; hasNextPage: boolean }
|
| { docs: unknown[]; hasNextPage: boolean }
|
||||||
| Record<string, { docs: unknown[]; hasNextPage: boolean }>
|
| Record<string, { docs: unknown[]; hasNextPage: boolean }>
|
||||||
@@ -447,7 +452,9 @@ export const traverseFields = <T extends Record<string, unknown>>({
|
|||||||
} else {
|
} else {
|
||||||
const hasNextPage = limit !== 0 && fieldData.length > limit
|
const hasNextPage = limit !== 0 && fieldData.length > limit
|
||||||
fieldResult = {
|
fieldResult = {
|
||||||
docs: hasNextPage ? fieldData.slice(0, limit) : fieldData,
|
docs: (hasNextPage ? fieldData.slice(0, limit) : fieldData).map((objOrID) => ({
|
||||||
|
id: typeof objOrID === 'object' ? objOrID.id : objOrID,
|
||||||
|
})),
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import type {
|
|||||||
StaticLabel,
|
StaticLabel,
|
||||||
} from '../../config/types.js'
|
} from '../../config/types.js'
|
||||||
import type { DBIdentifierName } from '../../database/types.js'
|
import type { DBIdentifierName } from '../../database/types.js'
|
||||||
import type { Field, JoinField } from '../../fields/config/types.js'
|
import type { Field, JoinField, RelationshipField, UploadField } from '../../fields/config/types.js'
|
||||||
import type {
|
import type {
|
||||||
CollectionSlug,
|
CollectionSlug,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
@@ -485,6 +485,7 @@ export type SanitizedJoin = {
|
|||||||
* The schemaPath of the join field in dot notation
|
* The schemaPath of the join field in dot notation
|
||||||
*/
|
*/
|
||||||
schemaPath: string
|
schemaPath: string
|
||||||
|
targetField: RelationshipField | UploadField
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SanitizedJoins = {
|
export type SanitizedJoins = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SanitizedJoins } from '../../collections/config/types.js'
|
import type { SanitizedJoin, SanitizedJoins } from '../../collections/config/types.js'
|
||||||
import type { Config } from '../../config/types.js'
|
import type { Config } from '../../config/types.js'
|
||||||
import type { JoinField, RelationshipField, UploadField } from './types.js'
|
import type { JoinField, RelationshipField, UploadField } from './types.js'
|
||||||
|
|
||||||
@@ -23,9 +23,10 @@ export const sanitizeJoinField = ({
|
|||||||
if (!field.maxDepth) {
|
if (!field.maxDepth) {
|
||||||
field.maxDepth = 1
|
field.maxDepth = 1
|
||||||
}
|
}
|
||||||
const join = {
|
const join: SanitizedJoin = {
|
||||||
field,
|
field,
|
||||||
schemaPath: `${schemaPath || ''}${schemaPath ? '.' : ''}${field.name}`,
|
schemaPath: `${schemaPath || ''}${schemaPath ? '.' : ''}${field.name}`,
|
||||||
|
targetField: undefined,
|
||||||
}
|
}
|
||||||
const joinCollection = config.collections.find(
|
const joinCollection = config.collections.find(
|
||||||
(collection) => collection.slug === field.collection,
|
(collection) => collection.slug === field.collection,
|
||||||
@@ -73,11 +74,12 @@ export const sanitizeJoinField = ({
|
|||||||
if (!joinRelationship) {
|
if (!joinRelationship) {
|
||||||
throw new InvalidFieldJoin(join.field)
|
throw new InvalidFieldJoin(join.field)
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(joinRelationship.relationTo)) {
|
||||||
if (joinRelationship.hasMany) {
|
throw new APIError('Join fields cannot be used with polymorphic relationships.')
|
||||||
throw new APIError('Join fields cannot be used with hasMany relationships.')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
join.targetField = joinRelationship
|
||||||
|
|
||||||
// override the join field localized property to use whatever the relationship field has
|
// override the join field localized property to use whatever the relationship field has
|
||||||
field.localized = joinRelationship.localized
|
field.localized = joinRelationship.localized
|
||||||
|
|
||||||
|
|||||||
@@ -725,6 +725,7 @@ export type {
|
|||||||
RequiredDataFromCollection,
|
RequiredDataFromCollection,
|
||||||
RequiredDataFromCollectionSlug,
|
RequiredDataFromCollectionSlug,
|
||||||
SanitizedCollectionConfig,
|
SanitizedCollectionConfig,
|
||||||
|
SanitizedJoins,
|
||||||
TypeWithID,
|
TypeWithID,
|
||||||
TypeWithTimestamps,
|
TypeWithTimestamps,
|
||||||
} from './collections/config/types.js'
|
} from './collections/config/types.js'
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export const Categories: CollectionConfig = {
|
|||||||
collection: postsSlug,
|
collection: postsSlug,
|
||||||
on: 'category',
|
on: 'category',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'hasManyPosts',
|
||||||
|
type: 'join',
|
||||||
|
collection: postsSlug,
|
||||||
|
on: 'categories',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'group',
|
name: 'group',
|
||||||
type: 'group',
|
type: 'group',
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ export const Posts: CollectionConfig = {
|
|||||||
type: 'relationship',
|
type: 'relationship',
|
||||||
relationTo: categoriesSlug,
|
relationTo: categoriesSlug,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'categories',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: categoriesSlug,
|
||||||
|
hasMany: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'group',
|
name: 'group',
|
||||||
type: 'group',
|
type: 'group',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const { email, password } = devUser
|
|||||||
|
|
||||||
describe('Joins Field', () => {
|
describe('Joins Field', () => {
|
||||||
let category: Category
|
let category: Category
|
||||||
|
let otherCategory: Category
|
||||||
let categoryID
|
let categoryID
|
||||||
// --__--__--__--__--__--__--__--__--__
|
// --__--__--__--__--__--__--__--__--__
|
||||||
// Boilerplate test setup/teardown
|
// Boilerplate test setup/teardown
|
||||||
@@ -49,6 +50,14 @@ describe('Joins Field', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
otherCategory = await payload.create({
|
||||||
|
collection: categoriesSlug,
|
||||||
|
data: {
|
||||||
|
name: 'otherCategory',
|
||||||
|
group: {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// create an upload
|
// create an upload
|
||||||
const imageFilePath = path.resolve(dirname, './image.png')
|
const imageFilePath = path.resolve(dirname, './image.png')
|
||||||
const imageFile = await getFileByPath(imageFilePath)
|
const imageFile = await getFileByPath(imageFilePath)
|
||||||
@@ -62,10 +71,15 @@ describe('Joins Field', () => {
|
|||||||
categoryID = idToString(category.id, payload)
|
categoryID = idToString(category.id, payload)
|
||||||
|
|
||||||
for (let i = 0; i < 15; i++) {
|
for (let i = 0; i < 15; i++) {
|
||||||
|
let categories = [category.id]
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
categories = [category.id, otherCategory.id]
|
||||||
|
}
|
||||||
await createPost({
|
await createPost({
|
||||||
title: `test ${i}`,
|
title: `test ${i}`,
|
||||||
category: category.id,
|
category: category.id,
|
||||||
upload: uploadedImage,
|
upload: uploadedImage,
|
||||||
|
categories,
|
||||||
group: {
|
group: {
|
||||||
category: category.id,
|
category: category.id,
|
||||||
camelCaseCategory: category.id,
|
camelCaseCategory: category.id,
|
||||||
@@ -90,6 +104,15 @@ describe('Joins Field', () => {
|
|||||||
},
|
},
|
||||||
collection: 'categories',
|
collection: 'categories',
|
||||||
})
|
})
|
||||||
|
// const sortCategoryWithPosts = await payload.findByID({
|
||||||
|
// id: category.id,
|
||||||
|
// joins: {
|
||||||
|
// 'group.relatedPosts': {
|
||||||
|
// sort: 'title',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// collection: 'categories',
|
||||||
|
// })
|
||||||
|
|
||||||
expect(categoryWithPosts.group.relatedPosts.docs).toHaveLength(10)
|
expect(categoryWithPosts.group.relatedPosts.docs).toHaveLength(10)
|
||||||
expect(categoryWithPosts.group.relatedPosts.docs[0]).toHaveProperty('id')
|
expect(categoryWithPosts.group.relatedPosts.docs[0]).toHaveProperty('id')
|
||||||
@@ -164,6 +187,31 @@ describe('Joins Field', () => {
|
|||||||
expect(categoryWithPosts.group.relatedPosts.docs[0].title).toBe('test 14')
|
expect(categoryWithPosts.group.relatedPosts.docs[0].title).toBe('test 14')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should populate joins using find with hasMany relationships', async () => {
|
||||||
|
const result = await payload.find({
|
||||||
|
collection: 'categories',
|
||||||
|
where: {
|
||||||
|
id: { equals: category.id },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const otherResult = await payload.find({
|
||||||
|
collection: 'categories',
|
||||||
|
where: {
|
||||||
|
id: { equals: otherCategory.id },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [categoryWithPosts] = result.docs
|
||||||
|
const [otherCategoryWithPosts] = otherResult.docs
|
||||||
|
|
||||||
|
expect(categoryWithPosts.hasManyPosts.docs).toHaveLength(10)
|
||||||
|
expect(categoryWithPosts.hasManyPosts.docs[0]).toHaveProperty('title')
|
||||||
|
expect(categoryWithPosts.hasManyPosts.docs[0].title).toBe('test 14')
|
||||||
|
expect(otherCategoryWithPosts.hasManyPosts.docs).toHaveLength(8)
|
||||||
|
expect(otherCategoryWithPosts.hasManyPosts.docs[0]).toHaveProperty('title')
|
||||||
|
expect(otherCategoryWithPosts.hasManyPosts.docs[0].title).toBe('test 14')
|
||||||
|
})
|
||||||
|
|
||||||
it('should not error when deleting documents with joins', async () => {
|
it('should not error when deleting documents with joins', async () => {
|
||||||
const category = await payload.create({
|
const category = await payload.create({
|
||||||
collection: 'categories',
|
collection: 'categories',
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface Post {
|
|||||||
title?: string | null;
|
title?: string | null;
|
||||||
upload?: (string | null) | Upload;
|
upload?: (string | null) | Upload;
|
||||||
category?: (string | null) | Category;
|
category?: (string | null) | Category;
|
||||||
|
categories?: (string | Category)[] | null;
|
||||||
group?: {
|
group?: {
|
||||||
category?: (string | null) | Category;
|
category?: (string | null) | Category;
|
||||||
camelCaseCategory?: (string | null) | Category;
|
camelCaseCategory?: (string | null) | Category;
|
||||||
@@ -97,6 +98,10 @@ export interface Category {
|
|||||||
docs?: (string | Post)[] | null;
|
docs?: (string | Post)[] | null;
|
||||||
hasNextPage?: boolean | null;
|
hasNextPage?: boolean | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
hasManyPosts?: {
|
||||||
|
docs?: (string | Post)[] | null;
|
||||||
|
hasNextPage?: boolean | null;
|
||||||
|
} | null;
|
||||||
group?: {
|
group?: {
|
||||||
relatedPosts?: {
|
relatedPosts?: {
|
||||||
docs?: (string | Post)[] | null;
|
docs?: (string | Post)[] | null;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
} from './payload-types.js'
|
} from './payload-types.js'
|
||||||
|
|
||||||
import { initPayloadInt } from '../helpers/initPayloadInt.js'
|
import { initPayloadInt } from '../helpers/initPayloadInt.js'
|
||||||
|
import { isMongoose } from '../helpers/isMongoose.js'
|
||||||
import {
|
import {
|
||||||
chainedRelSlug,
|
chainedRelSlug,
|
||||||
customIdNumberSlug,
|
customIdNumberSlug,
|
||||||
@@ -397,6 +398,52 @@ describe('Relationships', () => {
|
|||||||
expect(query2.totalDocs).toStrictEqual(2)
|
expect(query2.totalDocs).toStrictEqual(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should sort by a property of a hasMany relationship', async () => {
|
||||||
|
// no support for sort by relation in mongodb
|
||||||
|
if (isMongoose(payload)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const movie1 = await payload.create({
|
||||||
|
collection: 'movies',
|
||||||
|
data: {
|
||||||
|
name: 'Pulp Fiction',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const movie2 = await payload.create({
|
||||||
|
collection: 'movies',
|
||||||
|
data: {
|
||||||
|
name: 'Inception',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.delete({ collection: 'directors', where: {} })
|
||||||
|
|
||||||
|
const director1 = await payload.create({
|
||||||
|
collection: 'directors',
|
||||||
|
data: {
|
||||||
|
name: 'Quentin Tarantino',
|
||||||
|
movies: [movie1.id],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const director2 = await payload.create({
|
||||||
|
collection: 'directors',
|
||||||
|
data: {
|
||||||
|
name: 'Christopher Nolan',
|
||||||
|
movies: [movie2.id],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await payload.find({
|
||||||
|
collection: 'directors',
|
||||||
|
depth: 0,
|
||||||
|
sort: '-movies.name',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.docs[0].id).toStrictEqual(director1.id)
|
||||||
|
})
|
||||||
|
|
||||||
it('should query using "in" by hasMany relationship field', async () => {
|
it('should query using "in" by hasMany relationship field', async () => {
|
||||||
const tree1 = await payload.create({
|
const tree1 = await payload.create({
|
||||||
collection: treeSlug,
|
collection: treeSlug,
|
||||||
|
|||||||
Reference in New Issue
Block a user