feat: workspace health (#3344)
* feat: wip workspace health * feat: split structure and metadata check * feat: check default value structure health * feat: check targetColumnMap structure health * fix: composite types doesn't have default value properly defined * feat: check default value structure health * feat: check options structure health * fix: verbose option not working properly * fix: word issue * fix: tests * fix: remove console.log * fix: TRUE and FALSE instead of YES and NO * fix: fieldMetadataType instead of type
This commit is contained in:
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { DatabaseCommandModule } from 'src/database/commands/database-command.module';
|
||||
import { FetchWorkspaceMessagesCommandsModule } from 'src/workspace/messaging/commands/fetch-workspace-messages-commands.module';
|
||||
import { WorkspaceHealthCommandModule } from 'src/workspace/workspace-health/commands/workspace-health-command.module';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
@ -13,6 +14,7 @@ import { WorkspaceSyncMetadataCommandsModule } from './workspace/workspace-sync-
|
||||
WorkspaceSyncMetadataCommandsModule,
|
||||
DatabaseCommandModule,
|
||||
FetchWorkspaceMessagesCommandsModule,
|
||||
WorkspaceHealthCommandModule,
|
||||
],
|
||||
})
|
||||
export class CommandModule {}
|
||||
|
||||
@ -39,7 +39,7 @@ export class GoogleAuthController {
|
||||
const { firstName, lastName, email, picture, workspaceInviteHash } =
|
||||
req.user;
|
||||
|
||||
const mainDataSource = await this.typeORMService.getMainDataSource();
|
||||
const mainDataSource = this.typeORMService.getMainDataSource();
|
||||
|
||||
const existingUser = await mainDataSource
|
||||
.getRepository(User)
|
||||
|
||||
@ -25,7 +25,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
public async getMainDataSource(): Promise<DataSource> {
|
||||
public getMainDataSource(): DataSource {
|
||||
return this.mainDataSource;
|
||||
}
|
||||
|
||||
|
||||
@ -7,11 +7,14 @@ import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/gener
|
||||
export const currencyFields = (
|
||||
fieldMetadata?: FieldMetadataInterface,
|
||||
): FieldMetadataInterface[] => {
|
||||
const targetColumnMap = fieldMetadata
|
||||
const inferredFieldMetadata = fieldMetadata as
|
||||
| FieldMetadataInterface<FieldMetadataType.CURRENCY>
|
||||
| undefined;
|
||||
const targetColumnMap = inferredFieldMetadata
|
||||
? generateTargetColumnMap(
|
||||
fieldMetadata.type,
|
||||
fieldMetadata.isCustom ?? false,
|
||||
fieldMetadata.name,
|
||||
inferredFieldMetadata.type,
|
||||
inferredFieldMetadata.isCustom ?? false,
|
||||
inferredFieldMetadata.name,
|
||||
)
|
||||
: {
|
||||
amountMicros: 'amountMicros',
|
||||
@ -29,7 +32,14 @@ export const currencyFields = (
|
||||
value: targetColumnMap.amountMicros,
|
||||
},
|
||||
isNullable: true,
|
||||
} satisfies FieldMetadataInterface,
|
||||
...(inferredFieldMetadata
|
||||
? {
|
||||
defaultValue: {
|
||||
value: inferredFieldMetadata.defaultValue?.amountMicros ?? null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} satisfies FieldMetadataInterface<FieldMetadataType.NUMERIC>,
|
||||
{
|
||||
id: 'currencyCode',
|
||||
type: FieldMetadataType.TEXT,
|
||||
@ -40,7 +50,14 @@ export const currencyFields = (
|
||||
value: targetColumnMap.currencyCode,
|
||||
},
|
||||
isNullable: true,
|
||||
} satisfies FieldMetadataInterface,
|
||||
...(inferredFieldMetadata
|
||||
? {
|
||||
defaultValue: {
|
||||
value: inferredFieldMetadata.defaultValue?.currencyCode ?? null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@ -7,11 +7,14 @@ import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/gener
|
||||
export const fullNameFields = (
|
||||
fieldMetadata?: FieldMetadataInterface,
|
||||
): FieldMetadataInterface[] => {
|
||||
const targetColumnMap = fieldMetadata
|
||||
const inferredFieldMetadata = fieldMetadata as
|
||||
| FieldMetadataInterface<FieldMetadataType.FULL_NAME>
|
||||
| undefined;
|
||||
const targetColumnMap = inferredFieldMetadata
|
||||
? generateTargetColumnMap(
|
||||
fieldMetadata.type,
|
||||
fieldMetadata.isCustom ?? false,
|
||||
fieldMetadata.name,
|
||||
inferredFieldMetadata.type,
|
||||
inferredFieldMetadata.isCustom ?? false,
|
||||
inferredFieldMetadata.name,
|
||||
)
|
||||
: {
|
||||
firstName: 'firstName',
|
||||
@ -29,7 +32,14 @@ export const fullNameFields = (
|
||||
value: targetColumnMap.firstName,
|
||||
},
|
||||
isNullable: true,
|
||||
} satisfies FieldMetadataInterface,
|
||||
...(inferredFieldMetadata
|
||||
? {
|
||||
defaultValue: {
|
||||
value: inferredFieldMetadata.defaultValue?.firstName ?? null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
|
||||
{
|
||||
id: 'lastName',
|
||||
type: FieldMetadataType.TEXT,
|
||||
@ -40,7 +50,14 @@ export const fullNameFields = (
|
||||
value: targetColumnMap.lastName,
|
||||
},
|
||||
isNullable: true,
|
||||
} satisfies FieldMetadataInterface,
|
||||
...(inferredFieldMetadata
|
||||
? {
|
||||
defaultValue: {
|
||||
value: inferredFieldMetadata.defaultValue?.lastName ?? null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface';
|
||||
|
||||
import { currencyFields } from 'src/metadata/field-metadata/composite-types/currency.composite-type';
|
||||
import { fullNameFields } from 'src/metadata/field-metadata/composite-types/full-name.composite-type';
|
||||
import { linkFields } from 'src/metadata/field-metadata/composite-types/link.composite-type';
|
||||
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||
|
||||
export type CompositeFieldsDefinitionFunction = (
|
||||
fieldMetadata?: FieldMetadataInterface,
|
||||
) => FieldMetadataInterface[];
|
||||
|
||||
export const compositeDefinitions = new Map<
|
||||
string,
|
||||
CompositeFieldsDefinitionFunction
|
||||
>([
|
||||
[FieldMetadataType.LINK, linkFields],
|
||||
[FieldMetadataType.CURRENCY, currencyFields],
|
||||
[FieldMetadataType.FULL_NAME, fullNameFields],
|
||||
]);
|
||||
@ -7,11 +7,14 @@ import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/gener
|
||||
export const linkFields = (
|
||||
fieldMetadata?: FieldMetadataInterface,
|
||||
): FieldMetadataInterface[] => {
|
||||
const targetColumnMap = fieldMetadata
|
||||
const inferredFieldMetadata = fieldMetadata as
|
||||
| FieldMetadataInterface<FieldMetadataType.LINK>
|
||||
| undefined;
|
||||
const targetColumnMap = inferredFieldMetadata
|
||||
? generateTargetColumnMap(
|
||||
fieldMetadata.type,
|
||||
fieldMetadata.isCustom ?? false,
|
||||
fieldMetadata.name,
|
||||
inferredFieldMetadata.type,
|
||||
inferredFieldMetadata.isCustom ?? false,
|
||||
inferredFieldMetadata.name,
|
||||
)
|
||||
: {
|
||||
label: 'label',
|
||||
@ -29,7 +32,14 @@ export const linkFields = (
|
||||
value: targetColumnMap.label,
|
||||
},
|
||||
isNullable: true,
|
||||
} satisfies FieldMetadataInterface,
|
||||
...(inferredFieldMetadata
|
||||
? {
|
||||
defaultValue: {
|
||||
value: inferredFieldMetadata.defaultValue?.label ?? null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
|
||||
{
|
||||
id: 'url',
|
||||
type: FieldMetadataType.TEXT,
|
||||
@ -40,7 +50,14 @@ export const linkFields = (
|
||||
value: targetColumnMap.url,
|
||||
},
|
||||
isNullable: true,
|
||||
} satisfies FieldMetadataInterface,
|
||||
...(inferredFieldMetadata
|
||||
? {
|
||||
defaultValue: {
|
||||
value: inferredFieldMetadata.defaultValue?.url ?? null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDate,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsNumberString,
|
||||
IsString,
|
||||
Matches,
|
||||
ValidateIf,
|
||||
@ -52,8 +54,8 @@ export class FieldMetadataDefaultValueLink {
|
||||
|
||||
export class FieldMetadataDefaultValueCurrency {
|
||||
@ValidateIf((_object, value) => value !== null)
|
||||
@IsNumber()
|
||||
amountMicros: number | null;
|
||||
@IsNumberString()
|
||||
amountMicros: string | null;
|
||||
|
||||
@ValidateIf((_object, value) => value !== null)
|
||||
@IsString()
|
||||
|
||||
@ -133,7 +133,7 @@ describe('validateDefaultValueForType', () => {
|
||||
it('should validate CURRENCY default value', () => {
|
||||
expect(
|
||||
validateDefaultValueForType(FieldMetadataType.CURRENCY, {
|
||||
amountMicros: 100,
|
||||
amountMicros: '100',
|
||||
currencyCode: 'USD',
|
||||
}),
|
||||
).toBe(true);
|
||||
@ -144,7 +144,7 @@ describe('validateDefaultValueForType', () => {
|
||||
validateDefaultValueForType(
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error Just for testing purposes
|
||||
{ amountMicros: '100', currencyCode: 'USD' },
|
||||
{ amountMicros: 100, currencyCode: 'USD' },
|
||||
FieldMetadataType.CURRENCY,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
@ -2,6 +2,8 @@ import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { FieldMetadataDefaultSerializableValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
|
||||
import { serializeTypeDefaultValue } from 'src/metadata/field-metadata/utils/serialize-type-default-value.util';
|
||||
|
||||
export const serializeDefaultValue = (
|
||||
defaultValue?: FieldMetadataDefaultSerializableValue,
|
||||
) => {
|
||||
@ -15,14 +17,13 @@ export const serializeDefaultValue = (
|
||||
typeof defaultValue === 'object' &&
|
||||
'type' in defaultValue
|
||||
) {
|
||||
switch (defaultValue.type) {
|
||||
case 'uuid':
|
||||
return 'public.uuid_generate_v4()';
|
||||
case 'now':
|
||||
return 'now()';
|
||||
default:
|
||||
throw new BadRequestException('Invalid dynamic default value type');
|
||||
const serializedTypeDefaultValue = serializeTypeDefaultValue(defaultValue);
|
||||
|
||||
if (!serializedTypeDefaultValue) {
|
||||
throw new BadRequestException('Invalid default value');
|
||||
}
|
||||
|
||||
return serializedTypeDefaultValue;
|
||||
}
|
||||
|
||||
// Static default values
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
import { FieldMetadataDynamicDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
|
||||
export const serializeTypeDefaultValue = (
|
||||
defaultValue?: FieldMetadataDynamicDefaultValue,
|
||||
) => {
|
||||
if (!defaultValue?.type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (defaultValue.type) {
|
||||
case 'uuid':
|
||||
return 'public.uuid_generate_v4()';
|
||||
case 'now':
|
||||
return 'now()';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@ -12,13 +12,7 @@ import {
|
||||
WorkspaceMigrationColumnActionType,
|
||||
} from 'src/metadata/workspace-migration/workspace-migration.entity';
|
||||
import { isCompositeFieldMetadataType } from 'src/metadata/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { fullNameFields } from 'src/metadata/field-metadata/composite-types/full-name.composite-type';
|
||||
import { currencyFields } from 'src/metadata/field-metadata/composite-types/currency.composite-type';
|
||||
import { linkFields } from 'src/metadata/field-metadata/composite-types/link.composite-type';
|
||||
|
||||
type CompositeFieldsDefinitionFunction = (
|
||||
fieldMetadata: FieldMetadataInterface,
|
||||
) => FieldMetadataInterface[];
|
||||
import { compositeDefinitions } from 'src/metadata/field-metadata/composite-types';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceMigrationFactory {
|
||||
@ -30,10 +24,6 @@ export class WorkspaceMigrationFactory {
|
||||
options?: WorkspaceColumnActionOptions;
|
||||
}
|
||||
>;
|
||||
private compositeDefinitions = new Map<
|
||||
string,
|
||||
CompositeFieldsDefinitionFunction
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly basicColumnActionFactory: BasicColumnActionFactory,
|
||||
@ -89,15 +79,6 @@ export class WorkspaceMigrationFactory {
|
||||
{ factory: this.enumColumnActionFactory },
|
||||
],
|
||||
]);
|
||||
|
||||
this.compositeDefinitions = new Map<
|
||||
string,
|
||||
CompositeFieldsDefinitionFunction
|
||||
>([
|
||||
[FieldMetadataType.LINK, linkFields],
|
||||
[FieldMetadataType.CURRENCY, currencyFields],
|
||||
[FieldMetadataType.FULL_NAME, fullNameFields],
|
||||
]);
|
||||
}
|
||||
|
||||
createColumnActions(
|
||||
@ -138,7 +119,7 @@ export class WorkspaceMigrationFactory {
|
||||
|
||||
// If it's a composite field type, we need to create a column action for each of the fields
|
||||
if (isCompositeFieldMetadataType(alteredFieldMetadata.type)) {
|
||||
const fieldMetadataSplitterFunction = this.compositeDefinitions.get(
|
||||
const fieldMetadataSplitterFunction = compositeDefinitions.get(
|
||||
alteredFieldMetadata.type,
|
||||
);
|
||||
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceHealthCommand } from 'src/workspace/workspace-health/commands/workspace-health.command';
|
||||
import { WorkspaceHealthModule } from 'src/workspace/workspace-health/workspace-health.module';
|
||||
|
||||
@Module({
|
||||
imports: [WorkspaceHealthModule],
|
||||
providers: [WorkspaceHealthCommand],
|
||||
})
|
||||
export class WorkspaceHealthCommandModule {}
|
||||
@ -0,0 +1,76 @@
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { WorkspaceHealthMode } from 'src/workspace/workspace-health/interfaces/workspace-health-options.interface';
|
||||
|
||||
import { WorkspaceHealthService } from 'src/workspace/workspace-health/workspace-health.service';
|
||||
|
||||
interface WorkspaceHealthCommandOptions {
|
||||
workspaceId: string;
|
||||
verbose?: boolean;
|
||||
mode?: WorkspaceHealthMode;
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: 'workspace:health',
|
||||
description: 'Check health of the given workspace.',
|
||||
})
|
||||
export class WorkspaceHealthCommand extends CommandRunner {
|
||||
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) {
|
||||
console.log(chalk.green('Workspace is healthy'));
|
||||
} else {
|
||||
console.log(chalk.red('Workspace is not healthy'));
|
||||
|
||||
if (options.verbose) {
|
||||
console.group(chalk.red('Issues'));
|
||||
issues.forEach((issue) => {
|
||||
console.log(chalk.yellow(JSON.stringify(issue, null, 2)));
|
||||
});
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-w, --workspace-id [workspace_id]',
|
||||
description: 'workspace id',
|
||||
required: true,
|
||||
})
|
||||
parseWorkspaceId(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-v, --verbose',
|
||||
description: 'Detailed output',
|
||||
required: false,
|
||||
})
|
||||
parseVerbose(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-m, --mode [mode]',
|
||||
description: 'Mode of the health check [structure, metadata, all]',
|
||||
required: false,
|
||||
defaultValue: WorkspaceHealthMode.All,
|
||||
})
|
||||
parseMode(value: string): WorkspaceHealthMode {
|
||||
return value as WorkspaceHealthMode;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
import { WorkspaceTableStructure } from 'src/workspace/workspace-health/interfaces/workspace-table-definition.interface';
|
||||
|
||||
import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-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_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',
|
||||
}
|
||||
|
||||
export interface WorkspaceHealthTableIssue {
|
||||
type:
|
||||
| WorkspaceHealthIssueType.MISSING_TABLE
|
||||
| WorkspaceHealthIssueType.TABLE_NAME_SHOULD_BE_CUSTOM
|
||||
| WorkspaceHealthIssueType.TABLE_TARGET_TABLE_NAME_NOT_VALID
|
||||
| WorkspaceHealthIssueType.TABLE_DATA_SOURCE_ID_NOT_VALID
|
||||
| WorkspaceHealthIssueType.TABLE_NAME_NOT_VALID;
|
||||
objectMetadata: ObjectMetadataEntity;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceHealthColumnIssue {
|
||||
type:
|
||||
| WorkspaceHealthIssueType.MISSING_COLUMN
|
||||
| WorkspaceHealthIssueType.MISSING_INDEX
|
||||
| WorkspaceHealthIssueType.MISSING_FOREIGN_KEY
|
||||
| WorkspaceHealthIssueType.MISSING_COMPOSITE_TYPE
|
||||
| WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID
|
||||
| WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_BE_CUSTOM
|
||||
| WorkspaceHealthIssueType.COLUMN_OBJECT_REFERENCE_INVALID
|
||||
| WorkspaceHealthIssueType.COLUMN_NAME_NOT_VALID
|
||||
| WorkspaceHealthIssueType.COLUMN_TYPE_NOT_VALID
|
||||
| WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT
|
||||
| WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT
|
||||
| WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT
|
||||
| WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID
|
||||
| WorkspaceHealthIssueType.COLUMN_OPTIONS_NOT_VALID;
|
||||
fieldMetadata: FieldMetadataEntity;
|
||||
columnStructure?: WorkspaceTableStructure;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type WorkspaceHealthIssue =
|
||||
| WorkspaceHealthTableIssue
|
||||
| WorkspaceHealthColumnIssue;
|
||||
@ -0,0 +1,9 @@
|
||||
export enum WorkspaceHealthMode {
|
||||
Structure = 'structure',
|
||||
Metadata = 'metadata',
|
||||
All = 'all',
|
||||
}
|
||||
|
||||
export interface WorkspaceHealthOptions {
|
||||
mode: WorkspaceHealthMode;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
export interface WorkspaceTableStructure {
|
||||
tableSchema: string;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
dataType: string;
|
||||
isNullable: string;
|
||||
columnDefault: string;
|
||||
isPrimaryKey: string;
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ColumnType } from 'typeorm';
|
||||
import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
|
||||
|
||||
import { WorkspaceTableStructure } from 'src/workspace/workspace-health/interfaces/workspace-table-definition.interface';
|
||||
import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||
import { fieldMetadataTypeToColumnType } from 'src/metadata/workspace-migration/utils/field-metadata-type-to-column-type.util';
|
||||
import { serializeTypeDefaultValue } from 'src/metadata/field-metadata/utils/serialize-type-default-value.util';
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseStructureService {
|
||||
constructor(private readonly typeORMService: TypeORMService) {}
|
||||
|
||||
async getWorkspaceTableColumns(
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
): Promise<WorkspaceTableStructure[]> {
|
||||
const mainDataSource = this.typeORMService.getMainDataSource();
|
||||
|
||||
return mainDataSource.query<WorkspaceTableStructure[]>(`
|
||||
SELECT
|
||||
c.table_schema as "tableSchema",
|
||||
c.table_name as "tableName",
|
||||
c.column_name as "columnName",
|
||||
c.data_type 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"
|
||||
FROM
|
||||
information_schema.columns c
|
||||
LEFT JOIN
|
||||
information_schema.constraint_column_usage as ccu ON c.column_name = ccu.column_name AND c.table_name = ccu.table_name
|
||||
LEFT JOIN
|
||||
information_schema.table_constraints as pk ON pk.constraint_name = ccu.constraint_name AND pk.constraint_type = 'PRIMARY KEY'
|
||||
WHERE
|
||||
c.table_schema = '${schemaName}'
|
||||
AND c.table_name = '${tableName}';
|
||||
`);
|
||||
}
|
||||
|
||||
getPostgresDataType(fieldMedataType: FieldMetadataType): string {
|
||||
const typeORMType = fieldMetadataTypeToColumnType(fieldMedataType);
|
||||
const mainDataSource = this.typeORMService.getMainDataSource();
|
||||
|
||||
return mainDataSource.driver.normalizeType({
|
||||
type: typeORMType,
|
||||
});
|
||||
}
|
||||
|
||||
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,355 @@
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import {
|
||||
WorkspaceHealthIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/workspace/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import { WorkspaceTableStructure } from 'src/workspace/workspace-health/interfaces/workspace-table-definition.interface';
|
||||
import { WorkspaceHealthOptions } from 'src/workspace/workspace-health/interfaces/workspace-health-options.interface';
|
||||
|
||||
import {
|
||||
FieldMetadataEntity,
|
||||
FieldMetadataType,
|
||||
} from 'src/metadata/field-metadata/field-metadata.entity';
|
||||
import { isCompositeFieldMetadataType } from 'src/metadata/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { DatabaseStructureService } from 'src/workspace/workspace-health/services/database-structure.service';
|
||||
import { validName } from 'src/workspace/workspace-health/utils/valid-name.util';
|
||||
import { compositeDefinitions } from 'src/metadata/field-metadata/composite-types';
|
||||
import { validateDefaultValueForType } from 'src/metadata/field-metadata/utils/validate-default-value-for-type.util';
|
||||
import { isEnumFieldMetadataType } from 'src/metadata/field-metadata/utils/is-enum-field-metadata-type.util';
|
||||
import { validateOptionsForType } from 'src/metadata/field-metadata/utils/validate-options-for-type.util';
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataHealthService {
|
||||
constructor(
|
||||
private readonly databaseStructureService: DatabaseStructureService,
|
||||
) {}
|
||||
|
||||
async healthCheck(
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
fieldMetadataCollection: FieldMetadataEntity[],
|
||||
options: WorkspaceHealthOptions,
|
||||
): Promise<WorkspaceHealthIssue[]> {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
const workspaceTableColumns =
|
||||
await this.databaseStructureService.getWorkspaceTableColumns(
|
||||
schemaName,
|
||||
tableName,
|
||||
);
|
||||
|
||||
if (!workspaceTableColumns) {
|
||||
throw new NotFoundException(
|
||||
`Table ${tableName} not found in schema ${schemaName}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const fieldMetadata of fieldMetadataCollection) {
|
||||
// Ignore relation fields for now
|
||||
if (
|
||||
fieldMetadata.fromRelationMetadata ||
|
||||
fieldMetadata.toRelationMetadata
|
||||
) {
|
||||
// TODO: Check relation fields
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||
const compositeFieldMetadataCollection =
|
||||
compositeDefinitions.get(fieldMetadata.type)?.(fieldMetadata) ?? [];
|
||||
|
||||
if (options.mode === 'metadata' || options.mode === 'all') {
|
||||
const targetColumnMapIssues =
|
||||
this.targetColumnMapCheck(fieldMetadata);
|
||||
|
||||
issues.push(...targetColumnMapIssues);
|
||||
|
||||
const defaultValueIssues =
|
||||
this.defaultValueHealthCheck(fieldMetadata);
|
||||
|
||||
issues.push(...defaultValueIssues);
|
||||
}
|
||||
|
||||
for (const compositeFieldMetadata of compositeFieldMetadataCollection) {
|
||||
const compositeFieldIssues = await this.healthCheckField(
|
||||
tableName,
|
||||
workspaceTableColumns,
|
||||
compositeFieldMetadata as FieldMetadataEntity,
|
||||
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.type,
|
||||
);
|
||||
const defaultValue = this.databaseStructureService.getPostgresDefault(
|
||||
fieldMetadata.type,
|
||||
fieldMetadata.defaultValue,
|
||||
);
|
||||
const isNullable = fieldMetadata.isNullable ? 'TRUE' : 'FALSE';
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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 !== isNullable) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT,
|
||||
fieldMetadata,
|
||||
columnStructure,
|
||||
message: `Column ${columnName} is not nullable as expected`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
defaultValue &&
|
||||
columnStructure.columnDefault &&
|
||||
!columnStructure.columnDefault.startsWith(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 targetColumnMapIssues = this.targetColumnMapCheck(fieldMetadata);
|
||||
const defaultValueIssues = this.defaultValueHealthCheck(fieldMetadata);
|
||||
|
||||
if (Object.keys(fieldMetadata.targetColumnMap).length !== 1) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column ${columnName} has more than one target column map, it should only contains "value"`,
|
||||
});
|
||||
}
|
||||
|
||||
issues.push(...targetColumnMapIssues);
|
||||
|
||||
if (fieldMetadata.isCustom && !columnName.startsWith('_')) {
|
||||
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[] {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
if (!fieldMetadata.targetColumnMap) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column targetColumnMap of ${fieldMetadata.name} is empty`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||
if (
|
||||
Object.keys(fieldMetadata.targetColumnMap).length !== 1 &&
|
||||
!('value' in fieldMetadata.targetColumnMap)
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column targetColumnMap "${fieldMetadata.targetColumnMap}" is not valid or well structured`,
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.isCompositeObjectWellStructured(
|
||||
fieldMetadata.type,
|
||||
fieldMetadata.targetColumnMap,
|
||||
)
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column targetColumnMap for composite type ${fieldMetadata.type} is not well structured "${fieldMetadata.targetColumnMap}"`,
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
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`,
|
||||
});
|
||||
}
|
||||
|
||||
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,127 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
WorkspaceHealthIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/workspace/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import { WorkspaceHealthOptions } from 'src/workspace/workspace-health/interfaces/workspace-health-options.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
|
||||
import { validName } from 'src/workspace/workspace-health/utils/valid-name.util';
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
|
||||
@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 = '${objectMetadata.targetTableName}')`,
|
||||
);
|
||||
|
||||
if (!tableExist) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.MISSING_TABLE,
|
||||
objectMetadata,
|
||||
message: `Table ${objectMetadata.targetTableName} 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.isCustom &&
|
||||
!objectMetadata.targetTableName.startsWith('_')
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.TABLE_NAME_SHOULD_BE_CUSTOM,
|
||||
objectMetadata,
|
||||
message: `Table ${objectMetadata.targetTableName} is marked as custom but doesn't start with "_"`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!objectMetadata.targetTableName) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.TABLE_TARGET_TABLE_NAME_NOT_VALID,
|
||||
objectMetadata,
|
||||
message: `Table ${objectMetadata.targetTableName} doesn't have a valid target table name`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!objectMetadata.dataSourceId) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.TABLE_DATA_SOURCE_ID_NOT_VALID,
|
||||
objectMetadata,
|
||||
message: `Table ${objectMetadata.targetTableName} 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 ${objectMetadata.targetTableName} doesn't have a valid name or label`,
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
|
||||
import { FieldMetadataType } from 'src/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,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
|
||||
import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module';
|
||||
import { DatabaseStructureService } from 'src/workspace/workspace-health/services/database-structure.service';
|
||||
import { FieldMetadataHealthService } from 'src/workspace/workspace-health/services/field-metadata-health.service';
|
||||
import { ObjectMetadataHealthService } from 'src/workspace/workspace-health/services/object-metadata-health.service';
|
||||
import { WorkspaceHealthService } from 'src/workspace/workspace-health/workspace-health.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DataSourceModule,
|
||||
TypeORMModule,
|
||||
ObjectMetadataModule,
|
||||
WorkspaceDataSourceModule,
|
||||
],
|
||||
providers: [
|
||||
WorkspaceHealthService,
|
||||
DatabaseStructureService,
|
||||
ObjectMetadataHealthService,
|
||||
FieldMetadataHealthService,
|
||||
],
|
||||
exports: [WorkspaceHealthService],
|
||||
})
|
||||
export class WorkspaceHealthModule {}
|
||||
@ -0,0 +1,81 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceHealthIssue } from 'src/workspace/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import {
|
||||
WorkspaceHealthMode,
|
||||
WorkspaceHealthOptions,
|
||||
} from 'src/workspace/workspace-health/interfaces/workspace-health-options.interface';
|
||||
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
|
||||
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service';
|
||||
import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service';
|
||||
import { ObjectMetadataHealthService } from 'src/workspace/workspace-health/services/object-metadata-health.service';
|
||||
import { FieldMetadataHealthService } from 'src/workspace/workspace-health/services/field-metadata-health.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceHealthService {
|
||||
constructor(
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly typeORMService: TypeORMService,
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
|
||||
private readonly objectMetadataHealthService: ObjectMetadataHealthService,
|
||||
private readonly fieldMetadataHealthService: FieldMetadataHealthService,
|
||||
) {}
|
||||
|
||||
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) {
|
||||
// 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(
|
||||
schemaName,
|
||||
objectMetadata.targetTableName,
|
||||
objectMetadata.fields,
|
||||
options,
|
||||
);
|
||||
|
||||
issues.push(...fieldIssues);
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user