feat: workspace health target column map fix (#3932)

* feat: workspace health fix target column map

* fix: remove log

* feat: refactor health fixer

* fix: default-value issue and health check not working with composite

* fix: enhance target column map fix

* feat: create workspace migrations for target-column-map issues

* feat: enhance workspace-health issue detection
This commit is contained in:
Jérémy M 2024-02-15 18:04:12 +01:00 committed by GitHub
parent 0b93a6785b
commit 990cb107a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 625 additions and 246 deletions

View File

@ -39,12 +39,14 @@
"@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@4.2.0#./patches/@ptc-org+nestjs-query-graphql+4.2.0.patch",
"class-validator": "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch",
"graphql-middleware": "^6.1.35",
"lodash.isequal": "^4.5.0",
"passport": "^0.7.0"
},
"devDependencies": {
"@nestjs/cli": "10.3.0",
"@nx/js": "17.2.8",
"@types/lodash.isempty": "^4.4.7",
"@types/lodash.isequal": "^4.5.8",
"@types/lodash.isobject": "^3.0.7",
"@types/lodash.omit": "^4.5.9",
"@types/lodash.snakecase": "^4.1.7",

View File

@ -29,10 +29,8 @@ import {
RelationMetadataEntity,
RelationMetadataType,
} from 'src/metadata/relation-metadata/relation-metadata.entity';
import {
computeCustomName,
computeObjectTargetTable,
} from 'src/workspace/utils/compute-object-target-table.util';
import { computeCustomName } from 'src/workspace/utils/compute-custom-name.util';
import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util';
import { DeleteOneObjectInput } from 'src/metadata/object-metadata/dtos/delete-object.input';
import { RelationToDelete } from 'src/metadata/relation-metadata/types/relation-to-delete';
import { generateMigrationName } from 'src/metadata/workspace-migration/utils/generate-migration-name.util';

View File

@ -0,0 +1,5 @@
export const customNamePrefix = '_';
export const computeCustomName = (name: string, isCustom: boolean) => {
return isCustom ? `${customNamePrefix}${name}` : name;
};

View File

@ -0,0 +1,21 @@
import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity';
import { isCompositeFieldMetadataType } from 'src/metadata/field-metadata/utils/is-composite-field-metadata-type.util';
import { BasicFieldMetadataType } from 'src/metadata/workspace-migration/factories/basic-column-action.factory';
import { computeCustomName } from './compute-custom-name.util';
export const computeFieldTargetColumn = (
fieldMetadata:
| FieldMetadataEntity<BasicFieldMetadataType>
| FieldMetadataInterface<BasicFieldMetadataType>,
) => {
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
throw new Error(
"Composite field metadata should not be computed here, as they're split into multiple fields.",
);
}
return computeCustomName(fieldMetadata.name, fieldMetadata.isCustom ?? false);
};

View File

@ -1,5 +1,7 @@
import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/object-metadata.interface';
import { computeCustomName } from './compute-custom-name.util';
export const computeObjectTargetTable = (
objectMetadata: ObjectMetadataInterface,
) => {
@ -8,7 +10,3 @@ export const computeObjectTargetTable = (
objectMetadata.isCustom,
);
};
export const computeCustomName = (name: string, isCustom: boolean) => {
return isCustom ? `_${name}` : name;
};

View File

@ -11,7 +11,6 @@ import { CommandLogger } from 'src/commands/command-logger';
interface WorkspaceHealthCommandOptions {
workspaceId: string;
verbose?: boolean;
mode?: WorkspaceHealthMode;
fix?: WorkspaceHealthFixKind;
dryRun?: boolean;
@ -49,7 +48,7 @@ export class WorkspaceHealthCommand extends CommandRunner {
chalk.red(`Workspace is not healthy, found ${issues.length} issues`),
);
if (options.verbose) {
if (options.dryRun) {
await this.commandLogger.writeLog(
`workspace-health-issues-${options.workspaceId}`,
issues,
@ -61,25 +60,30 @@ export class WorkspaceHealthCommand extends CommandRunner {
if (options.fix) {
this.logger.log(chalk.yellow('Fixing issues'));
const workspaceMigrations = await this.workspaceHealthService.fixIssues(
options.workspaceId,
issues,
{
type: options.fix,
applyChanges: !options.dryRun,
},
);
const { workspaceMigrations, metadataEntities } =
await this.workspaceHealthService.fixIssues(
options.workspaceId,
issues,
{
type: options.fix,
applyChanges: !options.dryRun,
},
);
const totalCount = workspaceMigrations.length + metadataEntities.length;
if (options.dryRun) {
await this.commandLogger.writeLog(
`workspace-health-${options.fix}-migrations`,
workspaceMigrations,
);
await this.commandLogger.writeLog(
`workspace-health-${options.fix}-metadata-entities`,
metadataEntities,
);
} else {
this.logger.log(
chalk.green(
`Fixed ${workspaceMigrations.length}/${issues.length} issues`,
),
chalk.green(`Fixed ${totalCount}/${issues.length} issues`),
);
}
}
@ -107,15 +111,6 @@ export class WorkspaceHealthCommand extends CommandRunner {
return value as WorkspaceHealthFixKind;
}
@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]',

View File

@ -0,0 +1,68 @@
import { EntityManager } from 'typeorm';
import {
WorkspaceHealthIssue,
WorkspaceHealthIssueType,
WorkspaceIssueTypeToInterface,
} from 'src/workspace/workspace-health/interfaces/workspace-health-issue.interface';
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity';
export class CompareEntity<T> {
current: T | null;
altered: T | null;
}
export abstract class AbstractWorkspaceFixer<
IssueTypes extends WorkspaceHealthIssueType,
UpdateRecordEntities = unknown,
> {
private issueTypes: IssueTypes[];
constructor(...issueTypes: IssueTypes[]) {
this.issueTypes = issueTypes;
}
filterIssues(
issues: WorkspaceHealthIssue[],
): WorkspaceIssueTypeToInterface<IssueTypes>[] {
return issues.filter(
(issue): issue is WorkspaceIssueTypeToInterface<IssueTypes> =>
this.issueTypes.includes(issue.type as IssueTypes),
);
}
protected splitIssuesByType(
issues: WorkspaceIssueTypeToInterface<IssueTypes>[],
): Record<IssueTypes, WorkspaceIssueTypeToInterface<IssueTypes>[]> {
return issues.reduce(
(
acc: Record<IssueTypes, WorkspaceIssueTypeToInterface<IssueTypes>[]>,
issue,
) => {
const type = issue.type as IssueTypes;
if (!acc[type]) {
acc[type] = [];
}
acc[type].push(issue);
return acc;
},
{} as Record<IssueTypes, WorkspaceIssueTypeToInterface<IssueTypes>[]>,
);
}
async createWorkspaceMigrations?(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceIssueTypeToInterface<IssueTypes>[],
): Promise<Partial<WorkspaceMigrationEntity>[]>;
async createMetadataUpdates?(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceIssueTypeToInterface<IssueTypes>[],
): Promise<CompareEntity<UpdateRecordEntities>[]>;
}

View File

@ -0,0 +1,11 @@
import { WorkspaceNullableFixer } from './workspace-nullable.fixer';
import { WorkspaceDefaultValueFixer } from './workspace-default-value.fixer';
import { WorkspaceTypeFixer } from './workspace-type.fixer';
import { WorkspaceTargetColumnMapFixer } from './workspace-target-column-map.fixer';
export const workspaceFixers = [
WorkspaceNullableFixer,
WorkspaceDefaultValueFixer,
WorkspaceTypeFixer,
WorkspaceTargetColumnMapFixer,
];

View File

@ -13,37 +13,26 @@ import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metada
import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFieldFactory } from 'src/workspace/workspace-migration-builder/factories/workspace-migration-field.factory';
type WorkspaceHealthDefaultValueIssue =
WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT>;
import { AbstractWorkspaceFixer } from './abstract-workspace.fixer';
@Injectable()
export class WorkspaceFixDefaultValueService {
export class WorkspaceDefaultValueFixer extends AbstractWorkspaceFixer<WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT> {
constructor(
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
) {}
) {
super(WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT);
}
async fix(
async createWorkspaceMigrations(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceHealthDefaultValueIssue[],
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT>[],
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
const defaultValueIssues = issues.filter(
(issue) =>
issue.type === WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT,
) as WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT>[];
if (defaultValueIssues.length > 0) {
const columnDefaultValueWorkspaceMigrations =
await this.fixColumnDefaultValueIssues(
objectMetadataCollection,
defaultValueIssues,
);
workspaceMigrations.push(...columnDefaultValueWorkspaceMigrations);
if (issues.length <= 0) {
return [];
}
return workspaceMigrations;
return this.fixColumnDefaultValueIssues(objectMetadataCollection, issues);
}
private async fixColumnDefaultValueIssues(

View File

@ -8,41 +8,30 @@ import {
} from 'src/workspace/workspace-health/interfaces/workspace-health-issue.interface';
import { WorkspaceMigrationBuilderAction } from 'src/workspace/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity';
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFieldFactory } from 'src/workspace/workspace-migration-builder/factories/workspace-migration-field.factory';
type WorkspaceHealthNullableIssue =
WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT>;
import { AbstractWorkspaceFixer } from './abstract-workspace.fixer';
@Injectable()
export class WorkspaceFixNullableService {
export class WorkspaceNullableFixer extends AbstractWorkspaceFixer<WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT> {
constructor(
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
) {}
) {
super(WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT);
}
async fix(
async createWorkspaceMigrations(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceHealthNullableIssue[],
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT>[],
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
const nullabilityIssues = issues.filter(
(issue) =>
issue.type === WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT,
) as WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT>[];
if (nullabilityIssues.length > 0) {
const columnNullabilityWorkspaceMigrations =
await this.fixColumnNullabilityIssues(
objectMetadataCollection,
nullabilityIssues,
);
workspaceMigrations.push(...columnNullabilityWorkspaceMigrations);
if (issues.length <= 0) {
return [];
}
return workspaceMigrations;
return this.fixColumnNullabilityIssues(objectMetadataCollection, issues);
}
private async fixColumnNullabilityIssues(

View File

@ -0,0 +1,174 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import isEqual from 'lodash.isequal';
import {
WorkspaceHealthColumnIssue,
WorkspaceHealthIssueType,
} from 'src/workspace/workspace-health/interfaces/workspace-health-issue.interface';
import { WorkspaceMigrationBuilderAction } from 'src/workspace/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/generate-target-column-map.util';
import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity';
import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity';
import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util';
import { DataSourceEntity } from 'src/metadata/data-source/data-source.entity';
import { DatabaseStructureService } from 'src/workspace/workspace-health/services/database-structure.service';
import { WorkspaceMigrationFieldFactory } from 'src/workspace/workspace-migration-builder/factories/workspace-migration-field.factory';
import { isCompositeFieldMetadataType } from 'src/metadata/field-metadata/utils/is-composite-field-metadata-type.util';
import {
AbstractWorkspaceFixer,
CompareEntity,
} from './abstract-workspace.fixer';
@Injectable()
export class WorkspaceTargetColumnMapFixer extends AbstractWorkspaceFixer<
WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID,
FieldMetadataEntity
> {
constructor(
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
private readonly databaseStructureService: DatabaseStructureService,
) {
super(WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID);
}
async createWorkspaceMigrations(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID>[],
): Promise<Partial<WorkspaceMigrationEntity>[]> {
if (issues.length <= 0) {
return [];
}
return this.fixStructureTargetColumnMapIssues(
manager,
objectMetadataCollection,
issues,
);
}
async createMetadataUpdates(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID>[],
): Promise<CompareEntity<FieldMetadataEntity>[]> {
if (issues.length <= 0) {
return [];
}
return this.fixMetadataTargetColumnMapIssues(manager, issues);
}
private async fixStructureTargetColumnMapIssues(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID>[],
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrationCollection: Partial<WorkspaceMigrationEntity>[] =
[];
const dataSourceRepository = manager.getRepository(DataSourceEntity);
for (const issue of issues) {
const objectMetadata = objectMetadataCollection.find(
(metadata) => metadata.id === issue.fieldMetadata.objectMetadataId,
);
const targetColumnMap = generateTargetColumnMap(
issue.fieldMetadata.type,
issue.fieldMetadata.isCustom,
issue.fieldMetadata.name,
);
// Skip composite fields, too complicated to fix for now
if (isCompositeFieldMetadataType(issue.fieldMetadata.type)) {
continue;
}
if (!objectMetadata) {
throw new Error(
`Object metadata with id ${issue.fieldMetadata.objectMetadataId} not found`,
);
}
if (!isEqual(issue.fieldMetadata.targetColumnMap, targetColumnMap)) {
// Retrieve the data source to get the schema name
const dataSource = await dataSourceRepository.findOne({
where: {
id: objectMetadata.dataSourceId,
},
});
if (!dataSource) {
throw new Error(
`Data source with id ${objectMetadata.dataSourceId} not found`,
);
}
const columnName = issue.fieldMetadata.targetColumnMap?.value;
const columnExist =
await this.databaseStructureService.workspaceColumnExist(
dataSource.schema,
computeObjectTargetTable(objectMetadata),
columnName,
);
if (!columnExist) {
continue;
}
const workspaceMigration =
await this.workspaceMigrationFieldFactory.create(
objectMetadataCollection,
[
{
current: issue.fieldMetadata,
altered: {
...issue.fieldMetadata,
targetColumnMap,
},
},
],
WorkspaceMigrationBuilderAction.UPDATE,
);
workspaceMigrationCollection.push(workspaceMigration[0]);
}
}
return workspaceMigrationCollection;
}
private async fixMetadataTargetColumnMapIssues(
manager: EntityManager,
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID>[],
): Promise<CompareEntity<FieldMetadataEntity>[]> {
const fieldMetadataRepository = manager.getRepository(FieldMetadataEntity);
const updatedEntities: CompareEntity<FieldMetadataEntity>[] = [];
for (const issue of issues) {
await fieldMetadataRepository.update(issue.fieldMetadata.id, {
targetColumnMap: generateTargetColumnMap(
issue.fieldMetadata.type,
issue.fieldMetadata.isCustom,
issue.fieldMetadata.name,
),
});
const alteredEntity = await fieldMetadataRepository.findOne({
where: {
id: issue.fieldMetadata.id,
},
});
updatedEntities.push({
current: issue.fieldMetadata,
altered: alteredEntity as FieldMetadataEntity | null,
});
}
return updatedEntities;
}
}

View File

@ -11,41 +11,29 @@ import { WorkspaceMigrationBuilderAction } from 'src/workspace/workspace-migrati
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFieldFactory } from 'src/workspace/workspace-migration-builder/factories/workspace-migration-field.factory';
import { DatabaseStructureService } from 'src/workspace/workspace-health/services/database-structure.service';
import { DatabaseStructureService } from './database-structure.service';
type WorkspaceHealthTypeIssue =
WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT>;
import { AbstractWorkspaceFixer } from './abstract-workspace.fixer';
@Injectable()
export class WorkspaceFixTypeService {
export class WorkspaceTypeFixer extends AbstractWorkspaceFixer<WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT> {
constructor(
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
private readonly databaseStructureService: DatabaseStructureService,
) {}
) {
super(WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT);
}
async fix(
async createWorkspaceMigrations(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
issues: WorkspaceHealthTypeIssue[],
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT>[],
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
const columnTypeIssues = issues.filter(
(issue) =>
issue.type === WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT,
) as WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT>[];
if (columnTypeIssues.length > 0) {
const columnNullabilityWorkspaceMigrations =
await this.fixColumnTypeIssues(
objectMetadataCollection,
columnTypeIssues,
);
workspaceMigrations.push(...columnNullabilityWorkspaceMigrations);
if (issues.length <= 0) {
return [];
}
return workspaceMigrations;
return this.fixColumnTypeIssues(objectMetadataCollection, issues);
}
private async fixColumnTypeIssues(

View File

@ -14,6 +14,7 @@ export enum WorkspaceHealthIssueType {
MISSING_INDEX = 'MISSING_INDEX',
MISSING_FOREIGN_KEY = 'MISSING_FOREIGN_KEY',
MISSING_COMPOSITE_TYPE = 'MISSING_COMPOSITE_TYPE',
COLUMN_NAME_SHOULD_NOT_BE_PREFIXED = 'COLUMN_NAME_SHOULD_NOT_BE_PREFIXED',
COLUMN_TARGET_COLUMN_MAP_NOT_VALID = 'COLUMN_TARGET_COLUMN_MAP_NOT_VALID',
COLUMN_NAME_SHOULD_BE_CUSTOM = 'COLUMN_NAME_SHOULD_BE_CUSTOM',
COLUMN_OBJECT_REFERENCE_INVALID = 'COLUMN_OBJECT_REFERENCE_INVALID',
@ -30,61 +31,64 @@ export enum WorkspaceHealthIssueType {
RELATION_TYPE_NOT_VALID = 'RELATION_TYPE_NOT_VALID',
}
type ConditionalType<
T extends WorkspaceHealthIssueType | null,
U,
> = T extends WorkspaceHealthIssueType ? T : U;
/**
* Table issues
*/
export type WorkspaceTableIssueTypes =
| WorkspaceHealthIssueType.MISSING_TABLE
| WorkspaceHealthIssueType.TABLE_NAME_SHOULD_BE_CUSTOM
| WorkspaceHealthIssueType.TABLE_TARGET_TABLE_NAME_NOT_VALID
| WorkspaceHealthIssueType.TABLE_DATA_SOURCE_ID_NOT_VALID
| WorkspaceHealthIssueType.TABLE_NAME_NOT_VALID;
export interface WorkspaceHealthTableIssue<
T extends WorkspaceHealthIssueType | null = null,
> {
type: ConditionalType<
T,
| WorkspaceHealthIssueType.MISSING_TABLE
| WorkspaceHealthIssueType.TABLE_NAME_SHOULD_BE_CUSTOM
| WorkspaceHealthIssueType.TABLE_TARGET_TABLE_NAME_NOT_VALID
| WorkspaceHealthIssueType.TABLE_DATA_SOURCE_ID_NOT_VALID
| WorkspaceHealthIssueType.TABLE_NAME_NOT_VALID
>;
export interface WorkspaceHealthTableIssue<T extends WorkspaceTableIssueTypes> {
type: T;
objectMetadata: ObjectMetadataEntity;
message: string;
}
/**
* Column issues
*/
export type WorkspaceColumnIssueTypes =
| WorkspaceHealthIssueType.MISSING_COLUMN
| WorkspaceHealthIssueType.MISSING_INDEX
| WorkspaceHealthIssueType.MISSING_FOREIGN_KEY
| WorkspaceHealthIssueType.MISSING_COMPOSITE_TYPE
| WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_NOT_BE_PREFIXED
| WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID
| WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_BE_CUSTOM
| WorkspaceHealthIssueType.COLUMN_OBJECT_REFERENCE_INVALID
| WorkspaceHealthIssueType.COLUMN_NAME_NOT_VALID
| WorkspaceHealthIssueType.COLUMN_TYPE_NOT_VALID
| WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT
| WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT
| WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT
| WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID
| WorkspaceHealthIssueType.COLUMN_OPTIONS_NOT_VALID;
export interface WorkspaceHealthColumnIssue<
T extends WorkspaceHealthIssueType | null = null,
T extends WorkspaceColumnIssueTypes,
> {
type: ConditionalType<
T,
| 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
>;
type: T;
fieldMetadata: FieldMetadataEntity;
columnStructure?: WorkspaceTableStructure;
message: string;
}
/**
* Relation issues
*/
export type WorkspaceRelationIssueTypes =
| WorkspaceHealthIssueType.RELATION_FROM_OR_TO_FIELD_METADATA_NOT_VALID
| WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_NOT_VALID
| WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_CONFLICT
| WorkspaceHealthIssueType.RELATION_TYPE_NOT_VALID;
export interface WorkspaceHealthRelationIssue<
T extends WorkspaceHealthIssueType | null = null,
T extends WorkspaceRelationIssueTypes,
> {
type: ConditionalType<
T,
| WorkspaceHealthIssueType.RELATION_FROM_OR_TO_FIELD_METADATA_NOT_VALID
| WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_NOT_VALID
| WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_CONFLICT
| WorkspaceHealthIssueType.RELATION_TYPE_NOT_VALID
>;
type: T;
fromFieldMetadata: FieldMetadataEntity | undefined;
toFieldMetadata: FieldMetadataEntity | undefined;
relationMetadata: RelationMetadataEntity;
@ -92,7 +96,20 @@ export interface WorkspaceHealthRelationIssue<
message: string;
}
/**
* Get the interface for the issue type
*/
export type WorkspaceIssueTypeToInterface<T extends WorkspaceHealthIssueType> =
T extends WorkspaceTableIssueTypes
? WorkspaceHealthTableIssue<T>
: T extends WorkspaceColumnIssueTypes
? WorkspaceHealthColumnIssue<T>
: T extends WorkspaceRelationIssueTypes
? WorkspaceHealthRelationIssue<T>
: never;
/**
* Union of all issues
*/
export type WorkspaceHealthIssue =
| WorkspaceHealthTableIssue
| WorkspaceHealthColumnIssue
| WorkspaceHealthRelationIssue;
WorkspaceIssueTypeToInterface<WorkspaceHealthIssueType>;

View File

@ -125,6 +125,24 @@ export class DatabaseStructureService {
}));
}
async workspaceColumnExist(
schemaName: string,
tableName: string,
columnName: string,
): Promise<boolean> {
const mainDataSource = this.typeORMService.getMainDataSource();
const results = await mainDataSource.query(
`SELECT column_name
FROM information_schema.columns
WHERE table_schema = $1
AND table_name = $2
AND column_name = $3`,
[schemaName, tableName, columnName],
);
return results.length >= 1;
}
getPostgresDataType(fieldMetadata: FieldMetadataEntity): string {
const typeORMType = fieldMetadataTypeToColumnType(fieldMetadata.type);
const mainDataSource = this.typeORMService.getMainDataSource();

View File

@ -1,5 +1,7 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import isEqual from 'lodash.isequal';
import {
WorkspaceHealthIssue,
WorkspaceHealthIssueType,
@ -23,6 +25,9 @@ import {
} 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';
import { serializeDefaultValue } from 'src/metadata/field-metadata/utils/serialize-default-value';
import { computeCompositeFieldMetadata } from 'src/workspace/workspace-health/utils/compute-composite-field-metadata.util';
import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/generate-target-column-map.util';
import { customNamePrefix } from 'src/workspace/utils/compute-custom-name.util';
@Injectable()
export class FieldMetadataHealthService {
@ -52,10 +57,11 @@ export class FieldMetadataHealthService {
compositeDefinitions.get(fieldMetadata.type)?.(fieldMetadata) ?? [];
if (options.mode === 'metadata' || options.mode === 'all') {
const targetColumnMapIssues =
this.targetColumnMapCheck(fieldMetadata);
const targetColumnMapIssue = this.targetColumnMapCheck(fieldMetadata);
issues.push(...targetColumnMapIssues);
if (targetColumnMapIssue) {
issues.push(targetColumnMapIssue);
}
const defaultValueIssues =
this.defaultValueHealthCheck(fieldMetadata);
@ -67,7 +73,10 @@ export class FieldMetadataHealthService {
const compositeFieldIssues = await this.healthCheckField(
tableName,
workspaceTableColumns,
compositeFieldMetadata as FieldMetadataEntity,
computeCompositeFieldMetadata(
compositeFieldMetadata,
fieldMetadata,
),
options,
);
@ -169,11 +178,7 @@ export class FieldMetadataHealthService {
});
}
if (
defaultValue &&
columnDefaultValue &&
isEnumFieldMetadataType(fieldMetadata.type)
) {
if (columnDefaultValue && isEnumFieldMetadataType(fieldMetadata.type)) {
const enumValues = fieldMetadata.options?.map((option) =>
serializeDefaultValue(option.value),
);
@ -188,11 +193,7 @@ export class FieldMetadataHealthService {
}
}
if (
defaultValue &&
columnDefaultValue &&
columnDefaultValue !== defaultValue
) {
if (columnDefaultValue !== defaultValue) {
issues.push({
type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT,
fieldMetadata,
@ -210,20 +211,22 @@ export class FieldMetadataHealthService {
): WorkspaceHealthIssue[] {
const issues: WorkspaceHealthIssue[] = [];
const columnName = fieldMetadata.targetColumnMap.value;
const targetColumnMapIssues = this.targetColumnMapCheck(fieldMetadata);
const targetColumnMapIssue = this.targetColumnMapCheck(fieldMetadata);
const defaultValueIssues = this.defaultValueHealthCheck(fieldMetadata);
if (Object.keys(fieldMetadata.targetColumnMap).length !== 1) {
if (targetColumnMapIssue) {
issues.push(targetColumnMapIssue);
}
if (fieldMetadata.name.startsWith(customNamePrefix)) {
issues.push({
type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID,
type: WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_NOT_BE_PREFIXED,
fieldMetadata,
message: `Column ${columnName} has more than one target column map, it should only contains "value"`,
message: `Column ${columnName} should not be prefixed with "${customNamePrefix}"`,
});
}
issues.push(...targetColumnMapIssues);
if (fieldMetadata.isCustom && !columnName?.startsWith('_')) {
if (fieldMetadata.isCustom && !columnName?.startsWith(customNamePrefix)) {
issues.push({
type: WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_BE_CUSTOM,
fieldMetadata,
@ -277,46 +280,29 @@ export class FieldMetadataHealthService {
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;
}
): WorkspaceHealthIssue | null {
const targetColumnMap = generateTargetColumnMap(
fieldMetadata.type,
fieldMetadata.isCustom,
fieldMetadata.name,
);
if (
!this.isCompositeObjectWellStructured(
fieldMetadata.type,
fieldMetadata.targetColumnMap,
)
!fieldMetadata.targetColumnMap ||
!isEqual(targetColumnMap, fieldMetadata.targetColumnMap)
) {
issues.push({
return {
type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID,
fieldMetadata,
message: `Column targetColumnMap for composite type ${fieldMetadata.type} is not well structured "${fieldMetadata.targetColumnMap}"`,
});
message: `Column targetColumnMap "${JSON.stringify(
fieldMetadata.targetColumnMap,
)}" is not the same as the generated one "${JSON.stringify(
targetColumnMap,
)}"`,
};
}
return issues;
return null;
}
private defaultValueHealthCheck(

View File

@ -7,55 +7,92 @@ import { WorkspaceHealthIssue } from 'src/workspace/workspace-health/interfaces/
import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity';
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import {
isWorkspaceHealthDefaultValueIssue,
isWorkspaceHealthNullableIssue,
isWorkspaceHealthTypeIssue,
} from 'src/workspace/workspace-health/utils/is-workspace-health-issue-type.util';
import { WorkspaceFixNullableService } from './workspace-fix-nullable.service';
import { WorkspaceFixTypeService } from './workspace-fix-type.service';
import { WorkspaceFixDefaultValueService } from './workspace-fix-default-value.service';
import { WorkspaceNullableFixer } from 'src/workspace/workspace-health/fixer/workspace-nullable.fixer';
import { WorkspaceDefaultValueFixer } from 'src/workspace/workspace-health/fixer/workspace-default-value.fixer';
import { WorkspaceTypeFixer } from 'src/workspace/workspace-health/fixer/workspace-type.fixer';
import { WorkspaceTargetColumnMapFixer } from 'src/workspace/workspace-health/fixer/workspace-target-column-map.fixer';
import { CompareEntity } from 'src/workspace/workspace-health/fixer/abstract-workspace.fixer';
@Injectable()
export class WorkspaceFixService {
constructor(
private readonly workspaceFixNullableService: WorkspaceFixNullableService,
private readonly workspaceFixTypeService: WorkspaceFixTypeService,
private readonly workspaceFixDefaultValueService: WorkspaceFixDefaultValueService,
private readonly workspaceNullableFixer: WorkspaceNullableFixer,
private readonly workspaceDefaultValueFixer: WorkspaceDefaultValueFixer,
private readonly workspaceTypeFixer: WorkspaceTypeFixer,
private readonly workspaceTargetColumnMapFixer: WorkspaceTargetColumnMapFixer,
) {}
async fix(
async createWorkspaceMigrations(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
type: WorkspaceHealthFixKind,
issues: WorkspaceHealthIssue[],
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const services = {
[WorkspaceHealthFixKind.Nullable]: {
service: this.workspaceFixNullableService,
issues: issues.filter((issue) =>
isWorkspaceHealthNullableIssue(issue.type),
),
},
[WorkspaceHealthFixKind.Type]: {
service: this.workspaceFixTypeService,
issues: issues.filter((issue) =>
isWorkspaceHealthTypeIssue(issue.type),
),
},
[WorkspaceHealthFixKind.DefaultValue]: {
service: this.workspaceFixDefaultValueService,
issues: issues.filter((issue) =>
isWorkspaceHealthDefaultValueIssue(issue.type),
),
},
};
switch (type) {
case WorkspaceHealthFixKind.Nullable: {
const filteredIssues = this.workspaceNullableFixer.filterIssues(issues);
return services[type].service.fix(
manager,
objectMetadataCollection,
services[type].issues,
);
return this.workspaceNullableFixer.createWorkspaceMigrations(
manager,
objectMetadataCollection,
filteredIssues,
);
}
case WorkspaceHealthFixKind.DefaultValue: {
const filteredIssues =
this.workspaceDefaultValueFixer.filterIssues(issues);
return this.workspaceDefaultValueFixer.createWorkspaceMigrations(
manager,
objectMetadataCollection,
filteredIssues,
);
}
case WorkspaceHealthFixKind.Type: {
const filteredIssues = this.workspaceTypeFixer.filterIssues(issues);
return this.workspaceTypeFixer.createWorkspaceMigrations(
manager,
objectMetadataCollection,
filteredIssues,
);
}
case WorkspaceHealthFixKind.TargetColumnMap: {
const filteredIssues =
this.workspaceTargetColumnMapFixer.filterIssues(issues);
return this.workspaceTargetColumnMapFixer.createWorkspaceMigrations(
manager,
objectMetadataCollection,
filteredIssues,
);
}
default: {
return [];
}
}
}
async createMetadataUpdates(
manager: EntityManager,
objectMetadataCollection: ObjectMetadataEntity[],
type: WorkspaceHealthFixKind,
issues: WorkspaceHealthIssue[],
): Promise<CompareEntity<unknown>[]> {
switch (type) {
case WorkspaceHealthFixKind.TargetColumnMap: {
const filteredIssues =
this.workspaceTargetColumnMapFixer.filterIssues(issues);
return this.workspaceTargetColumnMapFixer.createMetadataUpdates(
manager,
objectMetadataCollection,
filteredIssues,
);
}
default: {
return [];
}
}
}
}

View File

@ -0,0 +1,15 @@
import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity';
import { camelCase } from 'src/utils/camel-case';
// Compute composite field metadata by combining the composite field metadata with the field metadata
export const computeCompositeFieldMetadata = (
compositeFieldMetadata: FieldMetadataInterface,
fieldMetadata: FieldMetadataEntity,
): FieldMetadataEntity => ({
...fieldMetadata,
...compositeFieldMetadata,
objectMetadataId: fieldMetadata.objectMetadataId,
name: camelCase(`${fieldMetadata.name}-${compositeFieldMetadata.name}`),
});

View File

@ -17,3 +17,9 @@ export const isWorkspaceHealthDefaultValueIssue = (
): type is WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT => {
return type === WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT;
};
export const isWorkspaceHealthTargetColumnMapIssue = (
type: WorkspaceHealthIssueType,
): type is WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID => {
return type === WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID;
};

View File

@ -12,10 +12,9 @@ import { WorkspaceHealthService } from 'src/workspace/workspace-health/workspace
import { WorkspaceMigrationBuilderModule } from 'src/workspace/workspace-migration-builder/workspace-migration-builder.module';
import { WorkspaceMigrationRunnerModule } from 'src/workspace/workspace-migration-runner/workspace-migration-runner.module';
import { workspaceFixers } from './fixer';
import { WorkspaceFixService } from './services/workspace-fix.service';
import { WorkspaceFixNullableService } from './services/workspace-fix-nullable.service';
import { WorkspaceFixTypeService } from './services/workspace-fix-type.service';
import { WorkspaceFixDefaultValueService } from './services/workspace-fix-default-value.service';
@Module({
imports: [
@ -27,14 +26,12 @@ import { WorkspaceFixDefaultValueService } from './services/workspace-fix-defaul
WorkspaceMigrationBuilderModule,
],
providers: [
...workspaceFixers,
WorkspaceHealthService,
DatabaseStructureService,
ObjectMetadataHealthService,
FieldMetadataHealthService,
RelationMetadataHealthService,
WorkspaceFixNullableService,
WorkspaceFixTypeService,
WorkspaceFixDefaultValueService,
WorkspaceFixService,
],
exports: [WorkspaceHealthService],

View File

@ -125,8 +125,12 @@ export class WorkspaceHealthService {
type: WorkspaceHealthFixKind;
applyChanges?: boolean;
},
): Promise<Partial<WorkspaceMigrationEntity>[]> {
): Promise<{
workspaceMigrations: Partial<WorkspaceMigrationEntity>[];
metadataEntities: unknown[];
}> {
let workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
let metadataEntities: unknown[] = [];
// Set default options
options.applyChanges ??= true;
@ -145,7 +149,15 @@ export class WorkspaceHealthService {
const objectMetadataCollection =
await this.objectMetadataService.findManyWithinWorkspace(workspaceId);
workspaceMigrations = await this.workspaceFixService.fix(
workspaceMigrations =
await this.workspaceFixService.createWorkspaceMigrations(
manager,
objectMetadataCollection,
options.type,
issues,
);
metadataEntities = await this.workspaceFixService.createMetadataUpdates(
manager,
objectMetadataCollection,
options.type,
@ -161,7 +173,10 @@ export class WorkspaceHealthService {
await queryRunner.release();
return workspaceMigrations;
return {
workspaceMigrations,
metadataEntities,
};
}
// Commit the transaction
@ -178,6 +193,9 @@ export class WorkspaceHealthService {
await queryRunner.release();
}
return workspaceMigrations;
return {
workspaceMigrations,
metadataEntities,
};
}
}

View File

@ -120,6 +120,7 @@ export class WorkspaceMigrationFieldFactory {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
for (const fieldMetadataUpdate of fieldMetadataUpdateCollection) {
// Skip relations, because they're just representation and not real columns
if (fieldMetadataUpdate.altered.type === FieldMetadataType.RELATION) {
continue;
}

View File

@ -268,7 +268,7 @@ export class WorkspaceMigrationRunnerService {
// TODO: Maybe we can do something better if we can recreate the old `TableColumn` object
if (enumValues) {
// This is returning the old enum values to avoid TypeORM droping the enum type
// This is returning the old enum values to avoid TypeORM dropping the enum type
await this.workspaceMigrationEnumService.alterEnum(
queryRunner,
schemaName,

View File

@ -1,7 +1,10 @@
import { Logger } from '@nestjs/common';
import { Command, CommandRunner, Option } from 'nest-commander';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metadata/workspace-sync-metadata.service';
import { WorkspaceHealthService } from 'src/workspace/workspace-health/workspace-health.service';
import { SyncWorkspaceLoggerService } from './services/sync-workspace-logger.service';
@ -9,6 +12,7 @@ import { SyncWorkspaceLoggerService } from './services/sync-workspace-logger.ser
interface RunWorkspaceMigrationsOptions {
workspaceId: string;
dryRun?: boolean;
force?: boolean;
}
@Command({
@ -16,8 +20,11 @@ interface RunWorkspaceMigrationsOptions {
description: 'Sync metadata',
})
export class SyncWorkspaceMetadataCommand extends CommandRunner {
private readonly logger = new Logger(SyncWorkspaceMetadataCommand.name);
constructor(
private readonly workspaceSyncMetadataService: WorkspaceSyncMetadataService,
private readonly workspaceHealthService: WorkspaceHealthService,
private readonly dataSourceService: DataSourceService,
private readonly syncWorkspaceLoggerService: SyncWorkspaceLoggerService,
) {
@ -28,7 +35,30 @@ export class SyncWorkspaceMetadataCommand extends CommandRunner {
_passedParam: string[],
options: RunWorkspaceMigrationsOptions,
): Promise<void> {
// TODO: run in a dedicated job + run queries in a transaction.
const issues = await this.workspaceHealthService.healthCheck(
options.workspaceId,
);
// Security: abort if there are issues.
if (issues.length > 0) {
if (!options.force) {
this.logger.error(
`Workspace contains ${issues.length} issues, aborting.`,
);
this.logger.log('If you want to force the migration, use --force flag');
this.logger.log(
'Please use `workspace:health` command to check issues and fix them before running this command.',
);
return;
}
this.logger.warn(
`Workspace contains ${issues.length} issues, sync has been forced.`,
);
}
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
options.workspaceId,
@ -68,4 +98,13 @@ export class SyncWorkspaceMetadataCommand extends CommandRunner {
dryRun(): boolean {
return true;
}
@Option({
flags: '-f, --force',
description: 'Force migration',
required: false,
})
force(): boolean {
return true;
}
}

View File

@ -2,13 +2,18 @@ import { Module } from '@nestjs/common';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
import { WorkspaceSyncMetadataModule } from 'src/workspace/workspace-sync-metadata/workspace-sync-metadata.module';
import { WorkspaceHealthModule } from 'src/workspace/workspace-health/workspace-health.module';
import { SyncWorkspaceMetadataCommand } from './sync-workspace-metadata.command';
import { SyncWorkspaceLoggerService } from './services/sync-workspace-logger.service';
@Module({
imports: [WorkspaceSyncMetadataModule, DataSourceModule],
imports: [
WorkspaceSyncMetadataModule,
WorkspaceHealthModule,
DataSourceModule,
],
providers: [SyncWorkspaceMetadataCommand, SyncWorkspaceLoggerService],
})
export class WorkspaceSyncMetadataCommandsModule {}

View File

@ -15577,7 +15577,7 @@ __metadata:
languageName: node
linkType: hard
"@types/lodash.isequal@npm:^4.5.7":
"@types/lodash.isequal@npm:^4.5.7, @types/lodash.isequal@npm:^4.5.8":
version: 4.5.8
resolution: "@types/lodash.isequal@npm:4.5.8"
dependencies:
@ -44115,6 +44115,7 @@ __metadata:
"@nx/js": "npm:17.2.8"
"@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@4.2.0#./patches/@ptc-org+nestjs-query-graphql+4.2.0.patch"
"@types/lodash.isempty": "npm:^4.4.7"
"@types/lodash.isequal": "npm:^4.5.8"
"@types/lodash.isobject": "npm:^3.0.7"
"@types/lodash.omit": "npm:^4.5.9"
"@types/lodash.snakecase": "npm:^4.1.7"
@ -44122,6 +44123,7 @@ __metadata:
"@types/react": "npm:^18.2.39"
class-validator: "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch"
graphql-middleware: "npm:^6.1.35"
lodash.isequal: "npm:^4.5.0"
passport: "npm:^0.7.0"
rimraf: "npm:^5.0.5"
typescript: "npm:^5.3.3"