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:
Jérémy M
2024-01-11 16:41:25 +01:00
committed by GitHub
parent c8aec95325
commit 5f0c9f67c9
24 changed files with 1010 additions and 52 deletions

View File

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

View File

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

View File

@ -25,7 +25,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
});
}
public async getMainDataSource(): Promise<DataSource> {
public getMainDataSource(): DataSource {
return this.mainDataSource;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,9 @@
export interface WorkspaceTableStructure {
tableSchema: string;
tableName: string;
columnName: string;
dataType: string;
isNullable: string;
columnDefault: string;
isPrimaryKey: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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