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)
})
}

View File

@@ -4,7 +4,7 @@
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { PostgreSqlService } from '@main/services/database/postgresql'
import { PostgreSqlService, prepareSql } from '@main/services/database/postgresql'
const mockConfig = {
host: 'localhost',
@@ -68,3 +68,146 @@ describe('PostgreSqlService Unit Tests', () => {
})
})
})
describe('prepareSql', () => {
it('should quote unquoted column names in SELECT', () => {
const sql = 'SELECT ID, UserName, UserType FROM "dbo"."BIPUsers"'
const result = prepareSql(sql)
expect(result).toBe('SELECT "ID", "UserName", "UserType" FROM "dbo"."BIPUsers"')
})
it('should quote column names in WHERE clause', () => {
const sql = 'WHERE UserName = $1 AND Password = $2'
const result = prepareSql(sql)
expect(result).toBe('WHERE "UserName" = $1 AND "Password" = $2')
})
it('should quote column names in INSERT', () => {
const sql = 'INSERT INTO "dbo"."BIPUsers" (UserName, Password, UserType) VALUES ($1, $2, $3)'
const result = prepareSql(sql)
expect(result).toBe(
'INSERT INTO "dbo"."BIPUsers" ("UserName", "Password", "UserType") VALUES ($1, $2, $3)'
)
})
it('should quote column names in UPDATE SET', () => {
const sql = 'UPDATE "dbo"."BIPUsers" SET UserType = $1 WHERE UserName = $2'
const result = prepareSql(sql)
expect(result).toBe('UPDATE "dbo"."BIPUsers" SET "UserType" = $1 WHERE "UserName" = $2')
})
it('should quote column names in DELETE', () => {
const sql = 'DELETE FROM "dbo"."BIPUsers" WHERE UserName = $1'
const result = prepareSql(sql)
expect(result).toBe('DELETE FROM "dbo"."BIPUsers" WHERE "UserName" = $1')
})
it('should quote column names in ORDER BY', () => {
const sql = 'SELECT UserName FROM "dbo"."BIPUsers" ORDER BY UserName'
const result = prepareSql(sql)
expect(result).toBe('SELECT "UserName" FROM "dbo"."BIPUsers" ORDER BY "UserName"')
})
it('should not quote SQL keywords', () => {
const sql = 'SELECT ID FROM "dbo"."BIPUsers" WHERE UserName = $1'
const result = prepareSql(sql)
expect(result).not.toContain('"SELECT"')
expect(result).not.toContain('"FROM"')
expect(result).not.toContain('"WHERE"')
expect(result).not.toContain('"AND"')
})
it('should not quote already-quoted identifiers', () => {
const sql = 'SELECT "ID" FROM "dbo"."BIPUsers"'
const result = prepareSql(sql)
expect(result).toBe('SELECT "ID" FROM "dbo"."BIPUsers"')
})
it('should preserve string literals', () => {
const sql = "WHERE Status = 'active'"
const result = prepareSql(sql)
expect(result).toBe('WHERE "Status" = \'active\'')
})
it('should preserve string literals with escaped quotes', () => {
const sql = "WHERE UserName = 'O''Brien'"
const result = prepareSql(sql)
expect(result).toBe('WHERE "UserName" = \'O\'\'Brien\'')
})
it('should preserve $N parameter placeholders', () => {
const sql = 'WHERE UserName = $1 AND Password = $2'
const result = prepareSql(sql)
expect(result).toContain('$1')
expect(result).toContain('$2')
})
it('should handle COUNT(*) correctly', () => {
const sql = 'SELECT COUNT(*) as count FROM "dbo"."BIPUsers" WHERE UserName = $1'
const result = prepareSql(sql)
expect(result).toBe(
'SELECT COUNT(*) as count FROM "dbo"."BIPUsers" WHERE "UserName" = $1'
)
})
it('should quote underscore-containing column names', () => {
const sql = 'SELECT ERP_URL, ERP_Username, ERP_Password FROM "dbo"."BIPUsers"'
const result = prepareSql(sql)
expect(result).toBe(
'SELECT "ERP_URL", "ERP_Username", "ERP_Password" FROM "dbo"."BIPUsers"'
)
})
it('should handle ON CONFLICT DO UPDATE SET with EXCLUDED', () => {
const sql =
'INSERT INTO "dbo"."Materials" (MaterialCode, ManagerName) VALUES ($1, $2) ON CONFLICT ("MaterialCode") DO UPDATE SET "ManagerName" = EXCLUDED."ManagerName"'
const result = prepareSql(sql)
expect(result).toBe(
'INSERT INTO "dbo"."Materials" ("MaterialCode", "ManagerName") VALUES ($1, $2) ON CONFLICT ("MaterialCode") DO UPDATE SET "ManagerName" = EXCLUDED."ManagerName"'
)
})
it('should handle CURRENT_TIMESTAMP without quoting', () => {
const sql = "INSERT INTO t (OperationTime) VALUES (CURRENT_TIMESTAMP)"
const result = prepareSql(sql)
expect(result).toBe('INSERT INTO "t" ("OperationTime") VALUES (CURRENT_TIMESTAMP)')
})
it('should handle LIMIT OFFSET without quoting', () => {
const sql = 'SELECT UserName FROM "dbo"."BIPUsers" LIMIT 10 OFFSET 20'
const result = prepareSql(sql)
expect(result).toBe('SELECT "UserName" FROM "dbo"."BIPUsers" LIMIT 10 OFFSET 20')
})
it('should return empty string for empty input', () => {
expect(prepareSql('')).toBe('')
})
it('should handle full BIPUsersDAO authenticate query', () => {
const sql = `
SELECT ID, UserName, UserType
FROM "dbo"."BIPUsers"
WHERE UserName = $1 AND Password = $2
`
const result = prepareSql(sql)
expect(result).toContain('"ID"')
expect(result).toContain('"UserName"')
expect(result).toContain('"UserType"')
expect(result).toContain('"Password"')
expect(result).toContain('$1')
expect(result).toContain('$2')
expect(result).toContain('"dbo"."BIPUsers"')
})
it('should handle full BIPUsersDAO userExists query', () => {
const sql = `
SELECT COUNT(*) as count
FROM "dbo"."BIPUsers"
WHERE UserName = $1
`
const result = prepareSql(sql)
expect(result).toContain('COUNT(*)')
expect(result).toContain('as count')
expect(result).toContain('"UserName"')
})
})