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