feat!: join field (#7518)

## Description

- Adds a new "join" field type to Payload and is supported by all database adapters
- The UI uses a table view for the new field
- `db-mongodb` changes relationships to be stored as ObjectIDs instead of strings (for now querying works using both types internally to the DB so no data migration should be necessary unless you're querying directly, see breaking changes for details
- Adds a reusable traverseFields utility to Payload to make it easier to work with nested fields, used internally and for plugin maintainers

```ts
export const Categories: CollectionConfig = {
    slug: 'categories',
    fields: [
        {
            name: 'relatedPosts',
            type: 'join',
            collection: 'posts',
            on: 'category',
        }
    ]
}
```

BREAKING CHANGES:
All mongodb relationship and upload values will be stored as MongoDB ObjectIDs instead of strings going forward. If you have existing data and you are querying data directly, outside of Payload's APIs, you get different results. For example, a `contains` query will no longer works given a partial ID of a relationship since the ObjectID requires the whole identifier to work. 

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
Co-authored-by: James <james@trbl.design>
This commit is contained in:
Dan Ribbens
2024-09-20 11:10:16 -04:00
committed by GitHub
parent b51d2bcb39
commit 6ef2bdea15
189 changed files with 11076 additions and 5882 deletions

View File

@@ -0,0 +1,18 @@
import type { CollectionConfig } from 'payload'
export const postsSlug = 'joins'
export const PostsCollection: CollectionConfig = {
slug: postsSlug,
admin: {
useAsTitle: 'text',
},
fields: [
{
name: 'text',
type: 'text',
},
],
versions: {
drafts: true,
},
}

View File

@@ -198,7 +198,9 @@ export async function openNav(page: Page): Promise<void> {
// check to see if the nav is already open and if not, open it
// use the `--nav-open` modifier class to check if the nav is open
// this will prevent clicking nav links that are bleeding off the screen
if (await page.locator('.template-default.template-default--nav-open').isVisible()) {return}
if (await page.locator('.template-default.template-default--nav-open').isVisible()) {
return
}
// playwright: get first element with .nav-toggler which is VISIBLE (not hidden), could be 2 elements with .nav-toggler on mobile and desktop but only one is visible
await page.locator('.nav-toggler >> visible=true').click()
await expect(page.locator('.template-default.template-default--nav-open')).toBeVisible()
@@ -221,7 +223,9 @@ export async function openCreateDocDrawer(page: Page, fieldSelector: string): Pr
}
export async function closeNav(page: Page): Promise<void> {
if (!(await page.locator('.template-default.template-default--nav-open').isVisible())) {return}
if (!(await page.locator('.template-default.template-default--nav-open').isVisible())) {
return
}
await page.locator('.nav-toggler >> visible=true').click()
await expect(page.locator('.template-default.template-default--nav-open')).toBeHidden()
}

View File

@@ -1,4 +1,4 @@
import type { SanitizedConfig, Where } from 'payload'
import type { JoinQuery, SanitizedConfig, Where } from 'payload'
import type { ParsedQs } from 'qs-esm'
import {
@@ -18,6 +18,7 @@ type RequestOptions = {
query?: {
depth?: number
fallbackLocale?: string
joins?: JoinQuery
limit?: number
locale?: string
page?: number

View File

@@ -1,7 +1,7 @@
import type { Page } from '@playwright/test'
export async function navigateToListCellLink(page: Page, selector = '.cell-id') {
const cellLink = page.locator(`${selector} a`).first()
export async function navigateToListCellLink(page: Page) {
const cellLink = page.locator(`tbody tr:first-child td a`).first()
const linkURL = await cellLink.getAttribute('href')
await cellLink.click()
await page.waitForURL(`**${linkURL}`)

View File

@@ -19,8 +19,12 @@ export const reorderColumns = async (
togglerSelector?: string
},
) => {
await page.locator(togglerSelector).click()
const columnContainer = page.locator(columnContainerSelector)
const columnContainer = page.locator(columnContainerSelector).first()
const isAlreadyOpen = await columnContainer.isVisible()
if (!isAlreadyOpen) {
await page.locator(togglerSelector).first().click()
}
await expect(page.locator(`${columnContainerSelector}.rah-static--height-auto`)).toBeVisible()
@@ -49,7 +53,7 @@ export const reorderColumns = async (
columnContainer.locator('.column-selector .column-selector__column').first(),
).toHaveText(fromColumn)
await expect(page.locator('table thead tr th').nth(1)).toHaveText(fromColumn)
await expect(page.locator('table thead tr th').nth(1).first()).toHaveText(fromColumn)
// TODO: This wait makes sure the preferences are actually saved. Just waiting for the UI to update is not enough. We should replace this wait
await wait(1000)
}

View File

@@ -0,0 +1,66 @@
import type { CollectionConfig } from 'payload'
import { categoriesSlug, postsSlug } from '../shared.js'
export const Categories: CollectionConfig = {
slug: categoriesSlug,
admin: {
useAsTitle: 'name',
},
fields: [
{
name: 'name',
type: 'text',
},
// Alternative tabs usage
// {
// type: 'tabs',
// tabs: [
// {
// label: 'Unnamed tab',
// fields: [
// {
// name: 'relatedPosts',
// label: 'Related Posts',
// type: 'join',
// collection: postsSlug,
// on: 'category',
// },
// ],
// },
// {
// name: 'group',
// fields: [
// {
// name: 'relatedPosts',
// label: 'Related Posts (Group)',
// type: 'join',
// collection: postsSlug,
// on: 'group.category',
// },
// ],
// },
// ],
// },
{
name: 'relatedPosts',
label: 'Related Posts',
type: 'join',
collection: postsSlug,
on: 'category',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'relatedPosts',
label: 'Related Posts (Group)',
type: 'join',
collection: postsSlug,
on: 'group.category',
},
],
},
],
}

View File

@@ -0,0 +1,33 @@
import type { CollectionConfig } from 'payload'
import { categoriesSlug, postsSlug } from '../shared.js'
export const Posts: CollectionConfig = {
slug: postsSlug,
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'category', 'updatedAt', 'createdAt'],
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'category',
type: 'relationship',
relationTo: categoriesSlug,
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'category',
type: 'relationship',
relationTo: categoriesSlug,
},
],
},
],
}

68
test/joins/config.ts Normal file
View File

@@ -0,0 +1,68 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { Categories } from './collections/Categories.js'
import { Posts } from './collections/Posts.js'
import { seed } from './seed.js'
import { localizedCategoriesSlug, localizedPostsSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
collections: [
Posts,
Categories,
{
slug: localizedPostsSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
localized: true,
},
{
name: 'category',
type: 'relationship',
localized: true,
relationTo: localizedCategoriesSlug,
},
],
},
{
slug: localizedCategoriesSlug,
admin: {
useAsTitle: 'name',
},
fields: [
{
name: 'name',
type: 'text',
},
{
name: 'relatedPosts',
type: 'join',
collection: localizedPostsSlug,
on: 'category',
localized: true,
},
],
},
],
localization: {
locales: ['en', 'es'],
defaultLocale: 'en',
},
onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
await seed(payload)
}
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

186
test/joins/e2e.spec.ts Normal file
View File

@@ -0,0 +1,186 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { reorderColumns } from 'helpers/e2e/reorderColumns.js'
import * as path from 'path'
import { fileURLToPath } from 'url'
import { ensureCompilationIsDone, exactText, initPageConsoleErrorCatch } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { navigateToDoc } from '../helpers/e2e/navigateToDoc.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { categoriesSlug, postsSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
test.describe('Admin Panel', () => {
let page: Page
let categoriesURL: AdminUrlUtil
let postsURL: AdminUrlUtil
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
const { payload, serverURL } = await initPayloadE2ENoConfig({ dirname })
postsURL = new AdminUrlUtil(serverURL, postsSlug)
categoriesURL = new AdminUrlUtil(serverURL, categoriesSlug)
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
})
test('should populate joined relationships in table cells of list view', async () => {
await page.goto(categoriesURL.list)
await expect
.poll(
async () =>
await page
.locator('tbody tr:first-child td.cell-relatedPosts', {
hasText: exactText('Test Post 3, Test Post 2, Test Post 1'),
})
.isVisible(),
)
.toBeTruthy()
})
test('should render initial rows within relationship table', async () => {
await navigateToDoc(page, categoriesURL)
const joinField = page.locator('.field-type.join').first()
await expect(joinField).toBeVisible()
const columns = await joinField.locator('.relationship-table tbody tr').count()
expect(columns).toBe(3)
})
test('should render collection type in first column of relationship table', async () => {
await navigateToDoc(page, categoriesURL)
const joinField = page.locator('.field-type.join').first()
await expect(joinField).toBeVisible()
const collectionTypeColumn = joinField.locator('thead tr th#heading-collection:first-child')
const text = collectionTypeColumn
await expect(text).toHaveText('Type')
const cells = joinField.locator('.relationship-table tbody tr td:first-child .pill__label')
const count = await cells.count()
for (let i = 0; i < count; i++) {
const element = cells.nth(i)
// Perform actions on each element
await expect(element).toBeVisible()
await expect(element).toHaveText('Post')
}
})
test('should render drawer toggler without document link in second column of relationship table', async () => {
await navigateToDoc(page, categoriesURL)
const joinField = page.locator('.field-type.join').first()
await expect(joinField).toBeVisible()
const actionColumn = joinField.locator('tbody tr td:nth-child(2)').first()
const toggler = actionColumn.locator('button.doc-drawer__toggler')
await expect(toggler).toBeVisible()
const link = actionColumn.locator('a')
await expect(link).toBeHidden()
await reorderColumns(page, {
togglerSelector: '.relationship-table__toggle-columns',
columnContainerSelector: '.relationship-table__columns',
fromColumn: 'Category',
toColumn: 'Title',
})
const newActionColumn = joinField.locator('tbody tr td:nth-child(2)').first()
const newToggler = newActionColumn.locator('button.doc-drawer__toggler')
await expect(newToggler).toBeVisible()
const newLink = newActionColumn.locator('a')
await expect(newLink).toBeHidden()
// put columns back in original order for the next test
await reorderColumns(page, {
togglerSelector: '.relationship-table__toggle-columns',
columnContainerSelector: '.relationship-table__columns',
fromColumn: 'Title',
toColumn: 'Category',
})
})
test('should sort relationship table by clicking on column headers', async () => {
await navigateToDoc(page, categoriesURL)
const joinField = page.locator('.field-type.join').first()
await expect(joinField).toBeVisible()
const titleColumn = joinField.locator('thead tr th#heading-title')
const titleAscButton = titleColumn.locator('button.sort-column__asc')
await expect(titleAscButton).toBeVisible()
await titleAscButton.click()
await expect(joinField.locator('tbody tr:first-child td:nth-child(2)')).toHaveText(
'Test Post 1',
)
const titleDescButton = titleColumn.locator('button.sort-column__desc')
await expect(titleDescButton).toBeVisible()
await titleDescButton.click()
await expect(joinField.locator('tbody tr:first-child td:nth-child(2)')).toHaveText(
'Test Post 3',
)
})
test('should update relationship table when new document is created', async () => {
await navigateToDoc(page, categoriesURL)
const joinField = page.locator('.field-type.join').first()
await expect(joinField).toBeVisible()
const addButton = joinField.locator('.relationship-table__actions button.doc-drawer__toggler', {
hasText: exactText('Add new'),
})
await expect(addButton).toBeVisible()
await addButton.click()
const drawer = page.locator('[id^=doc-drawer_posts_1_]')
await expect(drawer).toBeVisible()
const categoryField = drawer.locator('#field-category')
await expect(categoryField).toBeVisible()
const categoryValue = categoryField.locator('.relationship--single-value__text')
await expect(categoryValue).toHaveText('example')
const titleField = drawer.locator('#field-title')
await expect(titleField).toBeVisible()
await titleField.fill('Test Post 4')
await drawer.locator('button[id="action-save"]').click()
await expect(drawer).toBeHidden()
await expect(
joinField.locator('tbody tr td:nth-child(2)', {
hasText: exactText('Test Post 4'),
}),
).toBeVisible()
})
test('should update relationship table when document is updated', async () => {
await navigateToDoc(page, categoriesURL)
const joinField = page.locator('.field-type.join').first()
await expect(joinField).toBeVisible()
const editButton = joinField.locator(
'tbody tr:first-child td:nth-child(2) button.doc-drawer__toggler',
)
await expect(editButton).toBeVisible()
await editButton.click()
const drawer = page.locator('[id^=doc-drawer_posts_1_]')
await expect(drawer).toBeVisible()
const titleField = drawer.locator('#field-title')
await expect(titleField).toBeVisible()
await titleField.fill('Test Post 1 Updated')
await drawer.locator('button[id="action-save"]').click()
await expect(drawer).toBeHidden()
await expect(joinField.locator('tbody tr:first-child td:nth-child(2)')).toHaveText(
'Test Post 1 Updated',
)
})
test('should render empty relationship table when creating new document', async () => {
await page.goto(categoriesURL.create)
const joinField = page.locator('.field-type.join').first()
await expect(joinField).toBeVisible()
await expect(joinField.locator('.relationship-table tbody tr')).toBeHidden()
})
})

View File

@@ -1,5 +1,5 @@
import { rootParserOptions } from '../../eslint.config.js'
import testEslintConfig from '../eslint.config.js'
import { testEslintConfig } from '../eslint.config.js'
/** @typedef {import('eslint').Linter.FlatConfig} */
let FlatConfig

401
test/joins/int.spec.ts Normal file
View File

@@ -0,0 +1,401 @@
import type { Payload } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import type { Category, Post } from './payload-types.js'
import { devUser } from '../credentials.js'
import { idToString } from '../helpers/idToString.js'
import { initPayloadInt } from '../helpers/initPayloadInt.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 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: 'categories',
data: {
name: 'paginate example',
group: {},
},
})
categoryID = idToString(category.id, payload)
for (let i = 0; i < 15; i++) {
await createPost({
title: `test ${i}`,
category: category.id,
group: {
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: '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 populate relationships in joins', async () => {
const { docs } = await payload.find({
limit: 1,
collection: 'posts',
})
expect(docs[0].category.id).toBeDefined()
expect(docs[0].category.name).toBeDefined()
expect(docs[0].category.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',
})
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')
})
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('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')
})
})
})
async function createPost(overrides?: Partial<Post>) {
return payload.create({
collection: 'posts',
data: {
title: 'test',
...overrides,
},
})
}

View File

@@ -11,11 +11,12 @@ export interface Config {
users: UserAuthOperations;
};
collections: {
uploads: Upload;
pages: Page;
posts: Post;
relations: Relation;
categories: Category;
'localized-posts': LocalizedPost;
'localized-categories': LocalizedCategory;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
@@ -23,7 +24,7 @@ export interface Config {
defaultIDType: string;
};
globals: {};
locale: null;
locale: 'en' | 'es';
user: User & {
collection: 'users';
};
@@ -46,74 +47,62 @@ export interface UserAuthOperations {
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "uploads".
*/
export interface Upload {
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages".
*/
export interface Page {
id: string;
title: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
title: string;
title?: string | null;
category?: (string | null) | Category;
group?: {
category?: (string | null) | Category;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relations".
* via the `definition` "categories".
*/
export interface Relation {
export interface Category {
id: string;
hasOne?: (string | null) | Post;
hasOnePoly?:
| ({
relationTo: 'pages';
value: string | Page;
} | null)
| ({
relationTo: 'posts';
value: string | Post;
} | null);
hasMany?: (string | Post)[] | null;
hasManyPoly?:
| (
| {
relationTo: 'pages';
value: string | Page;
}
| {
relationTo: 'posts';
value: string | Post;
}
)[]
| null;
upload?: (string | null) | Upload;
name?: string | null;
relatedPosts?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
group?: {
relatedPosts?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localized-posts".
*/
export interface LocalizedPost {
id: string;
title?: string | null;
category?: (string | null) | LocalizedCategory;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localized-categories".
*/
export interface LocalizedCategory {
id: string;
name?: string | null;
relatedPosts?: {
docs?: (string | LocalizedPost)[] | null;
hasNextPage?: boolean | null;
} | null;
updatedAt: string;
createdAt: string;
}
@@ -134,6 +123,42 @@ export interface User {
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'categories';
value: string | Category;
} | null)
| ({
relationTo: 'localized-posts';
value: string | LocalizedPost;
} | null)
| ({
relationTo: 'localized-categories';
value: string | LocalizedCategory;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null);
editedAt?: string | null;
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".

1708
test/joins/schema.graphql Normal file

File diff suppressed because it is too large Load Diff

65
test/joins/seed.ts Normal file
View File

@@ -0,0 +1,65 @@
import type { Payload } from 'payload'
import { devUser } from '../credentials.js'
import { seedDB } from '../helpers/seed.js'
import { categoriesSlug, collectionSlugs, postsSlug } from './shared.js'
export const seed = async (_payload) => {
await _payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
const category = await _payload.create({
collection: categoriesSlug,
data: {
name: 'example',
group: {},
},
})
await _payload.create({
collection: postsSlug,
data: {
category: category.id,
group: {
category: category.id,
},
title: 'Test Post 1',
},
})
await _payload.create({
collection: postsSlug,
data: {
category: category.id,
group: {
category: category.id,
},
title: 'Test Post 2',
},
})
await _payload.create({
collection: postsSlug,
data: {
category: category.id,
group: {
category: category.id,
},
title: 'Test Post 3',
},
})
}
export async function clearAndSeedEverything(_payload: Payload) {
return await seedDB({
_payload,
collectionSlugs,
seedFunction: seed,
snapshotKey: 'adminTest',
})
}

14
test/joins/shared.ts Normal file
View File

@@ -0,0 +1,14 @@
export const categoriesSlug = 'categories'
export const postsSlug = 'posts'
export const localizedPostsSlug = 'localized-posts'
export const localizedCategoriesSlug = 'localized-categories'
export const collectionSlugs = [
categoriesSlug,
postsSlug,
localizedPostsSlug,
localizedCategoriesSlug,
]

View File

@@ -43,7 +43,6 @@
"@payloadcms/plugin-form-builder": "workspace:*",
"@payloadcms/plugin-nested-docs": "workspace:*",
"@payloadcms/plugin-redirects": "workspace:*",
"@payloadcms/plugin-relationship-object-ids": "workspace:*",
"@payloadcms/plugin-search": "workspace:*",
"@payloadcms/plugin-sentry": "workspace:*",
"@payloadcms/plugin-seo": "workspace:*",

View File

@@ -1 +0,0 @@
uploads

View File

@@ -1,140 +0,0 @@
import { relationshipsAsObjectID } from '@payloadcms/plugin-relationship-object-ids'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import { fileURLToPath } from 'node:url'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
export default buildConfigWithDefaults({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [
{
slug: 'uploads',
upload: true,
fields: [],
},
{
slug: 'pages',
fields: [
{
name: 'title',
type: 'text',
required: true,
},
],
},
{
slug: 'posts',
fields: [
{
name: 'title',
type: 'text',
required: true,
},
],
},
{
slug: 'relations',
fields: [
{
name: 'hasOne',
type: 'relationship',
relationTo: 'posts',
filterOptions: ({ id }) => ({ id: { not_equals: id } }),
},
{
name: 'hasOnePoly',
type: 'relationship',
relationTo: ['pages', 'posts'],
},
{
name: 'hasMany',
type: 'relationship',
relationTo: 'posts',
hasMany: true,
},
{
name: 'hasManyPoly',
type: 'relationship',
relationTo: ['pages', 'posts'],
hasMany: true,
},
{
name: 'upload',
type: 'upload',
relationTo: 'uploads',
},
],
},
],
plugins: [relationshipsAsObjectID()],
onInit: async (payload) => {
if (payload.db.name === 'mongoose') {
await payload.create({
collection: 'users',
data: {
email: 'dev@payloadcms.com',
password: 'test',
},
})
const page = await payload.create({
collection: 'pages',
data: {
title: 'page',
},
})
const post1 = await payload.create({
collection: 'posts',
data: {
title: 'post 1',
},
})
const post2 = await payload.create({
collection: 'posts',
data: {
title: 'post 2',
},
})
const upload = await payload.create({
collection: 'uploads',
data: {},
filePath: path.resolve(dirname, './payload-logo.png'),
})
await payload.create({
collection: 'relations',
depth: 0,
data: {
hasOne: post1.id,
hasOnePoly: { relationTo: 'pages', value: page.id },
hasMany: [post1.id, post2.id],
hasManyPoly: [
{ relationTo: 'posts', value: post1.id },
{ relationTo: 'pages', value: page.id },
],
upload: upload.id,
},
})
await payload.create({
collection: 'relations',
depth: 0,
data: {
hasOnePoly: { relationTo: 'pages', value: page.id },
},
})
}
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

View File

@@ -1,119 +0,0 @@
import type { Payload } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import type { Post, Relation } from './payload-types.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('Relationship Object IDs Plugin', () => {
let relations: Relation[]
let posts: Post[]
let payload: Payload
beforeAll(async () => {
;({ payload } = await initPayloadInt(dirname))
})
it('seeds data accordingly', async () => {
// eslint-disable-next-line jest/no-conditional-in-test
if (payload.db.name === 'mongoose') {
const relationsQuery = await payload.find({
collection: 'relations',
sort: 'createdAt',
})
relations = relationsQuery.docs
const postsQuery = await payload.find({
collection: 'posts',
sort: 'createdAt',
})
posts = postsQuery.docs
expect(relationsQuery.totalDocs).toStrictEqual(2)
expect(postsQuery.totalDocs).toStrictEqual(2)
}
})
it('stores relations as object ids', async () => {
// eslint-disable-next-line jest/no-conditional-in-test
if (payload.db.name === 'mongoose') {
const docs = await payload.db.collections.relations.find()
expect(typeof docs[0].hasOne).toBe('object')
expect(typeof docs[0].hasOnePoly.value).toBe('object')
expect(typeof docs[0].hasMany[0]).toBe('object')
expect(typeof docs[0].hasManyPoly[0].value).toBe('object')
expect(typeof docs[0].upload).toBe('object')
}
})
it('can query by relationship id', async () => {
// eslint-disable-next-line jest/no-conditional-in-test
if (payload.db.name === 'mongoose') {
const { totalDocs } = await payload.find({
collection: 'relations',
where: {
hasOne: {
equals: posts[0].id,
},
},
})
expect(totalDocs).toStrictEqual(1)
}
})
it('populates relations', () => {
// eslint-disable-next-line jest/no-conditional-in-test
if (payload.db.name === 'mongoose') {
const populatedPostTitle =
// eslint-disable-next-line jest/no-conditional-in-test
typeof relations[0].hasOne === 'object' ? relations[0].hasOne.title : undefined
expect(populatedPostTitle).toBeDefined()
const populatedUploadFilename =
// eslint-disable-next-line jest/no-conditional-in-test
typeof relations[0].upload === 'object' ? relations[0].upload.filename : undefined
expect(populatedUploadFilename).toBeDefined()
}
})
it('can query by nested property', async () => {
// eslint-disable-next-line jest/no-conditional-in-test
if (payload.db.name === 'mongoose') {
const { totalDocs } = await payload.find({
collection: 'relations',
where: {
'hasOne.title': {
equals: 'post 1',
},
},
})
expect(totalDocs).toStrictEqual(1)
}
})
it('can query using the "in" operator', async () => {
// eslint-disable-next-line jest/no-conditional-in-test
if (payload.db.name === 'mongoose') {
const { totalDocs } = await payload.find({
collection: 'relations',
where: {
hasMany: {
in: [posts[0].id],
},
},
})
expect(totalDocs).toStrictEqual(1)
}
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -25,7 +25,6 @@ export const tgzToPkgNameMap = {
'@payloadcms/plugin-form-builder': 'payloadcms-plugin-form-builder-*',
'@payloadcms/plugin-nested-docs': 'payloadcms-plugin-nested-docs-*',
'@payloadcms/plugin-redirects': 'payloadcms-plugin-redirects-*',
'@payloadcms/plugin-relationship-object-ids': 'payloadcms-plugin-relationship-object-ids-*',
'@payloadcms/plugin-search': 'payloadcms-plugin-search-*',
'@payloadcms/plugin-sentry': 'payloadcms-plugin-sentry-*',
'@payloadcms/plugin-seo': 'payloadcms-plugin-seo-*',