Files
payload/test/joins/int.spec.ts
Dan Ribbens b0b2fc6c47 feat: join field support relationships inside arrays (#9773)
### What?

Allow the join field to have a configuration `on` relationships inside
of an array, ie `on: 'myArray.myRelationship'`.

### Why?

This is a more powerful and expressive way to use the join field and not
be limited by usage of array data. For example, if you have a roles
array for multinant sites, you could add a join field on the sites to
show who the admins are.

### How?

This fixes the traverseFields function to allow the configuration to
pass sanitization. In addition, the function for querying the drizzle
tables needed to be ehanced.

Additional changes from https://github.com/payloadcms/payload/pull/9995:

- Significantly improves traverseFields and the 'join' case with a raw
query injection pattern, right now it's internal but we could expose it
at some point, for example for querying vectors.
- Fixes potential issues with not passed locale to traverseFields (it
was undefined always)
- Adds an empty array fallback for joins with localized relationships

Fixes #
https://github.com/payloadcms/payload/discussions/9643

---------

Co-authored-by: Because789 <thomas@because789.ch>
Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com>
2024-12-17 13:14:43 -05:00

949 lines
27 KiB
TypeScript

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 }],
})
}
})
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(Object.keys(categoryWithPosts)).toStrictEqual(['id', '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 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<Post>, locale?: Config['locale']) {
return payload.create({
collection: postsSlug,
locale,
data: {
title: 'test',
...overrides,
},
})
}