feat: refactor folder structure (#4498)
* feat: wip refactor folder structure * Fix * fix position --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceHealthCommand } from 'src/engine/workspace-manager/workspace-health/commands/workspace-health.command';
|
||||
import { WorkspaceHealthModule } from 'src/engine/workspace-manager/workspace-health/workspace-health.module';
|
||||
|
||||
@Module({
|
||||
imports: [WorkspaceHealthModule],
|
||||
providers: [WorkspaceHealthCommand],
|
||||
})
|
||||
export class WorkspaceHealthCommandModule {}
|
||||
@ -0,0 +1,136 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { WorkspaceHealthMode } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-options.interface';
|
||||
import { WorkspaceHealthFixKind } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-fix-kind.interface';
|
||||
|
||||
import { WorkspaceHealthService } from 'src/engine/workspace-manager/workspace-health/workspace-health.service';
|
||||
import { CommandLogger } from 'src/commands/command-logger';
|
||||
|
||||
interface WorkspaceHealthCommandOptions {
|
||||
workspaceId: string;
|
||||
mode?: WorkspaceHealthMode;
|
||||
fix?: WorkspaceHealthFixKind;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: 'workspace:health',
|
||||
description: 'Check health of the given workspace.',
|
||||
})
|
||||
export class WorkspaceHealthCommand extends CommandRunner {
|
||||
private readonly logger = new Logger(WorkspaceHealthCommand.name);
|
||||
private readonly commandLogger = new CommandLogger(
|
||||
WorkspaceHealthCommand.name,
|
||||
);
|
||||
|
||||
constructor(private readonly workspaceHealthService: WorkspaceHealthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(
|
||||
_passedParam: string[],
|
||||
options: WorkspaceHealthCommandOptions,
|
||||
): Promise<void> {
|
||||
const issues = await this.workspaceHealthService.healthCheck(
|
||||
options.workspaceId,
|
||||
{
|
||||
mode: options.mode ?? WorkspaceHealthMode.All,
|
||||
},
|
||||
);
|
||||
|
||||
if (issues.length === 0) {
|
||||
this.logger.log(chalk.green('Workspace is healthy'));
|
||||
} else {
|
||||
this.logger.log(
|
||||
chalk.red(`Workspace is not healthy, found ${issues.length} issues`),
|
||||
);
|
||||
|
||||
if (options.dryRun) {
|
||||
await this.commandLogger.writeLog(
|
||||
`workspace-health-issues-${options.workspaceId}`,
|
||||
issues,
|
||||
);
|
||||
this.logger.log(chalk.yellow('Issues written to log'));
|
||||
}
|
||||
}
|
||||
|
||||
if (options.fix) {
|
||||
this.logger.log(chalk.yellow('Fixing issues'));
|
||||
|
||||
const { workspaceMigrations, metadataEntities } =
|
||||
await this.workspaceHealthService.fixIssues(
|
||||
options.workspaceId,
|
||||
issues,
|
||||
{
|
||||
type: options.fix,
|
||||
applyChanges: !options.dryRun,
|
||||
},
|
||||
);
|
||||
const totalCount = workspaceMigrations.length + metadataEntities.length;
|
||||
|
||||
if (options.dryRun) {
|
||||
await this.commandLogger.writeLog(
|
||||
`workspace-health-${options.fix}-migrations`,
|
||||
workspaceMigrations,
|
||||
);
|
||||
|
||||
await this.commandLogger.writeLog(
|
||||
`workspace-health-${options.fix}-metadata-entities`,
|
||||
metadataEntities,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
chalk.green(`Fixed ${totalCount}/${issues.length} issues`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-w, --workspace-id [workspace_id]',
|
||||
description: 'workspace id',
|
||||
required: true,
|
||||
})
|
||||
parseWorkspaceId(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-f, --fix [kind]',
|
||||
description: 'fix issues',
|
||||
required: false,
|
||||
})
|
||||
fix(value: string): WorkspaceHealthFixKind {
|
||||
if (!Object.values(WorkspaceHealthFixKind).includes(value as any)) {
|
||||
throw new Error(`Invalid fix kind ${value}`);
|
||||
}
|
||||
|
||||
return value as WorkspaceHealthFixKind;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-m, --mode [mode]',
|
||||
description: 'Mode of the health check [structure, metadata, all]',
|
||||
required: false,
|
||||
defaultValue: WorkspaceHealthMode.All,
|
||||
})
|
||||
parseMode(value: string): WorkspaceHealthMode {
|
||||
if (!Object.values(WorkspaceHealthMode).includes(value as any)) {
|
||||
throw new Error(`Invalid mode ${value}`);
|
||||
}
|
||||
|
||||
return value as WorkspaceHealthMode;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-d, --dry-run',
|
||||
description: 'Dry run without applying changes',
|
||||
required: false,
|
||||
})
|
||||
dryRun(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import {
|
||||
WorkspaceHealthIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
WorkspaceIssueTypeToInterface,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { WorkspaceMigrationEntity } from 'src/engine-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>[]>;
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
@ -0,0 +1,101 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import {
|
||||
WorkspaceHealthColumnIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
|
||||
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { WorkspaceMigrationFieldFactory } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-field.factory';
|
||||
|
||||
import { AbstractWorkspaceFixer } from './abstract-workspace.fixer';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceDefaultValueFixer extends AbstractWorkspaceFixer<WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT> {
|
||||
constructor(
|
||||
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
|
||||
) {
|
||||
super(WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT);
|
||||
}
|
||||
|
||||
async createWorkspaceMigrations(
|
||||
manager: EntityManager,
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT>[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
if (issues.length <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.fixColumnDefaultValueIssues(objectMetadataCollection, issues);
|
||||
}
|
||||
|
||||
private async fixColumnDefaultValueIssues(
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT>[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const fieldMetadataUpdateCollection = issues.map((issue) => {
|
||||
const oldDefaultValue =
|
||||
this.computeFieldMetadataDefaultValueFromColumnDefault(
|
||||
issue.columnStructure?.columnDefault,
|
||||
);
|
||||
|
||||
return {
|
||||
current: {
|
||||
...issue.fieldMetadata,
|
||||
defaultValue: oldDefaultValue,
|
||||
},
|
||||
altered: issue.fieldMetadata,
|
||||
};
|
||||
});
|
||||
|
||||
return this.workspaceMigrationFieldFactory.create(
|
||||
objectMetadataCollection,
|
||||
fieldMetadataUpdateCollection,
|
||||
WorkspaceMigrationBuilderAction.UPDATE,
|
||||
);
|
||||
}
|
||||
|
||||
private computeFieldMetadataDefaultValueFromColumnDefault(
|
||||
columnDefault: string | undefined,
|
||||
): FieldMetadataDefaultValue<'default'> {
|
||||
if (
|
||||
columnDefault === undefined ||
|
||||
columnDefault === null ||
|
||||
columnDefault === 'NULL'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isNaN(Number(columnDefault))) {
|
||||
return { value: +columnDefault };
|
||||
}
|
||||
|
||||
if (columnDefault === 'true') {
|
||||
return { value: true };
|
||||
}
|
||||
|
||||
if (columnDefault === 'false') {
|
||||
return { value: false };
|
||||
}
|
||||
|
||||
if (columnDefault === '') {
|
||||
return { value: '' };
|
||||
}
|
||||
|
||||
if (columnDefault === 'now()') {
|
||||
return { type: 'now' };
|
||||
}
|
||||
|
||||
if (columnDefault.startsWith('public.uuid_generate_v4')) {
|
||||
return { type: 'uuid' };
|
||||
}
|
||||
|
||||
return { value: columnDefault };
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import {
|
||||
WorkspaceHealthColumnIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { WorkspaceMigrationFieldFactory } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-field.factory';
|
||||
|
||||
import { AbstractWorkspaceFixer } from './abstract-workspace.fixer';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceNullableFixer extends AbstractWorkspaceFixer<WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT> {
|
||||
constructor(
|
||||
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
|
||||
) {
|
||||
super(WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT);
|
||||
}
|
||||
|
||||
async createWorkspaceMigrations(
|
||||
manager: EntityManager,
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT>[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
if (issues.length <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.fixColumnNullabilityIssues(objectMetadataCollection, issues);
|
||||
}
|
||||
|
||||
private async fixColumnNullabilityIssues(
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT>[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const fieldMetadataUpdateCollection = issues.map((issue) => {
|
||||
return {
|
||||
current: {
|
||||
...issue.fieldMetadata,
|
||||
isNullable: issue.columnStructure?.isNullable ?? false,
|
||||
},
|
||||
altered: issue.fieldMetadata,
|
||||
};
|
||||
});
|
||||
|
||||
return this.workspaceMigrationFieldFactory.create(
|
||||
objectMetadataCollection,
|
||||
fieldMetadataUpdateCollection,
|
||||
WorkspaceMigrationBuilderAction.UPDATE,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { EntityManager } from 'typeorm';
|
||||
import isEqual from 'lodash.isequal';
|
||||
|
||||
import {
|
||||
WorkspaceHealthColumnIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { generateTargetColumnMap } from 'src/engine-metadata/field-metadata/utils/generate-target-column-map.util';
|
||||
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
|
||||
import { DataSourceEntity } from 'src/engine-metadata/data-source/data-source.entity';
|
||||
import { DatabaseStructureService } from 'src/engine/workspace-manager/workspace-health/services/database-structure.service';
|
||||
import { WorkspaceMigrationFieldFactory } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-field.factory';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine-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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import {
|
||||
WorkspaceHealthColumnIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import {
|
||||
FieldMetadataUpdate,
|
||||
WorkspaceMigrationFieldFactory,
|
||||
} from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-field.factory';
|
||||
import { DatabaseStructureService } from 'src/engine/workspace-manager/workspace-health/services/database-structure.service';
|
||||
|
||||
import { AbstractWorkspaceFixer } from './abstract-workspace.fixer';
|
||||
|
||||
const oldDataTypes = ['integer'];
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceTypeFixer extends AbstractWorkspaceFixer<WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT> {
|
||||
private readonly logger = new Logger(WorkspaceTypeFixer.name);
|
||||
|
||||
constructor(
|
||||
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
|
||||
private readonly databaseStructureService: DatabaseStructureService,
|
||||
) {
|
||||
super(WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT);
|
||||
}
|
||||
|
||||
async createWorkspaceMigrations(
|
||||
manager: EntityManager,
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT>[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
if (issues.length <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.fixColumnTypeIssues(objectMetadataCollection, issues);
|
||||
}
|
||||
|
||||
private async fixColumnTypeIssues(
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT>[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const fieldMetadataUpdateCollection: FieldMetadataUpdate[] = [];
|
||||
|
||||
for (const issue of issues) {
|
||||
const dataType = issue.columnStructure?.dataType;
|
||||
|
||||
if (!dataType) {
|
||||
throw new Error('Column structure data type is missing');
|
||||
}
|
||||
|
||||
const type =
|
||||
this.databaseStructureService.getFieldMetadataTypeFromPostgresDataType(
|
||||
dataType,
|
||||
);
|
||||
|
||||
if (oldDataTypes.includes(dataType)) {
|
||||
this.logger.warn(
|
||||
`Old data type detected for column ${issue.columnStructure?.columnName} with data type ${dataType}. Please update the column data type manually.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
throw new Error("Can't find field metadata type from column structure");
|
||||
}
|
||||
|
||||
fieldMetadataUpdateCollection.push({
|
||||
current: {
|
||||
...issue.fieldMetadata,
|
||||
type,
|
||||
},
|
||||
altered: issue.fieldMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
return this.workspaceMigrationFieldFactory.create(
|
||||
objectMetadataCollection,
|
||||
fieldMetadataUpdateCollection,
|
||||
WorkspaceMigrationBuilderAction.UPDATE,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export enum WorkspaceHealthFixKind {
|
||||
Nullable = 'nullable',
|
||||
Type = 'type',
|
||||
DefaultValue = 'default-value',
|
||||
TargetColumnMap = 'target-column-map',
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
import { WorkspaceTableStructure } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-table-definition.interface';
|
||||
|
||||
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
|
||||
|
||||
export enum WorkspaceHealthIssueType {
|
||||
MISSING_TABLE = 'MISSING_TABLE',
|
||||
TABLE_NAME_SHOULD_BE_CUSTOM = 'TABLE_NAME_SHOULD_BE_CUSTOM',
|
||||
TABLE_TARGET_TABLE_NAME_NOT_VALID = 'TABLE_TARGET_TABLE_NAME_NOT_VALID',
|
||||
TABLE_DATA_SOURCE_ID_NOT_VALID = 'TABLE_DATA_SOURCE_ID_NOT_VALID',
|
||||
TABLE_NAME_NOT_VALID = 'TABLE_NAME_NOT_VALID',
|
||||
MISSING_COLUMN = 'MISSING_COLUMN',
|
||||
MISSING_INDEX = 'MISSING_INDEX',
|
||||
MISSING_FOREIGN_KEY = 'MISSING_FOREIGN_KEY',
|
||||
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_NAME_SHOULD_BE_CUSTOM = 'COLUMN_NAME_SHOULD_BE_CUSTOM',
|
||||
COLUMN_OBJECT_REFERENCE_INVALID = 'COLUMN_OBJECT_REFERENCE_INVALID',
|
||||
COLUMN_NAME_NOT_VALID = 'COLUMN_NAME_NOT_VALID',
|
||||
COLUMN_TYPE_NOT_VALID = 'COLUMN_TYPE_NOT_VALID',
|
||||
COLUMN_DATA_TYPE_CONFLICT = 'COLUMN_DATA_TYPE_CONFLICT',
|
||||
COLUMN_NULLABILITY_CONFLICT = 'COLUMN_NULLABILITY_CONFLICT',
|
||||
COLUMN_DEFAULT_VALUE_CONFLICT = 'COLUMN_DEFAULT_VALUE_CONFLICT',
|
||||
COLUMN_DEFAULT_VALUE_NOT_VALID = 'COLUMN_DEFAULT_VALUE_NOT_VALID',
|
||||
COLUMN_OPTIONS_NOT_VALID = 'COLUMN_OPTIONS_NOT_VALID',
|
||||
RELATION_METADATA_NOT_VALID = 'RELATION_METADATA_NOT_VALID',
|
||||
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',
|
||||
}
|
||||
|
||||
/**
|
||||
* Table issues
|
||||
*/
|
||||
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<T extends WorkspaceTableIssueTypes> {
|
||||
type: T;
|
||||
objectMetadata: ObjectMetadataEntity;
|
||||
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<
|
||||
T extends WorkspaceColumnIssueTypes,
|
||||
> {
|
||||
type: T;
|
||||
fieldMetadata: FieldMetadataEntity;
|
||||
columnStructure?: WorkspaceTableStructure;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relation issues
|
||||
*/
|
||||
export type WorkspaceRelationIssueTypes =
|
||||
| WorkspaceHealthIssueType.RELATION_METADATA_NOT_VALID
|
||||
| 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<
|
||||
T extends WorkspaceRelationIssueTypes,
|
||||
> {
|
||||
type: T;
|
||||
fromFieldMetadata?: FieldMetadataEntity | undefined;
|
||||
toFieldMetadata?: FieldMetadataEntity | undefined;
|
||||
relationMetadata?: RelationMetadataEntity;
|
||||
columnStructure?: WorkspaceTableStructure;
|
||||
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 =
|
||||
WorkspaceIssueTypeToInterface<WorkspaceHealthIssueType>;
|
||||
@ -0,0 +1,9 @@
|
||||
export enum WorkspaceHealthMode {
|
||||
Structure = 'structure',
|
||||
Metadata = 'metadata',
|
||||
All = 'all',
|
||||
}
|
||||
|
||||
export interface WorkspaceHealthOptions {
|
||||
mode: WorkspaceHealthMode;
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
export interface WorkspaceTableStructure {
|
||||
tableSchema: string;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
dataType: string;
|
||||
columnDefault: string;
|
||||
isNullable: boolean;
|
||||
isPrimaryKey: boolean;
|
||||
isForeignKey: boolean;
|
||||
isUnique: boolean;
|
||||
onUpdateAction: string;
|
||||
onDeleteAction: string;
|
||||
}
|
||||
|
||||
export type WorkspaceTableStructureResult = {
|
||||
[P in keyof WorkspaceTableStructure]: string;
|
||||
};
|
||||
@ -0,0 +1,239 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ColumnType } from 'typeorm';
|
||||
import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
|
||||
|
||||
import {
|
||||
WorkspaceTableStructure,
|
||||
WorkspaceTableStructureResult,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-table-definition.interface';
|
||||
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import {
|
||||
FieldMetadataEntity,
|
||||
FieldMetadataType,
|
||||
} from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { fieldMetadataTypeToColumnType } from 'src/engine-metadata/workspace-migration/utils/field-metadata-type-to-column-type.util';
|
||||
import { serializeTypeDefaultValue } from 'src/engine-metadata/field-metadata/utils/serialize-type-default-value.util';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine-metadata/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { isRelationFieldMetadataType } from 'src/engine-workspace/utils/is-relation-field-metadata-type.util';
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseStructureService {
|
||||
constructor(private readonly typeORMService: TypeORMService) {}
|
||||
|
||||
async getWorkspaceTableColumns(
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
): Promise<WorkspaceTableStructure[]> {
|
||||
const mainDataSource = this.typeORMService.getMainDataSource();
|
||||
const results = await mainDataSource.query<
|
||||
WorkspaceTableStructureResult[]
|
||||
>(`
|
||||
WITH foreign_keys AS (
|
||||
SELECT
|
||||
kcu.table_schema AS schema_name,
|
||||
kcu.table_name AS table_name,
|
||||
kcu.column_name AS column_name,
|
||||
tc.constraint_name AS constraint_name
|
||||
FROM
|
||||
information_schema.key_column_usage AS kcu
|
||||
JOIN
|
||||
information_schema.table_constraints AS tc
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
WHERE
|
||||
tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_schema = '${schemaName}'
|
||||
AND tc.table_name = '${tableName}'
|
||||
),
|
||||
unique_constraints AS (
|
||||
SELECT
|
||||
tc.table_schema AS schema_name,
|
||||
tc.table_name AS table_name,
|
||||
kcu.column_name AS column_name
|
||||
FROM
|
||||
information_schema.key_column_usage AS kcu
|
||||
JOIN
|
||||
information_schema.table_constraints AS tc
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
WHERE
|
||||
tc.constraint_type = 'UNIQUE'
|
||||
AND tc.table_schema = '${schemaName}'
|
||||
AND tc.table_name = '${tableName}'
|
||||
)
|
||||
SELECT
|
||||
c.table_schema AS "tableSchema",
|
||||
c.table_name AS "tableName",
|
||||
c.column_name AS "columnName",
|
||||
CASE
|
||||
WHEN (c.data_type = 'USER-DEFINED') THEN c.udt_name
|
||||
ELSE data_type
|
||||
END AS "dataType",
|
||||
c.is_nullable AS "isNullable",
|
||||
c.column_default AS "columnDefault",
|
||||
CASE
|
||||
WHEN pk.constraint_type = 'PRIMARY KEY' THEN 'TRUE'
|
||||
ELSE 'FALSE'
|
||||
END AS "isPrimaryKey",
|
||||
CASE
|
||||
WHEN fk.constraint_name IS NOT NULL THEN 'TRUE'
|
||||
ELSE 'FALSE'
|
||||
END AS "isForeignKey",
|
||||
CASE
|
||||
WHEN uc.column_name IS NOT NULL THEN 'TRUE'
|
||||
ELSE 'FALSE'
|
||||
END AS "isUnique",
|
||||
rc.update_rule AS "onUpdateAction",
|
||||
rc.delete_rule AS "onDeleteAction"
|
||||
FROM
|
||||
information_schema.columns AS c
|
||||
LEFT JOIN
|
||||
information_schema.constraint_column_usage AS ccu
|
||||
ON c.column_name = ccu.column_name
|
||||
AND c.table_name = ccu.table_name
|
||||
AND c.table_schema = ccu.table_schema
|
||||
LEFT JOIN
|
||||
information_schema.table_constraints AS pk
|
||||
ON pk.constraint_name = ccu.constraint_name
|
||||
AND pk.constraint_type = 'PRIMARY KEY'
|
||||
AND pk.table_name = c.table_name
|
||||
AND pk.table_schema = c.table_schema
|
||||
LEFT JOIN
|
||||
foreign_keys AS fk
|
||||
ON c.table_schema = fk.schema_name
|
||||
AND c.table_name = fk.table_name
|
||||
AND c.column_name = fk.column_name
|
||||
LEFT JOIN
|
||||
unique_constraints AS uc
|
||||
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
|
||||
AND rc.constraint_schema = '${schemaName}'
|
||||
WHERE
|
||||
c.table_schema = '${schemaName}'
|
||||
AND c.table_name = '${tableName}';
|
||||
`);
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return results.map((item) => ({
|
||||
...item,
|
||||
isNullable: item.isNullable === 'YES',
|
||||
isPrimaryKey: item.isPrimaryKey === 'TRUE',
|
||||
isForeignKey: item.isForeignKey === 'TRUE',
|
||||
isUnique: item.isUnique === 'TRUE',
|
||||
}));
|
||||
}
|
||||
|
||||
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 {
|
||||
const typeORMType = fieldMetadataTypeToColumnType(fieldMetadata.type);
|
||||
const mainDataSource = this.typeORMService.getMainDataSource();
|
||||
|
||||
// Compute enum name to compare data type properly
|
||||
if (typeORMType === 'enum') {
|
||||
const objectName = fieldMetadata.object?.nameSingular;
|
||||
const prefix = fieldMetadata.isCustom ? '_' : '';
|
||||
const fieldName = fieldMetadata.name;
|
||||
|
||||
return `${objectName}_${prefix}${fieldName}_enum`;
|
||||
}
|
||||
|
||||
return mainDataSource.driver.normalizeType({
|
||||
type: typeORMType,
|
||||
});
|
||||
}
|
||||
|
||||
getFieldMetadataTypeFromPostgresDataType(
|
||||
postgresDataType: string,
|
||||
): FieldMetadataType | null {
|
||||
const mainDataSource = this.typeORMService.getMainDataSource();
|
||||
const types = Object.values(FieldMetadataType).filter((type) => {
|
||||
// We're skipping composite and relation types, as they're not directly mapped to a column type
|
||||
if (isCompositeFieldMetadataType(type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isRelationFieldMetadataType(type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const type of types) {
|
||||
const typeORMType = fieldMetadataTypeToColumnType(
|
||||
FieldMetadataType[type],
|
||||
) as ColumnType;
|
||||
const dataType = mainDataSource.driver.normalizeType({
|
||||
type: typeORMType,
|
||||
});
|
||||
|
||||
if (postgresDataType === dataType) {
|
||||
return FieldMetadataType[type];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getPostgresDefault(
|
||||
fieldMetadataType: FieldMetadataType,
|
||||
defaultValue: FieldMetadataDefaultValue | null,
|
||||
): string | null | undefined {
|
||||
const typeORMType = fieldMetadataTypeToColumnType(
|
||||
fieldMetadataType,
|
||||
) as ColumnType;
|
||||
const mainDataSource = this.typeORMService.getMainDataSource();
|
||||
|
||||
if (defaultValue && 'type' in defaultValue) {
|
||||
const serializedDefaultValue = serializeTypeDefaultValue(defaultValue);
|
||||
|
||||
// Special case for uuid_generate_v4() default value
|
||||
if (serializedDefaultValue === 'public.uuid_generate_v4()') {
|
||||
return 'uuid_generate_v4()';
|
||||
}
|
||||
|
||||
return serializedDefaultValue;
|
||||
}
|
||||
|
||||
const value =
|
||||
defaultValue && 'value' in defaultValue ? defaultValue.value : null;
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
return mainDataSource.driver.normalizeDefault({
|
||||
type: typeORMType,
|
||||
default: value,
|
||||
isArray: false,
|
||||
// Workaround to use normalizeDefault without a complete ColumnMetadata object
|
||||
} as ColumnMetadata);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,369 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
|
||||
import isEqual from 'lodash.isequal';
|
||||
|
||||
import {
|
||||
WorkspaceHealthIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import { WorkspaceTableStructure } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-table-definition.interface';
|
||||
import { WorkspaceHealthOptions } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-options.interface';
|
||||
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
|
||||
import {
|
||||
FieldMetadataEntity,
|
||||
FieldMetadataType,
|
||||
} from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine-metadata/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { DatabaseStructureService } from 'src/engine/workspace-manager/workspace-health/services/database-structure.service';
|
||||
import { validName } from 'src/engine/workspace-manager/workspace-health/utils/valid-name.util';
|
||||
import { compositeDefinitions } from 'src/engine-metadata/field-metadata/composite-types';
|
||||
import { validateDefaultValueForType } from 'src/engine-metadata/field-metadata/utils/validate-default-value-for-type.util';
|
||||
import {
|
||||
EnumFieldMetadataUnionType,
|
||||
isEnumFieldMetadataType,
|
||||
} from 'src/engine-metadata/field-metadata/utils/is-enum-field-metadata-type.util';
|
||||
import { validateOptionsForType } from 'src/engine-metadata/field-metadata/utils/validate-options-for-type.util';
|
||||
import { serializeDefaultValue } from 'src/engine-metadata/field-metadata/utils/serialize-default-value';
|
||||
import { computeCompositeFieldMetadata } from 'src/engine/workspace-manager/workspace-health/utils/compute-composite-field-metadata.util';
|
||||
import { generateTargetColumnMap } from 'src/engine-metadata/field-metadata/utils/generate-target-column-map.util';
|
||||
import { customNamePrefix } from 'src/engine-workspace/utils/compute-custom-name.util';
|
||||
import { isRelationFieldMetadataType } from 'src/engine-workspace/utils/is-relation-field-metadata-type.util';
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataHealthService {
|
||||
constructor(
|
||||
private readonly databaseStructureService: DatabaseStructureService,
|
||||
) {}
|
||||
|
||||
async healthCheck(
|
||||
tableName: string,
|
||||
workspaceTableColumns: WorkspaceTableStructure[],
|
||||
fieldMetadataCollection: FieldMetadataEntity[],
|
||||
options: WorkspaceHealthOptions,
|
||||
): Promise<WorkspaceHealthIssue[]> {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
for (const fieldMetadata of fieldMetadataCollection) {
|
||||
// Relation metadata are checked in another service
|
||||
if (isRelationFieldMetadataType(fieldMetadata.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||
const compositeFieldMetadataCollection =
|
||||
compositeDefinitions.get(fieldMetadata.type)?.(fieldMetadata) ?? [];
|
||||
|
||||
if (options.mode === 'metadata' || options.mode === 'all') {
|
||||
const targetColumnMapIssue = this.targetColumnMapCheck(fieldMetadata);
|
||||
|
||||
if (targetColumnMapIssue) {
|
||||
issues.push(targetColumnMapIssue);
|
||||
}
|
||||
|
||||
const defaultValueIssues =
|
||||
this.defaultValueHealthCheck(fieldMetadata);
|
||||
|
||||
issues.push(...defaultValueIssues);
|
||||
}
|
||||
|
||||
for (const compositeFieldMetadata of compositeFieldMetadataCollection) {
|
||||
const compositeFieldIssues = await this.healthCheckField(
|
||||
tableName,
|
||||
workspaceTableColumns,
|
||||
computeCompositeFieldMetadata(
|
||||
compositeFieldMetadata,
|
||||
fieldMetadata,
|
||||
),
|
||||
options,
|
||||
);
|
||||
|
||||
issues.push(...compositeFieldIssues);
|
||||
}
|
||||
} else {
|
||||
const fieldIssues = await this.healthCheckField(
|
||||
tableName,
|
||||
workspaceTableColumns,
|
||||
fieldMetadata,
|
||||
options,
|
||||
);
|
||||
|
||||
issues.push(...fieldIssues);
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private async healthCheckField(
|
||||
tableName: string,
|
||||
workspaceTableColumns: WorkspaceTableStructure[],
|
||||
fieldMetadata: FieldMetadataEntity,
|
||||
options: WorkspaceHealthOptions,
|
||||
): Promise<WorkspaceHealthIssue[]> {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
if (options.mode === 'structure' || options.mode === 'all') {
|
||||
const structureIssues = this.structureFieldCheck(
|
||||
tableName,
|
||||
workspaceTableColumns,
|
||||
fieldMetadata,
|
||||
);
|
||||
|
||||
issues.push(...structureIssues);
|
||||
}
|
||||
|
||||
if (options.mode === 'metadata' || options.mode === 'all') {
|
||||
const metadataIssues = this.metadataFieldCheck(tableName, fieldMetadata);
|
||||
|
||||
issues.push(...metadataIssues);
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private structureFieldCheck(
|
||||
tableName: string,
|
||||
workspaceTableColumns: WorkspaceTableStructure[],
|
||||
fieldMetadata: FieldMetadataEntity,
|
||||
): WorkspaceHealthIssue[] {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
const columnName = fieldMetadata.targetColumnMap.value;
|
||||
|
||||
const dataType =
|
||||
this.databaseStructureService.getPostgresDataType(fieldMetadata);
|
||||
|
||||
const defaultValue = this.databaseStructureService.getPostgresDefault(
|
||||
fieldMetadata.type,
|
||||
fieldMetadata.defaultValue,
|
||||
);
|
||||
// Check if column exist in database
|
||||
const columnStructure = workspaceTableColumns.find(
|
||||
(tableDefinition) => tableDefinition.columnName === columnName,
|
||||
);
|
||||
|
||||
if (!columnStructure) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.MISSING_COLUMN,
|
||||
fieldMetadata,
|
||||
columnStructure,
|
||||
message: `Column ${columnName} not found in table ${tableName}`,
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
const columnDefaultValue = columnStructure.columnDefault?.split('::')?.[0];
|
||||
|
||||
// Check if column data type is the same
|
||||
if (columnStructure.dataType !== dataType) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT,
|
||||
fieldMetadata,
|
||||
columnStructure,
|
||||
message: `Column ${columnName} type is not the same as the field metadata type "${columnStructure.dataType}" !== "${dataType}"`,
|
||||
});
|
||||
}
|
||||
|
||||
if (columnStructure.isNullable !== fieldMetadata.isNullable) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT,
|
||||
fieldMetadata,
|
||||
columnStructure,
|
||||
message: `Column ${columnName} is expected to be ${
|
||||
fieldMetadata.isNullable ? 'nullable' : 'not nullable'
|
||||
} but is ${columnStructure.isNullable ? 'nullable' : 'not nullable'}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (columnDefaultValue && isEnumFieldMetadataType(fieldMetadata.type)) {
|
||||
const enumValues = fieldMetadata.options?.map((option) =>
|
||||
serializeDefaultValue(option.value),
|
||||
);
|
||||
|
||||
if (!enumValues.includes(columnDefaultValue)) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID,
|
||||
fieldMetadata,
|
||||
columnStructure,
|
||||
message: `Column ${columnName} default value is not in the enum values "${columnDefaultValue}" NOT IN "${enumValues}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (columnDefaultValue !== defaultValue) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT,
|
||||
fieldMetadata,
|
||||
columnStructure,
|
||||
message: `Column ${columnName} default value is not the same as the field metadata default value "${columnStructure.columnDefault}" !== "${defaultValue}"`,
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private metadataFieldCheck(
|
||||
tableName: string,
|
||||
fieldMetadata: FieldMetadataEntity,
|
||||
): WorkspaceHealthIssue[] {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
const columnName = fieldMetadata.targetColumnMap.value;
|
||||
const targetColumnMapIssue = this.targetColumnMapCheck(fieldMetadata);
|
||||
const defaultValueIssues = this.defaultValueHealthCheck(fieldMetadata);
|
||||
|
||||
if (targetColumnMapIssue) {
|
||||
issues.push(targetColumnMapIssue);
|
||||
}
|
||||
|
||||
if (fieldMetadata.name.startsWith(customNamePrefix)) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_NOT_BE_PREFIXED,
|
||||
fieldMetadata,
|
||||
message: `Column ${columnName} should not be prefixed with "${customNamePrefix}"`,
|
||||
});
|
||||
}
|
||||
|
||||
if (fieldMetadata.isCustom && !columnName?.startsWith(customNamePrefix)) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_BE_CUSTOM,
|
||||
fieldMetadata,
|
||||
message: `Column ${columnName} is marked as custom in table ${tableName} but doesn't start with "_"`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!fieldMetadata.objectMetadataId) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_OBJECT_REFERENCE_INVALID,
|
||||
fieldMetadata,
|
||||
message: `Column ${columnName} doesn't have a valid object metadata id`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Object.values(FieldMetadataType).includes(fieldMetadata.type)) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_TYPE_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column ${columnName} doesn't have a valid field metadata type`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!fieldMetadata.name ||
|
||||
!validName(fieldMetadata.name) ||
|
||||
!fieldMetadata.label
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_NAME_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column ${columnName} doesn't have a valid name or label`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isEnumFieldMetadataType(fieldMetadata.type) &&
|
||||
!validateOptionsForType(fieldMetadata.type, fieldMetadata.options)
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_OPTIONS_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column options of ${fieldMetadata.targetColumnMap?.value} is not valid`,
|
||||
});
|
||||
}
|
||||
|
||||
issues.push(...defaultValueIssues);
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private targetColumnMapCheck(
|
||||
fieldMetadata: FieldMetadataEntity,
|
||||
): WorkspaceHealthIssue | null {
|
||||
const targetColumnMap = generateTargetColumnMap(
|
||||
fieldMetadata.type,
|
||||
fieldMetadata.isCustom,
|
||||
fieldMetadata.name,
|
||||
);
|
||||
|
||||
if (
|
||||
!fieldMetadata.targetColumnMap ||
|
||||
!isEqual(targetColumnMap, fieldMetadata.targetColumnMap)
|
||||
) {
|
||||
return {
|
||||
type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column targetColumnMap "${JSON.stringify(
|
||||
fieldMetadata.targetColumnMap,
|
||||
)}" is not the same as the generated one "${JSON.stringify(
|
||||
targetColumnMap,
|
||||
)}"`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private defaultValueHealthCheck(
|
||||
fieldMetadata: FieldMetadataEntity,
|
||||
): WorkspaceHealthIssue[] {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
if (
|
||||
!validateDefaultValueForType(
|
||||
fieldMetadata.type,
|
||||
fieldMetadata.defaultValue,
|
||||
)
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column default value for composite type ${fieldMetadata.type} is not well structured`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isEnumFieldMetadataType(fieldMetadata.type) &&
|
||||
fieldMetadata.defaultValue
|
||||
) {
|
||||
const enumValues = fieldMetadata.options?.map((option) => option.value);
|
||||
const metadataDefaultValue = (
|
||||
fieldMetadata.defaultValue as FieldMetadataDefaultValue<EnumFieldMetadataUnionType>
|
||||
)?.value;
|
||||
|
||||
if (metadataDefaultValue && !enumValues.includes(metadataDefaultValue)) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column default value is not in the enum values "${metadataDefaultValue}" NOT IN "${enumValues}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private isCompositeObjectWellStructured(
|
||||
fieldMetadataType: FieldMetadataType,
|
||||
object: any,
|
||||
): boolean {
|
||||
const subFields = compositeDefinitions.get(fieldMetadataType)?.() ?? [];
|
||||
|
||||
if (!object) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (subFields.length === 0) {
|
||||
throw new InternalServerErrorException(
|
||||
`The composite field type ${fieldMetadataType} doesn't have any sub fields, it seems this one is not implemented in the composite definitions map`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const subField of subFields) {
|
||||
if (!object[subField.name]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,116 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
WorkspaceHealthIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import { WorkspaceHealthOptions } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-options.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { validName } from 'src/engine/workspace-manager/workspace-health/utils/valid-name.util';
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
|
||||
|
||||
@Injectable()
|
||||
export class ObjectMetadataHealthService {
|
||||
constructor(private readonly typeORMService: TypeORMService) {}
|
||||
|
||||
async healthCheck(
|
||||
schemaName: string,
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
options: WorkspaceHealthOptions,
|
||||
): Promise<WorkspaceHealthIssue[]> {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
if (options.mode === 'structure' || options.mode === 'all') {
|
||||
const structureIssues = await this.structureObjectCheck(
|
||||
schemaName,
|
||||
objectMetadata,
|
||||
);
|
||||
|
||||
issues.push(...structureIssues);
|
||||
}
|
||||
|
||||
if (options.mode === 'metadata' || options.mode === 'all') {
|
||||
const metadataIssues = this.metadataObjectCheck(objectMetadata);
|
||||
|
||||
issues.push(...metadataIssues);
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the structure health of the table based on metadata
|
||||
* @param schemaName
|
||||
* @param objectMetadata
|
||||
* @returns WorkspaceHealthIssue[]
|
||||
*/
|
||||
private async structureObjectCheck(
|
||||
schemaName: string,
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
): Promise<WorkspaceHealthIssue[]> {
|
||||
const mainDataSource = this.typeORMService.getMainDataSource();
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
// Check if the table exist in database
|
||||
const tableExist = await mainDataSource.query(
|
||||
`SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = '${schemaName}'
|
||||
AND table_name = '${computeObjectTargetTable(objectMetadata)}')`,
|
||||
);
|
||||
|
||||
if (!tableExist) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.MISSING_TABLE,
|
||||
objectMetadata,
|
||||
message: `Table ${computeObjectTargetTable(
|
||||
objectMetadata,
|
||||
)} not found in schema ${schemaName}`,
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check ObjectMetadata health
|
||||
* @param objectMetadata
|
||||
* @returns WorkspaceHealthIssue[]
|
||||
*/
|
||||
private metadataObjectCheck(
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
): WorkspaceHealthIssue[] {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
if (!objectMetadata.dataSourceId) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.TABLE_DATA_SOURCE_ID_NOT_VALID,
|
||||
objectMetadata,
|
||||
message: `Table ${computeObjectTargetTable(
|
||||
objectMetadata,
|
||||
)} doesn't have a data source`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!objectMetadata.nameSingular ||
|
||||
!objectMetadata.namePlural ||
|
||||
!validName(objectMetadata.nameSingular) ||
|
||||
!validName(objectMetadata.namePlural) ||
|
||||
!objectMetadata.labelSingular ||
|
||||
!objectMetadata.labelPlural
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.TABLE_NAME_NOT_VALID,
|
||||
objectMetadata,
|
||||
message: `Table ${computeObjectTargetTable(
|
||||
objectMetadata,
|
||||
)} doesn't have a valid name or label`,
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,254 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceTableStructure } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-table-definition.interface';
|
||||
import {
|
||||
WorkspaceHealthIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import {
|
||||
WorkspaceHealthMode,
|
||||
WorkspaceHealthOptions,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-options.interface';
|
||||
|
||||
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import {
|
||||
RelationMetadataEntity,
|
||||
RelationMetadataType,
|
||||
} from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
|
||||
import {
|
||||
RelationDirection,
|
||||
deduceRelationDirection,
|
||||
} from 'src/engine-workspace/utils/deduce-relation-direction.util';
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { createRelationForeignKeyColumnName } from 'src/engine-metadata/relation-metadata/utils/create-relation-foreign-key-column-name.util';
|
||||
import { createRelationForeignKeyFieldMetadataName } from 'src/engine-metadata/relation-metadata/utils/create-relation-foreign-key-field-metadata-name.util';
|
||||
import { isRelationFieldMetadataType } from 'src/engine-workspace/utils/is-relation-field-metadata-type.util';
|
||||
import { convertOnDeleteActionToOnDelete } from 'src/engine/workspace-manager/workspace-migration-runner/utils/convert-on-delete-action-to-on-delete.util';
|
||||
|
||||
@Injectable()
|
||||
export class RelationMetadataHealthService {
|
||||
constructor() {}
|
||||
|
||||
public healthCheck(
|
||||
workspaceTableColumns: WorkspaceTableStructure[],
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
options: WorkspaceHealthOptions,
|
||||
) {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
for (const fieldMetadata of objectMetadata.fields) {
|
||||
// We're only interested in relation fields
|
||||
if (!isRelationFieldMetadataType(fieldMetadata.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relationMetadata =
|
||||
fieldMetadata.fromRelationMetadata ?? fieldMetadata.toRelationMetadata;
|
||||
|
||||
if (!relationMetadata) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_METADATA_NOT_VALID,
|
||||
message: `Field ${fieldMetadata.id} has invalid relation metadata`,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const relationDirection = deduceRelationDirection(
|
||||
fieldMetadata,
|
||||
relationMetadata,
|
||||
);
|
||||
|
||||
// Many to many relations are not supported yet
|
||||
if (relationMetadata.relationType === RelationMetadataType.MANY_TO_MANY) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fromObjectMetadata = objectMetadataCollection.find(
|
||||
(objectMetadata) =>
|
||||
objectMetadata.id === relationMetadata.fromObjectMetadataId,
|
||||
);
|
||||
const fromFieldMetadata = fromObjectMetadata?.fields.find(
|
||||
(fieldMetadata) =>
|
||||
fieldMetadata.id === relationMetadata.fromFieldMetadataId,
|
||||
);
|
||||
const toObjectMetadata = objectMetadataCollection.find(
|
||||
(objectMetadata) =>
|
||||
objectMetadata.id === relationMetadata.toObjectMetadataId,
|
||||
);
|
||||
const toFieldMetadata = toObjectMetadata?.fields.find(
|
||||
(fieldMetadata) =>
|
||||
fieldMetadata.id === relationMetadata.toFieldMetadataId,
|
||||
);
|
||||
|
||||
if (!fromFieldMetadata || !toFieldMetadata) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_FROM_OR_TO_FIELD_METADATA_NOT_VALID,
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationMetadata,
|
||||
message: `Relation ${relationMetadata.id} has invalid from or to field metadata`,
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
if (
|
||||
options.mode === WorkspaceHealthMode.All ||
|
||||
options.mode === WorkspaceHealthMode.Structure
|
||||
) {
|
||||
// Check relation structure
|
||||
const structureIssues = this.structureRelationCheck(
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
toObjectMetadata?.fields ?? [],
|
||||
relationDirection,
|
||||
relationMetadata,
|
||||
workspaceTableColumns,
|
||||
);
|
||||
|
||||
issues.push(...structureIssues);
|
||||
}
|
||||
|
||||
if (
|
||||
options.mode === WorkspaceHealthMode.All ||
|
||||
options.mode === WorkspaceHealthMode.Metadata
|
||||
) {
|
||||
// Check relation metadata
|
||||
const metadataIssues = this.metadataRelationCheck(
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationDirection,
|
||||
relationMetadata,
|
||||
);
|
||||
|
||||
issues.push(...metadataIssues);
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private structureRelationCheck(
|
||||
fromFieldMetadata: FieldMetadataEntity,
|
||||
toFieldMetadata: FieldMetadataEntity,
|
||||
toObjectMetadataFields: FieldMetadataEntity[],
|
||||
relationDirection: RelationDirection,
|
||||
relationMetadata: RelationMetadataEntity,
|
||||
workspaceTableColumns: WorkspaceTableStructure[],
|
||||
): WorkspaceHealthIssue[] {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
// Nothing to check on the structure
|
||||
if (relationDirection === RelationDirection.FROM) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const isCustom = toFieldMetadata.isCustom ?? false;
|
||||
const foreignKeyColumnName = createRelationForeignKeyColumnName(
|
||||
toFieldMetadata.name,
|
||||
isCustom,
|
||||
);
|
||||
const relationColumn = workspaceTableColumns.find(
|
||||
(column) => column.columnName === foreignKeyColumnName,
|
||||
);
|
||||
const relationFieldMetadata = toObjectMetadataFields.find(
|
||||
(fieldMetadata) =>
|
||||
fieldMetadata.name ===
|
||||
createRelationForeignKeyFieldMetadataName(toFieldMetadata.name),
|
||||
);
|
||||
|
||||
if (!relationFieldMetadata) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_NOT_VALID,
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationMetadata,
|
||||
message: `Relation ${
|
||||
relationMetadata.id
|
||||
} doesn't have a valid foreign key (expected fieldMetadata.name to be ${createRelationForeignKeyFieldMetadataName(
|
||||
toFieldMetadata.name,
|
||||
)}`,
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
if (!relationColumn) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_NOT_VALID,
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationMetadata,
|
||||
message: `Relation ${relationMetadata.id} doesn't have a valid foreign key (expected column name to be ${foreignKeyColumnName}`,
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
if (!relationColumn.isForeignKey) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_CONFLICT,
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationMetadata,
|
||||
message: `Relation ${relationMetadata.id} foreign key is not properly set`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
relationMetadata.relationType === RelationMetadataType.ONE_TO_ONE &&
|
||||
!relationColumn.isUnique
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_CONFLICT,
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationMetadata,
|
||||
message: `Relation ${relationMetadata.id} foreign key is not marked as unique and relation type is one-to-one`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
convertOnDeleteActionToOnDelete(relationMetadata.onDeleteAction) !==
|
||||
relationColumn.onDeleteAction
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_ON_DELETE_ACTION_CONFLICT,
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationMetadata,
|
||||
columnStructure: relationColumn,
|
||||
message: `Relation ${relationMetadata.id} foreign key onDeleteAction is not properly set`,
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private metadataRelationCheck(
|
||||
fromFieldMetadata: FieldMetadataEntity,
|
||||
toFieldMetadata: FieldMetadataEntity,
|
||||
relationDirection: RelationDirection,
|
||||
relationMetadata: RelationMetadataEntity,
|
||||
): WorkspaceHealthIssue[] {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
if (
|
||||
!Object.values(RelationMetadataType).includes(
|
||||
relationMetadata.relationType,
|
||||
)
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_TYPE_NOT_VALID,
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationMetadata,
|
||||
message: `Relation ${relationMetadata.id} has invalid relation type`,
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import { WorkspaceHealthFixKind } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-fix-kind.interface';
|
||||
import { WorkspaceHealthIssue } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
|
||||
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { WorkspaceNullableFixer } from 'src/engine/workspace-manager/workspace-health/fixer/workspace-nullable.fixer';
|
||||
import { WorkspaceDefaultValueFixer } from 'src/engine/workspace-manager/workspace-health/fixer/workspace-default-value.fixer';
|
||||
import { WorkspaceTypeFixer } from 'src/engine/workspace-manager/workspace-health/fixer/workspace-type.fixer';
|
||||
import { WorkspaceTargetColumnMapFixer } from 'src/engine/workspace-manager/workspace-health/fixer/workspace-target-column-map.fixer';
|
||||
import { CompareEntity } from 'src/engine/workspace-manager/workspace-health/fixer/abstract-workspace.fixer';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceFixService {
|
||||
constructor(
|
||||
private readonly workspaceNullableFixer: WorkspaceNullableFixer,
|
||||
private readonly workspaceDefaultValueFixer: WorkspaceDefaultValueFixer,
|
||||
private readonly workspaceTypeFixer: WorkspaceTypeFixer,
|
||||
private readonly workspaceTargetColumnMapFixer: WorkspaceTargetColumnMapFixer,
|
||||
) {}
|
||||
|
||||
async createWorkspaceMigrations(
|
||||
manager: EntityManager,
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
type: WorkspaceHealthFixKind,
|
||||
issues: WorkspaceHealthIssue[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
switch (type) {
|
||||
case WorkspaceHealthFixKind.Nullable: {
|
||||
const filteredIssues = this.workspaceNullableFixer.filterIssues(issues);
|
||||
|
||||
return this.workspaceNullableFixer.createWorkspaceMigrations(
|
||||
manager,
|
||||
objectMetadataCollection,
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
|
||||
|
||||
import { FieldMetadataEntity } from 'src/engine-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}`),
|
||||
});
|
||||
@ -0,0 +1,25 @@
|
||||
import { WorkspaceHealthIssueType } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
|
||||
export const isWorkspaceHealthNullableIssue = (
|
||||
type: WorkspaceHealthIssueType,
|
||||
): type is WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT => {
|
||||
return type === WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT;
|
||||
};
|
||||
|
||||
export const isWorkspaceHealthTypeIssue = (
|
||||
type: WorkspaceHealthIssueType,
|
||||
): type is WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT => {
|
||||
return type === WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT;
|
||||
};
|
||||
|
||||
export const isWorkspaceHealthDefaultValueIssue = (
|
||||
type: WorkspaceHealthIssueType,
|
||||
): type is 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;
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
|
||||
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
|
||||
export const mapFieldMetadataTypeToDataType = (
|
||||
fieldMetadataType: FieldMetadataType,
|
||||
): string => {
|
||||
switch (fieldMetadataType) {
|
||||
case FieldMetadataType.UUID:
|
||||
return 'uuid';
|
||||
case FieldMetadataType.TEXT:
|
||||
return 'text';
|
||||
case FieldMetadataType.PHONE:
|
||||
case FieldMetadataType.EMAIL:
|
||||
return 'varchar';
|
||||
case FieldMetadataType.NUMERIC:
|
||||
return 'numeric';
|
||||
case FieldMetadataType.NUMBER:
|
||||
case FieldMetadataType.PROBABILITY:
|
||||
return 'double precision';
|
||||
case FieldMetadataType.BOOLEAN:
|
||||
return 'boolean';
|
||||
case FieldMetadataType.DATE_TIME:
|
||||
return 'timestamp';
|
||||
case FieldMetadataType.RATING:
|
||||
case FieldMetadataType.SELECT:
|
||||
case FieldMetadataType.MULTI_SELECT:
|
||||
return 'enum';
|
||||
default:
|
||||
throw new ConflictException(
|
||||
`Cannot convert ${fieldMetadataType} to data type.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
export const validName = (name: string): boolean => {
|
||||
return /^[a-zA-Z0-9_]+$/.test(name);
|
||||
};
|
||||
@ -0,0 +1,39 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { DataSourceModule } from 'src/engine-metadata/data-source/data-source.module';
|
||||
import { ObjectMetadataModule } from 'src/engine-metadata/object-metadata/object-metadata.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { DatabaseStructureService } from 'src/engine/workspace-manager/workspace-health/services/database-structure.service';
|
||||
import { FieldMetadataHealthService } from 'src/engine/workspace-manager/workspace-health/services/field-metadata-health.service';
|
||||
import { ObjectMetadataHealthService } from 'src/engine/workspace-manager/workspace-health/services/object-metadata-health.service';
|
||||
import { RelationMetadataHealthService } from 'src/engine/workspace-manager/workspace-health/services/relation-metadata.health.service';
|
||||
import { WorkspaceHealthService } from 'src/engine/workspace-manager/workspace-health/workspace-health.service';
|
||||
import { WorkspaceMigrationBuilderModule } from 'src/engine/workspace-manager/workspace-migration-builder/workspace-migration-builder.module';
|
||||
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
|
||||
|
||||
import { workspaceFixers } from './fixer';
|
||||
|
||||
import { WorkspaceFixService } from './services/workspace-fix.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DataSourceModule,
|
||||
TypeORMModule,
|
||||
ObjectMetadataModule,
|
||||
WorkspaceDataSourceModule,
|
||||
WorkspaceMigrationRunnerModule,
|
||||
WorkspaceMigrationBuilderModule,
|
||||
],
|
||||
providers: [
|
||||
...workspaceFixers,
|
||||
WorkspaceHealthService,
|
||||
DatabaseStructureService,
|
||||
ObjectMetadataHealthService,
|
||||
FieldMetadataHealthService,
|
||||
RelationMetadataHealthService,
|
||||
WorkspaceFixService,
|
||||
],
|
||||
exports: [WorkspaceHealthService],
|
||||
})
|
||||
export class WorkspaceHealthModule {}
|
||||
@ -0,0 +1,201 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { WorkspaceHealthIssue } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import {
|
||||
WorkspaceHealthMode,
|
||||
WorkspaceHealthOptions,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-options.interface';
|
||||
import { WorkspaceHealthFixKind } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-fix-kind.interface';
|
||||
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import { DataSourceService } from 'src/engine-metadata/data-source/data-source.service';
|
||||
import { ObjectMetadataService } from 'src/engine-metadata/object-metadata/object-metadata.service';
|
||||
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
|
||||
import { ObjectMetadataHealthService } from 'src/engine/workspace-manager/workspace-health/services/object-metadata-health.service';
|
||||
import { FieldMetadataHealthService } from 'src/engine/workspace-manager/workspace-health/services/field-metadata-health.service';
|
||||
import { RelationMetadataHealthService } from 'src/engine/workspace-manager/workspace-health/services/relation-metadata.health.service';
|
||||
import { DatabaseStructureService } from 'src/engine/workspace-manager/workspace-health/services/database-structure.service';
|
||||
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
|
||||
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
|
||||
import { WorkspaceFixService } from 'src/engine/workspace-manager/workspace-health/services/workspace-fix.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceHealthService {
|
||||
constructor(
|
||||
@InjectDataSource('metadata')
|
||||
private readonly metadataDataSource: DataSource,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly typeORMService: TypeORMService,
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
private readonly databaseStructureService: DatabaseStructureService,
|
||||
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
|
||||
private readonly objectMetadataHealthService: ObjectMetadataHealthService,
|
||||
private readonly fieldMetadataHealthService: FieldMetadataHealthService,
|
||||
private readonly relationMetadataHealthService: RelationMetadataHealthService,
|
||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||
private readonly workspaceFixService: WorkspaceFixService,
|
||||
) {}
|
||||
|
||||
async healthCheck(
|
||||
workspaceId: string,
|
||||
options: WorkspaceHealthOptions = { mode: WorkspaceHealthMode.All },
|
||||
): Promise<WorkspaceHealthIssue[]> {
|
||||
const schemaName =
|
||||
this.workspaceDataSourceService.getSchemaName(workspaceId);
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
const dataSourceMetadata =
|
||||
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
// Check if a data source exists for this workspace
|
||||
if (!dataSourceMetadata) {
|
||||
throw new NotFoundException(
|
||||
`DataSource for workspace id ${workspaceId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
// Try to connect to the data source
|
||||
await this.typeORMService.connectToDataSource(dataSourceMetadata);
|
||||
|
||||
const objectMetadataCollection =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspaceId);
|
||||
|
||||
// Check if object metadata exists for this workspace
|
||||
if (!objectMetadataCollection || objectMetadataCollection.length === 0) {
|
||||
throw new NotFoundException(`Workspace with id ${workspaceId} not found`);
|
||||
}
|
||||
|
||||
for (const objectMetadata of objectMetadataCollection) {
|
||||
const tableName = computeObjectTargetTable(objectMetadata);
|
||||
const workspaceTableColumns =
|
||||
await this.databaseStructureService.getWorkspaceTableColumns(
|
||||
schemaName,
|
||||
tableName,
|
||||
);
|
||||
|
||||
if (!workspaceTableColumns || workspaceTableColumns.length === 0) {
|
||||
throw new NotFoundException(
|
||||
`Table ${tableName} not found in schema ${schemaName}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check object metadata health
|
||||
const objectIssues = await this.objectMetadataHealthService.healthCheck(
|
||||
schemaName,
|
||||
objectMetadata,
|
||||
options,
|
||||
);
|
||||
|
||||
issues.push(...objectIssues);
|
||||
|
||||
// Check fields metadata health
|
||||
const fieldIssues = await this.fieldMetadataHealthService.healthCheck(
|
||||
computeObjectTargetTable(objectMetadata),
|
||||
workspaceTableColumns,
|
||||
objectMetadata.fields,
|
||||
options,
|
||||
);
|
||||
|
||||
issues.push(...fieldIssues);
|
||||
|
||||
// Check relation metadata health
|
||||
const relationIssues = this.relationMetadataHealthService.healthCheck(
|
||||
workspaceTableColumns,
|
||||
objectMetadataCollection,
|
||||
objectMetadata,
|
||||
options,
|
||||
);
|
||||
|
||||
issues.push(...relationIssues);
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
async fixIssues(
|
||||
workspaceId: string,
|
||||
issues: WorkspaceHealthIssue[],
|
||||
options: {
|
||||
type: WorkspaceHealthFixKind;
|
||||
applyChanges?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
workspaceMigrations: Partial<WorkspaceMigrationEntity>[];
|
||||
metadataEntities: unknown[];
|
||||
}> {
|
||||
let workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
|
||||
let metadataEntities: unknown[] = [];
|
||||
|
||||
// Set default options
|
||||
options.applyChanges ??= true;
|
||||
|
||||
const queryRunner = this.metadataDataSource.createQueryRunner();
|
||||
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
const manager = queryRunner.manager;
|
||||
|
||||
try {
|
||||
const workspaceMigrationRepository = manager.getRepository(
|
||||
WorkspaceMigrationEntity,
|
||||
);
|
||||
const objectMetadataCollection =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspaceId);
|
||||
|
||||
workspaceMigrations =
|
||||
await this.workspaceFixService.createWorkspaceMigrations(
|
||||
manager,
|
||||
objectMetadataCollection,
|
||||
options.type,
|
||||
issues,
|
||||
);
|
||||
|
||||
metadataEntities = await this.workspaceFixService.createMetadataUpdates(
|
||||
manager,
|
||||
objectMetadataCollection,
|
||||
options.type,
|
||||
issues,
|
||||
);
|
||||
|
||||
// Save workspace migrations into the database
|
||||
await workspaceMigrationRepository.save(workspaceMigrations);
|
||||
|
||||
if (!options.applyChanges) {
|
||||
// Rollback transactions
|
||||
await queryRunner.rollbackTransaction();
|
||||
|
||||
await queryRunner.release();
|
||||
|
||||
return {
|
||||
workspaceMigrations,
|
||||
metadataEntities,
|
||||
};
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
// Apply pending migrations
|
||||
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
|
||||
workspaceId,
|
||||
);
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
console.error('Fix of issues failed with:', error);
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceMigrations,
|
||||
metadataEntities,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user