From f1c577aab64ef0cd083363869d2746b4374484ad Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 15 Mar 2026 03:42:28 +0000 Subject: [PATCH] fix(deparser): preserve parentheses around binary expressions in unary operators Fixes #285. The unary operator handler in A_Expr did not wrap a binary A_Expr rexpr in parentheses, causing -(a - b) to deparse as - a - b, which changes operator precedence and produces incorrect math. Also affects TypeCast conversions: (-(a - b))::numeric became CAST(- a - b AS numeric) instead of CAST(- (a - b) AS numeric). --- __fixtures__/generated/generated.json | 4 +++- __fixtures__/kitchen-sink/misc/issues.sql | 6 ++++++ .../__tests__/kitchen-sink/misc-issues.test.ts | 4 +++- packages/deparser/src/deparser.ts | 15 +++++++++++---- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/__fixtures__/generated/generated.json b/__fixtures__/generated/generated.json index 4f0f5b75..4082f320 100644 --- a/__fixtures__/generated/generated.json +++ b/__fixtures__/generated/generated.json @@ -21330,7 +21330,9 @@ "misc/issues-15.sql": "select \"A\" from \"table_name\"", "misc/issues-16.sql": "select \"AA\" from \"table_name\"", "misc/issues-17.sql": "SELECT CAST(t.date AT TIME ZONE $$America/New_York$$ AS text)::date FROM tbl t", - "misc/issues-18.sql": "CREATE TABLE test_exclude_where (\n id uuid PRIMARY KEY,\n database_id uuid NOT NULL,\n status text NOT NULL DEFAULT 'pending',\n EXCLUDE USING btree (database_id WITH =)\n WHERE (status = 'pending')\n)", + "misc/issues-18.sql": "SELECT (- (-10 - -12))::numeric AS delta", + "misc/issues-19.sql": "SELECT (- (a.actual_eur - a.budget_eur))::numeric AS delta_eur FROM accounts a", + "misc/issues-20.sql": "CREATE TABLE test_exclude_where (\n id uuid PRIMARY KEY,\n database_id uuid NOT NULL,\n status text NOT NULL DEFAULT 'pending',\n EXCLUDE USING btree (database_id WITH =)\n WHERE (status = 'pending')\n)", "misc/inflection-1.sql": "CREATE SCHEMA inflection", "misc/inflection-2.sql": "GRANT USAGE ON SCHEMA inflection TO PUBLIC", "misc/inflection-3.sql": "ALTER DEFAULT PRIVILEGES IN SCHEMA inflection \n GRANT EXECUTE ON FUNCTIONS TO PUBLIC", diff --git a/__fixtures__/kitchen-sink/misc/issues.sql b/__fixtures__/kitchen-sink/misc/issues.sql index e069822a..88c2c106 100644 --- a/__fixtures__/kitchen-sink/misc/issues.sql +++ b/__fixtures__/kitchen-sink/misc/issues.sql @@ -76,6 +76,12 @@ select "AA" from "table_name"; -- https://github.com/constructive-io/pgsql-parser/issues/217 SELECT CAST(t.date AT TIME ZONE $$America/New_York$$ AS text)::date FROM tbl t; +-- https://github.com/constructive-io/pgsql-parser/issues/285 +-- TypeCast with unary minus loses parentheses around inner expression +-- (- (a - b))::numeric becomes CAST(- a - b AS numeric) which changes the math +SELECT (- (-10 - -12))::numeric AS delta; +SELECT (- (a.actual_eur - a.budget_eur))::numeric AS delta_eur FROM accounts a; + -- https://github.com/constructive-io/pgsql-parser/issues/287 -- EXCLUDE constraint with WHERE clause (partial exclusion constraint) -- The deparser drops the WHERE clause from EXCLUDE USING ... WHERE (...) diff --git a/packages/deparser/__tests__/kitchen-sink/misc-issues.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-issues.test.ts index 53ab1b5d..02bc60ff 100644 --- a/packages/deparser/__tests__/kitchen-sink/misc-issues.test.ts +++ b/packages/deparser/__tests__/kitchen-sink/misc-issues.test.ts @@ -21,6 +21,8 @@ it('misc-issues', async () => { "misc/issues-15.sql", "misc/issues-16.sql", "misc/issues-17.sql", - "misc/issues-18.sql" + "misc/issues-18.sql", + "misc/issues-19.sql", + "misc/issues-20.sql" ]); }); diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index a3713d94..ecdbb409 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -720,10 +720,17 @@ export class Deparser implements DeparserVisitor { return context.format([leftExpr, operator, rightExpr]); }else if (rexpr) { - return context.format([ - this.deparseOperatorName(name, context), - this.visit(rexpr, context) - ]); + // Unary operator (e.g., unary minus: - expr) + const operator = this.deparseOperatorName(name, context); + let rightExpr = this.visit(rexpr, context); + + // Wrap in parentheses if rexpr is a binary A_Expr to preserve semantics + // e.g., -(a - b) must stay -(a - b), not become -a - b + if (rexpr && 'A_Expr' in rexpr && rexpr.A_Expr?.kind === 'AEXPR_OP' && rexpr.A_Expr?.lexpr) { + rightExpr = context.parens(rightExpr); + } + + return context.format([operator, rightExpr]); } break; case 'AEXPR_OP_ANY':