import type { Payload } from 'payload' import path from 'path' import { getFileByPath } from 'payload' import { fileURLToPath } from 'url' import type { NextRESTClient } from '../helpers/NextRESTClient.js' import type { Category, Config, Post, Singular } from './payload-types.js' import { devUser } from '../credentials.js' import { idToString } from '../helpers/idToString.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' import { categoriesJoinRestrictedSlug, categoriesSlug, postsSlug, restrictedCategoriesSlug, restrictedPostsSlug, uploadsSlug, } from './shared.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) let payload: Payload let token: string let restClient: NextRESTClient const { email, password } = devUser describe('Joins Field', () => { let category: Category let otherCategory: Category let categoryID let user // --__--__--__--__--__--__--__--__--__ // Boilerplate test setup/teardown // --__--__--__--__--__--__--__--__--__ beforeAll(async () => { ;({ payload, restClient } = await initPayloadInt(dirname)) const data = await restClient .POST('/users/login', { body: JSON.stringify({ email, password, }), }) .then((res) => res.json()) token = data.token user = data.user category = await payload.create({ collection: categoriesSlug, data: { name: 'paginate example', group: {}, }, }) otherCategory = await payload.create({ collection: categoriesSlug, data: { name: 'otherCategory', group: {}, }, }) // create an upload const imageFilePath = path.resolve(dirname, './image.png') const imageFile = await getFileByPath(imageFilePath) const { id: uploadedImage } = await payload.create({ collection: uploadsSlug, data: {}, file: imageFile, }) categoryID = idToString(category.id, payload) for (let i = 0; i < 15; i++) { let categories = [category.id] if (i % 2 === 0) { categories = [category.id, otherCategory.id] } await createPost({ title: `test ${i}`, category: category.id, upload: uploadedImage, categories, categoriesLocalized: categories, group: { category: category.id, camelCaseCategory: category.id, }, array: [{ category: category.id }], blocks: [{ blockType: 'block', category: category.id }], }) } }) afterAll(async () => { if (typeof payload.db.destroy === 'function') { await payload.db.destroy() } }) it('should populate joins using findByID', async () => { const categoryWithPosts = await payload.findByID({ id: category.id, joins: { 'group.relatedPosts': { sort: '-title', }, }, collection: categoriesSlug, }) expect(categoryWithPosts.group.relatedPosts.docs).toHaveLength(10) expect(categoryWithPosts.group.relatedPosts.docs[0]).toHaveProperty('id') expect(categoryWithPosts.group.relatedPosts.docs[0]).toHaveProperty('title') expect(categoryWithPosts.group.relatedPosts.docs[0].title).toStrictEqual('test 9') }) it('should not populate joins if not selected', async () => { const categoryWithPosts = await payload.findByID({ id: category.id, joins: { 'group.relatedPosts': { sort: '-title', }, }, select: {}, collection: categoriesSlug, }) expect(Object.keys(categoryWithPosts)).toStrictEqual(['id']) }) it('should populate joins if selected', async () => { const categoryWithPosts = await payload.findByID({ id: category.id, joins: { 'group.relatedPosts': { sort: '-title', }, }, select: { group: { relatedPosts: true, }, }, collection: categoriesSlug, }) expect(categoryWithPosts).toStrictEqual({ id: categoryWithPosts.id, group: categoryWithPosts.group, }) expect(categoryWithPosts.group.relatedPosts.docs).toHaveLength(10) expect(categoryWithPosts.group.relatedPosts.docs[0]).toHaveProperty('id') expect(categoryWithPosts.group.relatedPosts.docs[0]).toHaveProperty('title') expect(categoryWithPosts.group.relatedPosts.docs[0].title).toStrictEqual('test 9') }) it('should populate relationships in joins', async () => { const { docs } = await payload.find({ limit: 1, collection: postsSlug, depth: 2, }) expect(docs[0].category.id).toBeDefined() expect(docs[0].category.name).toBeDefined() expect(docs[0].category.relatedPosts.docs).toHaveLength(5) // uses defaultLimit }) it('should populate relationships in joins with camelCase names', async () => { const { docs } = await payload.find({ limit: 1, collection: postsSlug, }) expect(docs[0].group.camelCaseCategory.id).toBeDefined() expect(docs[0].group.camelCaseCategory.name).toBeDefined() expect(docs[0].group.camelCaseCategory.group.camelCasePosts.docs).toHaveLength(10) }) it('should populate joins with array relationships', async () => { const categoryWithPosts = await payload.findByID({ id: category.id, collection: categoriesSlug, }) expect(categoryWithPosts.arrayPosts.docs).toBeDefined() }) it('should populate joins with blocks relationships', async () => { const categoryWithPosts = await payload.findByID({ id: category.id, collection: categoriesSlug, }) expect(categoryWithPosts.blocksPosts.docs).toBeDefined() }) it('should populate uploads in joins', async () => { const { docs } = await payload.find({ limit: 1, collection: postsSlug, }) expect(docs[0].upload.id).toBeDefined() expect(docs[0].upload.relatedPosts.docs).toHaveLength(10) }) it('should filter joins using where query', async () => { const categoryWithPosts = await payload.findByID({ id: category.id, joins: { relatedPosts: { sort: '-title', where: { title: { equals: 'test 9', }, }, }, }, collection: categoriesSlug, }) expect(categoryWithPosts.relatedPosts.docs).toHaveLength(1) expect(categoryWithPosts.relatedPosts.hasNextPage).toStrictEqual(false) }) it('should populate joins using find', async () => { const result = await payload.find({ collection: categoriesSlug, where: { id: { equals: category.id }, }, }) const [categoryWithPosts] = result.docs expect(categoryWithPosts.group.relatedPosts.docs).toHaveLength(10) expect(categoryWithPosts.group.relatedPosts.docs[0]).toHaveProperty('title') 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: categoriesSlug, where: { id: { equals: category.id }, }, }) const otherResult = await payload.find({ collection: categoriesSlug, 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 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: categoriesSlug, where: { id: { equals: category.id }, }, }) const otherResultEn = await payload.find({ collection: categoriesSlug, 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: categoriesSlug, locale: 'es', where: { id: { equals: category.id }, }, }) const otherResultEs = await payload.find({ collection: categoriesSlug, 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: postsSlug, 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: categoriesSlug, data: { name: 'category with post', }, }) await createPost({ category: category.id, }) const result = await payload.delete({ collection: categoriesSlug, // id: category.id, where: { id: { equals: category.id }, }, }) expect(result.docs[0].id).toStrictEqual(category.id) }) describe('`where` filters', () => { let categoryWithFilteredPost beforeAll(async () => { categoryWithFilteredPost = await payload.create({ collection: categoriesSlug, data: { name: 'category with filtered post', }, }) await createPost({ title: 'filtered post', category: categoryWithFilteredPost.id, isFiltered: true, }) await createPost({ title: 'unfiltered post', category: categoryWithFilteredPost.id, isFiltered: false, }) categoryWithFilteredPost = await payload.findByID({ id: categoryWithFilteredPost.id, collection: categoriesSlug, }) }) it('should filter joins using where from field config', () => { expect(categoryWithFilteredPost.filtered.docs).toHaveLength(1) }) it('should filter joins using where from field config and the requested filter', async () => { categoryWithFilteredPost = await payload.findByID({ id: categoryWithFilteredPost.id, collection: categoriesSlug, joins: { filtered: { where: { title: { not_equals: 'unfiltered post' }, }, }, }, }) expect(categoryWithFilteredPost.filtered.docs).toHaveLength(0) }) }) describe('Joins with localization', () => { let localizedCategory: Category beforeAll(async () => { localizedCategory = await payload.create({ collection: 'localized-categories', locale: 'en', data: { name: 'localized category', }, }) const post1 = await payload.create({ collection: 'localized-posts', locale: 'en', data: { title: 'english post 1', category: localizedCategory.id, }, }) await payload.update({ collection: 'localized-posts', id: post1.id, locale: 'es', data: { title: 'spanish post', category: localizedCategory.id, }, }) await payload.create({ collection: 'localized-posts', locale: 'en', data: { title: 'english post 2', category: localizedCategory.id, }, }) }) it('should populate joins using findByID with localization on the relationship', async () => { const enCategory = await payload.findByID({ id: localizedCategory.id, collection: 'localized-categories', locale: 'en', }) const esCategory = await payload.findByID({ id: localizedCategory.id, collection: 'localized-categories', locale: 'es', }) expect(enCategory.relatedPosts.docs).toHaveLength(2) expect(esCategory.relatedPosts.docs).toHaveLength(1) }) }) describe('Joins with versions', () => { afterEach(async () => { await payload.delete({ collection: 'versions', where: {} }) await payload.delete({ collection: 'categories-versions', where: {} }) }) it('should populate joins when versions on both sides draft false', async () => { const category = await payload.create({ collection: 'categories-versions', data: {} }) const version = await payload.create({ collection: 'versions', data: { categoryVersion: category.id }, }) const res = await payload.find({ collection: 'categories-versions', draft: false }) expect(res.docs[0].relatedVersions.docs[0].id).toBe(version.id) }) it('should populate joins with hasMany relationships when versions on both sides draft false', async () => { const category = await payload.create({ collection: 'categories-versions', data: {} }) const version = await payload.create({ collection: 'versions', data: { categoryVersions: [category.id] }, }) const res = await payload.find({ collection: 'categories-versions', draft: false }) expect(res.docs[0].relatedVersionsMany.docs[0].id).toBe(version.id) }) it('should populate joins with hasMany relationships when versions on both sides draft true payload.db.queryDrafts', async () => { const category = await payload.create({ collection: 'categories-versions', data: {} }) const version = await payload.create({ collection: 'versions', data: { categoryVersion: category.id }, }) const res = await payload.find({ collection: 'categories-versions', draft: true, }) expect(res.docs[0].relatedVersions.docs[0].id).toBe(version.id) }) it('should populate joins when versions on both sides draft true payload.db.queryDrafts', async () => { const category = await payload.create({ collection: 'categories-versions', data: {} }) const version = await payload.create({ collection: 'versions', data: { categoryVersions: [category.id] }, }) const res = await payload.find({ collection: 'categories-versions', draft: true, }) expect(res.docs[0].relatedVersionsMany.docs[0].id).toBe(version.id) }) }) describe('REST', () => { it('should have simple paginate for joins', async () => { const query = { depth: 1, where: { name: { equals: 'paginate example' }, }, joins: { relatedPosts: { sort: 'createdAt', limit: 4, }, }, } const pageWithLimit = await restClient.GET(`/categories`, { query }).then((res) => res.json()) query.joins.relatedPosts.limit = 0 const unlimited = await restClient.GET(`/categories`, { query }).then((res) => res.json()) expect(pageWithLimit.docs[0].relatedPosts.docs).toHaveLength(4) expect(pageWithLimit.docs[0].relatedPosts.docs[0].title).toStrictEqual('test 0') expect(pageWithLimit.docs[0].relatedPosts.hasNextPage).toStrictEqual(true) expect(unlimited.docs[0].relatedPosts.docs).toHaveLength(15) expect(unlimited.docs[0].relatedPosts.docs[0].title).toStrictEqual('test 0') expect(unlimited.docs[0].relatedPosts.hasNextPage).toStrictEqual(false) }) it('should respect access control for join collections', async () => { const { docs } = await payload.find({ collection: categoriesJoinRestrictedSlug, where: { name: { equals: 'categoryJoinRestricted' }, }, overrideAccess: false, user, }) const [categoryWithRestrictedPosts] = docs expect(categoryWithRestrictedPosts.collectionRestrictedJoin.docs).toHaveLength(1) expect(categoryWithRestrictedPosts.collectionRestrictedJoin.docs[0].title).toStrictEqual( 'should allow read', ) }) it('should respect access control for join request `where` queries', async () => { await expect(async () => { await payload.findByID({ id: category.id, collection: categoriesSlug, overrideAccess: false, user, joins: { relatedPosts: { where: { restrictedField: { equals: 'restricted' }, }, }, }, }) }).rejects.toThrow('The following path cannot be queried: restrictedField') }) it('should respect access control of join field configured `where` queries', async () => { const restrictedCategory = await payload.create({ collection: restrictedCategoriesSlug, data: { name: 'restricted category', }, }) await createPost({ collection: restrictedPostsSlug, data: { title: 'restricted post', category: restrictedCategory.id, }, }) await expect(async () => { await payload.findByID({ id: category.id, collection: restrictedCategoriesSlug, overrideAccess: false, user, }) }).rejects.toThrow('The following path cannot be queried: restrictedField') }) it('should sort joins', async () => { const response = await restClient .GET(`/categories/${category.id}?joins[relatedPosts][sort]=-title`) .then((res) => res.json()) expect(response.relatedPosts.docs[0].title).toStrictEqual('test 9') }) it('should query in on collections with joins', async () => { const response = await restClient .GET(`/categories?where[id][in]=${category.id}`) .then((res) => res.json()) expect(response.docs[0].name).toStrictEqual(category.name) }) }) describe('GraphQL', () => { it('should have simple paginate for joins', async () => { const queryWithLimit = `query { Categories(where: { name: { equals: "paginate example" } }) { docs { relatedPosts( sort: "createdAt", limit: 4 ) { docs { title } hasNextPage } } } }` const pageWithLimit = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query: queryWithLimit }) }) .then((res) => res.json()) const queryUnlimited = `query { Categories( where: { name: { equals: "paginate example" } } ) { docs { relatedPosts( sort: "createdAt", limit: 0 ) { docs { title createdAt } hasNextPage } } } }` const unlimited = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query: queryUnlimited }) }) .then((res) => res.json()) expect(pageWithLimit.data.Categories.docs[0].relatedPosts.docs).toHaveLength(4) expect(pageWithLimit.data.Categories.docs[0].relatedPosts.docs[0].title).toStrictEqual( 'test 0', ) expect(pageWithLimit.data.Categories.docs[0].relatedPosts.hasNextPage).toStrictEqual(true) expect(unlimited.data.Categories.docs[0].relatedPosts.docs).toHaveLength(15) expect(unlimited.data.Categories.docs[0].relatedPosts.docs[0].title).toStrictEqual('test 0') expect(unlimited.data.Categories.docs[0].relatedPosts.hasNextPage).toStrictEqual(false) }) it('should have simple paginate for joins inside groups', async () => { const queryWithLimit = `query { Categories(where: { name: { equals: "paginate example" } }) { docs { group { relatedPosts( sort: "createdAt", limit: 4 ) { docs { title } hasNextPage } } } } }` const pageWithLimit = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query: queryWithLimit }) }) .then((res) => res.json()) const queryUnlimited = `query { Categories( where: { name: { equals: "paginate example" } } ) { docs { group { relatedPosts( sort: "createdAt", limit: 0 ) { docs { title } hasNextPage } } } } }` const unlimited = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query: queryUnlimited }) }) .then((res) => res.json()) expect(pageWithLimit.data.Categories.docs[0].group.relatedPosts.docs).toHaveLength(4) expect(pageWithLimit.data.Categories.docs[0].group.relatedPosts.docs[0].title).toStrictEqual( 'test 0', ) expect(pageWithLimit.data.Categories.docs[0].group.relatedPosts.hasNextPage).toStrictEqual( true, ) expect(unlimited.data.Categories.docs[0].group.relatedPosts.docs).toHaveLength(15) expect(unlimited.data.Categories.docs[0].group.relatedPosts.docs[0].title).toStrictEqual( 'test 0', ) expect(unlimited.data.Categories.docs[0].group.relatedPosts.hasNextPage).toStrictEqual(false) }) it('should sort joins', async () => { const query = `query { Category(id: ${categoryID}) { relatedPosts( sort: "-title" ) { docs { title } } } }` const response = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query }) }) .then((res) => res.json()) expect(response.data.Category.relatedPosts.docs[0].title).toStrictEqual('test 9') }) it('should query in on collections with joins', async () => { const query = `query { Category(id: ${categoryID}) { relatedPosts( where: { title: { equals: "test 3" } } ) { docs { title } } } }` const response = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query }) }) .then((res) => res.json()) expect(response.data.Category.relatedPosts.docs[0].title).toStrictEqual('test 3') }) it('should respect access control for join collections', async () => { const query = `query { CategoriesJoinRestricteds { docs { name collectionRestrictedJoin { docs { title canRead } } } } }` const response = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query }) }) .then((res) => res.json()) const [categoryWithRestrictedPosts] = response.data.CategoriesJoinRestricteds.docs expect(categoryWithRestrictedPosts.collectionRestrictedJoin.docs).toHaveLength(1) expect(categoryWithRestrictedPosts.collectionRestrictedJoin.docs[0].title).toStrictEqual( 'should allow read', ) }) }) it('should work id.in command delimited querying with joins', async () => { const allCategories = await payload.find({ collection: categoriesSlug, pagination: false }) const allCategoriesByIds = await restClient .GET(`/categories`, { query: { where: { id: { in: allCategories.docs.map((each) => each.id).join(','), }, }, }, }) .then((res) => res.json()) expect(allCategories.totalDocs).toBe(allCategoriesByIds.totalDocs) }) it('should join with singular collection name', async () => { const { docs: [category], } = await payload.find({ collection: categoriesSlug, limit: 1, depth: 0 }) const singular = await payload.create({ collection: 'singular', data: { category: category.id }, }) const categoryWithJoins = await payload.findByID({ collection: categoriesSlug, id: category.id, }) expect((categoryWithJoins.singulars.docs[0] as Singular).id).toBe(singular.id) }) it('local API should not populate individual join by providing schemaPath=false', async () => { const { docs: [res], } = await payload.find({ collection: categoriesSlug, where: { id: { equals: category.id }, }, joins: { relatedPosts: false, }, }) // removed from the result expect(res.relatedPosts).toBeUndefined() expect(res.hasManyPosts.docs).toBeDefined() expect(res.hasManyPostsLocalized.docs).toBeDefined() expect(res.group.relatedPosts.docs).toBeDefined() expect(res.group.camelCasePosts.docs).toBeDefined() }) it('rEST API should not populate individual join by providing schemaPath=false', async () => { const { docs: [res], } = await restClient .GET(`/${categoriesSlug}`, { query: { where: { id: { equals: category.id }, }, joins: { relatedPosts: false, }, }, }) .then((res) => res.json()) // removed from the result expect(res.relatedPosts).toBeUndefined() expect(res.hasManyPosts.docs).toBeDefined() expect(res.hasManyPostsLocalized.docs).toBeDefined() expect(res.group.relatedPosts.docs).toBeDefined() expect(res.group.camelCasePosts.docs).toBeDefined() }) it('should have correct totalDocs', async () => { for (let i = 0; i < 50; i++) { await payload.create({ collection: categoriesSlug, data: { name: 'totalDocs' } }) } const count = await payload.count({ collection: categoriesSlug, where: { name: { equals: 'totalDocs' } }, }) expect(count.totalDocs).toBe(50) const find = await payload.find({ collection: categoriesSlug, limit: 5, where: { name: { equals: 'totalDocs' } }, }) expect(find.totalDocs).toBe(50) expect(find.docs).toHaveLength(5) await payload.delete({ collection: categoriesSlug, where: { name: { equals: 'totalDocs' } } }) }) }) async function createPost(overrides?: Partial, locale?: Config['locale']) { return payload.create({ collection: postsSlug, locale, data: { title: 'test', ...overrides, }, }) }