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 { DatabaseCommandModule } from 'src/database/commands/database-command.module';
|
||||||
import { FetchWorkspaceMessagesCommandsModule } from 'src/workspace/messaging/commands/fetch-workspace-messages-commands.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';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ import { WorkspaceSyncMetadataCommandsModule } from './workspace/workspace-sync-
|
|||||||
WorkspaceSyncMetadataCommandsModule,
|
WorkspaceSyncMetadataCommandsModule,
|
||||||
DatabaseCommandModule,
|
DatabaseCommandModule,
|
||||||
FetchWorkspaceMessagesCommandsModule,
|
FetchWorkspaceMessagesCommandsModule,
|
||||||
|
WorkspaceHealthCommandModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CommandModule {}
|
export class CommandModule {}
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export class GoogleAuthController {
|
|||||||
const { firstName, lastName, email, picture, workspaceInviteHash } =
|
const { firstName, lastName, email, picture, workspaceInviteHash } =
|
||||||
req.user;
|
req.user;
|
||||||
|
|
||||||
const mainDataSource = await this.typeORMService.getMainDataSource();
|
const mainDataSource = this.typeORMService.getMainDataSource();
|
||||||
|
|
||||||
const existingUser = await mainDataSource
|
const existingUser = await mainDataSource
|
||||||
.getRepository(User)
|
.getRepository(User)
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMainDataSource(): Promise<DataSource> {
|
public getMainDataSource(): DataSource {
|
||||||
return this.mainDataSource;
|
return this.mainDataSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,11 +7,14 @@ import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/gener
|
|||||||
export const currencyFields = (
|
export const currencyFields = (
|
||||||
fieldMetadata?: FieldMetadataInterface,
|
fieldMetadata?: FieldMetadataInterface,
|
||||||
): FieldMetadataInterface[] => {
|
): FieldMetadataInterface[] => {
|
||||||
const targetColumnMap = fieldMetadata
|
const inferredFieldMetadata = fieldMetadata as
|
||||||
|
| FieldMetadataInterface<FieldMetadataType.CURRENCY>
|
||||||
|
| undefined;
|
||||||
|
const targetColumnMap = inferredFieldMetadata
|
||||||
? generateTargetColumnMap(
|
? generateTargetColumnMap(
|
||||||
fieldMetadata.type,
|
inferredFieldMetadata.type,
|
||||||
fieldMetadata.isCustom ?? false,
|
inferredFieldMetadata.isCustom ?? false,
|
||||||
fieldMetadata.name,
|
inferredFieldMetadata.name,
|
||||||
)
|
)
|
||||||
: {
|
: {
|
||||||
amountMicros: 'amountMicros',
|
amountMicros: 'amountMicros',
|
||||||
@ -29,7 +32,14 @@ export const currencyFields = (
|
|||||||
value: targetColumnMap.amountMicros,
|
value: targetColumnMap.amountMicros,
|
||||||
},
|
},
|
||||||
isNullable: true,
|
isNullable: true,
|
||||||
} satisfies FieldMetadataInterface,
|
...(inferredFieldMetadata
|
||||||
|
? {
|
||||||
|
defaultValue: {
|
||||||
|
value: inferredFieldMetadata.defaultValue?.amountMicros ?? null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
} satisfies FieldMetadataInterface<FieldMetadataType.NUMERIC>,
|
||||||
{
|
{
|
||||||
id: 'currencyCode',
|
id: 'currencyCode',
|
||||||
type: FieldMetadataType.TEXT,
|
type: FieldMetadataType.TEXT,
|
||||||
@ -40,7 +50,14 @@ export const currencyFields = (
|
|||||||
value: targetColumnMap.currencyCode,
|
value: targetColumnMap.currencyCode,
|
||||||
},
|
},
|
||||||
isNullable: true,
|
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 = (
|
export const fullNameFields = (
|
||||||
fieldMetadata?: FieldMetadataInterface,
|
fieldMetadata?: FieldMetadataInterface,
|
||||||
): FieldMetadataInterface[] => {
|
): FieldMetadataInterface[] => {
|
||||||
const targetColumnMap = fieldMetadata
|
const inferredFieldMetadata = fieldMetadata as
|
||||||
|
| FieldMetadataInterface<FieldMetadataType.FULL_NAME>
|
||||||
|
| undefined;
|
||||||
|
const targetColumnMap = inferredFieldMetadata
|
||||||
? generateTargetColumnMap(
|
? generateTargetColumnMap(
|
||||||
fieldMetadata.type,
|
inferredFieldMetadata.type,
|
||||||
fieldMetadata.isCustom ?? false,
|
inferredFieldMetadata.isCustom ?? false,
|
||||||
fieldMetadata.name,
|
inferredFieldMetadata.name,
|
||||||
)
|
)
|
||||||
: {
|
: {
|
||||||
firstName: 'firstName',
|
firstName: 'firstName',
|
||||||
@ -29,7 +32,14 @@ export const fullNameFields = (
|
|||||||
value: targetColumnMap.firstName,
|
value: targetColumnMap.firstName,
|
||||||
},
|
},
|
||||||
isNullable: true,
|
isNullable: true,
|
||||||
} satisfies FieldMetadataInterface,
|
...(inferredFieldMetadata
|
||||||
|
? {
|
||||||
|
defaultValue: {
|
||||||
|
value: inferredFieldMetadata.defaultValue?.firstName ?? null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
|
||||||
{
|
{
|
||||||
id: 'lastName',
|
id: 'lastName',
|
||||||
type: FieldMetadataType.TEXT,
|
type: FieldMetadataType.TEXT,
|
||||||
@ -40,7 +50,14 @@ export const fullNameFields = (
|
|||||||
value: targetColumnMap.lastName,
|
value: targetColumnMap.lastName,
|
||||||
},
|
},
|
||||||
isNullable: true,
|
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 = (
|
export const linkFields = (
|
||||||
fieldMetadata?: FieldMetadataInterface,
|
fieldMetadata?: FieldMetadataInterface,
|
||||||
): FieldMetadataInterface[] => {
|
): FieldMetadataInterface[] => {
|
||||||
const targetColumnMap = fieldMetadata
|
const inferredFieldMetadata = fieldMetadata as
|
||||||
|
| FieldMetadataInterface<FieldMetadataType.LINK>
|
||||||
|
| undefined;
|
||||||
|
const targetColumnMap = inferredFieldMetadata
|
||||||
? generateTargetColumnMap(
|
? generateTargetColumnMap(
|
||||||
fieldMetadata.type,
|
inferredFieldMetadata.type,
|
||||||
fieldMetadata.isCustom ?? false,
|
inferredFieldMetadata.isCustom ?? false,
|
||||||
fieldMetadata.name,
|
inferredFieldMetadata.name,
|
||||||
)
|
)
|
||||||
: {
|
: {
|
||||||
label: 'label',
|
label: 'label',
|
||||||
@ -29,7 +32,14 @@ export const linkFields = (
|
|||||||
value: targetColumnMap.label,
|
value: targetColumnMap.label,
|
||||||
},
|
},
|
||||||
isNullable: true,
|
isNullable: true,
|
||||||
} satisfies FieldMetadataInterface,
|
...(inferredFieldMetadata
|
||||||
|
? {
|
||||||
|
defaultValue: {
|
||||||
|
value: inferredFieldMetadata.defaultValue?.label ?? null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
|
||||||
{
|
{
|
||||||
id: 'url',
|
id: 'url',
|
||||||
type: FieldMetadataType.TEXT,
|
type: FieldMetadataType.TEXT,
|
||||||
@ -40,7 +50,14 @@ export const linkFields = (
|
|||||||
value: targetColumnMap.url,
|
value: targetColumnMap.url,
|
||||||
},
|
},
|
||||||
isNullable: true,
|
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 {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDate,
|
IsDate,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
|
IsNumberString,
|
||||||
IsString,
|
IsString,
|
||||||
Matches,
|
Matches,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
@ -52,8 +54,8 @@ export class FieldMetadataDefaultValueLink {
|
|||||||
|
|
||||||
export class FieldMetadataDefaultValueCurrency {
|
export class FieldMetadataDefaultValueCurrency {
|
||||||
@ValidateIf((_object, value) => value !== null)
|
@ValidateIf((_object, value) => value !== null)
|
||||||
@IsNumber()
|
@IsNumberString()
|
||||||
amountMicros: number | null;
|
amountMicros: string | null;
|
||||||
|
|
||||||
@ValidateIf((_object, value) => value !== null)
|
@ValidateIf((_object, value) => value !== null)
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@ -133,7 +133,7 @@ describe('validateDefaultValueForType', () => {
|
|||||||
it('should validate CURRENCY default value', () => {
|
it('should validate CURRENCY default value', () => {
|
||||||
expect(
|
expect(
|
||||||
validateDefaultValueForType(FieldMetadataType.CURRENCY, {
|
validateDefaultValueForType(FieldMetadataType.CURRENCY, {
|
||||||
amountMicros: 100,
|
amountMicros: '100',
|
||||||
currencyCode: 'USD',
|
currencyCode: 'USD',
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@ -144,7 +144,7 @@ describe('validateDefaultValueForType', () => {
|
|||||||
validateDefaultValueForType(
|
validateDefaultValueForType(
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-expect-error Just for testing purposes
|
// @ts-expect-error Just for testing purposes
|
||||||
{ amountMicros: '100', currencyCode: 'USD' },
|
{ amountMicros: 100, currencyCode: 'USD' },
|
||||||
FieldMetadataType.CURRENCY,
|
FieldMetadataType.CURRENCY,
|
||||||
),
|
),
|
||||||
).toBe(false);
|
).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 { 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 = (
|
export const serializeDefaultValue = (
|
||||||
defaultValue?: FieldMetadataDefaultSerializableValue,
|
defaultValue?: FieldMetadataDefaultSerializableValue,
|
||||||
) => {
|
) => {
|
||||||
@ -15,14 +17,13 @@ export const serializeDefaultValue = (
|
|||||||
typeof defaultValue === 'object' &&
|
typeof defaultValue === 'object' &&
|
||||||
'type' in defaultValue
|
'type' in defaultValue
|
||||||
) {
|
) {
|
||||||
switch (defaultValue.type) {
|
const serializedTypeDefaultValue = serializeTypeDefaultValue(defaultValue);
|
||||||
case 'uuid':
|
|
||||||
return 'public.uuid_generate_v4()';
|
if (!serializedTypeDefaultValue) {
|
||||||
case 'now':
|
throw new BadRequestException('Invalid default value');
|
||||||
return 'now()';
|
|
||||||
default:
|
|
||||||
throw new BadRequestException('Invalid dynamic default value type');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return serializedTypeDefaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static default values
|
// 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,
|
WorkspaceMigrationColumnActionType,
|
||||||
} from 'src/metadata/workspace-migration/workspace-migration.entity';
|
} from 'src/metadata/workspace-migration/workspace-migration.entity';
|
||||||
import { isCompositeFieldMetadataType } from 'src/metadata/field-metadata/utils/is-composite-field-metadata-type.util';
|
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 { compositeDefinitions } from 'src/metadata/field-metadata/composite-types';
|
||||||
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[];
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceMigrationFactory {
|
export class WorkspaceMigrationFactory {
|
||||||
@ -30,10 +24,6 @@ export class WorkspaceMigrationFactory {
|
|||||||
options?: WorkspaceColumnActionOptions;
|
options?: WorkspaceColumnActionOptions;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
private compositeDefinitions = new Map<
|
|
||||||
string,
|
|
||||||
CompositeFieldsDefinitionFunction
|
|
||||||
>();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly basicColumnActionFactory: BasicColumnActionFactory,
|
private readonly basicColumnActionFactory: BasicColumnActionFactory,
|
||||||
@ -89,15 +79,6 @@ export class WorkspaceMigrationFactory {
|
|||||||
{ factory: this.enumColumnActionFactory },
|
{ factory: this.enumColumnActionFactory },
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.compositeDefinitions = new Map<
|
|
||||||
string,
|
|
||||||
CompositeFieldsDefinitionFunction
|
|
||||||
>([
|
|
||||||
[FieldMetadataType.LINK, linkFields],
|
|
||||||
[FieldMetadataType.CURRENCY, currencyFields],
|
|
||||||
[FieldMetadataType.FULL_NAME, fullNameFields],
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createColumnActions(
|
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 it's a composite field type, we need to create a column action for each of the fields
|
||||||
if (isCompositeFieldMetadataType(alteredFieldMetadata.type)) {
|
if (isCompositeFieldMetadataType(alteredFieldMetadata.type)) {
|
||||||
const fieldMetadataSplitterFunction = this.compositeDefinitions.get(
|
const fieldMetadataSplitterFunction = compositeDefinitions.get(
|
||||||
alteredFieldMetadata.type,
|
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