diff --git a/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts b/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts index 681444377a5..caf281673ea 100644 --- a/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts +++ b/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts @@ -23,4 +23,5 @@ export enum TwentyORMExceptionCode { CONNECT_UNIQUE_CONSTRAINT_ERROR = 'CONNECT_UNIQUE_CONSTRAINT_ERROR', MISSING_MAIN_ALIAS_TARGET = 'MISSING_MAIN_ALIAS_TARGET', METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED', + ENUM_TYPE_NAME_NOT_FOUND = 'ENUM_TYPE_NAME_NOT_FOUND', } diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/__tests__/workspace-schema-manager.service.spec.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/__tests__/workspace-schema-manager.service.spec.ts new file mode 100644 index 00000000000..2cb2756c247 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/__tests__/workspace-schema-manager.service.spec.ts @@ -0,0 +1,176 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { QueryRunner } from 'typeorm'; + +import { WorkspaceSchemaColumnManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service'; +import { WorkspaceSchemaEnumManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service'; +import { WorkspaceSchemaForeignKeyManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-foreign-key-manager.service'; +import { WorkspaceSchemaIndexManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-index-manager.service'; +import { WorkspaceSchemaTableManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-table-manager.service'; +import { WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service'; + +describe('WorkspaceSchemaManager', () => { + let service: WorkspaceSchemaManagerService; + let mockQueryRunner: jest.Mocked; + let tableManager: jest.Mocked; + let columnManager: jest.Mocked; + let indexManager: jest.Mocked; + let enumManager: jest.Mocked; + let foreignKeyManager: jest.Mocked; + + beforeEach(async () => { + // Prepare + tableManager = { + createTable: jest.fn(), + dropTable: jest.fn(), + renameTable: jest.fn(), + tableExists: jest.fn(), + } as any; + + columnManager = { + addColumn: jest.fn(), + dropColumn: jest.fn(), + renameColumn: jest.fn(), + columnExists: jest.fn(), + } as any; + + indexManager = { + createIndex: jest.fn(), + dropIndex: jest.fn(), + indexExists: jest.fn(), + } as any; + + enumManager = { + createEnum: jest.fn(), + dropEnum: jest.fn(), + alterEnumValues: jest.fn(), + } as any; + + foreignKeyManager = { + createForeignKey: jest.fn(), + dropForeignKey: jest.fn(), + } as any; + + mockQueryRunner = { + query: jest.fn(), + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspaceSchemaManagerService, + { provide: WorkspaceSchemaTableManagerService, useValue: tableManager }, + { + provide: WorkspaceSchemaColumnManagerService, + useValue: columnManager, + }, + { provide: WorkspaceSchemaIndexManagerService, useValue: indexManager }, + { provide: WorkspaceSchemaEnumManagerService, useValue: enumManager }, + { + provide: WorkspaceSchemaForeignKeyManagerService, + useValue: foreignKeyManager, + }, + ], + }).compile(); + + service = module.get( + WorkspaceSchemaManagerService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should initialize all managers', () => { + expect(service.tableManager).toBe(tableManager); + expect(service.columnManager).toBe(columnManager); + expect(service.indexManager).toBe(indexManager); + expect(service.enumManager).toBe(enumManager); + expect(service.foreignKeyManager).toBe(foreignKeyManager); + }); + }); + + describe('manager access', () => { + it('should provide access to table manager', () => { + // Act & Assert + expect(service.tableManager).toBeInstanceOf(Object); + expect(service.tableManager.createTable).toBeDefined(); + }); + + it('should provide access to column manager', () => { + // Act & Assert + expect(service.columnManager).toBeInstanceOf(Object); + expect(service.columnManager.addColumn).toBeDefined(); + }); + + it('should provide access to index manager', () => { + // Act & Assert + expect(service.indexManager).toBeInstanceOf(Object); + expect(service.indexManager.createIndex).toBeDefined(); + }); + + it('should provide access to enum manager', () => { + // Act & Assert + expect(service.enumManager).toBeInstanceOf(Object); + expect(service.enumManager.createEnum).toBeDefined(); + }); + + it('should provide access to foreign key manager', () => { + // Act & Assert + expect(service.foreignKeyManager).toBeInstanceOf(Object); + expect(service.foreignKeyManager.createForeignKey).toBeDefined(); + }); + }); + + describe('integration scenarios', () => { + it('should support creating a complete table structure', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'users'; + + // Act + await service.tableManager.createTable( + mockQueryRunner, + schemaName, + tableName, + [ + { name: 'id', type: 'uuid', isPrimary: true }, + { name: 'name', type: 'varchar', isNullable: false }, + { name: 'status', type: 'varchar' }, + ], + ); + + await service.enumManager.createEnum( + mockQueryRunner, + schemaName, + 'user_status_enum', + ['ACTIVE', 'INACTIVE'], + ); + + await service.indexManager.createIndex( + mockQueryRunner, + schemaName, + tableName, + { + name: 'idx_users_name', + columns: ['name'], + }, + ); + + // Assert + expect(tableManager.createTable).toHaveBeenCalled(); + expect(enumManager.createEnum).toHaveBeenCalled(); + expect(indexManager.createIndex).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-column-manager.service.spec.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-column-manager.service.spec.ts new file mode 100644 index 00000000000..d54691cf640 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-column-manager.service.spec.ts @@ -0,0 +1,463 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { QueryRunner } from 'typeorm'; + +import { WorkspaceSchemaColumnManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service'; + +describe('WorkspaceSchemaColumnManager', () => { + let service: WorkspaceSchemaColumnManagerService; + let mockQueryRunner: jest.Mocked; + + beforeEach(async () => { + mockQueryRunner = { + query: jest.fn().mockResolvedValue([]), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [WorkspaceSchemaColumnManagerService], + }).compile(); + + service = module.get( + WorkspaceSchemaColumnManagerService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('addColumn', () => { + it('should add column with sanitized names', async () => { + // Prepare + const column = { + name: 'user_name', + type: 'varchar', + isNullable: false, + }; + + // Act + await service.addColumn( + mockQueryRunner, + 'workspace_test', + 'users', + column, + ); + + // Assert + expect(mockQueryRunner.query).toHaveBeenCalledWith( + expect.stringContaining( + 'ALTER TABLE "workspace_test"."users" ADD COLUMN', + ), + ); + expect(mockQueryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('"user_name" varchar NOT NULL'), + ); + }); + + it('should sanitize schema, table, and column names', async () => { + // Prepare + const column = { + name: 'col; DROP', + type: 'varchar; EXEC', + }; + + // Act + await service.addColumn( + mockQueryRunner, + 'schema; DELETE', + 'table; UPDATE', + column, + ); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('"schemaDELETE"."tableUPDATE"'); + expect(actualCall).toContain('"colDROP" varcharEXEC'); + }); + + it('should handle array columns', async () => { + // Prepare + const column = { + name: 'tags', + type: 'varchar', + isArray: true, + }; + + // Act + await service.addColumn( + mockQueryRunner, + 'workspace_test', + 'posts', + column, + ); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('"tags" varchar[]'); + }); + + it('should handle columns with defaults', async () => { + // Prepare + const column = { + name: 'status', + type: 'varchar', + default: 'active', + }; + + // Act + await service.addColumn( + mockQueryRunner, + 'workspace_test', + 'posts', + column, + ); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('DEFAULT active'); + }); + + it('should handle primary key columns', async () => { + // Prepare + const column = { + name: 'id', + type: 'uuid', + isPrimary: true, + }; + + // Act + await service.addColumn( + mockQueryRunner, + 'workspace_test', + 'posts', + column, + ); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('"id" uuid PRIMARY KEY'); + }); + + it('should handle unique columns', async () => { + // Prepare + const column = { + name: 'email', + type: 'varchar', + isUnique: true, + }; + + // Act + await service.addColumn( + mockQueryRunner, + 'workspace_test', + 'users', + column, + ); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('"email" varchar UNIQUE'); + }); + }); + + describe('dropColumn', () => { + it('should drop column with sanitized names', async () => { + await service.dropColumn( + mockQueryRunner, + 'workspace_test', + 'users', + 'old_column', + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + 'ALTER TABLE "workspace_test"."users" DROP COLUMN IF EXISTS "old_column"', + ); + }); + + it('should sanitize all input parameters', async () => { + await service.dropColumn( + mockQueryRunner, + 'schema; DROP', + 'table; DELETE', + 'col; TRUNCATE', + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + 'ALTER TABLE "schemaDROP"."tableDELETE" DROP COLUMN IF EXISTS "colTRUNCATE"', + ); + }); + }); + + describe('dropColumns', () => { + it('should drop multiple columns', async () => { + const columnNames = ['col1', 'col2', 'col3']; + + await service.dropColumns( + mockQueryRunner, + 'workspace_test', + 'users', + columnNames, + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('ALTER TABLE "workspace_test"."users"'); + expect(actualCall).toContain('DROP COLUMN IF EXISTS "col1"'); + expect(actualCall).toContain('DROP COLUMN IF EXISTS "col2"'); + expect(actualCall).toContain('DROP COLUMN IF EXISTS "col3"'); + }); + + it('should handle empty column list', async () => { + await service.dropColumns(mockQueryRunner, 'schema', 'table', []); + + expect(mockQueryRunner.query).not.toHaveBeenCalled(); + }); + + it('should sanitize column names', async () => { + const columnNames = ['col1; DROP', 'col2; DELETE']; + + await service.dropColumns( + mockQueryRunner, + 'schema', + 'table', + columnNames, + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('DROP COLUMN IF EXISTS "col1DROP"'); + expect(actualCall).toContain('DROP COLUMN IF EXISTS "col2DELETE"'); + }); + }); + + describe('renameColumn', () => { + it('should rename column with sanitized names', async () => { + await service.renameColumn( + mockQueryRunner, + 'workspace_test', + 'users', + 'old_name', + 'new_name', + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + 'ALTER TABLE "workspace_test"."users" RENAME COLUMN "old_name" TO "new_name"', + ); + }); + + it('should sanitize all parameters', async () => { + await service.renameColumn( + mockQueryRunner, + 'schema; DROP', + 'table; DELETE', + 'old; TRUNCATE', + 'new; UPDATE', + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + 'ALTER TABLE "schemaDROP"."tableDELETE" RENAME COLUMN "oldTRUNCATE" TO "newUPDATE"', + ); + }); + }); + + describe('alterColumnType', () => { + it('should alter column type', async () => { + await service.alterColumnType( + mockQueryRunner, + 'workspace_test', + 'users', + 'age', + 'bigint', + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + expect.stringContaining( + 'ALTER TABLE "workspace_test"."users" ALTER COLUMN "age" TYPE bigint', + ), + ); + }); + + it('should handle USING clause', async () => { + await service.alterColumnType( + mockQueryRunner, + 'workspace_test', + 'users', + 'data', + 'jsonb', + 'data::jsonb', + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('USING data::jsonb'); + }); + }); + + describe('columnExists', () => { + it('should check if column exists', async () => { + mockQueryRunner.query.mockResolvedValue([{ exists: true }]); + + const result = await service.columnExists( + mockQueryRunner, + 'workspace_test', + 'users', + 'email', + ); + + expect(result).toBe(true); + expect(mockQueryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('SELECT EXISTS'), + ['workspace_test', 'users', 'email'], + ); + }); + + it('should return false when column does not exist', async () => { + mockQueryRunner.query.mockResolvedValue([{ exists: false }]); + + const result = await service.columnExists( + mockQueryRunner, + 'workspace_test', + 'users', + 'nonexistent', + ); + + expect(result).toBe(false); + }); + + it('should sanitize input parameters', async () => { + mockQueryRunner.query.mockResolvedValue([{ exists: false }]); + + await service.columnExists( + mockQueryRunner, + 'schema; DROP', + 'table; DELETE', + 'col; TRUNCATE', + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith(expect.any(String), [ + 'schemaDROP', + 'tableDELETE', + 'colTRUNCATE', + ]); + }); + }); + + describe('buildColumnDefinition', () => { + it('should build basic column definition', () => { + const column = { + name: 'username', + type: 'varchar', + }; + + const result = (service as any).buildColumnDefinition(column); + + expect(result).toBe('"username" varchar'); + }); + + it('should build column with constraints', () => { + const column = { + name: 'id', + type: 'uuid', + isPrimary: true, + isNullable: false, + isUnique: true, + default: 'gen_random_uuid()', + }; + + const result = (service as any).buildColumnDefinition(column); + + expect(result).toContain('"id" uuid'); + expect(result).toContain('PRIMARY KEY'); + expect(result).toContain('NOT NULL'); + expect(result).toContain('UNIQUE'); + expect(result).toContain('DEFAULT gen_random_uuid()'); + }); + + it('should build array column', () => { + const column = { + name: 'tags', + type: 'varchar', + isArray: true, + }; + + const result = (service as any).buildColumnDefinition(column); + + expect(result).toBe('"tags" varchar[]'); + }); + + it('should build generated column', () => { + const column = { + name: 'full_name', + type: 'varchar', + asExpression: "first_name || ' ' || last_name", + generatedType: 'STORED' as const, + }; + + const result = (service as any).buildColumnDefinition(column); + + expect(result).toContain("AS (first_name || ' ' || last_name)"); + expect(result).toContain('STORED'); + }); + + it('should sanitize column name and type', () => { + const column = { + name: 'col; DROP', + type: 'varchar; EXEC', + }; + + const result = (service as any).buildColumnDefinition(column); + + expect(result).toBe('"colDROP" varcharEXEC'); + }); + }); + + describe('SQL injection protection', () => { + it('should prevent SQL injection in column operations', async () => { + const maliciousColumn = { + name: "name'; DROP TABLE users; --", + type: "varchar'; EXEC xp_cmdshell; --", + default: "'; DELETE FROM admin; --", + }; + + await service.addColumn( + mockQueryRunner, + 'schema', + 'table', + maliciousColumn, + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).not.toContain('DROP TABLE users'); + expect(actualCall).not.toContain('EXEC xp_cmdshell'); + expect(actualCall).not.toContain('DELETE FROM admin'); + expect(actualCall).toContain('"nameDROPTABLEusers"'); + expect(actualCall).toContain('varcharEXECxp_cmdshell'); + }); + + it('should prevent SQL injection in rename operations', async () => { + const maliciousOldName = "old'; DROP TABLE users; --"; + const maliciousNewName = "new'; DELETE FROM admin; --"; + + await service.renameColumn( + mockQueryRunner, + 'schema', + 'table', + maliciousOldName, + maliciousNewName, + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).not.toContain('DROP TABLE users'); + expect(actualCall).not.toContain('DELETE FROM admin'); + expect(actualCall).toContain('"oldDROPTABLEusers"'); + expect(actualCall).toContain('"newDELETEFROMadmin"'); + }); + }); +}); 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 00000000000..49e7e33dd2b --- /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,610 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { QueryRunner } from 'typeorm'; + +import { + TwentyORMException, + TwentyORMExceptionCode, +} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; +import { WorkspaceSchemaEnumManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service'; + +describe('WorkspaceSchemaEnumManager', () => { + let service: WorkspaceSchemaEnumManagerService; + let mockQueryRunner: jest.Mocked; + + beforeEach(async () => { + mockQueryRunner = { + query: jest.fn().mockResolvedValue([]), + isTransactionActive: false, + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [WorkspaceSchemaEnumManagerService], + }).compile(); + + service = module.get( + WorkspaceSchemaEnumManagerService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createEnum', () => { + it('should create an enum with the given values', async () => { + // Prepare + const schemaName = 'workspace_test'; + const enumName = 'status_enum'; + const values = ['active', 'inactive', 'pending']; + + // Act + await service.createEnum(mockQueryRunner, schemaName, enumName, values); + + // Assert + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `CREATE TYPE "workspace_test"."status_enum" AS ENUM ('active', 'inactive', 'pending')`, + ); + }); + + it('should sanitize schema name, enum name, and values', async () => { + // Prepare + const schemaName = 'workspace"test'; + const enumName = 'status"enum'; + const values = ['value"1', 'value"2']; + + // Act + await service.createEnum(mockQueryRunner, schemaName, enumName, values); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toMatch(/CREATE TYPE .+ AS ENUM/); + expect(actualCall).toContain('"workspacetest"."statusenum"'); + expect(actualCall).toContain("'value1'"); + expect(actualCall).toContain("'value2'"); + expect(actualCall).not.toContain('workspace"test'); + expect(actualCall).not.toContain('status"enum'); + expect(actualCall).not.toContain('value"1'); + expect(actualCall).not.toContain('value"2'); + }); + + it('should handle empty values array', async () => { + // Prepare + const schemaName = 'workspace_test'; + const enumName = 'empty_enum'; + const values: string[] = []; + + // Act + await service.createEnum(mockQueryRunner, schemaName, enumName, values); + + // Assert + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `CREATE TYPE "workspace_test"."empty_enum" AS ENUM ()`, + ); + }); + }); + + describe('dropEnum', () => { + it('should drop an enum', async () => { + const schemaName = 'workspace_test'; + const enumName = 'status_enum'; + + await service.dropEnum(mockQueryRunner, schemaName, enumName); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `DROP TYPE IF EXISTS "workspace_test"."status_enum"`, + ); + }); + + it('should sanitize schema name and enum name', async () => { + const schemaName = 'workspace"test'; + const enumName = 'status"enum'; + + await service.dropEnum(mockQueryRunner, schemaName, enumName); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + // Verify the SQL is properly structured + expect(actualCall).toMatch(/DROP TYPE IF EXISTS/); + expect(actualCall).toContain('"workspacetest"."statusenum"'); + + // Verify dangerous unescaped quotes are not present + expect(actualCall).not.toContain('workspace"test'); + expect(actualCall).not.toContain('status"enum'); + }); + }); + + describe('renameEnum', () => { + it('should rename an enum', async () => { + const schemaName = 'workspace_test'; + const oldEnumName = 'old_status_enum'; + const newEnumName = 'new_status_enum'; + + await service.renameEnum( + mockQueryRunner, + schemaName, + oldEnumName, + newEnumName, + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `ALTER TYPE "workspace_test"."old_status_enum" RENAME TO "new_status_enum"`, + ); + }); + + it('should sanitize all input parameters', async () => { + const schemaName = 'workspace"test'; + const oldEnumName = 'old"enum'; + const newEnumName = 'new"enum'; + + await service.renameEnum( + mockQueryRunner, + schemaName, + oldEnumName, + newEnumName, + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).not.toContain('"test'); + expect(actualCall).not.toContain('old"'); + expect(actualCall).not.toContain('new"'); + }); + }); + + describe('addEnumValue', () => { + it('should add a value to an enum without position', async () => { + const schemaName = 'workspace_test'; + const enumName = 'status_enum'; + const value = 'archived'; + + await service.addEnumValue(mockQueryRunner, schemaName, enumName, value); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `ALTER TYPE "workspace_test"."status_enum" ADD VALUE 'archived'`, + ); + }); + + it('should add a value before another value', async () => { + const schemaName = 'workspace_test'; + const enumName = 'status_enum'; + const value = 'draft'; + const beforeValue = 'active'; + + await service.addEnumValue( + mockQueryRunner, + schemaName, + enumName, + value, + beforeValue, + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `ALTER TYPE "workspace_test"."status_enum" ADD VALUE 'draft' BEFORE 'active'`, + ); + }); + + it('should add a value after another value', async () => { + const schemaName = 'workspace_test'; + const enumName = 'status_enum'; + const value = 'draft'; + const afterValue = 'pending'; + + await service.addEnumValue( + mockQueryRunner, + schemaName, + enumName, + value, + undefined, + afterValue, + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `ALTER TYPE "workspace_test"."status_enum" ADD VALUE 'draft' AFTER 'pending'`, + ); + }); + + it('should prioritize before over after when both are provided', async () => { + const schemaName = 'workspace_test'; + const enumName = 'status_enum'; + const value = 'draft'; + const beforeValue = 'active'; + const afterValue = 'pending'; + + await service.addEnumValue( + mockQueryRunner, + schemaName, + enumName, + value, + beforeValue, + afterValue, + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `ALTER TYPE "workspace_test"."status_enum" ADD VALUE 'draft' BEFORE 'active'`, + ); + }); + + it('should sanitize all input parameters', async () => { + const schemaName = 'workspace"test'; + const enumName = 'status"enum'; + const value = 'value"test'; + const beforeValue = 'before"value'; + + await service.addEnumValue( + mockQueryRunner, + schemaName, + enumName, + value, + beforeValue, + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toMatch(/ALTER TYPE .+ ADD VALUE .+ BEFORE/); + expect(actualCall).toContain('"workspacetest"."statusenum"'); + expect(actualCall).toContain("'valuetest'"); + expect(actualCall).toContain("'beforevalue'"); + + expect(actualCall).not.toContain('workspace"test'); + expect(actualCall).not.toContain('status"enum'); + expect(actualCall).not.toContain('value"test'); + expect(actualCall).not.toContain('before"value'); + }); + }); + + describe('renameEnumValue', () => { + it('should rename an enum value', async () => { + const schemaName = 'workspace_test'; + const enumName = 'status_enum'; + const oldValue = 'inactive'; + const newValue = 'disabled'; + + await service.renameEnumValue( + mockQueryRunner, + schemaName, + enumName, + oldValue, + newValue, + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `ALTER TYPE "workspace_test"."status_enum" RENAME VALUE 'inactive' TO 'disabled'`, + ); + }); + + it('should sanitize all input parameters', async () => { + const schemaName = 'workspace"test'; + const enumName = 'status"enum'; + const oldValue = 'old"value'; + const newValue = 'new"value'; + + await service.renameEnumValue( + mockQueryRunner, + schemaName, + enumName, + oldValue, + newValue, + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toMatch(/ALTER TYPE .+ RENAME VALUE .+ TO/); + expect(actualCall).toContain('"workspacetest"."statusenum"'); + expect(actualCall).toContain("'oldvalue'"); + expect(actualCall).toContain("'newvalue'"); + + expect(actualCall).not.toContain('workspace"test'); + expect(actualCall).not.toContain('status"enum'); + expect(actualCall).not.toContain('old"value'); + expect(actualCall).not.toContain('new"value'); + }); + }); + + describe('enumExists', () => { + it('should return true when enum exists', async () => { + mockQueryRunner.query.mockResolvedValue([{ exists: true }]); + + const result = await service.enumExists( + mockQueryRunner, + 'workspace_test', + 'status_enum', + ); + + expect(result).toBe(true); + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `SELECT EXISTS ( + SELECT FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = $1 AND t.typname = $2 AND t.typtype = 'e' + )`, + ['workspace_test', 'status_enum'], + ); + }); + + it('should return false when enum does not exist', async () => { + mockQueryRunner.query.mockResolvedValue([{ exists: false }]); + + const result = await service.enumExists( + mockQueryRunner, + 'workspace_test', + 'nonexistent_enum', + ); + + expect(result).toBe(false); + }); + + it('should return false when result is empty or undefined', async () => { + mockQueryRunner.query.mockResolvedValue([]); + + const result = await service.enumExists( + mockQueryRunner, + 'workspace_test', + 'status_enum', + ); + + expect(result).toBe(false); + }); + + it('should sanitize input parameters', async () => { + mockQueryRunner.query.mockResolvedValue([{ exists: false }]); + + await service.enumExists( + mockQueryRunner, + 'workspace"test', + 'status"enum', + ); + + const actualCall = mockQueryRunner.query.mock.calls[0]; + + expect(actualCall[1]).toEqual(['workspacetest', 'statusenum']); + }); + }); + + describe('getEnumValues', () => { + it('should return enum values in correct order', async () => { + const mockValues = [ + { value: 'pending' }, + { value: 'active' }, + { value: 'inactive' }, + ]; + + mockQueryRunner.query.mockResolvedValue(mockValues); + + const result = await service.getEnumValues( + mockQueryRunner, + 'workspace_test', + 'status_enum', + ); + + expect(result).toEqual(['pending', 'active', 'inactive']); + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `SELECT e.enumlabel as value + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + JOIN pg_enum e ON t.oid = e.enumtypid + WHERE n.nspname = $1 AND t.typname = $2 + ORDER BY e.enumsortorder`, + ['workspace_test', 'status_enum'], + ); + }); + + it('should return empty array when no values exist', async () => { + mockQueryRunner.query.mockResolvedValue([]); + + const result = await service.getEnumValues( + mockQueryRunner, + 'workspace_test', + 'empty_enum', + ); + + expect(result).toEqual([]); + }); + + it('should sanitize input parameters', async () => { + mockQueryRunner.query.mockResolvedValue([]); + + await service.getEnumValues( + mockQueryRunner, + 'workspace"test', + 'status"enum', + ); + + const actualCall = mockQueryRunner.query.mock.calls[0]; + + expect(actualCall[1]).toEqual(['workspacetest', 'statusenum']); + }); + }); + + describe('getEnumNameForColumn', () => { + it('should return enum name for regular enum column', async () => { + mockQueryRunner.query.mockResolvedValue([ + { udt_name: 'status_enum', data_type: 'USER-DEFINED' }, + ]); + + const result = await service.getEnumNameForColumn( + mockQueryRunner, + 'workspace_test', + 'users', + 'status', + ); + + expect(result).toBe('status_enum'); + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `SELECT udt_name, data_type + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 AND column_name = $3`, + ['workspace_test', 'users', 'status'], + ); + }); + + it('should return enum name for array enum column', async () => { + mockQueryRunner.query.mockResolvedValue([ + { udt_name: '_status_enum', data_type: 'ARRAY' }, + ]); + + const result = await service.getEnumNameForColumn( + mockQueryRunner, + 'workspace_test', + 'users', + 'statuses', + ); + + expect(result).toBe('status_enum'); + }); + + it('should return null when column does not exist', async () => { + mockQueryRunner.query.mockResolvedValue([]); + + const result = await service.getEnumNameForColumn( + mockQueryRunner, + 'workspace_test', + 'users', + 'nonexistent', + ); + + expect(result).toBeNull(); + }); + + it('should sanitize input parameters', async () => { + mockQueryRunner.query.mockResolvedValue([]); + + await service.getEnumNameForColumn( + mockQueryRunner, + 'workspace"test', + 'users"table', + 'status"column', + ); + + const actualCall = mockQueryRunner.query.mock.calls[0]; + + expect(actualCall[1]).toEqual([ + 'workspacetest', + 'userstable', + 'statuscolumn', + ]); + }); + }); + + describe('alterEnumValues', () => { + beforeEach(() => { + jest + .spyOn(service, 'getEnumNameForColumn') + .mockResolvedValue('old_status_enum'); + }); + + it('should alter enum values with proper sequence of operations', async () => { + const schemaName = 'workspace_test'; + const tableName = 'users'; + const columnName = 'status'; + const newValues = ['draft', 'published', 'archived']; + const valueMapping = { active: 'published', inactive: 'archived' }; + + mockQueryRunner.query.mockImplementation((sql: string) => { + if (sql.includes('SELECT id')) { + const columnMatch = sql.match(/SELECT id, "([^"]+)"/); + const columnName = columnMatch ? columnMatch[1] : 'old_status'; + + return Promise.resolve([ + { id: '1', [columnName]: 'active' }, + { id: '2', [columnName]: 'inactive' }, + ]); + } + + return Promise.resolve([]); + }); + + await service.alterEnumValues( + mockQueryRunner, + schemaName, + tableName, + columnName, + newValues, + valueMapping, + ); + + const calls = mockQueryRunner.query.mock.calls.map((call) => call[0]); + + expect( + calls.some((call) => call.includes('RENAME COLUMN "status" TO')), + ).toBe(true); + + expect( + calls.some((call) => call.includes('RENAME TO "old_status_enum_temp"')), + ).toBe(true); + + expect( + calls.some( + (call) => + call.includes('CREATE TYPE') && call.includes('users_status_enum'), + ), + ).toBe(true); + + expect(calls.some((call) => call.includes('ADD COLUMN "status"'))).toBe( + true, + ); + + expect( + calls.some( + (call) => call.includes('UPDATE') && call.includes('CASE "old_'), + ), + ).toBe(true); + + expect(calls.some((call) => call.includes('DROP COLUMN'))).toBe(true); + + expect( + calls.some( + (call) => call.includes('DROP TYPE') && call.includes('temp'), + ), + ).toBe(true); + }); + + it('should throw exception when enum type is not found', async () => { + jest.spyOn(service, 'getEnumNameForColumn').mockResolvedValue(null); + + await expect( + service.alterEnumValues( + mockQueryRunner, + 'workspace_test', + 'users', + 'nonexistent_column', + ['value1'], + ), + ).rejects.toThrow( + new TwentyORMException( + 'Enum type not found for column nonexistent_column', + TwentyORMExceptionCode.ENUM_TYPE_NAME_NOT_FOUND, + ), + ); + }); + + it('should handle empty value mapping', async () => { + const schemaName = 'workspace_test'; + const tableName = 'users'; + const columnName = 'status'; + const newValues = ['new_value']; + + mockQueryRunner.query.mockImplementation((sql: string) => { + if (sql.includes('SELECT id')) { + return Promise.resolve([{ id: '1', old_status: 'old_value' }]); + } + + return Promise.resolve([]); + }); + + await service.alterEnumValues( + mockQueryRunner, + schemaName, + tableName, + columnName, + newValues, + ); + + expect(mockQueryRunner.query).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-foreign-key-manager.service.spec.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-foreign-key-manager.service.spec.ts new file mode 100644 index 00000000000..7228dae3514 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-foreign-key-manager.service.spec.ts @@ -0,0 +1,624 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { QueryRunner } from 'typeorm'; + +import { WorkspaceSchemaForeignKeyManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-foreign-key-manager.service'; +import { WorkspaceSchemaForeignKeyDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-foreign-key-definition.type'; + +describe('WorkspaceSchemaForeignKeyManager', () => { + let service: WorkspaceSchemaForeignKeyManagerService; + let mockQueryRunner: jest.Mocked; + + beforeEach(async () => { + mockQueryRunner = { + query: jest.fn().mockResolvedValue([]), + connection: { + namingStrategy: { + foreignKeyName: jest.fn().mockReturnValue('FK_user_company'), + }, + }, + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [WorkspaceSchemaForeignKeyManagerService], + }).compile(); + + service = module.get( + WorkspaceSchemaForeignKeyManagerService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createForeignKey', () => { + it('should create a foreign key constraint', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'users'; + const foreignKey: WorkspaceSchemaForeignKeyDefinition = { + name: 'FK_user_company', + columnNames: ['companyId'], + referencedTableName: 'companies', + referencedColumnNames: ['id'], + }; + + // Act + await service.createForeignKey( + mockQueryRunner, + schemaName, + tableName, + foreignKey, + ); + + // Assert + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `ALTER TABLE "workspace_test"."users" ADD CONSTRAINT "FK_user_company" FOREIGN KEY ("companyId") REFERENCES "workspace_test"."companies" ("id")`, + ); + }); + + it('should create a foreign key with ON DELETE CASCADE', async () => { + const schemaName = 'workspace_test'; + const tableName = 'users'; + const foreignKey: WorkspaceSchemaForeignKeyDefinition = { + name: 'FK_user_company', + columnNames: ['companyId'], + referencedTableName: 'companies', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }; + + await service.createForeignKey( + mockQueryRunner, + schemaName, + tableName, + foreignKey, + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('ON DELETE CASCADE'); + }); + + it('should create a foreign key with ON UPDATE SET NULL', async () => { + const schemaName = 'workspace_test'; + const tableName = 'users'; + const foreignKey: WorkspaceSchemaForeignKeyDefinition = { + name: 'FK_user_company', + columnNames: ['companyId'], + referencedTableName: 'companies', + referencedColumnNames: ['id'], + onUpdate: 'SET NULL', + }; + + await service.createForeignKey( + mockQueryRunner, + schemaName, + tableName, + foreignKey, + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('ON UPDATE SET NULL'); + }); + + it('should create a foreign key with both ON DELETE and ON UPDATE', async () => { + const schemaName = 'workspace_test'; + const tableName = 'users'; + const foreignKey: WorkspaceSchemaForeignKeyDefinition = { + name: 'FK_user_company', + columnNames: ['companyId'], + referencedTableName: 'companies', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + onUpdate: 'RESTRICT', + }; + + await service.createForeignKey( + mockQueryRunner, + schemaName, + tableName, + foreignKey, + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('ON DELETE CASCADE'); + expect(actualCall).toContain('ON UPDATE RESTRICT'); + }); + + it('should handle multiple columns in foreign key', async () => { + const schemaName = 'workspace_test'; + const tableName = 'orders'; + const foreignKey: WorkspaceSchemaForeignKeyDefinition = { + name: 'FK_order_composite', + columnNames: ['userId', 'companyId'], + referencedTableName: 'user_companies', + referencedColumnNames: ['userId', 'companyId'], + }; + + await service.createForeignKey( + mockQueryRunner, + schemaName, + tableName, + foreignKey, + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('("userId", "companyId")'); + expect(actualCall).toContain( + 'REFERENCES "workspace_test"."user_companies" ("userId", "companyId")', + ); + }); + + it('should sanitize all input parameters', async () => { + // Prepare + const schemaName = 'workspace"test'; + const tableName = 'users"table'; + const foreignKey: WorkspaceSchemaForeignKeyDefinition = { + name: 'FK"constraint', + columnNames: ['column"id'], + referencedTableName: 'ref"table', + referencedColumnNames: ['ref"id'], + }; + + // Act + await service.createForeignKey( + mockQueryRunner, + schemaName, + tableName, + foreignKey, + ); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toMatch( + /ALTER TABLE .+ ADD CONSTRAINT .+ FOREIGN KEY/, + ); + expect(actualCall).toContain('"workspacetest"."userstable"'); + expect(actualCall).toContain('"FKconstraint"'); + expect(actualCall).toContain('"columnid"'); + expect(actualCall).toContain('"reftable"'); + expect(actualCall).toContain('"refid"'); + expect(actualCall).not.toContain('workspace"test'); + expect(actualCall).not.toContain('users"table'); + expect(actualCall).not.toContain('FK"constraint'); + expect(actualCall).not.toContain('column"id'); + expect(actualCall).not.toContain('ref"table'); + expect(actualCall).not.toContain('ref"id'); + }); + }); + + describe('dropForeignKey', () => { + it('should drop a foreign key constraint', async () => { + const schemaName = 'workspace_test'; + const tableName = 'users'; + const foreignKeyName = 'FK_user_company'; + + await service.dropForeignKey( + mockQueryRunner, + schemaName, + tableName, + foreignKeyName, + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `ALTER TABLE "workspace_test"."users" DROP CONSTRAINT IF EXISTS "FK_user_company"`, + ); + }); + + it('should sanitize input parameters', async () => { + const schemaName = 'workspace"test'; + const tableName = 'users"table'; + const foreignKeyName = 'FK"constraint'; + + await service.dropForeignKey( + mockQueryRunner, + schemaName, + tableName, + foreignKeyName, + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + // Verify the SQL is properly structured + expect(actualCall).toMatch(/ALTER TABLE .+ DROP CONSTRAINT IF EXISTS/); + expect(actualCall).toContain('"workspacetest"."userstable"'); + expect(actualCall).toContain('"FKconstraint"'); + + // Verify dangerous unescaped quotes are not present + expect(actualCall).not.toContain('workspace"test'); + expect(actualCall).not.toContain('users"table'); + expect(actualCall).not.toContain('FK"constraint'); + }); + }); + + describe('dropForeignKeyByColumn', () => { + it('should drop foreign key by column when constraint exists', async () => { + jest + .spyOn(service, 'getForeignKeyNameByColumn') + .mockResolvedValue('FK_user_company'); + const dropForeignKeySpy = jest + .spyOn(service, 'dropForeignKey') + .mockResolvedValue(); + + await service.dropForeignKeyByColumn( + mockQueryRunner, + 'workspace_test', + 'users', + 'companyId', + ); + + expect(service.getForeignKeyNameByColumn).toHaveBeenCalledWith( + mockQueryRunner, + 'workspace_test', + 'users', + 'companyId', + ); + expect(dropForeignKeySpy).toHaveBeenCalledWith( + mockQueryRunner, + 'workspace_test', + 'users', + 'FK_user_company', + ); + }); + + it('should do nothing when no foreign key exists for column', async () => { + jest.spyOn(service, 'getForeignKeyNameByColumn').mockResolvedValue(null); + const dropForeignKeySpy = jest + .spyOn(service, 'dropForeignKey') + .mockResolvedValue(); + + await service.dropForeignKeyByColumn( + mockQueryRunner, + 'workspace_test', + 'users', + 'nonConstrainedColumn', + ); + + expect(service.getForeignKeyNameByColumn).toHaveBeenCalledWith( + mockQueryRunner, + 'workspace_test', + 'users', + 'nonConstrainedColumn', + ); + expect(dropForeignKeySpy).not.toHaveBeenCalled(); + }); + }); + + describe('foreignKeyExists', () => { + it('should return true when foreign key exists', async () => { + mockQueryRunner.query.mockResolvedValue([{ exists: true }]); + + const result = await service.foreignKeyExists( + mockQueryRunner, + 'workspace_test', + 'users', + 'FK_user_company', + ); + + expect(result).toBe(true); + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `SELECT EXISTS ( + SELECT FROM information_schema.table_constraints + WHERE constraint_schema = $1 + AND table_name = $2 + AND constraint_name = $3 + AND constraint_type = 'FOREIGN KEY' + )`, + ['workspace_test', 'users', 'FK_user_company'], + ); + }); + + it('should return false when foreign key does not exist', async () => { + mockQueryRunner.query.mockResolvedValue([{ exists: false }]); + + const result = await service.foreignKeyExists( + mockQueryRunner, + 'workspace_test', + 'users', + 'FK_nonexistent', + ); + + expect(result).toBe(false); + }); + + it('should return false when result is empty', async () => { + mockQueryRunner.query.mockResolvedValue([]); + + const result = await service.foreignKeyExists( + mockQueryRunner, + 'workspace_test', + 'users', + 'FK_user_company', + ); + + expect(result).toBe(false); + }); + + it('should sanitize input parameters', async () => { + mockQueryRunner.query.mockResolvedValue([{ exists: false }]); + + await service.foreignKeyExists( + mockQueryRunner, + 'workspace"test', + 'users"table', + 'FK"constraint', + ); + + const actualCall = mockQueryRunner.query.mock.calls[0]; + + expect(actualCall[1]).toEqual([ + 'workspacetest', + 'userstable', + 'FKconstraint', + ]); + }); + }); + + describe('getForeignKeyNameByColumn', () => { + it('should return foreign key name for column', async () => { + mockQueryRunner.query.mockResolvedValue([ + { constraint_name: 'FK_user_company' }, + ]); + + const result = await service.getForeignKeyNameByColumn( + mockQueryRunner, + 'workspace_test', + 'users', + 'companyId', + ); + + expect(result).toBe('FK_user_company'); + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `SELECT tc.constraint_name + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = $1 + AND tc.table_name = $2 + AND kcu.column_name = $3`, + ['workspace_test', 'users', 'companyId'], + ); + }); + + it('should return null when no foreign key exists for column', async () => { + mockQueryRunner.query.mockResolvedValue([]); + + const result = await service.getForeignKeyNameByColumn( + mockQueryRunner, + 'workspace_test', + 'users', + 'nonConstrainedColumn', + ); + + expect(result).toBeNull(); + }); + + it('should sanitize input parameters', async () => { + mockQueryRunner.query.mockResolvedValue([]); + + await service.getForeignKeyNameByColumn( + mockQueryRunner, + 'workspace"test', + 'users"table', + 'column"id', + ); + + const actualCall = mockQueryRunner.query.mock.calls[0]; + + expect(actualCall[1]).toEqual([ + 'workspacetest', + 'userstable', + 'columnid', + ]); + }); + }); + + describe('getForeignKeysForTable', () => { + it('should return all foreign keys for a table', async () => { + const mockForeignKeys = [ + { + constraint_name: 'FK_user_company', + column_name: 'companyId', + foreign_table_name: 'companies', + foreign_column_name: 'id', + delete_rule: 'CASCADE', + update_rule: 'RESTRICT', + }, + { + constraint_name: 'FK_user_department', + column_name: 'departmentId', + foreign_table_name: 'departments', + foreign_column_name: 'id', + delete_rule: 'SET NULL', + update_rule: 'NO ACTION', + }, + ]; + + mockQueryRunner.query.mockResolvedValue(mockForeignKeys); + + const result = await service.getForeignKeysForTable( + mockQueryRunner, + 'workspace_test', + 'users', + ); + + expect(result).toEqual(mockForeignKeys); + expect(mockQueryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('SELECT'), + ['workspace_test', 'users'], + ); + }); + + it('should return empty array when no foreign keys exist', async () => { + mockQueryRunner.query.mockResolvedValue([]); + + const result = await service.getForeignKeysForTable( + mockQueryRunner, + 'workspace_test', + 'standalone_table', + ); + + expect(result).toEqual([]); + }); + + it('should sanitize input parameters', async () => { + mockQueryRunner.query.mockResolvedValue([]); + + await service.getForeignKeysForTable( + mockQueryRunner, + 'workspace"test', + 'users"table', + ); + + const actualCall = mockQueryRunner.query.mock.calls[0]; + + expect(actualCall[1]).toEqual(['workspacetest', 'userstable']); + }); + }); + + describe('createForeignKeyFromColumn', () => { + it('should create foreign key from column with default referenced column', async () => { + const createForeignKeySpy = jest + .spyOn(service, 'createForeignKey') + .mockResolvedValue(); + + await service.createForeignKeyFromColumn( + mockQueryRunner, + 'workspace_test', + 'users', + 'companyId', + 'companies', + ); + + expect( + mockQueryRunner.connection.namingStrategy.foreignKeyName, + ).toHaveBeenCalledWith( + 'users', + ['companyId'], + 'workspace_test.companies', + ['id'], + ); + expect(createForeignKeySpy).toHaveBeenCalledWith( + mockQueryRunner, + 'workspace_test', + 'users', + { + name: 'FK_user_company', + columnNames: ['companyId'], + referencedTableName: 'companies', + referencedColumnNames: ['id'], + onDelete: undefined, + }, + ); + }); + + it('should create foreign key with custom referenced column and onDelete', async () => { + const createForeignKeySpy = jest + .spyOn(service, 'createForeignKey') + .mockResolvedValue(); + + await service.createForeignKeyFromColumn( + mockQueryRunner, + 'workspace_test', + 'users', + 'companyCode', + 'companies', + 'code', + 'SET NULL', + ); + + expect(createForeignKeySpy).toHaveBeenCalledWith( + mockQueryRunner, + 'workspace_test', + 'users', + { + name: 'FK_user_company', + columnNames: ['companyCode'], + referencedTableName: 'companies', + referencedColumnNames: ['code'], + onDelete: 'SET NULL', + }, + ); + }); + }); + + describe('renameForeignKey', () => { + it('should rename a foreign key constraint', async () => { + await service.renameForeignKey( + mockQueryRunner, + 'workspace_test', + 'users', + 'FK_old_name', + 'FK_new_name', + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `ALTER TABLE "workspace_test"."users" RENAME CONSTRAINT "FK_old_name" TO "FK_new_name"`, + ); + }); + + it('should sanitize input parameters', async () => { + await service.renameForeignKey( + mockQueryRunner, + 'workspace"test', + 'users"table', + 'FK"old', + 'FK"new', + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toMatch(/ALTER TABLE .+ RENAME CONSTRAINT .+ TO/); + expect(actualCall).toContain('"workspacetest"."userstable"'); + expect(actualCall).toContain('"FKold"'); + expect(actualCall).toContain('"FKnew"'); + + expect(actualCall).not.toContain('workspace"test'); + expect(actualCall).not.toContain('users"table'); + expect(actualCall).not.toContain('FK"old'); + expect(actualCall).not.toContain('FK"new'); + }); + }); + + describe('validateForeignKey', () => { + it('should validate a foreign key constraint', async () => { + await service.validateForeignKey( + mockQueryRunner, + 'workspace_test', + 'users', + 'FK_user_company', + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `ALTER TABLE "workspace_test"."users" VALIDATE CONSTRAINT "FK_user_company"`, + ); + }); + + it('should sanitize input parameters', async () => { + await service.validateForeignKey( + mockQueryRunner, + 'workspace"test', + 'users"table', + 'FK"constraint', + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toMatch(/ALTER TABLE .+ VALIDATE CONSTRAINT/); + expect(actualCall).toContain('"workspacetest"."userstable"'); + expect(actualCall).toContain('"FKconstraint"'); + + expect(actualCall).not.toContain('workspace"test'); + expect(actualCall).not.toContain('users"table'); + expect(actualCall).not.toContain('FK"constraint'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-index-manager.service.spec.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-index-manager.service.spec.ts new file mode 100644 index 00000000000..91b3d7c5365 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-index-manager.service.spec.ts @@ -0,0 +1,721 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { QueryRunner } from 'typeorm'; + +import { WorkspaceSchemaIndexManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-index-manager.service'; +import { WorkspaceSchemaIndexDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-index-definition.type'; + +describe('WorkspaceSchemaIndexManager', () => { + let service: WorkspaceSchemaIndexManagerService; + let mockQueryRunner: jest.Mocked; + + beforeEach(async () => { + mockQueryRunner = { + query: jest.fn().mockResolvedValue([]), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [WorkspaceSchemaIndexManagerService], + }).compile(); + + service = module.get( + WorkspaceSchemaIndexManagerService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createIndex', () => { + it('should create a basic index', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'users'; + const index: WorkspaceSchemaIndexDefinition = { + name: 'idx_users_email', + columns: ['email'], + }; + + // Act + await service.createIndex(mockQueryRunner, schemaName, tableName, index); + + // Assert + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `CREATE INDEX IF NOT EXISTS "idx_users_email" ON "workspace_test"."users" ("email")`, + ); + }); + + it('should create a unique index', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'users'; + const index: WorkspaceSchemaIndexDefinition = { + name: 'idx_users_email_unique', + columns: ['email'], + isUnique: true, + }; + + // Act + await service.createIndex(mockQueryRunner, schemaName, tableName, index); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('CREATE UNIQUE INDEX'); + }); + + it('should create index with specific type', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'users'; + const index: WorkspaceSchemaIndexDefinition = { + name: 'idx_users_data_gin', + columns: ['data'], + type: 'GIN', + }; + + // Act + await service.createIndex(mockQueryRunner, schemaName, tableName, index); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('USING GIN'); + }); + + it('should create index with BTREE type (default)', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'users'; + const index: WorkspaceSchemaIndexDefinition = { + name: 'idx_users_name', + columns: ['name'], + type: 'BTREE', + }; + + // Act + await service.createIndex(mockQueryRunner, schemaName, tableName, index); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).not.toContain('USING BTREE'); + }); + + it('should create index with WHERE clause', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'users'; + const index: WorkspaceSchemaIndexDefinition = { + name: 'idx_users_active_email', + columns: ['email'], + where: 'active = true', + }; + + // Act + await service.createIndex(mockQueryRunner, schemaName, tableName, index); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('WHERE active = true'); + }); + + it('should create index with INCLUDE clause', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'users'; + const index: WorkspaceSchemaIndexDefinition = { + name: 'idx_users_email_include', + columns: ['email'], + include: ['name', 'created_at'], + }; + + // Act + await service.createIndex(mockQueryRunner, schemaName, tableName, index); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('INCLUDE ("name", "created_at")'); + }); + + it('should create composite index with multiple columns', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'users'; + const index: WorkspaceSchemaIndexDefinition = { + name: 'idx_users_company_department', + columns: ['companyId', 'departmentId'], + }; + + // Act + await service.createIndex(mockQueryRunner, schemaName, tableName, index); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('("companyId", "departmentId")'); + }); + + it('should create index with all options combined', async () => { + const schemaName = 'workspace_test'; + const tableName = 'users'; + const index: WorkspaceSchemaIndexDefinition = { + name: 'idx_users_complex', + columns: ['email', 'status'], + type: 'BTREE', + isUnique: true, + where: 'deleted_at IS NULL', + include: ['name'], + }; + + await service.createIndex(mockQueryRunner, schemaName, tableName, index); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('CREATE UNIQUE INDEX'); + expect(actualCall).toContain('("email", "status")'); + expect(actualCall).toContain('INCLUDE ("name")'); + expect(actualCall).toContain('WHERE deleted_at IS NULL'); + }); + + it('should handle index creation errors gracefully for existing index', async () => { + const error = new Error('Index already exists') as any; + + error.code = '42P07'; + mockQueryRunner.query.mockRejectedValue(error); + + const schemaName = 'workspace_test'; + const tableName = 'users'; + const index: WorkspaceSchemaIndexDefinition = { + name: 'idx_existing', + columns: ['email'], + }; + + await expect( + service.createIndex(mockQueryRunner, schemaName, tableName, index), + ).resolves.not.toThrow(); + }); + + it('should rethrow non-existing index errors', async () => { + const error = new Error('Other database error') as any; + + error.code = '42000'; + mockQueryRunner.query.mockRejectedValue(error); + + const schemaName = 'workspace_test'; + const tableName = 'users'; + const index: WorkspaceSchemaIndexDefinition = { + name: 'idx_failing', + columns: ['email'], + }; + + await expect( + service.createIndex(mockQueryRunner, schemaName, tableName, index), + ).rejects.toThrow('Other database error'); + }); + + it('should sanitize all input parameters', async () => { + // Prepare + const schemaName = 'workspace"test'; + const tableName = 'users"table'; + const index: WorkspaceSchemaIndexDefinition = { + name: 'idx"test', + columns: ['email"col', 'name"col'], + include: ['include"col'], + }; + + // Act + await service.createIndex(mockQueryRunner, schemaName, tableName, index); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toMatch(/CREATE\s+INDEX IF NOT EXISTS/); + expect(actualCall).toContain('"workspacetest"."userstable"'); + expect(actualCall).toContain('"idxtest"'); + expect(actualCall).toContain('"emailcol"'); + expect(actualCall).toContain('"namecol"'); + expect(actualCall).toContain('"includecol"'); + expect(actualCall).not.toContain('workspace"test'); + expect(actualCall).not.toContain('users"table'); + expect(actualCall).not.toContain('idx"test'); + expect(actualCall).not.toContain('email"col'); + expect(actualCall).not.toContain('name"col'); + expect(actualCall).not.toContain('include"col'); + }); + }); + + describe('dropIndex', () => { + it('should drop an index', async () => { + // Prepare + const schemaName = 'workspace_test'; + const indexName = 'idx_users_email'; + + // Act + await service.dropIndex(mockQueryRunner, schemaName, indexName); + + // Assert + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `DROP INDEX IF EXISTS "workspace_test"."idx_users_email"`, + ); + }); + + it('should handle index drop errors gracefully for non-existing index', async () => { + const error = new Error('Index does not exist') as any; + + error.code = '42704'; + mockQueryRunner.query.mockRejectedValue(error); + + await expect( + service.dropIndex(mockQueryRunner, 'workspace_test', 'idx_nonexistent'), + ).resolves.not.toThrow(); + }); + + it('should rethrow non-missing index errors', async () => { + const error = new Error('Other database error') as any; + + error.code = '42000'; + mockQueryRunner.query.mockRejectedValue(error); + + await expect( + service.dropIndex(mockQueryRunner, 'workspace_test', 'idx_failing'), + ).rejects.toThrow('Other database error'); + }); + + it('should sanitize input parameters', async () => { + // Prepare + const schemaName = 'workspace"test'; + const indexName = 'idx"test'; + + // Act + await service.dropIndex(mockQueryRunner, schemaName, indexName); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).not.toContain('"test'); + expect(actualCall).not.toContain('idx"'); + }); + }); + + describe('renameIndex', () => { + it('should rename an index', async () => { + // Prepare + const schemaName = 'workspace_test'; + const oldIndexName = 'idx_old_name'; + const newIndexName = 'idx_new_name'; + + // Act + await service.renameIndex( + mockQueryRunner, + schemaName, + oldIndexName, + newIndexName, + ); + + // Assert + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `ALTER INDEX "workspace_test"."idx_old_name" RENAME TO "idx_new_name"`, + ); + }); + + it('should sanitize input parameters', async () => { + // Prepare + const schemaName = 'workspace"test'; + const oldIndexName = 'idx"old'; + const newIndexName = 'idx"new'; + + // Act + await service.renameIndex( + mockQueryRunner, + schemaName, + oldIndexName, + newIndexName, + ); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).not.toContain('"test'); + expect(actualCall).not.toContain('idx"'); + }); + }); + + describe('indexExists', () => { + it('should return true when index exists', async () => { + // Prepare + mockQueryRunner.query.mockResolvedValue([{ exists: true }]); + + // Act + const result = await service.indexExists( + mockQueryRunner, + 'workspace_test', + 'idx_users_email', + ); + + // Assert + expect(result).toBe(true); + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `SELECT EXISTS ( + SELECT FROM pg_indexes + WHERE schemaname = $1 AND indexname = $2 + )`, + ['workspace_test', 'idx_users_email'], + ); + }); + + it('should return false when index does not exist', async () => { + // Prepare + mockQueryRunner.query.mockResolvedValue([{ exists: false }]); + + // Act + const result = await service.indexExists( + mockQueryRunner, + 'workspace_test', + 'idx_nonexistent', + ); + + // Assert + expect(result).toBe(false); + }); + + it('should return false when result is empty', async () => { + // Prepare + mockQueryRunner.query.mockResolvedValue([]); + + // Act + const result = await service.indexExists( + mockQueryRunner, + 'workspace_test', + 'idx_users_email', + ); + + // Assert + expect(result).toBe(false); + }); + + it('should sanitize input parameters', async () => { + // Prepare + mockQueryRunner.query.mockResolvedValue([{ exists: false }]); + + // Act + await service.indexExists(mockQueryRunner, 'workspace"test', 'idx"test'); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0]; + + expect(actualCall[1]).toEqual(['workspacetest', 'idxtest']); + }); + }); + + describe('getIndexesForTable', () => { + it('should return all indexes for a table', async () => { + // Prepare + const mockIndexes = [ + { + indexname: 'idx_users_email', + indexdef: 'CREATE INDEX idx_users_email ON users (email)', + }, + { + indexname: 'idx_users_name', + indexdef: 'CREATE INDEX idx_users_name ON users (name)', + }, + ]; + + mockQueryRunner.query.mockResolvedValue(mockIndexes); + + // Act + const result = await service.getIndexesForTable( + mockQueryRunner, + 'workspace_test', + 'users', + ); + + // Assert + expect(result).toEqual(mockIndexes); + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `SELECT indexname, indexdef + FROM pg_indexes + WHERE schemaname = $1 AND tablename = $2`, + ['workspace_test', 'users'], + ); + }); + + it('should return empty array when no indexes exist', async () => { + // Prepare + mockQueryRunner.query.mockResolvedValue([]); + + // Act + const result = await service.getIndexesForTable( + mockQueryRunner, + 'workspace_test', + 'empty_table', + ); + + // Assert + expect(result).toEqual([]); + }); + + it('should sanitize input parameters', async () => { + // Prepare + mockQueryRunner.query.mockResolvedValue([]); + + // Act + await service.getIndexesForTable( + mockQueryRunner, + 'workspace"test', + 'users"table', + ); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0]; + + expect(actualCall[1]).toEqual(['workspacetest', 'userstable']); + }); + }); + + describe('createPrimaryKey', () => { + it('should create a primary key constraint', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'users'; + const constraintName = 'PK_users'; + const columnNames = ['id']; + + // Act + await service.createPrimaryKey( + mockQueryRunner, + schemaName, + tableName, + constraintName, + columnNames, + ); + + // Assert + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `ALTER TABLE "workspace_test"."users" ADD CONSTRAINT "PK_users" PRIMARY KEY ("id")`, + ); + }); + + it('should create composite primary key', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'user_roles'; + const constraintName = 'PK_user_roles'; + const columnNames = ['userId', 'roleId']; + + // Act + await service.createPrimaryKey( + mockQueryRunner, + schemaName, + tableName, + constraintName, + columnNames, + ); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('PRIMARY KEY ("userId", "roleId")'); + }); + + it('should sanitize input parameters', async () => { + // Prepare + const schemaName = 'workspace"test'; + const tableName = 'users"table'; + const constraintName = 'PK"test'; + const columnNames = ['id"col']; + + // Act + await service.createPrimaryKey( + mockQueryRunner, + schemaName, + tableName, + constraintName, + columnNames, + ); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toMatch( + /ALTER TABLE .+ ADD CONSTRAINT .+ PRIMARY KEY/, + ); + expect(actualCall).toContain('"workspacetest"."userstable"'); + expect(actualCall).toContain('"PKtest"'); + expect(actualCall).toContain('"idcol"'); + expect(actualCall).not.toContain('workspace"test'); + expect(actualCall).not.toContain('users"table'); + expect(actualCall).not.toContain('PK"test'); + expect(actualCall).not.toContain('id"col'); + }); + }); + + describe('dropPrimaryKey', () => { + it('should drop a primary key constraint', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'users'; + const constraintName = 'PK_users'; + + // Act + await service.dropPrimaryKey( + mockQueryRunner, + schemaName, + tableName, + constraintName, + ); + + // Assert + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `ALTER TABLE "workspace_test"."users" DROP CONSTRAINT IF EXISTS "PK_users"`, + ); + }); + + it('should sanitize input parameters', async () => { + // Prepare + const schemaName = 'workspace"test'; + const tableName = 'users"table'; + const constraintName = 'PK"test'; + + // Act + await service.dropPrimaryKey( + mockQueryRunner, + schemaName, + tableName, + constraintName, + ); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toMatch(/ALTER TABLE .+ DROP CONSTRAINT IF EXISTS/); + expect(actualCall).toContain('"workspacetest"."userstable"'); + expect(actualCall).toContain('"PKtest"'); + expect(actualCall).not.toContain('workspace"test'); + expect(actualCall).not.toContain('users"table'); + expect(actualCall).not.toContain('PK"test'); + }); + }); + + describe('createUniqueConstraint', () => { + it('should create a unique constraint', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'users'; + const constraintName = 'UQ_users_email'; + const columnNames = ['email']; + + // Act + await service.createUniqueConstraint( + mockQueryRunner, + schemaName, + tableName, + constraintName, + columnNames, + ); + + // Assert + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `ALTER TABLE "workspace_test"."users" ADD CONSTRAINT "UQ_users_email" UNIQUE ("email")`, + ); + }); + + it('should create composite unique constraint', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'users'; + const constraintName = 'UQ_users_email_company'; + const columnNames = ['email', 'companyId']; + + // Act + await service.createUniqueConstraint( + mockQueryRunner, + schemaName, + tableName, + constraintName, + columnNames, + ); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('UNIQUE ("email", "companyId")'); + }); + + it('should sanitize input parameters', async () => { + const schemaName = 'workspace"test'; + const tableName = 'users"table'; + const constraintName = 'UQ"test'; + const columnNames = ['email"col']; + + await service.createUniqueConstraint( + mockQueryRunner, + schemaName, + tableName, + constraintName, + columnNames, + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toMatch(/ALTER TABLE .+ ADD CONSTRAINT .+ UNIQUE/); + expect(actualCall).toContain('"workspacetest"."userstable"'); + expect(actualCall).toContain('"UQtest"'); + expect(actualCall).toContain('"emailcol"'); + + expect(actualCall).not.toContain('workspace"test'); + expect(actualCall).not.toContain('users"table'); + expect(actualCall).not.toContain('UQ"test'); + expect(actualCall).not.toContain('email"col'); + }); + }); + + describe('dropUniqueConstraint', () => { + it('should drop a unique constraint', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'users'; + const constraintName = 'UQ_users_email'; + + // Act + await service.dropUniqueConstraint( + mockQueryRunner, + schemaName, + tableName, + constraintName, + ); + + // Assert + expect(mockQueryRunner.query).toHaveBeenCalledWith( + `ALTER TABLE "workspace_test"."users" DROP CONSTRAINT IF EXISTS "UQ_users_email"`, + ); + }); + + it('should sanitize input parameters', async () => { + const schemaName = 'workspace"test'; + const tableName = 'users"table'; + const constraintName = 'UQ"test'; + + await service.dropUniqueConstraint( + mockQueryRunner, + schemaName, + tableName, + constraintName, + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toMatch(/ALTER TABLE .+ DROP CONSTRAINT IF EXISTS/); + expect(actualCall).toContain('"workspacetest"."userstable"'); + expect(actualCall).toContain('"UQtest"'); + + expect(actualCall).not.toContain('workspace"test'); + expect(actualCall).not.toContain('users"table'); + expect(actualCall).not.toContain('UQ"test'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-table-manager.service.spec.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-table-manager.service.spec.ts new file mode 100644 index 00000000000..9ee1d302ea1 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-table-manager.service.spec.ts @@ -0,0 +1,276 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { QueryRunner } from 'typeorm'; + +import { WorkspaceSchemaTableManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-table-manager.service'; + +describe('WorkspaceSchemaTableManager', () => { + let service: WorkspaceSchemaTableManagerService; + let mockQueryRunner: jest.Mocked; + + beforeEach(async () => { + mockQueryRunner = { + query: jest.fn().mockResolvedValue([]), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [WorkspaceSchemaTableManagerService], + }).compile(); + + service = module.get( + WorkspaceSchemaTableManagerService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createTable', () => { + it('should create table with default columns when no columns provided', async () => { + // Prepare + const schemaName = 'workspace_test'; + const tableName = 'users'; + + // Act + await service.createTable(mockQueryRunner, schemaName, tableName); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain( + 'CREATE TABLE IF NOT EXISTS "workspace_test"."users"', + ); + expect(actualCall).toContain( + '"id" uuid PRIMARY KEY DEFAULT gen_random_uuid()', + ); + }); + + it('should create table with custom columns', async () => { + const schemaName = 'workspace_test'; + const tableName = 'products'; + const columns = [ + { name: 'id', type: 'uuid', isPrimary: true }, + { name: 'name', type: 'varchar', isNullable: false }, + { name: 'price', type: 'decimal', default: '0.00' }, + { name: 'tags', type: 'varchar', isArray: true }, + { name: 'email', type: 'varchar', isUnique: true }, + ]; + + await service.createTable( + mockQueryRunner, + schemaName, + tableName, + columns, + ); + + const expectedSql = expect.stringContaining( + 'CREATE TABLE IF NOT EXISTS "workspace_test"."products"', + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith(expectedSql); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('"id" uuid PRIMARY KEY'); + expect(actualCall).toContain('"name" varchar NOT NULL'); + expect(actualCall).toContain('"price" decimal DEFAULT 000'); + expect(actualCall).toContain('"tags" varchar[]'); + expect(actualCall).toContain('"email" varchar UNIQUE'); + }); + + it('should sanitize schema and table names', async () => { + // Prepare + const schemaName = 'workspace_test; DROP TABLE'; + const tableName = 'users; DELETE FROM'; + + // Act + await service.createTable(mockQueryRunner, schemaName, tableName); + + // Assert + expect(mockQueryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('"workspace_testDROPTABLE"."usersDELETEFROM"'), + ); + }); + + it('should sanitize column names and types', async () => { + // Prepare + const columns = [{ name: 'user_id; DROP', type: 'varchar; EXEC' }]; + + // Act + await service.createTable(mockQueryRunner, 'schema', 'table', columns); + + // Assert + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).toContain('"user_idDROP" varcharEXEC'); + }); + }); + + describe('dropTable', () => { + it('should drop table with sanitized names', async () => { + const schemaName = 'workspace_test'; + const tableName = 'old_table'; + + await service.dropTable(mockQueryRunner, schemaName, tableName); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + 'DROP TABLE IF EXISTS "workspace_test"."old_table"', + ); + }); + + it('should sanitize dangerous input', async () => { + const schemaName = 'schema; DROP DATABASE'; + const tableName = 'table; TRUNCATE'; + + await service.dropTable(mockQueryRunner, schemaName, tableName); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + 'DROP TABLE IF EXISTS "schemaDROPDATABASE"."tableTRUNCATE"', + ); + }); + }); + + describe('renameTable', () => { + it('should rename table with sanitized names', async () => { + const schemaName = 'workspace_test'; + const oldTableName = 'old_name'; + const newTableName = 'new_name'; + + await service.renameTable( + mockQueryRunner, + schemaName, + oldTableName, + newTableName, + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + 'ALTER TABLE "workspace_test"."old_name" RENAME TO "new_name"', + ); + }); + + it('should sanitize all table names', async () => { + const schemaName = 'schema; DROP'; + const oldTableName = 'old; DELETE'; + const newTableName = 'new; INSERT'; + + await service.renameTable( + mockQueryRunner, + schemaName, + oldTableName, + newTableName, + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith( + 'ALTER TABLE "schemaDROP"."oldDELETE" RENAME TO "newINSERT"', + ); + }); + }); + + describe('tableExists', () => { + it('should check if table exists', async () => { + mockQueryRunner.query.mockResolvedValue([{ exists: true }]); + + const result = await service.tableExists( + mockQueryRunner, + 'workspace_test', + 'users', + ); + + expect(result).toBe(true); + expect(mockQueryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('SELECT EXISTS'), + ['workspace_test', 'users'], + ); + }); + + it('should return false when table does not exist', async () => { + mockQueryRunner.query.mockResolvedValue([{ exists: false }]); + + const result = await service.tableExists( + mockQueryRunner, + 'workspace_test', + 'nonexistent', + ); + + expect(result).toBe(false); + }); + + it('should sanitize input parameters', async () => { + mockQueryRunner.query.mockResolvedValue([{ exists: false }]); + + await service.tableExists( + mockQueryRunner, + 'schema; DROP', + 'table; DELETE', + ); + + expect(mockQueryRunner.query).toHaveBeenCalledWith(expect.any(String), [ + 'schemaDROP', + 'tableDELETE', + ]); + }); + + it('should handle empty result', async () => { + mockQueryRunner.query.mockResolvedValue([]); + + const result = await service.tableExists( + mockQueryRunner, + 'workspace_test', + 'users', + ); + + expect(result).toBe(false); + }); + }); + + describe('SQL injection protection', () => { + it('should prevent SQL injection in schema names', async () => { + const maliciousSchema = "workspace'; DROP TABLE users; --"; + + await service.createTable(mockQueryRunner, maliciousSchema, 'table'); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).not.toContain('DROP TABLE users'); + expect(actualCall).toContain('"workspaceDROPTABLEusers"'); + }); + + it('should prevent SQL injection in table names', async () => { + const maliciousTable = "users'; DROP DATABASE; --"; + + await service.dropTable(mockQueryRunner, 'schema', maliciousTable); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).not.toContain('DROP DATABASE'); + expect(actualCall).toContain('"usersDROPDATABASE"'); + }); + + it('should prevent SQL injection in column definitions', async () => { + const maliciousColumns = [ + { + name: "name'; DROP TABLE", + type: 'varchar; EXEC sp_helpdb --', + default: "'; DELETE FROM users; --", + }, + ]; + + await service.createTable( + mockQueryRunner, + 'schema', + 'table', + maliciousColumns, + ); + + const actualCall = mockQueryRunner.query.mock.calls[0][0]; + + expect(actualCall).not.toContain('DROP TABLE'); + expect(actualCall).not.toContain('EXEC sp_helpdb'); + expect(actualCall).not.toContain('DELETE FROM users'); + expect(actualCall).toContain('"nameDROPTABLE"'); + expect(actualCall).toContain('varcharEXECsp_helpdb'); + expect(actualCall).toContain('DEFAULT DELETEFROMusers'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service.ts new file mode 100644 index 00000000000..a05af290c85 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service.ts @@ -0,0 +1,201 @@ +import { Injectable } from '@nestjs/common'; + +import { QueryRunner } from 'typeorm'; + +import { 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'; + +@Injectable() +export class WorkspaceSchemaColumnManagerService { + async addColumn( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + column: WorkspaceSchemaColumnDefinition, + ): Promise { + const columnDef = this.buildColumnDefinition(column); + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ADD COLUMN ${columnDef}`; + + await queryRunner.query(sql); + } + + async dropColumn( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + columnName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeColumnName = removeSqlDDLInjection(columnName); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" DROP COLUMN IF EXISTS "${safeColumnName}"`; + + await queryRunner.query(sql); + } + + async dropColumns( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + columnNames: string[], + ): Promise { + if (columnNames.length === 0) return; + + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const dropClauses = columnNames.map((name) => { + const safeName = removeSqlDDLInjection(name); + + return `DROP COLUMN IF EXISTS "${safeName}"`; + }); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ${dropClauses.join(', ')}`; + + await queryRunner.query(sql); + } + + async renameColumn( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + oldColumnName: string, + newColumnName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeOldColumnName = removeSqlDDLInjection(oldColumnName); + const safeNewColumnName = removeSqlDDLInjection(newColumnName); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" RENAME COLUMN "${safeOldColumnName}" TO "${safeNewColumnName}"`; + + await queryRunner.query(sql); + } + + async alterColumnType( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + columnName: string, + newType: string, + usingClause?: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeColumnName = removeSqlDDLInjection(columnName); + const safeNewType = removeSqlDDLInjection(newType); + let sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER COLUMN "${safeColumnName}" TYPE ${safeNewType}`; + + if (usingClause) { + sql += ` USING ${usingClause}`; + } + + await queryRunner.query(sql); + } + + async alterColumnNullability( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + columnName: string, + isNullable: boolean, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeColumnName = removeSqlDDLInjection(columnName); + const action = isNullable ? 'DROP NOT NULL' : 'SET NOT NULL'; + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER COLUMN "${safeColumnName}" ${action}`; + + await queryRunner.query(sql); + } + + async alterColumnDefault( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + columnName: string, + defaultValue?: string | number | boolean | null, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeColumnName = removeSqlDDLInjection(columnName); + let sql: string; + + if (defaultValue !== undefined) { + if (typeof defaultValue === 'string') { + const safeDefaultValue = sanitizeDefaultValue(defaultValue); + + sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER COLUMN "${safeColumnName}" SET DEFAULT ${safeDefaultValue}`; + } else { + sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER COLUMN "${safeColumnName}" SET DEFAULT ${defaultValue}`; + } + } else { + sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER COLUMN "${safeColumnName}" DROP DEFAULT`; + } + + await queryRunner.query(sql); + } + + async columnExists( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + columnName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeColumnName = removeSqlDDLInjection(columnName); + + const result = await queryRunner.query( + `SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 AND column_name = $3 + )`, + [safeSchemaName, safeTableName, safeColumnName], + ); + + return result[0]?.exists || false; + } + + private buildColumnDefinition( + column: WorkspaceSchemaColumnDefinition, + ): string { + const safeName = removeSqlDDLInjection(column.name); + const parts = [`"${safeName}"`]; + + if (column.asExpression) { + parts.push(`AS (${column.asExpression})`); + if (column.generatedType) { + parts.push(column.generatedType); + } + } else { + const safeType = removeSqlDDLInjection(column.type); + + parts.push(column.isArray ? `${safeType}[]` : safeType); + + if (column.isPrimary) { + parts.push('PRIMARY KEY'); + } + + if (column.isNullable === false) { + parts.push('NOT NULL'); + } + + if (column.isUnique) { + parts.push('UNIQUE'); + } + + if (column.default !== undefined) { + if (typeof column.default === 'string') { + const safeDefault = sanitizeDefaultValue(column.default); + + parts.push(`DEFAULT ${safeDefault}`); + } else { + parts.push(`DEFAULT ${column.default}`); + } + } + } + + return parts.join(' '); + } +} 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 new file mode 100644 index 00000000000..7e54ac4c105 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service.ts @@ -0,0 +1,327 @@ +import { Injectable } from '@nestjs/common'; + +import { QueryRunner } from 'typeorm'; + +import { + TwentyORMException, + TwentyORMExceptionCode, +} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; +import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util'; + +@Injectable() +export class WorkspaceSchemaEnumManagerService { + async createEnum( + queryRunner: QueryRunner, + schemaName: string, + enumName: string, + values: string[], + ): Promise { + const sanitizedValues = values + .map((value) => removeSqlDDLInjection(value.toString())) + .map((value) => `'${value}'`) + .join(', '); + + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeEnumName = removeSqlDDLInjection(enumName); + const sql = `CREATE TYPE "${safeSchemaName}"."${safeEnumName}" AS ENUM (${sanitizedValues})`; + + await queryRunner.query(sql); + } + + async dropEnum( + queryRunner: QueryRunner, + schemaName: string, + enumName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeEnumName = removeSqlDDLInjection(enumName); + const sql = `DROP TYPE IF EXISTS "${safeSchemaName}"."${safeEnumName}"`; + + await queryRunner.query(sql); + } + + async renameEnum( + queryRunner: QueryRunner, + schemaName: string, + oldEnumName: string, + newEnumName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeOldEnumName = removeSqlDDLInjection(oldEnumName); + const safeNewEnumName = removeSqlDDLInjection(newEnumName); + const sql = `ALTER TYPE "${safeSchemaName}"."${safeOldEnumName}" RENAME TO "${safeNewEnumName}"`; + + await queryRunner.query(sql); + } + + async addEnumValue( + queryRunner: QueryRunner, + schemaName: string, + enumName: string, + value: string, + beforeValue?: string, + afterValue?: string, + ): Promise { + const sanitizedValue = removeSqlDDLInjection(value); + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeEnumName = removeSqlDDLInjection(enumName); + let sql = `ALTER TYPE "${safeSchemaName}"."${safeEnumName}" ADD VALUE '${sanitizedValue}'`; + + if (beforeValue) { + const sanitizedBeforeValue = removeSqlDDLInjection(beforeValue); + + sql += ` BEFORE '${sanitizedBeforeValue}'`; + } else if (afterValue) { + const sanitizedAfterValue = removeSqlDDLInjection(afterValue); + + sql += ` AFTER '${sanitizedAfterValue}'`; + } + + await queryRunner.query(sql); + } + + async renameEnumValue( + queryRunner: QueryRunner, + schemaName: string, + enumName: string, + oldValue: string, + newValue: string, + ): Promise { + const sanitizedOldValue = removeSqlDDLInjection(oldValue); + const sanitizedNewValue = removeSqlDDLInjection(newValue); + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeEnumName = removeSqlDDLInjection(enumName); + const sql = `ALTER TYPE "${safeSchemaName}"."${safeEnumName}" RENAME VALUE '${sanitizedOldValue}' TO '${sanitizedNewValue}'`; + + await queryRunner.query(sql); + } + + async enumExists( + queryRunner: QueryRunner, + schemaName: string, + enumName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeEnumName = removeSqlDDLInjection(enumName); + + const result = await queryRunner.query( + `SELECT EXISTS ( + SELECT FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = $1 AND t.typname = $2 AND t.typtype = 'e' + )`, + [safeSchemaName, safeEnumName], + ); + + return result[0]?.exists || false; + } + + // TODO: Not sure if we want to use that query or prefer to rely on the "from" values. + async getEnumValues( + queryRunner: QueryRunner, + schemaName: string, + enumName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeEnumName = removeSqlDDLInjection(enumName); + + const result = await queryRunner.query( + `SELECT e.enumlabel as value + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + JOIN pg_enum e ON t.oid = e.enumtypid + WHERE n.nspname = $1 AND t.typname = $2 + ORDER BY e.enumsortorder`, + [safeSchemaName, safeEnumName], + ); + + return result.map((row: { value: string }) => row.value); + } + + async getEnumNameForColumn( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + columnName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeColumnName = removeSqlDDLInjection(columnName); + + const result = await queryRunner.query( + `SELECT udt_name, data_type + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 AND column_name = $3`, + [safeSchemaName, safeTableName, safeColumnName], + ); + + if (!result[0]) { + return null; + } + + const enumTypeName = + result[0].data_type === 'ARRAY' + ? result[0].udt_name.replace(/^_/, '') + : result[0].udt_name; + + return enumTypeName; + } + + async alterEnumValues( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + columnName: string, + newValues: string[], + valueMapping?: Record, + ): Promise { + const isTransactionAlreadyActive = queryRunner.isTransactionActive; + + if (!isTransactionAlreadyActive) { + await queryRunner.startTransaction(); + } + + try { + const oldEnumName = await this.getEnumNameForColumn( + queryRunner, + schemaName, + tableName, + columnName, + ); + + if (!oldEnumName) { + throw new TwentyORMException( + `Enum type not found for column ${columnName}`, + TwentyORMExceptionCode.ENUM_TYPE_NAME_NOT_FOUND, + ); + } + + const tempEnumName = `${oldEnumName}_temp`; + const safeTableName = removeSqlDDLInjection(tableName); + const safeColumnName = removeSqlDDLInjection(columnName); + const newEnumName = `${safeTableName}_${safeColumnName}_enum`; + const oldColumnName = `old_${safeColumnName}`; + + // Rename existing column and enum + await this.renameColumn( + queryRunner, + schemaName, + tableName, + columnName, + oldColumnName, + ); + await this.renameEnum(queryRunner, schemaName, oldEnumName, tempEnumName); + + // Create new enum and column + await this.createEnum(queryRunner, schemaName, newEnumName, newValues); + await this.addEnumColumn( + queryRunner, + schemaName, + tableName, + columnName, + newEnumName, + ); + + // Migrate data + await this.migrateEnumData( + queryRunner, + schemaName, + tableName, + oldColumnName, + columnName, + valueMapping || {}, + ); + + // Clean up + await this.dropColumn(queryRunner, schemaName, tableName, oldColumnName); + await this.dropEnum(queryRunner, schemaName, tempEnumName); + + if (!isTransactionAlreadyActive) { + await queryRunner.commitTransaction(); + } + } catch (error) { + if (!isTransactionAlreadyActive) { + await queryRunner.rollbackTransaction(); + } + throw error; + } + } + + private async renameColumn( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + oldColumnName: string, + newColumnName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeOldColumnName = removeSqlDDLInjection(oldColumnName); + const safeNewColumnName = removeSqlDDLInjection(newColumnName); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" RENAME COLUMN "${safeOldColumnName}" TO "${safeNewColumnName}"`; + + await queryRunner.query(sql); + } + + private async addEnumColumn( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + columnName: string, + enumTypeName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeColumnName = removeSqlDDLInjection(columnName); + const safeEnumTypeName = removeSqlDDLInjection(enumTypeName); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ADD COLUMN "${safeColumnName}" "${safeSchemaName}"."${safeEnumTypeName}"`; + + await queryRunner.query(sql); + } + + private async dropColumn( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + columnName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeColumnName = removeSqlDDLInjection(columnName); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" DROP COLUMN "${safeColumnName}"`; + + await queryRunner.query(sql); + } + + private async migrateEnumData( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + oldColumnName: string, + newColumnName: string, + valueMapping: Record, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeOldColumnName = removeSqlDDLInjection(oldColumnName); + const safeNewColumnName = removeSqlDDLInjection(newColumnName); + + const caseStatements = Object.entries(valueMapping) + .map( + ([oldVal, newVal]) => + `WHEN '${removeSqlDDLInjection(oldVal)}' THEN '${removeSqlDDLInjection(newVal)}'`, + ) + .join(' '); + + const updateSql = ` + UPDATE "${safeSchemaName}"."${safeTableName}" + SET "${safeNewColumnName}" = + CASE "${safeOldColumnName}" + ${caseStatements} + ELSE "${safeOldColumnName}" + END + WHERE "${safeOldColumnName}" IS NOT NULL`; + + await queryRunner.query(updateSql); + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-foreign-key-manager.service.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-foreign-key-manager.service.ts new file mode 100644 index 00000000000..4b741cab962 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-foreign-key-manager.service.ts @@ -0,0 +1,258 @@ +import { Injectable } from '@nestjs/common'; + +import { QueryRunner } from 'typeorm'; + +import { WorkspaceSchemaForeignKeyDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-foreign-key-definition.type'; +import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util'; + +@Injectable() +export class WorkspaceSchemaForeignKeyManagerService { + async createForeignKey( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + foreignKey: WorkspaceSchemaForeignKeyDefinition, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeForeignKeyName = removeSqlDDLInjection(foreignKey.name); + const safeReferencedTableName = removeSqlDDLInjection( + foreignKey.referencedTableName, + ); + + const quotedColumns = foreignKey.columnNames + .map((col) => `"${removeSqlDDLInjection(col)}"`) + .join(', '); + const quotedRefColumns = foreignKey.referencedColumnNames + .map((col) => `"${removeSqlDDLInjection(col)}"`) + .join(', '); + + let sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ADD CONSTRAINT "${safeForeignKeyName}" FOREIGN KEY (${quotedColumns}) REFERENCES "${safeSchemaName}"."${safeReferencedTableName}" (${quotedRefColumns})`; + + if (foreignKey.onDelete) { + sql += ` ON DELETE ${foreignKey.onDelete}`; + } + + if (foreignKey.onUpdate) { + sql += ` ON UPDATE ${foreignKey.onUpdate}`; + } + + await queryRunner.query(sql); + } + + async dropForeignKey( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + foreignKeyName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" DROP CONSTRAINT IF EXISTS "${safeForeignKeyName}"`; + + await queryRunner.query(sql); + } + + async dropForeignKeyByColumn( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + columnName: string, + ): Promise { + const foreignKeyName = await this.getForeignKeyNameByColumn( + queryRunner, + schemaName, + tableName, + columnName, + ); + + if (foreignKeyName) { + await this.dropForeignKey( + queryRunner, + schemaName, + tableName, + foreignKeyName, + ); + } + } + + async foreignKeyExists( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + foreignKeyName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName); + + const result = await queryRunner.query( + `SELECT EXISTS ( + SELECT FROM information_schema.table_constraints + WHERE constraint_schema = $1 + AND table_name = $2 + AND constraint_name = $3 + AND constraint_type = 'FOREIGN KEY' + )`, + [safeSchemaName, safeTableName, safeForeignKeyName], + ); + + return result[0]?.exists || false; + } + + async getForeignKeyNameByColumn( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + columnName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeColumnName = removeSqlDDLInjection(columnName); + + const result = await queryRunner.query( + `SELECT tc.constraint_name + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = $1 + AND tc.table_name = $2 + AND kcu.column_name = $3`, + [safeSchemaName, safeTableName, safeColumnName], + ); + + return result[0]?.constraint_name || null; + } + + async getForeignKeysForTable( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + ): Promise< + Array<{ + constraint_name: string; + column_name: string; + foreign_table_name: string; + foreign_column_name: string; + delete_rule: string; + update_rule: string; + }> + > { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + + const result = await queryRunner.query( + `SELECT + tc.constraint_name, + kcu.column_name, + ccu.table_name AS foreign_table_name, + ccu.column_name AS foreign_column_name, + rc.delete_rule, + rc.update_rule + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.table_schema + JOIN information_schema.referential_constraints AS rc + ON tc.constraint_name = rc.constraint_name + AND tc.table_schema = rc.constraint_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = $1 + AND tc.table_name = $2`, + [safeSchemaName, safeTableName], + ); + + return result; + } + + async createForeignKeyFromColumn( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + columnName: string, + referencedTableName: string, + referencedColumnName = 'id', + onDelete?: WorkspaceSchemaForeignKeyDefinition['onDelete'], + ): Promise { + const foreignKeyName = queryRunner.connection.namingStrategy.foreignKeyName( + tableName, + [columnName], + `${schemaName}.${referencedTableName}`, + [referencedColumnName], + ); + + const foreignKey: WorkspaceSchemaForeignKeyDefinition = { + name: foreignKeyName, + columnNames: [columnName], + referencedTableName, + referencedColumnNames: [referencedColumnName], + onDelete, + }; + + await this.createForeignKey(queryRunner, schemaName, tableName, foreignKey); + } + + async renameForeignKey( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + oldConstraintName: string, + newConstraintName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeOldConstraintName = removeSqlDDLInjection(oldConstraintName); + const safeNewConstraintName = removeSqlDDLInjection(newConstraintName); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" RENAME CONSTRAINT "${safeOldConstraintName}" TO "${safeNewConstraintName}"`; + + await queryRunner.query(sql); + } + + async validateForeignKey( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + foreignKeyName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" VALIDATE CONSTRAINT "${safeForeignKeyName}"`; + + await queryRunner.query(sql); + } + + async setForeignKeyNotDeferrable( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + foreignKeyName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER CONSTRAINT "${safeForeignKeyName}" NOT DEFERRABLE`; + + await queryRunner.query(sql); + } + + async setForeignKeyDeferrable( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + foreignKeyName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER CONSTRAINT "${safeForeignKeyName}" DEFERRABLE`; + + await queryRunner.query(sql); + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-index-manager.service.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-index-manager.service.ts new file mode 100644 index 00000000000..ac601574934 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-index-manager.service.ts @@ -0,0 +1,194 @@ +import { Injectable } from '@nestjs/common'; + +import { QueryRunner } from 'typeorm'; + +import { WorkspaceSchemaIndexDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-index-definition.type'; +import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util'; + +@Injectable() +export class WorkspaceSchemaIndexManagerService { + async createIndex( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + index: WorkspaceSchemaIndexDefinition, + ): Promise { + try { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeIndexName = removeSqlDDLInjection(index.name); + + const quotedColumns = index.columns.map( + (column) => `"${removeSqlDDLInjection(column)}"`, + ); + const isUnique = index.isUnique ? 'UNIQUE' : ''; + const indexType = + index.type && index.type !== 'BTREE' ? `USING ${index.type}` : ''; + const whereClause = index.where ? `WHERE ${index.where}` : ''; + const includeClause = index.include?.length + ? `INCLUDE (${index.include + .map((col) => `"${removeSqlDDLInjection(col)}"`) + .join(', ')})` + : ''; + + const sql = [ + 'CREATE', + isUnique && 'UNIQUE', + 'INDEX IF NOT EXISTS', + `"${safeIndexName}"`, + 'ON', + `"${safeSchemaName}"."${safeTableName}"`, + indexType, + `(${quotedColumns.join(', ')})`, + includeClause, + whereClause, + ] + .filter(Boolean) + .join(' ') + .trim(); + + await queryRunner.query(sql); + } catch (error: unknown) { + // Ignore error if index already exists + if (error instanceof Error && 'code' in error && error.code === '42P07') { + return; + } + throw error; + } + } + + async dropIndex( + queryRunner: QueryRunner, + schemaName: string, + indexName: string, + ): Promise { + try { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeIndexName = removeSqlDDLInjection(indexName); + const sql = `DROP INDEX IF EXISTS "${safeSchemaName}"."${safeIndexName}"`; + + await queryRunner.query(sql); + } catch (error: unknown) { + // Ignore error if index does not exist + if (error instanceof Error && 'code' in error && error.code === '42704') { + return; + } + throw error; + } + } + + async renameIndex( + queryRunner: QueryRunner, + schemaName: string, + oldIndexName: string, + newIndexName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeOldIndexName = removeSqlDDLInjection(oldIndexName); + const safeNewIndexName = removeSqlDDLInjection(newIndexName); + const sql = `ALTER INDEX "${safeSchemaName}"."${safeOldIndexName}" RENAME TO "${safeNewIndexName}"`; + + await queryRunner.query(sql); + } + + async indexExists( + queryRunner: QueryRunner, + schemaName: string, + indexName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeIndexName = removeSqlDDLInjection(indexName); + + const result = await queryRunner.query( + `SELECT EXISTS ( + SELECT FROM pg_indexes + WHERE schemaname = $1 AND indexname = $2 + )`, + [safeSchemaName, safeIndexName], + ); + + return result[0]?.exists || false; + } + + async getIndexesForTable( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + ): Promise> { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + + const result = await queryRunner.query( + `SELECT indexname, indexdef + FROM pg_indexes + WHERE schemaname = $1 AND tablename = $2`, + [safeSchemaName, safeTableName], + ); + + return result; + } + + async createPrimaryKey( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + constraintName: string, + columnNames: string[], + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeConstraintName = removeSqlDDLInjection(constraintName); + const quotedColumns = columnNames + .map((col) => `"${removeSqlDDLInjection(col)}"`) + .join(', '); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ADD CONSTRAINT "${safeConstraintName}" PRIMARY KEY (${quotedColumns})`; + + await queryRunner.query(sql); + } + + async dropPrimaryKey( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + constraintName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeConstraintName = removeSqlDDLInjection(constraintName); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" DROP CONSTRAINT IF EXISTS "${safeConstraintName}"`; + + await queryRunner.query(sql); + } + + async createUniqueConstraint( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + constraintName: string, + columnNames: string[], + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeConstraintName = removeSqlDDLInjection(constraintName); + const quotedColumns = columnNames + .map((col) => `"${removeSqlDDLInjection(col)}"`) + .join(', '); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ADD CONSTRAINT "${safeConstraintName}" UNIQUE (${quotedColumns})`; + + await queryRunner.query(sql); + } + + async dropUniqueConstraint( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + constraintName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeConstraintName = removeSqlDDLInjection(constraintName); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" DROP CONSTRAINT IF EXISTS "${safeConstraintName}"`; + + await queryRunner.query(sql); + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-table-manager.service.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-table-manager.service.ts new file mode 100644 index 00000000000..5ced5571ec6 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-table-manager.service.ts @@ -0,0 +1,113 @@ +import { Injectable } from '@nestjs/common'; + +import { QueryRunner } from 'typeorm'; + +import { sanitizeDefaultValue } from 'src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util'; +import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util'; + +@Injectable() +export class WorkspaceSchemaTableManagerService { + async createTable( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + columns?: Array<{ + name: string; + type: string; + isNullable?: boolean; + default?: string | number | boolean | null; + isPrimary?: boolean; + isUnique?: boolean; + isArray?: boolean; + }>, + ): Promise { + const columnDefinitions = + columns?.map((column) => { + const safeName = removeSqlDDLInjection(column.name); + const safeType = removeSqlDDLInjection(column.type); + const parts = [ + `"${safeName}" ${column.isArray ? `${safeType}[]` : safeType}`, + ]; + + if (column.isPrimary) { + parts.push('PRIMARY KEY'); + } + + if (column.isNullable === false) { + parts.push('NOT NULL'); + } + + if (column.isUnique) { + parts.push('UNIQUE'); + } + + if (column.default !== undefined) { + if (typeof column.default === 'string') { + const safeDefault = sanitizeDefaultValue(column.default); + + parts.push(`DEFAULT ${safeDefault}`); + } else { + parts.push(`DEFAULT ${column.default}`); + } + } + + return parts.join(' '); + }) || []; + + // Add default columns if no columns specified + if (columnDefinitions.length === 0) { + columnDefinitions.push('"id" uuid PRIMARY KEY DEFAULT gen_random_uuid()'); + } + + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const sql = `CREATE TABLE IF NOT EXISTS "${safeSchemaName}"."${safeTableName}" (${columnDefinitions.join(', ')})`; + + await queryRunner.query(sql); + } + + async dropTable( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const sql = `DROP TABLE IF EXISTS "${safeSchemaName}"."${safeTableName}"`; + + await queryRunner.query(sql); + } + + async renameTable( + queryRunner: QueryRunner, + schemaName: string, + oldTableName: string, + newTableName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeOldTableName = removeSqlDDLInjection(oldTableName); + const safeNewTableName = removeSqlDDLInjection(newTableName); + const sql = `ALTER TABLE "${safeSchemaName}"."${safeOldTableName}" RENAME TO "${safeNewTableName}"`; + + await queryRunner.query(sql); + } + + async tableExists( + queryRunner: QueryRunner, + schemaName: string, + tableName: string, + ): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + + const result = await queryRunner.query( + `SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = $1 AND table_name = $2 + )`, + [safeSchemaName, safeTableName], + ); + + return result[0]?.exists || false; + } +} 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 new file mode 100644 index 00000000000..3b69116c30d --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type.ts @@ -0,0 +1,11 @@ +export type WorkspaceSchemaColumnDefinition = { + name: string; + type: string; + isNullable?: boolean; + default?: string | number | boolean | null; + isPrimary?: boolean; + isUnique?: boolean; + isArray?: boolean; + asExpression?: string; + generatedType?: 'STORED' | 'VIRTUAL'; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-enum-definition.type.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-enum-definition.type.ts new file mode 100644 index 00000000000..bf1f02b494a --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-enum-definition.type.ts @@ -0,0 +1,4 @@ +export type WorkspaceSchemaEnumDefinition = { + name: string; + values: string[]; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-foreign-key-definition.type.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-foreign-key-definition.type.ts new file mode 100644 index 00000000000..9e67f6fa773 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-foreign-key-definition.type.ts @@ -0,0 +1,8 @@ +export type WorkspaceSchemaForeignKeyDefinition = { + name: string; + columnNames: string[]; + referencedTableName: string; + referencedColumnNames: string[]; + onDelete?: 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION' | 'SET DEFAULT'; + onUpdate?: 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION' | 'SET DEFAULT'; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-index-definition.type.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-index-definition.type.ts new file mode 100644 index 00000000000..7b0a008e9a5 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-index-definition.type.ts @@ -0,0 +1,16 @@ +export type WorkspaceSchemaIndexType = + | 'BTREE' + | 'HASH' + | 'GIST' + | 'SPGIST' + | 'GIN' + | 'BRIN'; + +export type WorkspaceSchemaIndexDefinition = { + name: string; + columns: string[]; + type?: WorkspaceSchemaIndexType; + isUnique?: boolean; + where?: string; + include?: 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 new file mode 100644 index 00000000000..b3d2d6bb05f --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/__tests__/sanitize-default-value.util.spec.ts @@ -0,0 +1,319 @@ +import { sanitizeDefaultValue } from 'src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util'; + +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()'; + + // Act + const result = sanitizeDefaultValue(input); + + // Assert + expect(result).toBe('uuid_generate_v4()'); + }); + + it('should allow now() function', () => { + // Prepare + const input = 'now()'; + + // Act + const result = sanitizeDefaultValue(input); + + // Assert + 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'); + }); + }); + + describe('SQL injection prevention', () => { + it('should sanitize potential SQL injection in string values', () => { + // Prepare + const maliciousInput = "'; DROP TABLE users; --"; + + // Act + const result = sanitizeDefaultValue(maliciousInput); + + // Assert + expect(result).not.toContain('DROP TABLE'); + expect(result).not.toContain(';'); + expect(result).not.toContain('--'); + expect(result).toBe('DROPTABLEusers'); + }); + + it('should sanitize quotes in string values', () => { + // Prepare + const inputWithQuotes = 'test"value'; + + // Act + const result = sanitizeDefaultValue(inputWithQuotes); + + // Assert + expect(result).not.toContain('"'); + expect(result).toBe('testvalue'); + }); + + it('should sanitize parentheses in non-function values', () => { + // Prepare + const inputWithParens = 'test(value)'; + + // Act + const result = sanitizeDefaultValue(inputWithParens); + + // Assert + expect(result).not.toContain('('); + expect(result).not.toContain(')'); + expect(result).toBe('testvalue'); + }); + + it('should sanitize backslashes', () => { + // Prepare + const inputWithBackslash = 'test\\value'; + + // Act + const result = sanitizeDefaultValue(inputWithBackslash); + + // Assert + expect(result).not.toContain('\\'); + expect(result).toBe('testvalue'); + }); + + it('should sanitize SQL comment patterns', () => { + // Prepare + const inputWithComments = 'value/*comment*/test'; + + // Act + const result = sanitizeDefaultValue(inputWithComments); + + // Assert + expect(result).not.toContain('/*'); + expect(result).not.toContain('*/'); + expect(result).toBe('valuecommenttest'); + }); + + it('should remove non-alphanumeric characters but preserve SQL keywords in alphanumeric form', () => { + // Prepare + const inputWithKeywords = 'SELECT * FROM users'; + + // Act + const result = sanitizeDefaultValue(inputWithKeywords); + + // Assert + expect(result).toBe('SELECTFROMusers'); + expect(result).not.toContain('*'); + expect(result).not.toContain(' '); + }); + }); + + describe('regular values', () => { + it('should preserve underscores and alphanumeric characters in simple string values', () => { + // Prepare + const input = 'simple_value'; + + // Act + const result = sanitizeDefaultValue(input); + + // Assert + expect(result).toBe('simple_value'); + }); + + it('should preserve numeric string values', () => { + // Prepare + const input = '12345'; + + // Act + const result = sanitizeDefaultValue(input); + + // Assert + expect(result).toBe('12345'); + }); + + it('should preserve boolean string values', () => { + // Act & Assert + expect(sanitizeDefaultValue('true')).toBe('true'); + expect(sanitizeDefaultValue('false')).toBe('false'); + }); + + it('should handle empty string', () => { + // Prepare + const input = ''; + + // Act + const result = sanitizeDefaultValue(input); + + // Assert + expect(result).toBe(''); + }); + + it('should remove whitespace but preserve alphanumeric and underscores', () => { + // Prepare + const input = ' test '; + + // Act + const result = sanitizeDefaultValue(input); + + // Assert + expect(result).toBe('test'); + }); + + it('should preserve alphanumeric values with underscores', () => { + // Prepare + const input = 'test_value_123'; + + // Act + const result = sanitizeDefaultValue(input); + + // Assert + expect(result).toBe('test_value_123'); + }); + }); + + describe('mixed cases', () => { + 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'); + }); + + 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'); + }); + + it('should handle complex mixed input', () => { + // Prepare + const complexInput = 'test"value; DROP TABLE users; /* comment */ now()'; + + // Act + const result = sanitizeDefaultValue(complexInput); + + // Assert + expect(result).toBe('testvalueDROPTABLEuserscommentnow'); + expect(result).not.toContain(';'); + expect(result).not.toContain('"'); + expect(result).not.toContain('/*'); + expect(result).not.toContain('*/'); + expect(result).not.toContain(' '); + }); + }); + + describe('edge cases', () => { + it('should handle null-like strings', () => { + // Act & Assert + expect(sanitizeDefaultValue('null')).toBe('null'); + expect(sanitizeDefaultValue('NULL')).toBe('NULL'); + expect(sanitizeDefaultValue('undefined')).toBe('undefined'); + }); + + it('should handle strings that start with allowed function names', () => { + // Act & Assert + expect(sanitizeDefaultValue('now_extended')).toBe('now_extended'); + expect(sanitizeDefaultValue('gen_random_uuid_custom')).toBe( + 'gen_random_uuid_custom', + ); + }); + + it('should handle strings with special characters', () => { + // Prepare + const specialChars = '!@#$%^&*()+=[]{}|\\:";\'<>?,.'; + + // Act + const result = sanitizeDefaultValue(specialChars); + + // Assert + expect(result).toBe(''); + }); + + it('should handle very long strings', () => { + // Prepare + const longString = 'a'.repeat(1000) + '; DROP TABLE users;'; + + // Act + const result = sanitizeDefaultValue(longString); + + // Assert + 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/sanitize-default-value.util.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts new file mode 100644 index 00000000000..dd6b566ee4b --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts @@ -0,0 +1,20 @@ +import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util'; + +export const sanitizeDefaultValue = (defaultValue: string): string => { + const allowedFunctions = [ + 'gen_random_uuid()', + 'uuid_generate_v4()', + 'now()', + 'current_timestamp', + 'current_date', + 'current_time', + 'localtime', + 'localtimestamp', + ]; + + if (allowedFunctions.includes(defaultValue.toLowerCase())) { + return defaultValue; + } + + return removeSqlDDLInjection(defaultValue); +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module.ts new file mode 100644 index 00000000000..1913eacdaca --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; + +import { WorkspaceSchemaColumnManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service'; +import { WorkspaceSchemaEnumManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service'; +import { WorkspaceSchemaForeignKeyManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-foreign-key-manager.service'; +import { WorkspaceSchemaIndexManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-index-manager.service'; +import { WorkspaceSchemaTableManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-table-manager.service'; + +import { WorkspaceSchemaManagerService } from './workspace-schema-manager.service'; + +@Module({ + providers: [ + WorkspaceSchemaManagerService, + WorkspaceSchemaTableManagerService, + WorkspaceSchemaColumnManagerService, + WorkspaceSchemaIndexManagerService, + WorkspaceSchemaEnumManagerService, + WorkspaceSchemaForeignKeyManagerService, + ], + exports: [WorkspaceSchemaManagerService], +}) +export class WorkspaceSchemaManagerModule {} diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service.ts new file mode 100644 index 00000000000..61b89f002cb --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceSchemaColumnManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service'; +import { WorkspaceSchemaEnumManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service'; +import { WorkspaceSchemaForeignKeyManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-foreign-key-manager.service'; +import { WorkspaceSchemaIndexManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-index-manager.service'; +import { WorkspaceSchemaTableManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-table-manager.service'; + +@Injectable() +export class WorkspaceSchemaManagerService { + public readonly tableManager: WorkspaceSchemaTableManagerService; + public readonly columnManager: WorkspaceSchemaColumnManagerService; + public readonly indexManager: WorkspaceSchemaIndexManagerService; + public readonly enumManager: WorkspaceSchemaEnumManagerService; + public readonly foreignKeyManager: WorkspaceSchemaForeignKeyManagerService; + + constructor( + workspaceSchemaTableManager: WorkspaceSchemaTableManagerService, + workspaceSchemaColumnManager: WorkspaceSchemaColumnManagerService, + workspaceSchemaIndexManager: WorkspaceSchemaIndexManagerService, + workspaceSchemaEnumManager: WorkspaceSchemaEnumManagerService, + workspaceSchemaForeignKeyManager: WorkspaceSchemaForeignKeyManagerService, + ) { + this.tableManager = workspaceSchemaTableManager; + this.columnManager = workspaceSchemaColumnManager; + this.indexManager = workspaceSchemaIndexManager; + this.enumManager = workspaceSchemaEnumManager; + this.foreignKeyManager = workspaceSchemaForeignKeyManager; + } +}