From d03658de013805a1b4c5599744d1f15965472366 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Thu, 19 Dec 2024 17:34:52 -0500 Subject: [PATCH] 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 # --- .../src/utilities/buildJoinAggregation.ts | 9 +- packages/drizzle/src/find.ts | 1 + .../drizzle/src/find/buildFindManyArgs.ts | 3 + packages/drizzle/src/find/findMany.ts | 3 + packages/drizzle/src/find/traverseFields.ts | 26 ++++- packages/drizzle/src/findOne.ts | 1 + .../drizzle/src/queries/sanitizeQueryValue.ts | 6 + packages/drizzle/src/queryDrafts.ts | 1 + packages/payload/src/fields/config/client.ts | 11 ++ .../src/fields/config/sanitizeJoinField.ts | 10 +- packages/payload/src/fields/config/types.ts | 8 +- .../payload/src/utilities/flattenAllFields.ts | 7 +- .../src/elements/RelationshipTable/index.tsx | 1 - packages/ui/src/fields/Join/index.tsx | 39 +++++-- test/joins/collections/Categories.ts | 24 ++++ test/joins/collections/Posts.ts | 24 ++++ test/joins/e2e.spec.ts | 61 ++++++++++ test/joins/int.spec.ts | 31 ++++++ test/joins/payload-types.ts | 104 +++++++++++++++--- 19 files changed, 330 insertions(+), 40 deletions(-) diff --git a/packages/db-mongodb/src/utilities/buildJoinAggregation.ts b/packages/db-mongodb/src/utilities/buildJoinAggregation.ts index 7bb5dd02d0..9de62af98d 100644 --- a/packages/db-mongodb/src/utilities/buildJoinAggregation.ts +++ b/packages/db-mongodb/src/utilities/buildJoinAggregation.ts @@ -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, diff --git a/packages/drizzle/src/find.ts b/packages/drizzle/src/find.ts index 1446572642..98d78e4d1b 100644 --- a/packages/drizzle/src/find.ts +++ b/packages/drizzle/src/find.ts @@ -17,6 +17,7 @@ export const find: Find = async function find( return findMany({ adapter: this, + collectionSlug: collectionConfig.slug, fields: collectionConfig.flattenedFields, joins, limit, diff --git a/packages/drizzle/src/find/buildFindManyArgs.ts b/packages/drizzle/src/find/buildFindManyArgs.ts index 280dd92cf4..a8b0ca2435 100644 --- a/packages/drizzle/src/find/buildFindManyArgs.ts +++ b/packages/drizzle/src/find/buildFindManyArgs.ts @@ -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, diff --git a/packages/drizzle/src/find/findMany.ts b/packages/drizzle/src/find/findMany.ts index ab0db070a9..01e886f8ca 100644 --- a/packages/drizzle/src/find/findMany.ts +++ b/packages/drizzle/src/find/findMany.ts @@ -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, diff --git a/packages/drizzle/src/find/traverseFields.ts b/packages/drizzle/src/find/traverseFields.ts index 712d39da0f..9a989b6e77 100644 --- a/packages/drizzle/src/find/traverseFields.ts +++ b/packages/drizzle/src/find/traverseFields.ts @@ -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], } diff --git a/packages/drizzle/src/findOne.ts b/packages/drizzle/src/findOne.ts index c8fa80ce93..4dbcf14ab9 100644 --- a/packages/drizzle/src/findOne.ts +++ b/packages/drizzle/src/findOne.ts @@ -16,6 +16,7 @@ export async function findOne( const { docs } = await findMany({ adapter: this, + collectionSlug: collection, fields: collectionConfig.flattenedFields, joins, limit: 1, diff --git a/packages/drizzle/src/queries/sanitizeQueryValue.ts b/packages/drizzle/src/queries/sanitizeQueryValue.ts index a6abcf1e0b..c8ca2f3bd4 100644 --- a/packages/drizzle/src/queries/sanitizeQueryValue.ts +++ b/packages/drizzle/src/queries/sanitizeQueryValue.ts @@ -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), diff --git a/packages/drizzle/src/queryDrafts.ts b/packages/drizzle/src/queryDrafts.ts index dd41bd3c66..474f3f2a45 100644 --- a/packages/drizzle/src/queryDrafts.ts +++ b/packages/drizzle/src/queryDrafts.ts @@ -21,6 +21,7 @@ export const queryDrafts: QueryDrafts = async function queryDrafts( const result = await findMany({ adapter: this, + collectionSlug: collection, fields, joins, limit, diff --git a/packages/payload/src/fields/config/client.ts b/packages/payload/src/fields/config/client.ts index 1e454ba6d3..3c905a5099 100644 --- a/packages/payload/src/fields/config/client.ts +++ b/packages/payload/src/fields/config/client.ts @@ -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': { diff --git a/packages/payload/src/fields/config/sanitizeJoinField.ts b/packages/payload/src/fields/config/sanitizeJoinField.ts index c05c82ae4e..566280f2da 100644 --- a/packages/payload/src/fields/config/sanitizeJoinField.ts +++ b/packages/payload/src/fields/config/sanitizeJoinField.ts @@ -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 { diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 8eff3f25f2..1f2e1b972f 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -1425,7 +1425,7 @@ export type JoinField = { export type JoinFieldClient = { admin?: AdminClient & Pick -} & FieldBaseClient & +} & { targetField: Pick } & FieldBaseClient & Pick< JoinField, 'collection' | 'defaultLimit' | 'defaultSort' | 'index' | 'maxDepth' | 'on' | 'type' | 'where' @@ -1451,6 +1451,10 @@ export type FlattenedTabAsField = { flattenedFields: FlattenedField[] } & MarkRequired +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 diff --git a/packages/payload/src/utilities/flattenAllFields.ts b/packages/payload/src/utilities/flattenAllFields.ts index 8aa7c07737..8815d511e8 100644 --- a/packages/payload/src/utilities/flattenAllFields.ts +++ b/packages/payload/src/utilities/flattenAllFields.ts @@ -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)) { diff --git a/packages/ui/src/elements/RelationshipTable/index.tsx b/packages/ui/src/elements/RelationshipTable/index.tsx index 2d00b244e6..8b755810b3 100644 --- a/packages/ui/src/elements/RelationshipTable/index.tsx +++ b/packages/ui/src/elements/RelationshipTable/index.tsx @@ -61,7 +61,6 @@ export const RelationshipTable: React.FC = (pro relationTo, } = props const [Table, setTable] = useState(null) - const { getEntityConfig } = useConfig() const { permissions } = useAuth() diff --git a/packages/ui/src/fields/Join/index.tsx b/packages/ui/src/fields/Join/index.tsx index f7c47cc414..724236398b 100644 --- a/packages/ui/src/fields/Join/index.tsx +++ b/packages/ui/src/fields/Join/index.tsx @@ -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 (
{ 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') diff --git a/test/joins/int.spec.ts b/test/joins/int.spec.ts index da47c7bb5e..627c65b9f6 100644 --- a/test/joins/int.spec.ts +++ b/test/joins/int.spec.ts @@ -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, diff --git a/test/joins/payload-types.ts b/test/joins/payload-types.ts index ecb770f6e2..ab340cf245 100644 --- a/test/joins/payload-types.ts +++ b/test/joins/payload-types.ts @@ -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 { category?: T; categories?: T; categoriesLocalized?: T; + polymorphic?: T; + polymorphics?: T; + localizedPolymorphic?: T; + localizedPolymorphics?: T; group?: | T | { @@ -525,6 +591,10 @@ export interface CategoriesSelect { }; arrayPosts?: T; blocksPosts?: T; + polymorphic?: T; + polymorphics?: T; + localizedPolymorphic?: T; + localizedPolymorphics?: T; singulars?: T; filtered?: T; updatedAt?: T;