Cascade delete Task targets when tasks deleted - logic + migration command (#17019)

Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
Etienne
2026-01-09 15:10:40 +01:00
committed by GitHub
parent 2fb099e198
commit d7b7e9def1
13 changed files with 857 additions and 175 deletions
@@ -0,0 +1,201 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata';
import {
FieldMetadataRelationSettings,
FieldMetadataType,
RelationOnDeleteAction,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { TASK_TARGET_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-migration/constant/standard-field-ids';
@Command({
name: 'upgrade:1-16:update-task-on-delete-action',
description:
'Update task relation onDelete action to CASCADE and delete orphaned taskTarget records',
})
export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(UpdateTaskOnDeleteActionCommand.name);
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly fieldMetadataService: FieldMetadataService,
) {
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`Running UpdateTaskOnDeleteActionCommand for workspace ${workspaceId}`,
);
await this.updateTaskRelationOnDeleteAction(workspaceId, isDryRun);
await this.deleteOrphanedTaskTargets(workspaceId, isDryRun);
}
private async updateTaskRelationOnDeleteAction(
workspaceId: string,
isDryRun: boolean,
): Promise<void> {
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
'flatObjectMetadataMaps',
]);
const taskTargetObjectMetadata = Object.values(
flatObjectMetadataMaps.byId,
).find(
(objectMetadata) =>
objectMetadata?.standardId === STANDARD_OBJECT_IDS.taskTarget,
);
if (!isDefined(taskTargetObjectMetadata)) {
this.logger.warn(
`TaskTarget object metadata not found in workspace ${workspaceId}`,
);
return;
}
const taskTargetFields = findManyFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityIds: taskTargetObjectMetadata.fieldMetadataIds,
flatEntityMaps: flatFieldMetadataMaps,
});
const taskField = taskTargetFields.find(
(field) => field.standardId === TASK_TARGET_STANDARD_FIELD_IDS.task,
);
if (!isDefined(taskField)) {
this.logger.warn(
`Task field not found on taskTarget object in workspace ${workspaceId}`,
);
return;
}
if (taskField.type !== FieldMetadataType.RELATION) {
this.logger.warn(
`Task field is not a relation field in workspace ${workspaceId}`,
);
return;
}
const taskFieldSettings =
taskField.settings as FieldMetadataRelationSettings;
if (taskFieldSettings?.onDelete === RelationOnDeleteAction.CASCADE) {
this.logger.log(
`Task relation already has CASCADE onDelete in workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`Updating task relation onDelete from ${taskFieldSettings?.onDelete} to CASCADE in workspace ${workspaceId}`,
);
if (!isDryRun) {
const updatedSettings: FieldMetadataRelationSettings = {
...taskFieldSettings,
onDelete: RelationOnDeleteAction.CASCADE,
};
await this.fieldMetadataService.updateOneField({
updateFieldInput: {
id: taskField.id,
settings: updatedSettings,
},
workspaceId,
isSystemBuild: true,
});
this.logger.log(
`Successfully updated task relation onDelete to CASCADE in workspace ${workspaceId}`,
);
} else {
this.logger.log(
`DRY RUN: Would update task relation onDelete to CASCADE in workspace ${workspaceId}`,
);
}
}
private async deleteOrphanedTaskTargets(
workspaceId: string,
isDryRun: boolean,
): Promise<void> {
const taskTargetRepository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
'taskTarget',
{ shouldBypassPermissionChecks: true },
);
const orphanedTaskTargets = await taskTargetRepository.find({
withDeleted: true,
select: ['id'],
where: {
taskId: IsNull(),
},
});
const orphanedCount = orphanedTaskTargets.length;
this.logger.log(
`Found ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`,
);
if (orphanedCount === 0) {
return;
}
if (!isDryRun) {
const orphanedIds = orphanedTaskTargets.map((record) => record.id);
const batchSize = 100;
for (let i = 0; i < orphanedIds.length; i += batchSize) {
const batch = orphanedIds.slice(i, i + batchSize);
await taskTargetRepository
.createQueryBuilder()
.delete()
.whereInIds(batch)
.execute();
}
this.logger.log(
`Deleted ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`,
);
} else {
this.logger.log(
`DRY RUN: Would delete ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`,
);
}
}
}
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UpdateTaskOnDeleteActionCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-update-task-on-delete-action.command';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Module({
imports: [
TypeOrmModule.forFeature([WorkspaceEntity]),
DataSourceModule,
WorkspaceCacheModule,
FieldMetadataModule,
],
providers: [UpdateTaskOnDeleteActionCommand],
exports: [UpdateTaskOnDeleteActionCommand],
})
export class V1_16_UpgradeVersionCommandModule {}
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { V1_13_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-13/1-13-upgrade-version-command.module';
import { V1_14_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-14/1-14-upgrade-version-command.module';
import { V1_15_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-15/1-15-upgrade-version-command.module';
import { V1_16_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-16/1-16-upgrade-version-command.module';
import { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
@@ -14,6 +15,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
V1_13_UpgradeVersionCommandModule,
V1_14_UpgradeVersionCommandModule,
V1_15_UpgradeVersionCommandModule,
V1_16_UpgradeVersionCommandModule,
DataSourceModule,
],
providers: [UpgradeCommand],
@@ -22,6 +22,7 @@ import { AddWorkspaceForeignKeysMigrationCommand } from 'src/database/commands/u
import { BackfillUpdatedByFieldCommand } from 'src/database/commands/upgrade-version-command/1-15/1-15-backfill-updated-by-field.command';
import { FixNanPositionValuesInNotesCommand } from 'src/database/commands/upgrade-version-command/1-15/1-15-fix-nan-position-values-in-notes.command';
import { MigratePageLayoutWidgetConfigurationCommand } from 'src/database/commands/upgrade-version-command/1-15/1-15-migrate-page-layout-widget-configuration.command';
import { UpdateTaskOnDeleteActionCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-update-task-on-delete-action.command';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
@@ -59,6 +60,9 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly fixNanPositionValuesInNotesCommand: FixNanPositionValuesInNotesCommand,
protected readonly backfillUpdatedByFieldCommand: BackfillUpdatedByFieldCommand,
protected readonly addWorkspaceForeignKeysMigrationCommand: AddWorkspaceForeignKeysMigrationCommand,
// 1.16 Commands
protected readonly updateTaskOnDeleteActionCommand: UpdateTaskOnDeleteActionCommand,
) {
super(
workspaceRepository,
@@ -92,11 +96,16 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.addWorkspaceForeignKeysMigrationCommand,
];
const commands_1160: VersionCommands = [
this.updateTaskOnDeleteActionCommand,
];
this.allCommands = {
'1.12.0': commands_1120,
'1.13.0': commands_1130,
'1.14.0': commands_1140,
'1.15.0': commands_1150,
'1.16.0': commands_1160,
};
}
@@ -3,13 +3,76 @@ import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type MorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/morph-or-relation-field-metadata-type.type';
import { type FlatEntityPropertiesUpdates } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-updates.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadataTypeValidationArgs } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type';
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { validateMorphOrRelationFlatFieldJoinColumName } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-join-column-name.util';
import { validateMorphOrRelationFlatFieldOnDelete } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util';
import { type PropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/types/property-update.type';
import { findFlatEntityPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/utils/find-flat-entity-property-update.util';
type ValidateMorphOrRelationFlatFieldMetadataUpdatesArgs = Omit<
FlatFieldMetadataTypeValidationArgs<MorphOrRelationFieldMetadataType>,
'updates'
> & {
updates: FlatEntityPropertiesUpdates<'fieldMetadata'>;
};
export const validateMorphOrRelationFlatFieldMetadataUpdates = ({
flatEntityToValidate: flatFieldMetadataToValidate,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatFieldMetadataMaps,
flatObjectMetadataMaps,
},
updates,
buildOptions,
}: ValidateMorphOrRelationFlatFieldMetadataUpdatesArgs): FlatFieldMetadataValidationError[] => {
const errors: FlatFieldMetadataValidationError[] = [];
const settingsUpdate = findFlatEntityPropertyUpdate({
flatEntityUpdates: updates,
property: 'settings',
}) as
| PropertyUpdate<
FlatFieldMetadata<MorphOrRelationFieldMetadataType>,
'settings'
>
| undefined;
const toSettings = settingsUpdate?.to;
const fromSettings = settingsUpdate?.from;
const isJoinColumnNameUpdated =
isDefined(settingsUpdate) &&
isDefined(toSettings?.joinColumnName) &&
isDefined(fromSettings?.joinColumnName) &&
toSettings.joinColumnName !== fromSettings.joinColumnName;
if (isJoinColumnNameUpdated) {
errors.push(
...validateMorphOrRelationFlatFieldJoinColumName({
buildOptions,
flatFieldMetadata: flatFieldMetadataToValidate,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatFieldMetadataMaps,
flatObjectMetadataMaps,
},
}),
);
}
errors.push(
...validateMorphOrRelationFlatFieldOnDelete({
flatFieldMetadata: flatFieldMetadataToValidate,
}),
);
return errors;
};
export const validateMorphOrRelationFlatFieldMetadata = ({
flatEntityToValidate: flatFieldMetadataToValidate,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
@@ -19,6 +82,8 @@ export const validateMorphOrRelationFlatFieldMetadata = ({
updates,
remainingFlatEntityMapsToValidate,
buildOptions,
workspaceId,
additionalCacheDataMaps,
}: FlatFieldMetadataTypeValidationArgs<MorphOrRelationFieldMetadataType>): FlatFieldMetadataValidationError[] => {
const { relationTargetFieldMetadataId, relationTargetObjectMetadataId } =
flatFieldMetadataToValidate;
@@ -120,15 +185,23 @@ export const validateMorphOrRelationFlatFieldMetadata = ({
}
}
if (
!isDefined(updates) ||
isDefined(
findFlatEntityPropertyUpdate({
flatEntityUpdates: updates,
property: 'settings',
// TODO prastoin refactor FlatFieldMetadataTypeValidator to implement two code flow: create and update https://github.com/twentyhq/core-team-issues/issues/2044
if (isDefined(updates)) {
errors.push(
...validateMorphOrRelationFlatFieldMetadataUpdates({
flatEntityToValidate: flatFieldMetadataToValidate,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatFieldMetadataMaps,
flatObjectMetadataMaps,
},
remainingFlatEntityMapsToValidate,
workspaceId,
updates,
buildOptions,
additionalCacheDataMaps,
}),
)
)
);
} else {
errors.push(
...validateMorphOrRelationFlatFieldJoinColumName({
buildOptions,
@@ -138,7 +211,11 @@ export const validateMorphOrRelationFlatFieldMetadata = ({
flatObjectMetadataMaps,
},
}),
...validateMorphOrRelationFlatFieldOnDelete({
flatFieldMetadata: flatFieldMetadataToValidate,
}),
);
}
return errors;
};
@@ -0,0 +1,29 @@
import { msg } from '@lingui/core/macro';
import { RelationType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type MorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/morph-or-relation-field-metadata-type.type';
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
export const validateMorphOrRelationFlatFieldOnDelete = ({
flatFieldMetadata,
}: {
flatFieldMetadata: FlatFieldMetadata<MorphOrRelationFieldMetadataType>;
}): FlatFieldMetadataValidationError[] => {
const errors: FlatFieldMetadataValidationError[] = [];
if (
isDefined(flatFieldMetadata.settings.onDelete) &&
flatFieldMetadata.settings.relationType !== RelationType.MANY_TO_ONE
) {
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: 'On delete action is only supported for many to one relations',
userFriendlyMessage: msg`On delete action is only supported for many to one relations`,
});
}
return errors;
};
@@ -89,4 +89,41 @@ export class WorkspaceSchemaForeignKeyManagerService {
await queryRunner.query(sql);
}
async getForeignKeyName({
queryRunner,
schemaName,
tableName,
columnName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
columnName: string;
}): Promise<string | undefined> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeColumnName = removeSqlDDLInjection(columnName);
const foreignKeys = await queryRunner.query(
`
SELECT
tc.constraint_name AS constraint_name
FROM
information_schema.table_constraints AS tc
JOIN
information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
WHERE
tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = $1
AND tc.table_name = $2
AND kcu.column_name = $3
`,
[safeSchemaName, safeTableName, safeColumnName],
);
return foreignKeys[0]?.constraint_name;
}
}
@@ -124,7 +124,7 @@ export const buildTaskTargetStandardFlatFieldMetadatas = ({
targetFieldName: 'taskTargets',
settings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: RelationOnDeleteAction.SET_NULL,
onDelete: RelationOnDeleteAction.CASCADE,
joinColumnName: 'taskId',
},
},
@@ -18,11 +18,13 @@ import { isEnumFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { isMorphOrRelationFieldMetadataType } from 'src/engine/utils/is-morph-or-relation-field-metadata-type.util';
import { PropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/types/property-update.type';
import { convertOnDeleteActionToOnDelete } from 'src/engine/workspace-manager/workspace-migration/utils/convert-on-delete-action-to-on-delete.util';
import { findFlatEntityPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/utils/find-flat-entity-property-update.util';
import { isPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/utils/is-property-update.util';
import { type UpdateFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action';
import { UpdateFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action';
import { serializeDefaultValue } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/utils/serialize-default-value.util';
import {
WorkspaceMigrationRunnerException,
@@ -221,6 +223,68 @@ export class UpdateFieldActionHandlerService extends WorkspaceMigrationRunnerAct
settings: update.to,
};
}
if (
isMorphOrRelationFlatFieldMetadata(optimisticFlatFieldMetadata) &&
isDefined(optimisticFlatFieldMetadata.settings.joinColumnName) &&
isPropertyUpdate(update, 'settings') &&
isDefined(update.from?.onDelete) &&
isDefined(update.to?.onDelete) &&
update.to.onDelete !== update.from.onDelete
) {
const foreignKeyName =
await this.workspaceSchemaManagerService.foreignKeyManager.getForeignKeyName(
{
queryRunner,
schemaName,
tableName,
columnName: optimisticFlatFieldMetadata.settings.joinColumnName,
},
);
if (!isDefined(foreignKeyName)) {
throw new WorkspaceMigrationRunnerException(
'Foreign key not found',
WorkspaceMigrationRunnerExceptionCode.NOT_SUPPORTED,
);
}
await this.workspaceSchemaManagerService.foreignKeyManager.dropForeignKey(
{
queryRunner,
schemaName,
tableName,
foreignKeyName,
},
);
const targetFlatObjectMetadata =
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId:
optimisticFlatFieldMetadata.relationTargetObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
const referencedTableName = computeObjectTargetTable(
targetFlatObjectMetadata,
);
await this.workspaceSchemaManagerService.foreignKeyManager.createForeignKey(
{
queryRunner,
schemaName,
foreignKey: {
tableName,
columnName: update.to.joinColumnName,
referencedTableName,
referencedColumnName: 'id',
onDelete:
convertOnDeleteActionToOnDelete(update.to.onDelete) ??
'CASCADE',
},
},
);
}
}
}