From e8878fad81263d3297f17338c4f6d869fb6d56c5 Mon Sep 17 00:00:00 2001 From: shuvamk Date: Tue, 4 Aug 2026 18:44:38 +0530 Subject: [PATCH 1/2] 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"); +} From 4687dfc4a4f0112933c72084cf2d52fa6a592e41 Mon Sep 17 00:00:00 2001 From: shuvamk Date: Sat, 8 Aug 2026 15:31:09 +0530 Subject: [PATCH 2/2] Only add the space when the two operators would lex as one token The previous condition fired for any nested `Expr::UnaryOp`, which spaced pairs that already round-trip unchanged: `++a`, `+@a`, `!!~a`, `-NOT a`. The hazard is not "the operand is a unary op", it is that the outer glyph and the operand's leading glyph concatenate into a different token. Which pairs do that follows from the tokenizer: - `-`, `~`, `|/` and `||/` end in `start_binop`, so on a dialect with `is_custom_operator_part` (Postgres) they absorb any following operator character; `--` also opens a line comment everywhere except MySQL. - `!` merges only with `!` and `~` (`!!`, `!~`). - `@` merges only with `@`, `-` and `?` (`@@`, `@-@`, `@-`, `@?`). - `+` and `!!` are returned by `consume_and_return` and never merge. - A keyword operand (`NOT`) starts with a letter and never merges. Verified by rendering every (outer, inner) prefix-operator pair as a nested `UnaryOp` and reparsing it on all 15 dialects in `all_dialects()`: all 476 representable combinations round-trip, and the only spaces added beyond what some dialect requires are `- !a` and `~ !a`, where the Hive-only `!` operand is unreachable on the dialects that would merge it. Co-Authored-By: Claude Opus 5 --- src/ast/mod.rs | 32 +++++++++++++++++++++++++++++++- tests/sqlparser_common.rs | 8 ++++++++ tests/sqlparser_postgres.rs | 8 ++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 6225855c1..2afae7390 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -196,6 +196,36 @@ where DisplaySeparated { slice, sep: ", " } } +/// Returns true when the prefix operator `op` written directly before `operand` +/// would be tokenized as a single operator rather than as two. +fn lexes_as_one_operator(op: &UnaryOperator, operand: &Expr) -> bool { + let Expr::UnaryOp { op: leading, .. } = operand else { + return false; + }; + match op { + UnaryOperator::Minus + | UnaryOperator::BitwiseNot + | UnaryOperator::PGSquareRoot + | UnaryOperator::PGCubeRoot => !matches!( + leading, + UnaryOperator::Not | UnaryOperator::PGPostfixFactorial + ), + UnaryOperator::BangNot => { + matches!(leading, UnaryOperator::BangNot | UnaryOperator::BitwiseNot) + } + UnaryOperator::PGAbs => matches!( + leading, + UnaryOperator::AtDashAt + | UnaryOperator::DoubleAt + | UnaryOperator::Minus + | UnaryOperator::PGAbs + | UnaryOperator::QuestionDash + | UnaryOperator::QuestionPipe + ), + _ => false, + } +} + /// Writes the given statements to the formatter, each ending with /// a semicolon and space separated. fn format_statement_list(f: &mut fmt::Formatter, statements: &[Statement]) -> fmt::Result { @@ -1963,7 +1993,7 @@ impl fmt::Display for Expr { | UnaryOperator::DoubleAt | UnaryOperator::QuestionDash | UnaryOperator::QuestionPipe - ) || matches!(expr.as_ref(), Expr::UnaryOp { .. }) + ) || lexes_as_one_operator(op, expr) { write!(f, "{op} {expr}") } else { diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 34b89c60a..04ed3e913 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -19687,3 +19687,11 @@ fn parse_nested_unary_ops() { all_dialects().verified_stmt("SELECT NOT NOT a"); all_dialects().one_statement_parses_to("SELECT ~ ~ 1", "SELECT ~ ~1"); } + +#[test] +fn parse_adjacent_unary_ops_that_do_not_combine() { + all_dialects().verified_stmt("SELECT ++a"); + all_dialects().verified_stmt("SELECT +~a"); + all_dialects().verified_stmt("SELECT -NOT a"); + all_dialects().verified_stmt("SELECT ~NOT a"); +} diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index b053362e5..d0b76af1f 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9689,3 +9689,11 @@ fn parse_nested_pg_unary_ops() { pg().verified_stmt("SELECT |/ |/1"); } + +#[test] +fn parse_adjacent_pg_unary_ops_that_do_not_combine() { + pg().verified_stmt("SELECT +@a"); + pg().verified_stmt("SELECT !!~a"); + pg().verified_stmt("SELECT @~a"); + pg().verified_stmt("SELECT !!|/a"); +}