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:
Sasha
2024-12-26 20:47:49 +02:00
committed by GitHub
parent 6b45b2d7e9
commit a0d8131649
9 changed files with 119 additions and 16 deletions

View File

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

View File

@@ -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,

View File

@@ -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,
})

View File

@@ -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,

View File

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

View 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',
},
],
}

View File

@@ -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: {

View File

@@ -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']) {

View File

@@ -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".