fix(db): auto-quote SQL identifiers for PostgreSQL case-sensitivity

Add prepareSql() to PostgreSqlService that quotes unquoted column names
before execution. PostgreSQL lowercases unquoted identifiers, but
SSMA-migrated tables have uppercase column names requiring double-quoting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-04-05 11:54:30 +08:00
parent b5ba18b595
commit 9791a84047
2 changed files with 313 additions and 5 deletions

View File

@@ -11,6 +11,168 @@ const log = createLogger('PostgreSqlService')
export type { PostgreSqlConfig } from '../../types/database.types'
/**
* SQL keywords that should NOT be double-quoted during identifier preprocessing.
* PostgreSQL lowercases unquoted identifiers, but SSMA-migrated databases
* have uppercase column names that require double-quoting to preserve case.
*/
const SQL_KEYWORDS = new Set([
// DML
'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'NOT', 'IN', 'IS', 'NULL',
'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE',
// Ordering & limiting
'ORDER', 'BY', 'ASC', 'DESC', 'LIMIT', 'OFFSET',
'FETCH', 'NEXT', 'ROWS', 'ONLY',
// Joins
'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'CROSS', 'FULL', 'ON',
// Set operations
'UNION', 'ALL', 'INTERSECT', 'EXCEPT',
// Grouping
'GROUP', 'HAVING', 'DISTINCT',
// DDL
'CREATE', 'ALTER', 'DROP', 'TABLE', 'INDEX', 'COLUMN',
'ADD', 'MODIFY', 'RENAME', 'TO',
// PostgreSQL specific
'CONFLICT', 'DO', 'NOTHING', 'EXCLUDED', 'RETURNING',
'MERGE', 'USING', 'MATCHED', 'WHEN', 'THEN', 'ELSE', 'END',
'TARGET', 'SOURCE',
// Functions
'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'EXISTS',
'CURRENT_TIMESTAMP', 'NOW', 'GETDATE',
'COALESCE', 'NULLIF', 'CAST', 'AS',
// Transaction
'BEGIN', 'COMMIT', 'ROLLBACK', 'SAVEPOINT',
// Types & values
'TRUE', 'FALSE', 'DEFAULT', 'PRIMARY', 'KEY',
'REFERENCES', 'FOREIGN', 'CONSTRAINT', 'UNIQUE', 'CHECK',
'CASE', 'BETWEEN', 'LIKE', 'ILIKE', 'ANY', 'SOME',
// Common
'IF', 'WITH', 'RECURSIVE', 'OVER', 'PARTITION', 'WINDOW',
'ROW', 'FIRST', 'AFTER', 'BEFORE'
])
/**
* Prepare SQL for PostgreSQL execution by quoting unquoted identifiers.
*
* PostgreSQL lowercases unquoted identifiers, but SSMA-migrated tables
* have uppercase column names (e.g., "UserName", "ID") that require
* double-quoting to preserve case.
*
* This function:
* - Preserves string literals ('...')
* - Preserves already-quoted identifiers ("...")
* - Preserves parameter placeholders ($1, $2, ...)
* - Preserves SQL keywords
* - Double-quotes remaining identifiers
*/
export function prepareSql(sql: string): string {
const result: string[] = []
let i = 0
const len = sql.length
while (i < len) {
const ch = sql[i]
// Skip whitespace
if (/\s/.test(ch)) {
result.push(ch)
i++
continue
}
// Skip single-line comments (--)
if (ch === '-' && i + 1 < len && sql[i + 1] === '-') {
while (i < len && sql[i] !== '\n') {
result.push(sql[i++])
}
continue
}
// Preserve string literals ('...')
if (ch === "'") {
result.push(ch)
i++
while (i < len) {
if (sql[i] === "'") {
result.push(sql[i++])
// Handle escaped quotes ('')
if (i < len && sql[i] === "'") {
result.push(sql[i++])
} else {
break
}
} else {
result.push(sql[i++])
}
}
continue
}
// Preserve already-quoted identifiers ("...")
if (ch === '"') {
result.push(ch)
i++
while (i < len && sql[i] !== '"') {
result.push(sql[i++])
}
if (i < len) {
result.push(sql[i++])
}
continue
}
// Preserve parameter placeholders ($N)
if (ch === '$') {
result.push(ch)
i++
while (i < len && /\d/.test(sql[i])) {
result.push(sql[i++])
}
continue
}
// Preserve @param placeholders
if (ch === '@') {
result.push(ch)
i++
while (i < len && /\w/.test(sql[i])) {
result.push(sql[i++])
}
continue
}
// Preserve ? placeholders
if (ch === '?') {
result.push(ch)
i++
continue
}
// Collect word tokens (identifiers and keywords)
if (/[a-zA-Z_]/.test(ch)) {
let word = ''
while (i < len && /\w/.test(sql[i])) {
word += sql[i++]
}
// Check if it's a SQL keyword (case-insensitive)
if (SQL_KEYWORDS.has(word.toUpperCase())) {
result.push(word)
} else {
// Quote the identifier to preserve case
result.push(`"${word}"`)
}
continue
}
// Everything else (operators, punctuation, numbers): pass through
result.push(ch)
i++
}
return result.join('')
}
export class PostgreSqlService implements IDatabaseService {
/** Database type identifier */
readonly type: DatabaseType = 'postgresql'
@@ -95,13 +257,15 @@ export class PostgreSqlService implements IDatabaseService {
throw new Error('Not connected to PostgreSQL. Call connect() first.')
}
const sqlPreview = sql.substring(0, 100)
// Quote unquoted identifiers to preserve case for SSMA-migrated columns
const preparedSql = prepareSql(sql)
const sqlPreview = preparedSql.substring(0, 100)
const paramCount = params?.length ?? 0
try {
const { result: queryResult } = await trackDuration(
async () => {
const result = await this.pool!.query(sql, params)
const result = await this.pool!.query(preparedSql, params)
// Extract column names from fields
const columns = result.fields ? result.fields.map((field) => field.name) : []
@@ -141,10 +305,11 @@ export class PostgreSqlService implements IDatabaseService {
for (let i = 0; i < queries.length; i++) {
const { sql, params } = queries[i]
await client.query(sql, params)
const preparedSql = prepareSql(sql)
await client.query(preparedSql, params)
log.debug('Transaction query executed', {
index: i,
sqlPreview: sql.substring(0, 100)
sqlPreview: preparedSql.substring(0, 100)
})
}