Workspace schema migration runner v2 (#13899)

This commit is contained in:
Weiko
2025-08-14 15:58:25 +02:00
committed by GitHub
parent c1885233bc
commit 1707e00ad4
47 changed files with 4098 additions and 3354 deletions
@@ -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,
];
@@ -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];
@@ -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,
];
@@ -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;
};
@@ -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;
}
@@ -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);
@@ -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);
@@ -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);
}
@@ -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);
@@ -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;
};
@@ -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;
};
@@ -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;
@@ -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;
};
@@ -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;
};
@@ -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
@@ -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();
@@ -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);
@@ -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"');
});
});
});
@@ -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();
});
});
});
@@ -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');
});
});
});
@@ -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');
});
});
});
@@ -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');
});
});
});
@@ -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(' ');
}
}
@@ -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(' ');
@@ -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);
@@ -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);
@@ -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;
}
}
@@ -8,4 +8,5 @@ export type WorkspaceSchemaColumnDefinition = {
isArray?: boolean;
asExpression?: string;
generatedType?: 'STORED' | 'VIRTUAL';
enumValues?: string[];
};
@@ -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(' ');
};
@@ -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;
};
@@ -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;
};
@@ -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;
}
@@ -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,
},
);
}
@@ -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,
@@ -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
});
});
});
});
@@ -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();
});
});
});
@@ -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',
}
@@ -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,
},
]);
});
});
});
@@ -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([]);
});
});
});
@@ -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)];
};
@@ -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),
};
};
@@ -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,
};
};
@@ -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,
);
}
};
@@ -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,
});
}
}
}
@@ -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>,
) => {
@@ -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;
}
@@ -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,
});
}
}
};
}