feat: workspace health target column map fix (#3932)

* feat: workspace health fix target column map

* fix: remove log

* feat: refactor health fixer

* fix: default-value issue and health check not working with composite

* fix: enhance target column map fix

* feat: create workspace migrations for target-column-map issues

* feat: enhance workspace-health issue detection
This commit is contained in:
Jérémy M
2024-02-15 18:04:12 +01:00
committed by GitHub
parent 0b93a6785b
commit 990cb107a1
25 changed files with 625 additions and 246 deletions

View File

@ -39,12 +39,14 @@
"@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@4.2.0#./patches/@ptc-org+nestjs-query-graphql+4.2.0.patch", "@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@4.2.0#./patches/@ptc-org+nestjs-query-graphql+4.2.0.patch",
"class-validator": "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch", "class-validator": "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch",
"graphql-middleware": "^6.1.35", "graphql-middleware": "^6.1.35",
"lodash.isequal": "^4.5.0",
"passport": "^0.7.0" "passport": "^0.7.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "10.3.0", "@nestjs/cli": "10.3.0",
"@nx/js": "17.2.8", "@nx/js": "17.2.8",
"@types/lodash.isempty": "^4.4.7", "@types/lodash.isempty": "^4.4.7",
"@types/lodash.isequal": "^4.5.8",
"@types/lodash.isobject": "^3.0.7", "@types/lodash.isobject": "^3.0.7",
"@types/lodash.omit": "^4.5.9", "@types/lodash.omit": "^4.5.9",
"@types/lodash.snakecase": "^4.1.7", "@types/lodash.snakecase": "^4.1.7",

View File

@ -29,10 +29,8 @@ import {
RelationMetadataEntity, RelationMetadataEntity,
RelationMetadataType, RelationMetadataType,
} from 'src/metadata/relation-metadata/relation-metadata.entity'; } from 'src/metadata/relation-metadata/relation-metadata.entity';
import { import { computeCustomName } from 'src/workspace/utils/compute-custom-name.util';
computeCustomName, import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util';
computeObjectTargetTable,
} from 'src/workspace/utils/compute-object-target-table.util';
import { DeleteOneObjectInput } from 'src/metadata/object-metadata/dtos/delete-object.input'; import { DeleteOneObjectInput } from 'src/metadata/object-metadata/dtos/delete-object.input';
import { RelationToDelete } from 'src/metadata/relation-metadata/types/relation-to-delete'; import { RelationToDelete } from 'src/metadata/relation-metadata/types/relation-to-delete';
import { generateMigrationName } from 'src/metadata/workspace-migration/utils/generate-migration-name.util'; import { generateMigrationName } from 'src/metadata/workspace-migration/utils/generate-migration-name.util';

View File

@ -0,0 +1,5 @@
export const customNamePrefix = '_';
export const computeCustomName = (name: string, isCustom: boolean) => {
return isCustom ? `${customNamePrefix}${name}` : name;
};

View File

@ -0,0 +1,21 @@
import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity';
import { isCompositeFieldMetadataType } from 'src/metadata/field-metadata/utils/is-composite-field-metadata-type.util';
import { BasicFieldMetadataType } from 'src/metadata/workspace-migration/factories/basic-column-action.factory';
import { computeCustomName } from './compute-custom-name.util';
export const computeFieldTargetColumn = (
fieldMetadata:
| FieldMetadataEntity<BasicFieldMetadataType>
| FieldMetadataInterface<BasicFieldMetadataType>,
) => {
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
throw new Error(
"Composite field metadata should not be computed here, as they're split into multiple fields.",
);
}
return computeCustomName(fieldMetadata.name, fieldMetadata.isCustom ?? false);
};

View File

@ -1,5 +1,7 @@
import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/object-metadata.interface'; import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/object-metadata.interface';
import { computeCustomName } from './compute-custom-name.util';
export const computeObjectTargetTable = ( export const computeObjectTargetTable = (
objectMetadata: ObjectMetadataInterface, objectMetadata: ObjectMetadataInterface,
) => { ) => {
@ -8,7 +10,3 @@ export const computeObjectTargetTable = (
objectMetadata.isCustom, objectMetadata.isCustom,
); );
}; };
export const computeCustomName = (name: string, isCustom: boolean) => {
return isCustom ? `_${name}` : name;
};

View File

@ -11,7 +11,6 @@ import { CommandLogger } from 'src/commands/command-logger';
interface WorkspaceHealthCommandOptions { interface WorkspaceHealthCommandOptions {
workspaceId: string; workspaceId: string;
verbose?: boolean;
mode?: WorkspaceHealthMode; mode?: WorkspaceHealthMode;
fix?: WorkspaceHealthFixKind; fix?: WorkspaceHealthFixKind;
dryRun?: boolean; dryRun?: boolean;
@ -49,7 +48,7 @@ export class WorkspaceHealthCommand extends CommandRunner {
chalk.red(`Workspace is not healthy, found ${issues.length} issues`), chalk.red(`Workspace is not healthy, found ${issues.length} issues`),
); );
if (options.verbose) { if (options.dryRun) {
await this.commandLogger.writeLog( await this.commandLogger.writeLog(
`workspace-health-issues-${options.workspaceId}`, `workspace-health-issues-${options.workspaceId}`,
issues, issues,
@ -61,25 +60,30 @@ export class WorkspaceHealthCommand extends CommandRunner {
if (options.fix) { if (options.fix) {
this.logger.log(chalk.yellow('Fixing issues')); this.logger.log(chalk.yellow('Fixing issues'));
const workspaceMigrations = await this.workspaceHealthService.fixIssues( const { workspaceMigrations, metadataEntities } =
options.workspaceId, await this.workspaceHealthService.fixIssues(
issues, options.workspaceId,
{ issues,
type: options.fix, {
applyChanges: !options.dryRun, type: options.fix,
}, applyChanges: !options.dryRun,
); },
);
const totalCount = workspaceMigrations.length + metadataEntities.length;
if (options.dryRun) { if (options.dryRun) {
await this.commandLogger.writeLog( await this.commandLogger.writeLog(
`workspace-health-${options.fix}-migrations`, `workspace-health-${options.fix}-migrations`,
workspaceMigrations, workspaceMigrations,
); );
await this.commandLogger.writeLog(
`workspace-health-${options.fix}-metadata-entities`,
metadataEntities,
);
} else { } else {
this.logger.log( this.logger.log(
chalk.green( chalk.green(`Fixed ${totalCount}/${issues.length} issues`),
`Fixed ${workspaceMigrations.length}/${issues.length} issues`,
),
); );
} }
} }
@ -107,15 +111,6 @@ export class WorkspaceHealthCommand extends CommandRunner {
return value as WorkspaceHealthFixKind; return value as WorkspaceHealthFixKind;
} }
@Option({
flags: '-v, --verbose',
description: 'Detailed output',
required: false,
})
parseVerbose(): boolean {
return true;
}
@Option({ @Option({
flags: '-m, --mode [mode]', flags: '-m, --mode [mode]',
description: 'Mode of the health check [structure, metadata, all]', description: 'Mode of the health check [structure, metadata, all]',

View File

@ -0,0 +1,68 @@
import { EntityManager } from 'typeorm';
import {
WorkspaceHealthIssue,
WorkspaceHealthIssueType,
WorkspaceIssueTypeToInterface,
} from 'src/workspace/workspace-health/interfaces/workspace-health-issue.interface';
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity';
export class CompareEntity<T> {
current: T | null;
altered: T | null;
}
export abstract class AbstractWorkspaceFixer<
IssueTypes extends WorkspaceHealthIssueType,
UpdateRecordEntities = unknown,
> {
private issueTypes: IssueTypes[];
constructor(...issueTypes: IssueTypes[]) {
this.issueTypes = issueTypes;
}
filterIssues(
issues: WorkspaceHealthIssue[],
): WorkspaceIssueTypeToInterface<IssueTypes>[] {
return issues.filter(
(issue): issue is WorkspaceIssueTypeToInterface<IssueTypes> =>
this.issueTypes.includes(issue.type as IssueTypes),
);
}
protected splitIssuesByType(
issues: WorkspaceIssueTypeToInterface<IssueTypes>[],
): Record<IssueTypes, WorkspaceIssueTypeToInterface<IssueTypes>[]> {
return issues.reduce(
(
acc: Record<IssueTypes, WorkspaceIssueTypeToInterface<IssueTypes>[]>,
issue,
) => {
const type = issue.type as IssueTypes;
if (!acc[type]) {
acc[type] = [];
}
acc[type].push(issue);
return acc;
},
{} as Record<IssueTypes, WorkspaceIssueTypeToInterface<IssueTypes>[]>,
);
}
async createWorkspaceMigrations?(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceIssueTypeToInterface<IssueTypes>[],
): Promise<Partial<WorkspaceMigrationEntity>[]>;
async createMetadataUpdates?(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceIssueTypeToInterface<IssueTypes>[],
): Promise<CompareEntity<UpdateRecordEntities>[]>;
}

View File

@ -0,0 +1,11 @@
import { WorkspaceNullableFixer } from './workspace-nullable.fixer';
import { WorkspaceDefaultValueFixer } from './workspace-default-value.fixer';
import { WorkspaceTypeFixer } from './workspace-type.fixer';
import { WorkspaceTargetColumnMapFixer } from './workspace-target-column-map.fixer';
export const workspaceFixers = [
WorkspaceNullableFixer,
WorkspaceDefaultValueFixer,
WorkspaceTypeFixer,
WorkspaceTargetColumnMapFixer,
];

View File

@ -13,37 +13,26 @@ import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metada
import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity'; import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFieldFactory } from 'src/workspace/workspace-migration-builder/factories/workspace-migration-field.factory'; import { WorkspaceMigrationFieldFactory } from 'src/workspace/workspace-migration-builder/factories/workspace-migration-field.factory';
type WorkspaceHealthDefaultValueIssue = import { AbstractWorkspaceFixer } from './abstract-workspace.fixer';
WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT>;
@Injectable() @Injectable()
export class WorkspaceFixDefaultValueService { export class WorkspaceDefaultValueFixer extends AbstractWorkspaceFixer<WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT> {
constructor( constructor(
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory, private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
) {} ) {
super(WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT);
}
async fix( async createWorkspaceMigrations(
manager: EntityManager, manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[], objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceHealthDefaultValueIssue[], issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT>[],
): Promise<Partial<WorkspaceMigrationEntity>[]> { ): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = []; if (issues.length <= 0) {
const defaultValueIssues = issues.filter( return [];
(issue) =>
issue.type === WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT,
) as WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT>[];
if (defaultValueIssues.length > 0) {
const columnDefaultValueWorkspaceMigrations =
await this.fixColumnDefaultValueIssues(
objectMetadataCollection,
defaultValueIssues,
);
workspaceMigrations.push(...columnDefaultValueWorkspaceMigrations);
} }
return workspaceMigrations; return this.fixColumnDefaultValueIssues(objectMetadataCollection, issues);
} }
private async fixColumnDefaultValueIssues( private async fixColumnDefaultValueIssues(

View File

@ -8,41 +8,30 @@ import {
} from 'src/workspace/workspace-health/interfaces/workspace-health-issue.interface'; } from 'src/workspace/workspace-health/interfaces/workspace-health-issue.interface';
import { WorkspaceMigrationBuilderAction } from 'src/workspace/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface'; import { WorkspaceMigrationBuilderAction } from 'src/workspace/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity';
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFieldFactory } from 'src/workspace/workspace-migration-builder/factories/workspace-migration-field.factory'; import { WorkspaceMigrationFieldFactory } from 'src/workspace/workspace-migration-builder/factories/workspace-migration-field.factory';
type WorkspaceHealthNullableIssue = import { AbstractWorkspaceFixer } from './abstract-workspace.fixer';
WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT>;
@Injectable() @Injectable()
export class WorkspaceFixNullableService { export class WorkspaceNullableFixer extends AbstractWorkspaceFixer<WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT> {
constructor( constructor(
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory, private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
) {} ) {
super(WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT);
}
async fix( async createWorkspaceMigrations(
manager: EntityManager, manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[], objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceHealthNullableIssue[], issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT>[],
): Promise<Partial<WorkspaceMigrationEntity>[]> { ): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = []; if (issues.length <= 0) {
const nullabilityIssues = issues.filter( return [];
(issue) =>
issue.type === WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT,
) as WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT>[];
if (nullabilityIssues.length > 0) {
const columnNullabilityWorkspaceMigrations =
await this.fixColumnNullabilityIssues(
objectMetadataCollection,
nullabilityIssues,
);
workspaceMigrations.push(...columnNullabilityWorkspaceMigrations);
} }
return workspaceMigrations; return this.fixColumnNullabilityIssues(objectMetadataCollection, issues);
} }
private async fixColumnNullabilityIssues( private async fixColumnNullabilityIssues(

View File

@ -0,0 +1,174 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import isEqual from 'lodash.isequal';
import {
WorkspaceHealthColumnIssue,
WorkspaceHealthIssueType,
} from 'src/workspace/workspace-health/interfaces/workspace-health-issue.interface';
import { WorkspaceMigrationBuilderAction } from 'src/workspace/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/generate-target-column-map.util';
import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity';
import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity';
import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util';
import { DataSourceEntity } from 'src/metadata/data-source/data-source.entity';
import { DatabaseStructureService } from 'src/workspace/workspace-health/services/database-structure.service';
import { WorkspaceMigrationFieldFactory } from 'src/workspace/workspace-migration-builder/factories/workspace-migration-field.factory';
import { isCompositeFieldMetadataType } from 'src/metadata/field-metadata/utils/is-composite-field-metadata-type.util';
import {
AbstractWorkspaceFixer,
CompareEntity,
} from './abstract-workspace.fixer';
@Injectable()
export class WorkspaceTargetColumnMapFixer extends AbstractWorkspaceFixer<
WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID,
FieldMetadataEntity
> {
constructor(
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
private readonly databaseStructureService: DatabaseStructureService,
) {
super(WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID);
}
async createWorkspaceMigrations(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID>[],
): Promise<Partial<WorkspaceMigrationEntity>[]> {
if (issues.length <= 0) {
return [];
}
return this.fixStructureTargetColumnMapIssues(
manager,
objectMetadataCollection,
issues,
);
}
async createMetadataUpdates(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID>[],
): Promise<CompareEntity<FieldMetadataEntity>[]> {
if (issues.length <= 0) {
return [];
}
return this.fixMetadataTargetColumnMapIssues(manager, issues);
}
private async fixStructureTargetColumnMapIssues(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID>[],
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrationCollection: Partial<WorkspaceMigrationEntity>[] =
[];
const dataSourceRepository = manager.getRepository(DataSourceEntity);
for (const issue of issues) {
const objectMetadata = objectMetadataCollection.find(
(metadata) => metadata.id === issue.fieldMetadata.objectMetadataId,
);
const targetColumnMap = generateTargetColumnMap(
issue.fieldMetadata.type,
issue.fieldMetadata.isCustom,
issue.fieldMetadata.name,
);
// Skip composite fields, too complicated to fix for now
if (isCompositeFieldMetadataType(issue.fieldMetadata.type)) {
continue;
}
if (!objectMetadata) {
throw new Error(
`Object metadata with id ${issue.fieldMetadata.objectMetadataId} not found`,
);
}
if (!isEqual(issue.fieldMetadata.targetColumnMap, targetColumnMap)) {
// Retrieve the data source to get the schema name
const dataSource = await dataSourceRepository.findOne({
where: {
id: objectMetadata.dataSourceId,
},
});
if (!dataSource) {
throw new Error(
`Data source with id ${objectMetadata.dataSourceId} not found`,
);
}
const columnName = issue.fieldMetadata.targetColumnMap?.value;
const columnExist =
await this.databaseStructureService.workspaceColumnExist(
dataSource.schema,
computeObjectTargetTable(objectMetadata),
columnName,
);
if (!columnExist) {
continue;
}
const workspaceMigration =
await this.workspaceMigrationFieldFactory.create(
objectMetadataCollection,
[
{
current: issue.fieldMetadata,
altered: {
...issue.fieldMetadata,
targetColumnMap,
},
},
],
WorkspaceMigrationBuilderAction.UPDATE,
);
workspaceMigrationCollection.push(workspaceMigration[0]);
}
}
return workspaceMigrationCollection;
}
private async fixMetadataTargetColumnMapIssues(
manager: EntityManager,
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID>[],
): Promise<CompareEntity<FieldMetadataEntity>[]> {
const fieldMetadataRepository = manager.getRepository(FieldMetadataEntity);
const updatedEntities: CompareEntity<FieldMetadataEntity>[] = [];
for (const issue of issues) {
await fieldMetadataRepository.update(issue.fieldMetadata.id, {
targetColumnMap: generateTargetColumnMap(
issue.fieldMetadata.type,
issue.fieldMetadata.isCustom,
issue.fieldMetadata.name,
),
});
const alteredEntity = await fieldMetadataRepository.findOne({
where: {
id: issue.fieldMetadata.id,
},
});
updatedEntities.push({
current: issue.fieldMetadata,
altered: alteredEntity as FieldMetadataEntity | null,
});
}
return updatedEntities;
}
}

View File

@ -11,41 +11,29 @@ import { WorkspaceMigrationBuilderAction } from 'src/workspace/workspace-migrati
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity'; import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFieldFactory } from 'src/workspace/workspace-migration-builder/factories/workspace-migration-field.factory'; import { WorkspaceMigrationFieldFactory } from 'src/workspace/workspace-migration-builder/factories/workspace-migration-field.factory';
import { DatabaseStructureService } from 'src/workspace/workspace-health/services/database-structure.service';
import { DatabaseStructureService } from './database-structure.service'; import { AbstractWorkspaceFixer } from './abstract-workspace.fixer';
type WorkspaceHealthTypeIssue =
WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT>;
@Injectable() @Injectable()
export class WorkspaceFixTypeService { export class WorkspaceTypeFixer extends AbstractWorkspaceFixer<WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT> {
constructor( constructor(
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory, private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
private readonly databaseStructureService: DatabaseStructureService, private readonly databaseStructureService: DatabaseStructureService,
) {} ) {
super(WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT);
}
async fix( async createWorkspaceMigrations(
manager: EntityManager, manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[], objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceHealthTypeIssue[], issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT>[],
): Promise<Partial<WorkspaceMigrationEntity>[]> { ): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = []; if (issues.length <= 0) {
const columnTypeIssues = issues.filter( return [];
(issue) =>
issue.type === WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT,
) as WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT>[];
if (columnTypeIssues.length > 0) {
const columnNullabilityWorkspaceMigrations =
await this.fixColumnTypeIssues(
objectMetadataCollection,
columnTypeIssues,
);
workspaceMigrations.push(...columnNullabilityWorkspaceMigrations);
} }
return workspaceMigrations; return this.fixColumnTypeIssues(objectMetadataCollection, issues);
} }
private async fixColumnTypeIssues( private async fixColumnTypeIssues(

View File

@ -14,6 +14,7 @@ export enum WorkspaceHealthIssueType {
MISSING_INDEX = 'MISSING_INDEX', MISSING_INDEX = 'MISSING_INDEX',
MISSING_FOREIGN_KEY = 'MISSING_FOREIGN_KEY', MISSING_FOREIGN_KEY = 'MISSING_FOREIGN_KEY',
MISSING_COMPOSITE_TYPE = 'MISSING_COMPOSITE_TYPE', MISSING_COMPOSITE_TYPE = 'MISSING_COMPOSITE_TYPE',
COLUMN_NAME_SHOULD_NOT_BE_PREFIXED = 'COLUMN_NAME_SHOULD_NOT_BE_PREFIXED',
COLUMN_TARGET_COLUMN_MAP_NOT_VALID = 'COLUMN_TARGET_COLUMN_MAP_NOT_VALID', COLUMN_TARGET_COLUMN_MAP_NOT_VALID = 'COLUMN_TARGET_COLUMN_MAP_NOT_VALID',
COLUMN_NAME_SHOULD_BE_CUSTOM = 'COLUMN_NAME_SHOULD_BE_CUSTOM', COLUMN_NAME_SHOULD_BE_CUSTOM = 'COLUMN_NAME_SHOULD_BE_CUSTOM',
COLUMN_OBJECT_REFERENCE_INVALID = 'COLUMN_OBJECT_REFERENCE_INVALID', COLUMN_OBJECT_REFERENCE_INVALID = 'COLUMN_OBJECT_REFERENCE_INVALID',
@ -30,61 +31,64 @@ export enum WorkspaceHealthIssueType {
RELATION_TYPE_NOT_VALID = 'RELATION_TYPE_NOT_VALID', RELATION_TYPE_NOT_VALID = 'RELATION_TYPE_NOT_VALID',
} }
type ConditionalType< /**
T extends WorkspaceHealthIssueType | null, * Table issues
U, */
> = T extends WorkspaceHealthIssueType ? T : U; export type WorkspaceTableIssueTypes =
| WorkspaceHealthIssueType.MISSING_TABLE
| WorkspaceHealthIssueType.TABLE_NAME_SHOULD_BE_CUSTOM
| WorkspaceHealthIssueType.TABLE_TARGET_TABLE_NAME_NOT_VALID
| WorkspaceHealthIssueType.TABLE_DATA_SOURCE_ID_NOT_VALID
| WorkspaceHealthIssueType.TABLE_NAME_NOT_VALID;
export interface WorkspaceHealthTableIssue< export interface WorkspaceHealthTableIssue<T extends WorkspaceTableIssueTypes> {
T extends WorkspaceHealthIssueType | null = null, type: T;
> {
type: ConditionalType<
T,
| WorkspaceHealthIssueType.MISSING_TABLE
| WorkspaceHealthIssueType.TABLE_NAME_SHOULD_BE_CUSTOM
| WorkspaceHealthIssueType.TABLE_TARGET_TABLE_NAME_NOT_VALID
| WorkspaceHealthIssueType.TABLE_DATA_SOURCE_ID_NOT_VALID
| WorkspaceHealthIssueType.TABLE_NAME_NOT_VALID
>;
objectMetadata: ObjectMetadataEntity; objectMetadata: ObjectMetadataEntity;
message: string; message: string;
} }
/**
* Column issues
*/
export type WorkspaceColumnIssueTypes =
| WorkspaceHealthIssueType.MISSING_COLUMN
| WorkspaceHealthIssueType.MISSING_INDEX
| WorkspaceHealthIssueType.MISSING_FOREIGN_KEY
| WorkspaceHealthIssueType.MISSING_COMPOSITE_TYPE
| WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_NOT_BE_PREFIXED
| WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID
| WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_BE_CUSTOM
| WorkspaceHealthIssueType.COLUMN_OBJECT_REFERENCE_INVALID
| WorkspaceHealthIssueType.COLUMN_NAME_NOT_VALID
| WorkspaceHealthIssueType.COLUMN_TYPE_NOT_VALID
| WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT
| WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT
| WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT
| WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID
| WorkspaceHealthIssueType.COLUMN_OPTIONS_NOT_VALID;
export interface WorkspaceHealthColumnIssue< export interface WorkspaceHealthColumnIssue<
T extends WorkspaceHealthIssueType | null = null, T extends WorkspaceColumnIssueTypes,
> { > {
type: ConditionalType< type: T;
T,
| WorkspaceHealthIssueType.MISSING_COLUMN
| WorkspaceHealthIssueType.MISSING_INDEX
| WorkspaceHealthIssueType.MISSING_FOREIGN_KEY
| WorkspaceHealthIssueType.MISSING_COMPOSITE_TYPE
| WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID
| WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_BE_CUSTOM
| WorkspaceHealthIssueType.COLUMN_OBJECT_REFERENCE_INVALID
| WorkspaceHealthIssueType.COLUMN_NAME_NOT_VALID
| WorkspaceHealthIssueType.COLUMN_TYPE_NOT_VALID
| WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT
| WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT
| WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT
| WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID
| WorkspaceHealthIssueType.COLUMN_OPTIONS_NOT_VALID
>;
fieldMetadata: FieldMetadataEntity; fieldMetadata: FieldMetadataEntity;
columnStructure?: WorkspaceTableStructure; columnStructure?: WorkspaceTableStructure;
message: string; message: string;
} }
/**
* Relation issues
*/
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_TYPE_NOT_VALID;
export interface WorkspaceHealthRelationIssue< export interface WorkspaceHealthRelationIssue<
T extends WorkspaceHealthIssueType | null = null, T extends WorkspaceRelationIssueTypes,
> { > {
type: ConditionalType< type: T;
T,
| WorkspaceHealthIssueType.RELATION_FROM_OR_TO_FIELD_METADATA_NOT_VALID
| WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_NOT_VALID
| WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_CONFLICT
| WorkspaceHealthIssueType.RELATION_TYPE_NOT_VALID
>;
fromFieldMetadata: FieldMetadataEntity | undefined; fromFieldMetadata: FieldMetadataEntity | undefined;
toFieldMetadata: FieldMetadataEntity | undefined; toFieldMetadata: FieldMetadataEntity | undefined;
relationMetadata: RelationMetadataEntity; relationMetadata: RelationMetadataEntity;
@ -92,7 +96,20 @@ export interface WorkspaceHealthRelationIssue<
message: string; message: string;
} }
/**
* Get the interface for the issue type
*/
export type WorkspaceIssueTypeToInterface<T extends WorkspaceHealthIssueType> =
T extends WorkspaceTableIssueTypes
? WorkspaceHealthTableIssue<T>
: T extends WorkspaceColumnIssueTypes
? WorkspaceHealthColumnIssue<T>
: T extends WorkspaceRelationIssueTypes
? WorkspaceHealthRelationIssue<T>
: never;
/**
* Union of all issues
*/
export type WorkspaceHealthIssue = export type WorkspaceHealthIssue =
| WorkspaceHealthTableIssue WorkspaceIssueTypeToInterface<WorkspaceHealthIssueType>;
| WorkspaceHealthColumnIssue
| WorkspaceHealthRelationIssue;

View File

@ -125,6 +125,24 @@ export class DatabaseStructureService {
})); }));
} }
async workspaceColumnExist(
schemaName: string,
tableName: string,
columnName: string,
): Promise<boolean> {
const mainDataSource = this.typeORMService.getMainDataSource();
const results = await mainDataSource.query(
`SELECT column_name
FROM information_schema.columns
WHERE table_schema = $1
AND table_name = $2
AND column_name = $3`,
[schemaName, tableName, columnName],
);
return results.length >= 1;
}
getPostgresDataType(fieldMetadata: FieldMetadataEntity): string { getPostgresDataType(fieldMetadata: FieldMetadataEntity): string {
const typeORMType = fieldMetadataTypeToColumnType(fieldMetadata.type); const typeORMType = fieldMetadataTypeToColumnType(fieldMetadata.type);
const mainDataSource = this.typeORMService.getMainDataSource(); const mainDataSource = this.typeORMService.getMainDataSource();

View File

@ -1,5 +1,7 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { Injectable, InternalServerErrorException } from '@nestjs/common';
import isEqual from 'lodash.isequal';
import { import {
WorkspaceHealthIssue, WorkspaceHealthIssue,
WorkspaceHealthIssueType, WorkspaceHealthIssueType,
@ -23,6 +25,9 @@ import {
} from 'src/metadata/field-metadata/utils/is-enum-field-metadata-type.util'; } from 'src/metadata/field-metadata/utils/is-enum-field-metadata-type.util';
import { validateOptionsForType } from 'src/metadata/field-metadata/utils/validate-options-for-type.util'; import { validateOptionsForType } from 'src/metadata/field-metadata/utils/validate-options-for-type.util';
import { serializeDefaultValue } from 'src/metadata/field-metadata/utils/serialize-default-value'; import { serializeDefaultValue } from 'src/metadata/field-metadata/utils/serialize-default-value';
import { computeCompositeFieldMetadata } from 'src/workspace/workspace-health/utils/compute-composite-field-metadata.util';
import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/generate-target-column-map.util';
import { customNamePrefix } from 'src/workspace/utils/compute-custom-name.util';
@Injectable() @Injectable()
export class FieldMetadataHealthService { export class FieldMetadataHealthService {
@ -52,10 +57,11 @@ export class FieldMetadataHealthService {
compositeDefinitions.get(fieldMetadata.type)?.(fieldMetadata) ?? []; compositeDefinitions.get(fieldMetadata.type)?.(fieldMetadata) ?? [];
if (options.mode === 'metadata' || options.mode === 'all') { if (options.mode === 'metadata' || options.mode === 'all') {
const targetColumnMapIssues = const targetColumnMapIssue = this.targetColumnMapCheck(fieldMetadata);
this.targetColumnMapCheck(fieldMetadata);
issues.push(...targetColumnMapIssues); if (targetColumnMapIssue) {
issues.push(targetColumnMapIssue);
}
const defaultValueIssues = const defaultValueIssues =
this.defaultValueHealthCheck(fieldMetadata); this.defaultValueHealthCheck(fieldMetadata);
@ -67,7 +73,10 @@ export class FieldMetadataHealthService {
const compositeFieldIssues = await this.healthCheckField( const compositeFieldIssues = await this.healthCheckField(
tableName, tableName,
workspaceTableColumns, workspaceTableColumns,
compositeFieldMetadata as FieldMetadataEntity, computeCompositeFieldMetadata(
compositeFieldMetadata,
fieldMetadata,
),
options, options,
); );
@ -169,11 +178,7 @@ export class FieldMetadataHealthService {
}); });
} }
if ( if (columnDefaultValue && isEnumFieldMetadataType(fieldMetadata.type)) {
defaultValue &&
columnDefaultValue &&
isEnumFieldMetadataType(fieldMetadata.type)
) {
const enumValues = fieldMetadata.options?.map((option) => const enumValues = fieldMetadata.options?.map((option) =>
serializeDefaultValue(option.value), serializeDefaultValue(option.value),
); );
@ -188,11 +193,7 @@ export class FieldMetadataHealthService {
} }
} }
if ( if (columnDefaultValue !== defaultValue) {
defaultValue &&
columnDefaultValue &&
columnDefaultValue !== defaultValue
) {
issues.push({ issues.push({
type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT, type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT,
fieldMetadata, fieldMetadata,
@ -210,20 +211,22 @@ export class FieldMetadataHealthService {
): WorkspaceHealthIssue[] { ): WorkspaceHealthIssue[] {
const issues: WorkspaceHealthIssue[] = []; const issues: WorkspaceHealthIssue[] = [];
const columnName = fieldMetadata.targetColumnMap.value; const columnName = fieldMetadata.targetColumnMap.value;
const targetColumnMapIssues = this.targetColumnMapCheck(fieldMetadata); const targetColumnMapIssue = this.targetColumnMapCheck(fieldMetadata);
const defaultValueIssues = this.defaultValueHealthCheck(fieldMetadata); const defaultValueIssues = this.defaultValueHealthCheck(fieldMetadata);
if (Object.keys(fieldMetadata.targetColumnMap).length !== 1) { if (targetColumnMapIssue) {
issues.push(targetColumnMapIssue);
}
if (fieldMetadata.name.startsWith(customNamePrefix)) {
issues.push({ issues.push({
type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID, type: WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_NOT_BE_PREFIXED,
fieldMetadata, fieldMetadata,
message: `Column ${columnName} has more than one target column map, it should only contains "value"`, message: `Column ${columnName} should not be prefixed with "${customNamePrefix}"`,
}); });
} }
issues.push(...targetColumnMapIssues); if (fieldMetadata.isCustom && !columnName?.startsWith(customNamePrefix)) {
if (fieldMetadata.isCustom && !columnName?.startsWith('_')) {
issues.push({ issues.push({
type: WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_BE_CUSTOM, type: WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_BE_CUSTOM,
fieldMetadata, fieldMetadata,
@ -277,46 +280,29 @@ export class FieldMetadataHealthService {
private targetColumnMapCheck( private targetColumnMapCheck(
fieldMetadata: FieldMetadataEntity, fieldMetadata: FieldMetadataEntity,
): WorkspaceHealthIssue[] { ): WorkspaceHealthIssue | null {
const issues: WorkspaceHealthIssue[] = []; const targetColumnMap = generateTargetColumnMap(
fieldMetadata.type,
if (!fieldMetadata.targetColumnMap) { fieldMetadata.isCustom,
issues.push({ fieldMetadata.name,
type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID, );
fieldMetadata,
message: `Column targetColumnMap of ${fieldMetadata.name} is empty`,
});
}
if (!isCompositeFieldMetadataType(fieldMetadata.type)) {
if (
Object.keys(fieldMetadata.targetColumnMap).length !== 1 &&
!('value' in fieldMetadata.targetColumnMap)
) {
issues.push({
type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID,
fieldMetadata,
message: `Column targetColumnMap "${fieldMetadata.targetColumnMap}" is not valid or well structured`,
});
}
return issues;
}
if ( if (
!this.isCompositeObjectWellStructured( !fieldMetadata.targetColumnMap ||
fieldMetadata.type, !isEqual(targetColumnMap, fieldMetadata.targetColumnMap)
fieldMetadata.targetColumnMap,
)
) { ) {
issues.push({ return {
type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID, type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID,
fieldMetadata, fieldMetadata,
message: `Column targetColumnMap for composite type ${fieldMetadata.type} is not well structured "${fieldMetadata.targetColumnMap}"`, message: `Column targetColumnMap "${JSON.stringify(
}); fieldMetadata.targetColumnMap,
)}" is not the same as the generated one "${JSON.stringify(
targetColumnMap,
)}"`,
};
} }
return issues; return null;
} }
private defaultValueHealthCheck( private defaultValueHealthCheck(

View File

@ -7,55 +7,92 @@ import { WorkspaceHealthIssue } from 'src/workspace/workspace-health/interfaces/
import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity'; import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity';
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { import { WorkspaceNullableFixer } from 'src/workspace/workspace-health/fixer/workspace-nullable.fixer';
isWorkspaceHealthDefaultValueIssue, import { WorkspaceDefaultValueFixer } from 'src/workspace/workspace-health/fixer/workspace-default-value.fixer';
isWorkspaceHealthNullableIssue, import { WorkspaceTypeFixer } from 'src/workspace/workspace-health/fixer/workspace-type.fixer';
isWorkspaceHealthTypeIssue, import { WorkspaceTargetColumnMapFixer } from 'src/workspace/workspace-health/fixer/workspace-target-column-map.fixer';
} from 'src/workspace/workspace-health/utils/is-workspace-health-issue-type.util'; import { CompareEntity } from 'src/workspace/workspace-health/fixer/abstract-workspace.fixer';
import { WorkspaceFixNullableService } from './workspace-fix-nullable.service';
import { WorkspaceFixTypeService } from './workspace-fix-type.service';
import { WorkspaceFixDefaultValueService } from './workspace-fix-default-value.service';
@Injectable() @Injectable()
export class WorkspaceFixService { export class WorkspaceFixService {
constructor( constructor(
private readonly workspaceFixNullableService: WorkspaceFixNullableService, private readonly workspaceNullableFixer: WorkspaceNullableFixer,
private readonly workspaceFixTypeService: WorkspaceFixTypeService, private readonly workspaceDefaultValueFixer: WorkspaceDefaultValueFixer,
private readonly workspaceFixDefaultValueService: WorkspaceFixDefaultValueService, private readonly workspaceTypeFixer: WorkspaceTypeFixer,
private readonly workspaceTargetColumnMapFixer: WorkspaceTargetColumnMapFixer,
) {} ) {}
async fix( async createWorkspaceMigrations(
manager: EntityManager, manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[], objectMetadataCollection: ObjectMetadataEntity[],
type: WorkspaceHealthFixKind, type: WorkspaceHealthFixKind,
issues: WorkspaceHealthIssue[], issues: WorkspaceHealthIssue[],
): Promise<Partial<WorkspaceMigrationEntity>[]> { ): Promise<Partial<WorkspaceMigrationEntity>[]> {
const services = { switch (type) {
[WorkspaceHealthFixKind.Nullable]: { case WorkspaceHealthFixKind.Nullable: {
service: this.workspaceFixNullableService, const filteredIssues = this.workspaceNullableFixer.filterIssues(issues);
issues: issues.filter((issue) =>
isWorkspaceHealthNullableIssue(issue.type),
),
},
[WorkspaceHealthFixKind.Type]: {
service: this.workspaceFixTypeService,
issues: issues.filter((issue) =>
isWorkspaceHealthTypeIssue(issue.type),
),
},
[WorkspaceHealthFixKind.DefaultValue]: {
service: this.workspaceFixDefaultValueService,
issues: issues.filter((issue) =>
isWorkspaceHealthDefaultValueIssue(issue.type),
),
},
};
return services[type].service.fix( return this.workspaceNullableFixer.createWorkspaceMigrations(
manager, manager,
objectMetadataCollection, objectMetadataCollection,
services[type].issues, filteredIssues,
); );
}
case WorkspaceHealthFixKind.DefaultValue: {
const filteredIssues =
this.workspaceDefaultValueFixer.filterIssues(issues);
return this.workspaceDefaultValueFixer.createWorkspaceMigrations(
manager,
objectMetadataCollection,
filteredIssues,
);
}
case WorkspaceHealthFixKind.Type: {
const filteredIssues = this.workspaceTypeFixer.filterIssues(issues);
return this.workspaceTypeFixer.createWorkspaceMigrations(
manager,
objectMetadataCollection,
filteredIssues,
);
}
case WorkspaceHealthFixKind.TargetColumnMap: {
const filteredIssues =
this.workspaceTargetColumnMapFixer.filterIssues(issues);
return this.workspaceTargetColumnMapFixer.createWorkspaceMigrations(
manager,
objectMetadataCollection,
filteredIssues,
);
}
default: {
return [];
}
}
}
async createMetadataUpdates(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
type: WorkspaceHealthFixKind,
issues: WorkspaceHealthIssue[],
): Promise<CompareEntity<unknown>[]> {
switch (type) {
case WorkspaceHealthFixKind.TargetColumnMap: {
const filteredIssues =
this.workspaceTargetColumnMapFixer.filterIssues(issues);
return this.workspaceTargetColumnMapFixer.createMetadataUpdates(
manager,
objectMetadataCollection,
filteredIssues,
);
}
default: {
return [];
}
}
} }
} }

View File

@ -0,0 +1,15 @@
import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity';
import { camelCase } from 'src/utils/camel-case';
// Compute composite field metadata by combining the composite field metadata with the field metadata
export const computeCompositeFieldMetadata = (
compositeFieldMetadata: FieldMetadataInterface,
fieldMetadata: FieldMetadataEntity,
): FieldMetadataEntity => ({
...fieldMetadata,
...compositeFieldMetadata,
objectMetadataId: fieldMetadata.objectMetadataId,
name: camelCase(`${fieldMetadata.name}-${compositeFieldMetadata.name}`),
});

View File

@ -17,3 +17,9 @@ export const isWorkspaceHealthDefaultValueIssue = (
): type is WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT => { ): type is WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT => {
return type === WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT; return type === WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT;
}; };
export const isWorkspaceHealthTargetColumnMapIssue = (
type: WorkspaceHealthIssueType,
): type is WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID => {
return type === WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID;
};

View File

@ -12,10 +12,9 @@ import { WorkspaceHealthService } from 'src/workspace/workspace-health/workspace
import { WorkspaceMigrationBuilderModule } from 'src/workspace/workspace-migration-builder/workspace-migration-builder.module'; import { WorkspaceMigrationBuilderModule } from 'src/workspace/workspace-migration-builder/workspace-migration-builder.module';
import { WorkspaceMigrationRunnerModule } from 'src/workspace/workspace-migration-runner/workspace-migration-runner.module'; import { WorkspaceMigrationRunnerModule } from 'src/workspace/workspace-migration-runner/workspace-migration-runner.module';
import { workspaceFixers } from './fixer';
import { WorkspaceFixService } from './services/workspace-fix.service'; import { WorkspaceFixService } from './services/workspace-fix.service';
import { WorkspaceFixNullableService } from './services/workspace-fix-nullable.service';
import { WorkspaceFixTypeService } from './services/workspace-fix-type.service';
import { WorkspaceFixDefaultValueService } from './services/workspace-fix-default-value.service';
@Module({ @Module({
imports: [ imports: [
@ -27,14 +26,12 @@ import { WorkspaceFixDefaultValueService } from './services/workspace-fix-defaul
WorkspaceMigrationBuilderModule, WorkspaceMigrationBuilderModule,
], ],
providers: [ providers: [
...workspaceFixers,
WorkspaceHealthService, WorkspaceHealthService,
DatabaseStructureService, DatabaseStructureService,
ObjectMetadataHealthService, ObjectMetadataHealthService,
FieldMetadataHealthService, FieldMetadataHealthService,
RelationMetadataHealthService, RelationMetadataHealthService,
WorkspaceFixNullableService,
WorkspaceFixTypeService,
WorkspaceFixDefaultValueService,
WorkspaceFixService, WorkspaceFixService,
], ],
exports: [WorkspaceHealthService], exports: [WorkspaceHealthService],

View File

@ -125,8 +125,12 @@ export class WorkspaceHealthService {
type: WorkspaceHealthFixKind; type: WorkspaceHealthFixKind;
applyChanges?: boolean; applyChanges?: boolean;
}, },
): Promise<Partial<WorkspaceMigrationEntity>[]> { ): Promise<{
workspaceMigrations: Partial<WorkspaceMigrationEntity>[];
metadataEntities: unknown[];
}> {
let workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = []; let workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
let metadataEntities: unknown[] = [];
// Set default options // Set default options
options.applyChanges ??= true; options.applyChanges ??= true;
@ -145,7 +149,15 @@ export class WorkspaceHealthService {
const objectMetadataCollection = const objectMetadataCollection =
await this.objectMetadataService.findManyWithinWorkspace(workspaceId); await this.objectMetadataService.findManyWithinWorkspace(workspaceId);
workspaceMigrations = await this.workspaceFixService.fix( workspaceMigrations =
await this.workspaceFixService.createWorkspaceMigrations(
manager,
objectMetadataCollection,
options.type,
issues,
);
metadataEntities = await this.workspaceFixService.createMetadataUpdates(
manager, manager,
objectMetadataCollection, objectMetadataCollection,
options.type, options.type,
@ -161,7 +173,10 @@ export class WorkspaceHealthService {
await queryRunner.release(); await queryRunner.release();
return workspaceMigrations; return {
workspaceMigrations,
metadataEntities,
};
} }
// Commit the transaction // Commit the transaction
@ -178,6 +193,9 @@ export class WorkspaceHealthService {
await queryRunner.release(); await queryRunner.release();
} }
return workspaceMigrations; return {
workspaceMigrations,
metadataEntities,
};
} }
} }

View File

@ -120,6 +120,7 @@ export class WorkspaceMigrationFieldFactory {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = []; const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
for (const fieldMetadataUpdate of fieldMetadataUpdateCollection) { for (const fieldMetadataUpdate of fieldMetadataUpdateCollection) {
// Skip relations, because they're just representation and not real columns
if (fieldMetadataUpdate.altered.type === FieldMetadataType.RELATION) { if (fieldMetadataUpdate.altered.type === FieldMetadataType.RELATION) {
continue; continue;
} }

View File

@ -268,7 +268,7 @@ export class WorkspaceMigrationRunnerService {
// TODO: Maybe we can do something better if we can recreate the old `TableColumn` object // TODO: Maybe we can do something better if we can recreate the old `TableColumn` object
if (enumValues) { if (enumValues) {
// This is returning the old enum values to avoid TypeORM droping the enum type // This is returning the old enum values to avoid TypeORM dropping the enum type
await this.workspaceMigrationEnumService.alterEnum( await this.workspaceMigrationEnumService.alterEnum(
queryRunner, queryRunner,
schemaName, schemaName,

View File

@ -1,7 +1,10 @@
import { Logger } from '@nestjs/common';
import { Command, CommandRunner, Option } from 'nest-commander'; import { Command, CommandRunner, Option } from 'nest-commander';
import { DataSourceService } from 'src/metadata/data-source/data-source.service'; import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metadata/workspace-sync-metadata.service'; import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metadata/workspace-sync-metadata.service';
import { WorkspaceHealthService } from 'src/workspace/workspace-health/workspace-health.service';
import { SyncWorkspaceLoggerService } from './services/sync-workspace-logger.service'; import { SyncWorkspaceLoggerService } from './services/sync-workspace-logger.service';
@ -9,6 +12,7 @@ import { SyncWorkspaceLoggerService } from './services/sync-workspace-logger.ser
interface RunWorkspaceMigrationsOptions { interface RunWorkspaceMigrationsOptions {
workspaceId: string; workspaceId: string;
dryRun?: boolean; dryRun?: boolean;
force?: boolean;
} }
@Command({ @Command({
@ -16,8 +20,11 @@ interface RunWorkspaceMigrationsOptions {
description: 'Sync metadata', description: 'Sync metadata',
}) })
export class SyncWorkspaceMetadataCommand extends CommandRunner { export class SyncWorkspaceMetadataCommand extends CommandRunner {
private readonly logger = new Logger(SyncWorkspaceMetadataCommand.name);
constructor( constructor(
private readonly workspaceSyncMetadataService: WorkspaceSyncMetadataService, private readonly workspaceSyncMetadataService: WorkspaceSyncMetadataService,
private readonly workspaceHealthService: WorkspaceHealthService,
private readonly dataSourceService: DataSourceService, private readonly dataSourceService: DataSourceService,
private readonly syncWorkspaceLoggerService: SyncWorkspaceLoggerService, private readonly syncWorkspaceLoggerService: SyncWorkspaceLoggerService,
) { ) {
@ -28,7 +35,30 @@ export class SyncWorkspaceMetadataCommand extends CommandRunner {
_passedParam: string[], _passedParam: string[],
options: RunWorkspaceMigrationsOptions, options: RunWorkspaceMigrationsOptions,
): Promise<void> { ): Promise<void> {
// TODO: run in a dedicated job + run queries in a transaction. const issues = await this.workspaceHealthService.healthCheck(
options.workspaceId,
);
// Security: abort if there are issues.
if (issues.length > 0) {
if (!options.force) {
this.logger.error(
`Workspace contains ${issues.length} issues, aborting.`,
);
this.logger.log('If you want to force the migration, use --force flag');
this.logger.log(
'Please use `workspace:health` command to check issues and fix them before running this command.',
);
return;
}
this.logger.warn(
`Workspace contains ${issues.length} issues, sync has been forced.`,
);
}
const dataSourceMetadata = const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
options.workspaceId, options.workspaceId,
@ -68,4 +98,13 @@ export class SyncWorkspaceMetadataCommand extends CommandRunner {
dryRun(): boolean { dryRun(): boolean {
return true; return true;
} }
@Option({
flags: '-f, --force',
description: 'Force migration',
required: false,
})
force(): boolean {
return true;
}
} }

View File

@ -2,13 +2,18 @@ import { Module } from '@nestjs/common';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module'; import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
import { WorkspaceSyncMetadataModule } from 'src/workspace/workspace-sync-metadata/workspace-sync-metadata.module'; import { WorkspaceSyncMetadataModule } from 'src/workspace/workspace-sync-metadata/workspace-sync-metadata.module';
import { WorkspaceHealthModule } from 'src/workspace/workspace-health/workspace-health.module';
import { SyncWorkspaceMetadataCommand } from './sync-workspace-metadata.command'; import { SyncWorkspaceMetadataCommand } from './sync-workspace-metadata.command';
import { SyncWorkspaceLoggerService } from './services/sync-workspace-logger.service'; import { SyncWorkspaceLoggerService } from './services/sync-workspace-logger.service';
@Module({ @Module({
imports: [WorkspaceSyncMetadataModule, DataSourceModule], imports: [
WorkspaceSyncMetadataModule,
WorkspaceHealthModule,
DataSourceModule,
],
providers: [SyncWorkspaceMetadataCommand, SyncWorkspaceLoggerService], providers: [SyncWorkspaceMetadataCommand, SyncWorkspaceLoggerService],
}) })
export class WorkspaceSyncMetadataCommandsModule {} export class WorkspaceSyncMetadataCommandsModule {}

View File

@ -15577,7 +15577,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/lodash.isequal@npm:^4.5.7": "@types/lodash.isequal@npm:^4.5.7, @types/lodash.isequal@npm:^4.5.8":
version: 4.5.8 version: 4.5.8
resolution: "@types/lodash.isequal@npm:4.5.8" resolution: "@types/lodash.isequal@npm:4.5.8"
dependencies: dependencies:
@ -44115,6 +44115,7 @@ __metadata:
"@nx/js": "npm:17.2.8" "@nx/js": "npm:17.2.8"
"@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@4.2.0#./patches/@ptc-org+nestjs-query-graphql+4.2.0.patch" "@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@4.2.0#./patches/@ptc-org+nestjs-query-graphql+4.2.0.patch"
"@types/lodash.isempty": "npm:^4.4.7" "@types/lodash.isempty": "npm:^4.4.7"
"@types/lodash.isequal": "npm:^4.5.8"
"@types/lodash.isobject": "npm:^3.0.7" "@types/lodash.isobject": "npm:^3.0.7"
"@types/lodash.omit": "npm:^4.5.9" "@types/lodash.omit": "npm:^4.5.9"
"@types/lodash.snakecase": "npm:^4.1.7" "@types/lodash.snakecase": "npm:^4.1.7"
@ -44122,6 +44123,7 @@ __metadata:
"@types/react": "npm:^18.2.39" "@types/react": "npm:^18.2.39"
class-validator: "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch" class-validator: "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch"
graphql-middleware: "npm:^6.1.35" graphql-middleware: "npm:^6.1.35"
lodash.isequal: "npm:^4.5.0"
passport: "npm:^0.7.0" passport: "npm:^0.7.0"
rimraf: "npm:^5.0.5" rimraf: "npm:^5.0.5"
typescript: "npm:^5.3.3" typescript: "npm:^5.3.3"