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