diff --git a/src/main/services/database/postgresql.ts b/src/main/services/database/postgresql.ts index 569cf01..b0eba23 100644 --- a/src/main/services/database/postgresql.ts +++ b/src/main/services/database/postgresql.ts @@ -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) }) } diff --git a/tests/unit/postgresql.test.ts b/tests/unit/postgresql.test.ts index 7dcaa00..8b6d9c5 100644 --- a/tests/unit/postgresql.test.ts +++ b/tests/unit/postgresql.test.ts @@ -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"') + }) +})