3 Commits

Author SHA1 Message Date
Misaka
4a7c220baa fix(extractor): resolve SQL syntax error from double-quoted table names 2026-04-05 13:51:18 +08:00
Misaka
f51cae0f6f fix(db): complete PostgreSQL integration in validation and cleaner services
OrderNumberResolver, validation, and cleaner services had incomplete
PostgreSQL support - they only handled SQL Server and MySQL, causing
PostgreSQL to fall through to MySQL code paths with invalid syntax
(backticks, ? placeholders) and missing schema.table name splitting.

Changes:
- Add PostgreSQL SQL generation ($N params, double-quoted identifiers)
  in OrderNumberResolver, validation-application-service,
  production-input-service, and validation-database
- Add PostgreSQL to database factory functions in validation-database
  and cleaner-application-service
- Add UPPER, LOWER, and 40+ common SQL functions to SQL_KEYWORDS to
  prevent prepareSql() from quoting them as identifiers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 13:46:37 +08:00
Misaka
7601b5f176 fix(db): PostgreSQL P0 fixes - SQL_KEYWORDS expansion, timeout config, and tests
- Expand SQL_KEYWORDS from ~120 to 226+ words covering:
  - Window functions (ROW_NUMBER, RANK, LAG, LEAD, etc.)
  - CTEs (WITH, RECURSIVE, MATERIALIZED, etc.)
  - Advanced grouping (ROLLUP, CUBE, GROUPING SETS)
  - JSON operations, types, table sampling
  - Transaction control and other PostgreSQL-specific keywords
- Add connection pool timeout configuration:
  - connectionTimeoutMillis: 10s
  - statement_timeout: 30s (PostgreSQL level)
  - idleTimeoutMillis: 30s (connection cleanup)
  - query_timeout: 60s (driver-level fallback)
- Add 12 comprehensive edge case tests covering:
  - Window functions, CTEs, advanced grouping
  - CASE expressions, set operations, JSON operators
- All 38 tests pass

Production-ready: prevents hung queries and supports complex SQL.
2026-04-05 13:09:01 +08:00
7 changed files with 598 additions and 49 deletions

View File

@@ -1,11 +1,11 @@
import type { WebContents } from 'electron'
import type { MySqlService } from '../database/mysql'
import type { SqlServerService } from '../database/sql-server'
import type { IDatabaseService } from '../../types/database.types'
import { ErpAuthService } from '../erp/erp-auth'
import { CleanerService } from '../erp/cleaner'
import { OrderNumberResolver } from '../erp/order-resolver'
import { MySqlService as MySqlServiceImpl } from '../database/mysql'
import { SqlServerService as SqlServerServiceImpl } from '../database/sql-server'
import { PostgreSqlService as PostgreSqlServiceImpl } from '../database/postgresql'
import { ConfigManager } from '../config/config-manager'
import { ResultExporter } from '../excel/result-exporter'
import { CleanerReportGenerator } from '../report/cleaner-report-generator'
@@ -26,13 +26,11 @@ import type {
const log = createLogger('CleanerApplicationService')
type DatabaseService = MySqlService | SqlServerService
export class CleanerApplicationService {
async runCleaner(eventSender: WebContents, input: CleanerInput): Promise<CleanerResult> {
const startTime = Date.now()
let authService: ErpAuthService | null = null
let dbService: DatabaseService | null = null
let dbService: IDatabaseService | null = null
try {
log.info('Fetching ERP configuration from database...')
@@ -46,7 +44,7 @@ export class CleanerApplicationService {
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
log.info(
`Connecting to ${dbType === 'sqlserver' ? 'SQL Server' : 'MySQL'} for order resolution...`
`Connecting to ${dbType === 'sqlserver' ? 'SQL Server' : dbType === 'postgresql' ? 'PostgreSQL' : 'MySQL'} for order resolution...`
)
try {
@@ -201,7 +199,7 @@ export class CleanerApplicationService {
}
}
private async getDatabaseService(): Promise<DatabaseService> {
private async getDatabaseService(): Promise<IDatabaseService> {
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
const dbType = configManager.getDatabaseType()
@@ -223,6 +221,19 @@ export class CleanerApplicationService {
return sqlServerService
}
if (dbType === 'postgresql') {
const dbConfig = config.database.postgresql
const pgService = new PostgreSqlServiceImpl({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
await pgService.connect()
return pgService
}
const dbConfig = config.database.mysql
const mysqlService = new MySqlServiceImpl({
host: dbConfig.host,

View File

@@ -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,112 @@ const SQL_KEYWORDS = new Set([
'CONSTRAINT',
'UNIQUE',
'CHECK',
'CASE',
'NULLS',
'FIRST',
'LAST',
// ==================== Scalar & String Functions ====================
'UPPER',
'LOWER',
'TRIM',
'LTRIM',
'RTRIM',
'BTRIM',
'SUBSTRING',
'CONCAT',
'LENGTH',
'CHAR_LENGTH',
'CHARACTER_LENGTH',
'REPLACE',
'POSITION',
'OVERLAY',
'LPAD',
'RPAD',
'REPEAT',
'REVERSE',
'SPLIT_PART',
'INITCAP',
'NORMALIZE',
'CHR',
'ASCII',
'FORMAT',
// ==================== Numeric Functions ====================
'ABS',
'CEIL',
'CEILING',
'FLOOR',
'ROUND',
'POWER',
'SQRT',
'MOD',
'SIGN',
'TRUNC',
// ==================== Date/Time Functions ====================
'EXTRACT',
'DATE_TRUNC',
'TO_CHAR',
'TO_DATE',
'TO_TIMESTAMP',
'TO_NUMBER',
'AGE',
// ==================== 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 +491,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

View File

@@ -58,23 +58,34 @@ export class OrderNumberResolver {
/**
* Get table name based on database type
* Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format
* e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据]
* dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted]
* Converts schema_tablename format to database-specific quoting:
* - SQL Server: [schema].[tablename]
* - PostgreSQL: "schema"."tablename"
* - MySQL: schema_tablename (as-is)
* e.g., productionContractData_26年压力表合同数据 ->
* SQL Server: [productionContractData].[26年压力表合同数据]
* PostgreSQL: "productionContractData"."26年压力表合同数据"
* MySQL: productionContractData_26年压力表合同数据
*/
private getTableName(tableName: string): string {
if (this.dbService.type === 'sqlserver') {
if (this.dbService.type === 'sqlserver' || this.dbService.type === 'postgresql') {
// Find the FIRST underscore to split schema and table name
// This handles patterns like: schema_tablename
const firstUnderscoreIndex = tableName.indexOf('_')
if (firstUnderscoreIndex > 0) {
const schema = tableName.substring(0, firstUnderscoreIndex)
const actualTableName = tableName.substring(firstUnderscoreIndex + 1)
if (this.dbService.type === 'sqlserver') {
return `[${schema}].[${actualTableName}]`
}
// If no underscore found, default to dbo schema
return `"${schema}"."${actualTableName}"`
}
// If no underscore found, default schema
if (this.dbService.type === 'sqlserver') {
return `[dbo].[${tableName}]`
}
return `"public"."${tableName}"`
}
return tableName
}
@@ -107,6 +118,12 @@ export class OrderNumberResolver {
// 使用 COLLATE 指定不区分大小写的排序规则
sql = `SELECT TOP 1 [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS = @p0`
params = [productionId]
} else if (this.dbService.type === 'postgresql') {
// PostgreSQL: 使用双引号保护中文标识符UPPER 实现不区分大小写
// prepareSql() 会保留已双引号包裹的标识符
// 注意getTableName() 已返回带双引号的表名,不应再加引号
sql = `SELECT DISTINCT "${dbConfig.FIELD_PRODUCTION_ID}", "${dbConfig.FIELD_ORDER_NUMBER}" FROM ${tableName} WHERE UPPER("${dbConfig.FIELD_PRODUCTION_ID}") = UPPER($1) LIMIT 1`
params = [productionId]
} else {
// MySQL 默认不区分大小写,但显式使用 UPPER 确保一致性
sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) = UPPER(?) LIMIT 1`
@@ -155,6 +172,11 @@ export class OrderNumberResolver {
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
// 使用 COLLATE 指定不区分大小写的排序规则
sql = `SELECT DISTINCT [${dbConfig.FIELD_PRODUCTION_ID}], [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS IN (${placeholders})`
} else if (this.dbService.type === 'postgresql') {
// PostgreSQL: 使用双引号保护中文标识符UPPER 实现不区分大小写
// 注意getTableName() 已返回带双引号的表名,不应再加引号
const pgPlaceholders = uniqueProductionIds.map((_, i) => `UPPER($${i + 1})`).join(', ')
sql = `SELECT DISTINCT "${dbConfig.FIELD_PRODUCTION_ID}", "${dbConfig.FIELD_ORDER_NUMBER}" FROM ${tableName} WHERE UPPER("${dbConfig.FIELD_PRODUCTION_ID}") IN (${pgPlaceholders})`
} else {
const idPlaceholders = uniqueProductionIds.map(() => 'UPPER(?)').join(', ')
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships

View File

@@ -29,7 +29,7 @@ export async function getSourceNumbersFromInputs(
const productionIds: string[] = []
const orderNumbers: string[] = []
const configManager = ConfigManager.getInstance()
const isSqlServer = configManager.getDatabaseType() === 'sqlserver'
const dbType = configManager.getDatabaseType()
for (const item of inputs) {
const type = identifyInputType(item)
@@ -44,7 +44,7 @@ export async function getSourceNumbersFromInputs(
const contractTableName = getValidationTableName('productionContractData_26年压力表合同数据')
const batchSize = 2000
if (isSqlServer) {
if (dbType === 'sqlserver') {
const sql = await import('mssql')
const allOrderNumbers: string[] = []
@@ -71,6 +71,22 @@ export async function getSourceNumbersFromInputs(
)
}
orderNumbers.push(...allOrderNumbers)
} else if (dbType === 'postgresql') {
const allOrderNumbers: string[] = []
for (let i = 0; i < productionIds.length; i += batchSize) {
const batch = productionIds.slice(i, i + batchSize)
const placeholders = batch.map((_, idx) => `$${idx + 1}`).join(',')
const contractSql = `
SELECT DISTINCT "生产订单号"
FROM ${contractTableName}
WHERE "总排号" IN (${placeholders})
`
const contractResult = await dbService.query(contractSql, batch)
allOrderNumbers.push(...contractResult.rows.map((row) => row. as string))
}
orderNumbers.push(...allOrderNumbers)
} else {
const allOrderNumbers: string[] = []

View File

@@ -463,6 +463,18 @@ export class ValidationApplicationService {
)
}
if (dbService.type === 'postgresql') {
return dbService.query(
`
SELECT "MaterialName", "Specification", "Model"
FROM ${detailTableName}
WHERE "MaterialCode" = $1
LIMIT 1
`,
[materialCode]
)
}
return dbService.query(
`
SELECT MaterialName, Specification, Model
@@ -521,6 +533,24 @@ export class ValidationApplicationService {
return materialCodes
}
if (dbService.type === 'postgresql') {
const result = await dbService.query(
`
SELECT "MaterialCode"
FROM ${markedTableName}
WHERE "ManagerName" = $1 AND "MaterialCode" IS NOT NULL
`,
[username]
)
const materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
log.info(`Regular user: got ${materialCodes.length} materials`, {
userId: username,
isAdmin: false,
materialCount: materialCodes.length
})
return materialCodes
}
const result = await dbService.query(
`
SELECT MaterialCode

View File

@@ -1,8 +1,9 @@
import { ConfigManager } from '../config/config-manager'
import { MySqlService } from '../database/mysql'
import { SqlServerService } from '../database/sql-server'
import { PostgreSqlService } from '../database/postgresql'
export type ValidationDatabaseService = MySqlService | SqlServerService
export type ValidationDatabaseService = MySqlService | SqlServerService | PostgreSqlService
export async function createValidationDatabaseService(): Promise<ValidationDatabaseService> {
const configManager = ConfigManager.getInstance()
@@ -26,6 +27,19 @@ export async function createValidationDatabaseService(): Promise<ValidationDatab
return sqlServerService
}
if (dbType === 'postgresql') {
const dbConfig = config.database.postgresql
const pgService = new PostgreSqlService({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
await pgService.connect()
return pgService
}
const dbConfig = config.database.mysql
const mysqlService = new MySqlService({
host: dbConfig.host,
@@ -42,15 +56,21 @@ export function getValidationTableName(mysqlTableName: string): string {
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
if (dbType === 'sqlserver') {
if (dbType === 'sqlserver' || dbType === 'postgresql') {
const firstUnderscoreIndex = mysqlTableName.indexOf('_')
if (firstUnderscoreIndex > 0) {
const schema = mysqlTableName.substring(0, firstUnderscoreIndex)
const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1)
if (dbType === 'sqlserver') {
return `[${schema}].[${tableName}]`
}
return `"${schema}"."${tableName}"`
}
if (dbType === 'sqlserver') {
return `[dbo].[${mysqlTableName}]`
}
return `"public"."${mysqlTableName}"`
}
return mysqlTableName
}

View File

@@ -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"')
})
})