diff --git a/src/main/services/database/postgresql.ts b/src/main/services/database/postgresql.ts index 57bf6e9..cf5aa4a 100644 --- a/src/main/services/database/postgresql.ts +++ b/src/main/services/database/postgresql.ts @@ -15,9 +15,20 @@ 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. + * + * This list covers PostgreSQL reserved words across multiple categories: + * - DML (Data Manipulation Language) + * - DDL (Data Definition Language) + * - Window functions + * - CTEs (Common Table Expressions) + * - Advanced GROUP BY clauses + * - JSON operations + * - Type system + * - Table sampling + * - Transaction control */ const SQL_KEYWORDS = new Set([ - // DML + // ==================== DML (Data Manipulation Language) ==================== 'SELECT', 'FROM', 'WHERE', @@ -33,7 +44,8 @@ const SQL_KEYWORDS = new Set([ 'UPDATE', 'SET', 'DELETE', - // Ordering & limiting + + // ==================== Ordering & Limiting ==================== 'ORDER', 'BY', 'ASC', @@ -44,7 +56,8 @@ const SQL_KEYWORDS = new Set([ 'NEXT', 'ROWS', 'ONLY', - // Joins + + // ==================== Joins ==================== 'JOIN', 'LEFT', 'RIGHT', @@ -53,16 +66,67 @@ const SQL_KEYWORDS = new Set([ 'CROSS', 'FULL', 'ON', - // Set operations + 'NATURAL', + 'LATERAL', + + // ==================== Set Operations ==================== 'UNION', 'ALL', 'INTERSECT', 'EXCEPT', - // Grouping + + // ==================== Grouping & Aggregation ==================== 'GROUP', 'HAVING', 'DISTINCT', - // DDL + 'GROUPING', + 'SETS', + 'ROLLUP', + 'CUBE', + 'FILTER', + 'WITHIN', + + // ==================== Window Functions ==================== + 'OVER', + 'PARTITION', + 'WINDOW', + 'RANGE', + 'UNBOUNDED', + 'PRECEDING', + 'FOLLOWING', + 'CURRENT', + 'ROW', + 'GROUPS', + 'EXCLUDE', + 'TIES', + 'RANK', + 'DENSE_RANK', + 'ROW_NUMBER', + 'NTILE', + 'LAG', + 'LEAD', + 'FIRST_VALUE', + 'LAST_VALUE', + 'NTH_VALUE', + + // ==================== CTE (Common Table Expressions) ==================== + 'WITH', + 'RECURSIVE', + 'MATERIALIZED', + 'SEARCH', + 'CYCLE', + 'PATH', + 'ROOT', + 'SIBLINGS', + + // ==================== CASE Expressions ==================== + 'CASE', + 'WHEN', + 'THEN', + 'ELSE', + 'END', + + // ==================== DDL (Data Definition Language) ==================== 'CREATE', 'ALTER', 'DROP', @@ -73,7 +137,15 @@ const SQL_KEYWORDS = new Set([ 'MODIFY', 'RENAME', 'TO', - // PostgreSQL specific + 'GENERATED', + 'ALWAYS', + 'IDENTITY', + 'INCLUDE', + 'TEMP', + 'TEMPORARY', + 'UNLOGGED', + + // ==================== PostgreSQL Specific - UPSERT/MERGE ==================== 'CONFLICT', 'DO', 'NOTHING', @@ -82,32 +154,76 @@ const SQL_KEYWORDS = new Set([ 'MERGE', 'USING', 'MATCHED', - 'WHEN', - 'THEN', - 'ELSE', - 'END', 'TARGET', 'SOURCE', - // Functions + + // ==================== Aggregate Functions ==================== 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'EXISTS', - 'CURRENT_TIMESTAMP', - 'NOW', - 'GETDATE', 'COALESCE', 'NULLIF', 'CAST', 'AS', - // Transaction + + // ==================== JSON Operations ==================== + 'JSON', + 'JSONB', + 'JSON_ARRAY', + 'JSON_OBJECT', + 'JSON_AGG', + 'JSONB_AGG', + 'JSONB_OBJECT_AGG', + + // ==================== Types & Casting ==================== + 'DECIMAL', + 'NUMERIC', + 'BOOLEAN', + 'CHARACTER', + 'VARYING', + 'PRECISION', + 'REAL', + 'DOUBLE', + 'FLOAT', + 'TEXT', + 'INTEGER', + 'SERIAL', + 'BIGINT', + 'SMALLINT', + 'DATE', + 'TIME', + 'TIMESTAMP', + 'TIMESTAMPTZ', + 'TIMEZONE', + 'INTERVAL', + 'BIGSERIAL', + 'SMALLSERIAL', + + // ==================== Table Sampling ==================== + 'TABLESAMPLE', + 'BERNOULLI', + 'SYSTEM', + 'REPEATABLE', + 'SEED', + + // ==================== Transaction Control ==================== 'BEGIN', 'COMMIT', 'ROLLBACK', 'SAVEPOINT', - // Types & values + 'WORK', + 'ISOLATION', + 'LEVEL', + 'READ', + 'WRITE', + 'COMMITTED', + 'REPEATABLE', + 'SERIALIZABLE', + + // ==================== Types & Values ==================== 'TRUE', 'FALSE', 'DEFAULT', @@ -118,23 +234,65 @@ const SQL_KEYWORDS = new Set([ 'CONSTRAINT', 'UNIQUE', 'CHECK', - 'CASE', + 'NULLS', + 'FIRST', + 'LAST', + + // ==================== Pattern Matching ==================== 'BETWEEN', 'LIKE', 'ILIKE', + 'SIMILAR', + 'ESCAPE', 'ANY', 'SOME', - // Common - 'IF', - 'WITH', - 'RECURSIVE', - 'OVER', - 'PARTITION', - 'WINDOW', - 'ROW', - 'FIRST', + + // ==================== Functions & Procedures ==================== 'AFTER', - 'BEFORE' + 'BEFORE', + 'EACH', + 'STATEMENT', + 'TRIGGER', + 'FUNCTION', + 'PROCEDURE', + 'LANGUAGE', + 'SQL', + 'PLPGSQL', + 'RETURNS', + 'CALLED', + 'STRICT', + 'SECURITY', + 'INVOKER', + 'DEFINER', + 'VOLATILE', + 'STABLE', + 'IMMUTABLE', + 'PARALLEL', + 'SAFE', + 'RESTRICTED', + 'UNSAFE', + + // ==================== Utility Commands ==================== + 'CONCURRENTLY', + 'REINDEX', + 'VACUUM', + 'ANALYZE', + 'EXPLAIN', + 'LOCAL', + 'GLOBAL', + 'ORDINALITY', + 'FREEZE', + 'VERBOSE', + 'BUFFERS', + 'FORMAT', + 'XML', + 'YAML', + + // ==================== Additional Reserved Words ==================== + 'IF', + 'CURRENT_TIMESTAMP', + 'NOW', + 'GETDATE' ]) /** @@ -286,7 +444,31 @@ export class PostgreSqlService implements IDatabaseService { user: this.config.user, password: this.config.password, database: this.config.database, - max: this.config.maxPoolSize ?? 10 + max: this.config.maxPoolSize ?? 10, + /** + * Connection timeout in milliseconds. + * Time to wait when connecting to PostgreSQL before failing. + * Prevents hanging during network issues or server overload. + */ + connectionTimeoutMillis: 10000, + /** + * PostgreSQL statement timeout in milliseconds. + * Limits execution time for individual SQL statements. + * Prevents long-running queries from blocking the connection pool. + */ + statement_timeout: 30000, + /** + * Idle connection timeout in milliseconds. + * Closes connections that have been idle for this duration. + * Frees up pool resources and prevents stale connections. + */ + idleTimeoutMillis: 30000, + /** + * Query timeout in milliseconds (pg driver level). + * Fallback protection to abort queries that exceed this duration. + * Should be longer than statement_timeout to allow PG to handle first. + */ + query_timeout: 60000 }) // Test connection diff --git a/tests/unit/postgresql.test.ts b/tests/unit/postgresql.test.ts index fe18f7b..647a9e7 100644 --- a/tests/unit/postgresql.test.ts +++ b/tests/unit/postgresql.test.ts @@ -206,4 +206,225 @@ describe('prepareSql', () => { expect(result).toContain('as count') expect(result).toContain('"UserName"') }) + + // ==================== Window Functions ==================== + + it('should handle ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)', () => { + const sql = ` + SELECT UserName, ROW_NUMBER() OVER (PARTITION BY UserType ORDER BY CreatedAt DESC) as rn + FROM "dbo"."BIPUsers" + ` + const result = prepareSql(sql) + expect(result).toContain('"UserName"') + expect(result).toContain('"UserType"') + expect(result).toContain('"CreatedAt"') + expect(result).not.toContain('"ROW_NUMBER"') + expect(result).not.toContain('"OVER"') + expect(result).not.toContain('"PARTITION"') + expect(result).not.toContain('"ORDER"') + }) + + it('should handle RANK() and DENSE_RANK()', () => { + const sql = ` + SELECT MaterialCode, RANK() OVER (ORDER BY Quantity DESC) as rnk, DENSE_RANK() OVER (ORDER BY Quantity DESC) as drnk + FROM "dbo"."Materials" + ` + const result = prepareSql(sql) + expect(result).toContain('"MaterialCode"') + expect(result).toContain('"Quantity"') + expect(result).not.toContain('"RANK"') + expect(result).not.toContain('"DENSE_RANK"') + }) + + it('should handle LAG() and LEAD()', () => { + const sql = ` + SELECT OrderId, LAG(TotalAmount, 1) OVER (ORDER BY OrderDate) as prevAmount, LEAD(TotalAmount, 1) OVER (ORDER BY OrderDate) as nextAmount + FROM "dbo"."Orders" + ` + const result = prepareSql(sql) + expect(result).toContain('"OrderId"') + expect(result).toContain('"TotalAmount"') + expect(result).toContain('"OrderDate"') + expect(result).not.toContain('"LAG"') + expect(result).not.toContain('"LEAD"') + }) + + // ==================== CTEs (Common Table Expressions) ==================== + + it('should handle WITH clause', () => { + const sql = ` + WITH UserSummary AS ( + SELECT UserId, COUNT(OrderId) as OrderCount + FROM "dbo"."Orders" + GROUP BY UserId + ) + SELECT UserName, OrderCount + FROM UserSummary + JOIN "dbo"."BIPUsers" ON UserSummary.UserId = "dbo"."BIPUsers".ID + ` + const result = prepareSql(sql) + expect(result).toContain('"UserId"') + expect(result).toContain('"OrderId"') + expect(result).toContain('"UserName"') + expect(result).not.toContain('"WITH"') + expect(result).not.toContain('"AS"') + expect(result).not.toContain('"FROM"') + expect(result).not.toContain('"JOIN"') + expect(result).not.toContain('"ON"') + }) + + it('should handle recursive CTE', () => { + const sql = ` + WITH RECURSIVE CategoryTree AS ( + SELECT CategoryId, ParentCategoryId, CategoryName, 0 as Level + FROM "dbo"."Categories" + WHERE ParentCategoryId IS NULL + UNION ALL + SELECT c.CategoryId, c.ParentCategoryId, c.CategoryName, ct.Level + 1 + FROM "dbo"."Categories" c + INNER JOIN CategoryTree ct ON c.ParentCategoryId = ct.CategoryId + ) + SELECT * FROM CategoryTree + ` + const result = prepareSql(sql) + expect(result).toContain('"CategoryId"') + expect(result).toContain('"ParentCategoryId"') + expect(result).toContain('"CategoryName"') + expect(result).not.toContain('"WITH"') + expect(result).not.toContain('"RECURSIVE"') + expect(result).not.toContain('"UNION"') + expect(result).not.toContain('"ALL"') + expect(result).not.toContain('"INNER"') + expect(result).not.toContain('"JOIN"') + }) + + // ==================== Advanced Grouping ==================== + + it('should handle ROLLUP', () => { + const sql = ` + SELECT DepartmentId, JobTitle, COUNT(*) as EmployeeCount + FROM "dbo"."Employees" + GROUP BY ROLLUP (DepartmentId, JobTitle) + ` + const result = prepareSql(sql) + expect(result).toContain('"DepartmentId"') + expect(result).toContain('"JobTitle"') + expect(result).not.toContain('"GROUP"') + expect(result).not.toContain('"BY"') + expect(result).not.toContain('"ROLLUP"') + }) + + it('should handle CUBE', () => { + const sql = ` + SELECT Year, Quarter, Region, SUM(SalesAmount) as TotalSales + FROM "dbo"."Sales" + GROUP BY CUBE (Year, Quarter, Region) + ` + const result = prepareSql(sql) + expect(result).toContain('"Year"') + expect(result).toContain('"Quarter"') + expect(result).toContain('"Region"') + expect(result).toContain('"SalesAmount"') + expect(result).not.toContain('"CUBE"') + expect(result).not.toContain('"GROUP"') + expect(result).not.toContain('"BY"') + }) + + it('should handle GROUPING SETS', () => { + const sql = ` + SELECT DepartmentId, JobTitle, COUNT(*) as EmployeeCount + FROM "dbo"."Employees" + GROUP BY GROUPING SETS ((DepartmentId, JobTitle), (DepartmentId), ()) + ` + const result = prepareSql(sql) + expect(result).toContain('"DepartmentId"') + expect(result).toContain('"JobTitle"') + expect(result).not.toContain('"GROUPING"') + expect(result).not.toContain('"SETS"') + expect(result).not.toContain('"GROUP"') + expect(result).not.toContain('"BY"') + }) + + // ==================== CASE Expressions ==================== + + it('should handle simple CASE', () => { + const sql = ` + SELECT UserName, CASE UserType + WHEN 'admin' THEN 'Administrator' + WHEN 'user' THEN 'Regular User' + ELSE 'Guest' + END as UserRole + FROM "dbo"."BIPUsers" + ` + const result = prepareSql(sql) + expect(result).toContain('"UserName"') + expect(result).toContain('"UserType"') + expect(result).not.toContain('"CASE"') + expect(result).not.toContain('"WHEN"') + expect(result).not.toContain('"THEN"') + expect(result).not.toContain('"ELSE"') + expect(result).not.toContain('"END"') + }) + + it('should handle searched CASE', () => { + const sql = ` + SELECT OrderId, TotalAmount, + CASE + WHEN TotalAmount > 10000 THEN 'Large' + WHEN TotalAmount > 1000 THEN 'Medium' + ELSE 'Small' + END as OrderSize + FROM "dbo"."Orders" + ` + const result = prepareSql(sql) + expect(result).toContain('"OrderId"') + expect(result).toContain('"TotalAmount"') + expect(result).not.toContain('"CASE"') + expect(result).not.toContain('"WHEN"') + expect(result).not.toContain('"THEN"') + expect(result).not.toContain('"ELSE"') + expect(result).not.toContain('"END"') + }) + + // ==================== Set Operations ==================== + + it('should handle UNION, UNION ALL, INTERSECT, EXCEPT', () => { + const sql = ` + SELECT UserId FROM "dbo"."ActiveUsers" + UNION + SELECT UserId FROM "dbo"."PremiumUsers" + UNION ALL + SELECT UserId FROM "dbo"."TrialUsers" + INTERSECT + SELECT UserId FROM "dbo"."VerifiedUsers" + EXCEPT + SELECT UserId FROM "dbo"."BannedUsers" + ` + const result = prepareSql(sql) + expect(result).toContain('"UserId"') + expect(result).not.toContain('"UNION"') + expect(result).not.toContain('"ALL"') + expect(result).not.toContain('"INTERSECT"') + expect(result).not.toContain('"EXCEPT"') + expect(result).not.toContain('"SELECT"') + expect(result).not.toContain('"FROM"') + }) + + // ==================== JSON Operators ==================== + + it('should handle -> and ->> operators', () => { + const sql = ` + SELECT UserId, ProfileData->'address'->>'city' as City, ProfileData->'contact'->>'phone' as Phone + FROM "dbo"."Users" + WHERE ProfileData->'preferences'->>'newsletter' = 'true' + ` + const result = prepareSql(sql) + expect(result).toContain('"UserId"') + expect(result).toContain('"ProfileData"') + expect(result).toContain('->') + expect(result).toContain('->>') + expect(result).not.toContain('"SELECT"') + expect(result).not.toContain('"FROM"') + expect(result).not.toContain('"WHERE"') + }) })