From 8ea816b7efd8981d412e21559784448357604543 Mon Sep 17 00:00:00 2001 From: Guillim Date: Fri, 25 Jul 2025 16:40:51 +0200 Subject: [PATCH] morph relation : renaming an object (#13404) # Why If we have a Morph Relation, like : Opportunity <-> Company & People Let's say it's a MANY_TO_ONE on Opportunity side Then we have two joinColumnNames looking like - ownerPersonId - ownerCompanyId Let's say someone renames the obejct Person (assume we can even though standard obejcts cannot be renames per say at the moment in the API) We need to update the joinColumnName and create the associated migrations --- .../object-metadata.exception.ts | 1 + .../object-metadata.service.ts | 15 ++ .../object-metadata-field-relation.service.ts | 109 +++++++++++++ .../object-metadata-migration.service.ts | 61 ++++++++ ...data-graphql-api-exception-handler.util.ts | 3 + ...ta-with-morph-relation.integration-spec.ts | 148 ++++++++++++++++++ ...ate-morph-relation-between-objects.util.ts | 6 + 7 files changed, 343 insertions(+) create mode 100644 packages/twenty-server/test/integration/metadata/suites/object-metadata/morph-relation/rename-object-metadata-with-morph-relation.integration-spec.ts diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.exception.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.exception.ts index 82cb98703..cffd00286 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.exception.ts @@ -17,4 +17,5 @@ export enum ObjectMetadataExceptionCode { OBJECT_MUTATION_NOT_ALLOWED = 'OBJECT_MUTATION_NOT_ALLOWED', OBJECT_ALREADY_EXISTS = 'OBJECT_ALREADY_EXISTS', MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD = 'MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD', + INVALID_ORM_OUTPUT = 'INVALID_ORM_OUTPUT', } diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts index 996d9638e..a19c8f763 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts @@ -606,6 +606,21 @@ export class ObjectMetadataService extends TypeOrmQueryService + > => { + return fieldMetadatas.every( + (fieldMetadata) => + fieldMetadata.type === FieldMetadataType.MORPH_RELATION, + ); + }; + + public async findTargetMorphRelationFieldMetadatas( + objectMetadataId: string, + ): Promise[]> { + const fieldMetadatas = await this.fieldMetadataRepository.find({ + where: { + relationTargetObjectMetadataId: objectMetadataId, + type: FieldMetadataType.MORPH_RELATION, + }, + relations: { + relationTargetObjectMetadata: true, + object: true, + }, + }); + + if (!this.validateFieldMetadataTypeIsMorphRelation(fieldMetadatas)) { + throw new ObjectMetadataException( + 'Invalid field metadata type. Expected MORPH_RELATION only', + ObjectMetadataExceptionCode.INVALID_ORM_OUTPUT, + ); + } + + return fieldMetadatas; + } + + public async updateMorphRelationsJoinColumnName({ + existingObjectMetadata, + objectMetadataForUpdate, + queryRunner, + }: { + existingObjectMetadata: Pick< + ObjectMetadataItemWithFieldMaps, + 'nameSingular' | 'isCustom' | 'id' | 'labelPlural' | 'icon' | 'fieldsById' + >; + objectMetadataForUpdate: Pick< + ObjectMetadataItemWithFieldMaps, + | 'nameSingular' + | 'isCustom' + | 'workspaceId' + | 'id' + | 'labelSingular' + | 'labelPlural' + | 'icon' + | 'fieldsById' + >; + queryRunner: QueryRunner; + }): Promise< + { + fieldMetadata: FieldMetadataEntity; + newJoinColumnName: string; + }[] + > { + const fieldMetadataRepository = + queryRunner.manager.getRepository(FieldMetadataEntity); + + const morphRelationFieldMetadataTargets = + await this.findTargetMorphRelationFieldMetadatas( + existingObjectMetadata.id, + ); + const morphRelationFieldMetadataToUpdate = + morphRelationFieldMetadataTargets.filter( + (morphRelationFieldMetadata) => + morphRelationFieldMetadata.settings?.relationType === + RelationType.MANY_TO_ONE, + ); + + const morphRelationFieldMetadataToUpdateWithNewJoinColumnName = []; + + if (morphRelationFieldMetadataToUpdate.length > 0) { + for (const morphRelationFieldMetadata of morphRelationFieldMetadataToUpdate) { + const newJoinColumnName = computeMorphRelationFieldJoinColumnName({ + name: morphRelationFieldMetadata.name, + targetObjectMetadataNameSingular: + objectMetadataForUpdate.nameSingular, + }); + + await fieldMetadataRepository.save({ + ...morphRelationFieldMetadata, + settings: { + ...morphRelationFieldMetadata.settings, + joinColumnName: newJoinColumnName, + }, + }); + + morphRelationFieldMetadataToUpdateWithNewJoinColumnName.push({ + fieldMetadata: morphRelationFieldMetadata, + newJoinColumnName, + }); + } + } + + return morphRelationFieldMetadataToUpdateWithNewJoinColumnName; + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/services/object-metadata-migration.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/services/object-metadata-migration.service.ts index 890490188..f7f6665d6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/services/object-metadata-migration.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/services/object-metadata-migration.service.ts @@ -8,6 +8,10 @@ import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfa import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { + ObjectMetadataException, + ObjectMetadataExceptionCode, +} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; import { buildMigrationsForCustomObjectRelations } from 'src/engine/metadata-modules/object-metadata/utils/build-migrations-for-custom-object-relations.util'; import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { fieldMetadataTypeToColumnType } from 'src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util'; @@ -360,4 +364,61 @@ export class ObjectMetadataMigrationService { ); } } + + public async updateMorphRelationMigrations({ + workspaceId, + morphRelationFieldMetadataToUpdate, + queryRunner, + }: { + workspaceId: string; + morphRelationFieldMetadataToUpdate: { + fieldMetadata: FieldMetadataEntity; + newJoinColumnName: string; + }[]; + queryRunner?: QueryRunner; + }) { + for (const morphRelationFieldMetadata of morphRelationFieldMetadataToUpdate) { + if (!morphRelationFieldMetadata.fieldMetadata.settings?.joinColumnName) { + throw new ObjectMetadataException( + `Settings for morph relation field should be defined ${morphRelationFieldMetadata.fieldMetadata.name}`, + ObjectMetadataExceptionCode.INVALID_ORM_OUTPUT, + ); + } + + await this.workspaceMigrationService.createCustomMigration( + generateMigrationName( + `rename-join-column-name-${morphRelationFieldMetadata.fieldMetadata.name}-to-${morphRelationFieldMetadata.newJoinColumnName}-in-${morphRelationFieldMetadata.fieldMetadata.object.nameSingular}`, + ), + workspaceId, + [ + { + name: computeObjectTargetTable( + morphRelationFieldMetadata.fieldMetadata.object, + ), + action: WorkspaceMigrationTableActionType.ALTER, + columns: [ + { + action: WorkspaceMigrationColumnActionType.ALTER, + currentColumnDefinition: { + columnName: + morphRelationFieldMetadata.fieldMetadata.settings + ?.joinColumnName, + columnType: 'uuid', + isNullable: true, + defaultValue: null, + }, + alteredColumnDefinition: { + columnName: `${morphRelationFieldMetadata.newJoinColumnName}`, + columnType: 'uuid', + isNullable: true, + defaultValue: null, + }, + }, + ], + }, + ], + queryRunner, + ); + } + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util.ts index 077e05923..8d952ee14 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util.ts @@ -1,6 +1,7 @@ import { ConflictError, ForbiddenError, + InternalServerError, NotFoundError, UserInputError, } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; @@ -25,6 +26,8 @@ export const objectMetadataGraphqlApiExceptionHandler = (error: Error) => { throw new ForbiddenError(error); case ObjectMetadataExceptionCode.OBJECT_ALREADY_EXISTS: throw new ConflictError(error); + case ObjectMetadataExceptionCode.INVALID_ORM_OUTPUT: + throw new InternalServerError(error); case ObjectMetadataExceptionCode.MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD: throw error; default: { diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/morph-relation/rename-object-metadata-with-morph-relation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/morph-relation/rename-object-metadata-with-morph-relation.integration-spec.ts new file mode 100644 index 000000000..34009b373 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/morph-relation/rename-object-metadata-with-morph-relation.integration-spec.ts @@ -0,0 +1,148 @@ +import { findManyFieldsMetadataQueryFactory } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util'; +import { createMorphRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { forceCreateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/force-create-one-object-metadata.util'; +import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + +import { RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; + +describe('Rename an object metadata with morph relation should succeed', () => { + let opportunityId = ''; + let personId = ''; + let companyId = ''; + let morphRelationField: FieldMetadataEntity & { + morphRelations: RelationDTO[]; + }; + + beforeEach(async () => { + const { + data: { + createOneObject: { id: aId }, + }, + } = await forceCreateOneObjectMetadata({ + input: { + nameSingular: 'opportunityForRename', + namePlural: 'opportunitiesForRename', + labelSingular: 'Opportunity For Rename', + labelPlural: 'Opportunities For Rename', + icon: 'IconOpportunity', + }, + }); + + opportunityId = aId; + const { + data: { + createOneObject: { id: bId }, + }, + } = await forceCreateOneObjectMetadata({ + input: { + nameSingular: 'personForRename', + namePlural: 'peopleForRename', + labelSingular: 'Person For Rename', + labelPlural: 'People For Rename', + icon: 'IconPerson', + }, + }); + + personId = bId; + const { + data: { + createOneObject: { id: cId }, + }, + } = await forceCreateOneObjectMetadata({ + input: { + nameSingular: 'companyForRename', + namePlural: 'companiesForRename', + labelSingular: 'Company For Rename', + labelPlural: 'Companies For Rename', + icon: 'IconCompany', + }, + }); + + companyId = cId; + }); + + afterEach(async () => { + await deleteOneObjectMetadata({ input: { idToDelete: opportunityId } }); + await deleteOneObjectMetadata({ input: { idToDelete: personId } }); + await deleteOneObjectMetadata({ input: { idToDelete: companyId } }); + }); + + it('should rename custom object, and update the join column name of the morph relation that contains the object name', async () => { + morphRelationField = await createMorphRelationBetweenObjects({ + name: 'owner', + objectMetadataId: opportunityId, + firstTargetObjectMetadataId: personId, + secondTargetObjectMetadataId: companyId, + type: FieldMetadataType.MORPH_RELATION, + relationType: RelationType.MANY_TO_ONE, + }); + + const { data } = await updateOneObjectMetadata({ + gqlFields: ` + nameSingular + labelSingular + namePlural + labelPlural + `, + input: { + idToUpdate: personId, + updatePayload: { + nameSingular: 'personForRename2', + namePlural: 'peopleForRename2', + labelSingular: 'Person For Rename2', + labelPlural: 'People For Rename2', + }, + }, + }); + + expect(data.updateOneObject.nameSingular).toBe('personForRename2'); + + const ownerFieldMetadataOnPersonId = morphRelationField.morphRelations.find( + (morphRelation) => morphRelation.targetObjectMetadata.id === personId, + )?.sourceFieldMetadata.id; + + if (!ownerFieldMetadataOnPersonId) { + throw new Error( + 'Morph Relation Error: Owner field metadata on person not found', + ); + } + + const fieldAfterRenaming = await findFieldMetadata({ + fieldMetadataId: ownerFieldMetadataOnPersonId, + }); + + expect(fieldAfterRenaming.settings.joinColumnName).toBe( + 'ownerPersonForRename2Id', + ); + }); +}); + +const findFieldMetadata = async ({ + fieldMetadataId, +}: { + fieldMetadataId: string; +}) => { + const operation = findManyFieldsMetadataQueryFactory({ + gqlFields: ` + id + name + object { id nameSingular } + relation { type targetFieldMetadata { id } targetObjectMetadata { id } } + settings + `, + input: { + filter: { id: { eq: fieldMetadataId } }, + paging: { first: 1 }, + }, + }); + const fields = await makeMetadataAPIRequest(operation); + const field = fields.body.data.fields.edges?.[0]?.node; + + return field; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util.ts index 1893bfaab..c9c7860e0 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util.ts @@ -74,6 +74,12 @@ export const createMorphRelationBetweenObjects = async ({ targetObjectMetadata { id } + sourceFieldMetadata { + id + } + sourceObjectMetadata { + id + } } `, expectToFail: false,