Workspace schema migration runner v2 (#13899)

This commit is contained in:
Weiko
2025-08-14 15:58:25 +02:00
committed by GitHub
parent c1885233bc
commit 1707e00ad4
47 changed files with 4098 additions and 3354 deletions
@@ -28,7 +28,7 @@ describe('WorkspaceSchemaManager', () => {
} as any;
columnManager = {
addColumn: jest.fn(),
addColumns: jest.fn(),
dropColumn: jest.fn(),
renameColumn: jest.fn(),
columnExists: jest.fn(),
@@ -110,7 +110,7 @@ describe('WorkspaceSchemaManager', () => {
it('should provide access to column manager', () => {
// Act & Assert
expect(service.columnManager).toBeInstanceOf(Object);
expect(service.columnManager.addColumn).toBeDefined();
expect(service.columnManager.addColumns).toBeDefined();
});
it('should provide access to index manager', () => {
@@ -139,33 +139,33 @@ describe('WorkspaceSchemaManager', () => {
const tableName = 'users';
// Act
await service.tableManager.createTable(
mockQueryRunner,
await service.tableManager.createTable({
queryRunner: mockQueryRunner,
schemaName,
tableName,
[
columnDefinitions: [
{ name: 'id', type: 'uuid', isPrimary: true },
{ name: 'name', type: 'varchar', isNullable: false },
{ name: 'status', type: 'varchar' },
],
);
});
await service.enumManager.createEnum(
mockQueryRunner,
await service.enumManager.createEnum({
queryRunner: mockQueryRunner,
schemaName,
'user_status_enum',
['ACTIVE', 'INACTIVE'],
);
enumName: 'user_status_enum',
values: ['ACTIVE', 'INACTIVE'],
});
await service.indexManager.createIndex(
mockQueryRunner,
await service.indexManager.createIndex({
queryRunner: mockQueryRunner,
schemaName,
tableName,
{
index: {
name: 'idx_users_name',
columns: ['name'],
},
);
});
// Assert
expect(tableManager.createTable).toHaveBeenCalled();
@@ -0,0 +1,12 @@
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
export class WorkspaceSchemaManagerException extends CustomException<
keyof typeof WorkspaceSchemaManagerExceptionCode
> {}
export const WorkspaceSchemaManagerExceptionCode = appendCommonExceptionCode({
ENUM_OPERATION_FAILED: 'ENUM_OPERATION_FAILED',
} as const);
@@ -1,463 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { type 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"');
});
});
});
@@ -1,610 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { type 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();
});
});
});
@@ -1,624 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { type QueryRunner } from 'typeorm';
import { WorkspaceSchemaForeignKeyManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-foreign-key-manager.service';
import { type 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');
});
});
});
@@ -1,721 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { type QueryRunner } from 'typeorm';
import { WorkspaceSchemaIndexManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-index-manager.service';
import { type 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');
});
});
});
@@ -1,276 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { type 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');
});
});
});
@@ -1,47 +1,45 @@
import { Injectable } from '@nestjs/common';
import { type QueryRunner } from 'typeorm';
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
import { buildSqlColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util';
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);
async addColumns({
queryRunner,
schemaName,
tableName,
columnDefinitions,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
columnDefinitions: WorkspaceSchemaColumnDefinition[];
}): Promise<void> {
if (columnDefinitions.length === 0) return;
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ADD COLUMN ${columnDef}`;
const addColumnClauses = columnDefinitions.map(
(column) => `ADD COLUMN ${buildSqlColumnDefinition(column)}`,
);
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ${addColumnClauses.join(', ')}`;
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> {
async dropColumns({
queryRunner,
schemaName,
tableName,
columnNames,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
columnNames: string[];
}): Promise<void> {
if (columnNames.length === 0) return;
const safeSchemaName = removeSqlDDLInjection(schemaName);
@@ -56,13 +54,19 @@ export class WorkspaceSchemaColumnManagerService {
await queryRunner.query(sql);
}
async renameColumn(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
oldColumnName: string,
newColumnName: string,
): Promise<void> {
async renameColumn({
queryRunner,
schemaName,
tableName,
oldColumnName,
newColumnName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
oldColumnName: string;
newColumnName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeOldColumnName = removeSqlDDLInjection(oldColumnName);
@@ -72,50 +76,19 @@ export class WorkspaceSchemaColumnManagerService {
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> {
async alterColumnDefault({
queryRunner,
schemaName,
tableName,
columnName,
defaultValue,
}: {
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);
@@ -135,67 +108,4 @@ export class WorkspaceSchemaColumnManagerService {
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(' ');
}
}
@@ -1,21 +1,34 @@
import { Injectable } from '@nestjs/common';
import { type QueryRunner } from 'typeorm';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
WorkspaceSchemaManagerException,
WorkspaceSchemaManagerExceptionCode,
} from 'src/engine/twenty-orm/workspace-schema-manager/exceptions/workspace-schema-manager.exception';
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
import { buildSqlColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util';
import { computePostgresEnumName } from 'src/engine/workspace-manager/workspace-migration-runner/utils/compute-postgres-enum-name.util';
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util';
@Injectable()
// TODO: upstream does not guarantee transactionality, implement IF EXISTS or equivalent for idempotency
export class WorkspaceSchemaEnumManagerService {
async createEnum(
queryRunner: QueryRunner,
schemaName: string,
enumName: string,
values: string[],
): Promise<void> {
async createEnum({
queryRunner,
schemaName,
enumName,
values,
}: {
queryRunner: QueryRunner;
schemaName: string;
enumName: string;
values: string[];
}): Promise<void> {
if (values.length === 0) {
throw new WorkspaceSchemaManagerException(
`Cannot create enum with no values`,
WorkspaceSchemaManagerExceptionCode.ENUM_OPERATION_FAILED,
);
}
const sanitizedValues = values
.map((value) => removeSqlDDLInjection(value.toString()))
.map((value) => `'${value}'`)
@@ -28,11 +41,15 @@ export class WorkspaceSchemaEnumManagerService {
await queryRunner.query(sql);
}
async dropEnum(
queryRunner: QueryRunner,
schemaName: string,
enumName: string,
): Promise<void> {
async dropEnum({
queryRunner,
schemaName,
enumName,
}: {
queryRunner: QueryRunner;
schemaName: string;
enumName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeEnumName = removeSqlDDLInjection(enumName);
const sql = `DROP TYPE IF EXISTS "${safeSchemaName}"."${safeEnumName}"`;
@@ -40,12 +57,17 @@ export class WorkspaceSchemaEnumManagerService {
await queryRunner.query(sql);
}
async renameEnum(
queryRunner: QueryRunner,
schemaName: string,
oldEnumName: string,
newEnumName: string,
): Promise<void> {
async renameEnum({
queryRunner,
schemaName,
oldEnumName,
newEnumName,
}: {
queryRunner: QueryRunner;
schemaName: string;
oldEnumName: string;
newEnumName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeOldEnumName = removeSqlDDLInjection(oldEnumName);
const safeNewEnumName = removeSqlDDLInjection(newEnumName);
@@ -54,14 +76,21 @@ export class WorkspaceSchemaEnumManagerService {
await queryRunner.query(sql);
}
async addEnumValue(
queryRunner: QueryRunner,
schemaName: string,
enumName: string,
value: string,
beforeValue?: string,
afterValue?: string,
): Promise<void> {
async addEnumValue({
queryRunner,
schemaName,
enumName,
value,
beforeValue,
afterValue,
}: {
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);
@@ -80,13 +109,19 @@ export class WorkspaceSchemaEnumManagerService {
await queryRunner.query(sql);
}
async renameEnumValue(
queryRunner: QueryRunner,
schemaName: string,
enumName: string,
oldValue: string,
newValue: string,
): Promise<void> {
async renameEnumValue({
queryRunner,
schemaName,
enumName,
oldValue,
newValue,
}: {
queryRunner: QueryRunner;
schemaName: string;
enumName: string;
oldValue: string;
newValue: string;
}): Promise<void> {
const sanitizedOldValue = removeSqlDDLInjection(oldValue);
const sanitizedNewValue = removeSqlDDLInjection(newValue);
const safeSchemaName = removeSqlDDLInjection(schemaName);
@@ -96,145 +131,99 @@ export class WorkspaceSchemaEnumManagerService {
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> {
// TODO: optimize this to not create a temp enum and column if not necessary (e.g. using ADD VALUE)
async alterEnumValues({
queryRunner,
schemaName,
tableName,
columnDefinition,
oldToNewEnumOptionMap,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
columnDefinition: WorkspaceSchemaColumnDefinition;
oldToNewEnumOptionMap?: Record<string, string>;
}): Promise<void> {
const isTransactionAlreadyActive = queryRunner.isTransactionActive;
if (!isTransactionAlreadyActive) {
await queryRunner.startTransaction();
}
if (
!columnDefinition.enumValues ||
columnDefinition.enumValues.length === 0
) {
throw new WorkspaceSchemaManagerException(
`Cannot alter enum values for column ${columnDefinition.name} because it has no enum values`,
WorkspaceSchemaManagerExceptionCode.ENUM_OPERATION_FAILED,
);
}
try {
const oldEnumName = await this.getEnumNameForColumn(
const columnName = columnDefinition.name;
const enumName = computePostgresEnumName({
tableName,
columnName,
});
const oldEnumName = `${enumName}_old`;
await this.renameEnum({
queryRunner,
schemaName,
oldEnumName: enumName,
newEnumName: oldEnumName,
});
await this.createEnum({
queryRunner,
schemaName,
enumName,
values: columnDefinition.enumValues,
});
const oldColumnName = `${columnName}_old`;
await this.renameColumn({
queryRunner,
schemaName,
tableName,
columnName,
);
oldColumnName: columnName,
newColumnName: oldColumnName,
});
if (!oldEnumName) {
throw new TwentyORMException(
`Enum type not found for column ${columnName}`,
TwentyORMExceptionCode.ENUM_TYPE_NAME_NOT_FOUND,
);
await this.createColumnUsingEnum({
queryRunner,
schemaName,
tableName,
columnDefinition,
enumTypeName: enumName,
});
if (
oldToNewEnumOptionMap &&
Object.keys(oldToNewEnumOptionMap).length > 0
) {
await this.migrateEnumData({
queryRunner,
schemaName,
tableName,
oldColumnName,
newColumnName: columnName,
oldToNewEnumOptionMap,
});
}
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(
await this.dropEnum({ queryRunner, schemaName, enumName: oldEnumName });
await this.dropColumn({
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);
columnName: oldColumnName,
});
if (!isTransactionAlreadyActive) {
await queryRunner.commitTransaction();
@@ -247,13 +236,19 @@ export class WorkspaceSchemaEnumManagerService {
}
}
private async renameColumn(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
oldColumnName: string,
newColumnName: string,
): Promise<void> {
private async renameColumn({
queryRunner,
schemaName,
tableName,
oldColumnName,
newColumnName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
oldColumnName: string;
newColumnName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeOldColumnName = removeSqlDDLInjection(oldColumnName);
@@ -263,28 +258,41 @@ export class WorkspaceSchemaEnumManagerService {
await queryRunner.query(sql);
}
private async addEnumColumn(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
columnName: string,
enumTypeName: string,
): Promise<void> {
private async createColumnUsingEnum({
queryRunner,
schemaName,
tableName,
columnDefinition,
enumTypeName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
columnDefinition: WorkspaceSchemaColumnDefinition;
enumTypeName: string;
}): Promise<void> {
const columnDef = buildSqlColumnDefinition({
...columnDefinition,
type: enumTypeName,
});
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}"`;
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ADD COLUMN ${columnDef}`;
await queryRunner.query(sql);
}
private async dropColumn(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
columnName: string,
): Promise<void> {
private async dropColumn({
queryRunner,
schemaName,
tableName,
columnName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
columnName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeColumnName = removeSqlDDLInjection(columnName);
@@ -293,23 +301,31 @@ export class WorkspaceSchemaEnumManagerService {
await queryRunner.query(sql);
}
private async migrateEnumData(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
oldColumnName: string,
newColumnName: string,
valueMapping: Record<string, string>,
): Promise<void> {
// TODO: explore USING clause to avoid the need for this function
private async migrateEnumData({
queryRunner,
schemaName,
tableName,
oldColumnName,
newColumnName,
oldToNewEnumOptionMap,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
oldColumnName: string;
newColumnName: string;
oldToNewEnumOptionMap: 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)
const caseStatements = Object.entries(oldToNewEnumOptionMap)
.map(
([oldVal, newVal]) =>
`WHEN '${removeSqlDDLInjection(oldVal)}' THEN '${removeSqlDDLInjection(newVal)}'`,
([oldEnumOption, newEnumOption]) =>
`WHEN '${removeSqlDDLInjection(oldEnumOption)}' THEN '${removeSqlDDLInjection(newEnumOption)}'`,
)
.join(' ');
@@ -1,18 +1,20 @@
import { Injectable } from '@nestjs/common';
import { type QueryRunner } from 'typeorm';
import { type 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> {
async createForeignKey({
queryRunner,
schemaName,
tableName,
foreignKey,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
foreignKey: WorkspaceSchemaForeignKeyDefinition;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeForeignKeyName = removeSqlDDLInjection(foreignKey.name);
@@ -40,12 +42,17 @@ export class WorkspaceSchemaForeignKeyManagerService {
await queryRunner.query(sql);
}
async dropForeignKey(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
foreignKeyName: string,
): Promise<void> {
async dropForeignKey({
queryRunner,
schemaName,
tableName,
foreignKeyName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
foreignKeyName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
@@ -54,35 +61,45 @@ export class WorkspaceSchemaForeignKeyManagerService {
await queryRunner.query(sql);
}
async dropForeignKeyByColumn(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
columnName: string,
): Promise<void> {
const foreignKeyName = await this.getForeignKeyNameByColumn(
async dropForeignKeyByColumn({
queryRunner,
schemaName,
tableName,
columnName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
columnName: string;
}): Promise<void> {
const foreignKeyName = await this.getForeignKeyNameByColumn({
queryRunner,
schemaName,
tableName,
columnName,
);
});
if (foreignKeyName) {
await this.dropForeignKey(
await this.dropForeignKey({
queryRunner,
schemaName,
tableName,
foreignKeyName,
);
});
}
}
async foreignKeyExists(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
foreignKeyName: string,
): Promise<boolean> {
async foreignKeyExists({
queryRunner,
schemaName,
tableName,
foreignKeyName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
foreignKeyName: string;
}): Promise<boolean> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
@@ -101,12 +118,17 @@ export class WorkspaceSchemaForeignKeyManagerService {
return result[0]?.exists || false;
}
async getForeignKeyNameByColumn(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
columnName: string,
): Promise<string | null> {
async getForeignKeyNameByColumn({
queryRunner,
schemaName,
tableName,
columnName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
columnName: string;
}): Promise<string | null> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeColumnName = removeSqlDDLInjection(columnName);
@@ -127,11 +149,15 @@ export class WorkspaceSchemaForeignKeyManagerService {
return result[0]?.constraint_name || null;
}
async getForeignKeysForTable(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
): Promise<
async getForeignKeysForTable({
queryRunner,
schemaName,
tableName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
}): Promise<
Array<{
constraint_name: string;
column_name: string;
@@ -171,15 +197,23 @@ export class WorkspaceSchemaForeignKeyManagerService {
return result;
}
async createForeignKeyFromColumn(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
columnName: string,
referencedTableName: string,
async createForeignKeyFromColumn({
queryRunner,
schemaName,
tableName,
columnName,
referencedTableName,
referencedColumnName = 'id',
onDelete?: WorkspaceSchemaForeignKeyDefinition['onDelete'],
): Promise<void> {
onDelete,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
columnName: string;
referencedTableName: string;
referencedColumnName?: string;
onDelete?: WorkspaceSchemaForeignKeyDefinition['onDelete'];
}): Promise<void> {
const foreignKeyName = queryRunner.connection.namingStrategy.foreignKeyName(
tableName,
[columnName],
@@ -195,16 +229,27 @@ export class WorkspaceSchemaForeignKeyManagerService {
onDelete,
};
await this.createForeignKey(queryRunner, schemaName, tableName, foreignKey);
await this.createForeignKey({
queryRunner,
schemaName,
tableName,
foreignKey,
});
}
async renameForeignKey(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
oldConstraintName: string,
newConstraintName: string,
): Promise<void> {
async renameForeignKey({
queryRunner,
schemaName,
tableName,
oldConstraintName,
newConstraintName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
oldConstraintName: string;
newConstraintName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeOldConstraintName = removeSqlDDLInjection(oldConstraintName);
@@ -214,12 +259,17 @@ export class WorkspaceSchemaForeignKeyManagerService {
await queryRunner.query(sql);
}
async validateForeignKey(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
foreignKeyName: string,
): Promise<void> {
async validateForeignKey({
queryRunner,
schemaName,
tableName,
foreignKeyName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
foreignKeyName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
@@ -228,12 +278,17 @@ export class WorkspaceSchemaForeignKeyManagerService {
await queryRunner.query(sql);
}
async setForeignKeyNotDeferrable(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
foreignKeyName: string,
): Promise<void> {
async setForeignKeyNotDeferrable({
queryRunner,
schemaName,
tableName,
foreignKeyName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
foreignKeyName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
@@ -242,12 +297,17 @@ export class WorkspaceSchemaForeignKeyManagerService {
await queryRunner.query(sql);
}
async setForeignKeyDeferrable(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
foreignKeyName: string,
): Promise<void> {
async setForeignKeyDeferrable({
queryRunner,
schemaName,
tableName,
foreignKeyName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
foreignKeyName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
@@ -1,18 +1,20 @@
import { Injectable } from '@nestjs/common';
import { type QueryRunner } from 'typeorm';
import { type 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> {
async createIndex({
queryRunner,
schemaName,
tableName,
index,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
index: WorkspaceSchemaIndexDefinition;
}): Promise<void> {
try {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
@@ -24,7 +26,7 @@ export class WorkspaceSchemaIndexManagerService {
const isUnique = index.isUnique ? 'UNIQUE' : '';
const indexType =
index.type && index.type !== 'BTREE' ? `USING ${index.type}` : '';
const whereClause = index.where ? `WHERE ${index.where}` : '';
const whereClause = index.where ? `WHERE ${index.where}` : ''; // TODO: to sanitize
const includeClause = index.include?.length
? `INCLUDE (${index.include
.map((col) => `"${removeSqlDDLInjection(col)}"`)
@@ -57,11 +59,15 @@ export class WorkspaceSchemaIndexManagerService {
}
}
async dropIndex(
queryRunner: QueryRunner,
schemaName: string,
indexName: string,
): Promise<void> {
async dropIndex({
queryRunner,
schemaName,
indexName,
}: {
queryRunner: QueryRunner;
schemaName: string;
indexName: string;
}): Promise<void> {
try {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeIndexName = removeSqlDDLInjection(indexName);
@@ -77,12 +83,17 @@ export class WorkspaceSchemaIndexManagerService {
}
}
async renameIndex(
queryRunner: QueryRunner,
schemaName: string,
oldIndexName: string,
newIndexName: string,
): Promise<void> {
async renameIndex({
queryRunner,
schemaName,
oldIndexName,
newIndexName,
}: {
queryRunner: QueryRunner;
schemaName: string;
oldIndexName: string;
newIndexName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeOldIndexName = removeSqlDDLInjection(oldIndexName);
const safeNewIndexName = removeSqlDDLInjection(newIndexName);
@@ -91,82 +102,19 @@ export class WorkspaceSchemaIndexManagerService {
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> {
async createUniqueConstraint({
queryRunner,
schemaName,
tableName,
constraintName,
columnNames,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
constraintName: string;
columnNames: string[];
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeConstraintName = removeSqlDDLInjection(constraintName);
@@ -178,12 +126,17 @@ export class WorkspaceSchemaIndexManagerService {
await queryRunner.query(sql);
}
async dropUniqueConstraint(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
constraintName: string,
): Promise<void> {
async dropUniqueConstraint({
queryRunner,
schemaName,
tableName,
constraintName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
constraintName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeConstraintName = removeSqlDDLInjection(constraintName);
@@ -1,76 +1,49 @@
import { Injectable } from '@nestjs/common';
import { type QueryRunner } from 'typeorm';
import { sanitizeDefaultValue } from 'src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util';
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
import { buildSqlColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.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(' ');
}) || [];
async createTable({
queryRunner,
schemaName,
tableName,
columnDefinitions,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
columnDefinitions?: WorkspaceSchemaColumnDefinition[];
}): Promise<void> {
const sqlColumnDefinitions =
columnDefinitions?.map((columnDefinition) =>
buildSqlColumnDefinition(columnDefinition),
) || [];
// Add default columns if no columns specified
if (columnDefinitions.length === 0) {
columnDefinitions.push('"id" uuid PRIMARY KEY DEFAULT gen_random_uuid()');
if (sqlColumnDefinitions.length === 0) {
sqlColumnDefinitions.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(', ')})`;
const sql = `CREATE TABLE IF NOT EXISTS "${safeSchemaName}"."${safeTableName}" (${sqlColumnDefinitions.join(', ')})`;
await queryRunner.query(sql);
}
async dropTable(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
): Promise<void> {
async dropTable({
queryRunner,
schemaName,
tableName,
}: {
queryRunner: QueryRunner;
schemaName: string;
tableName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const sql = `DROP TABLE IF EXISTS "${safeSchemaName}"."${safeTableName}"`;
@@ -78,12 +51,17 @@ export class WorkspaceSchemaTableManagerService {
await queryRunner.query(sql);
}
async renameTable(
queryRunner: QueryRunner,
schemaName: string,
oldTableName: string,
newTableName: string,
): Promise<void> {
async renameTable({
queryRunner,
schemaName,
oldTableName,
newTableName,
}: {
queryRunner: QueryRunner;
schemaName: string;
oldTableName: string;
newTableName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeOldTableName = removeSqlDDLInjection(oldTableName);
const safeNewTableName = removeSqlDDLInjection(newTableName);
@@ -91,23 +69,4 @@ export class WorkspaceSchemaTableManagerService {
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;
}
}
@@ -8,4 +8,5 @@ export type WorkspaceSchemaColumnDefinition = {
isArray?: boolean;
asExpression?: string;
generatedType?: 'STORED' | 'VIRTUAL';
enumValues?: string[];
};
@@ -0,0 +1,41 @@
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
import { sanitizeDefaultValue } from 'src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util';
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util';
export const buildSqlColumnDefinition = (
column: WorkspaceSchemaColumnDefinition,
): string => {
const safeName = removeSqlDDLInjection(column.name);
const parts = [`"${safeName}"`];
if (column.asExpression) {
parts.push(`AS (${column.asExpression})`); // TODO: to sanitize
if (column.generatedType) {
parts.push(column.generatedType);
}
} else {
const safeType = removeSqlDDLInjection(column.type);
parts.push(column.isArray ? `${safeType}[]` : safeType);
if (column.isPrimary) {
parts.push('PRIMARY KEY');
}
if (column.isNullable === false) {
parts.push('NOT NULL');
}
if (column.isUnique) {
parts.push('UNIQUE');
}
if (column.default !== undefined) {
const safeDefault = sanitizeDefaultValue(column.default);
parts.push(`DEFAULT ${safeDefault}`);
}
}
return parts.join(' ');
};
@@ -1,6 +1,12 @@
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration-runner/utils/remove-sql-injection.util';
export const sanitizeDefaultValue = (defaultValue: string): string => {
export const sanitizeDefaultValue = (
defaultValue: string | number | boolean | null,
): string | number | boolean => {
if (defaultValue === null) {
return 'NULL';
}
const allowedFunctions = [
'gen_random_uuid()',
'uuid_generate_v4()',
@@ -12,9 +18,13 @@ export const sanitizeDefaultValue = (defaultValue: string): string => {
'localtimestamp',
];
if (allowedFunctions.includes(defaultValue.toLowerCase())) {
return defaultValue;
if (typeof defaultValue === 'string') {
if (allowedFunctions.includes(defaultValue.toLowerCase())) {
return defaultValue;
}
return removeSqlDDLInjection(defaultValue);
}
return removeSqlDDLInjection(defaultValue);
return defaultValue;
};