feat(db): implement SqlDialect with MySQL, SQL Server, PostgreSQL dialects
Add three SqlDialect implementations with a factory function: - MySqlDialect: positional ?, ON DUPLICATE KEY UPDATE, LIMIT/OFFSET - SqlServerDialect: @pN params, MERGE USING, OFFSET/FETCH - PostgreSqlDialect: $N (1-based), ON CONFLICT DO UPDATE, LIMIT/OFFSET TDD approach: 43 tests written first, all passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
27
src/main/services/database/dialects/index.ts
Normal file
27
src/main/services/database/dialects/index.ts
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/main/services/database/dialects/mysql-dialect.ts
Normal file
73
src/main/services/database/dialects/mysql-dialect.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/main/services/database/dialects/postgresql-dialect.ts
Normal file
81
src/main/services/database/dialects/postgresql-dialect.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/main/services/database/dialects/sqlserver-dialect.ts
Normal file
95
src/main/services/database/dialects/sqlserver-dialect.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
143
tests/unit/dialects/mysql-dialect.test.ts
Normal file
143
tests/unit/dialects/mysql-dialect.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
147
tests/unit/dialects/postgresql-dialect.test.ts
Normal file
147
tests/unit/dialects/postgresql-dialect.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
166
tests/unit/dialects/sqlserver-dialect.test.ts
Normal file
166
tests/unit/dialects/sqlserver-dialect.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user