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
This commit is contained in:
Weiko
2025-07-30 19:24:39 +02:00
committed by GitHub
parent da79afa80a
commit 689a346c2e
20 changed files with 4394 additions and 0 deletions
@@ -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',
}
@@ -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<QueryRunner>;
let tableManager: jest.Mocked<WorkspaceSchemaTableManagerService>;
let columnManager: jest.Mocked<WorkspaceSchemaColumnManagerService>;
let indexManager: jest.Mocked<WorkspaceSchemaIndexManagerService>;
let enumManager: jest.Mocked<WorkspaceSchemaEnumManagerService>;
let foreignKeyManager: jest.Mocked<WorkspaceSchemaForeignKeyManagerService>;
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>(
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();
});
});
});
@@ -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<QueryRunner>;
beforeEach(async () => {
mockQueryRunner = {
query: jest.fn().mockResolvedValue([]),
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [WorkspaceSchemaColumnManagerService],
}).compile();
service = module.get<WorkspaceSchemaColumnManagerService>(
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"');
});
});
});
@@ -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<QueryRunner>;
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>(
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();
});
});
});
@@ -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<QueryRunner>;
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>(
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');
});
});
});
@@ -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<QueryRunner>;
beforeEach(async () => {
mockQueryRunner = {
query: jest.fn().mockResolvedValue([]),
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [WorkspaceSchemaIndexManagerService],
}).compile();
service = module.get<WorkspaceSchemaIndexManagerService>(
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');
});
});
});
@@ -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<QueryRunner>;
beforeEach(async () => {
mockQueryRunner = {
query: jest.fn().mockResolvedValue([]),
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [WorkspaceSchemaTableManagerService],
}).compile();
service = module.get<WorkspaceSchemaTableManagerService>(
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');
});
});
});
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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(' ');
}
}
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<string[]> {
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<string | null> {
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<string, string>,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<string, string>,
): Promise<void> {
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);
}
}
@@ -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<void> {
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<void> {
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<void> {
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<boolean> {
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<string | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
@@ -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<void> {
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<void> {
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<void> {
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<boolean> {
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<Array<{ indexname: string; indexdef: string }>> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
@@ -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<void> {
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<void> {
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<void> {
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<boolean> {
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;
}
}
@@ -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';
};
@@ -0,0 +1,4 @@
export type WorkspaceSchemaEnumDefinition = {
name: string;
values: string[];
};
@@ -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';
};
@@ -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[];
};
@@ -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(' ');
});
});
});
@@ -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);
};
@@ -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 {}
@@ -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;
}
}