Morph Relations : deleteOneField (#13349)

This PR adapts teh deleteOneField to make sure morph relations can be
deleted, either
- from a relation type, 
- or from a morph relation type (which is the most obvious case).

This PR covers 
- the deletion of fieldMetadata 
- and the migrationof workspace schemas, by using the already existing
definition of relation migrations

This PR implements a new test suite: "Delete Object metadata with morph
relation" and completes the other test suites for FieldMetadata and
ObjectMetadata creation/deletion.

Last, we added a nitpick from @paul I forgot on a previous PR on
process-nested-realtion-v2

Fixes https://github.com/twentyhq/core-team-issues/issues/1197
This commit is contained in:
Guillim
2025-07-24 14:40:14 +02:00
committed by GitHub
parent 15e13b4267
commit 1ea451c8be
9 changed files with 617 additions and 57 deletions

View File

@ -4,7 +4,6 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { FieldMetadataRelationSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import {
@ -175,7 +174,7 @@ export class ProcessNestedRelationsV2Helper {
targetRelation,
FieldMetadataType.MORPH_RELATION,
)
? `${(targetRelation?.settings as FieldMetadataRelationSettings)?.joinColumnName}`
? `${targetRelation.settings?.joinColumnName}`
: `${targetRelationName}Id`;
const { relationResults, relationAggregatedFieldsResult } =

View File

@ -5,7 +5,13 @@ import { t } from '@lingui/core/macro';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { DataSource, FindOneOptions, In, Repository } from 'typeorm';
import {
DataSource,
FindOneOptions,
In,
QueryRunner,
Repository,
} from 'typeorm';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
@ -24,6 +30,7 @@ import { FieldMetadataMorphRelationService } from 'src/engine/metadata-modules/f
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service';
import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service';
import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service';
import { areFieldMetadatasTypeRelationOrMorphRelation } from 'src/engine/metadata-modules/field-metadata/utils/are-field-metadatas-type-relation-or-morph-relation.util';
import { assertDoesNotNullifyDefaultValueForNonNullableField } from 'src/engine/metadata-modules/field-metadata/utils/assert-does-not-nullify-default-value-for-non-nullable-field.util';
import { buildUpdatableStandardFieldInput } from 'src/engine/metadata-modules/field-metadata/utils/build-updatable-standard-field-input.util';
import { checkCanDeactivateFieldOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/check-can-deactivate-field-or-throw';
@ -35,6 +42,8 @@ import { computeRelationFieldJoinColumnName } from 'src/engine/metadata-modules/
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 { isFieldMetadataTypeMorphRelation } from 'src/engine/metadata-modules/field-metadata/utils/is-field-metadata-type-morph-relation.util';
import { isFieldMetadataTypeRelation } from 'src/engine/metadata-modules/field-metadata/utils/is-field-metadata-type-relation.util';
import { isSelectOrMultiSelectFieldMetadata } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util';
import { prepareCustomFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/utils/prepare-custom-field-metadata-for-options.util';
import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util';
@ -59,6 +68,14 @@ import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { ViewService } from 'src/modules/view/services/view.service';
type GenerateMigrationArgs = {
fieldMetadata: FieldMetadataEntity<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>;
workspaceId: string;
queryRunner: QueryRunner;
};
@Injectable()
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> {
constructor(
@ -352,48 +369,85 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
);
}
if (fieldMetadata.type === FieldMetadataType.RELATION) {
const isManyToOneRelation =
(fieldMetadata as FieldMetadataEntity<FieldMetadataType.RELATION>)
.settings?.relationType === RelationType.MANY_TO_ONE;
if (isFieldMetadataTypeRelation(fieldMetadata)) {
const fieldMetadataIdsToDelete: string[] = [];
const isRelationTargetMorphRelation = isFieldMetadataTypeMorphRelation(
fieldMetadata.relationTargetFieldMetadata,
);
if (!isDefined(fieldMetadata.relationTargetFieldMetadata)) {
throw new FieldMetadataException(
'Target field metadata does not exist',
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
);
}
if (isRelationTargetMorphRelation) {
const morphRelationsWithSameName =
await this.getMorphRelationsWithSameName({
fieldMetadataName: fieldMetadata.relationTargetFieldMetadata.name,
objectMetadataId:
fieldMetadata.relationTargetFieldMetadata.objectMetadataId,
workspaceId,
fieldMetadataRepository,
});
await fieldMetadataRepository.delete({
id: In([
morphRelationsWithSameName.forEach((morphRelation) => {
fieldMetadataIdsToDelete.push(
morphRelation.id,
morphRelation.relationTargetFieldMetadataId,
);
});
await fieldMetadataRepository.delete({
id: In(fieldMetadataIdsToDelete),
});
for (const morphRelation of morphRelationsWithSameName) {
await this.generateDeleteRelationMigration({
fieldMetadata: morphRelation,
workspaceId,
queryRunner,
});
}
} else {
fieldMetadataIdsToDelete.push(
fieldMetadata.id,
fieldMetadata.relationTargetFieldMetadata.id,
]),
fieldMetadata.relationTargetFieldMetadataId,
);
await fieldMetadataRepository.delete({
id: In(fieldMetadataIdsToDelete),
});
await this.generateDeleteRelationMigration({
fieldMetadata,
workspaceId,
queryRunner,
});
}
} else if (isFieldMetadataTypeMorphRelation(fieldMetadata)) {
const fieldMetadataIdsToDelete: string[] = [];
const morphRelationsWithSameName =
await this.getMorphRelationsWithSameName({
fieldMetadataName: fieldMetadata.name,
objectMetadataId: fieldMetadata.objectMetadataId,
workspaceId,
fieldMetadataRepository,
});
morphRelationsWithSameName.forEach((morphRelation) => {
fieldMetadataIdsToDelete.push(
morphRelation.id,
morphRelation.relationTargetFieldMetadataId,
);
});
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`delete-${fieldMetadata.name}`),
workspaceId,
[
{
name: isManyToOneRelation
? computeObjectTargetTable(fieldMetadata.object)
: computeObjectTargetTable(
fieldMetadata.relationTargetObjectMetadata as ObjectMetadataEntity,
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.DROP,
columnName: isManyToOneRelation
? `${(fieldMetadata as FieldMetadataEntity<FieldMetadataType.RELATION>).settings?.joinColumnName}`
: `${(fieldMetadata.relationTargetFieldMetadata as FieldMetadataEntity<FieldMetadataType.RELATION>).settings?.joinColumnName}`,
} satisfies WorkspaceMigrationColumnDrop,
],
} satisfies WorkspaceMigrationTableAction,
],
queryRunner,
);
await fieldMetadataRepository.delete({
id: In(fieldMetadataIdsToDelete),
});
for (const morphRelation of morphRelationsWithSameName) {
await this.generateDeleteRelationMigration({
fieldMetadata: morphRelation,
workspaceId,
queryRunner,
});
}
} else if (isCompositeFieldMetadataType(fieldMetadata.type)) {
await fieldMetadataRepository.delete(fieldMetadata.id);
const compositeType = compositeTypeDefinitions.get(fieldMetadata.type);
@ -710,4 +764,126 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
},
);
}
private async getMorphRelationsWithSameName({
fieldMetadataName,
objectMetadataId,
workspaceId,
fieldMetadataRepository,
}: {
fieldMetadataName: string;
objectMetadataId: string;
workspaceId: string;
fieldMetadataRepository: Repository<FieldMetadataEntity>;
}): Promise<
FieldMetadataEntity<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>[]
> {
const fieldMetadatas = await fieldMetadataRepository.find({
where: {
name: fieldMetadataName,
objectMetadataId,
workspaceId,
},
relations: [
'object',
'relationTargetFieldMetadata',
'relationTargetObjectMetadata',
],
});
if (!areFieldMetadatasTypeRelationOrMorphRelation(fieldMetadatas)) {
throw new FieldMetadataException(
'At least one field metadata is not a relation or morph relation',
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
return fieldMetadatas;
}
private async generateDeleteRelationMigration({
fieldMetadata,
workspaceId,
queryRunner,
}: GenerateMigrationArgs) {
if (fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY) {
await this.generateDeleteOneToManyRelationMigration({
fieldMetadata,
workspaceId,
queryRunner,
});
} else {
await this.generateDeleteManyToOneRelationMigration({
fieldMetadata,
workspaceId,
queryRunner,
});
}
}
private async generateDeleteManyToOneRelationMigration({
fieldMetadata,
workspaceId,
queryRunner,
}: GenerateMigrationArgs) {
if (fieldMetadata.settings?.relationType !== RelationType.MANY_TO_ONE) {
throw new FieldMetadataException(
'Field metadata is not a many to one relation',
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`delete-${fieldMetadata.name}`),
workspaceId,
[
{
name: computeObjectTargetTable(fieldMetadata.object),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.DROP,
columnName: `${fieldMetadata.settings?.joinColumnName}`,
} satisfies WorkspaceMigrationColumnDrop,
],
} satisfies WorkspaceMigrationTableAction,
],
queryRunner,
);
}
private async generateDeleteOneToManyRelationMigration({
fieldMetadata,
workspaceId,
queryRunner,
}: GenerateMigrationArgs) {
if (fieldMetadata.settings?.relationType !== RelationType.ONE_TO_MANY) {
throw new FieldMetadataException(
'Field metadata is not a one to many relation',
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`delete-${fieldMetadata.name}`),
workspaceId,
[
{
name: computeObjectTargetTable(
fieldMetadata.relationTargetObjectMetadata as ObjectMetadataEntity,
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.DROP,
columnName: `${(fieldMetadata.relationTargetFieldMetadata as FieldMetadataEntity<FieldMetadataType.RELATION>).settings?.joinColumnName}`,
} satisfies WorkspaceMigrationColumnDrop,
],
} satisfies WorkspaceMigrationTableAction,
],
queryRunner,
);
}
}

View File

@ -0,0 +1,20 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isFieldMetadataTypeMorphRelation } from 'src/engine/metadata-modules/field-metadata/utils/is-field-metadata-type-morph-relation.util';
import { isFieldMetadataTypeRelation } from 'src/engine/metadata-modules/field-metadata/utils/is-field-metadata-type-relation.util';
export const areFieldMetadatasTypeRelationOrMorphRelation = (
fieldMetadatas: FieldMetadataEntity[],
): fieldMetadatas is Array<
FieldMetadataEntity &
FieldMetadataEntity<
FieldMetadataType.MORPH_RELATION | FieldMetadataType.RELATION
>
> => {
return fieldMetadatas.every(
(fieldMetadata) =>
isFieldMetadataTypeRelation(fieldMetadata) ||
isFieldMetadataTypeMorphRelation(fieldMetadata),
);
};

View File

@ -0,0 +1,10 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const isFieldMetadataTypeMorphRelation = (
fieldMetadata: FieldMetadataEntity,
): fieldMetadata is FieldMetadataEntity &
FieldMetadataEntity<FieldMetadataType.MORPH_RELATION> => {
return fieldMetadata.type === FieldMetadataType.MORPH_RELATION;
};

View File

@ -0,0 +1,10 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const isFieldMetadataTypeRelation = (
fieldMetadata: FieldMetadataEntity,
): fieldMetadata is FieldMetadataEntity &
FieldMetadataEntity<FieldMetadataType.RELATION> => {
return fieldMetadata.type === FieldMetadataType.RELATION;
};