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:
Guillim
2025-11-02 13:31:48 +01:00
committed by GitHub
parent 498d89633c
commit 53bb534af8
11 changed files with 190 additions and 47 deletions
@@ -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,
});
};
@@ -91,4 +91,14 @@ export interface WorkspaceRelationMetadataArgs {
readonly isActive?: boolean;
readonly isLabelSyncedWithName: boolean;
/**
* Is morph relation.
*/
readonly isMorphRelation: boolean;
/**
* Morph id.
*/
readonly morphId?: string;
}