Cascade delete Task targets when tasks deleted - logic + migration command (#17019)
Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
+201
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -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 {}
|
||||
+2
@@ -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],
|
||||
|
||||
+9
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+85
-8
@@ -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;
|
||||
};
|
||||
|
||||
+29
@@ -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;
|
||||
};
|
||||
+37
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@ export const buildTaskTargetStandardFlatFieldMetadatas = ({
|
||||
targetFieldName: 'taskTargets',
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
joinColumnName: 'taskId',
|
||||
},
|
||||
},
|
||||
|
||||
+65
-1
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+156
-78
@@ -1,9 +1,10 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
|
||||
import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.util';
|
||||
import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util';
|
||||
import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util';
|
||||
import { destroyManyOperationFactory } from 'test/integration/graphql/utils/destroy-many-operation-factory.util';
|
||||
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
|
||||
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
|
||||
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
|
||||
import { restoreManyOperationFactory } from 'test/integration/graphql/utils/restore-many-operation-factory.util';
|
||||
@@ -21,7 +22,7 @@ const NOTE_TARGET_GQL_FIELDS = `
|
||||
deletedAt
|
||||
`;
|
||||
|
||||
describe('Note post-query hooks', () => {
|
||||
describe('noteTargets hooks on note actions', () => {
|
||||
const noteIds: string[] = [];
|
||||
const noteTargetIds: string[] = [];
|
||||
|
||||
@@ -49,29 +50,25 @@ describe('Note post-query hooks', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('deleteOne should soft delete related noteTargets', async () => {
|
||||
it('deleteOne note should soft delete related noteTargets', async () => {
|
||||
const noteId = randomUUID();
|
||||
const noteTargetId = randomUUID();
|
||||
|
||||
noteIds.push(noteId);
|
||||
noteTargetIds.push(noteTargetId);
|
||||
|
||||
const createNoteOperation = createOneOperationFactory({
|
||||
await createOneOperation({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: NOTE_GQL_FIELDS,
|
||||
data: { id: noteId, title: 'Test Note for DeleteOne' },
|
||||
input: { id: noteId, title: 'Test Note for DeleteOne' },
|
||||
});
|
||||
|
||||
await makeGraphqlAPIRequest(createNoteOperation);
|
||||
|
||||
const createNoteTargetOperation = createOneOperationFactory({
|
||||
await createOneOperation({
|
||||
objectMetadataSingularName: 'noteTarget',
|
||||
gqlFields: NOTE_TARGET_GQL_FIELDS,
|
||||
data: { id: noteTargetId, noteId },
|
||||
input: { id: noteTargetId, noteId },
|
||||
});
|
||||
|
||||
await makeGraphqlAPIRequest(createNoteTargetOperation);
|
||||
|
||||
const deleteNoteOperation = deleteOneOperationFactory({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: NOTE_GQL_FIELDS,
|
||||
@@ -103,7 +100,7 @@ describe('Note post-query hooks', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('deleteMany should soft delete related noteTargets', async () => {
|
||||
it('deleteMany notes should soft delete related noteTargets', async () => {
|
||||
const noteId1 = randomUUID();
|
||||
const noteId2 = randomUUID();
|
||||
const noteTargetId1 = randomUUID();
|
||||
@@ -112,38 +109,30 @@ describe('Note post-query hooks', () => {
|
||||
noteIds.push(noteId1, noteId2);
|
||||
noteTargetIds.push(noteTargetId1, noteTargetId2);
|
||||
|
||||
const createNote1Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: NOTE_GQL_FIELDS,
|
||||
data: { id: noteId1, title: 'Test Note 1 for DeleteMany' },
|
||||
});
|
||||
|
||||
const createNote2Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: NOTE_GQL_FIELDS,
|
||||
data: { id: noteId2, title: 'Test Note 2 for DeleteMany' },
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
makeGraphqlAPIRequest(createNote1Operation),
|
||||
makeGraphqlAPIRequest(createNote2Operation),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: NOTE_GQL_FIELDS,
|
||||
input: { id: noteId1, title: 'Test Note 1 for DeleteMany' },
|
||||
}),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: NOTE_GQL_FIELDS,
|
||||
input: { id: noteId2, title: 'Test Note 2 for DeleteMany' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const createNoteTarget1Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'noteTarget',
|
||||
gqlFields: NOTE_TARGET_GQL_FIELDS,
|
||||
data: { id: noteTargetId1, noteId: noteId1 },
|
||||
});
|
||||
|
||||
const createNoteTarget2Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'noteTarget',
|
||||
gqlFields: NOTE_TARGET_GQL_FIELDS,
|
||||
data: { id: noteTargetId2, noteId: noteId2 },
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
makeGraphqlAPIRequest(createNoteTarget1Operation),
|
||||
makeGraphqlAPIRequest(createNoteTarget2Operation),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'noteTarget',
|
||||
gqlFields: NOTE_TARGET_GQL_FIELDS,
|
||||
input: { id: noteTargetId1, noteId: noteId1 },
|
||||
}),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'noteTarget',
|
||||
gqlFields: NOTE_TARGET_GQL_FIELDS,
|
||||
input: { id: noteTargetId2, noteId: noteId2 },
|
||||
}),
|
||||
]);
|
||||
|
||||
const deleteNotesOperation = deleteManyOperationFactory({
|
||||
@@ -180,29 +169,25 @@ describe('Note post-query hooks', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('restoreOne should restore related noteTargets', async () => {
|
||||
it('restoreOne note should restore related noteTargets', async () => {
|
||||
const noteId = randomUUID();
|
||||
const noteTargetId = randomUUID();
|
||||
|
||||
noteIds.push(noteId);
|
||||
noteTargetIds.push(noteTargetId);
|
||||
|
||||
const createNoteOperation = createOneOperationFactory({
|
||||
await createOneOperation({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: NOTE_GQL_FIELDS,
|
||||
data: { id: noteId, title: 'Test Note for RestoreOne' },
|
||||
input: { id: noteId, title: 'Test Note for RestoreOne' },
|
||||
});
|
||||
|
||||
await makeGraphqlAPIRequest(createNoteOperation);
|
||||
|
||||
const createNoteTargetOperation = createOneOperationFactory({
|
||||
await createOneOperation({
|
||||
objectMetadataSingularName: 'noteTarget',
|
||||
gqlFields: NOTE_TARGET_GQL_FIELDS,
|
||||
data: { id: noteTargetId, noteId },
|
||||
input: { id: noteTargetId, noteId },
|
||||
});
|
||||
|
||||
await makeGraphqlAPIRequest(createNoteTargetOperation);
|
||||
|
||||
const deleteNoteOperation = deleteOneOperationFactory({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: NOTE_GQL_FIELDS,
|
||||
@@ -239,7 +224,7 @@ describe('Note post-query hooks', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('restoreMany should restore related noteTargets', async () => {
|
||||
it('restoreMany notes should restore related noteTargets', async () => {
|
||||
const noteId1 = randomUUID();
|
||||
const noteId2 = randomUUID();
|
||||
const noteTargetId1 = randomUUID();
|
||||
@@ -248,38 +233,30 @@ describe('Note post-query hooks', () => {
|
||||
noteIds.push(noteId1, noteId2);
|
||||
noteTargetIds.push(noteTargetId1, noteTargetId2);
|
||||
|
||||
const createNote1Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: NOTE_GQL_FIELDS,
|
||||
data: { id: noteId1, title: 'Test Note 1 for RestoreMany' },
|
||||
});
|
||||
|
||||
const createNote2Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: NOTE_GQL_FIELDS,
|
||||
data: { id: noteId2, title: 'Test Note 2 for RestoreMany' },
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
makeGraphqlAPIRequest(createNote1Operation),
|
||||
makeGraphqlAPIRequest(createNote2Operation),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: NOTE_GQL_FIELDS,
|
||||
input: { id: noteId1, title: 'Test Note 1 for RestoreMany' },
|
||||
}),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: NOTE_GQL_FIELDS,
|
||||
input: { id: noteId2, title: 'Test Note 2 for RestoreMany' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const createNoteTarget1Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'noteTarget',
|
||||
gqlFields: NOTE_TARGET_GQL_FIELDS,
|
||||
data: { id: noteTargetId1, noteId: noteId1 },
|
||||
});
|
||||
|
||||
const createNoteTarget2Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'noteTarget',
|
||||
gqlFields: NOTE_TARGET_GQL_FIELDS,
|
||||
data: { id: noteTargetId2, noteId: noteId2 },
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
makeGraphqlAPIRequest(createNoteTarget1Operation),
|
||||
makeGraphqlAPIRequest(createNoteTarget2Operation),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'noteTarget',
|
||||
gqlFields: NOTE_TARGET_GQL_FIELDS,
|
||||
input: { id: noteTargetId1, noteId: noteId1 },
|
||||
}),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'noteTarget',
|
||||
gqlFields: NOTE_TARGET_GQL_FIELDS,
|
||||
input: { id: noteTargetId2, noteId: noteId2 },
|
||||
}),
|
||||
]);
|
||||
|
||||
const deleteNotesOperation = deleteManyOperationFactory({
|
||||
@@ -321,4 +298,105 @@ describe('Note post-query hooks', () => {
|
||||
noteTargetResponse.body.data.noteTargets.edges[1].node.deletedAt,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('destroyOne note should destroy related noteTargets', async () => {
|
||||
const noteId = randomUUID();
|
||||
const noteTargetId = randomUUID();
|
||||
|
||||
noteIds.push(noteId);
|
||||
|
||||
await createOneOperation({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: NOTE_GQL_FIELDS,
|
||||
input: { id: noteId, title: 'Test Note for DestroyOne' },
|
||||
});
|
||||
|
||||
await createOneOperation({
|
||||
objectMetadataSingularName: 'noteTarget',
|
||||
gqlFields: NOTE_TARGET_GQL_FIELDS,
|
||||
input: { id: noteTargetId, noteId },
|
||||
});
|
||||
|
||||
const destroyNoteOperation = destroyOneOperationFactory({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: 'id',
|
||||
recordId: noteId,
|
||||
});
|
||||
|
||||
const destroyResponse = await makeGraphqlAPIRequest(destroyNoteOperation);
|
||||
|
||||
expect(destroyResponse.body.data.destroyNote).toBeDefined();
|
||||
|
||||
const findNoteTargetsOperation = findManyOperationFactory({
|
||||
objectMetadataSingularName: 'noteTarget',
|
||||
objectMetadataPluralName: 'noteTargets',
|
||||
gqlFields: NOTE_TARGET_GQL_FIELDS,
|
||||
filter: { id: { eq: noteTargetId } },
|
||||
});
|
||||
|
||||
const noteTargetResponse = await makeGraphqlAPIRequest(
|
||||
findNoteTargetsOperation,
|
||||
);
|
||||
|
||||
expect(noteTargetResponse.body.data.noteTargets.edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('destroyMany notes should destroy related noteTargets', async () => {
|
||||
const noteId1 = randomUUID();
|
||||
const noteId2 = randomUUID();
|
||||
const noteTargetId1 = randomUUID();
|
||||
const noteTargetId2 = randomUUID();
|
||||
|
||||
noteIds.push(noteId1, noteId2);
|
||||
|
||||
await Promise.all([
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: NOTE_GQL_FIELDS,
|
||||
input: { id: noteId1, title: 'Test Note 1 for DestroyMany' },
|
||||
}),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'note',
|
||||
gqlFields: NOTE_GQL_FIELDS,
|
||||
input: { id: noteId2, title: 'Test Note 2 for DestroyMany' },
|
||||
}),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'noteTarget',
|
||||
gqlFields: NOTE_TARGET_GQL_FIELDS,
|
||||
input: { id: noteTargetId1, noteId: noteId1 },
|
||||
}),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'noteTarget',
|
||||
gqlFields: NOTE_TARGET_GQL_FIELDS,
|
||||
input: { id: noteTargetId2, noteId: noteId2 },
|
||||
}),
|
||||
]);
|
||||
|
||||
const destroyNotesOperation = destroyManyOperationFactory({
|
||||
objectMetadataSingularName: 'note',
|
||||
objectMetadataPluralName: 'notes',
|
||||
gqlFields: 'id',
|
||||
filter: { id: { in: [noteId1, noteId2] } },
|
||||
});
|
||||
|
||||
const destroyResponse = await makeGraphqlAPIRequest(destroyNotesOperation);
|
||||
|
||||
expect(destroyResponse.body.data.destroyNotes).toHaveLength(2);
|
||||
|
||||
const findNoteTargetsOperation = findManyOperationFactory({
|
||||
objectMetadataSingularName: 'noteTarget',
|
||||
objectMetadataPluralName: 'noteTargets',
|
||||
gqlFields: NOTE_TARGET_GQL_FIELDS,
|
||||
filter: { id: { in: [noteTargetId1, noteTargetId2] } },
|
||||
});
|
||||
|
||||
const noteTargetResponse = await makeGraphqlAPIRequest(
|
||||
findNoteTargetsOperation,
|
||||
);
|
||||
|
||||
expect(noteTargetResponse.body.data.noteTargets.edges).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+156
-78
@@ -1,9 +1,10 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
|
||||
import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.util';
|
||||
import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util';
|
||||
import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util';
|
||||
import { destroyManyOperationFactory } from 'test/integration/graphql/utils/destroy-many-operation-factory.util';
|
||||
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
|
||||
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
|
||||
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
|
||||
import { restoreManyOperationFactory } from 'test/integration/graphql/utils/restore-many-operation-factory.util';
|
||||
@@ -21,7 +22,7 @@ const TASK_TARGET_GQL_FIELDS = `
|
||||
deletedAt
|
||||
`;
|
||||
|
||||
describe('Task post-query hooks', () => {
|
||||
describe('taskTargets hooks on task actions', () => {
|
||||
const taskIds: string[] = [];
|
||||
const taskTargetIds: string[] = [];
|
||||
|
||||
@@ -49,29 +50,25 @@ describe('Task post-query hooks', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('deleteOne should soft delete related taskTargets', async () => {
|
||||
it('deleteOne task should soft delete related taskTargets', async () => {
|
||||
const taskId = randomUUID();
|
||||
const taskTargetId = randomUUID();
|
||||
|
||||
taskIds.push(taskId);
|
||||
taskTargetIds.push(taskTargetId);
|
||||
|
||||
const createTaskOperation = createOneOperationFactory({
|
||||
await createOneOperation({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: TASK_GQL_FIELDS,
|
||||
data: { id: taskId, title: 'Test Task for DeleteOne' },
|
||||
input: { id: taskId, title: 'Test Task for DeleteOne' },
|
||||
});
|
||||
|
||||
await makeGraphqlAPIRequest(createTaskOperation);
|
||||
|
||||
const createTaskTargetOperation = createOneOperationFactory({
|
||||
await createOneOperation({
|
||||
objectMetadataSingularName: 'taskTarget',
|
||||
gqlFields: TASK_TARGET_GQL_FIELDS,
|
||||
data: { id: taskTargetId, taskId },
|
||||
input: { id: taskTargetId, taskId },
|
||||
});
|
||||
|
||||
await makeGraphqlAPIRequest(createTaskTargetOperation);
|
||||
|
||||
const deleteTaskOperation = deleteOneOperationFactory({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: TASK_GQL_FIELDS,
|
||||
@@ -103,7 +100,7 @@ describe('Task post-query hooks', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('deleteMany should soft delete related taskTargets', async () => {
|
||||
it('deleteMany tasks should soft delete related taskTargets', async () => {
|
||||
const taskId1 = randomUUID();
|
||||
const taskId2 = randomUUID();
|
||||
const taskTargetId1 = randomUUID();
|
||||
@@ -112,38 +109,30 @@ describe('Task post-query hooks', () => {
|
||||
taskIds.push(taskId1, taskId2);
|
||||
taskTargetIds.push(taskTargetId1, taskTargetId2);
|
||||
|
||||
const createTask1Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: TASK_GQL_FIELDS,
|
||||
data: { id: taskId1, title: 'Test Task 1 for DeleteMany' },
|
||||
});
|
||||
|
||||
const createTask2Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: TASK_GQL_FIELDS,
|
||||
data: { id: taskId2, title: 'Test Task 2 for DeleteMany' },
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
makeGraphqlAPIRequest(createTask1Operation),
|
||||
makeGraphqlAPIRequest(createTask2Operation),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: TASK_GQL_FIELDS,
|
||||
input: { id: taskId1, title: 'Test Task 1 for DeleteMany' },
|
||||
}),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: TASK_GQL_FIELDS,
|
||||
input: { id: taskId2, title: 'Test Task 2 for DeleteMany' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const createTaskTarget1Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'taskTarget',
|
||||
gqlFields: TASK_TARGET_GQL_FIELDS,
|
||||
data: { id: taskTargetId1, taskId: taskId1 },
|
||||
});
|
||||
|
||||
const createTaskTarget2Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'taskTarget',
|
||||
gqlFields: TASK_TARGET_GQL_FIELDS,
|
||||
data: { id: taskTargetId2, taskId: taskId2 },
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
makeGraphqlAPIRequest(createTaskTarget1Operation),
|
||||
makeGraphqlAPIRequest(createTaskTarget2Operation),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'taskTarget',
|
||||
gqlFields: TASK_TARGET_GQL_FIELDS,
|
||||
input: { id: taskTargetId1, taskId: taskId1 },
|
||||
}),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'taskTarget',
|
||||
gqlFields: TASK_TARGET_GQL_FIELDS,
|
||||
input: { id: taskTargetId2, taskId: taskId2 },
|
||||
}),
|
||||
]);
|
||||
|
||||
const deleteTasksOperation = deleteManyOperationFactory({
|
||||
@@ -180,29 +169,25 @@ describe('Task post-query hooks', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('restoreOne should restore related taskTargets', async () => {
|
||||
it('restoreOne task should restore related taskTargets', async () => {
|
||||
const taskId = randomUUID();
|
||||
const taskTargetId = randomUUID();
|
||||
|
||||
taskIds.push(taskId);
|
||||
taskTargetIds.push(taskTargetId);
|
||||
|
||||
const createTaskOperation = createOneOperationFactory({
|
||||
await createOneOperation({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: TASK_GQL_FIELDS,
|
||||
data: { id: taskId, title: 'Test Task for RestoreOne' },
|
||||
input: { id: taskId, title: 'Test Task for RestoreOne' },
|
||||
});
|
||||
|
||||
await makeGraphqlAPIRequest(createTaskOperation);
|
||||
|
||||
const createTaskTargetOperation = createOneOperationFactory({
|
||||
await createOneOperation({
|
||||
objectMetadataSingularName: 'taskTarget',
|
||||
gqlFields: TASK_TARGET_GQL_FIELDS,
|
||||
data: { id: taskTargetId, taskId },
|
||||
input: { id: taskTargetId, taskId },
|
||||
});
|
||||
|
||||
await makeGraphqlAPIRequest(createTaskTargetOperation);
|
||||
|
||||
const deleteTaskOperation = deleteOneOperationFactory({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: TASK_GQL_FIELDS,
|
||||
@@ -239,7 +224,7 @@ describe('Task post-query hooks', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('restoreMany should restore related taskTargets', async () => {
|
||||
it('restoreMany tasks should restore related taskTargets', async () => {
|
||||
const taskId1 = randomUUID();
|
||||
const taskId2 = randomUUID();
|
||||
const taskTargetId1 = randomUUID();
|
||||
@@ -248,38 +233,30 @@ describe('Task post-query hooks', () => {
|
||||
taskIds.push(taskId1, taskId2);
|
||||
taskTargetIds.push(taskTargetId1, taskTargetId2);
|
||||
|
||||
const createTask1Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: TASK_GQL_FIELDS,
|
||||
data: { id: taskId1, title: 'Test Task 1 for RestoreMany' },
|
||||
});
|
||||
|
||||
const createTask2Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: TASK_GQL_FIELDS,
|
||||
data: { id: taskId2, title: 'Test Task 2 for RestoreMany' },
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
makeGraphqlAPIRequest(createTask1Operation),
|
||||
makeGraphqlAPIRequest(createTask2Operation),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: TASK_GQL_FIELDS,
|
||||
input: { id: taskId1, title: 'Test Task 1 for RestoreMany' },
|
||||
}),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: TASK_GQL_FIELDS,
|
||||
input: { id: taskId2, title: 'Test Task 2 for RestoreMany' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const createTaskTarget1Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'taskTarget',
|
||||
gqlFields: TASK_TARGET_GQL_FIELDS,
|
||||
data: { id: taskTargetId1, taskId: taskId1 },
|
||||
});
|
||||
|
||||
const createTaskTarget2Operation = createOneOperationFactory({
|
||||
objectMetadataSingularName: 'taskTarget',
|
||||
gqlFields: TASK_TARGET_GQL_FIELDS,
|
||||
data: { id: taskTargetId2, taskId: taskId2 },
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
makeGraphqlAPIRequest(createTaskTarget1Operation),
|
||||
makeGraphqlAPIRequest(createTaskTarget2Operation),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'taskTarget',
|
||||
gqlFields: TASK_TARGET_GQL_FIELDS,
|
||||
input: { id: taskTargetId1, taskId: taskId1 },
|
||||
}),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'taskTarget',
|
||||
gqlFields: TASK_TARGET_GQL_FIELDS,
|
||||
input: { id: taskTargetId2, taskId: taskId2 },
|
||||
}),
|
||||
]);
|
||||
|
||||
const deleteTasksOperation = deleteManyOperationFactory({
|
||||
@@ -321,4 +298,105 @@ describe('Task post-query hooks', () => {
|
||||
taskTargetResponse.body.data.taskTargets.edges[1].node.deletedAt,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('destroyOne task should destroy related taskTargets', async () => {
|
||||
const taskId = randomUUID();
|
||||
const taskTargetId = randomUUID();
|
||||
|
||||
taskIds.push(taskId);
|
||||
|
||||
await createOneOperation({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: TASK_GQL_FIELDS,
|
||||
input: { id: taskId, title: 'Test Task for DestroyOne' },
|
||||
});
|
||||
|
||||
await createOneOperation({
|
||||
objectMetadataSingularName: 'taskTarget',
|
||||
gqlFields: TASK_TARGET_GQL_FIELDS,
|
||||
input: { id: taskTargetId, taskId },
|
||||
});
|
||||
|
||||
const destroyTaskOperation = destroyOneOperationFactory({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: 'id',
|
||||
recordId: taskId,
|
||||
});
|
||||
|
||||
const destroyResponse = await makeGraphqlAPIRequest(destroyTaskOperation);
|
||||
|
||||
expect(destroyResponse.body.data.destroyTask).toBeDefined();
|
||||
|
||||
const findTaskTargetsOperation = findManyOperationFactory({
|
||||
objectMetadataSingularName: 'taskTarget',
|
||||
objectMetadataPluralName: 'taskTargets',
|
||||
gqlFields: TASK_TARGET_GQL_FIELDS,
|
||||
filter: { id: { eq: taskTargetId } },
|
||||
});
|
||||
|
||||
const taskTargetResponse = await makeGraphqlAPIRequest(
|
||||
findTaskTargetsOperation,
|
||||
);
|
||||
|
||||
expect(taskTargetResponse.body.data.taskTargets.edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('destroyMany tasks should destroy related taskTargets', async () => {
|
||||
const taskId1 = randomUUID();
|
||||
const taskId2 = randomUUID();
|
||||
const taskTargetId1 = randomUUID();
|
||||
const taskTargetId2 = randomUUID();
|
||||
|
||||
taskIds.push(taskId1, taskId2);
|
||||
|
||||
await Promise.all([
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: TASK_GQL_FIELDS,
|
||||
input: { id: taskId1, title: 'Test Task 1 for DestroyMany' },
|
||||
}),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'task',
|
||||
gqlFields: TASK_GQL_FIELDS,
|
||||
input: { id: taskId2, title: 'Test Task 2 for DestroyMany' },
|
||||
}),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'taskTarget',
|
||||
gqlFields: TASK_TARGET_GQL_FIELDS,
|
||||
input: { id: taskTargetId1, taskId: taskId1 },
|
||||
}),
|
||||
createOneOperation({
|
||||
objectMetadataSingularName: 'taskTarget',
|
||||
gqlFields: TASK_TARGET_GQL_FIELDS,
|
||||
input: { id: taskTargetId2, taskId: taskId2 },
|
||||
}),
|
||||
]);
|
||||
|
||||
const destroyTasksOperation = destroyManyOperationFactory({
|
||||
objectMetadataSingularName: 'task',
|
||||
objectMetadataPluralName: 'tasks',
|
||||
gqlFields: 'id',
|
||||
filter: { id: { in: [taskId1, taskId2] } },
|
||||
});
|
||||
|
||||
const destroyResponse = await makeGraphqlAPIRequest(destroyTasksOperation);
|
||||
|
||||
expect(destroyResponse.body.data.destroyTasks).toHaveLength(2);
|
||||
|
||||
const findTaskTargetsOperation = findManyOperationFactory({
|
||||
objectMetadataSingularName: 'taskTarget',
|
||||
objectMetadataPluralName: 'taskTargets',
|
||||
gqlFields: TASK_TARGET_GQL_FIELDS,
|
||||
filter: { id: { in: [taskTargetId1, taskTargetId2] } },
|
||||
});
|
||||
|
||||
const taskTargetResponse = await makeGraphqlAPIRequest(
|
||||
findTaskTargetsOperation,
|
||||
);
|
||||
|
||||
expect(taskTargetResponse.body.data.taskTargets.edges).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+40
@@ -46,3 +46,43 @@ exports[`Field metadata relation update should fail relation when name is not in
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`Field metadata relation update should fail relation when updating ONE_TO_MANY relation with onDelete action 1`] = `
|
||||
[
|
||||
{
|
||||
"extensions": {
|
||||
"code": "METADATA_VALIDATION_FAILED",
|
||||
"errors": {
|
||||
"fieldMetadata": [
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"code": "INVALID_FIELD_INPUT",
|
||||
"message": "On delete action is only supported for many to one relations",
|
||||
"userFriendlyMessage": "On delete action is only supported for many to one relations",
|
||||
},
|
||||
],
|
||||
"flatEntityMinimalInformation": {
|
||||
"id": Any<String>,
|
||||
"name": "staff",
|
||||
"objectMetadataId": Any<String>,
|
||||
"universalIdentifier": Any<String>,
|
||||
},
|
||||
"metadataName": "fieldMetadata",
|
||||
"status": "fail",
|
||||
"type": "update",
|
||||
},
|
||||
],
|
||||
},
|
||||
"message": "Validation failed for 1 fieldMetadata",
|
||||
"summary": {
|
||||
"fieldMetadata": 1,
|
||||
"totalErrors": 1,
|
||||
},
|
||||
"userFriendlyMessage": "Metadata validation failed",
|
||||
},
|
||||
"message": "Multiple validation errors occurred while updating field",
|
||||
"name": "GraphQLError",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
+56
-9
@@ -6,18 +6,31 @@ import { getMockCreateObjectInput } from 'test/integration/metadata/suites/objec
|
||||
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
|
||||
import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
|
||||
import { type EachTestingContext } from 'twenty-shared/testing';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { FieldMetadataType, RelationOnDeleteAction } from 'twenty-shared/types';
|
||||
|
||||
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||
|
||||
type UpdateOneFieldMetadataTestingContext = EachTestingContext<{
|
||||
name: string;
|
||||
}>;
|
||||
type UpdateOneFieldMetadataTestingContext = EachTestingContext<
|
||||
| {
|
||||
fieldKey: 'employerFieldMetadataId';
|
||||
updatePayload: { name: string };
|
||||
}
|
||||
| {
|
||||
fieldKey: 'employeesOneToManyFieldMetadataId';
|
||||
updatePayload: {
|
||||
settings: {
|
||||
relationType: RelationType;
|
||||
onDelete: RelationOnDeleteAction;
|
||||
};
|
||||
};
|
||||
}
|
||||
>;
|
||||
|
||||
const globalTestContext = {
|
||||
employeeObjectId: '',
|
||||
enterpriseObjectId: '',
|
||||
employerFieldMetadataId: '',
|
||||
employeesOneToManyFieldMetadataId: '',
|
||||
};
|
||||
|
||||
describe('Field metadata relation update should fail', () => {
|
||||
@@ -25,7 +38,22 @@ describe('Field metadata relation update should fail', () => {
|
||||
[
|
||||
{
|
||||
title: 'when name is not in camel case',
|
||||
context: { name: 'New Name' },
|
||||
context: {
|
||||
fieldKey: 'employerFieldMetadataId',
|
||||
updatePayload: { name: 'New Name' },
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when updating ONE_TO_MANY relation with onDelete action',
|
||||
context: {
|
||||
fieldKey: 'employeesOneToManyFieldMetadataId',
|
||||
updatePayload: {
|
||||
settings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -73,6 +101,27 @@ describe('Field metadata relation update should fail', () => {
|
||||
globalTestContext.employerFieldMetadataId = data.createOneField.id;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
|
||||
const { data: oneToManyData } = await createOneFieldMetadata({
|
||||
input: {
|
||||
objectMetadataId: enterpriseObjectId,
|
||||
name: 'staff',
|
||||
label: 'Staff',
|
||||
isLabelSyncedWithName: false,
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationCreationPayload: {
|
||||
targetFieldLabel: 'company',
|
||||
type: RelationType.ONE_TO_MANY,
|
||||
targetObjectMetadataId: employeeObjectId,
|
||||
targetFieldIcon: 'IconUsers',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalTestContext.employeesOneToManyFieldMetadataId =
|
||||
oneToManyData.createOneField.id;
|
||||
|
||||
expect(oneToManyData).toBeDefined();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -103,10 +152,8 @@ describe('Field metadata relation update should fail', () => {
|
||||
const { errors } = await updateOneFieldMetadata({
|
||||
expectToFail: true,
|
||||
input: {
|
||||
idToUpdate: globalTestContext.employerFieldMetadataId,
|
||||
updatePayload: {
|
||||
name: context.name,
|
||||
},
|
||||
idToUpdate: globalTestContext[context.fieldKey],
|
||||
updatePayload: context.updatePayload,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user