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,