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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user