Morph relation : migration builder (#13173)

This PR will create the migration to be run for the morph relations

- We created a dedicated util to generate the column name and refactored
a little the code in order to have less dependencies and a clearer devX
(updated the snapshot that changed because of this)
- Moved the `createMigrationActions` to its own util as well
- Created the `MorphRelationColumnActionFactory` based on the relation
one

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Guillim
2025-07-17 11:39:45 +02:00
committed by GitHub
parent 0a8a6b652a
commit 530a7dea86
10 changed files with 210 additions and 83 deletions

View File

@ -2,9 +2,10 @@ module.exports = {
plugins: ['@stylistic'],
extends: ['../../.eslintrc.global.cjs'],
ignorePatterns: [
'src/engine/workspace-manager/demo-objects-prefill-data/**',
'src/engine/seeder/data-seeds/**',
'src/engine/seeder/metadata-seeds/**',
'src/engine/workspace-manager/dev-seeder/data/constants/**',
'src/engine/workspace-manager/dev-seeder/data/seeds/**',
'src/utils/email-providers.ts',
'src/engine/core-modules/i18n/locales/generated/**',
'src/engine/core-modules/serverless/drivers/constants/base-typescript-project/src/index.ts',
],
overrides: [

View File

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'class-validator';
import omit from 'lodash.omit';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
@ -19,6 +19,8 @@ import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modul
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { computeMorphRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-relation-field-join-column-name.util';
import { computeRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-relation-field-join-column-name.util';
@Injectable()
export class FieldMetadataMorphRelationService {
@ -59,12 +61,26 @@ export class FieldMetadataMorphRelationService {
const fieldsCreated: FieldMetadataEntity[] = [];
for (const relation of morphRelationsCreationPayload) {
const targetObjectMetadata =
objectMetadataMaps.byId[relation.targetObjectMetadataId];
if (!isDefined(targetObjectMetadata)) {
throw new FieldMetadataException(
'Target object metadata does not exist in the object metadata maps',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
const relationFieldMetadataForCreate =
await this.fieldMetadataRelationService.addCustomRelationFieldMetadataForCreation(
{
fieldMetadataInput: fieldMetadataForCreate,
relationCreationPayload: relation,
objectMetadata,
joinColumnName: computeMorphRelationFieldJoinColumnName({
name: fieldMetadataForCreate.name,
targetObjectMetadataNameSingular:
targetObjectMetadata.nameSingular,
}),
},
);
@ -109,7 +125,9 @@ export class FieldMetadataMorphRelationService {
? RelationType.MANY_TO_ONE
: RelationType.ONE_TO_MANY,
},
objectMetadata,
joinColumnName: computeRelationFieldJoinColumnName({
name: targetFieldMetadataToCreate.name,
}),
},
);

View File

@ -3,7 +3,7 @@ import { Injectable, ValidationError } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { IsEnum, IsString, IsUUID, validateOrReject } from 'class-validator';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
@ -28,6 +28,7 @@ import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/v
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { isFieldMetadataInterfaceOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { computeRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-relation-field-join-column-name.util';
export class RelationCreationPayloadValidation {
@IsUUID()
@ -103,7 +104,9 @@ export class FieldMetadataRelationService {
? RelationType.MANY_TO_ONE
: RelationType.ONE_TO_MANY,
},
objectMetadata,
joinColumnName: computeRelationFieldJoinColumnName({
name: targetFieldMetadataToCreate.name,
}),
});
// todo better type
@ -285,11 +288,11 @@ export class FieldMetadataRelationService {
addCustomRelationFieldMetadataForCreation({
fieldMetadataInput,
relationCreationPayload,
objectMetadata,
joinColumnName,
}: {
fieldMetadataInput: CreateFieldInput;
relationCreationPayload: CreateFieldInput['relationCreationPayload'];
objectMetadata: ObjectMetadataItemWithFieldMaps;
joinColumnName: string;
}) {
const isRelation =
isFieldMetadataInterfaceOfType(
@ -309,13 +312,6 @@ export class FieldMetadataRelationService {
const defaultIcon = 'IconRelationOneToMany';
const joinColumnName = isFieldMetadataInterfaceOfType(
fieldMetadataInput,
FieldMetadataType.MORPH_RELATION,
)
? `${fieldMetadataInput.name}${capitalize(objectMetadata.nameSingular)}Id`
: `${fieldMetadataInput.name}Id`;
return {
...fieldMetadataInput,
icon: fieldMetadataInput.icon ?? defaultIcon,

View File

@ -32,6 +32,7 @@ import {
computeColumnName,
computeCompositeColumnName,
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { createMigrationActions } from 'src/engine/metadata-modules/field-metadata/utils/create-migration-actions.util';
import { generateRatingOptions } from 'src/engine/metadata-modules/field-metadata/utils/generate-rating-optionts.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { isSelectOrMultiSelectFieldMetadata } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util';
@ -54,9 +55,9 @@ import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { ViewService } from 'src/modules/view/services/view.service';
import { computeRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-relation-field-join-column-name.util';
@Injectable()
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> {
@ -578,15 +579,14 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
createdFieldMetadatas.push(...createdFieldMetadataItems);
const fieldMigrationActions = await this.createMigrationActions({
const fieldMigrationActions = await createMigrationActions({
createdFieldMetadataItems,
objectMetadataMap: objectMetadataMaps.byId,
isRemoteCreation: fieldMetadataInput.isRemoteCreation ?? false,
workspaceMigrationFactory: this.workspaceMigrationFactory,
});
if (fieldMetadataInput.type !== FieldMetadataType.MORPH_RELATION) {
migrationActions.push(...fieldMigrationActions);
}
migrationActions.push(...fieldMigrationActions);
}
}
@ -675,7 +675,9 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
{
fieldMetadataInput: fieldMetadataForCreate,
relationCreationPayload: fieldMetadataInput.relationCreationPayload,
objectMetadata,
joinColumnName: computeRelationFieldJoinColumnName({
name: fieldMetadataForCreate.name,
}),
},
);
@ -707,56 +709,4 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
},
);
}
private async createMigrationActions({
createdFieldMetadataItems,
objectMetadataMap,
isRemoteCreation,
}: {
createdFieldMetadataItems: FieldMetadataEntity[];
objectMetadataMap: ObjectMetadataMaps['byId'];
isRemoteCreation: boolean;
}): Promise<WorkspaceMigrationTableAction[]> {
if (isRemoteCreation) {
return [];
}
const migrationActions: WorkspaceMigrationTableAction[] = [];
for (const createdFieldMetadata of createdFieldMetadataItems) {
if (
isFieldMetadataEntityOfType(
createdFieldMetadata,
FieldMetadataType.RELATION,
)
) {
const relationType = createdFieldMetadata.settings?.relationType;
if (relationType === RelationType.ONE_TO_MANY) {
continue;
}
}
const objectMetadata =
objectMetadataMap[createdFieldMetadata.objectMetadataId];
if (!isDefined(objectMetadata)) {
throw new FieldMetadataException(
'Object metadata does not exist',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
migrationActions.push({
name: computeObjectTargetTable(objectMetadata),
action: WorkspaceMigrationTableActionType.ALTER,
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.CREATE,
createdFieldMetadata,
),
});
}
return migrationActions;
}
}

View File

@ -0,0 +1,13 @@
import { capitalize } from 'twenty-shared/utils';
type ComputeMorphRelationFieldJoinColumnNameArgs = {
name: string;
targetObjectMetadataNameSingular: string;
};
export const computeMorphRelationFieldJoinColumnName = ({
name,
targetObjectMetadataNameSingular,
}: ComputeMorphRelationFieldJoinColumnNameArgs) => {
return `${name}${capitalize(targetObjectMetadataNameSingular)}Id`;
};

View File

@ -0,0 +1,9 @@
type ComputeRelationFieldJoinColumnNameArgs = {
name: string;
};
export const computeRelationFieldJoinColumnName = ({
name,
}: ComputeRelationFieldJoinColumnNameArgs) => {
return `${name}Id`;
};

View File

@ -0,0 +1,77 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationTableAction,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
export const createMigrationActions = async ({
createdFieldMetadataItems,
objectMetadataMap,
isRemoteCreation,
workspaceMigrationFactory,
}: {
createdFieldMetadataItems: FieldMetadataEntity[];
objectMetadataMap: ObjectMetadataMaps['byId'];
isRemoteCreation: boolean;
workspaceMigrationFactory: WorkspaceMigrationFactory;
}): Promise<WorkspaceMigrationTableAction[]> => {
if (isRemoteCreation) {
return [];
}
const migrationActions: WorkspaceMigrationTableAction[] = [];
for (const createdFieldMetadata of createdFieldMetadataItems) {
if (
isFieldMetadataEntityOfType(
createdFieldMetadata,
FieldMetadataType.RELATION,
) ||
isFieldMetadataEntityOfType(
createdFieldMetadata,
FieldMetadataType.MORPH_RELATION,
)
) {
const relationType = createdFieldMetadata.settings?.relationType;
if (relationType === RelationType.ONE_TO_MANY) {
continue;
}
}
const objectMetadata =
objectMetadataMap[createdFieldMetadata.objectMetadataId];
if (!isDefined(objectMetadata)) {
throw new FieldMetadataException(
'Object metadata does not exist',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
migrationActions.push({
name: computeObjectTargetTable(objectMetadata),
action: WorkspaceMigrationTableActionType.ALTER,
columns: workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.CREATE,
createdFieldMetadata,
),
});
}
return migrationActions;
};

View File

@ -3,30 +3,93 @@ import { Injectable, Logger } from '@nestjs/common';
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { WorkspaceColumnActionOptions } from 'src/engine/metadata-modules/workspace-migration/interfaces/workspace-column-action-options.interface';
import { ColumnActionAbstractFactory } from 'src/engine/metadata-modules/workspace-migration/factories/column-action-abstract.factory';
import { fieldMetadataTypeToColumnType } from 'src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationColumnAlter,
WorkspaceMigrationColumnCreate,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import {
WorkspaceMigrationException,
WorkspaceMigrationExceptionCode,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.exception';
@Injectable()
export class MorphRelationColumnActionFactory extends ColumnActionAbstractFactory<FieldMetadataType.MORPH_RELATION> {
protected readonly logger = new Logger(MorphRelationColumnActionFactory.name);
protected handleCreateAction(
_fieldMetadata: FieldMetadataInterface<FieldMetadataType.MORPH_RELATION>,
fieldMetadata: FieldMetadataInterface<FieldMetadataType.MORPH_RELATION>,
_options?: WorkspaceColumnActionOptions,
): WorkspaceMigrationColumnCreate[] {
return [];
if (!fieldMetadata.settings || !fieldMetadata.settings.joinColumnName) {
return [];
}
const joinColumnName = fieldMetadata.settings.joinColumnName;
return [
{
action: WorkspaceMigrationColumnActionType.CREATE,
columnName: joinColumnName,
columnType: fieldMetadataTypeToColumnType(FieldMetadataType.UUID),
isArray: false,
isNullable: fieldMetadata.isNullable ?? true,
isUnique: false,
defaultValue: null,
},
];
}
protected handleAlterAction(
_currentFieldMetadata: FieldMetadataInterface<FieldMetadataType.MORPH_RELATION>,
_alteredFieldMetadata: FieldMetadataInterface<FieldMetadataType.MORPH_RELATION>,
currentFieldMetadata: FieldMetadataInterface<FieldMetadataType.MORPH_RELATION>,
alteredFieldMetadata: FieldMetadataInterface<FieldMetadataType.MORPH_RELATION>,
_options?: WorkspaceColumnActionOptions,
): WorkspaceMigrationColumnAlter[] {
return [];
if (!currentFieldMetadata.settings || !alteredFieldMetadata.settings) {
return [];
}
if (
currentFieldMetadata.settings.relationType === RelationType.ONE_TO_MANY
) {
return [];
}
const currentJoinColumnName = currentFieldMetadata.settings.joinColumnName;
const alteredJoinColumnName = alteredFieldMetadata.settings.joinColumnName;
if (!currentJoinColumnName || !alteredJoinColumnName) {
throw new WorkspaceMigrationException(
`Column name not found for current or altered field metadata`,
WorkspaceMigrationExceptionCode.INVALID_FIELD_METADATA,
);
}
return [
{
action: WorkspaceMigrationColumnActionType.ALTER,
currentColumnDefinition: {
columnName: currentJoinColumnName,
columnType: fieldMetadataTypeToColumnType(FieldMetadataType.UUID),
isArray: false,
isNullable: currentFieldMetadata.isNullable ?? true,
isUnique: false,
defaultValue: null,
},
alteredColumnDefinition: {
columnName: alteredJoinColumnName,
columnType: fieldMetadataTypeToColumnType(FieldMetadataType.UUID),
isArray: false,
isNullable: alteredFieldMetadata.isNullable ?? true,
isUnique: false,
defaultValue: null,
},
},
];
}
}

View File

@ -60,7 +60,7 @@ exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "Object metadata relation target not found for relation creation payload",
"message": "Target object metadata does not exist in the object metadata maps",
},
]
`;
@ -153,7 +153,7 @@ exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "Object metadata relation target not found for relation creation payload",
"message": "Target object metadata does not exist in the object metadata maps",
},
]
`;

View File

@ -149,7 +149,7 @@ describe('createOne FieldMetadataService morph relation fields', () => {
if (isManyToOne) {
expect(createdField.settings?.joinColumnName).toBe(
'ownerOpportunityForMorphRelationId',
'ownerPersonForMorphRelationId',
);
} else {
expect(createdField.settings?.joinColumnName).toBeUndefined();