fix(drizzle): hasMany joins - localized, limit and schema paths (#8633)
Fixes https://github.com/payloadcms/payload/issues/8630 - Fixes `hasMany: true` and `localized: true` on the foreign field - Adds `limit` to the subquery instead of hardcoded `11`. - Adds the schema path `field.on` to the subquery, without this having 2 or more relationship fields to the same collection breaks joins - Properly checks if the field is `hasMany`
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
|
import type { DBQueryConfig } from 'drizzle-orm'
|
||||||
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
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 { and, 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'
|
||||||
|
|
||||||
@@ -245,12 +246,15 @@ export const traverseFields = ({
|
|||||||
|
|
||||||
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 joinCollectionTableName = adapter.tableNameMap.get(toSnakeCase(field.collection))
|
||||||
const joinTableName = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${
|
let joinTableName = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${
|
||||||
field.localized && adapter.payload.config.localization ? adapter.localesSuffix : ''
|
field.localized && adapter.payload.config.localization ? adapter.localesSuffix : ''
|
||||||
}`
|
}`
|
||||||
|
|
||||||
if (!adapter.tables[joinTableName][field.on]) {
|
if (field.hasMany) {
|
||||||
const db = adapter.drizzle as LibSQLDatabase
|
const db = adapter.drizzle as LibSQLDatabase
|
||||||
|
if (field.localized) {
|
||||||
|
joinTableName = adapter.tableNameMap.get(toSnakeCase(field.collection))
|
||||||
|
}
|
||||||
const joinTable = `${joinTableName}${adapter.relationshipsSuffix}`
|
const joinTable = `${joinTableName}${adapter.relationshipsSuffix}`
|
||||||
|
|
||||||
const joins: BuildQueryJoinAliases = [
|
const joins: BuildQueryJoinAliases = [
|
||||||
@@ -262,6 +266,7 @@ export const traverseFields = ({
|
|||||||
sql.raw(`"${joinTable}"."${topLevelTableName}_id"`),
|
sql.raw(`"${joinTable}"."${topLevelTableName}_id"`),
|
||||||
adapter.tables[currentTableName].id,
|
adapter.tables[currentTableName].id,
|
||||||
),
|
),
|
||||||
|
eq(adapter.tables[joinTable].path, field.on),
|
||||||
),
|
),
|
||||||
table: adapter.tables[joinTable],
|
table: adapter.tables[joinTable],
|
||||||
},
|
},
|
||||||
@@ -291,30 +296,35 @@ export const traverseFields = ({
|
|||||||
query: db
|
query: db
|
||||||
.select({
|
.select({
|
||||||
id: adapter.tables[joinTableName].id,
|
id: adapter.tables[joinTableName].id,
|
||||||
|
...(field.localized && {
|
||||||
|
locale: adapter.tables[joinTable].locale,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.from(adapter.tables[joinTableName])
|
.from(adapter.tables[joinTableName])
|
||||||
.where(subQueryWhere)
|
.where(subQueryWhere)
|
||||||
.orderBy(orderBy.order(orderBy.column))
|
.orderBy(orderBy.order(orderBy.column))
|
||||||
.limit(11),
|
.limit(limit),
|
||||||
})
|
})
|
||||||
|
|
||||||
const columnName = `${path.replaceAll('.', '_')}${field.name}`
|
const columnName = `${path.replaceAll('.', '_')}${field.name}`
|
||||||
|
|
||||||
const extras = field.localized ? _locales.extras : currentArgs.extras
|
const jsonObjectSelect = field.localized
|
||||||
|
? sql.raw(`'_parentID', "id", '_locale', "locale"`)
|
||||||
|
: sql.raw(`'id', "id"`)
|
||||||
|
|
||||||
if (adapter.name === 'sqlite') {
|
if (adapter.name === 'sqlite') {
|
||||||
extras[columnName] = sql`
|
currentArgs.extras[columnName] = sql`
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_group_array("id")
|
SELECT json_group_array(json_object(${jsonObjectSelect}))
|
||||||
FROM (
|
FROM (
|
||||||
${subQuery}
|
${subQuery}
|
||||||
) AS ${sql.raw(`${columnName}_sub`)}
|
) AS ${sql.raw(`${columnName}_sub`)}
|
||||||
), '[]')
|
), '[]')
|
||||||
`.as(columnName)
|
`.as(columnName)
|
||||||
} else {
|
} else {
|
||||||
extras[columnName] = sql`
|
currentArgs.extras[columnName] = sql`
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg("id")
|
SELECT json_agg(json_build_object(${jsonObjectSelect}))
|
||||||
FROM (
|
FROM (
|
||||||
${subQuery}
|
${subQuery}
|
||||||
) AS ${sql.raw(`${columnName}_sub`)}
|
) AS ${sql.raw(`${columnName}_sub`)}
|
||||||
|
|||||||
@@ -452,8 +452,8 @@ 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).map((objOrID) => ({
|
docs: (hasNextPage ? fieldData.slice(0, limit) : fieldData).map(({ id }) => ({
|
||||||
id: typeof objOrID === 'object' ? objOrID.id : objOrID,
|
id,
|
||||||
})),
|
})),
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ export const sanitizeJoinField = ({
|
|||||||
|
|
||||||
// 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
|
||||||
|
// override the join field hasMany property to use whatever the relationship field has
|
||||||
|
field.hasMany = joinRelationship.hasMany
|
||||||
|
|
||||||
if (!joins[field.collection]) {
|
if (!joins[field.collection]) {
|
||||||
joins[field.collection] = [join]
|
joins[field.collection] = [join]
|
||||||
|
|||||||
@@ -1452,6 +1452,10 @@ export type JoinField = {
|
|||||||
*/
|
*/
|
||||||
collection: CollectionSlug
|
collection: CollectionSlug
|
||||||
defaultValue?: never
|
defaultValue?: never
|
||||||
|
/**
|
||||||
|
* This does not need to be set and will be overridden by the relationship field's hasMany property.
|
||||||
|
*/
|
||||||
|
hasMany?: boolean
|
||||||
hidden?: false
|
hidden?: false
|
||||||
index?: never
|
index?: never
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ export const Categories: CollectionConfig = {
|
|||||||
collection: postsSlug,
|
collection: postsSlug,
|
||||||
on: 'categories',
|
on: 'categories',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'hasManyPostsLocalized',
|
||||||
|
type: 'join',
|
||||||
|
collection: postsSlug,
|
||||||
|
on: 'categoriesLocalized',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'group',
|
name: 'group',
|
||||||
type: 'group',
|
type: 'group',
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ export const Posts: CollectionConfig = {
|
|||||||
relationTo: categoriesSlug,
|
relationTo: categoriesSlug,
|
||||||
hasMany: true,
|
hasMany: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'categoriesLocalized',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: categoriesSlug,
|
||||||
|
hasMany: true,
|
||||||
|
localized: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'group',
|
name: 'group',
|
||||||
type: 'group',
|
type: 'group',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getFileByPath } from 'payload'
|
|||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
|
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
|
||||||
import type { Category, Post } from './payload-types.js'
|
import type { Category, Config, Post } from './payload-types.js'
|
||||||
|
|
||||||
import { devUser } from '../credentials.js'
|
import { devUser } from '../credentials.js'
|
||||||
import { idToString } from '../helpers/idToString.js'
|
import { idToString } from '../helpers/idToString.js'
|
||||||
@@ -80,6 +80,7 @@ describe('Joins Field', () => {
|
|||||||
category: category.id,
|
category: category.id,
|
||||||
upload: uploadedImage,
|
upload: uploadedImage,
|
||||||
categories,
|
categories,
|
||||||
|
categoriesLocalized: categories,
|
||||||
group: {
|
group: {
|
||||||
category: category.id,
|
category: category.id,
|
||||||
camelCaseCategory: category.id,
|
camelCaseCategory: category.id,
|
||||||
@@ -212,6 +213,89 @@ describe('Joins Field', () => {
|
|||||||
expect(otherCategoryWithPosts.hasManyPosts.docs[0].title).toBe('test 14')
|
expect(otherCategoryWithPosts.hasManyPosts.docs[0].title).toBe('test 14')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should populate joins using find with hasMany localized relationships', async () => {
|
||||||
|
const post_1 = await createPost(
|
||||||
|
{
|
||||||
|
title: `test es localized 1`,
|
||||||
|
categoriesLocalized: [category.id],
|
||||||
|
group: {
|
||||||
|
category: category.id,
|
||||||
|
camelCaseCategory: category.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'es',
|
||||||
|
)
|
||||||
|
|
||||||
|
const post_2 = await createPost(
|
||||||
|
{
|
||||||
|
title: `test es localized 2`,
|
||||||
|
categoriesLocalized: [otherCategory.id],
|
||||||
|
group: {
|
||||||
|
category: category.id,
|
||||||
|
camelCaseCategory: category.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'es',
|
||||||
|
)
|
||||||
|
|
||||||
|
const resultEn = await payload.find({
|
||||||
|
collection: 'categories',
|
||||||
|
where: {
|
||||||
|
id: { equals: category.id },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const otherResultEn = await payload.find({
|
||||||
|
collection: 'categories',
|
||||||
|
where: {
|
||||||
|
id: { equals: otherCategory.id },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [categoryWithPostsEn] = resultEn.docs
|
||||||
|
const [otherCategoryWithPostsEn] = otherResultEn.docs
|
||||||
|
|
||||||
|
expect(categoryWithPostsEn.hasManyPostsLocalized.docs).toHaveLength(10)
|
||||||
|
expect(categoryWithPostsEn.hasManyPostsLocalized.docs[0]).toHaveProperty('title')
|
||||||
|
expect(categoryWithPostsEn.hasManyPostsLocalized.docs[0].title).toBe('test 14')
|
||||||
|
expect(otherCategoryWithPostsEn.hasManyPostsLocalized.docs).toHaveLength(8)
|
||||||
|
expect(otherCategoryWithPostsEn.hasManyPostsLocalized.docs[0]).toHaveProperty('title')
|
||||||
|
expect(otherCategoryWithPostsEn.hasManyPostsLocalized.docs[0].title).toBe('test 14')
|
||||||
|
|
||||||
|
const resultEs = await payload.find({
|
||||||
|
collection: 'categories',
|
||||||
|
locale: 'es',
|
||||||
|
where: {
|
||||||
|
id: { equals: category.id },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const otherResultEs = await payload.find({
|
||||||
|
collection: 'categories',
|
||||||
|
locale: 'es',
|
||||||
|
where: {
|
||||||
|
id: { equals: otherCategory.id },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [categoryWithPostsEs] = resultEs.docs
|
||||||
|
const [otherCategoryWithPostsEs] = otherResultEs.docs
|
||||||
|
|
||||||
|
expect(categoryWithPostsEs.hasManyPostsLocalized.docs).toHaveLength(1)
|
||||||
|
expect(categoryWithPostsEs.hasManyPostsLocalized.docs[0].title).toBe('test es localized 1')
|
||||||
|
|
||||||
|
expect(otherCategoryWithPostsEs.hasManyPostsLocalized.docs).toHaveLength(1)
|
||||||
|
expect(otherCategoryWithPostsEs.hasManyPostsLocalized.docs[0].title).toBe('test es localized 2')
|
||||||
|
|
||||||
|
// clean up
|
||||||
|
await payload.delete({
|
||||||
|
collection: 'posts',
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: [post_1.id, post_2.id],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
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',
|
||||||
@@ -499,9 +583,10 @@ describe('Joins Field', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function createPost(overrides?: Partial<Post>) {
|
async function createPost(overrides?: Partial<Post>, locale?: Config['locale']) {
|
||||||
return payload.create({
|
return payload.create({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
|
locale,
|
||||||
data: {
|
data: {
|
||||||
title: 'test',
|
title: 'test',
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export interface Post {
|
|||||||
upload?: (string | null) | Upload;
|
upload?: (string | null) | Upload;
|
||||||
category?: (string | null) | Category;
|
category?: (string | null) | Category;
|
||||||
categories?: (string | Category)[] | null;
|
categories?: (string | Category)[] | null;
|
||||||
|
categoriesLocalized?: (string | Category)[] | null;
|
||||||
group?: {
|
group?: {
|
||||||
category?: (string | null) | Category;
|
category?: (string | null) | Category;
|
||||||
camelCaseCategory?: (string | null) | Category;
|
camelCaseCategory?: (string | null) | Category;
|
||||||
@@ -102,6 +103,10 @@ export interface Category {
|
|||||||
docs?: (string | Post)[] | null;
|
docs?: (string | Post)[] | null;
|
||||||
hasNextPage?: boolean | null;
|
hasNextPage?: boolean | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
hasManyPostsLocalized?: {
|
||||||
|
docs?: (string | Post)[] | null;
|
||||||
|
hasNextPage?: boolean | null;
|
||||||
|
} | null;
|
||||||
group?: {
|
group?: {
|
||||||
relatedPosts?: {
|
relatedPosts?: {
|
||||||
docs?: (string | Post)[] | null;
|
docs?: (string | Post)[] | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user