From 689a346c2eba37cead307ce84378ee70a12ab979 Mon Sep 17 00:00:00 2001 From: Weiko Date: Wed, 30 Jul 2025 19:24:39 +0200 Subject: [PATCH] Implement Workspace Schema Manager Service (#13501) This PR adds a new set of services that can be used to manage a DB schema (workspace schema in this case) such as creating tables, columns, enums, indexes, FK, etc... Nothing should be new as I've simply reimplemented what we can already do in the current workspace migration runner but this using raw queries for everything as we faced some performances issues in the past using the orm API directly. Some methods are new but were picked from usages in migration commands so I took the liberty to implement them as well in case they are still needed. This is isolated enough to be used in a command but the first use case will be to use it in the new WorkspaceSchemaMigrationRunnerService that will call those methods from the migration instructions --- .../exceptions/twenty-orm.exception.ts | 1 + .../workspace-schema-manager.service.spec.ts | 176 +++++ ...pace-schema-column-manager.service.spec.ts | 463 +++++++++++ ...kspace-schema-enum-manager.service.spec.ts | 610 +++++++++++++++ ...schema-foreign-key-manager.service.spec.ts | 624 +++++++++++++++ ...space-schema-index-manager.service.spec.ts | 721 ++++++++++++++++++ ...space-schema-table-manager.service.spec.ts | 276 +++++++ ...workspace-schema-column-manager.service.ts | 201 +++++ .../workspace-schema-enum-manager.service.ts | 327 ++++++++ ...pace-schema-foreign-key-manager.service.ts | 258 +++++++ .../workspace-schema-index-manager.service.ts | 194 +++++ .../workspace-schema-table-manager.service.ts | 113 +++ ...workspace-schema-column-definition.type.ts | 11 + .../workspace-schema-enum-definition.type.ts | 4 + ...pace-schema-foreign-key-definition.type.ts | 8 + .../workspace-schema-index-definition.type.ts | 16 + .../sanitize-default-value.util.spec.ts | 319 ++++++++ .../utils/sanitize-default-value.util.ts | 20 + .../workspace-schema-manager.module.ts | 22 + .../workspace-schema-manager.service.ts | 30 + 20 files changed, 4394 insertions(+) create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/__tests__/workspace-schema-manager.service.spec.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-column-manager.service.spec.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-enum-manager.service.spec.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-foreign-key-manager.service.spec.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-index-manager.service.spec.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-table-manager.service.spec.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-foreign-key-manager.service.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-index-manager.service.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-table-manager.service.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-enum-definition.type.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-foreign-key-definition.type.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-index-definition.type.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/__tests__/sanitize-default-value.util.spec.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service.ts 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; + } +}