From 43bb8d6043df5a4a43934f2663376791c9d0a690 Mon Sep 17 00:00:00 2001 From: Weiko Date: Mon, 18 Aug 2025 16:33:51 +0200 Subject: [PATCH] Workspace schema migration runner v2 - Fix Enums and Create TsVector (#13955) ## Context - Adding ts-vector generatedType/asExpression as TS_VECTOR settings - Using those settings to setup properly tsVector searchVector column through the new migration runner - Fix enum creation/suppression Note: regarding the new tsVector, we should implement a command to update existing fields TODO: - TS_VECTOR search vector column update (note: should be properly updated whenever the object labelIdentifier is updated or a new TEXT field is added to the object to follow the current logic) - relation type fields and columns are not implemented yet - index migrations --- .../field-metadata-settings.interface.ts | 6 + ...-to-flat-field-metadatas-to-create.util.ts | 12 +- ...metadata-properties-to-compare.constant.ts | 1 + ...d-default-fields-for-custom-object.util.ts | 35 +++++ .../workspace-schema-enum-manager.service.ts | 57 +++++--- ...workspace-schema-column-definition.type.ts | 1 - .../sanitize-default-value.util.spec.ts | 132 ++++-------------- .../utils/build-sql-column-definition.util.ts | 42 +++--- .../utils/sanitize-default-value.util.ts | 13 +- ...schema-field-action-runner.service.spec.ts | 35 ++--- ...chema-object-action-runner.service.spec.ts | 56 ++++---- .../generate-column-definitions.util.spec.ts | 33 ++--- .../utils/generate-column-definitions.util.ts | 85 ++++++----- .../workspace-schema-enum-operations.util.ts | 13 +- ...pace-schema-field-action-runner.service.ts | 32 ++--- ...ace-schema-object-action-runner.service.ts | 24 ++-- 16 files changed, 280 insertions(+), 297 deletions(-) diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts index 999482f274..1d713fcab9 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts @@ -48,6 +48,11 @@ export type FieldMetadataAddressSettings = { subFields?: AllowedAddressSubField[]; }; +export type FieldMetadataTsVectorSettings = { + asExpression?: string; + generatedType?: 'STORED' | 'VIRTUAL'; +}; + type FieldMetadataSettingsMapping = { [FieldMetadataType.NUMBER]: FieldMetadataNumberSettings | null; [FieldMetadataType.DATE]: FieldMetadataDateSettings | null; @@ -56,6 +61,7 @@ type FieldMetadataSettingsMapping = { [FieldMetadataType.RELATION]: FieldMetadataRelationSettings; [FieldMetadataType.ADDRESS]: FieldMetadataAddressSettings | null; [FieldMetadataType.MORPH_RELATION]: FieldMetadataRelationSettings | null; // TODO Should not be null + [FieldMetadataType.TS_VECTOR]: FieldMetadataTsVectorSettings | null; }; export type AllFieldMetadataSettings = diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util.ts index aa0226a656..46ceacfb8d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util.ts @@ -125,6 +125,15 @@ export const fromCreateFieldInputToFlatFieldMetadatasToCreate = async ({ ], }; } + case FieldMetadataType.TS_VECTOR: { + return { + status: 'fail', + error: new FieldMetadataException( + 'TS Vector is not supported for field creation', + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ), + }; + } case FieldMetadataType.UUID: case FieldMetadataType.TEXT: case FieldMetadataType.PHONES: @@ -143,8 +152,7 @@ export const fromCreateFieldInputToFlatFieldMetadatasToCreate = async ({ case FieldMetadataType.RICH_TEXT: case FieldMetadataType.RICH_TEXT_V2: case FieldMetadataType.ACTOR: - case FieldMetadataType.ARRAY: - case FieldMetadataType.TS_VECTOR: { + case FieldMetadataType.ARRAY: { return { status: 'success', result: [ diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-properties-to-compare.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-properties-to-compare.constant.ts index a59a7c1c3d..dc921b8020 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-properties-to-compare.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-properties-to-compare.constant.ts @@ -10,4 +10,5 @@ export const FLAT_OBJECT_METADATA_PROPERTIES_TO_COMPARE = [ 'namePlural', 'nameSingular', 'standardOverrides', // Only if standard + 'labelIdentifierFieldMetadataId', ] as const satisfies (keyof FlatObjectMetadata)[]; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-fields-for-custom-object.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-fields-for-custom-object.util.ts index 958944c24d..30d880205b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-fields-for-custom-object.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-fields-for-custom-object.util.ts @@ -7,6 +7,7 @@ import { BASE_OBJECT_STANDARD_FIELD_IDS, CUSTOM_OBJECT_STANDARD_FIELD_IDS, } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; export const buildDefaultFieldsForCustomObject = ( workspaceId: string, @@ -336,6 +337,39 @@ export const buildDefaultFlatFieldMetadataForCustomObject = ({ settings: null, }; + const searchVectorField: FlatFieldMetadata = { + type: FieldMetadataType.TS_VECTOR, + id: v4(), + isLabelSyncedWithName: false, + isUnique: false, + objectMetadataId, + uniqueIdentifier: CUSTOM_OBJECT_STANDARD_FIELD_IDS.searchVector, + workspaceId, + standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.searchVector, + name: 'searchVector', + label: 'Search vector', + icon: 'IconSearch', + description: 'Search vector', + isNullable: true, + isActive: true, + isCustom: false, + isSystem: true, + defaultValue: null, + + createdAt, + updatedAt: createdAt, + flatRelationTargetFieldMetadata: null, + flatRelationTargetObjectMetadata: null, + options: null, + standardOverrides: null, + relationTargetFieldMetadataId: null, + relationTargetObjectMetadataId: null, + settings: { + asExpression: getTsVectorColumnExpressionFromFields([nameField]), + generatedType: 'STORED', + }, + }; + return { idField, nameField, @@ -344,5 +378,6 @@ export const buildDefaultFlatFieldMetadataForCustomObject = ({ deletedAtField, createdByField, positionField, + searchVectorField, } as const; }; diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service.ts index 1598769f3e..08232a72a5 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service.ts @@ -137,13 +137,15 @@ export class WorkspaceSchemaEnumManagerService { schemaName, tableName, columnDefinition, + enumValues, oldToNewEnumOptionMap, }: { queryRunner: QueryRunner; schemaName: string; tableName: string; columnDefinition: WorkspaceSchemaColumnDefinition; - oldToNewEnumOptionMap?: Record; + enumValues: string[]; + oldToNewEnumOptionMap: Record; }): Promise { const isTransactionAlreadyActive = queryRunner.isTransactionActive; @@ -151,10 +153,7 @@ export class WorkspaceSchemaEnumManagerService { await queryRunner.startTransaction(); } - if ( - !columnDefinition.enumValues || - columnDefinition.enumValues.length === 0 - ) { + if (!enumValues || enumValues.length === 0) { throw new WorkspaceSchemaManagerException( `Cannot alter enum values for column ${columnDefinition.name} because it has no enum values`, WorkspaceSchemaManagerExceptionCode.ENUM_OPERATION_FAILED, @@ -182,7 +181,7 @@ export class WorkspaceSchemaEnumManagerService { queryRunner, schemaName, enumName, - values: columnDefinition.enumValues, + values: enumValues, }); const oldColumnName = `${columnName}_old`; @@ -217,13 +216,13 @@ export class WorkspaceSchemaEnumManagerService { }); } - await this.dropEnum({ queryRunner, schemaName, enumName: oldEnumName }); await this.dropColumn({ queryRunner, schemaName, tableName, columnName: oldColumnName, }); + await this.dropEnum({ queryRunner, schemaName, enumName: oldEnumName }); if (!isTransactionAlreadyActive) { await queryRunner.commitTransaction(); @@ -271,12 +270,14 @@ export class WorkspaceSchemaEnumManagerService { columnDefinition: WorkspaceSchemaColumnDefinition; enumTypeName: string; }): Promise { - const columnDef = buildSqlColumnDefinition({ - ...columnDefinition, - type: enumTypeName, - }); const safeSchemaName = removeSqlDDLInjection(schemaName); const safeTableName = removeSqlDDLInjection(tableName); + + const columnDef = buildSqlColumnDefinition({ + ...columnDefinition, + type: `"${safeSchemaName}"."${enumTypeName}"`, + }); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ADD COLUMN ${columnDef}`; await queryRunner.query(sql); @@ -322,22 +323,36 @@ export class WorkspaceSchemaEnumManagerService { const safeOldColumnName = removeSqlDDLInjection(oldColumnName); const safeNewColumnName = removeSqlDDLInjection(newColumnName); + const newEnumTypeName = computePostgresEnumName({ + tableName, + columnName: newColumnName, + }); + + if (Object.keys(oldToNewEnumOptionMap).length === 0) { + return; + } + const caseStatements = Object.entries(oldToNewEnumOptionMap) .map( ([oldEnumOption, newEnumOption]) => - `WHEN '${removeSqlDDLInjection(oldEnumOption)}' THEN '${removeSqlDDLInjection(newEnumOption)}'`, + `WHEN '${removeSqlDDLInjection(oldEnumOption)}' THEN '${removeSqlDDLInjection(newEnumOption)}'::"${safeSchemaName}"."${newEnumTypeName}"`, ) .join(' '); - const updateSql = ` - UPDATE "${safeSchemaName}"."${safeTableName}" - SET "${safeNewColumnName}" = - CASE "${safeOldColumnName}" - ${caseStatements} - ELSE "${safeOldColumnName}" - END - WHERE "${safeOldColumnName}" IS NOT NULL`; + const mappedValuesCondition = Object.keys(oldToNewEnumOptionMap) + .map((oldValue) => `'${removeSqlDDLInjection(oldValue)}'`) + .join(', '); - await queryRunner.query(updateSql); + // Update rows with mapped enum values + const updateMappedSql = ` + UPDATE "${safeSchemaName}"."${safeTableName}" + SET "${safeNewColumnName}" = + CASE "${safeOldColumnName}"::text + ${caseStatements} + END + WHERE "${safeOldColumnName}" IS NOT NULL + AND "${safeOldColumnName}"::text IN (${mappedValuesCondition})`; + + await queryRunner.query(updateMappedSql); } } diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type.ts index d18c014e57..3b69116c30 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type.ts @@ -8,5 +8,4 @@ export type WorkspaceSchemaColumnDefinition = { isArray?: boolean; asExpression?: string; generatedType?: 'STORED' | 'VIRTUAL'; - enumValues?: string[]; }; diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/__tests__/sanitize-default-value.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/__tests__/sanitize-default-value.util.spec.ts index b3d2d6bb05..1246d92bd3 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/__tests__/sanitize-default-value.util.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/__tests__/sanitize-default-value.util.spec.ts @@ -2,26 +2,15 @@ import { sanitizeDefaultValue } from 'src/engine/twenty-orm/workspace-schema-man describe('sanitizeDefaultValue', () => { describe('allowed functions', () => { - it('should allow gen_random_uuid() function', () => { - // Prepare - const input = 'gen_random_uuid()'; - - // Act - const result = sanitizeDefaultValue(input); - - // Assert - expect(result).toBe('gen_random_uuid()'); - }); - it('should allow uuid_generate_v4() function', () => { // Prepare - const input = 'uuid_generate_v4()'; + const input = 'public.uuid_generate_v4()'; // Act const result = sanitizeDefaultValue(input); // Assert - expect(result).toBe('uuid_generate_v4()'); + expect(result).toBe('public.uuid_generate_v4()'); }); it('should allow now() function', () => { @@ -35,71 +24,10 @@ describe('sanitizeDefaultValue', () => { expect(result).toBe('now()'); }); - it('should allow current_timestamp function', () => { - // Prepare - const input = 'current_timestamp'; - - // Act - const result = sanitizeDefaultValue(input); - - // Assert - expect(result).toBe('current_timestamp'); - }); - - it('should allow current_date function', () => { - // Prepare - const input = 'current_date'; - - // Act - const result = sanitizeDefaultValue(input); - - // Assert - expect(result).toBe('current_date'); - }); - - it('should allow current_time function', () => { - // Prepare - const input = 'current_time'; - - // Act - const result = sanitizeDefaultValue(input); - - // Assert - expect(result).toBe('current_time'); - }); - - it('should allow localtime function', () => { - // Prepare - const input = 'localtime'; - - // Act - const result = sanitizeDefaultValue(input); - - // Assert - expect(result).toBe('localtime'); - }); - - it('should allow localtimestamp function', () => { - // Prepare - const input = 'localtimestamp'; - - // Act - const result = sanitizeDefaultValue(input); - - // Assert - expect(result).toBe('localtimestamp'); - }); - it('should be case insensitive for allowed functions', () => { // Act & Assert - expect(sanitizeDefaultValue('GEN_RANDOM_UUID()')).toBe( - 'GEN_RANDOM_UUID()', - ); + expect(sanitizeDefaultValue('NOW()')).toBe('NOW()'); - expect(sanitizeDefaultValue('CURRENT_TIMESTAMP')).toBe( - 'CURRENT_TIMESTAMP', - ); - expect(sanitizeDefaultValue('Current_Date')).toBe('Current_Date'); }); }); @@ -115,7 +43,7 @@ describe('sanitizeDefaultValue', () => { expect(result).not.toContain('DROP TABLE'); expect(result).not.toContain(';'); expect(result).not.toContain('--'); - expect(result).toBe('DROPTABLEusers'); + expect(result).toBe("'DROPTABLEusers'"); }); it('should sanitize quotes in string values', () => { @@ -127,7 +55,7 @@ describe('sanitizeDefaultValue', () => { // Assert expect(result).not.toContain('"'); - expect(result).toBe('testvalue'); + expect(result).toBe("'testvalue'"); }); it('should sanitize parentheses in non-function values', () => { @@ -140,7 +68,7 @@ describe('sanitizeDefaultValue', () => { // Assert expect(result).not.toContain('('); expect(result).not.toContain(')'); - expect(result).toBe('testvalue'); + expect(result).toBe("'testvalue'"); }); it('should sanitize backslashes', () => { @@ -152,7 +80,7 @@ describe('sanitizeDefaultValue', () => { // Assert expect(result).not.toContain('\\'); - expect(result).toBe('testvalue'); + expect(result).toBe("'testvalue'"); }); it('should sanitize SQL comment patterns', () => { @@ -165,7 +93,7 @@ describe('sanitizeDefaultValue', () => { // Assert expect(result).not.toContain('/*'); expect(result).not.toContain('*/'); - expect(result).toBe('valuecommenttest'); + expect(result).toBe("'valuecommenttest'"); }); it('should remove non-alphanumeric characters but preserve SQL keywords in alphanumeric form', () => { @@ -176,7 +104,7 @@ describe('sanitizeDefaultValue', () => { const result = sanitizeDefaultValue(inputWithKeywords); // Assert - expect(result).toBe('SELECTFROMusers'); + expect(result).toBe("'SELECTFROMusers'"); expect(result).not.toContain('*'); expect(result).not.toContain(' '); }); @@ -191,24 +119,24 @@ describe('sanitizeDefaultValue', () => { const result = sanitizeDefaultValue(input); // Assert - expect(result).toBe('simple_value'); + expect(result).toBe("'simple_value'"); }); - it('should preserve numeric string values', () => { + it('should preserve numeric values', () => { // Prepare - const input = '12345'; + const input = 12345; // Act const result = sanitizeDefaultValue(input); // Assert - expect(result).toBe('12345'); + expect(result).toBe(12345); }); - it('should preserve boolean string values', () => { + it('should preserve boolean values', () => { // Act & Assert - expect(sanitizeDefaultValue('true')).toBe('true'); - expect(sanitizeDefaultValue('false')).toBe('false'); + expect(sanitizeDefaultValue(true)).toBe(true); + expect(sanitizeDefaultValue(false)).toBe(false); }); it('should handle empty string', () => { @@ -219,7 +147,7 @@ describe('sanitizeDefaultValue', () => { const result = sanitizeDefaultValue(input); // Assert - expect(result).toBe(''); + expect(result).toBe("''"); }); it('should remove whitespace but preserve alphanumeric and underscores', () => { @@ -230,7 +158,7 @@ describe('sanitizeDefaultValue', () => { const result = sanitizeDefaultValue(input); // Assert - expect(result).toBe('test'); + expect(result).toBe("'test'"); }); it('should preserve alphanumeric values with underscores', () => { @@ -241,7 +169,7 @@ describe('sanitizeDefaultValue', () => { const result = sanitizeDefaultValue(input); // Assert - expect(result).toBe('test_value_123'); + expect(result).toBe("'test_value_123'"); }); }); @@ -249,14 +177,14 @@ describe('sanitizeDefaultValue', () => { it('should distinguish between allowed functions and similar strings', () => { // Act & Assert expect(sanitizeDefaultValue('now()')).toBe('now()'); - expect(sanitizeDefaultValue('now_test')).toBe('now_test'); - expect(sanitizeDefaultValue('not_now()')).toBe('not_now'); + expect(sanitizeDefaultValue('now_test')).toBe("'now_test'"); + expect(sanitizeDefaultValue('not_now()')).toBe("'not_now'"); }); it('should handle functions with different casing but sanitize non-functions normally', () => { // Act & Assert expect(sanitizeDefaultValue('NOW()')).toBe('NOW()'); - expect(sanitizeDefaultValue('now_function')).toBe('now_function'); + expect(sanitizeDefaultValue('now_function')).toBe("'now_function'"); }); it('should handle complex mixed input', () => { @@ -267,7 +195,7 @@ describe('sanitizeDefaultValue', () => { const result = sanitizeDefaultValue(complexInput); // Assert - expect(result).toBe('testvalueDROPTABLEuserscommentnow'); + expect(result).toBe("'testvalueDROPTABLEuserscommentnow'"); expect(result).not.toContain(';'); expect(result).not.toContain('"'); expect(result).not.toContain('/*'); @@ -277,18 +205,16 @@ describe('sanitizeDefaultValue', () => { }); describe('edge cases', () => { - it('should handle null-like strings', () => { + it('should handle null', () => { // Act & Assert - expect(sanitizeDefaultValue('null')).toBe('null'); - expect(sanitizeDefaultValue('NULL')).toBe('NULL'); - expect(sanitizeDefaultValue('undefined')).toBe('undefined'); + expect(sanitizeDefaultValue(null)).toBe('NULL'); }); it('should handle strings that start with allowed function names', () => { // Act & Assert - expect(sanitizeDefaultValue('now_extended')).toBe('now_extended'); + expect(sanitizeDefaultValue('now_extended')).toBe("'now_extended'"); expect(sanitizeDefaultValue('gen_random_uuid_custom')).toBe( - 'gen_random_uuid_custom', + "'gen_random_uuid_custom'", ); }); @@ -300,7 +226,7 @@ describe('sanitizeDefaultValue', () => { const result = sanitizeDefaultValue(specialChars); // Assert - expect(result).toBe(''); + expect(result).toBe("''"); }); it('should handle very long strings', () => { @@ -311,7 +237,7 @@ describe('sanitizeDefaultValue', () => { const result = sanitizeDefaultValue(longString); // Assert - expect(result).toBe('a'.repeat(1000) + 'DROPTABLEusers'); + expect(result).toBe(`'${'a'.repeat(1000)}DROPTABLEusers'`); expect(result).not.toContain(';'); expect(result).not.toContain(' '); }); diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util.ts index 6f830e9799..cdb25aaf02 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util.ts @@ -1,3 +1,5 @@ +import { isDefined } from 'twenty-shared/utils'; + import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type'; import { sanitizeDefaultValue } from 'src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util'; import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util'; @@ -8,33 +10,35 @@ export const buildSqlColumnDefinition = ( const safeName = removeSqlDDLInjection(column.name); const parts = [`"${safeName}"`]; - if (column.asExpression) { - parts.push(`AS (${column.asExpression})`); // TODO: to sanitize + parts.push(column.isArray ? `${column.type}[]` : column.type); + + if (column.asExpression && column.type === 'tsvector') { + parts.push(`GENERATED ALWAYS AS (${column.asExpression})`); // TODO: to sanitize if (column.generatedType) { parts.push(column.generatedType); } - } else { - const safeType = removeSqlDDLInjection(column.type); + } - parts.push(column.isArray ? `${safeType}[]` : safeType); + if (column.isPrimary) { + parts.push('PRIMARY KEY'); + } - if (column.isPrimary) { - parts.push('PRIMARY KEY'); - } + if (column.isNullable === false) { + parts.push('NOT NULL'); + } - if (column.isNullable === false) { - parts.push('NOT NULL'); - } + if (column.isUnique) { + parts.push('UNIQUE'); + } - if (column.isUnique) { - parts.push('UNIQUE'); - } + if ( + isDefined(column.default) && + column.default !== '' && + column.type !== 'tsvector' + ) { + const safeDefault = sanitizeDefaultValue(column.default); - if (column.default !== undefined) { - const safeDefault = sanitizeDefaultValue(column.default); - - parts.push(`DEFAULT ${safeDefault}`); - } + parts.push(`DEFAULT ${safeDefault}`); } return parts.join(' '); diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts index c9b82224c3..466a144ec2 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts @@ -7,23 +7,14 @@ export const sanitizeDefaultValue = ( return 'NULL'; } - const allowedFunctions = [ - 'gen_random_uuid()', - 'uuid_generate_v4()', - 'now()', - 'current_timestamp', - 'current_date', - 'current_time', - 'localtime', - 'localtimestamp', - ]; + const allowedFunctions = ['public.uuid_generate_v4()', 'now()']; if (typeof defaultValue === 'string') { if (allowedFunctions.includes(defaultValue.toLowerCase())) { return defaultValue; } - return removeSqlDDLInjection(defaultValue); + return `'${removeSqlDDLInjection(defaultValue)}'`; } return defaultValue; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/__tests__/workspace-schema-field-action-runner.service.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/__tests__/workspace-schema-field-action-runner.service.spec.ts index ed0f79eb7e..3437861a24 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/__tests__/workspace-schema-field-action-runner.service.spec.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/__tests__/workspace-schema-field-action-runner.service.spec.ts @@ -6,6 +6,7 @@ import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfa 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 { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; 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', () => { @@ -14,6 +15,7 @@ describe('WorkspaceSchemaFieldActionRunner', () => { let mockQueryRunner: jest.Mocked; const mockWorkspaceId = '20202020-1c25-4d02-bf25-6aeccf7ea419'; + const mockSchemaName = getWorkspaceSchemaName(mockWorkspaceId); const mockObjectMetadataId = '20202020-1c25-4d02-bf25-6aeccf7ea418'; const mockFieldMetadataId = '20202020-1c25-4d02-bf25-6aeccf7ea417'; @@ -99,7 +101,7 @@ describe('WorkspaceSchemaFieldActionRunner', () => { mockSchemaManagerService.columnManager.dropColumns, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_person', columnNames: [ 'homeAddressAddressStreet1', @@ -171,7 +173,7 @@ describe('WorkspaceSchemaFieldActionRunner', () => { mockSchemaManagerService.columnManager.dropColumns, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_person', columnNames: ['status'], }); @@ -181,7 +183,7 @@ describe('WorkspaceSchemaFieldActionRunner', () => { mockSchemaManagerService.enumManager.dropEnum, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, enumName: '_person_status_enum', }); }); @@ -226,7 +228,7 @@ describe('WorkspaceSchemaFieldActionRunner', () => { mockSchemaManagerService.columnManager.dropColumns, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_person', columnNames: ['companyId'], }); @@ -298,7 +300,7 @@ describe('WorkspaceSchemaFieldActionRunner', () => { mockSchemaManagerService.enumManager.createEnum, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, enumName: '_task_priority_enum', values: ['HIGH', 'LOW'], }); @@ -308,17 +310,16 @@ describe('WorkspaceSchemaFieldActionRunner', () => { mockSchemaManagerService.columnManager.addColumns, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_task', columnDefinitions: [ { name: 'priority', - type: '_task_priority_enum', + type: `"${mockSchemaName}"."_task_priority_enum"`, isNullable: true, isArray: false, isUnique: false, default: null, - enumValues: ['HIGH', 'LOW'], }, ], }); @@ -367,7 +368,7 @@ describe('WorkspaceSchemaFieldActionRunner', () => { mockSchemaManagerService.columnManager.addColumns, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_employee', columnDefinitions: [ { @@ -452,7 +453,7 @@ describe('WorkspaceSchemaFieldActionRunner', () => { mockSchemaManagerService.columnManager.renameColumn, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_company', oldColumnName: fromName, newColumnName: toName, @@ -550,16 +551,16 @@ describe('WorkspaceSchemaFieldActionRunner', () => { mockSchemaManagerService.enumManager.alterEnumValues, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_person', + enumValues: ['UPDATED_ACTIVE', 'UPDATED_INACTIVE'], columnDefinition: { name: 'status', - type: '_person_status_enum', + type: `"${mockSchemaName}"."_person_status_enum"`, isNullable: true, isArray: false, isUnique: false, default: null, - enumValues: ['UPDATED_ACTIVE', 'UPDATED_INACTIVE'], }, oldToNewEnumOptionMap: { ACTIVE: 'UPDATED_ACTIVE', // Keep original values @@ -622,7 +623,7 @@ describe('WorkspaceSchemaFieldActionRunner', () => { mockSchemaManagerService.columnManager.alterColumnDefault, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_product', columnName: 'priceAmountMicros', defaultValue: '100000000', @@ -632,7 +633,7 @@ describe('WorkspaceSchemaFieldActionRunner', () => { mockSchemaManagerService.columnManager.alterColumnDefault, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_product', columnName: 'priceCurrencyCode', defaultValue: 'EUR', @@ -678,7 +679,7 @@ describe('WorkspaceSchemaFieldActionRunner', () => { mockSchemaManagerService.columnManager.dropColumns, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_person', columnNames: [], // Empty array - no columns to drop }); @@ -720,7 +721,7 @@ describe('WorkspaceSchemaFieldActionRunner', () => { mockSchemaManagerService.enumManager.createEnum, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, enumName: '_test_emptyStatus_enum', values: [], // Empty enum values array }); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/__tests__/workspace-schema-object-action-runner.service.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/__tests__/workspace-schema-object-action-runner.service.spec.ts index 73fb639ad5..75f55c096c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/__tests__/workspace-schema-object-action-runner.service.spec.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/__tests__/workspace-schema-object-action-runner.service.spec.ts @@ -4,6 +4,7 @@ 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 { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; 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', () => { @@ -13,6 +14,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { const mockWorkspaceId = '20202020-1c25-4d02-bf25-6aeccf7ea419'; const mockObjectMetadataId = '20202020-1c25-4d02-bf25-6aeccf7ea418'; + const mockSchemaName = getWorkspaceSchemaName(mockWorkspaceId); const createMockFlatObjectMetadataMaps = ( objectMetadata: any, @@ -121,7 +123,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.tableManager.dropTable, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_task', }); @@ -133,14 +135,14 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.enumManager.dropEnum, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, enumName: '_task_status_enum', }); expect( mockSchemaManagerService.enumManager.dropEnum, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, enumName: '_task_priority_enum', }); }); @@ -202,7 +204,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.tableManager.dropTable, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_project', }); @@ -214,7 +216,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.enumManager.dropEnum, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, enumName: '_project_tags_enum', }); }); @@ -253,7 +255,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.tableManager.dropTable, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_simpleObject', }); @@ -350,7 +352,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.tableManager.createTable, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_article', columnDefinitions: [ // TEXT field column @@ -361,17 +363,15 @@ describe('WorkspaceSchemaObjectActionRunner', () => { isArray: false, isUnique: false, default: null, - enumValues: undefined, }, // SELECT field column { name: 'status', - type: '_article_status_enum', + type: `"${mockSchemaName}"."_article_status_enum"`, isNullable: true, isArray: false, isUnique: false, default: null, - enumValues: ['DRAFT', 'PUBLISHED'], }, // CURRENCY field columns (2 composite columns) { @@ -397,7 +397,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.enumManager.createEnum, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, enumName: '_article_status_enum', values: ['DRAFT', 'PUBLISHED'], }); @@ -449,13 +449,13 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.tableManager.createTable, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_company', columnDefinitions: [ { name: 'headquartersAddressStreet1', type: 'text', - isNullable: false, + isNullable: true, isUnique: false, default: null, isArray: false, @@ -463,7 +463,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { { name: 'headquartersAddressStreet2', type: 'text', - isNullable: false, + isNullable: true, isUnique: false, default: null, isArray: false, @@ -471,7 +471,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { { name: 'headquartersAddressCity', type: 'text', - isNullable: false, + isNullable: true, isUnique: false, default: null, isArray: false, @@ -479,7 +479,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { { name: 'headquartersAddressPostcode', type: 'text', - isNullable: false, + isNullable: true, isUnique: false, default: null, isArray: false, @@ -487,7 +487,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { { name: 'headquartersAddressState', type: 'text', - isNullable: false, + isNullable: true, isUnique: false, default: null, isArray: false, @@ -495,7 +495,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { { name: 'headquartersAddressCountry', type: 'text', - isNullable: false, + isNullable: true, isUnique: false, default: null, isArray: false, @@ -503,7 +503,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { { name: 'headquartersAddressLat', type: 'numeric', - isNullable: false, + isNullable: true, isUnique: false, default: null, isArray: false, @@ -511,7 +511,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { { name: 'headquartersAddressLng', type: 'numeric', - isNullable: false, + isNullable: true, isUnique: false, default: null, isArray: false, @@ -555,7 +555,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.tableManager.createTable, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_emptyObject', columnDefinitions: [], // Empty columns array }); @@ -629,7 +629,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.tableManager.renameTable, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, oldTableName: '_blogPost', newTableName: '_article', }); @@ -642,7 +642,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.enumManager.renameEnum, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, oldEnumName: '_blogPost_category_enum', newEnumName: '_article_category_enum', }); @@ -650,7 +650,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.enumManager.renameEnum, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, oldEnumName: '_blogPost_tags_enum', newEnumName: '_article_tags_enum', }); @@ -751,7 +751,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.tableManager.renameTable, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, oldTableName: '_oldEntity', newTableName: '_newEntity', }); @@ -764,7 +764,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.enumManager.renameEnum, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, oldEnumName: '_oldEntity_status_enum', newEnumName: '_newEntity_status_enum', }); @@ -810,7 +810,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.tableManager.createTable, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, tableName: '_testObject', columnDefinitions: [], }); @@ -859,7 +859,7 @@ describe('WorkspaceSchemaObjectActionRunner', () => { mockSchemaManagerService.tableManager.renameTable, ).toHaveBeenCalledWith({ queryRunner: mockQueryRunner, - schemaName: 'workspace_1wgvd1injqtife6y4rvfbu3h5', + schemaName: mockSchemaName, oldTableName: '_emptyFieldsObject', newTableName: '_renamedEmptyFieldsObject', }); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/__tests__/generate-column-definitions.util.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/__tests__/generate-column-definitions.util.spec.ts index cd379d9085..f36f846e6f 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/__tests__/generate-column-definitions.util.spec.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/__tests__/generate-column-definitions.util.spec.ts @@ -2,6 +2,7 @@ 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 { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; 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', () => { @@ -12,6 +13,8 @@ describe('Generate Column Definitions', () => { nameSingular: 'person', }); + const mockSchemaName = getWorkspaceSchemaName(mockObjectMetadata.workspaceId); + describe('Enum Field Schema Generation', () => { it('should generate deterministic enum names to prevent schema conflicts', () => { const enumField = getFlatFieldMetadataMock({ @@ -38,7 +41,7 @@ describe('Generate Column Definitions', () => { }); const columns = generateColumnDefinitions({ - fieldMetadata: enumField, + flatFieldMetadata: enumField, flatObjectMetadataWithoutFields: mockObjectMetadata, }); @@ -48,8 +51,7 @@ describe('Generate Column Definitions', () => { expect(column).toEqual({ name: 'status', - type: '_person_status_enum', - enumValues: ['ACTIVE', 'INACTIVE'], + type: `"${mockSchemaName}"."_person_status_enum"`, isArray: false, isNullable: true, isUnique: false, @@ -82,7 +84,7 @@ describe('Generate Column Definitions', () => { }); const columns = generateColumnDefinitions({ - fieldMetadata: multiSelectField, + flatFieldMetadata: multiSelectField, flatObjectMetadataWithoutFields: mockObjectMetadata, }); @@ -92,8 +94,7 @@ describe('Generate Column Definitions', () => { expect(column).toEqual({ name: 'tags', - type: '_person_tags_enum', - enumValues: ['URGENT', 'LOW_PRIORITY'], + type: `"${mockSchemaName}"."_person_tags_enum"`, isArray: true, isNullable: true, isUnique: false, @@ -113,7 +114,7 @@ describe('Generate Column Definitions', () => { }); const columns = generateColumnDefinitions({ - fieldMetadata: relationField, + flatFieldMetadata: relationField, flatObjectMetadataWithoutFields: mockObjectMetadata, }); @@ -134,7 +135,7 @@ describe('Generate Column Definitions', () => { }); const columns = generateColumnDefinitions({ - fieldMetadata: relationField, + flatFieldMetadata: relationField, flatObjectMetadataWithoutFields: mockObjectMetadata, }); @@ -164,7 +165,7 @@ describe('Generate Column Definitions', () => { }); const columns = generateColumnDefinitions({ - fieldMetadata: addressField, + flatFieldMetadata: addressField, flatObjectMetadataWithoutFields: mockObjectMetadata, }); @@ -204,7 +205,7 @@ describe('Generate Column Definitions', () => { }); const columns = generateColumnDefinitions({ - fieldMetadata: currencyField, + flatFieldMetadata: currencyField, flatObjectMetadataWithoutFields: mockObjectMetadata, }); @@ -243,7 +244,7 @@ describe('Generate Column Definitions', () => { }); const columns = generateColumnDefinitions({ - fieldMetadata: textField, + flatFieldMetadata: textField, flatObjectMetadataWithoutFields: mockObjectMetadata, }); @@ -254,7 +255,6 @@ describe('Generate Column Definitions', () => { isNullable: true, isUnique: false, default: null, - enumValues: undefined, isArray: false, }, ]); @@ -270,7 +270,7 @@ describe('Generate Column Definitions', () => { }); const columns = generateColumnDefinitions({ - fieldMetadata: booleanField, + flatFieldMetadata: booleanField, flatObjectMetadataWithoutFields: mockObjectMetadata, }); @@ -281,7 +281,6 @@ describe('Generate Column Definitions', () => { isNullable: true, isUnique: false, default: true, - enumValues: undefined, isArray: false, }, ]); @@ -298,7 +297,7 @@ describe('Generate Column Definitions', () => { }); const columns = generateColumnDefinitions({ - fieldMetadata: textField, + flatFieldMetadata: textField, flatObjectMetadataWithoutFields: mockObjectMetadata, }); @@ -309,7 +308,6 @@ describe('Generate Column Definitions', () => { isNullable: true, isUnique: false, default: null, - enumValues: undefined, isArray: false, }, ]); @@ -324,7 +322,7 @@ describe('Generate Column Definitions', () => { }); const columns = generateColumnDefinitions({ - fieldMetadata: uuidField, + flatFieldMetadata: uuidField, flatObjectMetadataWithoutFields: mockObjectMetadata, }); @@ -335,7 +333,6 @@ describe('Generate Column Definitions', () => { isNullable: true, isUnique: false, default: null, - enumValues: undefined, isArray: false, }, ]); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/generate-column-definitions.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/generate-column-definitions.util.ts index ba49c8a0d4..fc9bacc903 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/generate-column-definitions.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/generate-column-definitions.util.ts @@ -21,7 +21,6 @@ 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'; @@ -34,7 +33,7 @@ export const generateCompositeColumnDefinition = ({ parentFieldMetadata: FlatFieldMetadata; flatObjectMetadataWithoutFields: FlatObjectMetadataWithoutFields; }): WorkspaceSchemaColumnDefinition => { - const { tableName } = getWorkspaceSchemaContextForMigration({ + const { tableName, schemaName } = getWorkspaceSchemaContextForMigration({ workspaceId: flatObjectMetadataWithoutFields.workspaceId, flatObjectMetadataWithoutFields, }); @@ -70,51 +69,47 @@ export const generateCompositeColumnDefinition = ({ name: columnName, type: columnType === 'enum' - ? computePostgresEnumName({ tableName, columnName }) + ? `"${schemaName}"."${computePostgresEnumName({ tableName, columnName })}"` : columnType, - // Align composite column nullability with parent field nullability by default - isNullable: parentFieldMetadata.isNullable ?? true, + isNullable: parentFieldMetadata.isNullable || !compositeProperty.isRequired, 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, + flatFieldMetadata: FlatFieldMetadata, ): WorkspaceSchemaColumnDefinition => { - const columnName = computeColumnName(fieldMetadata.name); + const columnName = computeColumnName(flatFieldMetadata.name); return { name: columnName, - type: fieldMetadataTypeToColumnType(fieldMetadata.type), + type: fieldMetadataTypeToColumnType(flatFieldMetadata.type), isNullable: true, isArray: false, isUnique: false, default: null, - generatedType: 'STORED', - asExpression: getTsVectorColumnExpressionFromFields([]), // TODO: setup asExpression in flatFieldMetadata transpilation + asExpression: flatFieldMetadata.settings?.asExpression ?? undefined, + generatedType: flatFieldMetadata.settings?.generatedType ?? undefined, }; }; const generateRelationColumnDefinition = ( - fieldMetadata: FlatFieldMetadata< + flatFieldMetadata: FlatFieldMetadata< FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION >, ): WorkspaceSchemaColumnDefinition | null => { - if (!fieldMetadata.settings || !fieldMetadata.settings.joinColumnName) { + if ( + !flatFieldMetadata.settings || + !flatFieldMetadata.settings.joinColumnName + ) { return null; } - const joinColumnName = fieldMetadata.settings.joinColumnName; + const joinColumnName = flatFieldMetadata.settings.joinColumnName; return { name: joinColumnName, @@ -127,78 +122,80 @@ const generateRelationColumnDefinition = ( }; const generateStandardColumnDefinition = ( - fieldMetadata: FlatFieldMetadata, + flatFieldMetadata: FlatFieldMetadata, tableName: string, + schemaName: string, ): WorkspaceSchemaColumnDefinition => { - const columnName = computeColumnName(fieldMetadata.name); + const columnName = computeColumnName(flatFieldMetadata.name); const serializedDefaultValue = serializeDefaultValue( - fieldMetadata.defaultValue, + flatFieldMetadata.defaultValue, ); - const columnType = fieldMetadataTypeToColumnType(fieldMetadata.type); + const columnType = fieldMetadataTypeToColumnType(flatFieldMetadata.type); return { name: columnName, type: columnType === 'enum' - ? computePostgresEnumName({ tableName, columnName }) + ? `"${schemaName}"."${computePostgresEnumName({ tableName, columnName })}"` : columnType, - isNullable: fieldMetadata.isNullable ?? true, + isNullable: flatFieldMetadata.isNullable ?? true, isArray: - fieldMetadata.type === FieldMetadataType.ARRAY || - fieldMetadata.type === FieldMetadataType.MULTI_SELECT, - isUnique: fieldMetadata.isUnique ?? false, + flatFieldMetadata.type === FieldMetadataType.ARRAY || + flatFieldMetadata.type === FieldMetadataType.MULTI_SELECT, + isUnique: flatFieldMetadata.isUnique ?? false, default: serializedDefaultValue, - enumValues: - columnType === 'enum' - ? fieldMetadata.options?.map((option) => option.value) - : undefined, }; }; export const generateColumnDefinitions = ({ - fieldMetadata, + flatFieldMetadata, flatObjectMetadataWithoutFields, }: { - fieldMetadata: FlatFieldMetadata; + flatFieldMetadata: FlatFieldMetadata; flatObjectMetadataWithoutFields: FlatObjectMetadataWithoutFields; }): WorkspaceSchemaColumnDefinition[] => { - const { tableName } = getWorkspaceSchemaContextForMigration({ + const { tableName, schemaName } = getWorkspaceSchemaContextForMigration({ workspaceId: flatObjectMetadataWithoutFields.workspaceId, flatObjectMetadataWithoutFields: flatObjectMetadataWithoutFields, }); - if (isCompositeFlatFieldMetadata(fieldMetadata)) { - const compositeType = getCompositeTypeOrThrow(fieldMetadata.type); + if (isCompositeFlatFieldMetadata(flatFieldMetadata)) { + const compositeType = getCompositeTypeOrThrow(flatFieldMetadata.type); return compositeType.properties.map((property) => generateCompositeColumnDefinition({ compositeProperty: property, - parentFieldMetadata: fieldMetadata, + parentFieldMetadata: flatFieldMetadata, flatObjectMetadataWithoutFields: flatObjectMetadataWithoutFields, }), ); } if ( - isFlatFieldMetadataEntityOfType(fieldMetadata, FieldMetadataType.TS_VECTOR) + isFlatFieldMetadataEntityOfType( + flatFieldMetadata, + FieldMetadataType.TS_VECTOR, + ) ) { - return [generateTsVectorColumnDefinition(fieldMetadata)]; + return [generateTsVectorColumnDefinition(flatFieldMetadata)]; } if ( isFlatFieldMetadataEntityOfType( - fieldMetadata, + flatFieldMetadata, FieldMetadataType.RELATION, ) || isFlatFieldMetadataEntityOfType( - fieldMetadata, + flatFieldMetadata, FieldMetadataType.MORPH_RELATION, ) ) { - const relationColumn = generateRelationColumnDefinition(fieldMetadata); + const relationColumn = generateRelationColumnDefinition(flatFieldMetadata); return relationColumn ? [relationColumn] : []; } - return [generateStandardColumnDefinition(fieldMetadata, tableName)]; + return [ + generateStandardColumnDefinition(flatFieldMetadata, tableName, schemaName), + ]; }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/workspace-schema-enum-operations.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/workspace-schema-enum-operations.util.ts index ed8da191a3..4527baabd8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/workspace-schema-enum-operations.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/utils/workspace-schema-enum-operations.util.ts @@ -1,7 +1,4 @@ -import { - type EnumFieldMetadataType, - type FieldMetadataType, -} from 'twenty-shared/types'; +import { type FieldMetadataType } from 'twenty-shared/types'; import { assertUnreachable } from 'twenty-shared/utils'; import { type QueryRunner } from 'typeorm'; @@ -186,17 +183,17 @@ export const collectEnumOperationsForField = ({ export const collectEnumOperationsForObject = ({ tableName, operation, - enumFlatFieldMetadatas, + flatFieldMetadatas, options, }: { tableName: string; operation: EnumOperation; - enumFlatFieldMetadatas: FlatFieldMetadata[]; + flatFieldMetadatas: FlatFieldMetadata[]; options?: { newTableName?: string; newFieldName?: string }; }): EnumOperationSpec[] => { - return enumFlatFieldMetadatas.flatMap((enumFlatFieldMetadata) => + return flatFieldMetadatas.flatMap((flatFieldMetadata) => collectEnumOperationsForField({ - flatFieldMetadata: enumFlatFieldMetadata, + flatFieldMetadata, tableName, operation, options, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/workspace-schema-field-action-runner.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/workspace-schema-field-action-runner.service.ts index f585959358..fdf5eb2591 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/workspace-schema-field-action-runner.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/workspace-schema-field-action-runner.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { FieldMetadataType, type FromTo } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { type QueryRunner } from 'typeorm'; import { type FieldMetadataDefaultValueForAnyType } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface'; @@ -69,7 +70,7 @@ export class WorkspaceSchemaFieldActionRunnerService }); const columnDefinitions = generateColumnDefinitions({ - fieldMetadata, + flatFieldMetadata: fieldMetadata, flatObjectMetadataWithoutFields: flatObjectMetadata, }); const columnNamesToDrop = columnDefinitions.map((def) => def.name); @@ -126,7 +127,7 @@ export class WorkspaceSchemaFieldActionRunnerService }); const columnDefinitions = generateColumnDefinitions({ - fieldMetadata: flatFieldMetadata, + flatFieldMetadata: flatFieldMetadata, flatObjectMetadataWithoutFields: flatObjectMetadata, }); @@ -333,32 +334,28 @@ export class WorkspaceSchemaFieldActionRunnerService ) { const fromOptionsById = new Map( (update.from ?? []) - .filter( - (opt: FieldMetadataDefaultOption | FieldMetadataComplexOption) => - Boolean(opt.id), - ) - .map((opt: FieldMetadataDefaultOption | FieldMetadataComplexOption) => [ - opt.id, - opt, - ]), + .filter((opt) => isDefined(opt.id)) + .map((opt) => [opt.id, opt]), + ); + + const toOptionsById = new Map( + (update.to ?? []) + .filter((opt) => isDefined(opt.id)) + .map((opt) => [opt.id, opt]), ); const valueMapping: Record = {}; - for (const toOption of flatFieldMetadata.options ?? []) { - if (!toOption.id) { - continue; - } - + for (const toOption of toOptionsById.values()) { const fromOption = fromOptionsById.get(toOption.id); - if (fromOption && fromOption.value !== toOption.value) { + if (fromOption) { valueMapping[fromOption.value] = toOption.value; } } const enumColumnDefinitions = generateColumnDefinitions({ - fieldMetadata: flatFieldMetadata, + flatFieldMetadata: flatFieldMetadata, flatObjectMetadataWithoutFields: flatObjectMetadata, }); @@ -368,6 +365,7 @@ export class WorkspaceSchemaFieldActionRunnerService schemaName, tableName, columnDefinition: enumColumnDefinition, + enumValues: update.to?.map((opt) => opt.value) ?? [], oldToNewEnumOptionMap: valueMapping, }); } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/workspace-schema-object-action-runner.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/workspace-schema-object-action-runner.service.ts index 693721d510..5d1f6bd5c0 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/workspace-schema-object-action-runner.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/workspace-schema-object-action-runner.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; 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 { WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service'; import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; @@ -55,7 +56,7 @@ export class WorkspaceSchemaObjectActionRunnerService .filter((field) => isEnumFlatFieldMetadata(field)); const enumOperations = collectEnumOperationsForObject({ - enumFlatFieldMetadatas, + flatFieldMetadatas: enumFlatFieldMetadatas, tableName, operation: EnumOperation.DROP, }); @@ -77,18 +78,21 @@ export class WorkspaceSchemaObjectActionRunnerService const columnDefinitions = createFieldActions.flatMap((createFieldAction) => generateColumnDefinitions({ - fieldMetadata: createFieldAction.flatFieldMetadata, + flatFieldMetadata: createFieldAction.flatFieldMetadata, flatObjectMetadataWithoutFields: flatObjectMetadataWithoutFields, }), ); - const enumFlatFieldMetadatas = createFieldActions + const enumOrCompositeFlatFieldMetadatas = createFieldActions .map((createFieldAction) => createFieldAction.flatFieldMetadata) .filter((field): field is FlatFieldMetadata => field != null) - .filter((field) => isEnumFlatFieldMetadata(field)); + .filter( + (field) => + isEnumFlatFieldMetadata(field) || isCompositeFlatFieldMetadata(field), + ); const enumOperations = collectEnumOperationsForObject({ - enumFlatFieldMetadatas, + flatFieldMetadatas: enumOrCompositeFlatFieldMetadatas, tableName, operation: EnumOperation.CREATE, }); @@ -143,14 +147,18 @@ export class WorkspaceSchemaObjectActionRunnerService newTableName, }); - const enumFlatFieldMetadatas = Object.values( + const enumOrCompositeFlatFieldMetadatas = Object.values( flatObjectMetadataWithFlatFieldMaps.fieldsById, ) .filter((field): field is FlatFieldMetadata => field != null) - .filter((field) => isEnumFlatFieldMetadata(field)); + .filter( + (field) => + isEnumFlatFieldMetadata(field) || + isCompositeFlatFieldMetadata(field), + ); const enumOperations = collectEnumOperationsForObject({ - enumFlatFieldMetadatas, + flatFieldMetadatas: enumOrCompositeFlatFieldMetadatas, tableName: currentTableName, operation: EnumOperation.RENAME, options: {