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:
Jérémy M
2024-03-15 14:40:58 +01:00
committed by GitHub
parent 52f1b3ac98
commit 94487f6737
760 changed files with 3215 additions and 3155 deletions

View File

@ -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 {}

View File

@ -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;
}
}

View File

@ -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>[]>;
}

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

@ -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 };
}
}

View File

@ -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,
);
}
}

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/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;
}
}

View File

@ -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,
);
}
}

View File

@ -0,0 +1,6 @@
export enum WorkspaceHealthFixKind {
Nullable = 'nullable',
Type = 'type',
DefaultValue = 'default-value',
TargetColumnMap = 'target-column-map',
}

View File

@ -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>;

View File

@ -0,0 +1,9 @@
export enum WorkspaceHealthMode {
Structure = 'structure',
Metadata = 'metadata',
All = 'all',
}
export interface WorkspaceHealthOptions {
mode: WorkspaceHealthMode;
}

View File

@ -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;
};

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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 [];
}
}
}
}

View File

@ -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}`),
});

View File

@ -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;
};

View File

@ -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.`,
);
}
};

View File

@ -0,0 +1,3 @@
export const validName = (name: string): boolean => {
return /^[a-zA-Z0-9_]+$/.test(name);
};

View File

@ -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 {}

View File

@ -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,
};
}
}