diff --git a/packages/drizzle/src/find/traverseFields.ts b/packages/drizzle/src/find/traverseFields.ts index e4b4f9506..9c092a439 100644 --- a/packages/drizzle/src/find/traverseFields.ts +++ b/packages/drizzle/src/find/traverseFields.ts @@ -1,7 +1,8 @@ +import type { DBQueryConfig } from 'drizzle-orm' import type { LibSQLDatabase } from 'drizzle-orm/libsql' 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 toSnakeCase from 'to-snake-case' @@ -245,12 +246,15 @@ export const traverseFields = ({ 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))}${ + let joinTableName = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${ field.localized && adapter.payload.config.localization ? adapter.localesSuffix : '' }` - if (!adapter.tables[joinTableName][field.on]) { + if (field.hasMany) { const db = adapter.drizzle as LibSQLDatabase + if (field.localized) { + joinTableName = adapter.tableNameMap.get(toSnakeCase(field.collection)) + } const joinTable = `${joinTableName}${adapter.relationshipsSuffix}` const joins: BuildQueryJoinAliases = [ @@ -262,6 +266,7 @@ export const traverseFields = ({ sql.raw(`"${joinTable}"."${topLevelTableName}_id"`), adapter.tables[currentTableName].id, ), + eq(adapter.tables[joinTable].path, field.on), ), table: adapter.tables[joinTable], }, @@ -291,30 +296,35 @@ export const traverseFields = ({ query: db .select({ id: adapter.tables[joinTableName].id, + ...(field.localized && { + locale: adapter.tables[joinTable].locale, + }), }) .from(adapter.tables[joinTableName]) .where(subQueryWhere) .orderBy(orderBy.order(orderBy.column)) - .limit(11), + .limit(limit), }) 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') { - extras[columnName] = sql` + currentArgs.extras[columnName] = sql` COALESCE(( - SELECT json_group_array("id") + SELECT json_group_array(json_object(${jsonObjectSelect})) FROM ( ${subQuery} ) AS ${sql.raw(`${columnName}_sub`)} ), '[]') `.as(columnName) } else { - extras[columnName] = sql` + currentArgs.extras[columnName] = sql` COALESCE(( - SELECT json_agg("id") + SELECT json_agg(json_build_object(${jsonObjectSelect})) FROM ( ${subQuery} ) AS ${sql.raw(`${columnName}_sub`)} diff --git a/packages/drizzle/src/transform/read/traverseFields.ts b/packages/drizzle/src/transform/read/traverseFields.ts index b680e6e73..864eeb82c 100644 --- a/packages/drizzle/src/transform/read/traverseFields.ts +++ b/packages/drizzle/src/transform/read/traverseFields.ts @@ -452,8 +452,8 @@ export const traverseFields = >({ } else { const hasNextPage = limit !== 0 && fieldData.length > limit fieldResult = { - docs: (hasNextPage ? fieldData.slice(0, limit) : fieldData).map((objOrID) => ({ - id: typeof objOrID === 'object' ? objOrID.id : objOrID, + docs: (hasNextPage ? fieldData.slice(0, limit) : fieldData).map(({ id }) => ({ + id, })), hasNextPage, } diff --git a/packages/payload/src/fields/config/sanitizeJoinField.ts b/packages/payload/src/fields/config/sanitizeJoinField.ts index 9da7bc372..e3cce422f 100644 --- a/packages/payload/src/fields/config/sanitizeJoinField.ts +++ b/packages/payload/src/fields/config/sanitizeJoinField.ts @@ -82,6 +82,8 @@ export const sanitizeJoinField = ({ // override the join field localized property to use whatever the relationship field has 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]) { joins[field.collection] = [join] diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 3a964a8ed..96e8634cf 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -1452,6 +1452,10 @@ export type JoinField = { */ collection: CollectionSlug defaultValue?: never + /** + * This does not need to be set and will be overridden by the relationship field's hasMany property. + */ + hasMany?: boolean hidden?: false index?: never /** diff --git a/test/joins/collections/Categories.ts b/test/joins/collections/Categories.ts index 5780405ef..22bf85110 100644 --- a/test/joins/collections/Categories.ts +++ b/test/joins/collections/Categories.ts @@ -55,6 +55,12 @@ export const Categories: CollectionConfig = { collection: postsSlug, on: 'categories', }, + { + name: 'hasManyPostsLocalized', + type: 'join', + collection: postsSlug, + on: 'categoriesLocalized', + }, { name: 'group', type: 'group', diff --git a/test/joins/collections/Posts.ts b/test/joins/collections/Posts.ts index edfaf546e..2da610a06 100644 --- a/test/joins/collections/Posts.ts +++ b/test/joins/collections/Posts.ts @@ -29,6 +29,13 @@ export const Posts: CollectionConfig = { relationTo: categoriesSlug, hasMany: true, }, + { + name: 'categoriesLocalized', + type: 'relationship', + relationTo: categoriesSlug, + hasMany: true, + localized: true, + }, { name: 'group', type: 'group', diff --git a/test/joins/int.spec.ts b/test/joins/int.spec.ts index ade2ec6a0..e5b0857f7 100644 --- a/test/joins/int.spec.ts +++ b/test/joins/int.spec.ts @@ -5,7 +5,7 @@ import { getFileByPath } from 'payload' import { fileURLToPath } from 'url' 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 { idToString } from '../helpers/idToString.js' @@ -80,6 +80,7 @@ describe('Joins Field', () => { category: category.id, upload: uploadedImage, categories, + categoriesLocalized: categories, group: { category: category.id, camelCaseCategory: category.id, @@ -212,6 +213,89 @@ describe('Joins Field', () => { 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 () => { const category = await payload.create({ collection: 'categories', @@ -499,9 +583,10 @@ describe('Joins Field', () => { }) }) -async function createPost(overrides?: Partial) { +async function createPost(overrides?: Partial, locale?: Config['locale']) { return payload.create({ collection: 'posts', + locale, data: { title: 'test', ...overrides, diff --git a/test/joins/payload-types.ts b/test/joins/payload-types.ts index ae89d82ef..6fb3e55ba 100644 --- a/test/joins/payload-types.ts +++ b/test/joins/payload-types.ts @@ -58,6 +58,7 @@ export interface Post { upload?: (string | null) | Upload; category?: (string | null) | Category; categories?: (string | Category)[] | null; + categoriesLocalized?: (string | Category)[] | null; group?: { category?: (string | null) | Category; camelCaseCategory?: (string | null) | Category; @@ -102,6 +103,10 @@ export interface Category { docs?: (string | Post)[] | null; hasNextPage?: boolean | null; } | null; + hasManyPostsLocalized?: { + docs?: (string | Post)[] | null; + hasNextPage?: boolean | null; + } | null; group?: { relatedPosts?: { docs?: (string | Post)[] | null;