diff --git a/packages/deparser/__tests__/misc/typecast-edge-cases.test.ts b/packages/deparser/__tests__/misc/typecast-edge-cases.test.ts new file mode 100644 index 00000000..cbf73c7b --- /dev/null +++ b/packages/deparser/__tests__/misc/typecast-edge-cases.test.ts @@ -0,0 +1,284 @@ +import { expectParseDeparse } from '../../test-utils'; + +describe('TypeCast with negative numbers', () => { + it('should handle negative integer with CAST syntax', async () => { + const sql = `SELECT -1::integer`; + const result = await expectParseDeparse(sql); + // Negative numbers require CAST() syntax for precedence + // Note: PostgreSQL normalizes "integer" to "int" in the AST + expect(result).toBe(`SELECT CAST(-1 AS int)`); + }); + + it('should handle parenthesized negative integer', async () => { + const sql = `SELECT (-1)::integer`; + const result = await expectParseDeparse(sql); + // Parenthesized negative numbers can use :: syntax + // Note: PostgreSQL normalizes "integer" to "int" in the AST + expect(result).toBe(`SELECT (-1)::int`); + }); + + it('should handle negative float with CAST syntax', async () => { + const sql = `SELECT -1.5::numeric`; + const result = await expectParseDeparse(sql); + // Negative floats require CAST() syntax for precedence + expect(result).toBe(`SELECT CAST(-1.5 AS numeric)`); + }); + + it('should handle parenthesized negative float', async () => { + const sql = `SELECT (-1.5)::numeric`; + const result = await expectParseDeparse(sql); + // Parenthesized negative floats use CAST() syntax, parentheses removed + expect(result).toBe(`SELECT CAST(-1.5 AS numeric)`); + }); + + it('should handle negative bigint', async () => { + const sql = `SELECT -9223372036854775808::bigint`; + const result = await expectParseDeparse(sql); + // Negative bigints: deparser outputs unary minus operator separately + expect(result).toBe(`SELECT - CAST(9223372036854775808 AS bigint)`); + }); +}); + +describe('TypeCast with complex expressions', () => { + it('should handle arithmetic expression with CAST syntax', async () => { + const sql = `SELECT (1 + 2)::integer`; + const result = await expectParseDeparse(sql); + // Complex expressions require CAST() syntax + // Note: PostgreSQL normalizes "integer" to "int" in the AST + // Note: Deparser removes outer parentheses from arithmetic expressions + expect(result).toBe(`SELECT CAST(1 + 2 AS int)`); + }); + + it('should handle subtraction expression', async () => { + const sql = `SELECT (a - b)::integer FROM t`; + const result = await expectParseDeparse(sql); + // Complex expressions require CAST() syntax + // Note: PostgreSQL normalizes "integer" to "int" in the AST + // Note: Deparser removes outer parentheses from arithmetic expressions + expect(result).toBe(`SELECT CAST(a - b AS int) FROM t`); + }); + + it('should handle CASE expression with CAST syntax', async () => { + const sql = `SELECT (CASE WHEN a > 0 THEN 1 ELSE 2 END)::integer FROM t`; + const result = await expectParseDeparse(sql); + // Complex expressions require CAST() syntax + // Note: PostgreSQL normalizes "integer" to "int" in the AST + // Note: Deparser removes outer parentheses from CASE expressions + // Note: Deparser also removes parentheses from WHEN conditions + expect(result).toBe(`SELECT CAST(CASE WHEN a > 0 THEN 1 ELSE 2 END AS int) FROM t`); + }); + + it('should handle boolean expression', async () => { + const sql = `SELECT (a IS NULL)::boolean FROM t`; + const result = await expectParseDeparse(sql); + // Complex expressions require CAST() syntax + // Note: Deparser removes outer parentheses from boolean expressions + expect(result).toBe(`SELECT CAST(a IS NULL AS boolean) FROM t`); + }); + + it('should handle comparison expression', async () => { + const sql = `SELECT (a > b)::boolean FROM t`; + const result = await expectParseDeparse(sql); + // Complex expressions require CAST() syntax + // Note: Deparser removes outer parentheses from comparison expressions + expect(result).toBe(`SELECT CAST(a > b AS boolean) FROM t`); + }); +}); + +describe('TypeCast with function calls', () => { + it('should handle function call with :: syntax and parentheses', async () => { + const sql = `SELECT substring('test', 1, 2)::text`; + const result = await expectParseDeparse(sql); + // Function calls can use :: syntax with parentheses for precedence + expect(result).toBe(`SELECT (substring('test', 1, 2))::text`); + }); + + it('should handle qualified function call', async () => { + const sql = `SELECT pg_catalog.substring('test', 1, 2)::text`; + const result = await expectParseDeparse(sql); + // Qualified function calls can use :: syntax with parentheses + expect(result).toBe(`SELECT (pg_catalog.substring('test', 1, 2))::text`); + }); + + it('should handle aggregate function', async () => { + const sql = `SELECT sum(x)::numeric FROM t`; + const result = await expectParseDeparse(sql); + // Aggregate functions can use :: syntax with parentheses + expect(result).toBe(`SELECT (sum(x))::numeric FROM t`); + }); + + it('should handle nested function calls', async () => { + const sql = `SELECT upper(lower('TEST'))::text`; + const result = await expectParseDeparse(sql); + // Nested function calls can use :: syntax with parentheses + expect(result).toBe(`SELECT (upper(lower('TEST')))::text`); + }); +}); + +describe('TypeCast with pg_catalog.bpchar', () => { + it('should preserve CAST syntax for qualified bpchar', async () => { + const sql = `SELECT 'x'::pg_catalog.bpchar`; + const result = await expectParseDeparse(sql); + // Should use CAST() syntax for round-trip fidelity + expect(result).toBe(`SELECT CAST('x' AS pg_catalog.bpchar)`); + }); + + it('should use :: syntax for unqualified bpchar', async () => { + const sql = `SELECT 'x'::bpchar`; + const result = await expectParseDeparse(sql); + expect(result).toBe(`SELECT 'x'::bpchar`); + }); + + it('should handle bpchar with length modifier', async () => { + const sql = `SELECT 'hello'::bpchar(10)`; + const result = await expectParseDeparse(sql); + // bpchar with length modifier uses CAST() syntax (not :: syntax) + expect(result).toBe(`SELECT CAST('hello' AS bpchar(10))`); + }); +}); + +describe('TypeCast with string literals containing special characters', () => { + it('should handle string literal with parenthesis', async () => { + const sql = `SELECT '('::text`; + const result = await expectParseDeparse(sql); + // Should use :: syntax (not CAST) - improvement over old string-based heuristic + expect(result).toBe(`SELECT '('::text`); + }); + + it('should handle string literal starting with minus', async () => { + const sql = `SELECT '-hello'::text`; + const result = await expectParseDeparse(sql); + // Should use :: syntax (not CAST) - improvement over old string-based heuristic + expect(result).toBe(`SELECT '-hello'::text`); + }); + + it('should handle string literal with multiple special chars', async () => { + const sql = `SELECT '(-)'::text`; + const result = await expectParseDeparse(sql); + expect(result).toBe(`SELECT '(-)'::text`); + }); + + it('should handle empty string', async () => { + const sql = `SELECT ''::text`; + const result = await expectParseDeparse(sql); + expect(result).toBe(`SELECT ''::text`); + }); +}); + +describe('TypeCast with simple constants', () => { + it('should handle positive integer with :: syntax', async () => { + const sql = `SELECT 123::integer`; + const result = await expectParseDeparse(sql); + // Note: PostgreSQL normalizes "integer" to "int" in the AST + expect(result).toBe(`SELECT 123::int`); + }); + + it('should handle positive float with :: syntax', async () => { + const sql = `SELECT 3.14::numeric`; + const result = await expectParseDeparse(sql); + expect(result).toBe(`SELECT 3.14::numeric`); + }); + + it('should handle boolean true', async () => { + const sql = `SELECT true::boolean`; + const result = await expectParseDeparse(sql); + // Boolean constants use CAST() syntax (not :: syntax) + expect(result).toBe(`SELECT CAST(true AS boolean)`); + }); + + it('should handle boolean false', async () => { + const sql = `SELECT false::boolean`; + const result = await expectParseDeparse(sql); + // Boolean constants use CAST() syntax (not :: syntax) + expect(result).toBe(`SELECT CAST(false AS boolean)`); + }); + + it('should handle NULL cast', async () => { + const sql = `SELECT NULL::integer`; + const result = await expectParseDeparse(sql); + // NULL can use :: syntax + // Note: PostgreSQL normalizes "integer" to "int" in the AST + expect(result).toBe(`SELECT NULL::int`); + }); +}); + +describe('TypeCast with column references', () => { + it('should handle simple column reference with :: syntax', async () => { + const sql = `SELECT a::integer FROM t`; + const result = await expectParseDeparse(sql); + // Note: PostgreSQL normalizes "integer" to "int" in the AST + expect(result).toBe(`SELECT a::int FROM t`); + }); + + it('should handle qualified column reference', async () => { + const sql = `SELECT t.a::integer FROM t`; + const result = await expectParseDeparse(sql); + // Note: PostgreSQL normalizes "integer" to "int" in the AST + expect(result).toBe(`SELECT t.a::int FROM t`); + }); + + it('should handle fully qualified column reference', async () => { + const sql = `SELECT schema.t.a::integer FROM schema.t`; + const result = await expectParseDeparse(sql); + // Fully qualified column references can use :: syntax + // Note: PostgreSQL normalizes "integer" to "int" in the AST + expect(result).toBe(`SELECT schema.t.a::int FROM schema.t`); + }); +}); + +describe('TypeCast with pg_catalog types', () => { + it('should handle pg_catalog.int4 with :: syntax', async () => { + const sql = `SELECT 123::pg_catalog.int4`; + const result = await expectParseDeparse(sql); + // PostgreSQL normalizes int4 to int, and strips pg_catalog prefix + expect(result).toBe(`SELECT 123::int`); + }); + + it('should handle pg_catalog.varchar', async () => { + const sql = `SELECT 'hello'::pg_catalog.varchar`; + const result = await expectParseDeparse(sql); + // Should strip pg_catalog prefix and use :: syntax + expect(result).toBe(`SELECT 'hello'::varchar`); + }); + + it('should handle pg_catalog.numeric', async () => { + const sql = `SELECT 3.14::pg_catalog.numeric`; + const result = await expectParseDeparse(sql); + // Should strip pg_catalog prefix and use :: syntax + expect(result).toBe(`SELECT 3.14::numeric`); + }); +}); + +describe('TypeCast with arrays', () => { + it('should handle array literal cast', async () => { + const sql = `SELECT ARRAY[1, 2, 3]::integer[]`; + const result = await expectParseDeparse(sql); + // Array literals require CAST() syntax + // Note: PostgreSQL normalizes "integer[]" to "int[]" in the AST + expect(result).toBe(`SELECT CAST(ARRAY[1, 2, 3] AS int[])`); + }); + + it('should handle array string literal cast', async () => { + const sql = `SELECT '{1,2,3}'::integer[]`; + const result = await expectParseDeparse(sql); + // Array string literals require CAST() syntax + // Note: PostgreSQL normalizes "integer[]" to "int[]" in the AST + expect(result).toBe(`SELECT CAST('{1,2,3}' AS int[])`); + }); +}); + +describe('TypeCast with ROW expressions', () => { + it('should handle ROW cast', async () => { + const sql = `SELECT ROW(1, 2)::record`; + const result = await expectParseDeparse(sql); + // ROW expressions require CAST() syntax + expect(result).toBe(`SELECT CAST(ROW(1, 2) AS record)`); + }); + + it('should handle implicit row cast', async () => { + const sql = `SELECT (1, 2)::record`; + const result = await expectParseDeparse(sql); + // Implicit ROW expressions require CAST() syntax + expect(result).toBe(`SELECT CAST((1, 2) AS record)`); + }); +}); diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index 52c52db0..f36937f3 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -2346,40 +2346,171 @@ export class Deparser implements DeparserVisitor { return `COALESCE(${argStrs.join(', ')})`; } + /** + * Helper: Check if a TypeName node's names array matches a specific qualified path. + * Example: isQualifiedName(node.names, ['pg_catalog', 'bpchar']) checks for pg_catalog.bpchar + */ + private isQualifiedName(names: any[] | undefined, expectedPath: string[]): boolean { + if (!names || names.length !== expectedPath.length) { + return false; + } + + for (let i = 0; i < expectedPath.length; i++) { + const nameValue = (names[i] as any)?.String?.sval; + if (nameValue !== expectedPath[i]) { + return false; + } + } + + return true; + } + + /** + * Determine if an argument node needs CAST() syntax based on AST structure. + * + * This method inspects the AST node type and properties to decide whether + * the argument can safely use PostgreSQL's :: cast syntax or requires + * the more explicit CAST(... AS ...) syntax. + * + * @param argNode - The AST node representing the cast argument + * @returns true if CAST() syntax is required, false if :: syntax can be used + * + * Decision logic: + * - FuncCall: Can use :: (TypeCast will add parentheses for precedence) + * - A_Const (positive): Can use :: (simple literal) + * - A_Const (negative): Requires CAST() (precedence issues with -1::type) + * - ColumnRef: Can use :: (simple column reference) + * - All other types: Require CAST() (complex expressions, operators, etc.) + * + * @example + * // Returns false (can use ::) + * argumentNeedsCastSyntax({ A_Const: { ival: 42 } }) + * + * // Returns true (needs CAST) + * argumentNeedsCastSyntax({ A_Const: { ival: -1 } }) + * + * // Returns true (needs CAST) + * argumentNeedsCastSyntax({ A_Expr: { ... } }) + */ + private argumentNeedsCastSyntax(argNode: any): boolean { + const argType = this.getNodeType(argNode); + + // FuncCall nodes can use :: syntax (TypeCast will add parentheses) + if (argType === 'FuncCall') { + return false; + } + + // Simple constants and column references can use :: syntax + if (argType === 'A_Const' || argType === 'ColumnRef') { + // Check for A_Const with special cases that might need CAST syntax + if (argType === 'A_Const') { + // Unwrap the node to get the actual A_Const data + const nodeAny = (argNode.A_Const || argNode) as any; + + // Check if this is a negative number (needs parentheses with :: syntax) + // Negative numbers can be represented as negative ival or as fval starting with '-' + if (nodeAny.ival !== undefined) { + const ivalValue = typeof nodeAny.ival === 'object' ? nodeAny.ival.ival : nodeAny.ival; + if (typeof ivalValue === 'number' && ivalValue < 0) { + return true; // Negative integer needs CAST() to avoid precedence issues + } + } + + if (nodeAny.fval !== undefined) { + const fvalValue = typeof nodeAny.fval === 'object' ? nodeAny.fval.fval : nodeAny.fval; + const fvalStr = String(fvalValue); + if (fvalStr.startsWith('-')) { + return true; // Negative float needs CAST() to avoid precedence issues + } + } + + // Check for Integer/Float in val field + if (nodeAny.val) { + if (nodeAny.val.Integer?.ival !== undefined && nodeAny.val.Integer.ival < 0) { + return true; + } + if (nodeAny.val.Float?.fval !== undefined) { + const fvalStr = String(nodeAny.val.Float.fval); + if (fvalStr.startsWith('-')) { + return true; + } + } + } + + // All other A_Const types (positive numbers, strings, booleans, null, bit strings) are simple + return false; + } + + // ColumnRef can always use :: syntax + return false; + } + + // All other node types (A_Expr, SubLink, TypeCast, A_Indirection, RowExpr, etc.) + // are considered complex and should use CAST() syntax + return true; + } + + /** + * Deparse a TypeCast node to SQL. + * + * Chooses between PostgreSQL's two cast syntaxes: + * - :: syntax: Cleaner, preferred for simple cases (e.g., '123'::integer) + * - CAST() syntax: Required for complex expressions and special cases + * + * Decision logic: + * 1. pg_catalog.bpchar: Always use CAST() for round-trip fidelity + * 2. pg_catalog types with simple args: Use :: syntax (cleaner) + * 3. pg_catalog types with complex args: Use CAST() (precedence safety) + * 4. All other types: Use CAST() (default) + * + * Simple args: positive constants, column refs, function calls + * Complex args: negative numbers, expressions, operators, etc. + * + * @param node - The TypeCast AST node + * @param context - The deparser context + * @returns The deparsed SQL string + * + * @example + * // Simple constant -> :: syntax + * TypeCast({ arg: { A_Const: { ival: 123 } }, typeName: 'integer' }) + * // Returns: "123::integer" + * + * @example + * // Negative number -> CAST() syntax + * TypeCast({ arg: { A_Const: { ival: -1 } }, typeName: 'integer' }) + * // Returns: "CAST(-1 AS integer)" + * + * @example + * // pg_catalog.bpchar -> CAST() syntax + * TypeCast({ arg: { A_Const: { sval: 'x' } }, typeName: { names: ['pg_catalog', 'bpchar'] } }) + * // Returns: "CAST('x' AS pg_catalog.bpchar)" + */ TypeCast(node: t.TypeCast, context: DeparserContext): string { const arg = this.visit(node.arg, context); const typeName = this.TypeName(node.typeName, context); - // Check if this is a bpchar typecast that should preserve original syntax for AST consistency - if (typeName === 'bpchar' || typeName === 'pg_catalog.bpchar') { - const names = node.typeName?.names; - const isQualifiedBpchar = names && names.length === 2 && - (names[0] as any)?.String?.sval === 'pg_catalog' && - (names[1] as any)?.String?.sval === 'bpchar'; - - if (isQualifiedBpchar) { - return `CAST(${arg} AS ${typeName})`; - } + // Special handling for bpchar: preserve pg_catalog.bpchar with CAST() syntax for round-trip fidelity + if (this.isQualifiedName(node.typeName?.names, ['pg_catalog', 'bpchar'])) { + return `CAST(${arg} AS ${typeName})`; } + // Check if this is a built-in pg_catalog type based on the rendered type name if (this.isPgCatalogType(typeName)) { const argType = this.getNodeType(node.arg); - const isSimpleArgument = argType === 'A_Const' || argType === 'ColumnRef'; - const isFunctionCall = argType === 'FuncCall'; - - if (isSimpleArgument || isFunctionCall) { - // For simple arguments, avoid :: syntax if they have complex structure - const shouldUseCastSyntax = isSimpleArgument && (arg.includes('(') || arg.startsWith('-')); + // Determine if we can use :: syntax based on AST structure + const needsCastSyntax = this.argumentNeedsCastSyntax(node.arg); + + if (!needsCastSyntax) { + // Strip pg_catalog prefix from the rendered type name for :: syntax + const cleanTypeName = typeName.replace(/^pg_catalog\./, ''); - if (!shouldUseCastSyntax) { - const cleanTypeName = typeName.replace('pg_catalog.', ''); - // Wrap FuncCall arguments in parentheses to prevent operator precedence issues - if (isFunctionCall) { - return `${context.parens(arg)}::${cleanTypeName}`; - } - return `${arg}::${cleanTypeName}`; + // For FuncCall, wrap in parentheses to prevent operator precedence issues + if (argType === 'FuncCall') { + return `${context.parens(arg)}::${cleanTypeName}`; } + + return `${arg}::${cleanTypeName}`; } }