diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-enum-manager.service.spec.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-enum-manager.service.spec.ts new file mode 100644 index 0000000000..0aedea86de --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-enum-manager.service.spec.ts @@ -0,0 +1,105 @@ +import { type QueryRunner } from 'typeorm'; + +import { WorkspaceSchemaEnumManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service'; +import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type'; + +// PostgreSQL truncates identifiers to 63 bytes (NAMEDATALEN - 1). +const POSTGRES_MAX_IDENTIFIER_LENGTH = 63; + +// Extracts every double-quoted identifier from a SQL string. +const getQuotedIdentifiers = (sql: string): string[] => + [...sql.matchAll(/"([^"]+)"/g)].map((match) => match[1]); + +describe('WorkspaceSchemaEnumManagerService', () => { + let service: WorkspaceSchemaEnumManagerService; + let queryRunner: jest.Mocked< + Pick + >; + let executedSql: string[]; + + beforeEach(() => { + service = new WorkspaceSchemaEnumManagerService(); + executedSql = []; + queryRunner = { + isTransactionActive: true, + query: jest.fn((sql: string) => { + executedSql.push(sql); + + return Promise.resolve(); + }), + } as unknown as jest.Mocked< + Pick + >; + }); + + describe('alterEnumValues', () => { + it('should not generate an enum rename whose target collides with the source when the name exceeds the identifier length limit', async () => { + // Real-world case: long object + field names produce an enum name over + // 63 bytes. Naively appending `_old` truncates the suffix away and the + // RENAME target collides with the existing type. + const columnDefinition: WorkspaceSchemaColumnDefinition = { + name: 'insuranceCoverageClassifications', + type: 'enum', + isArray: true, + isNullable: true, + }; + + await service.alterEnumValues({ + queryRunner: queryRunner as unknown as QueryRunner, + schemaName: 'workspace_adhj7eaegq93fzpgbfpdm8ok3', + tableName: '_personalInsurancePolicyOrQuote', + columnDefinition, + enumValues: ['OPTION_1', 'OPTION_2', 'OPTION_3'], + oldToNewEnumOptionMap: {}, + }); + + const renameStatement = executedSql.find( + (sql) => sql.includes('ALTER TYPE') && sql.includes('RENAME TO'), + ); + + expect(renameStatement).toBeDefined(); + + const [sourceEnum, targetEnum] = getQuotedIdentifiers( + renameStatement as string, + ).slice(-2); + + expect(targetEnum.length).toBeLessThanOrEqual( + POSTGRES_MAX_IDENTIFIER_LENGTH, + ); + // Once Postgres truncates both to 63 bytes they must still differ, + // otherwise the rename targets the type's own name. + expect(targetEnum.slice(0, POSTGRES_MAX_IDENTIFIER_LENGTH)).not.toEqual( + sourceEnum.slice(0, POSTGRES_MAX_IDENTIFIER_LENGTH), + ); + }); + + it('should keep every emitted identifier within the Postgres length limit for long names', async () => { + const columnDefinition: WorkspaceSchemaColumnDefinition = { + name: 'insuranceCoverageClassifications', + type: 'enum', + isArray: true, + isNullable: true, + }; + + await service.alterEnumValues({ + queryRunner: queryRunner as unknown as QueryRunner, + schemaName: 'workspace_adhj7eaegq93fzpgbfpdm8ok3', + tableName: '_personalInsurancePolicyOrQuote', + columnDefinition, + enumValues: ['OPTION_1', 'OPTION_2'], + oldToNewEnumOptionMap: { OPTION_1: 'OPTION_1' }, + }); + + const temporaryIdentifiers = executedSql + .flatMap(getQuotedIdentifiers) + .filter((identifier) => identifier.endsWith('_old')); + + expect(temporaryIdentifiers.length).toBeGreaterThan(0); + temporaryIdentifiers.forEach((identifier) => + expect(identifier.length).toBeLessThanOrEqual( + POSTGRES_MAX_IDENTIFIER_LENGTH, + ), + ); + }); + }); +}); 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 e0a0760eb8..ff075c7398 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 @@ -12,6 +12,19 @@ import { escapeLiteral, } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util'; +const POSTGRES_MAX_IDENTIFIER_LENGTH = 63; + +const buildTemporaryIdentifier = (baseName: string, suffix: string): string => { + const maxBaseLength = POSTGRES_MAX_IDENTIFIER_LENGTH - suffix.length; + + const truncatedBase = + baseName.length <= maxBaseLength + ? baseName + : baseName.slice(0, maxBaseLength); + + return `${truncatedBase}${suffix}`; +}; + export class WorkspaceSchemaEnumManagerService { async createEnum({ queryRunner, @@ -150,7 +163,7 @@ export class WorkspaceSchemaEnumManagerService { columnName, }); - const oldEnumName = `${enumName}_old`; + const oldEnumName = buildTemporaryIdentifier(enumName, '_old'); await this.renameEnum({ queryRunner, @@ -166,7 +179,7 @@ export class WorkspaceSchemaEnumManagerService { values: enumValues, }); - const oldColumnName = `${columnName}_old`; + const oldColumnName = buildTemporaryIdentifier(columnName, '_old'); await this.renameColumn({ queryRunner,