feat: join field with polymorphic relationships (#9990)

### What?
The join field had a limitation imposed that prevents it from targeting
polymorphic relationship fields. With this change we can support any
relationship fields.

### Why?
Improves the functionality of join field.

### How?
Extended the database adapters and removed the config sanitization that
would throw an error when polymorphic relationships were used.

Fixes #
This commit is contained in:
Dan Ribbens
2024-12-19 17:34:52 -05:00
committed by GitHub
parent 07be617963
commit d03658de01
19 changed files with 330 additions and 40 deletions

View File

@@ -81,6 +81,11 @@ export const buildJoinAggregation = async ({
})
}
let polymorphicSuffix = ''
if (Array.isArray(join.targetField.relationTo)) {
polymorphicSuffix = '.value'
}
if (adapter.payload.config.localization && locale === 'all') {
adapter.payload.config.localization.localeCodes.forEach((code) => {
const as = `${versions ? `version.${join.joinPath}` : join.joinPath}${code}`
@@ -89,7 +94,7 @@ export const buildJoinAggregation = async ({
{
$lookup: {
as: `${as}.docs`,
foreignField: `${join.field.on}${code}`,
foreignField: `${join.field.on}${code}${polymorphicSuffix}`,
from: adapter.collections[slug].collection.name,
localField: versions ? 'parent' : '_id',
pipeline,
@@ -130,7 +135,7 @@ export const buildJoinAggregation = async ({
{
$lookup: {
as: `${as}.docs`,
foreignField: `${join.field.on}${localeSuffix}`,
foreignField: `${join.field.on}${localeSuffix}${polymorphicSuffix}`,
from: adapter.collections[slug].collection.name,
localField: versions ? 'parent' : '_id',
pipeline,

View File

@@ -17,6 +17,7 @@ export const find: Find = async function find(
return findMany({
adapter: this,
collectionSlug: collectionConfig.slug,
fields: collectionConfig.flattenedFields,
joins,
limit,

View File

@@ -9,6 +9,7 @@ import { traverseFields } from './traverseFields.js'
type BuildFindQueryArgs = {
adapter: DrizzleAdapter
collectionSlug?: string
depth: number
fields: FlattenedField[]
joinQuery?: JoinQuery
@@ -32,6 +33,7 @@ export type Result = {
// a collection field structure
export const buildFindManyArgs = ({
adapter,
collectionSlug,
depth,
fields,
joinQuery,
@@ -74,6 +76,7 @@ export const buildFindManyArgs = ({
traverseFields({
_locales,
adapter,
collectionSlug,
currentArgs: result,
currentTableName: tableName,
depth,

View File

@@ -13,6 +13,7 @@ import { buildFindManyArgs } from './buildFindManyArgs.js'
type Args = {
adapter: DrizzleAdapter
collectionSlug?: string
fields: FlattenedField[]
tableName: string
versions?: boolean
@@ -20,6 +21,7 @@ type Args = {
export const findMany = async function find({
adapter,
collectionSlug,
fields,
joins: joinQuery,
limit: limitArg,
@@ -70,6 +72,7 @@ export const findMany = async function find({
const findManyArgs = buildFindManyArgs({
adapter,
collectionSlug,
depth: 0,
fields,
joinQuery,

View File

@@ -16,6 +16,7 @@ import { chainMethods } from './chainMethods.js'
type TraverseFieldArgs = {
_locales: Result
adapter: DrizzleAdapter
collectionSlug?: string
currentArgs: Result
currentTableName: string
depth?: number
@@ -42,6 +43,7 @@ type TraverseFieldArgs = {
export const traverseFields = ({
_locales,
adapter,
collectionSlug,
currentArgs,
currentTableName,
depth,
@@ -292,6 +294,7 @@ export const traverseFields = ({
traverseFields({
_locales,
adapter,
collectionSlug,
currentArgs,
currentTableName,
depth,
@@ -357,13 +360,26 @@ export const traverseFields = ({
? adapter.tables[currentTableName].parent
: adapter.tables[currentTableName].id
let joinQueryWhere: Where = {
[field.on]: {
equals: rawConstraint(currentIDColumn),
},
let joinQueryWhere: Where
if (Array.isArray(field.targetField.relationTo)) {
joinQueryWhere = {
[field.on]: {
equals: {
relationTo: collectionSlug,
value: rawConstraint(currentIDColumn),
},
},
}
} else {
joinQueryWhere = {
[field.on]: {
equals: rawConstraint(currentIDColumn),
},
}
}
if (where) {
if (where && Object.keys(where).length) {
joinQueryWhere = {
and: [joinQueryWhere, where],
}

View File

@@ -16,6 +16,7 @@ export async function findOne<T extends TypeWithID>(
const { docs } = await findMany({
adapter: this,
collectionSlug: collection,
fields: collectionConfig.flattenedFields,
joins,
limit: 1,

View File

@@ -142,6 +142,12 @@ export const sanitizeQueryValue = ({
collection: adapter.payload.collections[val.relationTo],
})
if (isRawConstraint(val.value)) {
return {
operator,
value: val.value.value,
}
}
return {
operator,
value: idType === 'number' ? Number(val.value) : String(val.value),

View File

@@ -21,6 +21,7 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(
const result = await findMany({
adapter: this,
collectionSlug: collection,
fields,
joins,
limit,

View File

@@ -8,6 +8,7 @@ import type {
ClientField,
Field,
FieldBase,
JoinFieldClient,
LabelsClient,
RadioFieldClient,
RowFieldClient,
@@ -229,6 +230,16 @@ export const createClientField = ({
break
}
case 'join': {
const field = clientField as JoinFieldClient
field.targetField = {
relationTo: field.targetField.relationTo,
}
break
}
case 'radio':
// falls through
case 'select': {

View File

@@ -1,6 +1,6 @@
import type { SanitizedJoin, SanitizedJoins } from '../../collections/config/types.js'
import type { Config } from '../../config/types.js'
import type { JoinField, RelationshipField, UploadField } from './types.js'
import type { FlattenedJoinField, JoinField, RelationshipField, UploadField } from './types.js'
import { APIError } from '../../errors/index.js'
import { InvalidFieldJoin } from '../../errors/InvalidFieldJoin.js'
@@ -12,7 +12,7 @@ export const sanitizeJoinField = ({
joins,
}: {
config: Config
field: JoinField
field: FlattenedJoinField | JoinField
joinPath?: string
joins?: SanitizedJoins
}) => {
@@ -74,9 +74,6 @@ export const sanitizeJoinField = ({
if (!joinRelationship) {
throw new InvalidFieldJoin(join.field)
}
if (Array.isArray(joinRelationship.relationTo)) {
throw new APIError('Join fields cannot be used with polymorphic relationships.')
}
join.targetField = joinRelationship
@@ -85,6 +82,9 @@ export const sanitizeJoinField = ({
// override the join field hasMany property to use whatever the relationship field has
field.hasMany = joinRelationship.hasMany
// @ts-expect-error converting JoinField to FlattenedJoinField to track targetField
field.targetField = join.targetField
if (!joins[field.collection]) {
joins[field.collection] = [join]
} else {

View File

@@ -1425,7 +1425,7 @@ export type JoinField = {
export type JoinFieldClient = {
admin?: AdminClient &
Pick<JoinField['admin'], 'allowCreate' | 'defaultColumns' | 'disableBulkEdit' | 'readOnly'>
} & FieldBaseClient &
} & { targetField: Pick<RelationshipFieldClient, 'relationTo'> } & FieldBaseClient &
Pick<
JoinField,
'collection' | 'defaultLimit' | 'defaultSort' | 'index' | 'maxDepth' | 'on' | 'type' | 'where'
@@ -1451,6 +1451,10 @@ export type FlattenedTabAsField = {
flattenedFields: FlattenedField[]
} & MarkRequired<TabAsField, 'name'>
export type FlattenedJoinField = {
targetField: RelationshipField | UploadField
} & JoinField
export type FlattenedField =
| CheckboxField
| CodeField
@@ -1459,8 +1463,8 @@ export type FlattenedField =
| FlattenedArrayField
| FlattenedBlocksField
| FlattenedGroupField
| FlattenedJoinField
| FlattenedTabAsField
| JoinField
| JSONField
| NumberField
| PointField

View File

@@ -1,4 +1,4 @@
import type { Field, FlattenedField } from '../fields/config/types.js'
import type { Field, FlattenedField, FlattenedJoinField } from '../fields/config/types.js'
import { tabHasName } from '../fields/config/types.js'
@@ -36,6 +36,11 @@ export const flattenAllFields = ({ fields }: { fields: Field[] }): FlattenedFiel
break
}
case 'join': {
result.push(field as FlattenedJoinField)
break
}
case 'tabs': {
for (const tab of field.tabs) {
if (!tabHasName(tab)) {

View File

@@ -61,7 +61,6 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
relationTo,
} = props
const [Table, setTable] = useState<React.ReactNode>(null)
const { getEntityConfig } = useConfig()
const { permissions } = useAuth()

View File

@@ -29,10 +29,12 @@ const ObjectId = (ObjectIdImport.default ||
* Recursively builds the default data for joined collection
*/
const getInitialDrawerData = ({
collectionSlug,
docID,
fields,
segments,
}: {
collectionSlug: string
docID: number | string
fields: ClientField[]
segments: string[]
@@ -48,9 +50,15 @@ const getInitialDrawerData = ({
}
if (field.type === 'relationship' || field.type === 'upload') {
let value: { relationTo: string; value: number | string } | number | string = docID
if (Array.isArray(field.relationTo)) {
value = {
relationTo: collectionSlug,
value: docID,
}
}
return {
// TODO: Handle polymorphic https://github.com/payloadcms/payload/pull/9990
[field.name]: field.hasMany ? [docID] : docID,
[field.name]: field.hasMany ? [value] : value,
}
}
@@ -58,12 +66,18 @@ const getInitialDrawerData = ({
if (field.type === 'tab' || field.type === 'group') {
return {
[field.name]: getInitialDrawerData({ docID, fields: field.fields, segments: nextSegments }),
[field.name]: getInitialDrawerData({
collectionSlug,
docID,
fields: field.fields,
segments: nextSegments,
}),
}
}
if (field.type === 'array') {
const initialData = getInitialDrawerData({
collectionSlug,
docID,
fields: field.fields,
segments: nextSegments,
@@ -79,6 +93,7 @@ const getInitialDrawerData = ({
if (field.type === 'blocks') {
for (const block of field.blocks) {
const blockInitialData = getInitialDrawerData({
collectionSlug,
docID,
fields: block.fields,
segments: nextSegments,
@@ -110,7 +125,7 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
path,
} = props
const { id: docID } = useDocumentInfo()
const { id: docID, docConfig } = useDocumentInfo()
const {
config: { collections },
@@ -126,9 +141,18 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
return null
}
let value: { relationTo: string; value: number | string } | number | string = docID
if (Array.isArray(field.targetField.relationTo)) {
value = {
relationTo: docConfig.slug,
value,
}
}
const where = {
[on]: {
equals: docID,
equals: value,
},
}
@@ -139,17 +163,18 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
}
return where
}, [docID, on, field.where])
}, [docID, field.targetField.relationTo, field.where, on, docConfig.slug])
const initialDrawerData = useMemo(() => {
const relatedCollection = collections.find((collection) => collection.slug === field.collection)
return getInitialDrawerData({
collectionSlug: docConfig.slug,
docID,
fields: relatedCollection.fields,
segments: field.on.split('.'),
})
}, [collections, field.on, docID, field.collection])
}, [collections, field.on, field.collection, docConfig.slug, docID])
return (
<div

View File

@@ -115,6 +115,30 @@ export const Categories: CollectionConfig = {
collection: 'posts',
on: 'blocks.category',
},
{
name: 'polymorphic',
type: 'join',
collection: postsSlug,
on: 'polymorphic',
},
{
name: 'polymorphics',
type: 'join',
collection: postsSlug,
on: 'polymorphics',
},
{
name: 'localizedPolymorphic',
type: 'join',
collection: postsSlug,
on: 'localizedPolymorphic',
},
{
name: 'localizedPolymorphics',
type: 'join',
collection: postsSlug,
on: 'localizedPolymorphics',
},
{
name: 'singulars',
type: 'join',

View File

@@ -53,6 +53,30 @@ export const Posts: CollectionConfig = {
hasMany: true,
localized: true,
},
{
name: 'polymorphic',
type: 'relationship',
relationTo: ['categories', 'users'],
},
{
name: 'polymorphics',
type: 'relationship',
relationTo: ['categories', 'users'],
hasMany: true,
},
{
name: 'localizedPolymorphic',
type: 'relationship',
relationTo: ['categories', 'users'],
localized: true,
},
{
name: 'localizedPolymorphics',
type: 'relationship',
relationTo: ['categories', 'users'],
hasMany: true,
localized: true,
},
{
name: 'group',
type: 'group',

View File

@@ -291,6 +291,67 @@ test.describe('Join Field', () => {
await expect(joinField.locator('tbody .row-1')).toContainText('Test Post 1 Updated')
})
test('should create join collection from polymorphic relationships', async () => {
await page.goto(categoriesURL.edit(categoryID))
const joinField = page.locator('#field-polymorphic.field-type.join')
await expect(joinField).toBeVisible()
await joinField.locator('.relationship-table__add-new').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 polymorphic Post')
await expect(drawer.locator('#field-polymorphic')).toContainText('example')
await drawer.locator('button[id="action-save"]').click()
await expect(drawer).toBeHidden()
await expect(joinField.locator('tbody .row-1')).toContainText('Test polymorphic Post')
})
test('should create join collection from polymorphic, hasMany relationships', async () => {
await page.goto(categoriesURL.edit(categoryID))
const joinField = page.locator('#field-polymorphics.field-type.join')
await expect(joinField).toBeVisible()
await joinField.locator('.relationship-table__add-new').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 polymorphic Post')
await expect(drawer.locator('#field-polymorphics')).toContainText('example')
await drawer.locator('button[id="action-save"]').click()
await expect(drawer).toBeHidden()
await expect(joinField.locator('tbody .row-1')).toContainText('Test polymorphic Post')
})
test('should create join collection from polymorphic localized relationships', async () => {
await page.goto(categoriesURL.edit(categoryID))
const joinField = page.locator('#field-localizedPolymorphic.field-type.join')
await expect(joinField).toBeVisible()
await joinField.locator('.relationship-table__add-new').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 polymorphic Post')
await expect(drawer.locator('#field-localizedPolymorphic')).toContainText('example')
await drawer.locator('button[id="action-save"]').click()
await expect(drawer).toBeHidden()
await expect(joinField.locator('tbody .row-1')).toContainText('Test polymorphic Post')
})
test('should create join collection from polymorphic, hasMany, localized relationships', async () => {
await page.goto(categoriesURL.edit(categoryID))
const joinField = page.locator('#field-localizedPolymorphics.field-type.join')
await expect(joinField).toBeVisible()
await joinField.locator('.relationship-table__add-new').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 polymorphic Post')
await expect(drawer.locator('#field-localizedPolymorphics')).toContainText('example')
await drawer.locator('button[id="action-save"]').click()
await expect(drawer).toBeHidden()
await expect(joinField.locator('tbody .row-1')).toContainText('Test polymorphic Post')
})
test('should render empty relationship table when creating new document', async () => {
await page.goto(categoriesURL.create)
const joinField = page.locator('#field-relatedPosts.field-type.join')

View File

@@ -90,6 +90,26 @@ describe('Joins Field', () => {
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,
@@ -216,6 +236,17 @@ describe('Joins Field', () => {
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 filter joins using where query', async () => {
const categoryWithPosts = await payload.findByID({
id: category.id,

View File

@@ -38,6 +38,10 @@ export interface Config {
'group.camelCasePosts': 'posts';
arrayPosts: 'posts';
blocksPosts: 'posts';
polymorphic: 'posts';
polymorphics: 'posts';
localizedPolymorphic: 'posts';
localizedPolymorphics: 'posts';
filtered: 'posts';
hiddenPosts: 'hidden-posts';
singulars: 'singular';
@@ -123,6 +127,48 @@ export interface Post {
category?: (string | null) | Category;
categories?: (string | Category)[] | null;
categoriesLocalized?: (string | Category)[] | null;
polymorphic?:
| ({
relationTo: 'categories';
value: string | Category;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null);
polymorphics?:
| (
| {
relationTo: 'categories';
value: string | Category;
}
| {
relationTo: 'users';
value: string | User;
}
)[]
| null;
localizedPolymorphic?:
| ({
relationTo: 'categories';
value: string | Category;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null);
localizedPolymorphics?:
| (
| {
relationTo: 'categories';
value: string | Category;
}
| {
relationTo: 'users';
value: string | User;
}
)[]
| null;
group?: {
category?: (string | null) | Category;
camelCaseCategory?: (string | null) | Category;
@@ -207,6 +253,22 @@ export interface Category {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
polymorphic?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
polymorphics?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
localizedPolymorphic?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
localizedPolymorphics?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
singulars?: {
docs?: (string | Singular)[] | null;
hasNextPage?: boolean | null;
@@ -239,6 +301,23 @@ export interface Singular {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "versions".
@@ -347,23 +426,6 @@ export interface RestrictedPost {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -481,6 +543,10 @@ export interface PostsSelect<T extends boolean = true> {
category?: T;
categories?: T;
categoriesLocalized?: T;
polymorphic?: T;
polymorphics?: T;
localizedPolymorphic?: T;
localizedPolymorphics?: T;
group?:
| T
| {
@@ -525,6 +591,10 @@ export interface CategoriesSelect<T extends boolean = true> {
};
arrayPosts?: T;
blocksPosts?: T;
polymorphic?: T;
polymorphics?: T;
localizedPolymorphic?: T;
localizedPolymorphics?: T;
singulars?: T;
filtered?: T;
updatedAt?: T;