Files
payloadcms/test/joins/int.spec.ts
Sasha dae832c288 feat: select fields (#8550)
Adds `select` which is used to specify the field projection for local
and rest API calls. This is available as an optimization to reduce the
payload's of requests and make the database queries more efficient.

Includes:
- [x] generate types for the `select` property
- [x] infer the return type by `select` with 2 modes - include (`field:
true`) and exclude (`field: false`)
- [x] lots of integration tests, including deep fields / localization
etc
- [x] implement the property in db adapters
- [x] implement the property in the local api for most operations
- [x] implement the property in the rest api 
- [x] docs

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
2024-10-29 21:47:18 +00:00

681 lines
20 KiB
TypeScript

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, Post } from './payload-types.js'
import { devUser } from '../credentials.js'
import { idToString } from '../helpers/idToString.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { categoriesSlug, 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
// --__--__--__--__--__--__--__--__--__
// 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
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,
},
})
}
})
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: 'categories',
})
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: 'categories',
})
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: 'categories',
})
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: 'posts',
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: 'posts',
})
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 uploads in joins', async () => {
const { docs } = await payload.find({
limit: 1,
collection: 'posts',
})
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: 'categories',
})
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: 'categories',
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: 'categories',
where: {
id: { equals: category.id },
},
})
const otherResult = await payload.find({
collection: 'categories',
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: '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',
data: {
name: 'category with post',
},
})
const post = await createPost({
category: category.id,
})
const result = await payload.delete({
collection: 'categories',
// id: category.id,
where: {
id: { equals: category.id },
},
})
expect(result.docs[0].id).toStrictEqual(category.id)
})
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 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)
})
})
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 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 work id.in command delimited querying with joins', async () => {
const allCategories = await payload.find({ collection: 'categories', 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)
})
})
async function createPost(overrides?: Partial<Post>, locale?: Config['locale']) {
return payload.create({
collection: 'posts',
locale,
data: {
title: 'test',
...overrides,
},
})
}