From 5e396f8414673014ad3ab415cf37fe5a168fb7d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 15:40:06 +0000 Subject: [PATCH 1/2] Keep a set operation after an unaliased derived table outside it In `SELECT 1 AS s, * FROM (SELECT x FROM a) UNION ALL SELECT 2, x FROM b` the parser folded the UNION ALL (and any trailing LIMIT) into the derived table, changing the query: the outer projection then applied to the whole union. qualify_tables' set-operation normalization undid it only for a bare `SELECT *` outer query. A set operation after the closing paren belongs to the enclosing query; nested parens (`FROM ((SELECT 1) UNION (SELECT 2))`) are still parsed by the nested-paren branch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017CxHoCFbTWJA9bcZWC9ydu --- .../src/optimizer/qualify_tables.rs | 4 ++- crates/polyglot-sql/src/parser.rs | 33 +++---------------- .../unaliased_derived_table_set_operation.rs | 24 ++++++++++++++ 3 files changed, 32 insertions(+), 29 deletions(-) create mode 100644 crates/polyglot-sql/tests/unaliased_derived_table_set_operation.rs diff --git a/crates/polyglot-sql/src/optimizer/qualify_tables.rs b/crates/polyglot-sql/src/optimizer/qualify_tables.rs index 8257070c..c108143b 100644 --- a/crates/polyglot-sql/src/optimizer/qualify_tables.rs +++ b/crates/polyglot-sql/src/optimizer/qualify_tables.rs @@ -868,9 +868,11 @@ mod tests { let qualified = qualify_tables(expr, &options); let sql = gen(&qualified); + // The parser keeps the set operation outside the derived table, so + // there is no wrapper left to normalize. assert_eq!( sql, - "SELECT * FROM (SELECT * FROM (SELECT * FROM tab_1 AS tab_1) AS _0 UNION ALL SELECT * FROM (SELECT * FROM tab_1 AS tab_1) AS _1) AS _2" + "SELECT * FROM (SELECT * FROM tab_1 AS tab_1) AS _0 UNION ALL SELECT * FROM (SELECT * FROM tab_1 AS tab_1) AS _1" ); } } diff --git a/crates/polyglot-sql/src/parser.rs b/crates/polyglot-sql/src/parser.rs index 709e3262..16cda713 100644 --- a/crates/polyglot-sql/src/parser.rs +++ b/crates/polyglot-sql/src/parser.rs @@ -4807,34 +4807,11 @@ impl Parser { }; self.expect(TokenType::RParen)?; let trailing = self.previous_trailing_comments().to_vec(); - // Check for set operations after parenthesized query - // If there's a set operation, wrap query in Subquery first to preserve parens - // e.g., (SELECT 1) UNION (SELECT 2) - the left operand needs Subquery wrapper - let result = if self.check(TokenType::Union) - || self.check(TokenType::Intersect) - || self.check(TokenType::Except) - { - let left = Expression::Subquery(Box::new(Subquery { - this: query, - alias: None, - column_aliases: Vec::new(), - alias_explicit_as: false, - alias_keyword: None, - order_by: None, - limit: None, - offset: None, - lateral: false, - modifiers_inside: false, - trailing_comments: Vec::new(), - distribute_by: None, - sort_by: None, - cluster_by: None, - inferred_type: None, - })); - self.parse_set_operation(left)? - } else { - query - }; + // A set operation after the closing paren is not part of this + // FROM item: it belongs to the enclosing query, or, inside + // nested parens (`((SELECT 1) UNION (SELECT 2))`), to the + // nested-paren branch, which parses it itself. + let result = query; Expression::Subquery(Box::new(Subquery { this: result, alias: None, diff --git a/crates/polyglot-sql/tests/unaliased_derived_table_set_operation.rs b/crates/polyglot-sql/tests/unaliased_derived_table_set_operation.rs new file mode 100644 index 00000000..21083e94 --- /dev/null +++ b/crates/polyglot-sql/tests/unaliased_derived_table_set_operation.rs @@ -0,0 +1,24 @@ +//! An unaliased derived table at the end of a set operation's first operand +//! belongs to that operand: the set operation after it is not part of it. + +use polyglot_sql::{transpile, DialectType}; + +fn reprint(sql: &str, dialect: DialectType) -> String { + transpile(sql, dialect, dialect) + .unwrap_or_else(|e| panic!("failed to transpile {sql:?}: {e}")) + .remove(0) +} + +#[test] +fn a_set_operation_after_an_unaliased_derived_table_stays_outside_it() { + for sql in [ + "SELECT 1 AS s, * FROM (SELECT x FROM a) UNION ALL SELECT 2, x FROM b", + "SELECT 'p' AS side, * FROM (SELECT * FROM a LIMIT 1) UNION ALL SELECT 'l' AS side, * FROM (SELECT * FROM b LIMIT 1) LIMIT 2", + "SELECT * FROM c, (SELECT x FROM a LIMIT 1) UNION ALL SELECT x FROM b", + "SELECT y, * FROM (SELECT x FROM a) EXCEPT SELECT y, x FROM b", + ] { + for dialect in [DialectType::Trino, DialectType::Generic] { + assert_eq!(reprint(sql, dialect), sql, "{dialect:?}"); + } + } +} From 1ff1d4da9e65d4e7c4a1432fb59e1096f03a0f70 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 16:10:35 +0000 Subject: [PATCH 2/2] Wrap a set operation parsed inside nested parens as its derived table The nested-paren branch now parses the set operation itself, so wrap it in the Subquery the inner paren branch used to return. The ORDER BY/LIMIT/OFFSET handling after it relies on that shape: without it, SELECT * FROM (((SELECT 1) UNION SELECT 2) ORDER BY x LIMIT 1 OFFSET 1) moved the modifiers outside the outer parens (sqlglot_identity). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017CxHoCFbTWJA9bcZWC9ydu --- crates/polyglot-sql/src/parser.rs | 22 ++++++++++++++++--- .../unaliased_derived_table_set_operation.rs | 12 ++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/polyglot-sql/src/parser.rs b/crates/polyglot-sql/src/parser.rs index 16cda713..024cf415 100644 --- a/crates/polyglot-sql/src/parser.rs +++ b/crates/polyglot-sql/src/parser.rs @@ -4894,10 +4894,26 @@ impl Parser { || self.check(TokenType::Intersect) || self.check(TokenType::Except); let result = if had_set_operation { - // This is a set operation like ((SELECT 1) UNION (SELECT 2)) - // Wrap inner in a subquery-like expression and parse set operation + // This is a set operation like ((SELECT 1) UNION (SELECT 2)): + // the parens around it make it one derived table. let set_result = self.parse_set_operation(inner)?; - set_result + Expression::Subquery(Box::new(Subquery { + this: set_result, + alias: None, + column_aliases: Vec::new(), + alias_explicit_as: false, + alias_keyword: None, + order_by: None, + limit: None, + offset: None, + distribute_by: None, + sort_by: None, + cluster_by: None, + lateral: false, + modifiers_inside: false, + trailing_comments: Vec::new(), + inferred_type: None, + })) } else if self.check(TokenType::Cross) || self.check(TokenType::Outer) || self.check(TokenType::Inner) diff --git a/crates/polyglot-sql/tests/unaliased_derived_table_set_operation.rs b/crates/polyglot-sql/tests/unaliased_derived_table_set_operation.rs index 21083e94..7f807d8c 100644 --- a/crates/polyglot-sql/tests/unaliased_derived_table_set_operation.rs +++ b/crates/polyglot-sql/tests/unaliased_derived_table_set_operation.rs @@ -22,3 +22,15 @@ fn a_set_operation_after_an_unaliased_derived_table_stays_outside_it() { } } } + +#[test] +fn a_parenthesized_set_operation_keeps_its_own_modifiers_inside_its_parens() { + for sql in [ + "SELECT * FROM ((SELECT 1) UNION (SELECT 2))", + "SELECT * FROM ((SELECT 1) UNION SELECT 2)", + "SELECT * FROM (((SELECT 1) UNION SELECT 2) ORDER BY x LIMIT 1 OFFSET 1)", + "SELECT * FROM ((SELECT 1) AS a UNION ALL (SELECT 2) AS b)", + ] { + assert_eq!(reprint(sql, DialectType::Generic), sql); + } +}