Add onDeleteAction to RelationMetadata (#4100)

* Add onDeleteAction to relationMetadata

* rename to SET NULL

* fix migration

* fix migration

* fix after review
This commit is contained in:
Weiko
2024-02-22 10:27:15 +01:00
committed by GitHub
parent e69c462b70
commit 8425ce4987
21 changed files with 357 additions and 31 deletions

View File

@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddOnDeleteActionToRelationMetadata1708449210922
implements MigrationInterface
{
name = 'AddOnDeleteActionToRelationMetadata1708449210922';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "metadata"."relationMetadata_ondeleteaction_enum" AS ENUM('CASCADE', 'RESTRICT', 'SET_NULL')`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."relationMetadata" ADD "onDeleteAction" "metadata"."relationMetadata_ondeleteaction_enum" NOT NULL DEFAULT 'SET_NULL'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."relationMetadata" DROP COLUMN "onDeleteAction"`,
);
await queryRunner.query(
`DROP TYPE "metadata"."relationMetadata_ondeleteaction_enum"`,
);
}
}

View File

@ -347,7 +347,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.RELATION,
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY,
columnName: `${computeObjectTargetTable(
createdObjectMetadata,
)}Id`,
@ -378,7 +378,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.RELATION,
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY,
columnName: `${computeObjectTargetTable(
createdObjectMetadata,
)}Id`,

View File

@ -20,6 +20,12 @@ export enum RelationMetadataType {
MANY_TO_MANY = 'MANY_TO_MANY',
}
export enum RelationOnDeleteAction {
CASCADE = 'CASCADE',
RESTRICT = 'RESTRICT',
SET_NULL = 'SET_NULL',
}
@Entity('relationMetadata')
export class RelationMetadataEntity implements RelationMetadataInterface {
@PrimaryGeneratedColumn('uuid')
@ -28,6 +34,14 @@ export class RelationMetadataEntity implements RelationMetadataInterface {
@Column({ nullable: false })
relationType: RelationMetadataType;
@Column({
nullable: false,
default: RelationOnDeleteAction.SET_NULL,
type: 'enum',
enum: RelationOnDeleteAction,
})
onDeleteAction: RelationOnDeleteAction;
@Column({ nullable: false, type: 'uuid' })
fromObjectMetadataId: string;

View File

@ -200,7 +200,7 @@ export class RelationMetadataService extends TypeOrmQueryService<RelationMetadat
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.RELATION,
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY,
columnName: foreignKeyColumnName,
referencedTableName: computeObjectTargetTable(
objectMetadataMap[relationMetadataInput.fromObjectMetadataId],

View File

@ -5,10 +5,13 @@ import {
PrimaryGeneratedColumn,
} from 'typeorm';
import { RelationOnDeleteAction } from 'src/metadata/relation-metadata/relation-metadata.entity';
export enum WorkspaceMigrationColumnActionType {
CREATE = 'CREATE',
ALTER = 'ALTER',
RELATION = 'RELATION',
CREATE_FOREIGN_KEY = 'CREATE_FOREIGN_KEY',
DROP_FOREIGN_KEY = 'DROP_FOREIGN_KEY',
DROP = 'DROP',
}
@ -34,12 +37,18 @@ export type WorkspaceMigrationColumnAlter = {
alteredColumnDefinition: WorkspaceMigrationColumnDefinition;
};
export type WorkspaceMigrationColumnRelation = {
action: WorkspaceMigrationColumnActionType.RELATION;
export type WorkspaceMigrationColumnCreateRelation = {
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY;
columnName: string;
referencedTableName: string;
referencedTableColumnName: string;
isUnique?: boolean;
onDelete?: RelationOnDeleteAction;
};
export type WorkspaceMigrationColumnDropRelation = {
action: WorkspaceMigrationColumnActionType.DROP_FOREIGN_KEY;
columnName: string;
};
export type WorkspaceMigrationColumnDrop = {
@ -52,7 +61,8 @@ export type WorkspaceMigrationColumnAction = {
} & (
| WorkspaceMigrationColumnCreate
| WorkspaceMigrationColumnAlter
| WorkspaceMigrationColumnRelation
| WorkspaceMigrationColumnCreateRelation
| WorkspaceMigrationColumnDropRelation
| WorkspaceMigrationColumnDrop
);

View File

@ -29,6 +29,7 @@ export enum WorkspaceHealthIssueType {
RELATION_FROM_OR_TO_FIELD_METADATA_NOT_VALID = 'RELATION_FROM_OR_TO_FIELD_METADATA_NOT_VALID',
RELATION_FOREIGN_KEY_NOT_VALID = 'RELATION_FOREIGN_KEY_NOT_VALID',
RELATION_FOREIGN_KEY_CONFLICT = 'RELATION_FOREIGN_KEY_CONFLICT',
RELATION_FOREIGN_KEY_ON_DELETE_ACTION_CONFLICT = 'RELATION_FOREIGN_KEY_ON_DELETE_ACTION_CONFLICT',
RELATION_TYPE_NOT_VALID = 'RELATION_TYPE_NOT_VALID',
}
@ -85,6 +86,7 @@ export type WorkspaceRelationIssueTypes =
| WorkspaceHealthIssueType.RELATION_FROM_OR_TO_FIELD_METADATA_NOT_VALID
| WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_NOT_VALID
| WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_CONFLICT
| WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_ON_DELETE_ACTION_CONFLICT
| WorkspaceHealthIssueType.RELATION_TYPE_NOT_VALID;
export interface WorkspaceHealthRelationIssue<

View File

@ -8,6 +8,8 @@ export interface WorkspaceTableStructure {
isPrimaryKey: boolean;
isForeignKey: boolean;
isUnique: boolean;
onUpdateAction: string;
onDeleteAction: string;
}
export type WorkspaceTableStructureResult = {

View File

@ -85,7 +85,9 @@ export class DatabaseStructureService {
CASE
WHEN uc.column_name IS NOT NULL THEN 'TRUE'
ELSE 'FALSE'
END AS "isUnique"
END AS "isUnique",
rc.update_rule AS "onUpdateAction",
rc.delete_rule AS "onDeleteAction"
FROM
information_schema.columns AS c
LEFT JOIN
@ -109,6 +111,9 @@ export class DatabaseStructureService {
ON c.table_schema = uc.schema_name
AND c.table_name = uc.table_name
AND c.column_name = uc.column_name
LEFT JOIN
information_schema.referential_constraints AS rc
ON rc.constraint_name = fk.constraint_name
WHERE
c.table_schema = '${schemaName}'
AND c.table_name = '${tableName}';

View File

@ -209,6 +209,16 @@ export class RelationMetadataHealthService {
});
}
if (relationMetadata.onDeleteAction !== relationColumn.onDeleteAction) {
issues.push({
type: WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_ON_DELETE_ACTION_CONFLICT,
fromFieldMetadata,
toFieldMetadata,
relationMetadata,
message: `Relation ${relationMetadata.id} foreign key onDeleteAction is not properly set`,
});
}
return issues;
}

View File

@ -43,11 +43,92 @@ export class WorkspaceMigrationRelationFactory {
originalObjectMetadataMap,
relationMetadataCollection,
);
case WorkspaceMigrationBuilderAction.UPDATE:
return this.updateRelationMigration(
originalObjectMetadataMap,
relationMetadataCollection,
);
default:
return [];
}
}
private async updateRelationMigration(
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
relationMetadataCollection: RelationMetadataEntity[],
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
for (const relationMetadata of relationMetadataCollection) {
const toObjectMetadata =
originalObjectMetadataMap[relationMetadata.toObjectMetadataId];
const fromObjectMetadata =
originalObjectMetadataMap[relationMetadata.fromObjectMetadataId];
if (!toObjectMetadata) {
throw new Error(
`ObjectMetadata with id ${relationMetadata.toObjectMetadataId} not found`,
);
}
if (!fromObjectMetadata) {
throw new Error(
`ObjectMetadata with id ${relationMetadata.fromObjectMetadataId} not found`,
);
}
const toFieldMetadata = toObjectMetadata.fields.find(
(field) => field.id === relationMetadata.toFieldMetadataId,
);
if (!toFieldMetadata) {
throw new Error(
`FieldMetadata with id ${relationMetadata.toFieldMetadataId} not found`,
);
}
const migrations: WorkspaceMigrationTableAction[] = [
{
name: computeObjectTargetTable(toObjectMetadata),
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.DROP_FOREIGN_KEY,
columnName: `${camelCase(toFieldMetadata.name)}Id`,
},
],
},
{
name: computeObjectTargetTable(toObjectMetadata),
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY,
columnName: `${camelCase(toFieldMetadata.name)}Id`,
referencedTableName: computeObjectTargetTable(fromObjectMetadata),
referencedTableColumnName: 'id',
isUnique:
relationMetadata.relationType ===
RelationMetadataType.ONE_TO_ONE,
onDelete: relationMetadata.onDeleteAction,
},
],
},
];
workspaceMigrations.push({
workspaceId: relationMetadata.workspaceId,
name: generateMigrationName(
`update-relation-from-${fromObjectMetadata.nameSingular}-to-${toObjectMetadata.nameSingular}`,
),
isCustom: false,
migrations,
});
}
return workspaceMigrations;
}
private async createRelationMigration(
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
relationMetadataCollection: RelationMetadataEntity[],
@ -88,13 +169,14 @@ export class WorkspaceMigrationRelationFactory {
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.RELATION,
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY,
columnName: `${camelCase(toFieldMetadata.name)}Id`,
referencedTableName: computeObjectTargetTable(fromObjectMetadata),
referencedTableColumnName: 'id',
isUnique:
relationMetadata.relationType ===
RelationMetadataType.ONE_TO_ONE,
onDelete: relationMetadata.onDeleteAction,
},
],
},

View File

@ -15,8 +15,9 @@ import {
WorkspaceMigrationColumnAction,
WorkspaceMigrationColumnActionType,
WorkspaceMigrationColumnCreate,
WorkspaceMigrationColumnRelation,
WorkspaceMigrationColumnCreateRelation,
WorkspaceMigrationColumnAlter,
WorkspaceMigrationColumnDropRelation,
} from 'src/metadata/workspace-migration/workspace-migration.entity';
import { WorkspaceCacheVersionService } from 'src/metadata/workspace-cache-version/workspace-cache-version.service';
import { WorkspaceMigrationEnumService } from 'src/workspace/workspace-migration-runner/services/workspace-migration-enum.service';
@ -200,7 +201,7 @@ export class WorkspaceMigrationRunnerService {
columnMigration,
);
break;
case WorkspaceMigrationColumnActionType.RELATION:
case WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY:
await this.createRelation(
queryRunner,
schemaName,
@ -208,6 +209,14 @@ export class WorkspaceMigrationRunnerService {
columnMigration,
);
break;
case WorkspaceMigrationColumnActionType.DROP_FOREIGN_KEY:
await this.dropRelation(
queryRunner,
schemaName,
tableName,
columnMigration,
);
break;
case WorkspaceMigrationColumnActionType.DROP:
await queryRunner.dropColumn(
`${schemaName}.${tableName}`,
@ -325,7 +334,7 @@ export class WorkspaceMigrationRunnerService {
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
migrationColumn: WorkspaceMigrationColumnRelation,
migrationColumn: WorkspaceMigrationColumnCreateRelation,
) {
await queryRunner.createForeignKey(
`${schemaName}.${tableName}`,
@ -334,7 +343,7 @@ export class WorkspaceMigrationRunnerService {
referencedColumnNames: [migrationColumn.referencedTableColumnName],
referencedTableName: migrationColumn.referencedTableName,
referencedSchema: schemaName,
onDelete: 'CASCADE',
onDelete: migrationColumn.onDelete?.replace(/_/g, ' '),
}),
);
@ -349,4 +358,57 @@ export class WorkspaceMigrationRunnerService {
);
}
}
private async dropRelation(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
migrationColumn: WorkspaceMigrationColumnDropRelation,
) {
const foreignKeyName = await this.getForeignKeyName(
queryRunner,
schemaName,
tableName,
migrationColumn.columnName,
);
if (!foreignKeyName) {
throw new Error(
`Foreign key not found for column ${migrationColumn.columnName}`,
);
}
await queryRunner.dropForeignKey(
`${schemaName}.${tableName}`,
foreignKeyName,
);
}
private async getForeignKeyName(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
columnName: string,
): Promise<string | undefined> {
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
`,
[schemaName, tableName, columnName],
);
return foreignKeys[0]?.constraint_name;
}
}

View File

@ -48,6 +48,28 @@ describe('WorkspaceRelationComparator', () => {
]);
});
it('should generate UPDATE action for changed relations', () => {
const original = [
createMockRelationMetadata({ onDeleteAction: 'CASCADE' }),
];
const standard = [
createMockRelationMetadata({ onDeleteAction: 'SET_NULL' }),
];
const result = comparator.compare(original, standard);
expect(result).toEqual([
{
action: ComparatorAction.UPDATE,
object: expect.objectContaining({
fromObjectMetadataId: 'object-1',
fromFieldMetadataId: 'field-1',
onDeleteAction: 'SET_NULL',
}),
},
]);
});
it('should not generate any action for identical relations', () => {
const relation = createMockRelationMetadata({});
const original = [{ id: '1', ...relation }];

View File

@ -11,6 +11,7 @@ import { RelationMetadataEntity } from 'src/metadata/relation-metadata/relation-
import { transformMetadataForComparison } from 'src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
const relationPropertiesToIgnore = ['createdAt', 'updatedAt'] as const;
const relationPropertiesToUpdate = ['onDeleteAction'];
@Injectable()
export class WorkspaceRelationComparator {
@ -51,19 +52,54 @@ export class WorkspaceRelationComparator {
);
for (const difference of relationMetadataDifference) {
if (difference.type === 'CREATE') {
results.push({
action: ComparatorAction.CREATE,
object: difference.value,
});
} else if (
difference.type === 'REMOVE' &&
difference.path[difference.path.length - 1] !== 'id'
) {
results.push({
action: ComparatorAction.DELETE,
object: difference.oldValue,
});
switch (difference.type) {
case 'CREATE':
results.push({
action: ComparatorAction.CREATE,
object: difference.value,
});
break;
case 'REMOVE':
if (difference.path[difference.path.length - 1] !== 'id') {
results.push({
action: ComparatorAction.DELETE,
object: difference.oldValue,
});
}
break;
case 'CHANGE':
const fieldName = difference.path[0];
const property = difference.path[difference.path.length - 1];
if (!relationPropertiesToUpdate.includes(property as string)) {
continue;
}
const originalRelationMetadata =
originalRelationMetadataMap[fieldName];
if (!originalRelationMetadata) {
throw new Error(
`Relation ${fieldName} not found in originalRelationMetadataMap`,
);
}
results.push({
action: ComparatorAction.UPDATE,
object: {
id: originalRelationMetadata.id,
fromObjectMetadataId:
originalRelationMetadata.fromObjectMetadataId,
fromFieldMetadataId: originalRelationMetadata.fromFieldMetadataId,
toObjectMetadataId: originalRelationMetadata.toObjectMetadataId,
toFieldMetadataId: originalRelationMetadata.toFieldMetadataId,
workspaceId: originalRelationMetadata.workspaceId,
...{
[property]: difference.value,
},
},
});
break;
}
}

View File

@ -1,9 +1,13 @@
import 'reflect-metadata';
import { RelationMetadataDecoratorParams } from 'src/workspace/workspace-sync-metadata/interfaces/reflect-relation-metadata.interface';
import {
ReflectRelationMetadata,
RelationMetadataDecoratorParams,
} from 'src/workspace/workspace-sync-metadata/interfaces/reflect-relation-metadata.interface';
import { TypedReflect } from 'src/utils/typed-reflect';
import { convertClassNameToObjectMetadataName } from 'src/workspace/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util';
import { RelationOnDeleteAction } from 'src/metadata/relation-metadata/relation-metadata.entity';
export function RelationMetadata(
params: RelationMetadataDecoratorParams,
@ -29,8 +33,9 @@ export function RelationMetadata(
toObjectNameSingular: params.objectName,
fromFieldMetadataName: fieldKey,
toFieldMetadataName: params.inverseSideFieldName ?? objectName,
onDelete: params.onDelete ?? RelationOnDeleteAction.SET_NULL,
gate,
},
} satisfies ReflectRelationMetadata,
],
target.constructor,
);

View File

@ -110,6 +110,7 @@ export class StandardRelationFactory {
fromFieldMetadataId: fromFieldMetadata?.id,
toFieldMetadataId: toFieldMetadata?.id,
workspaceId: context.workspaceId,
onDeleteAction: relationMetadata.onDelete,
};
});
}

View File

@ -43,4 +43,5 @@ export type FieldComparatorResult =
export type RelationComparatorResult =
| ComparatorCreateResult<Partial<RelationMetadataEntity>>
| ComparatorDeleteResult<RelationMetadataEntity>;
| ComparatorDeleteResult<RelationMetadataEntity>
| ComparatorUpdateResult<Partial<RelationMetadataEntity>>;

View File

@ -0,0 +1,10 @@
import { ReflectRelationMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/reflect-Relation-metadata.interface';
export type PartialRelationMetadata = ReflectRelationMetadata & {
id: string;
workspaceId: string;
fromObjectMetadataId: string;
toObjectMetadataId: string;
fromFieldMetadataId: string;
toFieldMetadataId: string;
};

View File

@ -1,11 +1,15 @@
import { GateDecoratorParams } from 'src/workspace/workspace-sync-metadata/interfaces/gate-decorator.interface';
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
import {
RelationOnDeleteAction,
RelationMetadataType,
} from 'src/metadata/relation-metadata/relation-metadata.entity';
export interface RelationMetadataDecoratorParams {
type: RelationMetadataType;
objectName: string;
inverseSideFieldName?: string;
onDelete?: RelationOnDeleteAction;
}
export interface ReflectRelationMetadata {
@ -15,4 +19,5 @@ export interface ReflectRelationMetadata {
fromFieldMetadataName: string;
toFieldMetadataName: string;
gate?: GateDecoratorParams;
onDelete: RelationOnDeleteAction;
}

View File

@ -209,6 +209,7 @@ export class WorkspaceMetadataUpdaterService {
storage: WorkspaceSyncStorage,
): Promise<{
createdRelationMetadataCollection: RelationMetadataEntity[];
updatedRelationMetadataCollection: RelationMetadataEntity[];
}> {
const relationMetadataRepository = manager.getRepository(
RelationMetadataEntity,
@ -223,6 +224,15 @@ export class WorkspaceMetadataUpdaterService {
storage.relationMetadataCreateCollection,
);
/**
* Update relation metadata
*/
const updatedRelationMetadataCollection =
await relationMetadataRepository.save(
storage.relationMetadataUpdateCollection,
);
/**
* Delete relation metadata
*/
@ -250,6 +260,7 @@ export class WorkspaceMetadataUpdaterService {
return {
createdRelationMetadataCollection,
updatedRelationMetadataCollection,
};
}
}

View File

@ -84,6 +84,8 @@ export class WorkspaceSyncRelationMetadataService {
for (const relationComparatorResult of relationComparatorResults) {
if (relationComparatorResult.action === ComparatorAction.CREATE) {
storage.addCreateRelationMetadata(relationComparatorResult.object);
} else if (relationComparatorResult.action === ComparatorAction.UPDATE) {
storage.addUpdateRelationMetadata(relationComparatorResult.object);
} else if (relationComparatorResult.action === ComparatorAction.DELETE) {
storage.addDeleteRelationMetadata(relationComparatorResult.object);
}
@ -103,6 +105,16 @@ export class WorkspaceSyncRelationMetadataService {
WorkspaceMigrationBuilderAction.CREATE,
);
return createRelationWorkspaceMigrations;
const updateRelationWorkspaceMigrations =
await this.workspaceMigrationRelationFactory.create(
originalObjectMetadataCollection,
metadataRelationUpdaterResult.updatedRelationMetadataCollection,
WorkspaceMigrationBuilderAction.UPDATE,
);
return [
...createRelationWorkspaceMigrations,
...updateRelationWorkspaceMigrations,
];
}
}

View File

@ -1,5 +1,6 @@
import { PartialObjectMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface';
import { PartialFieldMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-field-metadata.interface';
import { PartialRelationMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-relation-metadata.interface';
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity';
@ -25,6 +26,8 @@ export class WorkspaceSyncStorage {
[];
private readonly _relationMetadataDeleteCollection: RelationMetadataEntity[] =
[];
private readonly _relationMetadataUpdateCollection: Partial<PartialRelationMetadata>[] =
[];
constructor() {}
@ -56,6 +59,10 @@ export class WorkspaceSyncStorage {
return this._relationMetadataCreateCollection;
}
get relationMetadataUpdateCollection() {
return this._relationMetadataUpdateCollection;
}
get relationMetadataDeleteCollection() {
return this._relationMetadataDeleteCollection;
}
@ -90,6 +97,10 @@ export class WorkspaceSyncStorage {
this._relationMetadataCreateCollection.push(relation);
}
addUpdateRelationMetadata(relation: Partial<PartialRelationMetadata>) {
this._relationMetadataUpdateCollection.push(relation);
}
addDeleteRelationMetadata(relation: RelationMetadataEntity) {
this._relationMetadataDeleteCollection.push(relation);
}