Workspace schema migration runner v2 (#13899)
This commit is contained in:
+15
-15
@@ -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();
|
||||
|
||||
+12
@@ -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);
|
||||
-463
@@ -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"');
|
||||
});
|
||||
});
|
||||
});
|
||||
-610
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
-624
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
-721
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
-276
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+55
-145
@@ -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(' ');
|
||||
}
|
||||
}
|
||||
|
||||
+211
-195
@@ -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(' ');
|
||||
|
||||
|
||||
+136
-76
@@ -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);
|
||||
|
||||
+56
-103
@@ -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);
|
||||
|
||||
+42
-83
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -8,4 +8,5 @@ export type WorkspaceSchemaColumnDefinition = {
|
||||
isArray?: boolean;
|
||||
asExpression?: string;
|
||||
generatedType?: 'STORED' | 'VIRTUAL';
|
||||
enumValues?: string[];
|
||||
};
|
||||
|
||||
+41
@@ -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(' ');
|
||||
};
|
||||
+14
-4
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user