MORPH for relationDecorator (#15420)
# Adding MORPH support to the relationDecorator
Extends the @WorkspaceRelation decorator to support morph relations by
adding isMorphRelation and morphId options
The implementation enforces type safety through TypeScript discriminated
unions, ensuring morphId is required when isMorphRelation: true, and
includes runtime validation that throws a descriptive error if morphId
is missing.
The morph relation metadata is properly stored in metadataArgsStorage
and integrates seamlessly with the existing StandardFieldRelationFactory
to convert decorated fields into FieldMetadataType.MORPH_RELATION
entities with appropriate database constraints.
To test :
in person entity, add
```ts
@WorkspaceRelation({
standardId: PERSON_STANDARD_FIELD_IDS.testRelatedRecordCompany,
type: RelationType.MANY_TO_ONE,
label: msg`Test Related Record of Company`,
description: msg`Test relation to company`,
icon: 'IconLink',
inverseSideTarget: () => CompanyWorkspaceEntity,
inverseSideFieldKey: 'testRelatedPeople',
onDelete: RelationOnDeleteAction.CASCADE,
isMorphRelation: true,
morphId: PERSON_STANDARD_FIELD_IDS.testRelatedRecordMorphId,
})
@WorkspaceIsNullable()
@WorkspaceIsSystem()
testRelatedRecordCompany: Relation<CompanyWorkspaceEntity> | null;
@WorkspaceJoinColumn('testRelatedRecordCompany')
testRelatedRecordCompanyId: string | null;
@WorkspaceRelation({
standardId: PERSON_STANDARD_FIELD_IDS.testRelatedRecordOpportunity,
type: RelationType.MANY_TO_ONE,
label: msg`Test Related Record of Opportunity`,
description: msg`Test relation to opportunity`,
icon: 'IconLink',
inverseSideTarget: () => OpportunityWorkspaceEntity,
inverseSideFieldKey: 'testRelatedPeople',
onDelete: RelationOnDeleteAction.CASCADE,
isMorphRelation: true,
morphId: PERSON_STANDARD_FIELD_IDS.testRelatedRecordMorphId,
})
@WorkspaceIsNullable()
@WorkspaceIsSystem()
testRelatedRecordOpportunity: Relation<OpportunityWorkspaceEntity> | null;
@WorkspaceJoinColumn('testRelatedRecordOpportunity')
testRelatedRecordOpportunityId: string | null;
```
in opportunity,
```ts
@WorkspaceRelation({
standardId: OPPORTUNITY_STANDARD_FIELD_IDS.testRelatedPeople,
type: RelationType.ONE_TO_MANY,
label: msg`Test Related People`,
description: msg`People linked to the opportunity via Test Related Record`,
icon: 'IconUsers',
inverseSideTarget: () => PersonWorkspaceEntity,
inverseSideFieldKey: 'testRelatedRecordOpportunity',
onDelete: RelationOnDeleteAction.SET_NULL,
})
@WorkspaceIsNullable()
@WorkspaceIsSystem()
testRelatedPeople: Relation<PersonWorkspaceEntity[]>;
```
in company,
```ts
@WorkspaceRelation({
standardId: COMPANY_STANDARD_FIELD_IDS.testRelatedPeople,
type: RelationType.ONE_TO_MANY,
label: msg`Test Related People`,
description: msg`People linked to the company via Test Related Record`,
icon: 'IconUsers',
inverseSideTarget: () => PersonWorkspaceEntity,
inverseSideFieldKey: 'testRelatedRecordCompany',
onDelete: RelationOnDeleteAction.SET_NULL,
})
@WorkspaceIsNullable()
@WorkspaceIsSystem()
testRelatedPeople: Relation<PersonWorkspaceEntity[]>;
```
In constants/standard-field-ids.ts, add the standard fields ids
```ts
// COMPANY_STANDARD_FIELD_IDS
testRelatedPeople: '20202020-7a90-47e9-b31f-916a716fd212',
// OPPORTUNITY_STANDARD_FIELD_IDS
testRelatedPeople: '20202020-7a90-47e9-b31f-916a716fd213',
// PERSON_STANDARD_FIELD_IDS
testRelatedRecordOpportunity: '20202020-5a90-47e9-b31f-916a716fd211',
testRelatedRecordCompany: '20202020-1eb2-4298-910b-66b015b36d72',
testRelatedRecordMorphId: '20202020-6ccf-48e3-bb92-bc9961bc011e',
```
This commit is contained in:
+41
-8
@@ -1,4 +1,6 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { isDefined, isUUID } from 'class-validator';
|
||||
import { CustomError } from 'twenty-shared/utils';
|
||||
import { type ObjectType } from 'typeorm';
|
||||
|
||||
import { type RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface';
|
||||
@@ -9,7 +11,7 @@ import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/
|
||||
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
|
||||
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
|
||||
interface WorkspaceRelationBaseOptions<TClass> {
|
||||
interface WorkspaceRelationMinimumBaseOptions<TClass> {
|
||||
standardId: string;
|
||||
label: MessageDescriptor;
|
||||
description?:
|
||||
@@ -21,17 +23,32 @@ interface WorkspaceRelationBaseOptions<TClass> {
|
||||
onDelete?: RelationOnDeleteAction;
|
||||
}
|
||||
|
||||
interface WorkspaceOtherRelationOptions<TClass>
|
||||
extends WorkspaceRelationBaseOptions<TClass> {
|
||||
type: RelationType.ONE_TO_MANY;
|
||||
interface WorkspaceRegularRelationBaseOptions<TClass>
|
||||
extends WorkspaceRelationMinimumBaseOptions<TClass> {
|
||||
isMorphRelation?: false;
|
||||
}
|
||||
|
||||
interface WorkspaceManyToOneRelationOptions<TClass extends object>
|
||||
extends WorkspaceRelationBaseOptions<TClass> {
|
||||
type: RelationType.MANY_TO_ONE;
|
||||
inverseSideFieldKey: keyof TClass;
|
||||
interface WorkspaceMorphRelationBaseOptions<TClass>
|
||||
extends WorkspaceRelationMinimumBaseOptions<TClass> {
|
||||
isMorphRelation: true;
|
||||
morphId: string;
|
||||
}
|
||||
|
||||
type WorkspaceRelationBaseOptions<TClass> =
|
||||
| WorkspaceRegularRelationBaseOptions<TClass>
|
||||
| WorkspaceMorphRelationBaseOptions<TClass>;
|
||||
|
||||
type WorkspaceOtherRelationOptions<TClass> =
|
||||
WorkspaceRelationBaseOptions<TClass> & {
|
||||
type: RelationType.ONE_TO_MANY;
|
||||
};
|
||||
|
||||
type WorkspaceManyToOneRelationOptions<TClass extends object> =
|
||||
WorkspaceRelationBaseOptions<TClass> & {
|
||||
type: RelationType.MANY_TO_ONE;
|
||||
inverseSideFieldKey: keyof TClass;
|
||||
};
|
||||
|
||||
type WorkspaceRelationOptions<TClass extends object> =
|
||||
| WorkspaceOtherRelationOptions<TClass>
|
||||
| WorkspaceManyToOneRelationOptions<TClass>;
|
||||
@@ -73,6 +90,20 @@ export function WorkspaceRelation<TClass extends object>(
|
||||
const label = options.label.message ?? '';
|
||||
const isLabelSyncedWithName = computeMetadataNameFromLabel(label) === name;
|
||||
|
||||
if (options.isMorphRelation && !isDefined(options.morphId)) {
|
||||
throw new CustomError(
|
||||
'Morph relation must have a morph id',
|
||||
'MORPH_RELATION_MUST_HAVE_MORPH_ID',
|
||||
);
|
||||
}
|
||||
|
||||
if (options.isMorphRelation && !isUUID(options.morphId)) {
|
||||
throw new CustomError(
|
||||
'Morph ID must be a valid UUID',
|
||||
'INVALID_MORPH_ID_FORMAT',
|
||||
);
|
||||
}
|
||||
|
||||
metadataArgsStorage.addRelations({
|
||||
target: object.constructor,
|
||||
standardId: options.standardId,
|
||||
@@ -97,6 +128,8 @@ export function WorkspaceRelation<TClass extends object>(
|
||||
isSystem,
|
||||
isUIReadOnly,
|
||||
gate,
|
||||
isMorphRelation: options.isMorphRelation ?? false,
|
||||
morphId: options.isMorphRelation ? options.morphId : undefined,
|
||||
isLabelSyncedWithName,
|
||||
});
|
||||
};
|
||||
|
||||
+10
@@ -91,4 +91,14 @@ export interface WorkspaceRelationMetadataArgs {
|
||||
readonly isActive?: boolean;
|
||||
|
||||
readonly isLabelSyncedWithName: boolean;
|
||||
|
||||
/**
|
||||
* Is morph relation.
|
||||
*/
|
||||
readonly isMorphRelation: boolean;
|
||||
|
||||
/**
|
||||
* Morph id.
|
||||
*/
|
||||
readonly morphId?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user