From e8878fad81263d3297f17338c4f6d869fb6d56c5 Mon Sep 17 00:00:00 2001 From: shuvamk Date: Tue, 4 Aug 2026 18:44:38 +0530 Subject: [PATCH] Print a space between adjacent prefix unary operators `Expr::UnaryOp`'s `Display` writes `{op}{expr}` with no separator, so when the operand is itself a `UnaryOp` the two operator glyphs are emitted glued together and the output no longer round-trips: SELECT ~ ~ 1 -> SELECT ~~1 -> "Expected: an expression, found: ~~" `SELECT - -1` prints as `SELECT --1`, which is the same defect but only breaks on dialects where `--` opens a line comment; MySQL requires whitespace after `--` and reparses it unchanged. The Postgres case is worse than a parse error, because `@@` is a distinct operator: `SELECT @ @ 1` (abs of abs) prints as `SELECT @@1` and silently reparses as `UnaryOperator::DoubleAt` applied to `1`. Extend the existing "needs a space" condition so it also fires when the operand is another `Expr::UnaryOp`. Operators already in that list and non-unary operands are unaffected, so `-1` and `NOT a` are unchanged. Co-Authored-By: Claude Opus 5 --- src/ast/mod.rs | 3 ++- tests/sqlparser_common.rs | 8 ++++++++ tests/sqlparser_postgres.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 8a9a67a74..6225855c1 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -1963,7 +1963,8 @@ impl fmt::Display for Expr { | UnaryOperator::DoubleAt | UnaryOperator::QuestionDash | UnaryOperator::QuestionPipe - ) { + ) || matches!(expr.as_ref(), Expr::UnaryOp { .. }) + { write!(f, "{op} {expr}") } else { write!(f, "{op}{expr}") diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 0800bc41f..34b89c60a 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -19679,3 +19679,11 @@ fn parse_function_arg_call_chain_no_exponential_blowup() { rx.recv_timeout(Duration::from_secs(5)) .expect("parser should reject this quickly, not loop exponentially"); } + +#[test] +fn parse_nested_unary_ops() { + all_dialects().verified_stmt("SELECT - -1"); + all_dialects().verified_stmt("SELECT ~ ~1"); + all_dialects().verified_stmt("SELECT NOT NOT a"); + all_dialects().one_statement_parses_to("SELECT ~ ~ 1", "SELECT ~ ~1"); +} diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index a7128eafd..b053362e5 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9663,3 +9663,29 @@ fn parse_right_deep_join_chain() { // NATURAL JOIN followed by a constrained join must stay left-associative. pg().verified_stmt("SELECT * FROM t0 NATURAL JOIN t1 INNER JOIN t2 ON true"); } + +#[test] +fn parse_nested_pg_unary_ops() { + let select = pg().verified_only_select("SELECT @ @1"); + assert_eq!( + SelectItem::UnnamedExpr(Expr::UnaryOp { + op: UnaryOperator::PGAbs, + expr: Box::new(Expr::UnaryOp { + op: UnaryOperator::PGAbs, + expr: Box::new(Expr::value(number("1"))), + }), + }), + select.projection[0] + ); + + let select = pg().verified_only_select("SELECT @@ 1"); + assert_eq!( + SelectItem::UnnamedExpr(Expr::UnaryOp { + op: UnaryOperator::DoubleAt, + expr: Box::new(Expr::value(number("1"))), + }), + select.projection[0] + ); + + pg().verified_stmt("SELECT |/ |/1"); +}