diff --git a/src/main/services/database/dialects/index.ts b/src/main/services/database/dialects/index.ts new file mode 100644 index 0000000..aee66ac --- /dev/null +++ b/src/main/services/database/dialects/index.ts @@ -0,0 +1,27 @@ +/** + * SQL Dialect Factory + * + * Creates the appropriate SqlDialect implementation based on database type. + */ + +import type { DatabaseType } from '@types/database.types' +import type { SqlDialect } from '@types/sql-dialect.types' + +import { MySqlDialect } from './mysql-dialect' +import { PostgreSqlDialect } from './postgresql-dialect' +import { SqlServerDialect } from './sqlserver-dialect' + +export { MySqlDialect } from './mysql-dialect' +export { PostgreSqlDialect } from './postgresql-dialect' +export { SqlServerDialect } from './sqlserver-dialect' + +export function createDialect(type: DatabaseType): SqlDialect { + switch (type) { + case 'sqlserver': + return new SqlServerDialect() + case 'postgresql': + return new PostgreSqlDialect() + default: + return new MySqlDialect() + } +} diff --git a/src/main/services/database/dialects/mysql-dialect.ts b/src/main/services/database/dialects/mysql-dialect.ts new file mode 100644 index 0000000..64b3953 --- /dev/null +++ b/src/main/services/database/dialects/mysql-dialect.ts @@ -0,0 +1,73 @@ +/** + * MySQL SQL Dialect Implementation + * + * Encapsulates MySQL-specific SQL syntax for: + * - Table name quoting (underscore-separated) + * - Positional parameter placeholders (?) + * - INSERT ... ON DUPLICATE KEY UPDATE upsert + * - LIMIT/OFFSET pagination + */ + +import type { SqlDialect } from '@types/sql-dialect.types' + +export class MySqlDialect implements SqlDialect { + readonly dbType = 'mysql' as const + + quoteTableName(schema: string, table: string): string { + return `${schema}_${table}` + } + + param(_index: number): string { + return '?' + } + + params(count: number): string { + return Array.from({ length: count }, () => '?').join(',') + } + + currentTimestamp(): string { + return 'NOW()' + } + + upsert(params: { + table: string + keyColumns: string[] + allColumns: string[] + startParamIndex: number + }): { sql: string; nextParamIndex: number } { + const { table, keyColumns, allColumns, startParamIndex } = params + + const columns = allColumns.join(', ') + const placeholders = allColumns.map(() => '?').join(', ') + + const nonKeyColumns = allColumns.filter((col) => !keyColumns.includes(col)) + const updateClause = nonKeyColumns + .map((col) => `${col} = VALUES(${col})`) + .join(', ') + + const sql = `INSERT INTO ${table} (${columns}) VALUES (${placeholders}) ON DUPLICATE KEY UPDATE ${updateClause}` + + return { + sql, + nextParamIndex: startParamIndex + allColumns.length + } + } + + paginate(params: { + sql: string + limit: number + offset?: number + paramIndex: number + }): { sql: string; nextParamIndex: number } { + const { sql, limit, offset, paramIndex } = params + + return { + sql: `${sql} LIMIT ${limit} OFFSET ${offset ?? 0}`, + nextParamIndex: paramIndex + } + } + + maxBatchRows(_columnsPerRow: number): number { + return 1000 + } +} diff --git a/src/main/services/database/dialects/postgresql-dialect.ts b/src/main/services/database/dialects/postgresql-dialect.ts new file mode 100644 index 0000000..d3caa55 --- /dev/null +++ b/src/main/services/database/dialects/postgresql-dialect.ts @@ -0,0 +1,81 @@ +/** + * PostgreSQL Dialect Implementation + * + * Encapsulates PostgreSQL-specific SQL syntax for: + * - Table name quoting (double-quoted "schema"."table") + * - Positional parameter placeholders ($1, $2, ...) — 1-based + * - INSERT ... ON CONFLICT ... DO UPDATE SET upsert + * - LIMIT/OFFSET pagination + */ + +import type { SqlDialect } from '@types/sql-dialect.types' + +export class PostgreSqlDialect implements SqlDialect { + readonly dbType = 'postgresql' as const + + quoteTableName(schema: string, table: string): string { + return `"${schema}"."${table}"` + } + + param(index: number): string { + return `$${index + 1}` + } + + params(count: number): string { + return Array.from({ length: count }, (_, i) => `$${i + 1}`).join(',') + } + + currentTimestamp(): string { + return 'CURRENT_TIMESTAMP' + } + + upsert(params: { + table: string + keyColumns: string[] + allColumns: string[] + startParamIndex: number + }): { sql: string; nextParamIndex: number } { + const { table, keyColumns, allColumns, startParamIndex } = params + + const columns = allColumns.join(', ') + const placeholders = allColumns + .map((_, i) => `$${startParamIndex + i + 1}`) + .join(', ') + + const conflictKeys = keyColumns.map((col) => `"${col}"`).join(', ') + + const nonKeyColumns = allColumns.filter((col) => !keyColumns.includes(col)) + const updateSet = nonKeyColumns + .map((col) => `"${col}" = EXCLUDED."${col}"`) + .join(', ') + + const sql = [ + `INSERT INTO ${table} (${columns}) VALUES (${placeholders})`, + `ON CONFLICT (${conflictKeys})`, + `DO UPDATE SET ${updateSet}` + ].join(' ') + + return { + sql, + nextParamIndex: startParamIndex + allColumns.length + } + } + + paginate(params: { + sql: string + limit: number + offset?: number + paramIndex: number + }): { sql: string; nextParamIndex: number } { + const { sql, limit, offset, paramIndex } = params + + return { + sql: `${sql} LIMIT ${limit} OFFSET ${offset ?? 0}`, + nextParamIndex: paramIndex + } + } + + maxBatchRows(_columnsPerRow: number): number { + return 1000 + } +} diff --git a/src/main/services/database/dialects/sqlserver-dialect.ts b/src/main/services/database/dialects/sqlserver-dialect.ts new file mode 100644 index 0000000..070272d --- /dev/null +++ b/src/main/services/database/dialects/sqlserver-dialect.ts @@ -0,0 +1,95 @@ +/** + * SQL Server Dialect Implementation + * + * Encapsulates SQL Server-specific SQL syntax for: + * - Table name quoting (bracket notation [schema].[table]) + * - Named parameter placeholders (@p0, @p1, ...) + * - MERGE ... USING upsert + * - OFFSET/FETCH pagination + */ + +import type { SqlDialect } from '@types/sql-dialect.types' + +export class SqlServerDialect implements SqlDialect { + readonly dbType = 'sqlserver' as const + + quoteTableName(schema: string, table: string): string { + return `[${schema}].[${table}]` + } + + param(index: number): string { + return `@p${index}` + } + + params(count: number): string { + return Array.from({ length: count }, (_, i) => `@p${i}`).join(',') + } + + currentTimestamp(): string { + return 'GETDATE()' + } + + upsert(params: { + table: string + keyColumns: string[] + allColumns: string[] + startParamIndex: number + }): { sql: string; nextParamIndex: number } { + const { table, keyColumns, allColumns, startParamIndex } = params + + const valueParams = allColumns + .map((_, i) => `@p${startParamIndex + i}`) + .join(', ') + const sourceColumns = allColumns.join(', ') + + const joinCondition = keyColumns + .map((col) => `target.${col} = source.${col}`) + .join(' AND ') + + const nonKeyColumns = allColumns.filter((col) => !keyColumns.includes(col)) + const updateSet = nonKeyColumns + .map((col) => `target.${col} = source.${col}`) + .join(', ') + + const insertColumns = allColumns.join(', ') + const insertValues = allColumns.map((col) => `source.${col}`).join(', ') + + const sql = [ + `MERGE ${table} AS target`, + `USING (VALUES (${valueParams})) AS source (${sourceColumns})`, + `ON ${joinCondition}`, + `WHEN MATCHED THEN UPDATE SET ${updateSet}`, + `WHEN NOT MATCHED THEN INSERT (${insertColumns}) VALUES (${insertValues});` + ].join(' ') + + return { + sql, + nextParamIndex: startParamIndex + allColumns.length + } + } + + paginate(params: { + sql: string + limit: number + offset?: number + paramIndex: number + }): { sql: string; nextParamIndex: number } { + const { sql, limit, offset, paramIndex } = params + + if (offset !== undefined) { + return { + sql: `${sql} OFFSET @p${paramIndex} ROWS FETCH NEXT @p${paramIndex + 1} ROWS ONLY`, + nextParamIndex: paramIndex + 2 + } + } + + return { + sql: `${sql} OFFSET 0 ROWS FETCH NEXT @p${paramIndex} ROWS ONLY`, + nextParamIndex: paramIndex + 1 + } + } + + maxBatchRows(columnsPerRow: number): number { + return Math.floor(2000 / columnsPerRow) + } +} diff --git a/tests/unit/dialects/mysql-dialect.test.ts b/tests/unit/dialects/mysql-dialect.test.ts new file mode 100644 index 0000000..7023873 --- /dev/null +++ b/tests/unit/dialects/mysql-dialect.test.ts @@ -0,0 +1,143 @@ +/** + * Unit tests for MySqlDialect + */ + +import { describe, it, expect } from 'vitest' +import { MySqlDialect } from '@services/database/dialects/mysql-dialect' + +describe('MySqlDialect', () => { + const dialect = new MySqlDialect() + + describe('dbType', () => { + it('should return mysql', () => { + expect(dialect.dbType).toBe('mysql') + }) + }) + + describe('quoteTableName', () => { + it('should join schema and table with underscore', () => { + expect(dialect.quoteTableName('dbo', 'Table')).toBe('dbo_Table') + }) + + it('should handle arbitrary schema and table names', () => { + expect(dialect.quoteTableName('my_schema', 'my_table')).toBe( + 'my_schema_my_table' + ) + }) + }) + + describe('param', () => { + it('should return ? for any index', () => { + expect(dialect.param(0)).toBe('?') + expect(dialect.param(1)).toBe('?') + expect(dialect.param(5)).toBe('?') + expect(dialect.param(100)).toBe('?') + }) + }) + + describe('params', () => { + it('should return comma-separated question marks', () => { + expect(dialect.params(1)).toBe('?') + expect(dialect.params(3)).toBe('?,?,?') + expect(dialect.params(5)).toBe('?,?,?,?,?') + }) + + it('should return empty string for count 0', () => { + expect(dialect.params(0)).toBe('') + }) + }) + + describe('currentTimestamp', () => { + it('should return NOW()', () => { + expect(dialect.currentTimestamp()).toBe('NOW()') + }) + }) + + describe('upsert', () => { + it('should generate ON DUPLICATE KEY UPDATE SQL', () => { + const result = dialect.upsert({ + table: 'dbo_Table', + keyColumns: ['id'], + allColumns: ['id', 'name', 'value'], + startParamIndex: 0 + }) + + expect(result.sql).toBe( + 'INSERT INTO dbo_Table (id, name, value) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), value = VALUES(value)' + ) + expect(result.nextParamIndex).toBe(3) + }) + + it('should handle composite key columns', () => { + const result = dialect.upsert({ + table: 'dbo_Table', + keyColumns: ['id', 'code'], + allColumns: ['id', 'code', 'name', 'value'], + startParamIndex: 0 + }) + + expect(result.sql).toContain('ON DUPLICATE KEY UPDATE') + expect(result.sql).toContain('name = VALUES(name)') + expect(result.sql).toContain('value = VALUES(value)') + // key columns should NOT appear in the UPDATE SET clause + expect(result.sql).not.toContain('id = VALUES(id)') + expect(result.sql).not.toContain('code = VALUES(code)') + expect(result.nextParamIndex).toBe(4) + }) + + it('should advance nextParamIndex from non-zero start', () => { + const result = dialect.upsert({ + table: 'dbo_Table', + keyColumns: ['id'], + allColumns: ['id', 'name'], + startParamIndex: 5 + }) + + expect(result.nextParamIndex).toBe(7) + }) + }) + + describe('paginate', () => { + it('should append LIMIT and OFFSET with literal values', () => { + const result = dialect.paginate({ + sql: 'SELECT * FROM dbo_Table', + limit: 10, + offset: 20, + paramIndex: 0 + }) + + expect(result.sql).toBe('SELECT * FROM dbo_Table LIMIT 10 OFFSET 20') + expect(result.nextParamIndex).toBe(0) + }) + + it('should use 0 as default offset', () => { + const result = dialect.paginate({ + sql: 'SELECT * FROM dbo_Table', + limit: 50, + paramIndex: 3 + }) + + expect(result.sql).toBe('SELECT * FROM dbo_Table LIMIT 50 OFFSET 0') + expect(result.nextParamIndex).toBe(3) + }) + + it('should not change nextParamIndex (no params added)', () => { + const result = dialect.paginate({ + sql: 'SELECT * FROM dbo_Table', + limit: 100, + offset: 50, + paramIndex: 10 + }) + + expect(result.nextParamIndex).toBe(10) + }) + }) + + describe('maxBatchRows', () => { + it('should return 1000 regardless of columns', () => { + expect(dialect.maxBatchRows(1)).toBe(1000) + expect(dialect.maxBatchRows(10)).toBe(1000) + expect(dialect.maxBatchRows(100)).toBe(1000) + }) + }) +}) diff --git a/tests/unit/dialects/postgresql-dialect.test.ts b/tests/unit/dialects/postgresql-dialect.test.ts new file mode 100644 index 0000000..648de2b --- /dev/null +++ b/tests/unit/dialects/postgresql-dialect.test.ts @@ -0,0 +1,147 @@ +/** + * Unit tests for PostgreSqlDialect + */ + +import { describe, it, expect } from 'vitest' +import { PostgreSqlDialect } from '@services/database/dialects/postgresql-dialect' + +describe('PostgreSqlDialect', () => { + const dialect = new PostgreSqlDialect() + + describe('dbType', () => { + it('should return postgresql', () => { + expect(dialect.dbType).toBe('postgresql') + }) + }) + + describe('quoteTableName', () => { + it('should wrap schema and table in double quotes', () => { + expect(dialect.quoteTableName('dbo', 'Table')).toBe('"dbo"."Table"') + }) + + it('should handle arbitrary names', () => { + expect(dialect.quoteTableName('my_schema', 'my_table')).toBe( + '"my_schema"."my_table"' + ) + }) + }) + + describe('param', () => { + it('should return $N with 1-based index ($1 for param(0))', () => { + expect(dialect.param(0)).toBe('$1') + expect(dialect.param(1)).toBe('$2') + expect(dialect.param(3)).toBe('$4') + expect(dialect.param(10)).toBe('$11') + }) + }) + + describe('params', () => { + it('should return comma-separated $N placeholders (1-based)', () => { + expect(dialect.params(1)).toBe('$1') + expect(dialect.params(3)).toBe('$1,$2,$3') + expect(dialect.params(5)).toBe('$1,$2,$3,$4,$5') + }) + + it('should return empty string for count 0', () => { + expect(dialect.params(0)).toBe('') + }) + }) + + describe('currentTimestamp', () => { + it('should return CURRENT_TIMESTAMP', () => { + expect(dialect.currentTimestamp()).toBe('CURRENT_TIMESTAMP') + }) + }) + + describe('upsert', () => { + it('should generate ON CONFLICT DO UPDATE SQL', () => { + const result = dialect.upsert({ + table: '"dbo"."Table"', + keyColumns: ['id'], + allColumns: ['id', 'name', 'value'], + startParamIndex: 0 + }) + + expect(result.sql).toContain('INSERT INTO "dbo"."Table" (id, name, value)') + expect(result.sql).toContain('VALUES ($1, $2, $3)') + expect(result.sql).toContain('ON CONFLICT ("id")') + expect(result.sql).toContain('DO UPDATE SET') + expect(result.sql).toContain('"name" = EXCLUDED."name"') + expect(result.sql).toContain('"value" = EXCLUDED."value"') + // key column should NOT appear in DO UPDATE SET + expect(result.sql).not.toContain('"id" = EXCLUDED."id"') + expect(result.nextParamIndex).toBe(3) + }) + + it('should handle composite key columns with all double-quoted', () => { + const result = dialect.upsert({ + table: '"dbo"."Table"', + keyColumns: ['id', 'code'], + allColumns: ['id', 'code', 'name'], + startParamIndex: 0 + }) + + expect(result.sql).toContain('ON CONFLICT ("id", "code")') + expect(result.sql).toContain('"name" = EXCLUDED."name"') + expect(result.nextParamIndex).toBe(3) + }) + + it('should use startParamIndex for parameter numbering', () => { + const result = dialect.upsert({ + table: '"dbo"."Table"', + keyColumns: ['id'], + allColumns: ['id', 'name'], + startParamIndex: 5 + }) + + // startParamIndex=5 means $6, $7 (1-based: index+1) + expect(result.sql).toContain('$6') + expect(result.sql).toContain('$7') + expect(result.nextParamIndex).toBe(7) + }) + }) + + describe('paginate', () => { + it('should append LIMIT and OFFSET with literal values', () => { + const result = dialect.paginate({ + sql: 'SELECT * FROM "dbo"."Table"', + limit: 10, + offset: 20, + paramIndex: 0 + }) + + expect(result.sql).toBe('SELECT * FROM "dbo"."Table" LIMIT 10 OFFSET 20') + expect(result.nextParamIndex).toBe(0) + }) + + it('should use 0 as default offset', () => { + const result = dialect.paginate({ + sql: 'SELECT * FROM "dbo"."Table"', + limit: 50, + paramIndex: 3 + }) + + expect(result.sql).toBe('SELECT * FROM "dbo"."Table" LIMIT 50 OFFSET 0') + expect(result.nextParamIndex).toBe(3) + }) + + it('should not change nextParamIndex (no params added)', () => { + const result = dialect.paginate({ + sql: 'SELECT * FROM "dbo"."Table"', + limit: 100, + offset: 50, + paramIndex: 10 + }) + + expect(result.nextParamIndex).toBe(10) + }) + }) + + describe('maxBatchRows', () => { + it('should return 1000 regardless of columns', () => { + expect(dialect.maxBatchRows(1)).toBe(1000) + expect(dialect.maxBatchRows(10)).toBe(1000) + expect(dialect.maxBatchRows(100)).toBe(1000) + }) + }) +}) diff --git a/tests/unit/dialects/sqlserver-dialect.test.ts b/tests/unit/dialects/sqlserver-dialect.test.ts new file mode 100644 index 0000000..a88e511 --- /dev/null +++ b/tests/unit/dialects/sqlserver-dialect.test.ts @@ -0,0 +1,166 @@ +/** + * Unit tests for SqlServerDialect + */ + +import { describe, it, expect } from 'vitest' +import { SqlServerDialect } from '@services/database/dialects/sqlserver-dialect' + +describe('SqlServerDialect', () => { + const dialect = new SqlServerDialect() + + describe('dbType', () => { + it('should return sqlserver', () => { + expect(dialect.dbType).toBe('sqlserver') + }) + }) + + describe('quoteTableName', () => { + it('should wrap schema and table in brackets', () => { + expect(dialect.quoteTableName('dbo', 'Table')).toBe('[dbo].[Table]') + }) + + it('should handle arbitrary names', () => { + expect(dialect.quoteTableName('my_schema', 'my_table')).toBe( + '[my_schema].[my_table]' + ) + }) + }) + + describe('param', () => { + it('should return @pN with 0-based index', () => { + expect(dialect.param(0)).toBe('@p0') + expect(dialect.param(1)).toBe('@p1') + expect(dialect.param(5)).toBe('@p5') + }) + }) + + describe('params', () => { + it('should return comma-separated @pN placeholders', () => { + expect(dialect.params(1)).toBe('@p0') + expect(dialect.params(3)).toBe('@p0,@p1,@p2') + expect(dialect.params(5)).toBe('@p0,@p1,@p2,@p3,@p4') + }) + + it('should return empty string for count 0', () => { + expect(dialect.params(0)).toBe('') + }) + }) + + describe('currentTimestamp', () => { + it('should return GETDATE()', () => { + expect(dialect.currentTimestamp()).toBe('GETDATE()') + }) + }) + + describe('upsert', () => { + it('should generate MERGE SQL with single key column', () => { + const result = dialect.upsert({ + table: '[dbo].[Table]', + keyColumns: ['id'], + allColumns: ['id', 'name', 'value'], + startParamIndex: 0 + }) + + // Should contain MERGE ... USING ... ON ... WHEN MATCHED ... WHEN NOT MATCHED ... + expect(result.sql).toContain('MERGE [dbo].[Table] AS target') + expect(result.sql).toContain('USING (VALUES (@p0, @p1, @p2)) AS source (id, name, value)') + expect(result.sql).toContain('ON target.id = source.id') + expect(result.sql).toContain('WHEN MATCHED THEN UPDATE SET') + expect(result.sql).toContain('target.name = source.name') + expect(result.sql).toContain('target.value = source.value') + expect(result.sql).toContain('WHEN NOT MATCHED THEN INSERT (id, name, value)') + expect(result.sql).toContain('VALUES (source.id, source.name, source.value)') + expect(result.nextParamIndex).toBe(3) + }) + + it('should handle composite key columns', () => { + const result = dialect.upsert({ + table: '[dbo].[Table]', + keyColumns: ['id', 'code'], + allColumns: ['id', 'code', 'name'], + startParamIndex: 0 + }) + + expect(result.sql).toContain('ON target.id = source.id AND target.code = source.code') + // non-key columns in UPDATE SET + expect(result.sql).toContain('target.name = source.name') + // key columns should NOT be in UPDATE SET + expect(result.sql).not.toMatch(/UPDATE SET[\s\S]*target\.id = source\.id/) + expect(result.nextParamIndex).toBe(3) + }) + + it('should use startParamIndex for parameter names', () => { + const result = dialect.upsert({ + table: '[dbo].[Table]', + keyColumns: ['id'], + allColumns: ['id', 'name'], + startParamIndex: 5 + }) + + expect(result.sql).toContain('@p5') + expect(result.sql).toContain('@p6') + expect(result.nextParamIndex).toBe(7) + }) + }) + + describe('paginate', () => { + it('should append OFFSET/FETCH with parameterized offset when offset is provided', () => { + const result = dialect.paginate({ + sql: 'SELECT * FROM [dbo].[Table] ORDER BY id', + limit: 10, + offset: 20, + paramIndex: 0 + }) + + expect(result.sql).toContain('OFFSET @p0 ROWS') + expect(result.sql).toContain('FETCH NEXT @p1 ROWS ONLY') + expect(result.nextParamIndex).toBe(2) + }) + + it('should use literal 0 offset when no offset is provided', () => { + const result = dialect.paginate({ + sql: 'SELECT * FROM [dbo].[Table] ORDER BY id', + limit: 50, + paramIndex: 0 + }) + + expect(result.sql).toContain('OFFSET 0 ROWS') + expect(result.sql).toContain('FETCH NEXT @p0 ROWS ONLY') + expect(result.nextParamIndex).toBe(1) + }) + + it('should advance paramIndex from non-zero start with offset', () => { + const result = dialect.paginate({ + sql: 'SELECT * FROM [dbo].[Table] ORDER BY id', + limit: 10, + offset: 100, + paramIndex: 5 + }) + + expect(result.sql).toContain('OFFSET @p5 ROWS') + expect(result.sql).toContain('FETCH NEXT @p6 ROWS ONLY') + expect(result.nextParamIndex).toBe(7) + }) + + it('should advance paramIndex from non-zero start without offset', () => { + const result = dialect.paginate({ + sql: 'SELECT * FROM [dbo].[Table] ORDER BY id', + limit: 10, + paramIndex: 3 + }) + + expect(result.sql).toContain('OFFSET 0 ROWS') + expect(result.sql).toContain('FETCH NEXT @p3 ROWS ONLY') + expect(result.nextParamIndex).toBe(4) + }) + }) + + describe('maxBatchRows', () => { + it('should return floor(2000 / columnsPerRow)', () => { + expect(dialect.maxBatchRows(10)).toBe(200) + expect(dialect.maxBatchRows(28)).toBe(71) + expect(dialect.maxBatchRows(1)).toBe(2000) + expect(dialect.maxBatchRows(100)).toBe(20) + }) + }) +})