fix(db-postgres): joins to self collection (#10182)
### What?
With Postgres, before join to self like:
```ts
import type { CollectionConfig } from 'payload'
export const SelfJoins: CollectionConfig = {
slug: 'self-joins',
fields: [
{
name: 'rel',
type: 'relationship',
relationTo: 'self-joins',
},
{
name: 'joins',
type: 'join',
on: 'rel',
collection: 'self-joins',
},
],
}
```
wasn't possible, even though it's a valid usage.
### How?
Now, to differentiate parent `self_joins` and children `self_joins` we
do additional alias for the nested select -
`"4d3cf2b6_1adf_46a8_b6d2_3e1c3809d737"`:
```sql
select
"id",
"rel_id",
"updated_at",
"created_at",
(
select
coalesce(
json_agg(
json_build_object('id', "joins_alias".id)
),
'[]' :: json
)
from
(
select
"created_at",
"rel_id",
"id"
from
"self_joins" "4d3cf2b6_1adf_46a8_b6d2_3e1c3809d737"
where
"4d3cf2b6_1adf_46a8_b6d2_3e1c3809d737"."rel_id" = "self_joins"."id"
order by
"4d3cf2b6_1adf_46a8_b6d2_3e1c3809d737"."created_at" desc
limit
$1
) "joins_alias"
) as "joins_alias"
from
"self_joins"
where
"self_joins"."id" = $2
order by
"self_joins"."created_at" desc
limit
$3
```
Fixes https://github.com/payloadcms/payload/issues/10144
-->
This commit is contained in:
@@ -9,6 +9,8 @@ import type { BuildQueryJoinAliases, ChainedMethods, DrizzleAdapter } from '../t
|
||||
import type { Result } from './buildFindManyArgs.js'
|
||||
|
||||
import buildQuery from '../queries/buildQuery.js'
|
||||
import { getTableAlias } from '../queries/getTableAlias.js'
|
||||
import { getNameFromDrizzleTable } from '../utilities/getNameFromDrizzleTable.js'
|
||||
import { jsonAggBuildObject } from '../utilities/json.js'
|
||||
import { rawConstraint } from '../utilities/rawConstraint.js'
|
||||
import { chainMethods } from './chainMethods.js'
|
||||
@@ -385,12 +387,22 @@ export const traverseFields = ({
|
||||
}
|
||||
}
|
||||
|
||||
const columnName = `${path.replaceAll('.', '_')}${field.name}`
|
||||
|
||||
const subQueryAlias = `${columnName}_alias`
|
||||
|
||||
const { newAliasTable } = getTableAlias({
|
||||
adapter,
|
||||
tableName: joinCollectionTableName,
|
||||
})
|
||||
|
||||
const {
|
||||
orderBy,
|
||||
selectFields,
|
||||
where: subQueryWhere,
|
||||
} = buildQuery({
|
||||
adapter,
|
||||
aliasTable: newAliasTable,
|
||||
fields,
|
||||
joins,
|
||||
locale,
|
||||
@@ -418,15 +430,21 @@ export const traverseFields = ({
|
||||
|
||||
const db = adapter.drizzle as LibSQLDatabase
|
||||
|
||||
const columnName = `${path.replaceAll('.', '_')}${field.name}`
|
||||
for (let key in selectFields) {
|
||||
const val = selectFields[key]
|
||||
|
||||
const subQueryAlias = `${columnName}_alias`
|
||||
if (val.table && getNameFromDrizzleTable(val.table) === joinCollectionTableName) {
|
||||
delete selectFields[key]
|
||||
key = key.split('.').pop()
|
||||
selectFields[key] = newAliasTable[key]
|
||||
}
|
||||
}
|
||||
|
||||
const subQuery = chainMethods({
|
||||
methods: chainedMethods,
|
||||
query: db
|
||||
.select(selectFields as any)
|
||||
.from(adapter.tables[joinCollectionTableName])
|
||||
.from(newAliasTable)
|
||||
.where(subQueryWhere)
|
||||
.orderBy(() => orderBy.map(({ column, order }) => order(column))),
|
||||
}).as(subQueryAlias)
|
||||
@@ -440,7 +458,7 @@ export const traverseFields = ({
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.from(sql`${subQuery}`)}`.as(columnName)
|
||||
.from(sql`${subQuery}`)}`.as(subQueryAlias)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SQL } from 'drizzle-orm'
|
||||
import type { SQL, Table } from 'drizzle-orm'
|
||||
import type { FlattenedField, Where } from 'payload'
|
||||
|
||||
import type { DrizzleAdapter, GenericColumn } from '../types.js'
|
||||
@@ -8,6 +8,7 @@ import { parseParams } from './parseParams.js'
|
||||
|
||||
export function buildAndOrConditions({
|
||||
adapter,
|
||||
aliasTable,
|
||||
fields,
|
||||
joins,
|
||||
locale,
|
||||
@@ -17,6 +18,7 @@ export function buildAndOrConditions({
|
||||
where,
|
||||
}: {
|
||||
adapter: DrizzleAdapter
|
||||
aliasTable?: Table
|
||||
collectionSlug?: string
|
||||
fields: FlattenedField[]
|
||||
globalSlug?: string
|
||||
@@ -36,6 +38,7 @@ export function buildAndOrConditions({
|
||||
if (typeof condition === 'object') {
|
||||
const result = parseParams({
|
||||
adapter,
|
||||
aliasTable,
|
||||
fields,
|
||||
joins,
|
||||
locale,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Table } from 'drizzle-orm'
|
||||
import type { FlattenedField, Sort } from 'payload'
|
||||
|
||||
import { asc, desc } from 'drizzle-orm'
|
||||
@@ -5,10 +6,12 @@ import { asc, desc } from 'drizzle-orm'
|
||||
import type { DrizzleAdapter, GenericColumn } from '../types.js'
|
||||
import type { BuildQueryJoinAliases, BuildQueryResult } from './buildQuery.js'
|
||||
|
||||
import { getNameFromDrizzleTable } from '../utilities/getNameFromDrizzleTable.js'
|
||||
import { getTableColumnFromPath } from './getTableColumnFromPath.js'
|
||||
|
||||
type Args = {
|
||||
adapter: DrizzleAdapter
|
||||
aliasTable?: Table
|
||||
fields: FlattenedField[]
|
||||
joins: BuildQueryJoinAliases
|
||||
locale?: string
|
||||
@@ -22,6 +25,7 @@ type Args = {
|
||||
*/
|
||||
export const buildOrderBy = ({
|
||||
adapter,
|
||||
aliasTable,
|
||||
fields,
|
||||
joins,
|
||||
locale,
|
||||
@@ -68,7 +72,10 @@ export const buildOrderBy = ({
|
||||
})
|
||||
if (sortTable?.[sortTableColumnName]) {
|
||||
orderBy.push({
|
||||
column: sortTable[sortTableColumnName],
|
||||
column:
|
||||
aliasTable && tableName === getNameFromDrizzleTable(sortTable)
|
||||
? aliasTable[sortTableColumnName]
|
||||
: sortTable[sortTableColumnName],
|
||||
order: sortDirection === 'asc' ? asc : desc,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { asc, desc, SQL } from 'drizzle-orm'
|
||||
import type { asc, desc, SQL, Table } from 'drizzle-orm'
|
||||
import type { PgTableWithColumns } from 'drizzle-orm/pg-core'
|
||||
import type { FlattenedField, Sort, Where } from 'payload'
|
||||
|
||||
@@ -15,6 +15,7 @@ export type BuildQueryJoinAliases = {
|
||||
|
||||
type BuildQueryArgs = {
|
||||
adapter: DrizzleAdapter
|
||||
aliasTable?: Table
|
||||
fields: FlattenedField[]
|
||||
joins?: BuildQueryJoinAliases
|
||||
locale?: string
|
||||
@@ -35,6 +36,7 @@ export type BuildQueryResult = {
|
||||
}
|
||||
const buildQuery = function buildQuery({
|
||||
adapter,
|
||||
aliasTable,
|
||||
fields,
|
||||
joins = [],
|
||||
locale,
|
||||
@@ -49,6 +51,7 @@ const buildQuery = function buildQuery({
|
||||
|
||||
const orderBy = buildOrderBy({
|
||||
adapter,
|
||||
aliasTable,
|
||||
fields,
|
||||
joins,
|
||||
locale,
|
||||
@@ -62,6 +65,7 @@ const buildQuery = function buildQuery({
|
||||
if (incomingWhere && Object.keys(incomingWhere).length > 0) {
|
||||
where = parseParams({
|
||||
adapter,
|
||||
aliasTable,
|
||||
fields,
|
||||
joins,
|
||||
locale,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SQL } from 'drizzle-orm'
|
||||
import type { SQL, Table } from 'drizzle-orm'
|
||||
import type { FlattenedField, Operator, Where } from 'payload'
|
||||
|
||||
import { and, isNotNull, isNull, ne, notInArray, or, sql } from 'drizzle-orm'
|
||||
@@ -9,12 +9,14 @@ import { validOperators } from 'payload/shared'
|
||||
import type { DrizzleAdapter, GenericColumn } from '../types.js'
|
||||
import type { BuildQueryJoinAliases } from './buildQuery.js'
|
||||
|
||||
import { getNameFromDrizzleTable } from '../utilities/getNameFromDrizzleTable.js'
|
||||
import { buildAndOrConditions } from './buildAndOrConditions.js'
|
||||
import { getTableColumnFromPath } from './getTableColumnFromPath.js'
|
||||
import { sanitizeQueryValue } from './sanitizeQueryValue.js'
|
||||
|
||||
type Args = {
|
||||
adapter: DrizzleAdapter
|
||||
aliasTable?: Table
|
||||
fields: FlattenedField[]
|
||||
joins: BuildQueryJoinAliases
|
||||
locale: string
|
||||
@@ -26,6 +28,7 @@ type Args = {
|
||||
|
||||
export function parseParams({
|
||||
adapter,
|
||||
aliasTable,
|
||||
fields,
|
||||
joins,
|
||||
locale,
|
||||
@@ -51,6 +54,7 @@ export function parseParams({
|
||||
if (Array.isArray(condition)) {
|
||||
const builtConditions = buildAndOrConditions({
|
||||
adapter,
|
||||
aliasTable,
|
||||
fields,
|
||||
joins,
|
||||
locale,
|
||||
@@ -83,6 +87,7 @@ export function parseParams({
|
||||
table,
|
||||
} = getTableColumnFromPath({
|
||||
adapter,
|
||||
aliasTable,
|
||||
collectionPath: relationOrPath,
|
||||
fields,
|
||||
joins,
|
||||
@@ -261,12 +266,18 @@ export function parseParams({
|
||||
break
|
||||
}
|
||||
|
||||
const resolvedColumn =
|
||||
rawColumn ||
|
||||
(aliasTable && tableName === getNameFromDrizzleTable(table)
|
||||
? aliasTable[columnName]
|
||||
: table[columnName])
|
||||
|
||||
if (queryOperator === 'not_equals' && queryValue !== null) {
|
||||
constraints.push(
|
||||
or(
|
||||
isNull(rawColumn || table[columnName]),
|
||||
isNull(resolvedColumn),
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
ne<any>(rawColumn || table[columnName], queryValue),
|
||||
ne<any>(resolvedColumn, queryValue),
|
||||
),
|
||||
)
|
||||
break
|
||||
@@ -288,12 +299,12 @@ export function parseParams({
|
||||
}
|
||||
|
||||
if (operator === 'equals' && queryValue === null) {
|
||||
constraints.push(isNull(rawColumn || table[columnName]))
|
||||
constraints.push(isNull(resolvedColumn))
|
||||
break
|
||||
}
|
||||
|
||||
if (operator === 'not_equals' && queryValue === null) {
|
||||
constraints.push(isNotNull(rawColumn || table[columnName]))
|
||||
constraints.push(isNotNull(resolvedColumn))
|
||||
break
|
||||
}
|
||||
|
||||
@@ -330,9 +341,7 @@ export function parseParams({
|
||||
break
|
||||
}
|
||||
|
||||
constraints.push(
|
||||
adapter.operators[queryOperator](rawColumn || table[columnName], queryValue),
|
||||
)
|
||||
constraints.push(adapter.operators[queryOperator](resolvedColumn, queryValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
test/joins/collections/SelfJoins.ts
Normal file
18
test/joins/collections/SelfJoins.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const SelfJoins: CollectionConfig = {
|
||||
slug: 'self-joins',
|
||||
fields: [
|
||||
{
|
||||
name: 'rel',
|
||||
type: 'relationship',
|
||||
relationTo: 'self-joins',
|
||||
},
|
||||
{
|
||||
name: 'joins',
|
||||
type: 'join',
|
||||
on: 'rel',
|
||||
collection: 'self-joins',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Categories } from './collections/Categories.js'
|
||||
import { CategoriesVersions } from './collections/CategoriesVersions.js'
|
||||
import { HiddenPosts } from './collections/HiddenPosts.js'
|
||||
import { Posts } from './collections/Posts.js'
|
||||
import { SelfJoins } from './collections/SelfJoins.js'
|
||||
import { Singular } from './collections/Singular.js'
|
||||
import { Uploads } from './collections/Uploads.js'
|
||||
import { Versions } from './collections/Versions.js'
|
||||
@@ -37,6 +38,7 @@ export default buildConfigWithDefaults({
|
||||
Versions,
|
||||
CategoriesVersions,
|
||||
Singular,
|
||||
SelfJoins,
|
||||
{
|
||||
slug: localizedPostsSlug,
|
||||
admin: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Payload } from 'payload'
|
||||
import type { Payload, TypeWithID } from 'payload'
|
||||
|
||||
import path from 'path'
|
||||
import { getFileByPath } from 'payload'
|
||||
@@ -975,6 +975,15 @@ describe('Joins Field', () => {
|
||||
|
||||
await payload.delete({ collection: categoriesSlug, where: { name: { equals: 'totalDocs' } } })
|
||||
})
|
||||
|
||||
it('should self join', async () => {
|
||||
const doc_1 = await payload.create({ collection: 'self-joins', data: {} })
|
||||
const doc_2 = await payload.create({ collection: 'self-joins', data: { rel: doc_1 }, depth: 0 })
|
||||
|
||||
const data = await payload.findByID({ collection: 'self-joins', id: doc_1.id, depth: 1 })
|
||||
|
||||
expect((data.joins.docs[0] as TypeWithID).id).toBe(doc_2.id)
|
||||
})
|
||||
})
|
||||
|
||||
async function createPost(overrides?: Partial<Post>, locale?: Config['locale']) {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface Config {
|
||||
versions: Version;
|
||||
'categories-versions': CategoriesVersion;
|
||||
singular: Singular;
|
||||
'self-joins': SelfJoin;
|
||||
'localized-posts': LocalizedPost;
|
||||
'localized-categories': LocalizedCategory;
|
||||
'restricted-categories': RestrictedCategory;
|
||||
@@ -53,6 +54,9 @@ export interface Config {
|
||||
relatedVersions: 'versions';
|
||||
relatedVersionsMany: 'versions';
|
||||
};
|
||||
'self-joins': {
|
||||
joins: 'self-joins';
|
||||
};
|
||||
'localized-categories': {
|
||||
relatedPosts: 'localized-posts';
|
||||
};
|
||||
@@ -71,6 +75,7 @@ export interface Config {
|
||||
versions: VersionsSelect<false> | VersionsSelect<true>;
|
||||
'categories-versions': CategoriesVersionsSelect<false> | CategoriesVersionsSelect<true>;
|
||||
singular: SingularSelect<false> | SingularSelect<true>;
|
||||
'self-joins': SelfJoinsSelect<false> | SelfJoinsSelect<true>;
|
||||
'localized-posts': LocalizedPostsSelect<false> | LocalizedPostsSelect<true>;
|
||||
'localized-categories': LocalizedCategoriesSelect<false> | LocalizedCategoriesSelect<true>;
|
||||
'restricted-categories': RestrictedCategoriesSelect<false> | RestrictedCategoriesSelect<true>;
|
||||
@@ -355,6 +360,20 @@ export interface CategoriesVersion {
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "self-joins".
|
||||
*/
|
||||
export interface SelfJoin {
|
||||
id: string;
|
||||
rel?: (string | null) | SelfJoin;
|
||||
joins?: {
|
||||
docs?: (string | SelfJoin)[] | null;
|
||||
hasNextPage?: boolean | null;
|
||||
} | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "localized-posts".
|
||||
@@ -467,6 +486,10 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'singular';
|
||||
value: string | Singular;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'self-joins';
|
||||
value: string | SelfJoin;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'localized-posts';
|
||||
value: string | LocalizedPost;
|
||||
@@ -666,6 +689,16 @@ export interface SingularSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "self-joins_select".
|
||||
*/
|
||||
export interface SelfJoinsSelect<T extends boolean = true> {
|
||||
rel?: T;
|
||||
joins?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "localized-posts_select".
|
||||
|
||||
Reference in New Issue
Block a user