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