Workspace schema migration runner v2 (#13899)
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
const compositeFieldTypes = [
|
||||
FieldMetadataType.ADDRESS,
|
||||
FieldMetadataType.CURRENCY,
|
||||
FieldMetadataType.FULL_NAME,
|
||||
FieldMetadataType.LINKS,
|
||||
FieldMetadataType.EMAILS,
|
||||
FieldMetadataType.PHONES,
|
||||
FieldMetadataType.RICH_TEXT_V2,
|
||||
FieldMetadataType.ACTOR,
|
||||
] as const;
|
||||
|
||||
export type CompositeFieldMetadataType = (typeof compositeFieldTypes)[number];
|
||||
|
||||
export const COMPOSITE_FIELD_TYPES: CompositeFieldMetadataType[] = [
|
||||
...compositeFieldTypes,
|
||||
];
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
const enumFieldTypes = [
|
||||
FieldMetadataType.SELECT,
|
||||
FieldMetadataType.MULTI_SELECT,
|
||||
FieldMetadataType.RATING,
|
||||
] as const;
|
||||
|
||||
export type EnumFieldMetadataType = (typeof enumFieldTypes)[number];
|
||||
|
||||
export const ENUM_FIELD_TYPES: EnumFieldMetadataType[] = [...enumFieldTypes];
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
const relationFieldTypes = [
|
||||
FieldMetadataType.RELATION,
|
||||
FieldMetadataType.MORPH_RELATION,
|
||||
] as const;
|
||||
|
||||
export type RelationFieldMetadataType = (typeof relationFieldTypes)[number];
|
||||
|
||||
export const RELATION_FIELD_TYPES: RelationFieldMetadataType[] = [
|
||||
...relationFieldTypes,
|
||||
];
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { type FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
|
||||
|
||||
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
|
||||
import {
|
||||
FieldMetadataException,
|
||||
FieldMetadataExceptionCode,
|
||||
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
|
||||
export const getCompositeTypeOrThrow = (
|
||||
fieldType: FieldMetadataType,
|
||||
): CompositeType => {
|
||||
const compositeType = compositeTypeDefinitions.get(fieldType);
|
||||
|
||||
if (!compositeType) {
|
||||
throw new FieldMetadataException(
|
||||
`Composite type not found for field metadata type: ${fieldType}`,
|
||||
FieldMetadataExceptionCode.UNCOVERED_FIELD_METADATA_TYPE_VALIDATION,
|
||||
);
|
||||
}
|
||||
|
||||
return compositeType;
|
||||
};
|
||||
+1
-1
@@ -4,7 +4,7 @@ export const unserializeDefaultValue = (
|
||||
serializedDefaultValue: FieldMetadataDefaultSerializableValue,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
): any => {
|
||||
if (serializedDefaultValue === null) {
|
||||
if (serializedDefaultValue === undefined || serializedDefaultValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
COMPOSITE_FIELD_TYPES,
|
||||
type CompositeFieldMetadataType,
|
||||
} from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFlatFieldMetadataEntityOfTypes } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-falt-field-metadata-of-types.util';
|
||||
|
||||
export const isCompositeFlatFieldMetadata = (
|
||||
flatFieldMetadata: FlatFieldMetadata,
|
||||
): flatFieldMetadata is FlatFieldMetadata<CompositeFieldMetadataType> =>
|
||||
isFlatFieldMetadataEntityOfTypes(flatFieldMetadata, COMPOSITE_FIELD_TYPES);
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
ENUM_FIELD_TYPES,
|
||||
type EnumFieldMetadataType,
|
||||
} from 'src/engine/metadata-modules/field-metadata/types/enum-field-metadata-type.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFlatFieldMetadataEntityOfTypes } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-falt-field-metadata-of-types.util';
|
||||
|
||||
export const isEnumFlatFieldMetadata = (
|
||||
flatFieldMetadata: FlatFieldMetadata,
|
||||
): flatFieldMetadata is FlatFieldMetadata<EnumFieldMetadataType> =>
|
||||
isFlatFieldMetadataEntityOfTypes(flatFieldMetadata, ENUM_FIELD_TYPES);
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { type FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
export function isFlatFieldMetadataEntityOfTypes<
|
||||
Field extends FlatFieldMetadata<FieldMetadataType>,
|
||||
Types extends FieldMetadataType[],
|
||||
>(
|
||||
fieldMetadata: Pick<Field, 'type'>,
|
||||
types: Types,
|
||||
): fieldMetadata is Field & FlatFieldMetadata<Types[number]> {
|
||||
return types.includes(fieldMetadata.type);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
RELATION_FIELD_TYPES,
|
||||
type RelationFieldMetadataType,
|
||||
} from 'src/engine/metadata-modules/field-metadata/types/relation-field-metadata-type.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFlatFieldMetadataEntityOfTypes } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-falt-field-metadata-of-types.util';
|
||||
|
||||
export const isRelationFlatFieldMetadata = (
|
||||
flatFieldMetadata: FlatFieldMetadata,
|
||||
): flatFieldMetadata is FlatFieldMetadata<RelationFieldMetadataType> =>
|
||||
isFlatFieldMetadataEntityOfTypes(flatFieldMetadata, RELATION_FIELD_TYPES);
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import {
|
||||
FlatObjectMetadataMapsException,
|
||||
FlatObjectMetadataMapsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/flat-object-metadata-maps/flat-object-metadata-maps.exception';
|
||||
import {
|
||||
findFlatFieldMetadataInFlatObjectMetadataMaps,
|
||||
type FindFlatFieldMetadataInFlatObjectMetadataMapsArgs,
|
||||
} from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/find-flat-field-metadata-in-flat-object-metadata-maps.util';
|
||||
|
||||
export const findFlatFieldMetadataInFlatObjectMetadataMapsOrThrow = ({
|
||||
flatObjectMetadataMaps,
|
||||
fieldMetadataId,
|
||||
objectMetadataId,
|
||||
}: FindFlatFieldMetadataInFlatObjectMetadataMapsArgs): FlatFieldMetadata => {
|
||||
const flatFieldMetadata = findFlatFieldMetadataInFlatObjectMetadataMaps({
|
||||
flatObjectMetadataMaps,
|
||||
fieldMetadataId,
|
||||
objectMetadataId,
|
||||
});
|
||||
|
||||
if (!isDefined(flatFieldMetadata)) {
|
||||
throw new FlatObjectMetadataMapsException(
|
||||
'flat field metadata not found in flat object metadata maps',
|
||||
FlatObjectMetadataMapsExceptionCode.FIELD_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return flatFieldMetadata;
|
||||
};
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
FlatObjectMetadataMapsException,
|
||||
FlatObjectMetadataMapsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/flat-object-metadata-maps/flat-object-metadata-maps.exception';
|
||||
import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type';
|
||||
import { findFlatObjectMetadataInFlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/find-flat-object-metadata-in-flat-object-metadata-maps.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
export type FindFlatObjectMetadataInFlatObjectMetadataMapsArgs = {
|
||||
objectMetadataId: string;
|
||||
flatObjectMetadataMaps: FlatObjectMetadataMaps;
|
||||
};
|
||||
export const findFlatObjectMetadataInFlatObjectMetadataMapsOrThrow = ({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
}: FindFlatObjectMetadataInFlatObjectMetadataMapsArgs): FlatObjectMetadata => {
|
||||
const flatObjectMetadata = findFlatObjectMetadataInFlatObjectMetadataMaps({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
});
|
||||
|
||||
if (!isDefined(flatObjectMetadata)) {
|
||||
throw new FlatObjectMetadataMapsException(
|
||||
'flat object metadata not found in flat object metadata maps',
|
||||
FlatObjectMetadataMapsExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return flatObjectMetadata;
|
||||
};
|
||||
+5
-1
@@ -1,6 +1,7 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type';
|
||||
import { findFlatObjectMetadataWithFlatFieldMapsInFlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/find-flat-object-metadata-with-flat-field-maps-in-flat-object-metadata-maps.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { fromFlatObjectMetadataWithFlatFieldMapsToFlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-with-flat-field-maps-to-flat-object-metadatas.util';
|
||||
|
||||
@@ -15,7 +16,10 @@ export const findFlatObjectMetadataInFlatObjectMetadataMaps = ({
|
||||
| FlatObjectMetadata
|
||||
| undefined => {
|
||||
const flatObjectMetadataWithFlatFieldMaps =
|
||||
flatObjectMetadataMaps.byId[objectMetadataId];
|
||||
findFlatObjectMetadataWithFlatFieldMapsInFlatObjectMetadataMaps({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
});
|
||||
|
||||
if (!isDefined(flatObjectMetadataWithFlatFieldMaps)) {
|
||||
return undefined;
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
FlatObjectMetadataMapsException,
|
||||
FlatObjectMetadataMapsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/flat-object-metadata-maps/flat-object-metadata-maps.exception';
|
||||
import { type FlatObjectMetadataWithFlatFieldMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-with-flat-field-metadata-maps.type';
|
||||
import {
|
||||
type FindFlatObjectMetadataWithFlatFieldMapsInFlatObjectMetadataMapsArgs,
|
||||
findFlatObjectMetadataWithFlatFieldMapsInFlatObjectMetadataMaps,
|
||||
} from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/find-flat-object-metadata-with-flat-field-maps-in-flat-object-metadata-maps.util';
|
||||
|
||||
export const findFlatObjectMetadataWithFlatFieldMapsInFlatObjectMetadataMapsOrThrow =
|
||||
({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
}: FindFlatObjectMetadataWithFlatFieldMapsInFlatObjectMetadataMapsArgs): FlatObjectMetadataWithFlatFieldMaps => {
|
||||
const flatObjectMetadataWithFlatFieldMaps =
|
||||
findFlatObjectMetadataWithFlatFieldMapsInFlatObjectMetadataMaps({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
});
|
||||
|
||||
if (!isDefined(flatObjectMetadataWithFlatFieldMaps)) {
|
||||
throw new FlatObjectMetadataMapsException(
|
||||
'flat object metadata with flat field maps not found in flat object metadata maps',
|
||||
FlatObjectMetadataMapsExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return flatObjectMetadataWithFlatFieldMaps;
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type';
|
||||
import { type FlatObjectMetadataWithFlatFieldMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-with-flat-field-metadata-maps.type';
|
||||
|
||||
export type FindFlatObjectMetadataWithFlatFieldMapsInFlatObjectMetadataMapsArgs =
|
||||
{
|
||||
objectMetadataId: string;
|
||||
flatObjectMetadataMaps: FlatObjectMetadataMaps;
|
||||
};
|
||||
|
||||
export const findFlatObjectMetadataWithFlatFieldMapsInFlatObjectMetadataMaps =
|
||||
({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
}: FindFlatObjectMetadataWithFlatFieldMapsInFlatObjectMetadataMapsArgs):
|
||||
| FlatObjectMetadataWithFlatFieldMaps
|
||||
| undefined => {
|
||||
const flatObjectMetadataWithFlatFieldMaps =
|
||||
flatObjectMetadataMaps.byId[objectMetadataId];
|
||||
|
||||
if (!isDefined(flatObjectMetadataWithFlatFieldMaps)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return flatObjectMetadataWithFlatFieldMaps;
|
||||
};
|
||||
+1
@@ -19,6 +19,7 @@ import {
|
||||
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.exception';
|
||||
|
||||
// TODO: could we export this to GraphQL ?
|
||||
// TOOD: once this module is removed, replace existing usages with CompositeFieldMetadataType from composite-field-metadata-type.type.ts
|
||||
export type CompositeFieldMetadataType =
|
||||
| FieldMetadataType.ADDRESS
|
||||
| FieldMetadataType.CURRENCY
|
||||
|
||||
+15
-15
@@ -28,7 +28,7 @@ describe('WorkspaceSchemaManager', () => {
|
||||
} as any;
|
||||
|
||||
columnManager = {
|
||||
addColumn: jest.fn(),
|
||||
addColumns: jest.fn(),
|
||||
dropColumn: jest.fn(),
|
||||
renameColumn: jest.fn(),
|
||||
columnExists: jest.fn(),
|
||||
@@ -110,7 +110,7 @@ describe('WorkspaceSchemaManager', () => {
|
||||
it('should provide access to column manager', () => {
|
||||
// Act & Assert
|
||||
expect(service.columnManager).toBeInstanceOf(Object);
|
||||
expect(service.columnManager.addColumn).toBeDefined();
|
||||
expect(service.columnManager.addColumns).toBeDefined();
|
||||
});
|
||||
|
||||
it('should provide access to index manager', () => {
|
||||
@@ -139,33 +139,33 @@ describe('WorkspaceSchemaManager', () => {
|
||||
const tableName = 'users';
|
||||
|
||||
// Act
|
||||
await service.tableManager.createTable(
|
||||
mockQueryRunner,
|
||||
await service.tableManager.createTable({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
[
|
||||
columnDefinitions: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true },
|
||||
{ name: 'name', type: 'varchar', isNullable: false },
|
||||
{ name: 'status', type: 'varchar' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
await service.enumManager.createEnum(
|
||||
mockQueryRunner,
|
||||
await service.enumManager.createEnum({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName,
|
||||
'user_status_enum',
|
||||
['ACTIVE', 'INACTIVE'],
|
||||
);
|
||||
enumName: 'user_status_enum',
|
||||
values: ['ACTIVE', 'INACTIVE'],
|
||||
});
|
||||
|
||||
await service.indexManager.createIndex(
|
||||
mockQueryRunner,
|
||||
await service.indexManager.createIndex({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
{
|
||||
index: {
|
||||
name: 'idx_users_name',
|
||||
columns: ['name'],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(tableManager.createTable).toHaveBeenCalled();
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
appendCommonExceptionCode,
|
||||
CustomException,
|
||||
} from 'src/utils/custom-exception';
|
||||
|
||||
export class WorkspaceSchemaManagerException extends CustomException<
|
||||
keyof typeof WorkspaceSchemaManagerExceptionCode
|
||||
> {}
|
||||
|
||||
export const WorkspaceSchemaManagerExceptionCode = appendCommonExceptionCode({
|
||||
ENUM_OPERATION_FAILED: 'ENUM_OPERATION_FAILED',
|
||||
} as const);
|
||||
-463
@@ -1,463 +0,0 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { WorkspaceSchemaColumnManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service';
|
||||
|
||||
describe('WorkspaceSchemaColumnManager', () => {
|
||||
let service: WorkspaceSchemaColumnManagerService;
|
||||
let mockQueryRunner: jest.Mocked<QueryRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockQueryRunner = {
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
} as any;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [WorkspaceSchemaColumnManagerService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WorkspaceSchemaColumnManagerService>(
|
||||
WorkspaceSchemaColumnManagerService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('addColumn', () => {
|
||||
it('should add column with sanitized names', async () => {
|
||||
// Prepare
|
||||
const column = {
|
||||
name: 'user_name',
|
||||
type: 'varchar',
|
||||
isNullable: false,
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.addColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
column,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "workspace_test"."users" ADD COLUMN',
|
||||
),
|
||||
);
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"user_name" varchar NOT NULL'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize schema, table, and column names', async () => {
|
||||
// Prepare
|
||||
const column = {
|
||||
name: 'col; DROP',
|
||||
type: 'varchar; EXEC',
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.addColumn(
|
||||
mockQueryRunner,
|
||||
'schema; DELETE',
|
||||
'table; UPDATE',
|
||||
column,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('"schemaDELETE"."tableUPDATE"');
|
||||
expect(actualCall).toContain('"colDROP" varcharEXEC');
|
||||
});
|
||||
|
||||
it('should handle array columns', async () => {
|
||||
// Prepare
|
||||
const column = {
|
||||
name: 'tags',
|
||||
type: 'varchar',
|
||||
isArray: true,
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.addColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'posts',
|
||||
column,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('"tags" varchar[]');
|
||||
});
|
||||
|
||||
it('should handle columns with defaults', async () => {
|
||||
// Prepare
|
||||
const column = {
|
||||
name: 'status',
|
||||
type: 'varchar',
|
||||
default: 'active',
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.addColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'posts',
|
||||
column,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('DEFAULT active');
|
||||
});
|
||||
|
||||
it('should handle primary key columns', async () => {
|
||||
// Prepare
|
||||
const column = {
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.addColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'posts',
|
||||
column,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('"id" uuid PRIMARY KEY');
|
||||
});
|
||||
|
||||
it('should handle unique columns', async () => {
|
||||
// Prepare
|
||||
const column = {
|
||||
name: 'email',
|
||||
type: 'varchar',
|
||||
isUnique: true,
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.addColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
column,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('"email" varchar UNIQUE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropColumn', () => {
|
||||
it('should drop column with sanitized names', async () => {
|
||||
await service.dropColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'old_column',
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
'ALTER TABLE "workspace_test"."users" DROP COLUMN IF EXISTS "old_column"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize all input parameters', async () => {
|
||||
await service.dropColumn(
|
||||
mockQueryRunner,
|
||||
'schema; DROP',
|
||||
'table; DELETE',
|
||||
'col; TRUNCATE',
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
'ALTER TABLE "schemaDROP"."tableDELETE" DROP COLUMN IF EXISTS "colTRUNCATE"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropColumns', () => {
|
||||
it('should drop multiple columns', async () => {
|
||||
const columnNames = ['col1', 'col2', 'col3'];
|
||||
|
||||
await service.dropColumns(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
columnNames,
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('ALTER TABLE "workspace_test"."users"');
|
||||
expect(actualCall).toContain('DROP COLUMN IF EXISTS "col1"');
|
||||
expect(actualCall).toContain('DROP COLUMN IF EXISTS "col2"');
|
||||
expect(actualCall).toContain('DROP COLUMN IF EXISTS "col3"');
|
||||
});
|
||||
|
||||
it('should handle empty column list', async () => {
|
||||
await service.dropColumns(mockQueryRunner, 'schema', 'table', []);
|
||||
|
||||
expect(mockQueryRunner.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sanitize column names', async () => {
|
||||
const columnNames = ['col1; DROP', 'col2; DELETE'];
|
||||
|
||||
await service.dropColumns(
|
||||
mockQueryRunner,
|
||||
'schema',
|
||||
'table',
|
||||
columnNames,
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('DROP COLUMN IF EXISTS "col1DROP"');
|
||||
expect(actualCall).toContain('DROP COLUMN IF EXISTS "col2DELETE"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameColumn', () => {
|
||||
it('should rename column with sanitized names', async () => {
|
||||
await service.renameColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'old_name',
|
||||
'new_name',
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
'ALTER TABLE "workspace_test"."users" RENAME COLUMN "old_name" TO "new_name"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize all parameters', async () => {
|
||||
await service.renameColumn(
|
||||
mockQueryRunner,
|
||||
'schema; DROP',
|
||||
'table; DELETE',
|
||||
'old; TRUNCATE',
|
||||
'new; UPDATE',
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
'ALTER TABLE "schemaDROP"."tableDELETE" RENAME COLUMN "oldTRUNCATE" TO "newUPDATE"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('alterColumnType', () => {
|
||||
it('should alter column type', async () => {
|
||||
await service.alterColumnType(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'age',
|
||||
'bigint',
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "workspace_test"."users" ALTER COLUMN "age" TYPE bigint',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle USING clause', async () => {
|
||||
await service.alterColumnType(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'data',
|
||||
'jsonb',
|
||||
'data::jsonb',
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('USING data::jsonb');
|
||||
});
|
||||
});
|
||||
|
||||
describe('columnExists', () => {
|
||||
it('should check if column exists', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([{ exists: true }]);
|
||||
|
||||
const result = await service.columnExists(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'email',
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT EXISTS'),
|
||||
['workspace_test', 'users', 'email'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when column does not exist', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([{ exists: false }]);
|
||||
|
||||
const result = await service.columnExists(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'nonexistent',
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([{ exists: false }]);
|
||||
|
||||
await service.columnExists(
|
||||
mockQueryRunner,
|
||||
'schema; DROP',
|
||||
'table; DELETE',
|
||||
'col; TRUNCATE',
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(expect.any(String), [
|
||||
'schemaDROP',
|
||||
'tableDELETE',
|
||||
'colTRUNCATE',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildColumnDefinition', () => {
|
||||
it('should build basic column definition', () => {
|
||||
const column = {
|
||||
name: 'username',
|
||||
type: 'varchar',
|
||||
};
|
||||
|
||||
const result = (service as any).buildColumnDefinition(column);
|
||||
|
||||
expect(result).toBe('"username" varchar');
|
||||
});
|
||||
|
||||
it('should build column with constraints', () => {
|
||||
const column = {
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
isNullable: false,
|
||||
isUnique: true,
|
||||
default: 'gen_random_uuid()',
|
||||
};
|
||||
|
||||
const result = (service as any).buildColumnDefinition(column);
|
||||
|
||||
expect(result).toContain('"id" uuid');
|
||||
expect(result).toContain('PRIMARY KEY');
|
||||
expect(result).toContain('NOT NULL');
|
||||
expect(result).toContain('UNIQUE');
|
||||
expect(result).toContain('DEFAULT gen_random_uuid()');
|
||||
});
|
||||
|
||||
it('should build array column', () => {
|
||||
const column = {
|
||||
name: 'tags',
|
||||
type: 'varchar',
|
||||
isArray: true,
|
||||
};
|
||||
|
||||
const result = (service as any).buildColumnDefinition(column);
|
||||
|
||||
expect(result).toBe('"tags" varchar[]');
|
||||
});
|
||||
|
||||
it('should build generated column', () => {
|
||||
const column = {
|
||||
name: 'full_name',
|
||||
type: 'varchar',
|
||||
asExpression: "first_name || ' ' || last_name",
|
||||
generatedType: 'STORED' as const,
|
||||
};
|
||||
|
||||
const result = (service as any).buildColumnDefinition(column);
|
||||
|
||||
expect(result).toContain("AS (first_name || ' ' || last_name)");
|
||||
expect(result).toContain('STORED');
|
||||
});
|
||||
|
||||
it('should sanitize column name and type', () => {
|
||||
const column = {
|
||||
name: 'col; DROP',
|
||||
type: 'varchar; EXEC',
|
||||
};
|
||||
|
||||
const result = (service as any).buildColumnDefinition(column);
|
||||
|
||||
expect(result).toBe('"colDROP" varcharEXEC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SQL injection protection', () => {
|
||||
it('should prevent SQL injection in column operations', async () => {
|
||||
const maliciousColumn = {
|
||||
name: "name'; DROP TABLE users; --",
|
||||
type: "varchar'; EXEC xp_cmdshell; --",
|
||||
default: "'; DELETE FROM admin; --",
|
||||
};
|
||||
|
||||
await service.addColumn(
|
||||
mockQueryRunner,
|
||||
'schema',
|
||||
'table',
|
||||
maliciousColumn,
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).not.toContain('DROP TABLE users');
|
||||
expect(actualCall).not.toContain('EXEC xp_cmdshell');
|
||||
expect(actualCall).not.toContain('DELETE FROM admin');
|
||||
expect(actualCall).toContain('"nameDROPTABLEusers"');
|
||||
expect(actualCall).toContain('varcharEXECxp_cmdshell');
|
||||
});
|
||||
|
||||
it('should prevent SQL injection in rename operations', async () => {
|
||||
const maliciousOldName = "old'; DROP TABLE users; --";
|
||||
const maliciousNewName = "new'; DELETE FROM admin; --";
|
||||
|
||||
await service.renameColumn(
|
||||
mockQueryRunner,
|
||||
'schema',
|
||||
'table',
|
||||
maliciousOldName,
|
||||
maliciousNewName,
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).not.toContain('DROP TABLE users');
|
||||
expect(actualCall).not.toContain('DELETE FROM admin');
|
||||
expect(actualCall).toContain('"oldDROPTABLEusers"');
|
||||
expect(actualCall).toContain('"newDELETEFROMadmin"');
|
||||
});
|
||||
});
|
||||
});
|
||||
-610
@@ -1,610 +0,0 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import {
|
||||
TwentyORMException,
|
||||
TwentyORMExceptionCode,
|
||||
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
|
||||
import { WorkspaceSchemaEnumManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service';
|
||||
|
||||
describe('WorkspaceSchemaEnumManager', () => {
|
||||
let service: WorkspaceSchemaEnumManagerService;
|
||||
let mockQueryRunner: jest.Mocked<QueryRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockQueryRunner = {
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
isTransactionActive: false,
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [WorkspaceSchemaEnumManagerService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WorkspaceSchemaEnumManagerService>(
|
||||
WorkspaceSchemaEnumManagerService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createEnum', () => {
|
||||
it('should create an enum with the given values', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const enumName = 'status_enum';
|
||||
const values = ['active', 'inactive', 'pending'];
|
||||
|
||||
// Act
|
||||
await service.createEnum(mockQueryRunner, schemaName, enumName, values);
|
||||
|
||||
// Assert
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`CREATE TYPE "workspace_test"."status_enum" AS ENUM ('active', 'inactive', 'pending')`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize schema name, enum name, and values', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace"test';
|
||||
const enumName = 'status"enum';
|
||||
const values = ['value"1', 'value"2'];
|
||||
|
||||
// Act
|
||||
await service.createEnum(mockQueryRunner, schemaName, enumName, values);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toMatch(/CREATE TYPE .+ AS ENUM/);
|
||||
expect(actualCall).toContain('"workspacetest"."statusenum"');
|
||||
expect(actualCall).toContain("'value1'");
|
||||
expect(actualCall).toContain("'value2'");
|
||||
expect(actualCall).not.toContain('workspace"test');
|
||||
expect(actualCall).not.toContain('status"enum');
|
||||
expect(actualCall).not.toContain('value"1');
|
||||
expect(actualCall).not.toContain('value"2');
|
||||
});
|
||||
|
||||
it('should handle empty values array', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const enumName = 'empty_enum';
|
||||
const values: string[] = [];
|
||||
|
||||
// Act
|
||||
await service.createEnum(mockQueryRunner, schemaName, enumName, values);
|
||||
|
||||
// Assert
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`CREATE TYPE "workspace_test"."empty_enum" AS ENUM ()`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropEnum', () => {
|
||||
it('should drop an enum', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const enumName = 'status_enum';
|
||||
|
||||
await service.dropEnum(mockQueryRunner, schemaName, enumName);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`DROP TYPE IF EXISTS "workspace_test"."status_enum"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize schema name and enum name', async () => {
|
||||
const schemaName = 'workspace"test';
|
||||
const enumName = 'status"enum';
|
||||
|
||||
await service.dropEnum(mockQueryRunner, schemaName, enumName);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
// Verify the SQL is properly structured
|
||||
expect(actualCall).toMatch(/DROP TYPE IF EXISTS/);
|
||||
expect(actualCall).toContain('"workspacetest"."statusenum"');
|
||||
|
||||
// Verify dangerous unescaped quotes are not present
|
||||
expect(actualCall).not.toContain('workspace"test');
|
||||
expect(actualCall).not.toContain('status"enum');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameEnum', () => {
|
||||
it('should rename an enum', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const oldEnumName = 'old_status_enum';
|
||||
const newEnumName = 'new_status_enum';
|
||||
|
||||
await service.renameEnum(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
oldEnumName,
|
||||
newEnumName,
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`ALTER TYPE "workspace_test"."old_status_enum" RENAME TO "new_status_enum"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize all input parameters', async () => {
|
||||
const schemaName = 'workspace"test';
|
||||
const oldEnumName = 'old"enum';
|
||||
const newEnumName = 'new"enum';
|
||||
|
||||
await service.renameEnum(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
oldEnumName,
|
||||
newEnumName,
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).not.toContain('"test');
|
||||
expect(actualCall).not.toContain('old"');
|
||||
expect(actualCall).not.toContain('new"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addEnumValue', () => {
|
||||
it('should add a value to an enum without position', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const enumName = 'status_enum';
|
||||
const value = 'archived';
|
||||
|
||||
await service.addEnumValue(mockQueryRunner, schemaName, enumName, value);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`ALTER TYPE "workspace_test"."status_enum" ADD VALUE 'archived'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should add a value before another value', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const enumName = 'status_enum';
|
||||
const value = 'draft';
|
||||
const beforeValue = 'active';
|
||||
|
||||
await service.addEnumValue(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
enumName,
|
||||
value,
|
||||
beforeValue,
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`ALTER TYPE "workspace_test"."status_enum" ADD VALUE 'draft' BEFORE 'active'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should add a value after another value', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const enumName = 'status_enum';
|
||||
const value = 'draft';
|
||||
const afterValue = 'pending';
|
||||
|
||||
await service.addEnumValue(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
enumName,
|
||||
value,
|
||||
undefined,
|
||||
afterValue,
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`ALTER TYPE "workspace_test"."status_enum" ADD VALUE 'draft' AFTER 'pending'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize before over after when both are provided', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const enumName = 'status_enum';
|
||||
const value = 'draft';
|
||||
const beforeValue = 'active';
|
||||
const afterValue = 'pending';
|
||||
|
||||
await service.addEnumValue(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
enumName,
|
||||
value,
|
||||
beforeValue,
|
||||
afterValue,
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`ALTER TYPE "workspace_test"."status_enum" ADD VALUE 'draft' BEFORE 'active'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize all input parameters', async () => {
|
||||
const schemaName = 'workspace"test';
|
||||
const enumName = 'status"enum';
|
||||
const value = 'value"test';
|
||||
const beforeValue = 'before"value';
|
||||
|
||||
await service.addEnumValue(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
enumName,
|
||||
value,
|
||||
beforeValue,
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toMatch(/ALTER TYPE .+ ADD VALUE .+ BEFORE/);
|
||||
expect(actualCall).toContain('"workspacetest"."statusenum"');
|
||||
expect(actualCall).toContain("'valuetest'");
|
||||
expect(actualCall).toContain("'beforevalue'");
|
||||
|
||||
expect(actualCall).not.toContain('workspace"test');
|
||||
expect(actualCall).not.toContain('status"enum');
|
||||
expect(actualCall).not.toContain('value"test');
|
||||
expect(actualCall).not.toContain('before"value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameEnumValue', () => {
|
||||
it('should rename an enum value', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const enumName = 'status_enum';
|
||||
const oldValue = 'inactive';
|
||||
const newValue = 'disabled';
|
||||
|
||||
await service.renameEnumValue(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
enumName,
|
||||
oldValue,
|
||||
newValue,
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`ALTER TYPE "workspace_test"."status_enum" RENAME VALUE 'inactive' TO 'disabled'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize all input parameters', async () => {
|
||||
const schemaName = 'workspace"test';
|
||||
const enumName = 'status"enum';
|
||||
const oldValue = 'old"value';
|
||||
const newValue = 'new"value';
|
||||
|
||||
await service.renameEnumValue(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
enumName,
|
||||
oldValue,
|
||||
newValue,
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toMatch(/ALTER TYPE .+ RENAME VALUE .+ TO/);
|
||||
expect(actualCall).toContain('"workspacetest"."statusenum"');
|
||||
expect(actualCall).toContain("'oldvalue'");
|
||||
expect(actualCall).toContain("'newvalue'");
|
||||
|
||||
expect(actualCall).not.toContain('workspace"test');
|
||||
expect(actualCall).not.toContain('status"enum');
|
||||
expect(actualCall).not.toContain('old"value');
|
||||
expect(actualCall).not.toContain('new"value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enumExists', () => {
|
||||
it('should return true when enum exists', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([{ exists: true }]);
|
||||
|
||||
const result = await service.enumExists(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'status_enum',
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM pg_type t
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE n.nspname = $1 AND t.typname = $2 AND t.typtype = 'e'
|
||||
)`,
|
||||
['workspace_test', 'status_enum'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when enum does not exist', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([{ exists: false }]);
|
||||
|
||||
const result = await service.enumExists(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'nonexistent_enum',
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when result is empty or undefined', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.enumExists(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'status_enum',
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([{ exists: false }]);
|
||||
|
||||
await service.enumExists(
|
||||
mockQueryRunner,
|
||||
'workspace"test',
|
||||
'status"enum',
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0];
|
||||
|
||||
expect(actualCall[1]).toEqual(['workspacetest', 'statusenum']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnumValues', () => {
|
||||
it('should return enum values in correct order', async () => {
|
||||
const mockValues = [
|
||||
{ value: 'pending' },
|
||||
{ value: 'active' },
|
||||
{ value: 'inactive' },
|
||||
];
|
||||
|
||||
mockQueryRunner.query.mockResolvedValue(mockValues);
|
||||
|
||||
const result = await service.getEnumValues(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'status_enum',
|
||||
);
|
||||
|
||||
expect(result).toEqual(['pending', 'active', 'inactive']);
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`SELECT e.enumlabel as value
|
||||
FROM pg_type t
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
JOIN pg_enum e ON t.oid = e.enumtypid
|
||||
WHERE n.nspname = $1 AND t.typname = $2
|
||||
ORDER BY e.enumsortorder`,
|
||||
['workspace_test', 'status_enum'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no values exist', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getEnumValues(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'empty_enum',
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([]);
|
||||
|
||||
await service.getEnumValues(
|
||||
mockQueryRunner,
|
||||
'workspace"test',
|
||||
'status"enum',
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0];
|
||||
|
||||
expect(actualCall[1]).toEqual(['workspacetest', 'statusenum']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnumNameForColumn', () => {
|
||||
it('should return enum name for regular enum column', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([
|
||||
{ udt_name: 'status_enum', data_type: 'USER-DEFINED' },
|
||||
]);
|
||||
|
||||
const result = await service.getEnumNameForColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'status',
|
||||
);
|
||||
|
||||
expect(result).toBe('status_enum');
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`SELECT udt_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1 AND table_name = $2 AND column_name = $3`,
|
||||
['workspace_test', 'users', 'status'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return enum name for array enum column', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([
|
||||
{ udt_name: '_status_enum', data_type: 'ARRAY' },
|
||||
]);
|
||||
|
||||
const result = await service.getEnumNameForColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'statuses',
|
||||
);
|
||||
|
||||
expect(result).toBe('status_enum');
|
||||
});
|
||||
|
||||
it('should return null when column does not exist', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getEnumNameForColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'nonexistent',
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([]);
|
||||
|
||||
await service.getEnumNameForColumn(
|
||||
mockQueryRunner,
|
||||
'workspace"test',
|
||||
'users"table',
|
||||
'status"column',
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0];
|
||||
|
||||
expect(actualCall[1]).toEqual([
|
||||
'workspacetest',
|
||||
'userstable',
|
||||
'statuscolumn',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('alterEnumValues', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(service, 'getEnumNameForColumn')
|
||||
.mockResolvedValue('old_status_enum');
|
||||
});
|
||||
|
||||
it('should alter enum values with proper sequence of operations', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const columnName = 'status';
|
||||
const newValues = ['draft', 'published', 'archived'];
|
||||
const valueMapping = { active: 'published', inactive: 'archived' };
|
||||
|
||||
mockQueryRunner.query.mockImplementation((sql: string) => {
|
||||
if (sql.includes('SELECT id')) {
|
||||
const columnMatch = sql.match(/SELECT id, "([^"]+)"/);
|
||||
const columnName = columnMatch ? columnMatch[1] : 'old_status';
|
||||
|
||||
return Promise.resolve([
|
||||
{ id: '1', [columnName]: 'active' },
|
||||
{ id: '2', [columnName]: 'inactive' },
|
||||
]);
|
||||
}
|
||||
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
await service.alterEnumValues(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnName,
|
||||
newValues,
|
||||
valueMapping,
|
||||
);
|
||||
|
||||
const calls = mockQueryRunner.query.mock.calls.map((call) => call[0]);
|
||||
|
||||
expect(
|
||||
calls.some((call) => call.includes('RENAME COLUMN "status" TO')),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
calls.some((call) => call.includes('RENAME TO "old_status_enum_temp"')),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
calls.some(
|
||||
(call) =>
|
||||
call.includes('CREATE TYPE') && call.includes('users_status_enum'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(calls.some((call) => call.includes('ADD COLUMN "status"'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(
|
||||
calls.some(
|
||||
(call) => call.includes('UPDATE') && call.includes('CASE "old_'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(calls.some((call) => call.includes('DROP COLUMN'))).toBe(true);
|
||||
|
||||
expect(
|
||||
calls.some(
|
||||
(call) => call.includes('DROP TYPE') && call.includes('temp'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw exception when enum type is not found', async () => {
|
||||
jest.spyOn(service, 'getEnumNameForColumn').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.alterEnumValues(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'nonexistent_column',
|
||||
['value1'],
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new TwentyORMException(
|
||||
'Enum type not found for column nonexistent_column',
|
||||
TwentyORMExceptionCode.ENUM_TYPE_NAME_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty value mapping', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const columnName = 'status';
|
||||
const newValues = ['new_value'];
|
||||
|
||||
mockQueryRunner.query.mockImplementation((sql: string) => {
|
||||
if (sql.includes('SELECT id')) {
|
||||
return Promise.resolve([{ id: '1', old_status: 'old_value' }]);
|
||||
}
|
||||
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
await service.alterEnumValues(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnName,
|
||||
newValues,
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
-624
@@ -1,624 +0,0 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { WorkspaceSchemaForeignKeyManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-foreign-key-manager.service';
|
||||
import { type WorkspaceSchemaForeignKeyDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-foreign-key-definition.type';
|
||||
|
||||
describe('WorkspaceSchemaForeignKeyManager', () => {
|
||||
let service: WorkspaceSchemaForeignKeyManagerService;
|
||||
let mockQueryRunner: jest.Mocked<QueryRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockQueryRunner = {
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
connection: {
|
||||
namingStrategy: {
|
||||
foreignKeyName: jest.fn().mockReturnValue('FK_user_company'),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [WorkspaceSchemaForeignKeyManagerService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WorkspaceSchemaForeignKeyManagerService>(
|
||||
WorkspaceSchemaForeignKeyManagerService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createForeignKey', () => {
|
||||
it('should create a foreign key constraint', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const foreignKey: WorkspaceSchemaForeignKeyDefinition = {
|
||||
name: 'FK_user_company',
|
||||
columnNames: ['companyId'],
|
||||
referencedTableName: 'companies',
|
||||
referencedColumnNames: ['id'],
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.createForeignKey(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKey,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`ALTER TABLE "workspace_test"."users" ADD CONSTRAINT "FK_user_company" FOREIGN KEY ("companyId") REFERENCES "workspace_test"."companies" ("id")`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a foreign key with ON DELETE CASCADE', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const foreignKey: WorkspaceSchemaForeignKeyDefinition = {
|
||||
name: 'FK_user_company',
|
||||
columnNames: ['companyId'],
|
||||
referencedTableName: 'companies',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
};
|
||||
|
||||
await service.createForeignKey(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKey,
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('ON DELETE CASCADE');
|
||||
});
|
||||
|
||||
it('should create a foreign key with ON UPDATE SET NULL', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const foreignKey: WorkspaceSchemaForeignKeyDefinition = {
|
||||
name: 'FK_user_company',
|
||||
columnNames: ['companyId'],
|
||||
referencedTableName: 'companies',
|
||||
referencedColumnNames: ['id'],
|
||||
onUpdate: 'SET NULL',
|
||||
};
|
||||
|
||||
await service.createForeignKey(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKey,
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('ON UPDATE SET NULL');
|
||||
});
|
||||
|
||||
it('should create a foreign key with both ON DELETE and ON UPDATE', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const foreignKey: WorkspaceSchemaForeignKeyDefinition = {
|
||||
name: 'FK_user_company',
|
||||
columnNames: ['companyId'],
|
||||
referencedTableName: 'companies',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'RESTRICT',
|
||||
};
|
||||
|
||||
await service.createForeignKey(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKey,
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('ON DELETE CASCADE');
|
||||
expect(actualCall).toContain('ON UPDATE RESTRICT');
|
||||
});
|
||||
|
||||
it('should handle multiple columns in foreign key', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'orders';
|
||||
const foreignKey: WorkspaceSchemaForeignKeyDefinition = {
|
||||
name: 'FK_order_composite',
|
||||
columnNames: ['userId', 'companyId'],
|
||||
referencedTableName: 'user_companies',
|
||||
referencedColumnNames: ['userId', 'companyId'],
|
||||
};
|
||||
|
||||
await service.createForeignKey(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKey,
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('("userId", "companyId")');
|
||||
expect(actualCall).toContain(
|
||||
'REFERENCES "workspace_test"."user_companies" ("userId", "companyId")',
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize all input parameters', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace"test';
|
||||
const tableName = 'users"table';
|
||||
const foreignKey: WorkspaceSchemaForeignKeyDefinition = {
|
||||
name: 'FK"constraint',
|
||||
columnNames: ['column"id'],
|
||||
referencedTableName: 'ref"table',
|
||||
referencedColumnNames: ['ref"id'],
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.createForeignKey(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKey,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toMatch(
|
||||
/ALTER TABLE .+ ADD CONSTRAINT .+ FOREIGN KEY/,
|
||||
);
|
||||
expect(actualCall).toContain('"workspacetest"."userstable"');
|
||||
expect(actualCall).toContain('"FKconstraint"');
|
||||
expect(actualCall).toContain('"columnid"');
|
||||
expect(actualCall).toContain('"reftable"');
|
||||
expect(actualCall).toContain('"refid"');
|
||||
expect(actualCall).not.toContain('workspace"test');
|
||||
expect(actualCall).not.toContain('users"table');
|
||||
expect(actualCall).not.toContain('FK"constraint');
|
||||
expect(actualCall).not.toContain('column"id');
|
||||
expect(actualCall).not.toContain('ref"table');
|
||||
expect(actualCall).not.toContain('ref"id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropForeignKey', () => {
|
||||
it('should drop a foreign key constraint', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const foreignKeyName = 'FK_user_company';
|
||||
|
||||
await service.dropForeignKey(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKeyName,
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`ALTER TABLE "workspace_test"."users" DROP CONSTRAINT IF EXISTS "FK_user_company"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
const schemaName = 'workspace"test';
|
||||
const tableName = 'users"table';
|
||||
const foreignKeyName = 'FK"constraint';
|
||||
|
||||
await service.dropForeignKey(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKeyName,
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
// Verify the SQL is properly structured
|
||||
expect(actualCall).toMatch(/ALTER TABLE .+ DROP CONSTRAINT IF EXISTS/);
|
||||
expect(actualCall).toContain('"workspacetest"."userstable"');
|
||||
expect(actualCall).toContain('"FKconstraint"');
|
||||
|
||||
// Verify dangerous unescaped quotes are not present
|
||||
expect(actualCall).not.toContain('workspace"test');
|
||||
expect(actualCall).not.toContain('users"table');
|
||||
expect(actualCall).not.toContain('FK"constraint');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropForeignKeyByColumn', () => {
|
||||
it('should drop foreign key by column when constraint exists', async () => {
|
||||
jest
|
||||
.spyOn(service, 'getForeignKeyNameByColumn')
|
||||
.mockResolvedValue('FK_user_company');
|
||||
const dropForeignKeySpy = jest
|
||||
.spyOn(service, 'dropForeignKey')
|
||||
.mockResolvedValue();
|
||||
|
||||
await service.dropForeignKeyByColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'companyId',
|
||||
);
|
||||
|
||||
expect(service.getForeignKeyNameByColumn).toHaveBeenCalledWith(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'companyId',
|
||||
);
|
||||
expect(dropForeignKeySpy).toHaveBeenCalledWith(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'FK_user_company',
|
||||
);
|
||||
});
|
||||
|
||||
it('should do nothing when no foreign key exists for column', async () => {
|
||||
jest.spyOn(service, 'getForeignKeyNameByColumn').mockResolvedValue(null);
|
||||
const dropForeignKeySpy = jest
|
||||
.spyOn(service, 'dropForeignKey')
|
||||
.mockResolvedValue();
|
||||
|
||||
await service.dropForeignKeyByColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'nonConstrainedColumn',
|
||||
);
|
||||
|
||||
expect(service.getForeignKeyNameByColumn).toHaveBeenCalledWith(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'nonConstrainedColumn',
|
||||
);
|
||||
expect(dropForeignKeySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('foreignKeyExists', () => {
|
||||
it('should return true when foreign key exists', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([{ exists: true }]);
|
||||
|
||||
const result = await service.foreignKeyExists(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'FK_user_company',
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM information_schema.table_constraints
|
||||
WHERE constraint_schema = $1
|
||||
AND table_name = $2
|
||||
AND constraint_name = $3
|
||||
AND constraint_type = 'FOREIGN KEY'
|
||||
)`,
|
||||
['workspace_test', 'users', 'FK_user_company'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when foreign key does not exist', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([{ exists: false }]);
|
||||
|
||||
const result = await service.foreignKeyExists(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'FK_nonexistent',
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when result is empty', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.foreignKeyExists(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'FK_user_company',
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([{ exists: false }]);
|
||||
|
||||
await service.foreignKeyExists(
|
||||
mockQueryRunner,
|
||||
'workspace"test',
|
||||
'users"table',
|
||||
'FK"constraint',
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0];
|
||||
|
||||
expect(actualCall[1]).toEqual([
|
||||
'workspacetest',
|
||||
'userstable',
|
||||
'FKconstraint',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getForeignKeyNameByColumn', () => {
|
||||
it('should return foreign key name for column', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([
|
||||
{ constraint_name: 'FK_user_company' },
|
||||
]);
|
||||
|
||||
const result = await service.getForeignKeyNameByColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'companyId',
|
||||
);
|
||||
|
||||
expect(result).toBe('FK_user_company');
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`SELECT tc.constraint_name
|
||||
FROM information_schema.table_constraints AS tc
|
||||
JOIN information_schema.key_column_usage AS kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_schema = $1
|
||||
AND tc.table_name = $2
|
||||
AND kcu.column_name = $3`,
|
||||
['workspace_test', 'users', 'companyId'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null when no foreign key exists for column', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getForeignKeyNameByColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'nonConstrainedColumn',
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([]);
|
||||
|
||||
await service.getForeignKeyNameByColumn(
|
||||
mockQueryRunner,
|
||||
'workspace"test',
|
||||
'users"table',
|
||||
'column"id',
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0];
|
||||
|
||||
expect(actualCall[1]).toEqual([
|
||||
'workspacetest',
|
||||
'userstable',
|
||||
'columnid',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getForeignKeysForTable', () => {
|
||||
it('should return all foreign keys for a table', async () => {
|
||||
const mockForeignKeys = [
|
||||
{
|
||||
constraint_name: 'FK_user_company',
|
||||
column_name: 'companyId',
|
||||
foreign_table_name: 'companies',
|
||||
foreign_column_name: 'id',
|
||||
delete_rule: 'CASCADE',
|
||||
update_rule: 'RESTRICT',
|
||||
},
|
||||
{
|
||||
constraint_name: 'FK_user_department',
|
||||
column_name: 'departmentId',
|
||||
foreign_table_name: 'departments',
|
||||
foreign_column_name: 'id',
|
||||
delete_rule: 'SET NULL',
|
||||
update_rule: 'NO ACTION',
|
||||
},
|
||||
];
|
||||
|
||||
mockQueryRunner.query.mockResolvedValue(mockForeignKeys);
|
||||
|
||||
const result = await service.getForeignKeysForTable(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockForeignKeys);
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT'),
|
||||
['workspace_test', 'users'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no foreign keys exist', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getForeignKeysForTable(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'standalone_table',
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([]);
|
||||
|
||||
await service.getForeignKeysForTable(
|
||||
mockQueryRunner,
|
||||
'workspace"test',
|
||||
'users"table',
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0];
|
||||
|
||||
expect(actualCall[1]).toEqual(['workspacetest', 'userstable']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createForeignKeyFromColumn', () => {
|
||||
it('should create foreign key from column with default referenced column', async () => {
|
||||
const createForeignKeySpy = jest
|
||||
.spyOn(service, 'createForeignKey')
|
||||
.mockResolvedValue();
|
||||
|
||||
await service.createForeignKeyFromColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'companyId',
|
||||
'companies',
|
||||
);
|
||||
|
||||
expect(
|
||||
mockQueryRunner.connection.namingStrategy.foreignKeyName,
|
||||
).toHaveBeenCalledWith(
|
||||
'users',
|
||||
['companyId'],
|
||||
'workspace_test.companies',
|
||||
['id'],
|
||||
);
|
||||
expect(createForeignKeySpy).toHaveBeenCalledWith(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
{
|
||||
name: 'FK_user_company',
|
||||
columnNames: ['companyId'],
|
||||
referencedTableName: 'companies',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should create foreign key with custom referenced column and onDelete', async () => {
|
||||
const createForeignKeySpy = jest
|
||||
.spyOn(service, 'createForeignKey')
|
||||
.mockResolvedValue();
|
||||
|
||||
await service.createForeignKeyFromColumn(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'companyCode',
|
||||
'companies',
|
||||
'code',
|
||||
'SET NULL',
|
||||
);
|
||||
|
||||
expect(createForeignKeySpy).toHaveBeenCalledWith(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
{
|
||||
name: 'FK_user_company',
|
||||
columnNames: ['companyCode'],
|
||||
referencedTableName: 'companies',
|
||||
referencedColumnNames: ['code'],
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameForeignKey', () => {
|
||||
it('should rename a foreign key constraint', async () => {
|
||||
await service.renameForeignKey(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'FK_old_name',
|
||||
'FK_new_name',
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`ALTER TABLE "workspace_test"."users" RENAME CONSTRAINT "FK_old_name" TO "FK_new_name"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
await service.renameForeignKey(
|
||||
mockQueryRunner,
|
||||
'workspace"test',
|
||||
'users"table',
|
||||
'FK"old',
|
||||
'FK"new',
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toMatch(/ALTER TABLE .+ RENAME CONSTRAINT .+ TO/);
|
||||
expect(actualCall).toContain('"workspacetest"."userstable"');
|
||||
expect(actualCall).toContain('"FKold"');
|
||||
expect(actualCall).toContain('"FKnew"');
|
||||
|
||||
expect(actualCall).not.toContain('workspace"test');
|
||||
expect(actualCall).not.toContain('users"table');
|
||||
expect(actualCall).not.toContain('FK"old');
|
||||
expect(actualCall).not.toContain('FK"new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateForeignKey', () => {
|
||||
it('should validate a foreign key constraint', async () => {
|
||||
await service.validateForeignKey(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
'FK_user_company',
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`ALTER TABLE "workspace_test"."users" VALIDATE CONSTRAINT "FK_user_company"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
await service.validateForeignKey(
|
||||
mockQueryRunner,
|
||||
'workspace"test',
|
||||
'users"table',
|
||||
'FK"constraint',
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toMatch(/ALTER TABLE .+ VALIDATE CONSTRAINT/);
|
||||
expect(actualCall).toContain('"workspacetest"."userstable"');
|
||||
expect(actualCall).toContain('"FKconstraint"');
|
||||
|
||||
expect(actualCall).not.toContain('workspace"test');
|
||||
expect(actualCall).not.toContain('users"table');
|
||||
expect(actualCall).not.toContain('FK"constraint');
|
||||
});
|
||||
});
|
||||
});
|
||||
-721
@@ -1,721 +0,0 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { WorkspaceSchemaIndexManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-index-manager.service';
|
||||
import { type WorkspaceSchemaIndexDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-index-definition.type';
|
||||
|
||||
describe('WorkspaceSchemaIndexManager', () => {
|
||||
let service: WorkspaceSchemaIndexManagerService;
|
||||
let mockQueryRunner: jest.Mocked<QueryRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockQueryRunner = {
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
} as any;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [WorkspaceSchemaIndexManagerService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WorkspaceSchemaIndexManagerService>(
|
||||
WorkspaceSchemaIndexManagerService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createIndex', () => {
|
||||
it('should create a basic index', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const index: WorkspaceSchemaIndexDefinition = {
|
||||
name: 'idx_users_email',
|
||||
columns: ['email'],
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.createIndex(mockQueryRunner, schemaName, tableName, index);
|
||||
|
||||
// Assert
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`CREATE INDEX IF NOT EXISTS "idx_users_email" ON "workspace_test"."users" ("email")`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a unique index', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const index: WorkspaceSchemaIndexDefinition = {
|
||||
name: 'idx_users_email_unique',
|
||||
columns: ['email'],
|
||||
isUnique: true,
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.createIndex(mockQueryRunner, schemaName, tableName, index);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('CREATE UNIQUE INDEX');
|
||||
});
|
||||
|
||||
it('should create index with specific type', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const index: WorkspaceSchemaIndexDefinition = {
|
||||
name: 'idx_users_data_gin',
|
||||
columns: ['data'],
|
||||
type: 'GIN',
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.createIndex(mockQueryRunner, schemaName, tableName, index);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('USING GIN');
|
||||
});
|
||||
|
||||
it('should create index with BTREE type (default)', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const index: WorkspaceSchemaIndexDefinition = {
|
||||
name: 'idx_users_name',
|
||||
columns: ['name'],
|
||||
type: 'BTREE',
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.createIndex(mockQueryRunner, schemaName, tableName, index);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).not.toContain('USING BTREE');
|
||||
});
|
||||
|
||||
it('should create index with WHERE clause', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const index: WorkspaceSchemaIndexDefinition = {
|
||||
name: 'idx_users_active_email',
|
||||
columns: ['email'],
|
||||
where: 'active = true',
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.createIndex(mockQueryRunner, schemaName, tableName, index);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('WHERE active = true');
|
||||
});
|
||||
|
||||
it('should create index with INCLUDE clause', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const index: WorkspaceSchemaIndexDefinition = {
|
||||
name: 'idx_users_email_include',
|
||||
columns: ['email'],
|
||||
include: ['name', 'created_at'],
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.createIndex(mockQueryRunner, schemaName, tableName, index);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('INCLUDE ("name", "created_at")');
|
||||
});
|
||||
|
||||
it('should create composite index with multiple columns', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const index: WorkspaceSchemaIndexDefinition = {
|
||||
name: 'idx_users_company_department',
|
||||
columns: ['companyId', 'departmentId'],
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.createIndex(mockQueryRunner, schemaName, tableName, index);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('("companyId", "departmentId")');
|
||||
});
|
||||
|
||||
it('should create index with all options combined', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const index: WorkspaceSchemaIndexDefinition = {
|
||||
name: 'idx_users_complex',
|
||||
columns: ['email', 'status'],
|
||||
type: 'BTREE',
|
||||
isUnique: true,
|
||||
where: 'deleted_at IS NULL',
|
||||
include: ['name'],
|
||||
};
|
||||
|
||||
await service.createIndex(mockQueryRunner, schemaName, tableName, index);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('CREATE UNIQUE INDEX');
|
||||
expect(actualCall).toContain('("email", "status")');
|
||||
expect(actualCall).toContain('INCLUDE ("name")');
|
||||
expect(actualCall).toContain('WHERE deleted_at IS NULL');
|
||||
});
|
||||
|
||||
it('should handle index creation errors gracefully for existing index', async () => {
|
||||
const error = new Error('Index already exists') as any;
|
||||
|
||||
error.code = '42P07';
|
||||
mockQueryRunner.query.mockRejectedValue(error);
|
||||
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const index: WorkspaceSchemaIndexDefinition = {
|
||||
name: 'idx_existing',
|
||||
columns: ['email'],
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.createIndex(mockQueryRunner, schemaName, tableName, index),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should rethrow non-existing index errors', async () => {
|
||||
const error = new Error('Other database error') as any;
|
||||
|
||||
error.code = '42000';
|
||||
mockQueryRunner.query.mockRejectedValue(error);
|
||||
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const index: WorkspaceSchemaIndexDefinition = {
|
||||
name: 'idx_failing',
|
||||
columns: ['email'],
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.createIndex(mockQueryRunner, schemaName, tableName, index),
|
||||
).rejects.toThrow('Other database error');
|
||||
});
|
||||
|
||||
it('should sanitize all input parameters', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace"test';
|
||||
const tableName = 'users"table';
|
||||
const index: WorkspaceSchemaIndexDefinition = {
|
||||
name: 'idx"test',
|
||||
columns: ['email"col', 'name"col'],
|
||||
include: ['include"col'],
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.createIndex(mockQueryRunner, schemaName, tableName, index);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toMatch(/CREATE\s+INDEX IF NOT EXISTS/);
|
||||
expect(actualCall).toContain('"workspacetest"."userstable"');
|
||||
expect(actualCall).toContain('"idxtest"');
|
||||
expect(actualCall).toContain('"emailcol"');
|
||||
expect(actualCall).toContain('"namecol"');
|
||||
expect(actualCall).toContain('"includecol"');
|
||||
expect(actualCall).not.toContain('workspace"test');
|
||||
expect(actualCall).not.toContain('users"table');
|
||||
expect(actualCall).not.toContain('idx"test');
|
||||
expect(actualCall).not.toContain('email"col');
|
||||
expect(actualCall).not.toContain('name"col');
|
||||
expect(actualCall).not.toContain('include"col');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropIndex', () => {
|
||||
it('should drop an index', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const indexName = 'idx_users_email';
|
||||
|
||||
// Act
|
||||
await service.dropIndex(mockQueryRunner, schemaName, indexName);
|
||||
|
||||
// Assert
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`DROP INDEX IF EXISTS "workspace_test"."idx_users_email"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle index drop errors gracefully for non-existing index', async () => {
|
||||
const error = new Error('Index does not exist') as any;
|
||||
|
||||
error.code = '42704';
|
||||
mockQueryRunner.query.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
service.dropIndex(mockQueryRunner, 'workspace_test', 'idx_nonexistent'),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should rethrow non-missing index errors', async () => {
|
||||
const error = new Error('Other database error') as any;
|
||||
|
||||
error.code = '42000';
|
||||
mockQueryRunner.query.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
service.dropIndex(mockQueryRunner, 'workspace_test', 'idx_failing'),
|
||||
).rejects.toThrow('Other database error');
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace"test';
|
||||
const indexName = 'idx"test';
|
||||
|
||||
// Act
|
||||
await service.dropIndex(mockQueryRunner, schemaName, indexName);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).not.toContain('"test');
|
||||
expect(actualCall).not.toContain('idx"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameIndex', () => {
|
||||
it('should rename an index', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const oldIndexName = 'idx_old_name';
|
||||
const newIndexName = 'idx_new_name';
|
||||
|
||||
// Act
|
||||
await service.renameIndex(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
oldIndexName,
|
||||
newIndexName,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`ALTER INDEX "workspace_test"."idx_old_name" RENAME TO "idx_new_name"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace"test';
|
||||
const oldIndexName = 'idx"old';
|
||||
const newIndexName = 'idx"new';
|
||||
|
||||
// Act
|
||||
await service.renameIndex(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
oldIndexName,
|
||||
newIndexName,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).not.toContain('"test');
|
||||
expect(actualCall).not.toContain('idx"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('indexExists', () => {
|
||||
it('should return true when index exists', async () => {
|
||||
// Prepare
|
||||
mockQueryRunner.query.mockResolvedValue([{ exists: true }]);
|
||||
|
||||
// Act
|
||||
const result = await service.indexExists(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'idx_users_email',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM pg_indexes
|
||||
WHERE schemaname = $1 AND indexname = $2
|
||||
)`,
|
||||
['workspace_test', 'idx_users_email'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when index does not exist', async () => {
|
||||
// Prepare
|
||||
mockQueryRunner.query.mockResolvedValue([{ exists: false }]);
|
||||
|
||||
// Act
|
||||
const result = await service.indexExists(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'idx_nonexistent',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when result is empty', async () => {
|
||||
// Prepare
|
||||
mockQueryRunner.query.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await service.indexExists(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'idx_users_email',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
// Prepare
|
||||
mockQueryRunner.query.mockResolvedValue([{ exists: false }]);
|
||||
|
||||
// Act
|
||||
await service.indexExists(mockQueryRunner, 'workspace"test', 'idx"test');
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0];
|
||||
|
||||
expect(actualCall[1]).toEqual(['workspacetest', 'idxtest']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIndexesForTable', () => {
|
||||
it('should return all indexes for a table', async () => {
|
||||
// Prepare
|
||||
const mockIndexes = [
|
||||
{
|
||||
indexname: 'idx_users_email',
|
||||
indexdef: 'CREATE INDEX idx_users_email ON users (email)',
|
||||
},
|
||||
{
|
||||
indexname: 'idx_users_name',
|
||||
indexdef: 'CREATE INDEX idx_users_name ON users (name)',
|
||||
},
|
||||
];
|
||||
|
||||
mockQueryRunner.query.mockResolvedValue(mockIndexes);
|
||||
|
||||
// Act
|
||||
const result = await service.getIndexesForTable(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockIndexes);
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`SELECT indexname, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = $1 AND tablename = $2`,
|
||||
['workspace_test', 'users'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no indexes exist', async () => {
|
||||
// Prepare
|
||||
mockQueryRunner.query.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await service.getIndexesForTable(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'empty_table',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
// Prepare
|
||||
mockQueryRunner.query.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await service.getIndexesForTable(
|
||||
mockQueryRunner,
|
||||
'workspace"test',
|
||||
'users"table',
|
||||
);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0];
|
||||
|
||||
expect(actualCall[1]).toEqual(['workspacetest', 'userstable']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPrimaryKey', () => {
|
||||
it('should create a primary key constraint', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const constraintName = 'PK_users';
|
||||
const columnNames = ['id'];
|
||||
|
||||
// Act
|
||||
await service.createPrimaryKey(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
constraintName,
|
||||
columnNames,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`ALTER TABLE "workspace_test"."users" ADD CONSTRAINT "PK_users" PRIMARY KEY ("id")`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should create composite primary key', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'user_roles';
|
||||
const constraintName = 'PK_user_roles';
|
||||
const columnNames = ['userId', 'roleId'];
|
||||
|
||||
// Act
|
||||
await service.createPrimaryKey(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
constraintName,
|
||||
columnNames,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('PRIMARY KEY ("userId", "roleId")');
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace"test';
|
||||
const tableName = 'users"table';
|
||||
const constraintName = 'PK"test';
|
||||
const columnNames = ['id"col'];
|
||||
|
||||
// Act
|
||||
await service.createPrimaryKey(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
constraintName,
|
||||
columnNames,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toMatch(
|
||||
/ALTER TABLE .+ ADD CONSTRAINT .+ PRIMARY KEY/,
|
||||
);
|
||||
expect(actualCall).toContain('"workspacetest"."userstable"');
|
||||
expect(actualCall).toContain('"PKtest"');
|
||||
expect(actualCall).toContain('"idcol"');
|
||||
expect(actualCall).not.toContain('workspace"test');
|
||||
expect(actualCall).not.toContain('users"table');
|
||||
expect(actualCall).not.toContain('PK"test');
|
||||
expect(actualCall).not.toContain('id"col');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropPrimaryKey', () => {
|
||||
it('should drop a primary key constraint', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const constraintName = 'PK_users';
|
||||
|
||||
// Act
|
||||
await service.dropPrimaryKey(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
constraintName,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`ALTER TABLE "workspace_test"."users" DROP CONSTRAINT IF EXISTS "PK_users"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace"test';
|
||||
const tableName = 'users"table';
|
||||
const constraintName = 'PK"test';
|
||||
|
||||
// Act
|
||||
await service.dropPrimaryKey(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
constraintName,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toMatch(/ALTER TABLE .+ DROP CONSTRAINT IF EXISTS/);
|
||||
expect(actualCall).toContain('"workspacetest"."userstable"');
|
||||
expect(actualCall).toContain('"PKtest"');
|
||||
expect(actualCall).not.toContain('workspace"test');
|
||||
expect(actualCall).not.toContain('users"table');
|
||||
expect(actualCall).not.toContain('PK"test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUniqueConstraint', () => {
|
||||
it('should create a unique constraint', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const constraintName = 'UQ_users_email';
|
||||
const columnNames = ['email'];
|
||||
|
||||
// Act
|
||||
await service.createUniqueConstraint(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
constraintName,
|
||||
columnNames,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`ALTER TABLE "workspace_test"."users" ADD CONSTRAINT "UQ_users_email" UNIQUE ("email")`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should create composite unique constraint', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const constraintName = 'UQ_users_email_company';
|
||||
const columnNames = ['email', 'companyId'];
|
||||
|
||||
// Act
|
||||
await service.createUniqueConstraint(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
constraintName,
|
||||
columnNames,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('UNIQUE ("email", "companyId")');
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
const schemaName = 'workspace"test';
|
||||
const tableName = 'users"table';
|
||||
const constraintName = 'UQ"test';
|
||||
const columnNames = ['email"col'];
|
||||
|
||||
await service.createUniqueConstraint(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
constraintName,
|
||||
columnNames,
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toMatch(/ALTER TABLE .+ ADD CONSTRAINT .+ UNIQUE/);
|
||||
expect(actualCall).toContain('"workspacetest"."userstable"');
|
||||
expect(actualCall).toContain('"UQtest"');
|
||||
expect(actualCall).toContain('"emailcol"');
|
||||
|
||||
expect(actualCall).not.toContain('workspace"test');
|
||||
expect(actualCall).not.toContain('users"table');
|
||||
expect(actualCall).not.toContain('UQ"test');
|
||||
expect(actualCall).not.toContain('email"col');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropUniqueConstraint', () => {
|
||||
it('should drop a unique constraint', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
const constraintName = 'UQ_users_email';
|
||||
|
||||
// Act
|
||||
await service.dropUniqueConstraint(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
constraintName,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
`ALTER TABLE "workspace_test"."users" DROP CONSTRAINT IF EXISTS "UQ_users_email"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
const schemaName = 'workspace"test';
|
||||
const tableName = 'users"table';
|
||||
const constraintName = 'UQ"test';
|
||||
|
||||
await service.dropUniqueConstraint(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
constraintName,
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toMatch(/ALTER TABLE .+ DROP CONSTRAINT IF EXISTS/);
|
||||
expect(actualCall).toContain('"workspacetest"."userstable"');
|
||||
expect(actualCall).toContain('"UQtest"');
|
||||
|
||||
expect(actualCall).not.toContain('workspace"test');
|
||||
expect(actualCall).not.toContain('users"table');
|
||||
expect(actualCall).not.toContain('UQ"test');
|
||||
});
|
||||
});
|
||||
});
|
||||
-276
@@ -1,276 +0,0 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { WorkspaceSchemaTableManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-table-manager.service';
|
||||
|
||||
describe('WorkspaceSchemaTableManager', () => {
|
||||
let service: WorkspaceSchemaTableManagerService;
|
||||
let mockQueryRunner: jest.Mocked<QueryRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockQueryRunner = {
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
} as any;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [WorkspaceSchemaTableManagerService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WorkspaceSchemaTableManagerService>(
|
||||
WorkspaceSchemaTableManagerService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createTable', () => {
|
||||
it('should create table with default columns when no columns provided', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'users';
|
||||
|
||||
// Act
|
||||
await service.createTable(mockQueryRunner, schemaName, tableName);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain(
|
||||
'CREATE TABLE IF NOT EXISTS "workspace_test"."users"',
|
||||
);
|
||||
expect(actualCall).toContain(
|
||||
'"id" uuid PRIMARY KEY DEFAULT gen_random_uuid()',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create table with custom columns', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'products';
|
||||
const columns = [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true },
|
||||
{ name: 'name', type: 'varchar', isNullable: false },
|
||||
{ name: 'price', type: 'decimal', default: '0.00' },
|
||||
{ name: 'tags', type: 'varchar', isArray: true },
|
||||
{ name: 'email', type: 'varchar', isUnique: true },
|
||||
];
|
||||
|
||||
await service.createTable(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columns,
|
||||
);
|
||||
|
||||
const expectedSql = expect.stringContaining(
|
||||
'CREATE TABLE IF NOT EXISTS "workspace_test"."products"',
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(expectedSql);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('"id" uuid PRIMARY KEY');
|
||||
expect(actualCall).toContain('"name" varchar NOT NULL');
|
||||
expect(actualCall).toContain('"price" decimal DEFAULT 000');
|
||||
expect(actualCall).toContain('"tags" varchar[]');
|
||||
expect(actualCall).toContain('"email" varchar UNIQUE');
|
||||
});
|
||||
|
||||
it('should sanitize schema and table names', async () => {
|
||||
// Prepare
|
||||
const schemaName = 'workspace_test; DROP TABLE';
|
||||
const tableName = 'users; DELETE FROM';
|
||||
|
||||
// Act
|
||||
await service.createTable(mockQueryRunner, schemaName, tableName);
|
||||
|
||||
// Assert
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"workspace_testDROPTABLE"."usersDELETEFROM"'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize column names and types', async () => {
|
||||
// Prepare
|
||||
const columns = [{ name: 'user_id; DROP', type: 'varchar; EXEC' }];
|
||||
|
||||
// Act
|
||||
await service.createTable(mockQueryRunner, 'schema', 'table', columns);
|
||||
|
||||
// Assert
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).toContain('"user_idDROP" varcharEXEC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropTable', () => {
|
||||
it('should drop table with sanitized names', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const tableName = 'old_table';
|
||||
|
||||
await service.dropTable(mockQueryRunner, schemaName, tableName);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
'DROP TABLE IF EXISTS "workspace_test"."old_table"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize dangerous input', async () => {
|
||||
const schemaName = 'schema; DROP DATABASE';
|
||||
const tableName = 'table; TRUNCATE';
|
||||
|
||||
await service.dropTable(mockQueryRunner, schemaName, tableName);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
'DROP TABLE IF EXISTS "schemaDROPDATABASE"."tableTRUNCATE"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameTable', () => {
|
||||
it('should rename table with sanitized names', async () => {
|
||||
const schemaName = 'workspace_test';
|
||||
const oldTableName = 'old_name';
|
||||
const newTableName = 'new_name';
|
||||
|
||||
await service.renameTable(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
oldTableName,
|
||||
newTableName,
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
'ALTER TABLE "workspace_test"."old_name" RENAME TO "new_name"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize all table names', async () => {
|
||||
const schemaName = 'schema; DROP';
|
||||
const oldTableName = 'old; DELETE';
|
||||
const newTableName = 'new; INSERT';
|
||||
|
||||
await service.renameTable(
|
||||
mockQueryRunner,
|
||||
schemaName,
|
||||
oldTableName,
|
||||
newTableName,
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
'ALTER TABLE "schemaDROP"."oldDELETE" RENAME TO "newINSERT"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableExists', () => {
|
||||
it('should check if table exists', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([{ exists: true }]);
|
||||
|
||||
const result = await service.tableExists(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT EXISTS'),
|
||||
['workspace_test', 'users'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when table does not exist', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([{ exists: false }]);
|
||||
|
||||
const result = await service.tableExists(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'nonexistent',
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should sanitize input parameters', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([{ exists: false }]);
|
||||
|
||||
await service.tableExists(
|
||||
mockQueryRunner,
|
||||
'schema; DROP',
|
||||
'table; DELETE',
|
||||
);
|
||||
|
||||
expect(mockQueryRunner.query).toHaveBeenCalledWith(expect.any(String), [
|
||||
'schemaDROP',
|
||||
'tableDELETE',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty result', async () => {
|
||||
mockQueryRunner.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.tableExists(
|
||||
mockQueryRunner,
|
||||
'workspace_test',
|
||||
'users',
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SQL injection protection', () => {
|
||||
it('should prevent SQL injection in schema names', async () => {
|
||||
const maliciousSchema = "workspace'; DROP TABLE users; --";
|
||||
|
||||
await service.createTable(mockQueryRunner, maliciousSchema, 'table');
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).not.toContain('DROP TABLE users');
|
||||
expect(actualCall).toContain('"workspaceDROPTABLEusers"');
|
||||
});
|
||||
|
||||
it('should prevent SQL injection in table names', async () => {
|
||||
const maliciousTable = "users'; DROP DATABASE; --";
|
||||
|
||||
await service.dropTable(mockQueryRunner, 'schema', maliciousTable);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).not.toContain('DROP DATABASE');
|
||||
expect(actualCall).toContain('"usersDROPDATABASE"');
|
||||
});
|
||||
|
||||
it('should prevent SQL injection in column definitions', async () => {
|
||||
const maliciousColumns = [
|
||||
{
|
||||
name: "name'; DROP TABLE",
|
||||
type: 'varchar; EXEC sp_helpdb --',
|
||||
default: "'; DELETE FROM users; --",
|
||||
},
|
||||
];
|
||||
|
||||
await service.createTable(
|
||||
mockQueryRunner,
|
||||
'schema',
|
||||
'table',
|
||||
maliciousColumns,
|
||||
);
|
||||
|
||||
const actualCall = mockQueryRunner.query.mock.calls[0][0];
|
||||
|
||||
expect(actualCall).not.toContain('DROP TABLE');
|
||||
expect(actualCall).not.toContain('EXEC sp_helpdb');
|
||||
expect(actualCall).not.toContain('DELETE FROM users');
|
||||
expect(actualCall).toContain('"nameDROPTABLE"');
|
||||
expect(actualCall).toContain('varcharEXECsp_helpdb');
|
||||
expect(actualCall).toContain('DEFAULT DELETEFROMusers');
|
||||
});
|
||||
});
|
||||
});
|
||||
+55
-145
@@ -1,47 +1,45 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
|
||||
import { buildSqlColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util';
|
||||
import { sanitizeDefaultValue } from 'src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util';
|
||||
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceSchemaColumnManagerService {
|
||||
async addColumn(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
column: WorkspaceSchemaColumnDefinition,
|
||||
): Promise<void> {
|
||||
const columnDef = this.buildColumnDefinition(column);
|
||||
async addColumns({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnDefinitions,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
columnDefinitions: WorkspaceSchemaColumnDefinition[];
|
||||
}): Promise<void> {
|
||||
if (columnDefinitions.length === 0) return;
|
||||
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ADD COLUMN ${columnDef}`;
|
||||
const addColumnClauses = columnDefinitions.map(
|
||||
(column) => `ADD COLUMN ${buildSqlColumnDefinition(column)}`,
|
||||
);
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ${addColumnClauses.join(', ')}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async dropColumn(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeColumnName = removeSqlDDLInjection(columnName);
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" DROP COLUMN IF EXISTS "${safeColumnName}"`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async dropColumns(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnNames: string[],
|
||||
): Promise<void> {
|
||||
async dropColumns({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnNames,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
columnNames: string[];
|
||||
}): Promise<void> {
|
||||
if (columnNames.length === 0) return;
|
||||
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
@@ -56,13 +54,19 @@ export class WorkspaceSchemaColumnManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async renameColumn(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
oldColumnName: string,
|
||||
newColumnName: string,
|
||||
): Promise<void> {
|
||||
async renameColumn({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
oldColumnName,
|
||||
newColumnName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
oldColumnName: string;
|
||||
newColumnName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeOldColumnName = removeSqlDDLInjection(oldColumnName);
|
||||
@@ -72,50 +76,19 @@ export class WorkspaceSchemaColumnManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async alterColumnType(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
newType: string,
|
||||
usingClause?: string,
|
||||
): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeColumnName = removeSqlDDLInjection(columnName);
|
||||
const safeNewType = removeSqlDDLInjection(newType);
|
||||
let sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER COLUMN "${safeColumnName}" TYPE ${safeNewType}`;
|
||||
|
||||
if (usingClause) {
|
||||
sql += ` USING ${usingClause}`;
|
||||
}
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async alterColumnNullability(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
isNullable: boolean,
|
||||
): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeColumnName = removeSqlDDLInjection(columnName);
|
||||
const action = isNullable ? 'DROP NOT NULL' : 'SET NOT NULL';
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER COLUMN "${safeColumnName}" ${action}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async alterColumnDefault(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
defaultValue?: string | number | boolean | null,
|
||||
): Promise<void> {
|
||||
async alterColumnDefault({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnName,
|
||||
defaultValue,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
defaultValue?: string | number | boolean | null;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeColumnName = removeSqlDDLInjection(columnName);
|
||||
@@ -135,67 +108,4 @@ export class WorkspaceSchemaColumnManagerService {
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async columnExists(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): Promise<boolean> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeColumnName = removeSqlDDLInjection(columnName);
|
||||
|
||||
const result = await queryRunner.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_schema = $1 AND table_name = $2 AND column_name = $3
|
||||
)`,
|
||||
[safeSchemaName, safeTableName, safeColumnName],
|
||||
);
|
||||
|
||||
return result[0]?.exists || false;
|
||||
}
|
||||
|
||||
private buildColumnDefinition(
|
||||
column: WorkspaceSchemaColumnDefinition,
|
||||
): string {
|
||||
const safeName = removeSqlDDLInjection(column.name);
|
||||
const parts = [`"${safeName}"`];
|
||||
|
||||
if (column.asExpression) {
|
||||
parts.push(`AS (${column.asExpression})`);
|
||||
if (column.generatedType) {
|
||||
parts.push(column.generatedType);
|
||||
}
|
||||
} else {
|
||||
const safeType = removeSqlDDLInjection(column.type);
|
||||
|
||||
parts.push(column.isArray ? `${safeType}[]` : safeType);
|
||||
|
||||
if (column.isPrimary) {
|
||||
parts.push('PRIMARY KEY');
|
||||
}
|
||||
|
||||
if (column.isNullable === false) {
|
||||
parts.push('NOT NULL');
|
||||
}
|
||||
|
||||
if (column.isUnique) {
|
||||
parts.push('UNIQUE');
|
||||
}
|
||||
|
||||
if (column.default !== undefined) {
|
||||
if (typeof column.default === 'string') {
|
||||
const safeDefault = sanitizeDefaultValue(column.default);
|
||||
|
||||
parts.push(`DEFAULT ${safeDefault}`);
|
||||
} else {
|
||||
parts.push(`DEFAULT ${column.default}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
+211
-195
@@ -1,21 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import {
|
||||
TwentyORMException,
|
||||
TwentyORMExceptionCode,
|
||||
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
|
||||
WorkspaceSchemaManagerException,
|
||||
WorkspaceSchemaManagerExceptionCode,
|
||||
} from 'src/engine/twenty-orm/workspace-schema-manager/exceptions/workspace-schema-manager.exception';
|
||||
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
|
||||
import { buildSqlColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util';
|
||||
import { computePostgresEnumName } from 'src/engine/workspace-manager/workspace-migration-runner/utils/compute-postgres-enum-name.util';
|
||||
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util';
|
||||
|
||||
@Injectable()
|
||||
// TODO: upstream does not guarantee transactionality, implement IF EXISTS or equivalent for idempotency
|
||||
export class WorkspaceSchemaEnumManagerService {
|
||||
async createEnum(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
enumName: string,
|
||||
values: string[],
|
||||
): Promise<void> {
|
||||
async createEnum({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
enumName,
|
||||
values,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
enumName: string;
|
||||
values: string[];
|
||||
}): Promise<void> {
|
||||
if (values.length === 0) {
|
||||
throw new WorkspaceSchemaManagerException(
|
||||
`Cannot create enum with no values`,
|
||||
WorkspaceSchemaManagerExceptionCode.ENUM_OPERATION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
const sanitizedValues = values
|
||||
.map((value) => removeSqlDDLInjection(value.toString()))
|
||||
.map((value) => `'${value}'`)
|
||||
@@ -28,11 +41,15 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async dropEnum(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
enumName: string,
|
||||
): Promise<void> {
|
||||
async dropEnum({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
enumName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
enumName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeEnumName = removeSqlDDLInjection(enumName);
|
||||
const sql = `DROP TYPE IF EXISTS "${safeSchemaName}"."${safeEnumName}"`;
|
||||
@@ -40,12 +57,17 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async renameEnum(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
oldEnumName: string,
|
||||
newEnumName: string,
|
||||
): Promise<void> {
|
||||
async renameEnum({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
oldEnumName,
|
||||
newEnumName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
oldEnumName: string;
|
||||
newEnumName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeOldEnumName = removeSqlDDLInjection(oldEnumName);
|
||||
const safeNewEnumName = removeSqlDDLInjection(newEnumName);
|
||||
@@ -54,14 +76,21 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async addEnumValue(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
enumName: string,
|
||||
value: string,
|
||||
beforeValue?: string,
|
||||
afterValue?: string,
|
||||
): Promise<void> {
|
||||
async addEnumValue({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
enumName,
|
||||
value,
|
||||
beforeValue,
|
||||
afterValue,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
enumName: string;
|
||||
value: string;
|
||||
beforeValue?: string;
|
||||
afterValue?: string;
|
||||
}): Promise<void> {
|
||||
const sanitizedValue = removeSqlDDLInjection(value);
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeEnumName = removeSqlDDLInjection(enumName);
|
||||
@@ -80,13 +109,19 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async renameEnumValue(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
enumName: string,
|
||||
oldValue: string,
|
||||
newValue: string,
|
||||
): Promise<void> {
|
||||
async renameEnumValue({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
enumName,
|
||||
oldValue,
|
||||
newValue,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
enumName: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
}): Promise<void> {
|
||||
const sanitizedOldValue = removeSqlDDLInjection(oldValue);
|
||||
const sanitizedNewValue = removeSqlDDLInjection(newValue);
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
@@ -96,145 +131,99 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async enumExists(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
enumName: string,
|
||||
): Promise<boolean> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeEnumName = removeSqlDDLInjection(enumName);
|
||||
|
||||
const result = await queryRunner.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM pg_type t
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE n.nspname = $1 AND t.typname = $2 AND t.typtype = 'e'
|
||||
)`,
|
||||
[safeSchemaName, safeEnumName],
|
||||
);
|
||||
|
||||
return result[0]?.exists || false;
|
||||
}
|
||||
|
||||
// TODO: Not sure if we want to use that query or prefer to rely on the "from" values.
|
||||
async getEnumValues(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
enumName: string,
|
||||
): Promise<string[]> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeEnumName = removeSqlDDLInjection(enumName);
|
||||
|
||||
const result = await queryRunner.query(
|
||||
`SELECT e.enumlabel as value
|
||||
FROM pg_type t
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
JOIN pg_enum e ON t.oid = e.enumtypid
|
||||
WHERE n.nspname = $1 AND t.typname = $2
|
||||
ORDER BY e.enumsortorder`,
|
||||
[safeSchemaName, safeEnumName],
|
||||
);
|
||||
|
||||
return result.map((row: { value: string }) => row.value);
|
||||
}
|
||||
|
||||
async getEnumNameForColumn(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): Promise<string | null> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeColumnName = removeSqlDDLInjection(columnName);
|
||||
|
||||
const result = await queryRunner.query(
|
||||
`SELECT udt_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1 AND table_name = $2 AND column_name = $3`,
|
||||
[safeSchemaName, safeTableName, safeColumnName],
|
||||
);
|
||||
|
||||
if (!result[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enumTypeName =
|
||||
result[0].data_type === 'ARRAY'
|
||||
? result[0].udt_name.replace(/^_/, '')
|
||||
: result[0].udt_name;
|
||||
|
||||
return enumTypeName;
|
||||
}
|
||||
|
||||
async alterEnumValues(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
newValues: string[],
|
||||
valueMapping?: Record<string, string>,
|
||||
): Promise<void> {
|
||||
// TODO: optimize this to not create a temp enum and column if not necessary (e.g. using ADD VALUE)
|
||||
async alterEnumValues({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnDefinition,
|
||||
oldToNewEnumOptionMap,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
columnDefinition: WorkspaceSchemaColumnDefinition;
|
||||
oldToNewEnumOptionMap?: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
const isTransactionAlreadyActive = queryRunner.isTransactionActive;
|
||||
|
||||
if (!isTransactionAlreadyActive) {
|
||||
await queryRunner.startTransaction();
|
||||
}
|
||||
|
||||
if (
|
||||
!columnDefinition.enumValues ||
|
||||
columnDefinition.enumValues.length === 0
|
||||
) {
|
||||
throw new WorkspaceSchemaManagerException(
|
||||
`Cannot alter enum values for column ${columnDefinition.name} because it has no enum values`,
|
||||
WorkspaceSchemaManagerExceptionCode.ENUM_OPERATION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const oldEnumName = await this.getEnumNameForColumn(
|
||||
const columnName = columnDefinition.name;
|
||||
|
||||
const enumName = computePostgresEnumName({
|
||||
tableName,
|
||||
columnName,
|
||||
});
|
||||
|
||||
const oldEnumName = `${enumName}_old`;
|
||||
|
||||
await this.renameEnum({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
oldEnumName: enumName,
|
||||
newEnumName: oldEnumName,
|
||||
});
|
||||
|
||||
await this.createEnum({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
enumName,
|
||||
values: columnDefinition.enumValues,
|
||||
});
|
||||
|
||||
const oldColumnName = `${columnName}_old`;
|
||||
|
||||
await this.renameColumn({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnName,
|
||||
);
|
||||
oldColumnName: columnName,
|
||||
newColumnName: oldColumnName,
|
||||
});
|
||||
|
||||
if (!oldEnumName) {
|
||||
throw new TwentyORMException(
|
||||
`Enum type not found for column ${columnName}`,
|
||||
TwentyORMExceptionCode.ENUM_TYPE_NAME_NOT_FOUND,
|
||||
);
|
||||
await this.createColumnUsingEnum({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnDefinition,
|
||||
enumTypeName: enumName,
|
||||
});
|
||||
|
||||
if (
|
||||
oldToNewEnumOptionMap &&
|
||||
Object.keys(oldToNewEnumOptionMap).length > 0
|
||||
) {
|
||||
await this.migrateEnumData({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
oldColumnName,
|
||||
newColumnName: columnName,
|
||||
oldToNewEnumOptionMap,
|
||||
});
|
||||
}
|
||||
|
||||
const tempEnumName = `${oldEnumName}_temp`;
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeColumnName = removeSqlDDLInjection(columnName);
|
||||
const newEnumName = `${safeTableName}_${safeColumnName}_enum`;
|
||||
const oldColumnName = `old_${safeColumnName}`;
|
||||
|
||||
// Rename existing column and enum
|
||||
await this.renameColumn(
|
||||
await this.dropEnum({ queryRunner, schemaName, enumName: oldEnumName });
|
||||
await this.dropColumn({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnName,
|
||||
oldColumnName,
|
||||
);
|
||||
await this.renameEnum(queryRunner, schemaName, oldEnumName, tempEnumName);
|
||||
|
||||
// Create new enum and column
|
||||
await this.createEnum(queryRunner, schemaName, newEnumName, newValues);
|
||||
await this.addEnumColumn(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnName,
|
||||
newEnumName,
|
||||
);
|
||||
|
||||
// Migrate data
|
||||
await this.migrateEnumData(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
oldColumnName,
|
||||
columnName,
|
||||
valueMapping || {},
|
||||
);
|
||||
|
||||
// Clean up
|
||||
await this.dropColumn(queryRunner, schemaName, tableName, oldColumnName);
|
||||
await this.dropEnum(queryRunner, schemaName, tempEnumName);
|
||||
columnName: oldColumnName,
|
||||
});
|
||||
|
||||
if (!isTransactionAlreadyActive) {
|
||||
await queryRunner.commitTransaction();
|
||||
@@ -247,13 +236,19 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
private async renameColumn(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
oldColumnName: string,
|
||||
newColumnName: string,
|
||||
): Promise<void> {
|
||||
private async renameColumn({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
oldColumnName,
|
||||
newColumnName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
oldColumnName: string;
|
||||
newColumnName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeOldColumnName = removeSqlDDLInjection(oldColumnName);
|
||||
@@ -263,28 +258,41 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
private async addEnumColumn(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
enumTypeName: string,
|
||||
): Promise<void> {
|
||||
private async createColumnUsingEnum({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnDefinition,
|
||||
enumTypeName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
columnDefinition: WorkspaceSchemaColumnDefinition;
|
||||
enumTypeName: string;
|
||||
}): Promise<void> {
|
||||
const columnDef = buildSqlColumnDefinition({
|
||||
...columnDefinition,
|
||||
type: enumTypeName,
|
||||
});
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeColumnName = removeSqlDDLInjection(columnName);
|
||||
const safeEnumTypeName = removeSqlDDLInjection(enumTypeName);
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ADD COLUMN "${safeColumnName}" "${safeSchemaName}"."${safeEnumTypeName}"`;
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ADD COLUMN ${columnDef}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
private async dropColumn(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): Promise<void> {
|
||||
private async dropColumn({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeColumnName = removeSqlDDLInjection(columnName);
|
||||
@@ -293,23 +301,31 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
private async migrateEnumData(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
oldColumnName: string,
|
||||
newColumnName: string,
|
||||
valueMapping: Record<string, string>,
|
||||
): Promise<void> {
|
||||
// TODO: explore USING clause to avoid the need for this function
|
||||
private async migrateEnumData({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
oldColumnName,
|
||||
newColumnName,
|
||||
oldToNewEnumOptionMap,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
oldColumnName: string;
|
||||
newColumnName: string;
|
||||
oldToNewEnumOptionMap: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeOldColumnName = removeSqlDDLInjection(oldColumnName);
|
||||
const safeNewColumnName = removeSqlDDLInjection(newColumnName);
|
||||
|
||||
const caseStatements = Object.entries(valueMapping)
|
||||
const caseStatements = Object.entries(oldToNewEnumOptionMap)
|
||||
.map(
|
||||
([oldVal, newVal]) =>
|
||||
`WHEN '${removeSqlDDLInjection(oldVal)}' THEN '${removeSqlDDLInjection(newVal)}'`,
|
||||
([oldEnumOption, newEnumOption]) =>
|
||||
`WHEN '${removeSqlDDLInjection(oldEnumOption)}' THEN '${removeSqlDDLInjection(newEnumOption)}'`,
|
||||
)
|
||||
.join(' ');
|
||||
|
||||
|
||||
+136
-76
@@ -1,18 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { type WorkspaceSchemaForeignKeyDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-foreign-key-definition.type';
|
||||
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceSchemaForeignKeyManagerService {
|
||||
async createForeignKey(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
foreignKey: WorkspaceSchemaForeignKeyDefinition,
|
||||
): Promise<void> {
|
||||
async createForeignKey({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKey,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
foreignKey: WorkspaceSchemaForeignKeyDefinition;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeForeignKeyName = removeSqlDDLInjection(foreignKey.name);
|
||||
@@ -40,12 +42,17 @@ export class WorkspaceSchemaForeignKeyManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async dropForeignKey(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
foreignKeyName: string,
|
||||
): Promise<void> {
|
||||
async dropForeignKey({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKeyName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
foreignKeyName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
|
||||
@@ -54,35 +61,45 @@ export class WorkspaceSchemaForeignKeyManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async dropForeignKeyByColumn(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): Promise<void> {
|
||||
const foreignKeyName = await this.getForeignKeyNameByColumn(
|
||||
async dropForeignKeyByColumn({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
}): Promise<void> {
|
||||
const foreignKeyName = await this.getForeignKeyNameByColumn({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnName,
|
||||
);
|
||||
});
|
||||
|
||||
if (foreignKeyName) {
|
||||
await this.dropForeignKey(
|
||||
await this.dropForeignKey({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKeyName,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async foreignKeyExists(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
foreignKeyName: string,
|
||||
): Promise<boolean> {
|
||||
async foreignKeyExists({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKeyName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
foreignKeyName: string;
|
||||
}): Promise<boolean> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
|
||||
@@ -101,12 +118,17 @@ export class WorkspaceSchemaForeignKeyManagerService {
|
||||
return result[0]?.exists || false;
|
||||
}
|
||||
|
||||
async getForeignKeyNameByColumn(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): Promise<string | null> {
|
||||
async getForeignKeyNameByColumn({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
}): Promise<string | null> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeColumnName = removeSqlDDLInjection(columnName);
|
||||
@@ -127,11 +149,15 @@ export class WorkspaceSchemaForeignKeyManagerService {
|
||||
return result[0]?.constraint_name || null;
|
||||
}
|
||||
|
||||
async getForeignKeysForTable(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
): Promise<
|
||||
async getForeignKeysForTable({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
}): Promise<
|
||||
Array<{
|
||||
constraint_name: string;
|
||||
column_name: string;
|
||||
@@ -171,15 +197,23 @@ export class WorkspaceSchemaForeignKeyManagerService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async createForeignKeyFromColumn(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
referencedTableName: string,
|
||||
async createForeignKeyFromColumn({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnName,
|
||||
referencedTableName,
|
||||
referencedColumnName = 'id',
|
||||
onDelete?: WorkspaceSchemaForeignKeyDefinition['onDelete'],
|
||||
): Promise<void> {
|
||||
onDelete,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
referencedTableName: string;
|
||||
referencedColumnName?: string;
|
||||
onDelete?: WorkspaceSchemaForeignKeyDefinition['onDelete'];
|
||||
}): Promise<void> {
|
||||
const foreignKeyName = queryRunner.connection.namingStrategy.foreignKeyName(
|
||||
tableName,
|
||||
[columnName],
|
||||
@@ -195,16 +229,27 @@ export class WorkspaceSchemaForeignKeyManagerService {
|
||||
onDelete,
|
||||
};
|
||||
|
||||
await this.createForeignKey(queryRunner, schemaName, tableName, foreignKey);
|
||||
await this.createForeignKey({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKey,
|
||||
});
|
||||
}
|
||||
|
||||
async renameForeignKey(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
oldConstraintName: string,
|
||||
newConstraintName: string,
|
||||
): Promise<void> {
|
||||
async renameForeignKey({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
oldConstraintName,
|
||||
newConstraintName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
oldConstraintName: string;
|
||||
newConstraintName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeOldConstraintName = removeSqlDDLInjection(oldConstraintName);
|
||||
@@ -214,12 +259,17 @@ export class WorkspaceSchemaForeignKeyManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async validateForeignKey(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
foreignKeyName: string,
|
||||
): Promise<void> {
|
||||
async validateForeignKey({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKeyName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
foreignKeyName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
|
||||
@@ -228,12 +278,17 @@ export class WorkspaceSchemaForeignKeyManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async setForeignKeyNotDeferrable(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
foreignKeyName: string,
|
||||
): Promise<void> {
|
||||
async setForeignKeyNotDeferrable({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKeyName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
foreignKeyName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
|
||||
@@ -242,12 +297,17 @@ export class WorkspaceSchemaForeignKeyManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async setForeignKeyDeferrable(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
foreignKeyName: string,
|
||||
): Promise<void> {
|
||||
async setForeignKeyDeferrable({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
foreignKeyName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
foreignKeyName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
|
||||
|
||||
+56
-103
@@ -1,18 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { type WorkspaceSchemaIndexDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-index-definition.type';
|
||||
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceSchemaIndexManagerService {
|
||||
async createIndex(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
index: WorkspaceSchemaIndexDefinition,
|
||||
): Promise<void> {
|
||||
async createIndex({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
index,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
index: WorkspaceSchemaIndexDefinition;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
@@ -24,7 +26,7 @@ export class WorkspaceSchemaIndexManagerService {
|
||||
const isUnique = index.isUnique ? 'UNIQUE' : '';
|
||||
const indexType =
|
||||
index.type && index.type !== 'BTREE' ? `USING ${index.type}` : '';
|
||||
const whereClause = index.where ? `WHERE ${index.where}` : '';
|
||||
const whereClause = index.where ? `WHERE ${index.where}` : ''; // TODO: to sanitize
|
||||
const includeClause = index.include?.length
|
||||
? `INCLUDE (${index.include
|
||||
.map((col) => `"${removeSqlDDLInjection(col)}"`)
|
||||
@@ -57,11 +59,15 @@ export class WorkspaceSchemaIndexManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
async dropIndex(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
indexName: string,
|
||||
): Promise<void> {
|
||||
async dropIndex({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
indexName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
indexName: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeIndexName = removeSqlDDLInjection(indexName);
|
||||
@@ -77,12 +83,17 @@ export class WorkspaceSchemaIndexManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
async renameIndex(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
oldIndexName: string,
|
||||
newIndexName: string,
|
||||
): Promise<void> {
|
||||
async renameIndex({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
oldIndexName,
|
||||
newIndexName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
oldIndexName: string;
|
||||
newIndexName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeOldIndexName = removeSqlDDLInjection(oldIndexName);
|
||||
const safeNewIndexName = removeSqlDDLInjection(newIndexName);
|
||||
@@ -91,82 +102,19 @@ export class WorkspaceSchemaIndexManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async indexExists(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
indexName: string,
|
||||
): Promise<boolean> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeIndexName = removeSqlDDLInjection(indexName);
|
||||
|
||||
const result = await queryRunner.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM pg_indexes
|
||||
WHERE schemaname = $1 AND indexname = $2
|
||||
)`,
|
||||
[safeSchemaName, safeIndexName],
|
||||
);
|
||||
|
||||
return result[0]?.exists || false;
|
||||
}
|
||||
|
||||
async getIndexesForTable(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
): Promise<Array<{ indexname: string; indexdef: string }>> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
|
||||
const result = await queryRunner.query(
|
||||
`SELECT indexname, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = $1 AND tablename = $2`,
|
||||
[safeSchemaName, safeTableName],
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async createPrimaryKey(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
constraintName: string,
|
||||
columnNames: string[],
|
||||
): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeConstraintName = removeSqlDDLInjection(constraintName);
|
||||
const quotedColumns = columnNames
|
||||
.map((col) => `"${removeSqlDDLInjection(col)}"`)
|
||||
.join(', ');
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ADD CONSTRAINT "${safeConstraintName}" PRIMARY KEY (${quotedColumns})`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async dropPrimaryKey(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
constraintName: string,
|
||||
): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeConstraintName = removeSqlDDLInjection(constraintName);
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" DROP CONSTRAINT IF EXISTS "${safeConstraintName}"`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async createUniqueConstraint(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
constraintName: string,
|
||||
columnNames: string[],
|
||||
): Promise<void> {
|
||||
async createUniqueConstraint({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
constraintName,
|
||||
columnNames,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
constraintName: string;
|
||||
columnNames: string[];
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeConstraintName = removeSqlDDLInjection(constraintName);
|
||||
@@ -178,12 +126,17 @@ export class WorkspaceSchemaIndexManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async dropUniqueConstraint(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
constraintName: string,
|
||||
): Promise<void> {
|
||||
async dropUniqueConstraint({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
constraintName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
constraintName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeConstraintName = removeSqlDDLInjection(constraintName);
|
||||
|
||||
+42
-83
@@ -1,76 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { sanitizeDefaultValue } from 'src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util';
|
||||
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
|
||||
import { buildSqlColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util';
|
||||
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceSchemaTableManagerService {
|
||||
async createTable(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
isNullable?: boolean;
|
||||
default?: string | number | boolean | null;
|
||||
isPrimary?: boolean;
|
||||
isUnique?: boolean;
|
||||
isArray?: boolean;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
const columnDefinitions =
|
||||
columns?.map((column) => {
|
||||
const safeName = removeSqlDDLInjection(column.name);
|
||||
const safeType = removeSqlDDLInjection(column.type);
|
||||
const parts = [
|
||||
`"${safeName}" ${column.isArray ? `${safeType}[]` : safeType}`,
|
||||
];
|
||||
|
||||
if (column.isPrimary) {
|
||||
parts.push('PRIMARY KEY');
|
||||
}
|
||||
|
||||
if (column.isNullable === false) {
|
||||
parts.push('NOT NULL');
|
||||
}
|
||||
|
||||
if (column.isUnique) {
|
||||
parts.push('UNIQUE');
|
||||
}
|
||||
|
||||
if (column.default !== undefined) {
|
||||
if (typeof column.default === 'string') {
|
||||
const safeDefault = sanitizeDefaultValue(column.default);
|
||||
|
||||
parts.push(`DEFAULT ${safeDefault}`);
|
||||
} else {
|
||||
parts.push(`DEFAULT ${column.default}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}) || [];
|
||||
async createTable({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnDefinitions,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
columnDefinitions?: WorkspaceSchemaColumnDefinition[];
|
||||
}): Promise<void> {
|
||||
const sqlColumnDefinitions =
|
||||
columnDefinitions?.map((columnDefinition) =>
|
||||
buildSqlColumnDefinition(columnDefinition),
|
||||
) || [];
|
||||
|
||||
// Add default columns if no columns specified
|
||||
if (columnDefinitions.length === 0) {
|
||||
columnDefinitions.push('"id" uuid PRIMARY KEY DEFAULT gen_random_uuid()');
|
||||
if (sqlColumnDefinitions.length === 0) {
|
||||
sqlColumnDefinitions.push(
|
||||
'"id" uuid PRIMARY KEY DEFAULT gen_random_uuid()',
|
||||
);
|
||||
}
|
||||
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const sql = `CREATE TABLE IF NOT EXISTS "${safeSchemaName}"."${safeTableName}" (${columnDefinitions.join(', ')})`;
|
||||
const sql = `CREATE TABLE IF NOT EXISTS "${safeSchemaName}"."${safeTableName}" (${sqlColumnDefinitions.join(', ')})`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async dropTable(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
): Promise<void> {
|
||||
async dropTable({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const sql = `DROP TABLE IF EXISTS "${safeSchemaName}"."${safeTableName}"`;
|
||||
@@ -78,12 +51,17 @@ export class WorkspaceSchemaTableManagerService {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async renameTable(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
oldTableName: string,
|
||||
newTableName: string,
|
||||
): Promise<void> {
|
||||
async renameTable({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
oldTableName,
|
||||
newTableName,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
oldTableName: string;
|
||||
newTableName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeOldTableName = removeSqlDDLInjection(oldTableName);
|
||||
const safeNewTableName = removeSqlDDLInjection(newTableName);
|
||||
@@ -91,23 +69,4 @@ export class WorkspaceSchemaTableManagerService {
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
async tableExists(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
): Promise<boolean> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
|
||||
const result = await queryRunner.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = $1 AND table_name = $2
|
||||
)`,
|
||||
[safeSchemaName, safeTableName],
|
||||
);
|
||||
|
||||
return result[0]?.exists || false;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -8,4 +8,5 @@ export type WorkspaceSchemaColumnDefinition = {
|
||||
isArray?: boolean;
|
||||
asExpression?: string;
|
||||
generatedType?: 'STORED' | 'VIRTUAL';
|
||||
enumValues?: string[];
|
||||
};
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
|
||||
import { sanitizeDefaultValue } from 'src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util';
|
||||
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util';
|
||||
|
||||
export const buildSqlColumnDefinition = (
|
||||
column: WorkspaceSchemaColumnDefinition,
|
||||
): string => {
|
||||
const safeName = removeSqlDDLInjection(column.name);
|
||||
const parts = [`"${safeName}"`];
|
||||
|
||||
if (column.asExpression) {
|
||||
parts.push(`AS (${column.asExpression})`); // TODO: to sanitize
|
||||
if (column.generatedType) {
|
||||
parts.push(column.generatedType);
|
||||
}
|
||||
} else {
|
||||
const safeType = removeSqlDDLInjection(column.type);
|
||||
|
||||
parts.push(column.isArray ? `${safeType}[]` : safeType);
|
||||
|
||||
if (column.isPrimary) {
|
||||
parts.push('PRIMARY KEY');
|
||||
}
|
||||
|
||||
if (column.isNullable === false) {
|
||||
parts.push('NOT NULL');
|
||||
}
|
||||
|
||||
if (column.isUnique) {
|
||||
parts.push('UNIQUE');
|
||||
}
|
||||
|
||||
if (column.default !== undefined) {
|
||||
const safeDefault = sanitizeDefaultValue(column.default);
|
||||
|
||||
parts.push(`DEFAULT ${safeDefault}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
+14
-4
@@ -1,6 +1,12 @@
|
||||
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util';
|
||||
|
||||
export const sanitizeDefaultValue = (defaultValue: string): string => {
|
||||
export const sanitizeDefaultValue = (
|
||||
defaultValue: string | number | boolean | null,
|
||||
): string | number | boolean => {
|
||||
if (defaultValue === null) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
const allowedFunctions = [
|
||||
'gen_random_uuid()',
|
||||
'uuid_generate_v4()',
|
||||
@@ -12,9 +18,13 @@ export const sanitizeDefaultValue = (defaultValue: string): string => {
|
||||
'localtimestamp',
|
||||
];
|
||||
|
||||
if (allowedFunctions.includes(defaultValue.toLowerCase())) {
|
||||
return defaultValue;
|
||||
if (typeof defaultValue === 'string') {
|
||||
if (allowedFunctions.includes(defaultValue.toLowerCase())) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return removeSqlDDLInjection(defaultValue);
|
||||
}
|
||||
|
||||
return removeSqlDDLInjection(defaultValue);
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
+2
@@ -1,5 +1,6 @@
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type';
|
||||
import { type WorkspaceMigrationActionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-action-common-v2';
|
||||
|
||||
export type WorkspaceMigrationActionRunnerArgs<
|
||||
@@ -7,4 +8,5 @@ export type WorkspaceMigrationActionRunnerArgs<
|
||||
> = {
|
||||
queryRunner: QueryRunner;
|
||||
action: T;
|
||||
flatObjectMetadataMaps: FlatObjectMetadataMaps;
|
||||
};
|
||||
|
||||
+9
-8
@@ -18,53 +18,54 @@ export class WorkspaceMetadataMigrationRunnerService {
|
||||
runWorkspaceMetadataMigration = async ({
|
||||
action,
|
||||
queryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
}: WorkspaceMigrationRunnerArgs) => {
|
||||
switch (action.type) {
|
||||
case 'delete_object': {
|
||||
await this.workspaceMetadataObjectMigrationRunnerService.runDeleteObjectMetadataMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'create_object': {
|
||||
await this.workspaceMetadataObjectMigrationRunnerService.runCreateObjectMetadataMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'update_object': {
|
||||
await this.workspaceMetadataObjectMigrationRunnerService.runUpdateObjectMetadataMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'create_field': {
|
||||
await this.workspaceMetadataFieldMigrationRunnerService.runCreateFieldMetadataMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'update_field': {
|
||||
await this.workspaceMetadataFieldMigrationRunnerService.runUpdateFieldMetadataMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'delete_field': {
|
||||
await this.workspaceMetadataFieldMigrationRunnerService.runDeleteFieldMetadataMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'create_index': {
|
||||
await this.workspaceMetadataIndexMigrationRunnerService.runCreateIndexMetadataMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'delete_index': {
|
||||
await this.workspaceMetadataIndexMigrationRunnerService.runDeleteIndexMetadataMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
+2
@@ -38,6 +38,7 @@ export class WorkspaceMetadataObjectActionRunnerService
|
||||
runCreateObjectMetadataMigration = async ({
|
||||
action: { flatObjectMetadataWithoutFields, createFieldActions },
|
||||
queryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
}: WorkspaceMigrationActionRunnerArgs<CreateObjectAction>) => {
|
||||
const objectMetadataRepository =
|
||||
queryRunner.manager.getRepository<ObjectMetadataEntity>(
|
||||
@@ -59,6 +60,7 @@ export class WorkspaceMetadataObjectActionRunnerService
|
||||
{
|
||||
action: createFieldAction,
|
||||
queryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+2
@@ -6,6 +6,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
|
||||
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
|
||||
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
|
||||
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
|
||||
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
|
||||
import { WorkspaceMetadataFieldActionRunnerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-metadata-migration-runner/workspace-metadata-field-action-runner.service';
|
||||
import { WorkspaceMetadataIndexActionRunnerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-metadata-migration-runner/workspace-metadata-index-action-runner.service';
|
||||
import { WorkspaceMetadataMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-metadata-migration-runner/workspace-metadata-migration-runner-service';
|
||||
@@ -24,6 +25,7 @@ import { WorkspaceSchemaObjectActionRunnerService } from 'src/engine/workspace-m
|
||||
WorkspaceMetadataVersionModule,
|
||||
WorkspacePermissionsCacheModule,
|
||||
WorkspaceMetadataCacheModule,
|
||||
WorkspaceSchemaManagerModule,
|
||||
],
|
||||
providers: [
|
||||
WorkspaceMetadataObjectActionRunnerService,
|
||||
|
||||
+729
@@ -0,0 +1,729 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||
|
||||
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
|
||||
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
|
||||
import { type WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
|
||||
import { WorkspaceSchemaFieldActionRunnerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/workspace-schema-field-action-runner.service';
|
||||
|
||||
describe('WorkspaceSchemaFieldActionRunner', () => {
|
||||
let service: WorkspaceSchemaFieldActionRunnerService;
|
||||
let mockSchemaManagerService: jest.Mocked<WorkspaceSchemaManagerService>;
|
||||
let mockQueryRunner: jest.Mocked<QueryRunner>;
|
||||
|
||||
const mockWorkspaceId = '20202020-1c25-4d02-bf25-6aeccf7ea419';
|
||||
const mockObjectMetadataId = '20202020-1c25-4d02-bf25-6aeccf7ea418';
|
||||
const mockFieldMetadataId = '20202020-1c25-4d02-bf25-6aeccf7ea417';
|
||||
|
||||
const createMockFlatObjectMetadataMaps = (
|
||||
objectMetadata: any,
|
||||
fieldMetadata: any,
|
||||
) => ({
|
||||
byId: {
|
||||
[mockObjectMetadataId]: {
|
||||
...objectMetadata,
|
||||
fieldsById: {
|
||||
[mockFieldMetadataId]: fieldMetadata,
|
||||
},
|
||||
},
|
||||
},
|
||||
idByNameSingular: {
|
||||
[objectMetadata.nameSingular]: mockObjectMetadataId,
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockSchemaManagerService = {
|
||||
columnManager: {
|
||||
dropColumns: jest.fn(),
|
||||
addColumns: jest.fn(),
|
||||
renameColumn: jest.fn(),
|
||||
alterColumnDefault: jest.fn(),
|
||||
},
|
||||
enumManager: {
|
||||
createEnum: jest.fn(),
|
||||
dropEnum: jest.fn(),
|
||||
renameEnum: jest.fn(),
|
||||
alterEnumValues: jest.fn(),
|
||||
},
|
||||
tableManager: {
|
||||
createTable: jest.fn(),
|
||||
dropTable: jest.fn(),
|
||||
renameTable: jest.fn(),
|
||||
},
|
||||
} as any;
|
||||
|
||||
mockQueryRunner = {} as any;
|
||||
|
||||
service = new WorkspaceSchemaFieldActionRunnerService(
|
||||
mockSchemaManagerService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('DELETE Field Migration', () => {
|
||||
it('should properly delete composite ADDRESS fields with all sub-columns', async () => {
|
||||
const addressField = getFlatFieldMetadataMock({
|
||||
id: mockFieldMetadataId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
name: 'homeAddress',
|
||||
uniqueIdentifier: 'homeAddress',
|
||||
});
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'person',
|
||||
uniqueIdentifier: 'person',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
addressField,
|
||||
);
|
||||
|
||||
await service.runDeleteFieldSchemaMigration({
|
||||
action: {
|
||||
type: 'delete_field',
|
||||
fieldMetadataId: mockFieldMetadataId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// All 8 ADDRESS composite columns must be dropped
|
||||
expect(
|
||||
mockSchemaManagerService.columnManager.dropColumns,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_person',
|
||||
columnNames: [
|
||||
'homeAddressAddressStreet1',
|
||||
'homeAddressAddressStreet2',
|
||||
'homeAddressAddressCity',
|
||||
'homeAddressAddressPostcode',
|
||||
'homeAddressAddressState',
|
||||
'homeAddressAddressCountry',
|
||||
'homeAddressAddressLat',
|
||||
'homeAddressAddressLng',
|
||||
],
|
||||
});
|
||||
|
||||
// No enum operations should be performed for ADDRESS fields
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.dropEnum,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should properly delete SELECT fields with enum cleanup', async () => {
|
||||
const selectField = getFlatFieldMetadataMock({
|
||||
id: mockFieldMetadataId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'status',
|
||||
options: [
|
||||
{
|
||||
id: '1',
|
||||
value: 'ACTIVE',
|
||||
label: 'Active',
|
||||
color: 'green',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
value: 'INACTIVE',
|
||||
label: 'Inactive',
|
||||
color: 'red',
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
uniqueIdentifier: 'status',
|
||||
});
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'person',
|
||||
uniqueIdentifier: 'person',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
selectField,
|
||||
);
|
||||
|
||||
await service.runDeleteFieldSchemaMigration({
|
||||
action: {
|
||||
type: 'delete_field',
|
||||
fieldMetadataId: mockFieldMetadataId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// Column must be dropped first
|
||||
expect(
|
||||
mockSchemaManagerService.columnManager.dropColumns,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_person',
|
||||
columnNames: ['status'],
|
||||
});
|
||||
|
||||
// Enum type must be properly cleaned up
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.dropEnum,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
enumName: '_person_status_enum',
|
||||
});
|
||||
});
|
||||
|
||||
it('should properly delete RELATION fields with foreign key cleanup', async () => {
|
||||
const relationField = getFlatFieldMetadataMock({
|
||||
id: mockFieldMetadataId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.RELATION,
|
||||
name: 'company',
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
joinColumnName: 'companyId',
|
||||
},
|
||||
uniqueIdentifier: 'company',
|
||||
});
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'person',
|
||||
uniqueIdentifier: 'person',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
relationField,
|
||||
);
|
||||
|
||||
await service.runDeleteFieldSchemaMigration({
|
||||
action: {
|
||||
type: 'delete_field',
|
||||
fieldMetadataId: mockFieldMetadataId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// Foreign key column must be dropped
|
||||
expect(
|
||||
mockSchemaManagerService.columnManager.dropColumns,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_person',
|
||||
columnNames: ['companyId'],
|
||||
});
|
||||
|
||||
// No enum operations for relation fields
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.dropEnum,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CREATE Field Migration', () => {
|
||||
it('should create SELECT fields with proper enum-first ordering', async () => {
|
||||
const selectField = getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'priority',
|
||||
options: [
|
||||
{ id: '1', value: 'HIGH', label: 'High', color: 'red', position: 0 },
|
||||
{ id: '2', value: 'LOW', label: 'Low', color: 'blue', position: 1 },
|
||||
],
|
||||
uniqueIdentifier: 'priority',
|
||||
});
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'task',
|
||||
uniqueIdentifier: 'task',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
selectField,
|
||||
);
|
||||
|
||||
await service.runCreateFieldSchemaMigration({
|
||||
action: {
|
||||
type: 'create_field',
|
||||
flatFieldMetadata: selectField,
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// Enum must be created BEFORE column that depends on it
|
||||
const enumCreateMock = mockSchemaManagerService.enumManager
|
||||
.createEnum as unknown as jest.Mock;
|
||||
const addColumnsMock = mockSchemaManagerService.columnManager
|
||||
.addColumns as unknown as jest.Mock;
|
||||
|
||||
const allCalls = [
|
||||
...enumCreateMock.mock.calls.map((call: unknown[]) => ({
|
||||
type: 'enum',
|
||||
call,
|
||||
})),
|
||||
...addColumnsMock.mock.calls.map((call: unknown[]) => ({
|
||||
type: 'column',
|
||||
call,
|
||||
})),
|
||||
];
|
||||
|
||||
expect(allCalls).toHaveLength(2);
|
||||
expect(allCalls[0].type).toBe('enum');
|
||||
expect(allCalls[1].type).toBe('column');
|
||||
|
||||
// Enum creation with correct parameters
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.createEnum,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
enumName: '_task_priority_enum',
|
||||
values: ['HIGH', 'LOW'],
|
||||
});
|
||||
|
||||
// Column creation with enum reference
|
||||
expect(
|
||||
mockSchemaManagerService.columnManager.addColumns,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_task',
|
||||
columnDefinitions: [
|
||||
{
|
||||
name: 'priority',
|
||||
type: '_task_priority_enum',
|
||||
isNullable: true,
|
||||
isArray: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
enumValues: ['HIGH', 'LOW'],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create CURRENCY composite fields with proper column generation', async () => {
|
||||
const currencyField = getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.CURRENCY,
|
||||
name: 'salary',
|
||||
defaultValue: {
|
||||
amountMicros: '5000000000',
|
||||
currencyCode: 'USD',
|
||||
},
|
||||
uniqueIdentifier: 'salary',
|
||||
});
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'employee',
|
||||
uniqueIdentifier: 'employee',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
currencyField,
|
||||
);
|
||||
|
||||
await service.runCreateFieldSchemaMigration({
|
||||
action: {
|
||||
type: 'create_field',
|
||||
flatFieldMetadata: currencyField,
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// No enum operations for composite fields
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.createEnum,
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
// Both composite columns must be created
|
||||
expect(
|
||||
mockSchemaManagerService.columnManager.addColumns,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_employee',
|
||||
columnDefinitions: [
|
||||
{
|
||||
name: 'salaryAmountMicros',
|
||||
type: 'numeric',
|
||||
isNullable: true,
|
||||
isUnique: false,
|
||||
default: '5000000000',
|
||||
isArray: false,
|
||||
},
|
||||
{
|
||||
name: 'salaryCurrencyCode',
|
||||
type: 'text',
|
||||
isNullable: true,
|
||||
isUnique: false,
|
||||
default: 'USD',
|
||||
isArray: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPDATE Field Migration', () => {
|
||||
it('should handle field name updates with composite field column renaming', async () => {
|
||||
const addressField = getFlatFieldMetadataMock({
|
||||
id: mockFieldMetadataId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
name: 'oldAddress',
|
||||
uniqueIdentifier: 'oldAddress',
|
||||
});
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'company',
|
||||
uniqueIdentifier: 'company',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
addressField,
|
||||
);
|
||||
|
||||
await service.runUpdateFieldSchemaMigration({
|
||||
action: {
|
||||
type: 'update_field',
|
||||
workspaceId: mockWorkspaceId,
|
||||
fieldMetadataId: mockFieldMetadataId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
updates: [
|
||||
{
|
||||
property: 'name',
|
||||
from: 'oldAddress',
|
||||
to: 'newAddress',
|
||||
},
|
||||
],
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// All 8 ADDRESS composite columns must be renamed
|
||||
const expectedRenameCalls = [
|
||||
['oldAddressAddressStreet1', 'newAddressAddressStreet1'],
|
||||
['oldAddressAddressStreet2', 'newAddressAddressStreet2'],
|
||||
['oldAddressAddressCity', 'newAddressAddressCity'],
|
||||
['oldAddressAddressPostcode', 'newAddressAddressPostcode'],
|
||||
['oldAddressAddressState', 'newAddressAddressState'],
|
||||
['oldAddressAddressCountry', 'newAddressAddressCountry'],
|
||||
['oldAddressAddressLat', 'newAddressAddressLat'],
|
||||
['oldAddressAddressLng', 'newAddressAddressLng'],
|
||||
];
|
||||
|
||||
expect(
|
||||
mockSchemaManagerService.columnManager.renameColumn,
|
||||
).toHaveBeenCalledTimes(8);
|
||||
|
||||
expectedRenameCalls.forEach(([fromName, toName]) => {
|
||||
expect(
|
||||
mockSchemaManagerService.columnManager.renameColumn,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_company',
|
||||
oldColumnName: fromName,
|
||||
newColumnName: toName,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle enum field option updates with proper value mapping', async () => {
|
||||
const selectField = getFlatFieldMetadataMock({
|
||||
id: mockFieldMetadataId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'status',
|
||||
options: [
|
||||
{
|
||||
id: '1',
|
||||
value: 'UPDATED_ACTIVE',
|
||||
label: 'Updated Active',
|
||||
color: 'green',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
value: 'UPDATED_INACTIVE',
|
||||
label: 'Updated Inactive',
|
||||
color: 'red',
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
uniqueIdentifier: 'status',
|
||||
});
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'person',
|
||||
uniqueIdentifier: 'person',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
selectField,
|
||||
);
|
||||
|
||||
await service.runUpdateFieldSchemaMigration({
|
||||
action: {
|
||||
type: 'update_field',
|
||||
workspaceId: mockWorkspaceId,
|
||||
fieldMetadataId: mockFieldMetadataId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
updates: [
|
||||
{
|
||||
property: 'options',
|
||||
from: [
|
||||
{
|
||||
id: '1',
|
||||
value: 'ACTIVE',
|
||||
label: 'Active',
|
||||
color: 'green',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
value: 'INACTIVE',
|
||||
label: 'Inactive',
|
||||
color: 'red',
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
to: [
|
||||
{
|
||||
id: '1',
|
||||
value: 'UPDATED_ACTIVE',
|
||||
label: 'Updated Active',
|
||||
color: 'green',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
value: 'UPDATED_INACTIVE',
|
||||
label: 'Updated Inactive',
|
||||
color: 'red',
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// Enum values must be updated with proper mapping
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.alterEnumValues,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_person',
|
||||
columnDefinition: {
|
||||
name: 'status',
|
||||
type: '_person_status_enum',
|
||||
isNullable: true,
|
||||
isArray: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
enumValues: ['UPDATED_ACTIVE', 'UPDATED_INACTIVE'],
|
||||
},
|
||||
oldToNewEnumOptionMap: {
|
||||
ACTIVE: 'UPDATED_ACTIVE', // Keep original values
|
||||
INACTIVE: 'UPDATED_INACTIVE',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle default value updates for composite fields', async () => {
|
||||
const currencyField = getFlatFieldMetadataMock({
|
||||
id: mockFieldMetadataId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.CURRENCY,
|
||||
name: 'price',
|
||||
defaultValue: {
|
||||
amountMicros: "'0'",
|
||||
currencyCode: "'USD'",
|
||||
},
|
||||
uniqueIdentifier: 'price',
|
||||
});
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'product',
|
||||
uniqueIdentifier: 'product',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
currencyField,
|
||||
);
|
||||
|
||||
await service.runUpdateFieldSchemaMigration({
|
||||
action: {
|
||||
type: 'update_field',
|
||||
workspaceId: mockWorkspaceId,
|
||||
fieldMetadataId: mockFieldMetadataId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
updates: [
|
||||
{
|
||||
property: 'defaultValue',
|
||||
from: {
|
||||
amountMicros: "'0'",
|
||||
currencyCode: "'USD'",
|
||||
},
|
||||
to: {
|
||||
amountMicros: "'100000000'",
|
||||
currencyCode: "'EUR'",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// Both composite column defaults must be updated
|
||||
expect(
|
||||
mockSchemaManagerService.columnManager.alterColumnDefault,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_product',
|
||||
columnName: 'priceAmountMicros',
|
||||
defaultValue: '100000000',
|
||||
});
|
||||
|
||||
expect(
|
||||
mockSchemaManagerService.columnManager.alterColumnDefault,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_product',
|
||||
columnName: 'priceCurrencyCode',
|
||||
defaultValue: 'EUR',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling and Edge Cases', () => {
|
||||
it('should handle fields with no join column settings gracefully', async () => {
|
||||
const relationFieldWithoutSettings = getFlatFieldMetadataMock({
|
||||
id: mockFieldMetadataId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.RELATION,
|
||||
name: 'company',
|
||||
settings: undefined,
|
||||
uniqueIdentifier: 'company',
|
||||
});
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'person',
|
||||
uniqueIdentifier: 'person',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
relationFieldWithoutSettings,
|
||||
);
|
||||
|
||||
await service.runDeleteFieldSchemaMigration({
|
||||
action: {
|
||||
type: 'delete_field',
|
||||
fieldMetadataId: mockFieldMetadataId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// Should not attempt to drop columns for relations without join columns
|
||||
expect(
|
||||
mockSchemaManagerService.columnManager.dropColumns,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_person',
|
||||
columnNames: [], // Empty array - no columns to drop
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle enum fields without options safely', async () => {
|
||||
const selectFieldWithoutOptions = getFlatFieldMetadataMock({
|
||||
id: mockFieldMetadataId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'emptyStatus',
|
||||
options: [],
|
||||
uniqueIdentifier: 'emptyStatus',
|
||||
});
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'test',
|
||||
uniqueIdentifier: 'test',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
selectFieldWithoutOptions,
|
||||
);
|
||||
|
||||
await service.runCreateFieldSchemaMigration({
|
||||
action: {
|
||||
type: 'create_field',
|
||||
flatFieldMetadata: selectFieldWithoutOptions,
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// Should still create enum even with empty values
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.createEnum,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
enumName: '_test_emptyStatus_enum',
|
||||
values: [], // Empty enum values array
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+873
@@ -0,0 +1,873 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
|
||||
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
|
||||
import { type WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
|
||||
import { WorkspaceSchemaObjectActionRunnerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/workspace-schema-object-action-runner.service';
|
||||
|
||||
describe('WorkspaceSchemaObjectActionRunner', () => {
|
||||
let service: WorkspaceSchemaObjectActionRunnerService;
|
||||
let mockSchemaManagerService: jest.Mocked<WorkspaceSchemaManagerService>;
|
||||
let mockQueryRunner: jest.Mocked<QueryRunner>;
|
||||
|
||||
const mockWorkspaceId = '20202020-1c25-4d02-bf25-6aeccf7ea419';
|
||||
const mockObjectMetadataId = '20202020-1c25-4d02-bf25-6aeccf7ea418';
|
||||
|
||||
const createMockFlatObjectMetadataMaps = (
|
||||
objectMetadata: any,
|
||||
fields: any[] = [],
|
||||
) => {
|
||||
const fieldsById = fields.reduce((acc, field, index) => {
|
||||
const fieldId = `field-${index}`;
|
||||
|
||||
acc[fieldId] = { ...field, id: fieldId };
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
byId: {
|
||||
[mockObjectMetadataId]: {
|
||||
...objectMetadata,
|
||||
fieldsById,
|
||||
},
|
||||
},
|
||||
idByNameSingular: {
|
||||
[objectMetadata.nameSingular]: mockObjectMetadataId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockSchemaManagerService = {
|
||||
columnManager: {
|
||||
dropColumns: jest.fn(),
|
||||
addColumns: jest.fn(),
|
||||
renameColumn: jest.fn(),
|
||||
alterColumnDefault: jest.fn(),
|
||||
},
|
||||
enumManager: {
|
||||
createEnum: jest.fn(),
|
||||
dropEnum: jest.fn(),
|
||||
renameEnum: jest.fn(),
|
||||
alterEnumValues: jest.fn(),
|
||||
},
|
||||
tableManager: {
|
||||
createTable: jest.fn(),
|
||||
dropTable: jest.fn(),
|
||||
renameTable: jest.fn(),
|
||||
},
|
||||
} as any;
|
||||
|
||||
mockQueryRunner = {} as any;
|
||||
|
||||
service = new WorkspaceSchemaObjectActionRunnerService(
|
||||
mockSchemaManagerService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('DELETE Object Migration', () => {
|
||||
it('should properly delete objects with SELECT field enum cleanup', async () => {
|
||||
const selectField1 = getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'status',
|
||||
uniqueIdentifier: 'status',
|
||||
options: [
|
||||
{
|
||||
id: '1',
|
||||
value: 'ACTIVE',
|
||||
label: 'Active',
|
||||
color: 'green',
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const selectField2 = getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'priority',
|
||||
uniqueIdentifier: 'priority',
|
||||
options: [
|
||||
{ id: '1', value: 'HIGH', label: 'High', color: 'red', position: 0 },
|
||||
],
|
||||
});
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'task',
|
||||
uniqueIdentifier: 'task',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
[selectField1, selectField2],
|
||||
);
|
||||
|
||||
await service.runDeleteObjectSchemaMigration({
|
||||
action: {
|
||||
type: 'delete_object',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// Table must be dropped first
|
||||
expect(
|
||||
mockSchemaManagerService.tableManager.dropTable,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_task',
|
||||
});
|
||||
|
||||
// All enum types associated with the object must be cleaned up
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.dropEnum,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.dropEnum,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
enumName: '_task_status_enum',
|
||||
});
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.dropEnum,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
enumName: '_task_priority_enum',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle object deletion with mixed field types', async () => {
|
||||
const textField = getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'description',
|
||||
uniqueIdentifier: 'description',
|
||||
});
|
||||
|
||||
const addressField = getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
name: 'location',
|
||||
uniqueIdentifier: 'location',
|
||||
});
|
||||
|
||||
const multiSelectField = getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.MULTI_SELECT,
|
||||
name: 'tags',
|
||||
uniqueIdentifier: 'tags',
|
||||
options: [
|
||||
{
|
||||
id: '1',
|
||||
value: 'URGENT',
|
||||
label: 'Urgent',
|
||||
color: 'red',
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'project',
|
||||
uniqueIdentifier: 'project',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
[textField, addressField, multiSelectField],
|
||||
);
|
||||
|
||||
await service.runDeleteObjectSchemaMigration({
|
||||
action: {
|
||||
type: 'delete_object',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// Table must be dropped
|
||||
expect(
|
||||
mockSchemaManagerService.tableManager.dropTable,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_project',
|
||||
});
|
||||
|
||||
// Only enum fields should trigger enum cleanup
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.dropEnum,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.dropEnum,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
enumName: '_project_tags_enum',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle object deletion with no enum fields', async () => {
|
||||
const textField = getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'name',
|
||||
uniqueIdentifier: 'name',
|
||||
});
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'simpleObject',
|
||||
uniqueIdentifier: 'simpleObject',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
[textField],
|
||||
);
|
||||
|
||||
await service.runDeleteObjectSchemaMigration({
|
||||
action: {
|
||||
type: 'delete_object',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// Table must be dropped
|
||||
expect(
|
||||
mockSchemaManagerService.tableManager.dropTable,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_simpleObject',
|
||||
});
|
||||
|
||||
// No enum cleanup should occur
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.dropEnum,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CREATE Object Migration', () => {
|
||||
it('should create objects with mixed field types and proper column generation', async () => {
|
||||
const textFieldAction = {
|
||||
type: 'create_field' as const,
|
||||
flatFieldMetadata: getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'title',
|
||||
uniqueIdentifier: 'title',
|
||||
}),
|
||||
};
|
||||
|
||||
const selectFieldAction = {
|
||||
type: 'create_field' as const,
|
||||
flatFieldMetadata: getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'status',
|
||||
uniqueIdentifier: 'status',
|
||||
options: [
|
||||
{
|
||||
id: '1',
|
||||
value: 'DRAFT',
|
||||
label: 'Draft',
|
||||
color: 'gray',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
value: 'PUBLISHED',
|
||||
label: 'Published',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const currencyFieldAction = {
|
||||
type: 'create_field' as const,
|
||||
flatFieldMetadata: getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.CURRENCY,
|
||||
name: 'price',
|
||||
uniqueIdentifier: 'price',
|
||||
}),
|
||||
};
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'article',
|
||||
uniqueIdentifier: 'article',
|
||||
});
|
||||
|
||||
await service.runCreateObjectSchemaMigration({
|
||||
action: {
|
||||
type: 'create_object',
|
||||
flatObjectMetadataWithoutFields: objectMetadata,
|
||||
createFieldActions: [
|
||||
textFieldAction,
|
||||
selectFieldAction,
|
||||
currencyFieldAction,
|
||||
],
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps: {
|
||||
byId: {
|
||||
[mockObjectMetadataId]: {
|
||||
...objectMetadata,
|
||||
fieldsById: {},
|
||||
fieldIdByJoinColumnName: {},
|
||||
fieldIdByName: {},
|
||||
},
|
||||
},
|
||||
idByNameSingular: {
|
||||
[objectMetadata.nameSingular]: mockObjectMetadataId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Table must be created with all field columns and enums
|
||||
expect(
|
||||
mockSchemaManagerService.tableManager.createTable,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_article',
|
||||
columnDefinitions: [
|
||||
// TEXT field column
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
isNullable: true,
|
||||
isArray: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
enumValues: undefined,
|
||||
},
|
||||
// SELECT field column
|
||||
{
|
||||
name: 'status',
|
||||
type: '_article_status_enum',
|
||||
isNullable: true,
|
||||
isArray: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
enumValues: ['DRAFT', 'PUBLISHED'],
|
||||
},
|
||||
// CURRENCY field columns (2 composite columns)
|
||||
{
|
||||
name: 'priceAmountMicros',
|
||||
type: 'numeric',
|
||||
isNullable: true,
|
||||
isArray: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
},
|
||||
{
|
||||
name: 'priceCurrencyCode',
|
||||
type: 'text',
|
||||
isNullable: true,
|
||||
isArray: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.createEnum,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
enumName: '_article_status_enum',
|
||||
values: ['DRAFT', 'PUBLISHED'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle object creation with ADDRESS composite fields', async () => {
|
||||
const addressFieldAction = {
|
||||
type: 'create_field' as const,
|
||||
flatFieldMetadata: getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
name: 'headquarters',
|
||||
isNullable: false, // Test non-nullable composite field
|
||||
uniqueIdentifier: 'headquarters',
|
||||
}),
|
||||
};
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'company',
|
||||
uniqueIdentifier: 'company',
|
||||
});
|
||||
|
||||
await service.runCreateObjectSchemaMigration({
|
||||
action: {
|
||||
type: 'create_object',
|
||||
flatObjectMetadataWithoutFields: objectMetadata,
|
||||
createFieldActions: [addressFieldAction],
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps: {
|
||||
byId: {
|
||||
[mockObjectMetadataId]: {
|
||||
...objectMetadata,
|
||||
fieldsById: {},
|
||||
fieldIdByJoinColumnName: {},
|
||||
fieldIdByName: {},
|
||||
},
|
||||
},
|
||||
idByNameSingular: {
|
||||
[objectMetadata.nameSingular]: mockObjectMetadataId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Table must be created with all 8 ADDRESS composite columns
|
||||
expect(
|
||||
mockSchemaManagerService.tableManager.createTable,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_company',
|
||||
columnDefinitions: [
|
||||
{
|
||||
name: 'headquartersAddressStreet1',
|
||||
type: 'text',
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
isArray: false,
|
||||
},
|
||||
{
|
||||
name: 'headquartersAddressStreet2',
|
||||
type: 'text',
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
isArray: false,
|
||||
},
|
||||
{
|
||||
name: 'headquartersAddressCity',
|
||||
type: 'text',
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
isArray: false,
|
||||
},
|
||||
{
|
||||
name: 'headquartersAddressPostcode',
|
||||
type: 'text',
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
isArray: false,
|
||||
},
|
||||
{
|
||||
name: 'headquartersAddressState',
|
||||
type: 'text',
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
isArray: false,
|
||||
},
|
||||
{
|
||||
name: 'headquartersAddressCountry',
|
||||
type: 'text',
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
isArray: false,
|
||||
},
|
||||
{
|
||||
name: 'headquartersAddressLat',
|
||||
type: 'numeric',
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
isArray: false,
|
||||
},
|
||||
{
|
||||
name: 'headquartersAddressLng',
|
||||
type: 'numeric',
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
isArray: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle object creation with no field actions', async () => {
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'emptyObject',
|
||||
uniqueIdentifier: 'emptyObject',
|
||||
});
|
||||
|
||||
await service.runCreateObjectSchemaMigration({
|
||||
action: {
|
||||
type: 'create_object',
|
||||
flatObjectMetadataWithoutFields: objectMetadata,
|
||||
createFieldActions: [],
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps: {
|
||||
byId: {
|
||||
[mockObjectMetadataId]: {
|
||||
...objectMetadata,
|
||||
fieldsById: {},
|
||||
fieldIdByJoinColumnName: {},
|
||||
fieldIdByName: {},
|
||||
},
|
||||
},
|
||||
idByNameSingular: {
|
||||
[objectMetadata.nameSingular]: mockObjectMetadataId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Table must be created even with no columns
|
||||
expect(
|
||||
mockSchemaManagerService.tableManager.createTable,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_emptyObject',
|
||||
columnDefinitions: [], // Empty columns array
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPDATE Object Migration', () => {
|
||||
it('should handle object name changes with enum type updates', async () => {
|
||||
const selectField = getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'category',
|
||||
uniqueIdentifier: 'category',
|
||||
options: [
|
||||
{
|
||||
id: '1',
|
||||
value: 'TECH',
|
||||
label: 'Technology',
|
||||
color: 'blue',
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const multiSelectField = getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.MULTI_SELECT,
|
||||
name: 'tags',
|
||||
uniqueIdentifier: 'tags',
|
||||
options: [
|
||||
{
|
||||
id: '1',
|
||||
value: 'FEATURED',
|
||||
label: 'Featured',
|
||||
color: 'yellow',
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'blogPost',
|
||||
uniqueIdentifier: 'blogPost',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
[selectField, multiSelectField],
|
||||
);
|
||||
|
||||
await service.runUpdateObjectSchemaMigration({
|
||||
action: {
|
||||
type: 'update_object',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
updates: [
|
||||
{
|
||||
property: 'nameSingular',
|
||||
from: 'blogPost',
|
||||
to: 'article',
|
||||
},
|
||||
],
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// Table must be renamed
|
||||
expect(
|
||||
mockSchemaManagerService.tableManager.renameTable,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
oldTableName: '_blogPost',
|
||||
newTableName: '_article',
|
||||
});
|
||||
|
||||
// All enum types must be renamed to match new table name
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.renameEnum,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.renameEnum,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
oldEnumName: '_blogPost_category_enum',
|
||||
newEnumName: '_article_category_enum',
|
||||
});
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.renameEnum,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
oldEnumName: '_blogPost_tags_enum',
|
||||
newEnumName: '_article_tags_enum',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle object name changes with no actual table rename needed', async () => {
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'person',
|
||||
uniqueIdentifier: 'person',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
[],
|
||||
);
|
||||
|
||||
await service.runUpdateObjectSchemaMigration({
|
||||
action: {
|
||||
type: 'update_object',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
updates: [
|
||||
{
|
||||
property: 'nameSingular',
|
||||
from: 'person',
|
||||
to: 'person', // Same name - no change needed
|
||||
},
|
||||
],
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// No table operations should occur when names are the same
|
||||
expect(
|
||||
mockSchemaManagerService.tableManager.renameTable,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.renameEnum,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle object updates with complex field combinations', async () => {
|
||||
const textField = getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'description',
|
||||
uniqueIdentifier: 'description',
|
||||
});
|
||||
|
||||
const selectField = getFlatFieldMetadataMock({
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'status',
|
||||
uniqueIdentifier: 'status',
|
||||
options: [
|
||||
{
|
||||
id: '1',
|
||||
value: 'ACTIVE',
|
||||
label: 'Active',
|
||||
color: 'green',
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'oldEntity',
|
||||
uniqueIdentifier: 'oldEntity',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = createMockFlatObjectMetadataMaps(
|
||||
objectMetadata,
|
||||
[textField, selectField],
|
||||
);
|
||||
|
||||
await service.runUpdateObjectSchemaMigration({
|
||||
action: {
|
||||
type: 'update_object',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
updates: [
|
||||
{
|
||||
property: 'nameSingular',
|
||||
from: 'oldEntity',
|
||||
to: 'newEntity',
|
||||
},
|
||||
],
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// Table rename
|
||||
expect(
|
||||
mockSchemaManagerService.tableManager.renameTable,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
oldTableName: '_oldEntity',
|
||||
newTableName: '_newEntity',
|
||||
});
|
||||
|
||||
// Only SELECT field should trigger enum rename
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.renameEnum,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.renameEnum,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
oldEnumName: '_oldEntity_status_enum',
|
||||
newEnumName: '_newEntity_status_enum',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases and Error Conditions', () => {
|
||||
it('should handle objects with undefined fieldsById gracefully', async () => {
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'testObject',
|
||||
uniqueIdentifier: 'testObject',
|
||||
});
|
||||
|
||||
// Create maps with undefined fieldsById
|
||||
const flatObjectMetadataMaps = {
|
||||
byId: {
|
||||
[mockObjectMetadataId]: {
|
||||
...objectMetadata,
|
||||
fieldsById: undefined as unknown as Record<string, any>,
|
||||
fieldIdByJoinColumnName: {},
|
||||
fieldIdByName: {},
|
||||
},
|
||||
},
|
||||
idByNameSingular: {
|
||||
[objectMetadata.nameSingular]: mockObjectMetadataId,
|
||||
},
|
||||
};
|
||||
|
||||
await service.runCreateObjectSchemaMigration({
|
||||
action: {
|
||||
type: 'create_object',
|
||||
flatObjectMetadataWithoutFields: objectMetadata,
|
||||
createFieldActions: [],
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// Should still drop table
|
||||
expect(
|
||||
mockSchemaManagerService.tableManager.createTable,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
tableName: '_testObject',
|
||||
columnDefinitions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty fieldsById object', async () => {
|
||||
const objectMetadata = getFlatObjectMetadataMock({
|
||||
id: mockObjectMetadataId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
nameSingular: 'emptyFieldsObject',
|
||||
uniqueIdentifier: 'emptyFieldsObject',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps = {
|
||||
byId: {
|
||||
[mockObjectMetadataId]: {
|
||||
...objectMetadata,
|
||||
fieldsById: {},
|
||||
fieldIdByJoinColumnName: {},
|
||||
fieldIdByName: {},
|
||||
},
|
||||
},
|
||||
idByNameSingular: {
|
||||
[objectMetadata.nameSingular]: mockObjectMetadataId,
|
||||
},
|
||||
};
|
||||
|
||||
await service.runUpdateObjectSchemaMigration({
|
||||
action: {
|
||||
type: 'update_object',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
updates: [
|
||||
{
|
||||
property: 'nameSingular',
|
||||
from: 'emptyFieldsObject',
|
||||
to: 'renamedEmptyFieldsObject',
|
||||
},
|
||||
],
|
||||
},
|
||||
queryRunner: mockQueryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
// Should rename table
|
||||
expect(
|
||||
mockSchemaManagerService.tableManager.renameTable,
|
||||
).toHaveBeenCalledWith({
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5',
|
||||
oldTableName: '_emptyFieldsObject',
|
||||
newTableName: '_renamedEmptyFieldsObject',
|
||||
});
|
||||
|
||||
// Should not perform enum operations
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.renameEnum,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class WorkspaceSchemaMigrationException extends CustomException<WorkspaceSchemaMigrationExceptionCode> {}
|
||||
|
||||
export enum WorkspaceSchemaMigrationExceptionCode {
|
||||
COMPOSITE_TYPE_NOT_FOUND = 'COMPOSITE_TYPE_NOT_FOUND',
|
||||
INVALID_FIELD_TYPE = 'INVALID_FIELD_TYPE',
|
||||
ENUM_OPERATION_FAILED = 'ENUM_OPERATION_FAILED',
|
||||
COLUMN_OPERATION_FAILED = 'COLUMN_OPERATION_FAILED',
|
||||
TABLE_OPERATION_FAILED = 'TABLE_OPERATION_FAILED',
|
||||
UNSUPPORTED_COMPOSITE_COLUMN_TYPE = 'UNSUPPORTED_COMPOSITE_COLUMN_TYPE',
|
||||
}
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
|
||||
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
|
||||
import { generateColumnDefinitions } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/generate-column-definitions.util';
|
||||
|
||||
describe('Generate Column Definitions', () => {
|
||||
const mockObjectMetadata = getFlatObjectMetadataMock({
|
||||
uniqueIdentifier: 'person',
|
||||
id: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
nameSingular: 'person',
|
||||
});
|
||||
|
||||
describe('Enum Field Schema Generation', () => {
|
||||
it('should generate deterministic enum names to prevent schema conflicts', () => {
|
||||
const enumField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'status',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'status',
|
||||
options: [
|
||||
{
|
||||
id: '1',
|
||||
value: 'ACTIVE',
|
||||
label: 'Active',
|
||||
color: 'green',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
value: 'INACTIVE',
|
||||
label: 'Inactive',
|
||||
color: 'red',
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const columns = generateColumnDefinitions({
|
||||
fieldMetadata: enumField,
|
||||
flatObjectMetadataWithoutFields: mockObjectMetadata,
|
||||
});
|
||||
|
||||
expect(columns).toHaveLength(1);
|
||||
|
||||
const column = columns[0];
|
||||
|
||||
expect(column).toEqual({
|
||||
name: 'status',
|
||||
type: '_person_status_enum',
|
||||
enumValues: ['ACTIVE', 'INACTIVE'],
|
||||
isArray: false,
|
||||
isNullable: true,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multi-select fields with proper enum generation', () => {
|
||||
const multiSelectField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'tags',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.MULTI_SELECT,
|
||||
name: 'tags',
|
||||
options: [
|
||||
{
|
||||
id: '1',
|
||||
value: 'URGENT',
|
||||
label: 'Urgent',
|
||||
color: 'red',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
value: 'LOW_PRIORITY',
|
||||
label: 'Low Priority',
|
||||
color: 'blue',
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const columns = generateColumnDefinitions({
|
||||
fieldMetadata: multiSelectField,
|
||||
flatObjectMetadataWithoutFields: mockObjectMetadata,
|
||||
});
|
||||
|
||||
expect(columns).toHaveLength(1);
|
||||
|
||||
const column = columns[0];
|
||||
|
||||
expect(column).toEqual({
|
||||
name: 'tags',
|
||||
type: '_person_tags_enum',
|
||||
enumValues: ['URGENT', 'LOW_PRIORITY'],
|
||||
isArray: true,
|
||||
isNullable: true,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Relation Field Schema Generation', () => {
|
||||
it('should handle null relation settings without crashing', () => {
|
||||
const relationField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'company',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.RELATION,
|
||||
name: 'company',
|
||||
settings: undefined,
|
||||
});
|
||||
|
||||
const columns = generateColumnDefinitions({
|
||||
fieldMetadata: relationField,
|
||||
flatObjectMetadataWithoutFields: mockObjectMetadata,
|
||||
});
|
||||
|
||||
// Relations without join columns must return empty array
|
||||
expect(columns).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should generate proper UUID foreign key columns for valid relations', () => {
|
||||
const relationField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'company',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.RELATION,
|
||||
name: 'company',
|
||||
settings: {
|
||||
relationType: 'MANY_TO_ONE' as any,
|
||||
joinColumnName: 'companyId',
|
||||
},
|
||||
});
|
||||
|
||||
const columns = generateColumnDefinitions({
|
||||
fieldMetadata: relationField,
|
||||
flatObjectMetadataWithoutFields: mockObjectMetadata,
|
||||
});
|
||||
|
||||
expect(columns).toHaveLength(1);
|
||||
|
||||
const column = columns[0];
|
||||
|
||||
expect(column).toEqual({
|
||||
name: 'companyId',
|
||||
type: 'uuid',
|
||||
isNullable: true,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
isArray: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Composite Field Schema Generation', () => {
|
||||
it('should generate all required columns for ADDRESS composite type', () => {
|
||||
const addressField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'address',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
name: 'homeAddress',
|
||||
isNullable: true,
|
||||
});
|
||||
|
||||
const columns = generateColumnDefinitions({
|
||||
fieldMetadata: addressField,
|
||||
flatObjectMetadataWithoutFields: mockObjectMetadata,
|
||||
});
|
||||
|
||||
expect(columns).toHaveLength(8);
|
||||
|
||||
const columnNames = columns.map((col) => col.name);
|
||||
|
||||
expect(columnNames).toEqual([
|
||||
'homeAddressAddressStreet1',
|
||||
'homeAddressAddressStreet2',
|
||||
'homeAddressAddressCity',
|
||||
'homeAddressAddressPostcode',
|
||||
'homeAddressAddressState',
|
||||
'homeAddressAddressCountry',
|
||||
'homeAddressAddressLat',
|
||||
'homeAddressAddressLng',
|
||||
]);
|
||||
|
||||
// All composite columns must inherit parent nullable constraint
|
||||
columns.forEach((column) => {
|
||||
expect(column.isNullable).toBe(true);
|
||||
expect(column.isUnique).toBe(false);
|
||||
expect(column.default).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle CURRENCY composite type properly', () => {
|
||||
const currencyField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'price',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.CURRENCY,
|
||||
name: 'price',
|
||||
defaultValue: {
|
||||
amountMicros: '100000000',
|
||||
currencyCode: "'USD'",
|
||||
},
|
||||
});
|
||||
|
||||
const columns = generateColumnDefinitions({
|
||||
fieldMetadata: currencyField,
|
||||
flatObjectMetadataWithoutFields: mockObjectMetadata,
|
||||
});
|
||||
|
||||
expect(columns).toHaveLength(2);
|
||||
|
||||
const columnNames = columns.map((col) => col.name);
|
||||
|
||||
expect(columnNames).toEqual(['priceAmountMicros', 'priceCurrencyCode']);
|
||||
|
||||
expect(columns[0]).toMatchObject({
|
||||
name: 'priceAmountMicros',
|
||||
type: 'numeric',
|
||||
isNullable: true,
|
||||
isUnique: false,
|
||||
default: '100000000',
|
||||
});
|
||||
|
||||
expect(columns[1]).toMatchObject({
|
||||
name: 'priceCurrencyCode',
|
||||
type: 'text',
|
||||
isNullable: true,
|
||||
isUnique: false,
|
||||
default: 'USD',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Default Value Schema Generation', () => {
|
||||
it('should handle null and undefined default values safely', () => {
|
||||
const textField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'description',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'description',
|
||||
defaultValue: null,
|
||||
});
|
||||
|
||||
const columns = generateColumnDefinitions({
|
||||
fieldMetadata: textField,
|
||||
flatObjectMetadataWithoutFields: mockObjectMetadata,
|
||||
});
|
||||
|
||||
expect(columns).toStrictEqual([
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
isNullable: true,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
enumValues: undefined,
|
||||
isArray: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle boolean fields with default values', () => {
|
||||
const booleanField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'isActive',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.BOOLEAN,
|
||||
name: 'isActive',
|
||||
defaultValue: true,
|
||||
});
|
||||
|
||||
const columns = generateColumnDefinitions({
|
||||
fieldMetadata: booleanField,
|
||||
flatObjectMetadataWithoutFields: mockObjectMetadata,
|
||||
});
|
||||
|
||||
expect(columns).toStrictEqual([
|
||||
{
|
||||
name: 'isActive',
|
||||
type: 'boolean',
|
||||
isNullable: true,
|
||||
isUnique: false,
|
||||
default: true,
|
||||
enumValues: undefined,
|
||||
isArray: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Field Definition Generation', () => {
|
||||
it('should handle text fields without crashing', () => {
|
||||
const textField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'content',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'content',
|
||||
});
|
||||
|
||||
const columns = generateColumnDefinitions({
|
||||
fieldMetadata: textField,
|
||||
flatObjectMetadataWithoutFields: mockObjectMetadata,
|
||||
});
|
||||
|
||||
expect(columns).toStrictEqual([
|
||||
{
|
||||
name: 'content',
|
||||
type: 'text',
|
||||
isNullable: true,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
enumValues: undefined,
|
||||
isArray: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle UUID fields properly', () => {
|
||||
const uuidField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'uuid',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.UUID,
|
||||
name: 'uuid',
|
||||
});
|
||||
|
||||
const columns = generateColumnDefinitions({
|
||||
fieldMetadata: uuidField,
|
||||
flatObjectMetadataWithoutFields: mockObjectMetadata,
|
||||
});
|
||||
|
||||
expect(columns).toStrictEqual([
|
||||
{
|
||||
name: 'uuid',
|
||||
type: 'uuid',
|
||||
isNullable: true,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
enumValues: undefined,
|
||||
isArray: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
|
||||
import { type WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
|
||||
import { WorkspaceSchemaMigrationException } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/exceptions/workspace-schema-migration.exception';
|
||||
import {
|
||||
collectEnumOperationsForField,
|
||||
EnumOperation,
|
||||
type EnumOperationSpec,
|
||||
executeBatchEnumOperations,
|
||||
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/workspace-schema-enum-operations.util';
|
||||
|
||||
describe('WorkspaceSchemaEnumOperations', () => {
|
||||
let mockSchemaManagerService: jest.Mocked<WorkspaceSchemaManagerService>;
|
||||
let mockQueryRunner: jest.Mocked<QueryRunner>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSchemaManagerService = {
|
||||
enumManager: {
|
||||
createEnum: jest.fn(),
|
||||
dropEnum: jest.fn(),
|
||||
renameEnum: jest.fn(),
|
||||
alterEnumValues: jest.fn(),
|
||||
},
|
||||
} as any;
|
||||
|
||||
mockQueryRunner = {} as any;
|
||||
});
|
||||
|
||||
describe('Batch Operation Atomicity', () => {
|
||||
it('should fail fast when any enum operation fails to preserve transaction integrity', async () => {
|
||||
const enumOperations: EnumOperationSpec[] = [
|
||||
{
|
||||
operation: EnumOperation.CREATE,
|
||||
enumName: 'enum1',
|
||||
values: ['A', 'B'],
|
||||
},
|
||||
{
|
||||
operation: EnumOperation.CREATE,
|
||||
enumName: 'enum2',
|
||||
values: ['X', 'Y'],
|
||||
},
|
||||
{
|
||||
operation: EnumOperation.CREATE,
|
||||
enumName: 'enum3',
|
||||
values: ['P', 'Q'],
|
||||
},
|
||||
];
|
||||
|
||||
// Simulate failure on second enum
|
||||
(mockSchemaManagerService.enumManager.createEnum as jest.Mock)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('Enum collision'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(
|
||||
executeBatchEnumOperations({
|
||||
enumOperations,
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'test_schema',
|
||||
workspaceSchemaManagerService: mockSchemaManagerService,
|
||||
}),
|
||||
).rejects.toThrow(WorkspaceSchemaMigrationException);
|
||||
|
||||
// All operations should be attempted in parallel despite failure
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.createEnum,
|
||||
).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should handle empty operations without unnecessary database calls', async () => {
|
||||
await executeBatchEnumOperations({
|
||||
enumOperations: [],
|
||||
queryRunner: mockQueryRunner,
|
||||
schemaName: 'test_schema',
|
||||
workspaceSchemaManagerService: mockSchemaManagerService,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockSchemaManagerService.enumManager.createEnum,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enum Collection for SELECT Fields', () => {
|
||||
it('should collect enum operations for simple SELECT fields', () => {
|
||||
const selectField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'status',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'status',
|
||||
options: [
|
||||
{
|
||||
id: '1',
|
||||
value: 'ACTIVE',
|
||||
label: 'Active',
|
||||
color: 'green',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
value: 'INACTIVE',
|
||||
label: 'Inactive',
|
||||
color: 'red',
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const enumOps = collectEnumOperationsForField({
|
||||
flatFieldMetadata: selectField,
|
||||
tableName: 'contacts',
|
||||
operation: EnumOperation.CREATE,
|
||||
});
|
||||
|
||||
expect(enumOps).toMatchObject([
|
||||
{
|
||||
enumName: expect.stringContaining('contacts_status_enum'),
|
||||
values: ['ACTIVE', 'INACTIVE'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should collect enum operations for MULTI_SELECT fields', () => {
|
||||
const multiSelectField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'tags',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.MULTI_SELECT,
|
||||
name: 'tags',
|
||||
options: [
|
||||
{
|
||||
id: '1',
|
||||
value: 'URGENT',
|
||||
label: 'Urgent',
|
||||
color: 'red',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
value: 'LOW_PRIORITY',
|
||||
label: 'Low Priority',
|
||||
color: 'blue',
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const enumOps = collectEnumOperationsForField({
|
||||
flatFieldMetadata: multiSelectField,
|
||||
tableName: 'tasks',
|
||||
operation: EnumOperation.CREATE,
|
||||
});
|
||||
|
||||
expect(enumOps).toMatchObject([
|
||||
{
|
||||
enumName: expect.stringContaining('tasks_tags_enum'),
|
||||
values: ['URGENT', 'LOW_PRIORITY'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle field renaming with proper enum name mapping', () => {
|
||||
const selectField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'status',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'oldStatus',
|
||||
});
|
||||
|
||||
const enumOps = collectEnumOperationsForField({
|
||||
flatFieldMetadata: selectField,
|
||||
tableName: 'contacts',
|
||||
operation: EnumOperation.RENAME,
|
||||
options: { newFieldName: 'newStatus' },
|
||||
});
|
||||
|
||||
expect(enumOps).toMatchObject([
|
||||
{
|
||||
fromName: expect.stringContaining('contacts_oldStatus_enum'),
|
||||
toName: expect.stringContaining('contacts_newStatus_enum'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Composite Field Enum Handling', () => {
|
||||
it('should handle composite fields that do not currently generate enum operations', () => {
|
||||
const addressField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'address',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
name: 'primaryAddress',
|
||||
});
|
||||
|
||||
const enumOps = collectEnumOperationsForField({
|
||||
flatFieldMetadata: addressField,
|
||||
tableName: 'contacts',
|
||||
operation: EnumOperation.CREATE,
|
||||
});
|
||||
|
||||
// Current implementation doesn't handle composite enum fields
|
||||
expect(enumOps).toEqual([]);
|
||||
});
|
||||
|
||||
it('should skip relation properties in composite types', () => {
|
||||
const relationField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'relation',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.RELATION,
|
||||
name: 'company',
|
||||
});
|
||||
|
||||
const enumOps = collectEnumOperationsForField({
|
||||
flatFieldMetadata: relationField,
|
||||
tableName: 'contacts',
|
||||
operation: EnumOperation.CREATE,
|
||||
});
|
||||
|
||||
// Relation fields should not generate enum operations
|
||||
expect(enumOps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Non-Enum Field Safety', () => {
|
||||
it('should return empty operations for non-enum fields', () => {
|
||||
const textField = getFlatFieldMetadataMock({
|
||||
uniqueIdentifier: 'description',
|
||||
objectMetadataId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'description',
|
||||
});
|
||||
|
||||
const enumOps = collectEnumOperationsForField({
|
||||
flatFieldMetadata: textField,
|
||||
tableName: 'contacts',
|
||||
operation: EnumOperation.CREATE,
|
||||
});
|
||||
|
||||
expect(enumOps).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type CompositeProperty } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
|
||||
|
||||
import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type';
|
||||
import {
|
||||
computeColumnName,
|
||||
computeCompositeColumnName,
|
||||
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
|
||||
import { getCompositeTypeOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/get-composite-type-or-throw.util';
|
||||
import { serializeDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/serialize-default-value';
|
||||
import { unserializeDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/unserialize-default-value';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isCompositeFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-composite-flat-field-metadata.util';
|
||||
import { isFlatFieldMetadataEntityOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
|
||||
import { type FlatObjectMetadataWithoutFields } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { fieldMetadataTypeToColumnType } from 'src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util';
|
||||
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
|
||||
import { computePostgresEnumName } from 'src/engine/workspace-manager/workspace-migration-runner/utils/compute-postgres-enum-name.util';
|
||||
import {
|
||||
WorkspaceSchemaMigrationException,
|
||||
WorkspaceSchemaMigrationExceptionCode,
|
||||
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/exceptions/workspace-schema-migration.exception';
|
||||
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
|
||||
|
||||
import { getWorkspaceSchemaContextForMigration } from './get-workspace-schema-context-for-migration.util';
|
||||
|
||||
export const generateCompositeColumnDefinition = ({
|
||||
compositeProperty,
|
||||
parentFieldMetadata,
|
||||
flatObjectMetadataWithoutFields,
|
||||
}: {
|
||||
compositeProperty: CompositeProperty;
|
||||
parentFieldMetadata: FlatFieldMetadata<CompositeFieldMetadataType>;
|
||||
flatObjectMetadataWithoutFields: FlatObjectMetadataWithoutFields;
|
||||
}): WorkspaceSchemaColumnDefinition => {
|
||||
const { tableName } = getWorkspaceSchemaContextForMigration({
|
||||
workspaceId: flatObjectMetadataWithoutFields.workspaceId,
|
||||
flatObjectMetadataWithoutFields,
|
||||
});
|
||||
|
||||
if (
|
||||
compositeProperty.type === FieldMetadataType.RELATION ||
|
||||
compositeProperty.type === FieldMetadataType.MORPH_RELATION
|
||||
) {
|
||||
throw new WorkspaceSchemaMigrationException(
|
||||
`Relation type not supported for composite columns`,
|
||||
WorkspaceSchemaMigrationExceptionCode.UNSUPPORTED_COMPOSITE_COLUMN_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
const columnName = computeCompositeColumnName(
|
||||
parentFieldMetadata.name,
|
||||
compositeProperty,
|
||||
);
|
||||
const defaultValue =
|
||||
// @ts-expect-error - TODO: fix this
|
||||
parentFieldMetadata.defaultValue?.[compositeProperty.name];
|
||||
|
||||
const unserializedDefaultValue = unserializeDefaultValue(defaultValue);
|
||||
|
||||
const columnType = fieldMetadataTypeToColumnType(compositeProperty.type);
|
||||
|
||||
const isArrayFlag =
|
||||
compositeProperty.type === FieldMetadataType.ARRAY ||
|
||||
compositeProperty.type === FieldMetadataType.MULTI_SELECT ||
|
||||
Boolean(compositeProperty.isArray);
|
||||
|
||||
const definition: WorkspaceSchemaColumnDefinition = {
|
||||
name: columnName,
|
||||
type:
|
||||
columnType === 'enum'
|
||||
? computePostgresEnumName({ tableName, columnName })
|
||||
: columnType,
|
||||
// Align composite column nullability with parent field nullability by default
|
||||
isNullable: parentFieldMetadata.isNullable ?? true,
|
||||
isUnique: parentFieldMetadata.isUnique ?? false,
|
||||
default: unserializedDefaultValue,
|
||||
isArray: isArrayFlag,
|
||||
};
|
||||
|
||||
if (columnType === 'enum') {
|
||||
definition.enumValues = compositeProperty.options?.map(
|
||||
(option) => option.value,
|
||||
);
|
||||
}
|
||||
|
||||
return definition;
|
||||
};
|
||||
|
||||
const generateTsVectorColumnDefinition = (
|
||||
fieldMetadata: FlatFieldMetadata<FieldMetadataType.TS_VECTOR>,
|
||||
): WorkspaceSchemaColumnDefinition => {
|
||||
const columnName = computeColumnName(fieldMetadata.name);
|
||||
|
||||
return {
|
||||
name: columnName,
|
||||
type: fieldMetadataTypeToColumnType(fieldMetadata.type),
|
||||
isNullable: true,
|
||||
isArray: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
generatedType: 'STORED',
|
||||
asExpression: getTsVectorColumnExpressionFromFields([]), // TODO: setup asExpression in flatFieldMetadata transpilation
|
||||
};
|
||||
};
|
||||
|
||||
const generateRelationColumnDefinition = (
|
||||
fieldMetadata: FlatFieldMetadata<
|
||||
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
|
||||
>,
|
||||
): WorkspaceSchemaColumnDefinition | null => {
|
||||
if (!fieldMetadata.settings || !fieldMetadata.settings.joinColumnName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const joinColumnName = fieldMetadata.settings.joinColumnName;
|
||||
|
||||
return {
|
||||
name: joinColumnName,
|
||||
type: fieldMetadataTypeToColumnType(FieldMetadataType.UUID),
|
||||
isNullable: true,
|
||||
isArray: false,
|
||||
isUnique: false,
|
||||
default: null,
|
||||
};
|
||||
};
|
||||
|
||||
const generateStandardColumnDefinition = (
|
||||
fieldMetadata: FlatFieldMetadata,
|
||||
tableName: string,
|
||||
): WorkspaceSchemaColumnDefinition => {
|
||||
const columnName = computeColumnName(fieldMetadata.name);
|
||||
const serializedDefaultValue = serializeDefaultValue(
|
||||
fieldMetadata.defaultValue,
|
||||
);
|
||||
const columnType = fieldMetadataTypeToColumnType(fieldMetadata.type);
|
||||
|
||||
return {
|
||||
name: columnName,
|
||||
type:
|
||||
columnType === 'enum'
|
||||
? computePostgresEnumName({ tableName, columnName })
|
||||
: columnType,
|
||||
isNullable: fieldMetadata.isNullable ?? true,
|
||||
isArray:
|
||||
fieldMetadata.type === FieldMetadataType.ARRAY ||
|
||||
fieldMetadata.type === FieldMetadataType.MULTI_SELECT,
|
||||
isUnique: fieldMetadata.isUnique ?? false,
|
||||
default: serializedDefaultValue,
|
||||
enumValues:
|
||||
columnType === 'enum'
|
||||
? fieldMetadata.options?.map((option) => option.value)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const generateColumnDefinitions = ({
|
||||
fieldMetadata,
|
||||
flatObjectMetadataWithoutFields,
|
||||
}: {
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
flatObjectMetadataWithoutFields: FlatObjectMetadataWithoutFields;
|
||||
}): WorkspaceSchemaColumnDefinition[] => {
|
||||
const { tableName } = getWorkspaceSchemaContextForMigration({
|
||||
workspaceId: flatObjectMetadataWithoutFields.workspaceId,
|
||||
flatObjectMetadataWithoutFields: flatObjectMetadataWithoutFields,
|
||||
});
|
||||
|
||||
if (isCompositeFlatFieldMetadata(fieldMetadata)) {
|
||||
const compositeType = getCompositeTypeOrThrow(fieldMetadata.type);
|
||||
|
||||
return compositeType.properties.map((property) =>
|
||||
generateCompositeColumnDefinition({
|
||||
compositeProperty: property,
|
||||
parentFieldMetadata: fieldMetadata,
|
||||
flatObjectMetadataWithoutFields: flatObjectMetadataWithoutFields,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isFlatFieldMetadataEntityOfType(fieldMetadata, FieldMetadataType.TS_VECTOR)
|
||||
) {
|
||||
return [generateTsVectorColumnDefinition(fieldMetadata)];
|
||||
}
|
||||
|
||||
if (
|
||||
isFlatFieldMetadataEntityOfType(
|
||||
fieldMetadata,
|
||||
FieldMetadataType.RELATION,
|
||||
) ||
|
||||
isFlatFieldMetadataEntityOfType(
|
||||
fieldMetadata,
|
||||
FieldMetadataType.MORPH_RELATION,
|
||||
)
|
||||
) {
|
||||
const relationColumn = generateRelationColumnDefinition(fieldMetadata);
|
||||
|
||||
return relationColumn ? [relationColumn] : [];
|
||||
}
|
||||
|
||||
return [generateStandardColumnDefinition(fieldMetadata, tableName)];
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { type FlatObjectMetadataWithoutFields } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
|
||||
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
|
||||
|
||||
export type WorkspaceSchemaContextForMigration = {
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
};
|
||||
|
||||
export const getWorkspaceSchemaContextForMigration = ({
|
||||
workspaceId,
|
||||
flatObjectMetadataWithoutFields,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
flatObjectMetadataWithoutFields: FlatObjectMetadataWithoutFields;
|
||||
}): WorkspaceSchemaContextForMigration => {
|
||||
return {
|
||||
schemaName: getWorkspaceSchemaName(workspaceId),
|
||||
tableName: computeObjectTargetTable(flatObjectMetadataWithoutFields),
|
||||
};
|
||||
};
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type';
|
||||
import { type FlatObjectMetadataWithFlatFieldMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-with-flat-field-metadata-maps.type';
|
||||
import { findFlatFieldMetadataInFlatObjectMetadataMapsOrThrow } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/find-flat-field-metadata-in-flat-object-metadata-maps-or-throw.util';
|
||||
import { findFlatObjectMetadataWithFlatFieldMapsInFlatObjectMetadataMapsOrThrow } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/find-flat-object-metadata-with-flat-field-maps-in-flat-object-metadata-maps-or-throw.util';
|
||||
|
||||
import { getWorkspaceSchemaContextForMigration } from './get-workspace-schema-context-for-migration.util';
|
||||
|
||||
export interface WorkspaceSchemaContext {
|
||||
flatObjectMetadataWithFlatFieldMaps: FlatObjectMetadataWithFlatFieldMaps;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
export const prepareWorkspaceSchemaContext = ({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
}: {
|
||||
flatObjectMetadataMaps: FlatObjectMetadataMaps;
|
||||
objectMetadataId: string;
|
||||
}): WorkspaceSchemaContext => {
|
||||
const flatObjectMetadataWithFlatFieldMaps =
|
||||
findFlatObjectMetadataWithFlatFieldMapsInFlatObjectMetadataMapsOrThrow({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
});
|
||||
|
||||
const { schemaName, tableName } = getWorkspaceSchemaContextForMigration({
|
||||
workspaceId: flatObjectMetadataWithFlatFieldMaps.workspaceId,
|
||||
flatObjectMetadataWithoutFields: flatObjectMetadataWithFlatFieldMaps,
|
||||
});
|
||||
|
||||
return {
|
||||
flatObjectMetadataWithFlatFieldMaps,
|
||||
schemaName,
|
||||
tableName,
|
||||
};
|
||||
};
|
||||
|
||||
export const prepareFieldWorkspaceSchemaContext = ({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
fieldMetadataId,
|
||||
}: {
|
||||
flatObjectMetadataMaps: FlatObjectMetadataMaps;
|
||||
objectMetadataId: string;
|
||||
fieldMetadataId: string;
|
||||
}): WorkspaceSchemaContext & { fieldMetadata: FlatFieldMetadata } => {
|
||||
const context = prepareWorkspaceSchemaContext({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
});
|
||||
const fieldMetadata = findFlatFieldMetadataInFlatObjectMetadataMapsOrThrow({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
fieldMetadataId,
|
||||
});
|
||||
|
||||
return {
|
||||
...context,
|
||||
fieldMetadata,
|
||||
};
|
||||
};
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
type EnumFieldMetadataType,
|
||||
type FieldMetadataType,
|
||||
} from 'twenty-shared/types';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type';
|
||||
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
|
||||
import { getCompositeTypeOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/get-composite-type-or-throw.util';
|
||||
import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isCompositeFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-composite-flat-field-metadata.util';
|
||||
import { isEnumFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-enum-flat-field-metadata.util';
|
||||
import { type WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
|
||||
import { computePostgresEnumName } from 'src/engine/workspace-manager/workspace-migration-runner/utils/compute-postgres-enum-name.util';
|
||||
import {
|
||||
WorkspaceSchemaMigrationException,
|
||||
WorkspaceSchemaMigrationExceptionCode,
|
||||
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/exceptions/workspace-schema-migration.exception';
|
||||
|
||||
export interface CreateEnumOperationSpec {
|
||||
operation: EnumOperation.CREATE;
|
||||
enumName: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
export interface DropEnumOperationSpec {
|
||||
operation: EnumOperation.DROP;
|
||||
enumName: string;
|
||||
}
|
||||
|
||||
export interface RenameEnumOperationSpec {
|
||||
operation: EnumOperation.RENAME;
|
||||
fromName: string;
|
||||
toName: string;
|
||||
}
|
||||
|
||||
export type EnumOperationSpec =
|
||||
| CreateEnumOperationSpec
|
||||
| DropEnumOperationSpec
|
||||
| RenameEnumOperationSpec;
|
||||
|
||||
export enum EnumOperation {
|
||||
CREATE = 'create',
|
||||
DROP = 'drop',
|
||||
RENAME = 'rename',
|
||||
}
|
||||
|
||||
const collectEnumOperationsForBasicEnumField = ({
|
||||
flatFieldMetadata,
|
||||
tableName,
|
||||
operation,
|
||||
options,
|
||||
}: {
|
||||
flatFieldMetadata: FlatFieldMetadata<
|
||||
| FieldMetadataType.SELECT
|
||||
| FieldMetadataType.MULTI_SELECT
|
||||
| FieldMetadataType.RATING
|
||||
>;
|
||||
tableName: string;
|
||||
operation: EnumOperation;
|
||||
options?: { newTableName?: string; newFieldName?: string };
|
||||
}): EnumOperationSpec[] => {
|
||||
const enumName = computePostgresEnumName({
|
||||
tableName,
|
||||
columnName: flatFieldMetadata.name,
|
||||
});
|
||||
|
||||
switch (operation) {
|
||||
case EnumOperation.CREATE:
|
||||
return [
|
||||
{
|
||||
operation: EnumOperation.CREATE,
|
||||
enumName,
|
||||
values:
|
||||
flatFieldMetadata.options?.map((option) => option.value) ?? [],
|
||||
},
|
||||
];
|
||||
case EnumOperation.DROP:
|
||||
return [{ operation: EnumOperation.DROP, enumName }];
|
||||
case EnumOperation.RENAME: {
|
||||
const newEnumName = computePostgresEnumName({
|
||||
tableName: options?.newTableName ?? tableName,
|
||||
columnName: options?.newFieldName ?? flatFieldMetadata.name,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
operation: EnumOperation.RENAME,
|
||||
fromName: enumName,
|
||||
toName: newEnumName,
|
||||
},
|
||||
];
|
||||
}
|
||||
default:
|
||||
return assertUnreachable(operation, 'Unsupported enum operation');
|
||||
}
|
||||
};
|
||||
|
||||
const collectEnumOperationsForCompositeField = ({
|
||||
flatFieldMetadata,
|
||||
tableName,
|
||||
operation,
|
||||
options,
|
||||
}: {
|
||||
flatFieldMetadata: FlatFieldMetadata<CompositeFieldMetadataType>;
|
||||
tableName: string;
|
||||
operation: EnumOperation;
|
||||
options?: { newTableName?: string; newFieldName?: string };
|
||||
}): EnumOperationSpec[] => {
|
||||
const compositeType = getCompositeTypeOrThrow(flatFieldMetadata.type);
|
||||
|
||||
return compositeType.properties
|
||||
.filter((property) => isEnumFieldMetadataType(property.type))
|
||||
.map((property) => {
|
||||
const columnName = computeCompositeColumnName(
|
||||
flatFieldMetadata.name,
|
||||
property,
|
||||
);
|
||||
const enumName = computePostgresEnumName({ tableName, columnName });
|
||||
|
||||
switch (operation) {
|
||||
case EnumOperation.CREATE:
|
||||
return {
|
||||
operation: EnumOperation.CREATE,
|
||||
enumName,
|
||||
values: property.options?.map((option) => option.value) ?? [],
|
||||
};
|
||||
case EnumOperation.DROP:
|
||||
return { operation: EnumOperation.DROP, enumName };
|
||||
case EnumOperation.RENAME: {
|
||||
const newOrExistingTableName = options?.newTableName ?? tableName;
|
||||
const newOrExistingColumnName = computeCompositeColumnName(
|
||||
options?.newFieldName ?? flatFieldMetadata.name,
|
||||
property,
|
||||
);
|
||||
const newEnumName = computePostgresEnumName({
|
||||
tableName: newOrExistingTableName,
|
||||
columnName: newOrExistingColumnName,
|
||||
});
|
||||
|
||||
return {
|
||||
operation: EnumOperation.RENAME,
|
||||
fromName: enumName,
|
||||
toName: newEnumName,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return assertUnreachable(operation, 'Unsupported enum operation');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const collectEnumOperationsForField = ({
|
||||
flatFieldMetadata,
|
||||
tableName,
|
||||
operation,
|
||||
options,
|
||||
}: {
|
||||
flatFieldMetadata: FlatFieldMetadata;
|
||||
tableName: string;
|
||||
operation: EnumOperation;
|
||||
options?: { newTableName?: string; newFieldName?: string };
|
||||
}): EnumOperationSpec[] => {
|
||||
if (isCompositeFlatFieldMetadata(flatFieldMetadata)) {
|
||||
return collectEnumOperationsForCompositeField({
|
||||
flatFieldMetadata,
|
||||
tableName,
|
||||
operation,
|
||||
options,
|
||||
});
|
||||
}
|
||||
if (isEnumFlatFieldMetadata(flatFieldMetadata)) {
|
||||
return collectEnumOperationsForBasicEnumField({
|
||||
flatFieldMetadata,
|
||||
tableName,
|
||||
operation,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
export const collectEnumOperationsForObject = ({
|
||||
tableName,
|
||||
operation,
|
||||
enumFlatFieldMetadatas,
|
||||
options,
|
||||
}: {
|
||||
tableName: string;
|
||||
operation: EnumOperation;
|
||||
enumFlatFieldMetadatas: FlatFieldMetadata<EnumFieldMetadataType>[];
|
||||
options?: { newTableName?: string; newFieldName?: string };
|
||||
}): EnumOperationSpec[] => {
|
||||
return enumFlatFieldMetadatas.flatMap((enumFlatFieldMetadata) =>
|
||||
collectEnumOperationsForField({
|
||||
flatFieldMetadata: enumFlatFieldMetadata,
|
||||
tableName,
|
||||
operation,
|
||||
options,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const executeBatchEnumOperations = async ({
|
||||
enumOperations,
|
||||
queryRunner,
|
||||
schemaName,
|
||||
workspaceSchemaManagerService,
|
||||
}: {
|
||||
enumOperations: EnumOperationSpec[];
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
workspaceSchemaManagerService: WorkspaceSchemaManagerService;
|
||||
}): Promise<void> => {
|
||||
if (enumOperations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const enumPromises = enumOperations.map((enumOp) => {
|
||||
switch (enumOp.operation) {
|
||||
case EnumOperation.CREATE:
|
||||
return workspaceSchemaManagerService.enumManager.createEnum({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
enumName: enumOp.enumName,
|
||||
values: enumOp.values,
|
||||
});
|
||||
case EnumOperation.DROP:
|
||||
return workspaceSchemaManagerService.enumManager.dropEnum({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
enumName: enumOp.enumName,
|
||||
});
|
||||
case EnumOperation.RENAME:
|
||||
return workspaceSchemaManagerService.enumManager.renameEnum({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
oldEnumName: enumOp.fromName,
|
||||
newEnumName: enumOp.toName,
|
||||
});
|
||||
default:
|
||||
return assertUnreachable(enumOp, 'Unsupported enum operation');
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(enumPromises);
|
||||
} catch (error) {
|
||||
throw new WorkspaceSchemaMigrationException(
|
||||
`Failed to execute batch enum operations: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
WorkspaceSchemaMigrationExceptionCode.ENUM_OPERATION_FAILED,
|
||||
);
|
||||
}
|
||||
};
|
||||
+352
-9
@@ -1,5 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { FieldMetadataType, type FromTo } from 'twenty-shared/types';
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { type FieldMetadataDefaultValueForAnyType } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
|
||||
import {
|
||||
FieldMetadataDefaultOption,
|
||||
type FieldMetadataComplexOption,
|
||||
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
|
||||
import { EnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/enum-field-metadata-type.type';
|
||||
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
|
||||
import { getCompositeTypeOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/get-composite-type-or-throw.util';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { unserializeDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/unserialize-default-value';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isCompositeFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-composite-flat-field-metadata.util';
|
||||
import { isEnumFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-enum-flat-field-metadata.util';
|
||||
import { findFlatObjectMetadataInFlatObjectMetadataMapsOrThrow } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/find-flat-object-metadata-in-flat-object-metadata-maps-or-throw.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
|
||||
import {
|
||||
type CreateFieldAction,
|
||||
type DeleteFieldAction,
|
||||
@@ -8,25 +28,348 @@ import {
|
||||
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-field-action-v2';
|
||||
import { type RunnerMethodForActionType } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/types/runner-method-for-action-type';
|
||||
import { type WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/types/workspace-migration-action-runner-args.type';
|
||||
import { generateColumnDefinitions } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/generate-column-definitions.util';
|
||||
|
||||
import {
|
||||
prepareFieldWorkspaceSchemaContext,
|
||||
prepareWorkspaceSchemaContext,
|
||||
} from './utils/workspace-schema-context.util';
|
||||
import {
|
||||
collectEnumOperationsForField,
|
||||
EnumOperation,
|
||||
executeBatchEnumOperations,
|
||||
} from './utils/workspace-schema-enum-operations.util';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceSchemaFieldActionRunnerService
|
||||
implements
|
||||
RunnerMethodForActionType<WorkspaceMigrationFieldActionTypeV2, 'schema'>
|
||||
{
|
||||
runDeleteFieldSchemaMigration = async (
|
||||
_action: WorkspaceMigrationActionRunnerArgs<DeleteFieldAction>,
|
||||
) => {
|
||||
constructor(
|
||||
private readonly workspaceSchemaManagerService: WorkspaceSchemaManagerService,
|
||||
) {}
|
||||
|
||||
runDeleteFieldSchemaMigration = async ({
|
||||
action,
|
||||
queryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
}: WorkspaceMigrationActionRunnerArgs<DeleteFieldAction>) => {
|
||||
const { objectMetadataId, fieldMetadataId } = action;
|
||||
const { schemaName, tableName, fieldMetadata } =
|
||||
prepareFieldWorkspaceSchemaContext({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
fieldMetadataId,
|
||||
});
|
||||
|
||||
const flatObjectMetadata =
|
||||
findFlatObjectMetadataInFlatObjectMetadataMapsOrThrow({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
});
|
||||
|
||||
const columnDefinitions = generateColumnDefinitions({
|
||||
fieldMetadata,
|
||||
flatObjectMetadataWithoutFields: flatObjectMetadata,
|
||||
});
|
||||
const columnNamesToDrop = columnDefinitions.map((def) => def.name);
|
||||
|
||||
await this.workspaceSchemaManagerService.columnManager.dropColumns({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnNames: columnNamesToDrop,
|
||||
});
|
||||
|
||||
const enumOperations = collectEnumOperationsForField({
|
||||
flatFieldMetadata: fieldMetadata,
|
||||
tableName,
|
||||
operation: EnumOperation.DROP,
|
||||
});
|
||||
|
||||
await executeBatchEnumOperations({
|
||||
enumOperations,
|
||||
queryRunner,
|
||||
schemaName,
|
||||
workspaceSchemaManagerService: this.workspaceSchemaManagerService,
|
||||
});
|
||||
|
||||
return;
|
||||
};
|
||||
runCreateFieldSchemaMigration = async (
|
||||
_action: WorkspaceMigrationActionRunnerArgs<CreateFieldAction>,
|
||||
) => {
|
||||
|
||||
runCreateFieldSchemaMigration = async ({
|
||||
action,
|
||||
queryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
}: WorkspaceMigrationActionRunnerArgs<CreateFieldAction>) => {
|
||||
const { flatFieldMetadata } = action;
|
||||
const {
|
||||
schemaName,
|
||||
tableName,
|
||||
flatObjectMetadataWithFlatFieldMaps: flatObjectMetadata,
|
||||
} = prepareWorkspaceSchemaContext({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId: flatFieldMetadata.objectMetadataId,
|
||||
});
|
||||
|
||||
const enumOperations = collectEnumOperationsForField({
|
||||
flatFieldMetadata: flatFieldMetadata,
|
||||
tableName,
|
||||
operation: EnumOperation.CREATE,
|
||||
});
|
||||
|
||||
await executeBatchEnumOperations({
|
||||
enumOperations,
|
||||
queryRunner,
|
||||
schemaName,
|
||||
workspaceSchemaManagerService: this.workspaceSchemaManagerService,
|
||||
});
|
||||
|
||||
const columnDefinitions = generateColumnDefinitions({
|
||||
fieldMetadata: flatFieldMetadata,
|
||||
flatObjectMetadataWithoutFields: flatObjectMetadata,
|
||||
});
|
||||
|
||||
await this.workspaceSchemaManagerService.columnManager.addColumns({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnDefinitions,
|
||||
});
|
||||
|
||||
return;
|
||||
};
|
||||
runUpdateFieldSchemaMigration = async (
|
||||
_action: WorkspaceMigrationActionRunnerArgs<UpdateFieldAction>,
|
||||
) => {
|
||||
|
||||
runUpdateFieldSchemaMigration = async ({
|
||||
action,
|
||||
queryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
}: WorkspaceMigrationActionRunnerArgs<UpdateFieldAction>) => {
|
||||
const { objectMetadataId, fieldMetadataId, updates } = action;
|
||||
const {
|
||||
schemaName,
|
||||
tableName,
|
||||
flatObjectMetadataWithFlatFieldMaps: flatObjectMetadata,
|
||||
fieldMetadata: currentFlatFieldMetadata,
|
||||
} = prepareFieldWorkspaceSchemaContext({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
fieldMetadataId,
|
||||
});
|
||||
|
||||
let optimisticFlatFieldMetadata = structuredClone(currentFlatFieldMetadata);
|
||||
|
||||
for (const update of updates) {
|
||||
if (update.property === 'name') {
|
||||
await this.handleFieldNameUpdate(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
optimisticFlatFieldMetadata,
|
||||
update,
|
||||
);
|
||||
optimisticFlatFieldMetadata.name = update.to;
|
||||
}
|
||||
if (update.property === 'defaultValue') {
|
||||
await this.handleFieldDefaultValueUpdate(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
optimisticFlatFieldMetadata,
|
||||
update,
|
||||
);
|
||||
optimisticFlatFieldMetadata.defaultValue = update.to;
|
||||
}
|
||||
if (
|
||||
update.property === 'options' &&
|
||||
isEnumFlatFieldMetadata(optimisticFlatFieldMetadata)
|
||||
) {
|
||||
await this.handleFieldOptionsUpdate(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
flatObjectMetadata,
|
||||
optimisticFlatFieldMetadata,
|
||||
update,
|
||||
);
|
||||
optimisticFlatFieldMetadata.options = update.to ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
private async handleFieldNameUpdate(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
flatFieldMetadata: FlatFieldMetadata,
|
||||
update: {
|
||||
property: 'name';
|
||||
} & FromTo<string>,
|
||||
) {
|
||||
if (isCompositeFlatFieldMetadata(flatFieldMetadata)) {
|
||||
const compositeType = getCompositeTypeOrThrow(flatFieldMetadata.type);
|
||||
|
||||
for (const property of compositeType.properties) {
|
||||
if (
|
||||
property.type === FieldMetadataType.RELATION ||
|
||||
property.type === FieldMetadataType.MORPH_RELATION
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fromCompositeColumnName = computeCompositeColumnName(
|
||||
update.from,
|
||||
property,
|
||||
);
|
||||
const toCompositeColumnName = computeCompositeColumnName(
|
||||
update.to,
|
||||
property,
|
||||
);
|
||||
|
||||
await this.workspaceSchemaManagerService.columnManager.renameColumn({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
oldColumnName: fromCompositeColumnName,
|
||||
newColumnName: toCompositeColumnName,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await this.workspaceSchemaManagerService.columnManager.renameColumn({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
oldColumnName: update.from,
|
||||
newColumnName: update.to,
|
||||
});
|
||||
}
|
||||
|
||||
const enumOperations = collectEnumOperationsForField({
|
||||
flatFieldMetadata: flatFieldMetadata,
|
||||
tableName,
|
||||
operation: EnumOperation.RENAME,
|
||||
options: {
|
||||
newFieldName: update.to,
|
||||
},
|
||||
});
|
||||
|
||||
await executeBatchEnumOperations({
|
||||
enumOperations,
|
||||
queryRunner,
|
||||
schemaName,
|
||||
workspaceSchemaManagerService: this.workspaceSchemaManagerService,
|
||||
});
|
||||
}
|
||||
|
||||
private async handleFieldDefaultValueUpdate(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
flatFieldMetadata: FlatFieldMetadata,
|
||||
update: {
|
||||
property: 'defaultValue';
|
||||
} & FromTo<FieldMetadataDefaultValueForAnyType>,
|
||||
) {
|
||||
if (isCompositeFieldMetadataType(flatFieldMetadata.type)) {
|
||||
const compositeType = getCompositeTypeOrThrow(flatFieldMetadata.type);
|
||||
|
||||
for (const property of compositeType.properties) {
|
||||
if (
|
||||
property.type === FieldMetadataType.RELATION ||
|
||||
property.type === FieldMetadataType.MORPH_RELATION
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const compositeColumnName = computeCompositeColumnName(
|
||||
flatFieldMetadata.name,
|
||||
property,
|
||||
);
|
||||
// @ts-expect-error - TODO: fix this
|
||||
let compositeDefaultValue = update.to?.[property.name];
|
||||
|
||||
const serializedNewDefaultValue = unserializeDefaultValue(
|
||||
compositeDefaultValue,
|
||||
);
|
||||
|
||||
await this.workspaceSchemaManagerService.columnManager.alterColumnDefault(
|
||||
{
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnName: compositeColumnName,
|
||||
defaultValue: serializedNewDefaultValue,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const serializedNewDefaultValue = unserializeDefaultValue(update.to);
|
||||
|
||||
await this.workspaceSchemaManagerService.columnManager.alterColumnDefault(
|
||||
{
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnName: flatFieldMetadata.name,
|
||||
defaultValue: serializedNewDefaultValue,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleFieldOptionsUpdate(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
flatFieldMetadata: FlatFieldMetadata<EnumFieldMetadataType>,
|
||||
update: {
|
||||
property: 'options';
|
||||
} & FromTo<
|
||||
FieldMetadataDefaultOption[] | FieldMetadataComplexOption[] | null
|
||||
>,
|
||||
) {
|
||||
const fromOptionsById = new Map(
|
||||
(update.from ?? [])
|
||||
.filter(
|
||||
(opt: FieldMetadataDefaultOption | FieldMetadataComplexOption) =>
|
||||
Boolean(opt.id),
|
||||
)
|
||||
.map((opt: FieldMetadataDefaultOption | FieldMetadataComplexOption) => [
|
||||
opt.id,
|
||||
opt,
|
||||
]),
|
||||
);
|
||||
|
||||
const valueMapping: Record<string, string> = {};
|
||||
|
||||
for (const toOption of flatFieldMetadata.options ?? []) {
|
||||
if (!toOption.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fromOption = fromOptionsById.get(toOption.id);
|
||||
|
||||
if (fromOption && fromOption.value !== toOption.value) {
|
||||
valueMapping[fromOption.value] = toOption.value;
|
||||
}
|
||||
}
|
||||
|
||||
const enumColumnDefinitions = generateColumnDefinitions({
|
||||
fieldMetadata: flatFieldMetadata,
|
||||
flatObjectMetadataWithoutFields: flatObjectMetadata,
|
||||
});
|
||||
|
||||
for (const enumColumnDefinition of enumColumnDefinitions) {
|
||||
await this.workspaceSchemaManagerService.enumManager.alterEnumValues({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnDefinition: enumColumnDefinition,
|
||||
oldToNewEnumOptionMap: valueMapping,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -13,11 +13,13 @@ export class WorkspaceSchemaIndexActionRunnerService
|
||||
implements
|
||||
RunnerMethodForActionType<WorkspaceMigrationIndexActionTypeV2, 'schema'>
|
||||
{
|
||||
// TODO: Implement index schema operations
|
||||
runDeleteIndexSchemaMigration = async (
|
||||
_action: WorkspaceMigrationActionRunnerArgs<DeleteIndexAction>,
|
||||
) => {
|
||||
return;
|
||||
};
|
||||
|
||||
runCreateIndexSchemaMigration = async (
|
||||
_action: WorkspaceMigrationActionRunnerArgs<CreateIndexAction>,
|
||||
) => {
|
||||
|
||||
+9
-8
@@ -18,53 +18,54 @@ export class WorkspaceSchemaMigrationRunnerService {
|
||||
runWorkspaceSchemaMigration = async ({
|
||||
action,
|
||||
queryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
}: WorkspaceMigrationRunnerArgs) => {
|
||||
switch (action.type) {
|
||||
case 'delete_object': {
|
||||
await this.workspaceSchemaObjectMigrationRunnerService.runDeleteObjectSchemaMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'create_object': {
|
||||
await this.workspaceSchemaObjectMigrationRunnerService.runCreateObjectSchemaMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'update_object': {
|
||||
await this.workspaceSchemaObjectMigrationRunnerService.runUpdateObjectSchemaMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'create_field': {
|
||||
await this.workspaceSchemaFieldMigrationRunnerService.runCreateFieldSchemaMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'update_field': {
|
||||
await this.workspaceSchemaFieldMigrationRunnerService.runUpdateFieldSchemaMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'delete_field': {
|
||||
await this.workspaceSchemaFieldMigrationRunnerService.runDeleteFieldSchemaMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'create_index': {
|
||||
await this.workspaceSchemaIndexMigrationRunnerService.runCreateIndexSchemaMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'delete_index': {
|
||||
await this.workspaceSchemaIndexMigrationRunnerService.runDeleteIndexSchemaMigration(
|
||||
{ action, queryRunner },
|
||||
{ action, queryRunner, flatObjectMetadataMaps },
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
+150
-12
@@ -1,5 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isEnumFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-enum-flat-field-metadata.util';
|
||||
import { WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
|
||||
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
|
||||
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
|
||||
import {
|
||||
type CreateObjectAction,
|
||||
type DeleteObjectAction,
|
||||
@@ -8,25 +13,158 @@ import {
|
||||
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-object-action-v2';
|
||||
import { type RunnerMethodForActionType } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/types/runner-method-for-action-type';
|
||||
import { type WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/types/workspace-migration-action-runner-args.type';
|
||||
import { generateColumnDefinitions } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/generate-column-definitions.util';
|
||||
|
||||
import { prepareWorkspaceSchemaContext } from './utils/workspace-schema-context.util';
|
||||
import {
|
||||
collectEnumOperationsForObject,
|
||||
EnumOperation,
|
||||
executeBatchEnumOperations,
|
||||
} from './utils/workspace-schema-enum-operations.util';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceSchemaObjectActionRunnerService
|
||||
implements
|
||||
RunnerMethodForActionType<WorkspaceMigrationObjectActionTypeV2, 'schema'>
|
||||
{
|
||||
runDeleteObjectSchemaMigration = async (
|
||||
_action: WorkspaceMigrationActionRunnerArgs<DeleteObjectAction>,
|
||||
) => {
|
||||
return;
|
||||
constructor(
|
||||
private readonly workspaceSchemaManagerService: WorkspaceSchemaManagerService,
|
||||
) {}
|
||||
|
||||
runDeleteObjectSchemaMigration = async ({
|
||||
action: { objectMetadataId },
|
||||
queryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
}: WorkspaceMigrationActionRunnerArgs<DeleteObjectAction>) => {
|
||||
const { schemaName, tableName, flatObjectMetadataWithFlatFieldMaps } =
|
||||
prepareWorkspaceSchemaContext({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
});
|
||||
|
||||
await this.workspaceSchemaManagerService.tableManager.dropTable({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
});
|
||||
|
||||
const enumFlatFieldMetadatas = Object.values(
|
||||
flatObjectMetadataWithFlatFieldMaps.fieldsById,
|
||||
)
|
||||
.filter((field): field is FlatFieldMetadata => field != null)
|
||||
.filter((field) => isEnumFlatFieldMetadata(field));
|
||||
|
||||
const enumOperations = collectEnumOperationsForObject({
|
||||
enumFlatFieldMetadatas,
|
||||
tableName,
|
||||
operation: EnumOperation.DROP,
|
||||
});
|
||||
|
||||
await executeBatchEnumOperations({
|
||||
enumOperations,
|
||||
queryRunner,
|
||||
schemaName,
|
||||
workspaceSchemaManagerService: this.workspaceSchemaManagerService,
|
||||
});
|
||||
};
|
||||
runCreateObjectSchemaMigration = async (
|
||||
_action: WorkspaceMigrationActionRunnerArgs<CreateObjectAction>,
|
||||
) => {
|
||||
return;
|
||||
runCreateObjectSchemaMigration = async ({
|
||||
action: { flatObjectMetadataWithoutFields, createFieldActions },
|
||||
queryRunner,
|
||||
}: WorkspaceMigrationActionRunnerArgs<CreateObjectAction>) => {
|
||||
const flatObjectMetadata = flatObjectMetadataWithoutFields;
|
||||
const schemaName = getWorkspaceSchemaName(flatObjectMetadata.workspaceId);
|
||||
const tableName = computeObjectTargetTable(flatObjectMetadata);
|
||||
|
||||
const columnDefinitions = createFieldActions.flatMap((createFieldAction) =>
|
||||
generateColumnDefinitions({
|
||||
fieldMetadata: createFieldAction.flatFieldMetadata,
|
||||
flatObjectMetadataWithoutFields: flatObjectMetadataWithoutFields,
|
||||
}),
|
||||
);
|
||||
|
||||
const enumFlatFieldMetadatas = createFieldActions
|
||||
.map((createFieldAction) => createFieldAction.flatFieldMetadata)
|
||||
.filter((field): field is FlatFieldMetadata => field != null)
|
||||
.filter((field) => isEnumFlatFieldMetadata(field));
|
||||
|
||||
const enumOperations = collectEnumOperationsForObject({
|
||||
enumFlatFieldMetadatas,
|
||||
tableName,
|
||||
operation: EnumOperation.CREATE,
|
||||
});
|
||||
|
||||
await executeBatchEnumOperations({
|
||||
enumOperations,
|
||||
queryRunner,
|
||||
schemaName,
|
||||
workspaceSchemaManagerService: this.workspaceSchemaManagerService,
|
||||
});
|
||||
|
||||
await this.workspaceSchemaManagerService.tableManager.createTable({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnDefinitions,
|
||||
});
|
||||
};
|
||||
runUpdateObjectSchemaMigration = async (
|
||||
_action: WorkspaceMigrationActionRunnerArgs<UpdateObjectAction>,
|
||||
) => {
|
||||
return;
|
||||
|
||||
runUpdateObjectSchemaMigration = async ({
|
||||
action,
|
||||
queryRunner,
|
||||
flatObjectMetadataMaps,
|
||||
}: WorkspaceMigrationActionRunnerArgs<UpdateObjectAction>) => {
|
||||
const { objectMetadataId, updates } = action;
|
||||
const {
|
||||
schemaName,
|
||||
tableName: currentTableName,
|
||||
flatObjectMetadataWithFlatFieldMaps,
|
||||
} = prepareWorkspaceSchemaContext({
|
||||
flatObjectMetadataMaps,
|
||||
objectMetadataId,
|
||||
});
|
||||
|
||||
for (const update of updates) {
|
||||
if (update.property !== 'nameSingular') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedObjectMetadata = {
|
||||
...flatObjectMetadataWithFlatFieldMaps,
|
||||
[update.property]: update.to,
|
||||
};
|
||||
|
||||
const newTableName = computeObjectTargetTable(updatedObjectMetadata);
|
||||
|
||||
if (currentTableName !== newTableName) {
|
||||
await this.workspaceSchemaManagerService.tableManager.renameTable({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
oldTableName: currentTableName,
|
||||
newTableName,
|
||||
});
|
||||
|
||||
const enumFlatFieldMetadatas = Object.values(
|
||||
flatObjectMetadataWithFlatFieldMaps.fieldsById,
|
||||
)
|
||||
.filter((field): field is FlatFieldMetadata => field != null)
|
||||
.filter((field) => isEnumFlatFieldMetadata(field));
|
||||
|
||||
const enumOperations = collectEnumOperationsForObject({
|
||||
enumFlatFieldMetadatas,
|
||||
tableName: currentTableName,
|
||||
operation: EnumOperation.RENAME,
|
||||
options: {
|
||||
newTableName,
|
||||
},
|
||||
});
|
||||
|
||||
await executeBatchEnumOperations({
|
||||
enumOperations,
|
||||
queryRunner,
|
||||
schemaName,
|
||||
workspaceSchemaManagerService: this.workspaceSchemaManagerService,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user