import type { Payload, TypeWithID } 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, DepthJoins1, DepthJoins3, 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, polymorphic: { relationTo: 'categories', value: category.id, }, polymorphics: [ { relationTo: 'categories', value: category.id, }, ], localizedPolymorphic: { relationTo: 'categories', value: category.id, }, localizedPolymorphics: [ { relationTo: 'categories', value: category.id, }, ], group: { category: category.id, camelCaseCategory: category.id, }, array: [{ category: category.id }], arrayHasMany: [{ category: [category.id] }], localizedArray: [{ category: category.id }], blocks: [{ blockType: 'block', category: category.id }], }) } }) afterAll(async () => { await payload.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 count joins', async () => { let categoryWithPosts = await payload.findByID({ id: category.id, joins: { 'group.relatedPosts': { sort: '-title', count: true, }, }, collection: categoriesSlug, }) expect(categoryWithPosts.group.relatedPosts?.totalDocs).toBe(15) // With limit 1 categoryWithPosts = await payload.findByID({ id: category.id, joins: { 'group.relatedPosts': { sort: '-title', count: true, limit: 1, }, }, collection: categoriesSlug, }) expect(categoryWithPosts.group.relatedPosts?.totalDocs).toBe(15) }) it('should count hasMany relationship joins', async () => { const res = await payload.findByID({ id: category.id, collection: categoriesSlug, joins: { hasManyPosts: { limit: 1, count: true }, }, }) expect(res.hasManyPosts?.totalDocs).toBe(15) }) 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() expect(categoryWithPosts.arrayPosts.docs).toHaveLength(10) }) it('should populate joins with array hasMany relationships', async () => { const categoryWithPosts = await payload.findByID({ id: category.id, collection: categoriesSlug, }) expect(categoryWithPosts.arrayHasManyPosts.docs).toBeDefined() expect(categoryWithPosts.arrayHasManyPosts.docs).toHaveLength(10) }) it('should populate joins with localized array relationships', async () => { const categoryWithPosts = await payload.findByID({ id: category.id, collection: categoriesSlug, }) expect(categoryWithPosts.localizedArrayPosts.docs).toBeDefined() expect(categoryWithPosts.localizedArrayPosts.docs).toHaveLength(10) }) 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 join on polymorphic relationships', async () => { const categoryWithPosts = await payload.findByID({ collection: categoriesSlug, id: category.id, }) expect(categoryWithPosts.polymorphic.docs[0]).toHaveProperty('id') expect(categoryWithPosts.polymorphics.docs[0]).toHaveProperty('id') expect(categoryWithPosts.localizedPolymorphic.docs[0]).toHaveProperty('id') expect(categoryWithPosts.localizedPolymorphics.docs[0]).toHaveProperty('id') }) it('should not throw a path validation error when querying joins with polymorphic relationships', async () => { const folderDoc = await payload.create({ collection: 'payload-folders', data: { name: 'sharedFolder', }, }) await payload.create({ collection: 'folderPoly1', data: { folderPoly1Title: 'Poly 1 title', folder: folderDoc.id, }, depth: 0, }) await payload.create({ collection: 'folderPoly2', data: { folderPoly2Title: 'Poly 2 Title', folder: folderDoc.id, }, depth: 0, }) const result = await payload.find({ collection: 'payload-folders', joins: { documentsAndFolders: { limit: 100_000, sort: 'name', where: { and: [ { relationTo: { in: ['folderPoly1', 'folderPoly2'], }, }, { folderPoly2Title: { equals: 'Poly 2 Title', }, }, ], }, }, }, where: { id: { equals: folderDoc.id, }, }, }) expect(result.docs[0]?.documentsAndFolders.docs).toHaveLength(1) }) it('should allow join where query on hasMany select fields', async () => { const folderDoc = await payload.create({ collection: 'payload-folders', data: { name: 'scopedFolder', folderType: ['folderPoly1', 'folderPoly2'], }, }) await payload.create({ collection: 'payload-folders', data: { name: 'childFolder', folderType: ['folderPoly1'], folder: folderDoc.id, }, }) const findFolder = await payload.find({ collection: 'payload-folders', where: { id: { equals: folderDoc.id, }, }, joins: { documentsAndFolders: { limit: 100_000, sort: 'name', where: { and: [ { relationTo: { equals: 'payload-folders', }, }, { folderType: { in: ['folderPoly1'], }, }, ], }, }, }, }) expect(findFolder?.docs[0]?.documentsAndFolders?.docs).toHaveLength(1) }) it('should query where with exists for hasMany select fields', async () => { await payload.delete({ collection: 'payload-folders', where: {} }) const folderDoc = await payload.create({ collection: 'payload-folders', data: { name: 'scopedFolder', folderType: ['folderPoly1', 'folderPoly2'], }, }) await payload.create({ collection: 'payload-folders', data: { name: 'childFolder', folderType: ['folderPoly1'], folder: folderDoc.id, }, }) const findFolder = await payload.find({ collection: 'payload-folders', where: { id: { equals: folderDoc.id, }, }, joins: { documentsAndFolders: { limit: 100_000, sort: 'name', where: { and: [ { relationTo: { equals: 'payload-folders', }, }, { or: [ { folderType: { in: ['folderPoly1'], }, }, { folderType: { exists: false, }, }, ], }, ], }, }, }, }) expect(findFolder?.docs[0]?.documentsAndFolders?.docs).toHaveLength(1) }) 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: { title: 'version', 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: { title: 'version', 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: { title: 'version', 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 with hasMany when on both sides documents are in draft', async () => { const category = await payload.create({ collection: 'categories-versions', data: { _status: 'draft' }, draft: true, }) const version = await payload.create({ collection: 'versions', data: { title: 'original-title', _status: 'draft', categoryVersion: category.id }, draft: true, }) await payload.update({ collection: 'versions', id: version.id, data: { title: 'updated-title' }, draft: true, }) const res = await payload.find({ collection: 'categories-versions', draft: true, }) expect(res.docs[0].relatedVersions.docs[0].id).toBe(version.id) expect(res.docs[0].relatedVersions.docs[0].title).toBe('updated-title') }) 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], title: 'version' }, }) 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 have simple paginate with page for joins', async () => { const query = { depth: 1, where: { name: { equals: 'paginate example' }, }, joins: { relatedPosts: { sort: 'createdAt', limit: 2, page: 1, }, }, } let 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(2) expect(pageWithLimit.docs[0].relatedPosts.docs[0].id).toBe( unlimited.docs[0].relatedPosts.docs[0].id, ) expect(pageWithLimit.docs[0].relatedPosts.docs[1].id).toBe( unlimited.docs[0].relatedPosts.docs[1].id, ) query.joins.relatedPosts.limit = 2 query.joins.relatedPosts.page = 2 pageWithLimit = await restClient.GET(`/categories`, { query }).then((res) => res.json()) expect(pageWithLimit.docs[0].relatedPosts.docs).toHaveLength(2) expect(pageWithLimit.docs[0].relatedPosts.docs[0].id).toBe( unlimited.docs[0].relatedPosts.docs[2].id, ) expect(pageWithLimit.docs[0].relatedPosts.docs[1].id).toBe( unlimited.docs[0].relatedPosts.docs[3].id, ) }) 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 return totalDocs with count: true', async () => { const queryWithLimit = `query { Categories(where: { name: { equals: "paginate example" } }) { docs { relatedPosts( sort: "createdAt", limit: 4, count: true ) { docs { title } hasNextPage totalDocs } } } }` const pageWithLimit = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query: queryWithLimit }) }) .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(pageWithLimit.data.Categories.docs[0].relatedPosts.totalDocs).toStrictEqual(15) }) it('should have simple paginate with page for joins', async () => { let queryWithLimit = `query { Categories(where: { name: { equals: "paginate example" } }) { docs { relatedPosts( sort: "createdAt", limit: 2 ) { docs { title } hasNextPage } } } }` let 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(2) expect(pageWithLimit.data.Categories.docs[0].relatedPosts.docs[0].id).toStrictEqual( unlimited.data.Categories.docs[0].relatedPosts.docs[0].id, ) expect(pageWithLimit.data.Categories.docs[0].relatedPosts.docs[1].id).toStrictEqual( unlimited.data.Categories.docs[0].relatedPosts.docs[1].id, ) expect(pageWithLimit.data.Categories.docs[0].relatedPosts.hasNextPage).toStrictEqual(true) queryWithLimit = `query { Categories(where: { name: { equals: "paginate example" } }) { docs { relatedPosts( sort: "createdAt", limit: 2, page: 2, ) { docs { title } hasNextPage } } } }` pageWithLimit = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query: queryWithLimit }) }) .then((res) => res.json()) expect(pageWithLimit.data.Categories.docs[0].relatedPosts.docs[0].id).toStrictEqual( unlimited.data.Categories.docs[0].relatedPosts.docs[2].id, ) expect(pageWithLimit.data.Categories.docs[0].relatedPosts.docs[1].id).toStrictEqual( unlimited.data.Categories.docs[0].relatedPosts.docs[3].id, ) }) it('should have simple paginate with page for joins polymorphic', async () => { let queryWithLimit = `query { Categories(where: { name: { equals: "paginate example" } }) { docs { polymorphic( sort: "createdAt", limit: 2 ) { docs { title } hasNextPage } } } }` let pageWithLimit = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query: queryWithLimit }) }) .then((res) => res.json()) const queryUnlimited = `query { Categories( where: { name: { equals: "paginate example" } } ) { docs { polymorphic( 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].polymorphic.docs).toHaveLength(2) expect(pageWithLimit.data.Categories.docs[0].polymorphic.docs[0].id).toStrictEqual( unlimited.data.Categories.docs[0].polymorphic.docs[0].id, ) expect(pageWithLimit.data.Categories.docs[0].polymorphic.docs[1].id).toStrictEqual( unlimited.data.Categories.docs[0].polymorphic.docs[1].id, ) expect(pageWithLimit.data.Categories.docs[0].polymorphic.hasNextPage).toStrictEqual(true) queryWithLimit = `query { Categories(where: { name: { equals: "paginate example" } }) { docs { polymorphic( sort: "createdAt", limit: 2, page: 2, ) { docs { title } hasNextPage } } } }` pageWithLimit = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query: queryWithLimit }) }) .then((res) => res.json()) expect(pageWithLimit.data.Categories.docs[0].polymorphic.docs[0].id).toStrictEqual( unlimited.data.Categories.docs[0].polymorphic.docs[2].id, ) expect(pageWithLimit.data.Categories.docs[0].polymorphic.docs[1].id).toStrictEqual( unlimited.data.Categories.docs[0].polymorphic.docs[3].id, ) }) it('should populate joins with hasMany when on both sides documents are in draft', async () => { const category = await payload.create({ collection: 'categories-versions', data: { _status: 'draft' }, draft: true, }) const version = await payload.create({ collection: 'versions', data: { _status: 'draft', title: 'original-title', categoryVersion: category.id }, draft: true, }) await payload.update({ collection: 'versions', draft: true, id: version.id, data: { title: 'updated-title' }, }) const query = `query { CategoriesVersions(draft: true) { docs { relatedVersions( limit: 1 ) { docs { id, title } hasNextPage } } } }` const res = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query }) }) .then((res) => res.json()) expect(res.data.CategoriesVersions.docs[0].relatedVersions.docs[0].id).toBe(version.id) expect(res.data.CategoriesVersions.docs[0].relatedVersions.docs[0].title).toBe( 'updated-title', ) }) 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' } } }) }) 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) }) it('should populate joins on depth 2', async () => { const depthJoin_2 = await payload.create({ collection: 'depth-joins-2', data: {}, depth: 0 }) const depthJoin_1 = await payload.create({ collection: 'depth-joins-1', data: { rel: depthJoin_2 }, depth: 0, }) const depthJoin_3 = await payload.create({ collection: 'depth-joins-3', data: { rel: depthJoin_1 }, depth: 0, }) const data = await payload.findByID({ collection: 'depth-joins-2', id: depthJoin_2.id, depth: 2, }) const joinedDoc = data.joins.docs[0] as DepthJoins1 expect(joinedDoc.id).toBe(depthJoin_1.id) const joinedDoc2 = joinedDoc.joins.docs[0] as DepthJoins3 expect(joinedDoc2.id).toBe(depthJoin_3.id) }) describe('Array of collection', () => { it('should join across multiple collections', async () => { let parent = await payload.create({ collection: 'multiple-collections-parents', depth: 0, data: {}, }) const child_1 = await payload.create({ collection: 'multiple-collections-1', depth: 0, data: { parent, title: 'doc-1', }, }) const child_2 = await payload.create({ collection: 'multiple-collections-2', depth: 0, data: { parent, title: 'doc-2', }, }) parent = await payload.findByID({ collection: 'multiple-collections-parents', id: parent.id, depth: 0, }) expect(parent.children.docs[0].value).toBe(child_2.id) expect(parent.children.docs[0]?.relationTo).toBe('multiple-collections-2') expect(parent.children.docs[1]?.value).toBe(child_1.id) expect(parent.children.docs[1]?.relationTo).toBe('multiple-collections-1') parent = await payload.findByID({ collection: 'multiple-collections-parents', id: parent.id, depth: 1, }) expect(parent.children.docs[0].value.id).toBe(child_2.id) expect(parent.children.docs[0]?.relationTo).toBe('multiple-collections-2') expect(parent.children.docs[1]?.value.id).toBe(child_1.id) expect(parent.children.docs[1]?.relationTo).toBe('multiple-collections-1') // Pagination across collections parent = await payload.findByID({ collection: 'multiple-collections-parents', id: parent.id, depth: 1, joins: { children: { limit: 1, sort: 'title', }, }, }) expect(parent.children.docs).toHaveLength(1) expect(parent.children?.hasNextPage).toBe(true) parent = await payload.findByID({ collection: 'multiple-collections-parents', id: parent.id, depth: 1, joins: { children: { limit: 2, sort: 'title', }, }, }) expect(parent.children.docs).toHaveLength(2) expect(parent.children?.hasNextPage).toBe(false) // Sorting across collections parent = await payload.findByID({ collection: 'multiple-collections-parents', id: parent.id, depth: 1, joins: { children: { sort: 'title', }, }, }) expect(parent.children.docs[0]?.value.title).toBe('doc-1') expect(parent.children.docs[1]?.value.title).toBe('doc-2') parent = await payload.findByID({ collection: 'multiple-collections-parents', id: parent.id, depth: 1, joins: { children: { sort: '-title', }, }, }) expect(parent.children.docs[0]?.value.title).toBe('doc-2') expect(parent.children.docs[1]?.value.title).toBe('doc-1') // WHERE across collections parent = await payload.findByID({ collection: 'multiple-collections-parents', id: parent.id, depth: 1, joins: { children: { where: { title: { equals: 'doc-1', }, }, }, }, }) expect(parent.children?.docs).toHaveLength(1) expect(parent.children.docs[0]?.value.title).toBe('doc-1') // WHERE by relationTo (join for specific collectionSlug) parent = await payload.findByID({ collection: 'multiple-collections-parents', id: parent.id, depth: 1, joins: { children: { where: { relationTo: { equals: 'multiple-collections-2', }, }, }, }, }) // WHERE by relationTo with overrideAccess:false parent = await payload.findByID({ collection: 'multiple-collections-parents', id: parent.id, overrideAccess: false, depth: 1, joins: { children: { where: { relationTo: { equals: 'multiple-collections-2', }, }, }, }, }) expect(parent.children?.docs).toHaveLength(1) expect(parent.children.docs[0]?.value.title).toBe('doc-2') // counting parent = await payload.findByID({ collection: 'multiple-collections-parents', id: parent.id, depth: 1, joins: { children: { count: true, }, }, }) expect(parent.children?.totalDocs).toBe(2) // counting filtered parent = await payload.findByID({ collection: 'multiple-collections-parents', id: parent.id, depth: 1, joins: { children: { count: true, where: { relationTo: { equals: 'multiple-collections-2', }, }, }, }, }) expect(parent.children?.totalDocs).toBe(1) }) }) it('should support where querying by a top level join field', async () => { const category = await payload.create({ collection: 'categories', data: {} }) await payload.create({ collection: 'posts', data: { category: category.id, title: 'my-title' }, }) const found = await payload.find({ collection: 'categories', where: { 'relatedPosts.title': { equals: 'my-title' } }, }) expect(found.docs).toHaveLength(1) expect(found.docs[0].id).toBe(category.id) }) it('should support where querying by a join field as ID', async () => { const category = await payload.create({ collection: 'categories', data: {} }) const post = await payload.create({ collection: 'posts', data: { category: category.id, title: 'my-title' }, }) const found_1 = await payload.find({ collection: 'categories', where: { 'relatedPosts.id': { equals: post.id } }, overrideAccess: true, }) expect(found_1.docs).toHaveLength(1) expect(found_1.docs[0].id).toBe(category.id) const found_2 = await payload.find({ collection: 'categories', where: { relatedPosts: { equals: post.id } }, overrideAccess: true, }) expect(found_2.docs).toHaveLength(1) expect(found_2.docs[0].id).toBe(category.id) }) it('should support where querying by a join field with hasMany relationship', async () => { const category = await payload.create({ collection: 'categories', data: {} }) await payload.create({ collection: 'posts', data: { categories: [category.id], title: 'my-title' }, }) const found = await payload.find({ collection: 'categories', where: { 'hasManyPosts.title': { equals: 'my-title' } }, }) expect(found.docs).toHaveLength(1) expect(found.docs[0].id).toBe(category.id) }) it('should support where querying by a join field with relationship nested to a group', async () => { const category = await payload.create({ collection: 'categories', data: {} }) await payload.create({ collection: 'posts', data: { group: { category: category.id }, title: 'my-category-title' }, }) const found = await payload.find({ collection: 'categories', where: { 'group.relatedPosts.title': { equals: 'my-category-title' } }, }) expect(found.docs).toHaveLength(1) expect(found.docs[0].id).toBe(category.id) }) }) async function createPost(overrides?: Partial, locale?: Config['locale']) { return payload.create({ collection: postsSlug, locale, data: { title: 'test', ...overrides, }, }) }