From 23132a28713f211881ea63d1938fa69524bc24bb Mon Sep 17 00:00:00 2001 From: Mosha Pasumansky Date: Fri, 14 Aug 2026 11:40:45 -0700 Subject: [PATCH] feat(bigquery): parse GoogleSQL set-operation column matching Parse the GoogleSQL/BigQuery set-operation column-matching grammar: query_expr [ { INNER | [ { FULL | LEFT } [ OUTER ] ] } ] { UNION | INTERSECT | EXCEPT } { ALL | DISTINCT } [ { BY NAME [ ON (cols) ] | [ STRICT ] CORRESPONDING [ BY (cols) ] } ] query_expr AST: `SetExpr::SetOperation` (and the `UNION`/`INTERSECT`/`EXCEPT` pipe operators) gain a `mode: Option` column-propagation prefix (`INNER`/`LEFT`/`FULL`, each `LEFT`/`FULL` with an optional `OUTER`) and a `column_match: Option` trailing clause carrying the spelling (`BY NAME` vs `CORRESPONDING`), the `STRICT` flag, and the explicit column list. `BY NAME` moves out of `SetQuantifier` into `column_match`, so the quantifier is once again just `ALL`/`DISTINCT`; DuckDB's `UNION BY NAME` uses the same representation. `BY NAME` and `CORRESPONDING` are kept distinct (they differ in default semantics and in the column-list keyword), the mode prefix is recognized only in the slot immediately before the operator -- and is not captured as a preceding select item's alias -- and `STRICT` is parsed only as part of `STRICT CORRESPONDING`. Co-Authored-By: Claude Opus 4.8 --- src/ast/mod.rs | 16 ++-- src/ast/query.rs | 141 +++++++++++++++++++++++++++++++----- src/ast/spans.rs | 2 + src/parser/mod.rs | 139 +++++++++++++++++++++++++++++++---- tests/sqlparser_bigquery.rs | 99 +++++++++++++++++++++++++ tests/sqlparser_common.rs | 2 + tests/sqlparser_duckdb.rs | 12 ++- tests/sqlparser_postgres.rs | 2 + 8 files changed, 370 insertions(+), 43 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 423200e204..9d986ec802 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -102,14 +102,14 @@ pub use self::query::{ PipeOperator, PivotValueSource, ProjectionSelect, Query, RenameSelectItem, RepetitionQuantifier, ReplaceSelectElement, ReplaceSelectItem, RowsPerMatch, Select, SelectFlavor, SelectInto, SelectItem, SelectItemQualifiedWildcardKind, SelectModifiers, - SetExpr, SetOperator, SetQuantifier, Setting, SymbolDefinition, Table, TableAlias, - TableAliasColumnDef, TableFactor, TableFunctionArgs, TableIndexHintForClause, - TableIndexHintType, TableIndexHints, TableIndexType, TableSample, TableSampleBucket, - TableSampleKind, TableSampleMethod, TableSampleModifier, TableSampleQuantity, TableSampleSeed, - TableSampleSeedModifier, TableSampleUnit, TableVersion, TableWithJoins, Top, TopQuantity, - UpdateTableFromKind, ValueTableMode, Values, WildcardAdditionalOptions, With, WithFill, - XmlNamespaceDefinition, XmlPassingArgument, XmlPassingClause, XmlTableColumn, - XmlTableColumnOption, + SetExpr, SetOperationColumnMatch, SetOperationColumnMatchKind, SetOperationMode, SetOperator, + SetQuantifier, Setting, SymbolDefinition, Table, TableAlias, TableAliasColumnDef, TableFactor, + TableFunctionArgs, TableIndexHintForClause, TableIndexHintType, TableIndexHints, + TableIndexType, TableSample, TableSampleBucket, TableSampleKind, TableSampleMethod, + TableSampleModifier, TableSampleQuantity, TableSampleSeed, TableSampleSeedModifier, + TableSampleUnit, TableVersion, TableWithJoins, Top, TopQuantity, UpdateTableFromKind, + ValueTableMode, Values, WildcardAdditionalOptions, With, WithFill, XmlNamespaceDefinition, + XmlPassingArgument, XmlPassingClause, XmlTableColumn, XmlTableColumnOption, }; pub use self::trigger::{ diff --git a/src/ast/query.rs b/src/ast/query.rs index eecfb0490d..9ea25e0845 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -160,8 +160,14 @@ pub enum SetExpr { left: Box, /// The set operator used (e.g. `UNION`, `EXCEPT`). op: SetOperator, - /// Optional quantifier (`ALL`, `DISTINCT`, etc.). + /// Optional quantifier (`ALL`, `DISTINCT`). set_quantifier: SetQuantifier, + /// GoogleSQL column-propagation mode prefix before the operator, e.g. the + /// `FULL` in `FULL UNION ALL BY NAME`. + mode: Option, + /// Column matching after the quantifier: `BY NAME [ON (...)]` or + /// `[STRICT] CORRESPONDING [BY (...)]`. + column_match: Option, /// Right operand of the set operation. right: Box, }, @@ -210,21 +216,27 @@ impl fmt::Display for SetExpr { right, op, set_quantifier, + mode, + column_match, } => { left.fmt(f)?; SpaceOrNewline.fmt(f)?; + if let Some(mode) = mode { + mode.fmt(f)?; + f.write_str(" ")?; + } op.fmt(f)?; match set_quantifier { - SetQuantifier::All - | SetQuantifier::Distinct - | SetQuantifier::ByName - | SetQuantifier::AllByName - | SetQuantifier::DistinctByName => { + SetQuantifier::All | SetQuantifier::Distinct => { f.write_str(" ")?; set_quantifier.fmt(f)?; } SetQuantifier::None => {} } + if let Some(column_match) = column_match { + f.write_str(" ")?; + column_match.fmt(f)?; + } SpaceOrNewline.fmt(f)?; right.fmt(f)?; Ok(()) @@ -259,6 +271,95 @@ impl fmt::Display for SetOperator { } } +/// The column-propagation mode prefix that may precede a set operator in +/// GoogleSQL, e.g. the `FULL` in `FULL UNION ALL BY NAME`. +/// +/// See [GoogleSQL set operators]. +/// +/// [GoogleSQL set operators]: https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#set_operators +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum SetOperationMode { + /// `INNER`: keep only columns present in both inputs. + Inner, + /// `LEFT`: keep the left input's columns. + Left, + /// `LEFT OUTER`: same as `LEFT`. + LeftOuter, + /// `FULL`: keep columns from both inputs, NULL-filling the gaps. + Full, + /// `FULL OUTER`: same as `FULL`. + FullOuter, +} + +impl fmt::Display for SetOperationMode { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(match self { + SetOperationMode::Inner => "INNER", + SetOperationMode::Left => "LEFT", + SetOperationMode::LeftOuter => "LEFT OUTER", + SetOperationMode::Full => "FULL", + SetOperationMode::FullOuter => "FULL OUTER", + }) + } +} + +/// Column matching for a set operation: the `BY NAME` / `CORRESPONDING` clause +/// after the quantifier. The two spellings are equivalent apart from their +/// default semantics (`BY NAME` is strict; bare `CORRESPONDING` is `INNER`) and +/// the column-list keyword (`ON` vs `BY`). +/// +/// See [GoogleSQL set operators]. +/// +/// [GoogleSQL set operators]: https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#set_operators +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct SetOperationColumnMatch { + /// The spelling used (`BY NAME` or `CORRESPONDING`). + pub kind: SetOperationColumnMatchKind, + /// The `STRICT` keyword, valid only before `CORRESPONDING`. + pub strict: bool, + /// The explicit column list, if any: `BY NAME ON (cols)` / + /// `CORRESPONDING BY (cols)`. + pub columns: Option>, +} + +impl fmt::Display for SetOperationColumnMatch { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self.kind { + SetOperationColumnMatchKind::ByName => { + f.write_str("BY NAME")?; + if let Some(columns) = &self.columns { + write!(f, " ON ({})", display_comma_separated(columns))?; + } + } + SetOperationColumnMatchKind::Corresponding => { + if self.strict { + f.write_str("STRICT ")?; + } + f.write_str("CORRESPONDING")?; + if let Some(columns) = &self.columns { + write!(f, " BY ({})", display_comma_separated(columns))?; + } + } + } + Ok(()) + } +} + +/// Which column-matching spelling a [`SetOperationColumnMatch`] used. +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum SetOperationColumnMatchKind { + /// `BY NAME` — strict column matching (DuckDB and GoogleSQL). + ByName, + /// `CORRESPONDING` — standard SQL column matching. + Corresponding, +} + /// A quantifier for [SetOperator]. // TODO: Restrict parsing specific SetQuantifier in some specific dialects. // For example, BigQuery does not support `DISTINCT` for `EXCEPT` and `INTERSECT` @@ -270,12 +371,6 @@ pub enum SetQuantifier { All, /// `DISTINCT` quantifier Distinct, - /// `BY NAME` quantifier - ByName, - /// `ALL BY NAME` quantifier - AllByName, - /// `DISTINCT BY NAME` quantifier - DistinctByName, /// No quantifier specified None, } @@ -285,9 +380,6 @@ impl fmt::Display for SetQuantifier { match self { SetQuantifier::All => write!(f, "ALL"), SetQuantifier::Distinct => write!(f, "DISTINCT"), - SetQuantifier::ByName => write!(f, "BY NAME"), - SetQuantifier::AllByName => write!(f, "ALL BY NAME"), - SetQuantifier::DistinctByName => write!(f, "DISTINCT BY NAME"), SetQuantifier::None => Ok(()), } } @@ -3266,6 +3358,8 @@ pub enum PipeOperator { Union { /// Set quantifier (`ALL` or `DISTINCT`). set_quantifier: SetQuantifier, + /// Optional `BY NAME` / `CORRESPONDING` column matching. + column_match: Option, /// The queries to combine with `UNION`. queries: Vec, }, @@ -3277,6 +3371,8 @@ pub enum PipeOperator { Intersect { /// Set quantifier for the `INTERSECT` operator. set_quantifier: SetQuantifier, + /// Optional `BY NAME` / `CORRESPONDING` column matching. + column_match: Option, /// The queries to intersect. queries: Vec, }, @@ -3288,6 +3384,8 @@ pub enum PipeOperator { Except { /// Set quantifier for the `EXCEPT` operator. set_quantifier: SetQuantifier, + /// Optional `BY NAME` / `CORRESPONDING` column matching. + column_match: Option, /// The queries to exclude from the input set. queries: Vec, }, @@ -3401,16 +3499,19 @@ impl fmt::Display for PipeOperator { } PipeOperator::Union { set_quantifier, + column_match, queries, - } => Self::fmt_set_operation(f, "UNION", set_quantifier, queries), + } => Self::fmt_set_operation(f, "UNION", set_quantifier, column_match, queries), PipeOperator::Intersect { set_quantifier, + column_match, queries, - } => Self::fmt_set_operation(f, "INTERSECT", set_quantifier, queries), + } => Self::fmt_set_operation(f, "INTERSECT", set_quantifier, column_match, queries), PipeOperator::Except { set_quantifier, + column_match, queries, - } => Self::fmt_set_operation(f, "EXCEPT", set_quantifier, queries), + } => Self::fmt_set_operation(f, "EXCEPT", set_quantifier, column_match, queries), PipeOperator::Call { function, alias } => { write!(f, "CALL {function}")?; Self::fmt_optional_alias(f, alias) @@ -3464,6 +3565,7 @@ impl PipeOperator { f: &mut fmt::Formatter<'_>, operation: &str, set_quantifier: &SetQuantifier, + column_match: &Option, queries: &[Query], ) -> fmt::Result { write!(f, "{operation}")?; @@ -3473,6 +3575,9 @@ impl PipeOperator { write!(f, " {set_quantifier}")?; } } + if let Some(column_match) = column_match { + write!(f, " {column_match}")?; + } write!(f, " ")?; let parenthesized_queries: Vec = queries.iter().map(|query| format!("({query})")).collect(); diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 581cd4bef4..41a5fe6d10 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -224,6 +224,8 @@ impl Spanned for SetExpr { SetExpr::SetOperation { op: _, set_quantifier: _, + mode: _, + column_match: _, left, right, } => left.span().union(&right.span()), diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 31563f4212..efff501b7e 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -12940,7 +12940,7 @@ impl<'a> Parser<'a> { ) -> Result { let quantifier = self.parse_set_quantifier(&Some(SetOperator::Intersect)); match quantifier { - SetQuantifier::Distinct | SetQuantifier::DistinctByName => Ok(quantifier), + SetQuantifier::Distinct => Ok(quantifier), _ => Err(ParserError::ParserError(format!( "{operator_name} pipe operator requires DISTINCT modifier", ))), @@ -12960,11 +12960,38 @@ impl<'a> Parser<'a> { /// Optionally parses an alias for a select list item fn maybe_parse_select_item_alias(&mut self) -> Result, ParserError> { fn validator(explicit: bool, kw: &Keyword, parser: &mut Parser) -> bool { + // A set-operation column-propagation mode keyword (`INNER`/`LEFT`/ + // `FULL` [`OUTER`]) directly before a set operator opens the next set + // operation -- it must not be captured as the preceding item's alias. + if !explicit && parser.peek_set_operation_mode_after(kw) { + return false; + } parser.dialect.is_select_item_alias(explicit, kw, parser) } self.parse_optional_alias_inner(None, validator) } + /// Given a just-consumed keyword `kw`, returns true if it is a set-operation + /// column-propagation mode prefix (`INNER`, or `FULL`/`LEFT` optionally + /// followed by `OUTER`) that is immediately followed by a set operator. + fn peek_set_operation_mode_after(&self, kw: &Keyword) -> bool { + let ahead = match kw { + Keyword::INNER => 0, + Keyword::LEFT | Keyword::FULL => usize::from( + matches!(self.peek_token().token, Token::Word(w) if w.keyword == Keyword::OUTER), + ), + _ => return false, + }; + matches!( + self.peek_nth_token(ahead).token, + Token::Word(w) + if matches!( + w.keyword, + Keyword::UNION | Keyword::INTERSECT | Keyword::EXCEPT | Keyword::MINUS + ) + ) + } + /// Optionally parses an alias for a table like in `... FROM generate_series(1, 10) AS t (col)`. /// In this case, the alias is allowed to optionally name the columns in the table, in /// addition to the table itself. @@ -14379,26 +14406,32 @@ impl<'a> Parser<'a> { } Keyword::UNION => { let set_quantifier = self.parse_set_quantifier(&Some(SetOperator::Union)); + let column_match = self.parse_set_operation_column_match()?; let queries = self.parse_pipe_operator_queries()?; pipe_operators.push(PipeOperator::Union { set_quantifier, + column_match, queries, }); } Keyword::INTERSECT => { let set_quantifier = self.parse_distinct_required_set_quantifier("INTERSECT")?; + let column_match = self.parse_set_operation_column_match()?; let queries = self.parse_pipe_operator_queries()?; pipe_operators.push(PipeOperator::Intersect { set_quantifier, + column_match, queries, }); } Keyword::EXCEPT => { let set_quantifier = self.parse_distinct_required_set_quantifier("EXCEPT")?; + let column_match = self.parse_set_operation_column_match()?; let queries = self.parse_pipe_operator_queries()?; pipe_operators.push(PipeOperator::Except { set_quantifier, + column_match, queries, }); } @@ -14739,8 +14772,10 @@ impl<'a> Parser<'a> { precedence: u8, ) -> Result, ParserError> { loop { - // The query can be optionally followed by a set operator: - let op = self.parse_set_operator(&self.peek_token().token); + // The query can be optionally followed by a BigQuery column-matching + // mode prefix and then a set operator: + let (mode, mode_tokens) = self.peek_set_operation_mode(); + let op = self.parse_set_operator(&self.peek_nth_token(mode_tokens).token); let next_precedence = match op { // UNION and EXCEPT have the same binding power and evaluate left-to-right Some(SetOperator::Union) | Some(SetOperator::Except) | Some(SetOperator::Minus) => { @@ -14754,12 +14789,18 @@ impl<'a> Parser<'a> { if precedence >= next_precedence { break; } + for _ in 0..mode_tokens { + self.next_token(); // skip past the mode prefix (and OUTER) + } self.next_token(); // skip past the set operator let set_quantifier = self.parse_set_quantifier(&op); + let column_match = self.parse_set_operation_column_match()?; expr = SetExpr::SetOperation { left: Box::new(expr), op: op.unwrap(), set_quantifier, + mode, + column_match, right: self.parse_query_body(next_precedence)?, }; } @@ -14778,7 +14819,7 @@ impl<'a> Parser<'a> { } } - /// Parse a set quantifier (e.g., `ALL`, `DISTINCT BY NAME`) for the given set operator. + /// Parse a set quantifier (`ALL` / `DISTINCT`) for the given set operator. pub fn parse_set_quantifier(&mut self, op: &Option) -> SetQuantifier { match op { Some( @@ -14787,16 +14828,8 @@ impl<'a> Parser<'a> { | SetOperator::Union | SetOperator::Minus, ) => { - if self.parse_keywords(&[Keyword::DISTINCT, Keyword::BY, Keyword::NAME]) { - SetQuantifier::DistinctByName - } else if self.parse_keywords(&[Keyword::BY, Keyword::NAME]) { - SetQuantifier::ByName - } else if self.parse_keyword(Keyword::ALL) { - if self.parse_keywords(&[Keyword::BY, Keyword::NAME]) { - SetQuantifier::AllByName - } else { - SetQuantifier::All - } + if self.parse_keyword(Keyword::ALL) { + SetQuantifier::All } else if self.parse_keyword(Keyword::DISTINCT) { SetQuantifier::Distinct } else { @@ -14807,6 +14840,84 @@ impl<'a> Parser<'a> { } } + /// Peek for a GoogleSQL column-propagation mode prefix (`INNER`, or + /// `FULL`/`LEFT` with an optional `OUTER`) and return it with the token count + /// it occupies. Only recognized when a set operator follows (otherwise + /// `INNER`/`LEFT`/`FULL` begin a JOIN); consumes nothing. + fn peek_set_operation_mode(&mut self) -> (Option, usize) { + let (base, has_outer) = match self.peek_token().token { + Token::Word(w) if w.keyword == Keyword::INNER => (SetOperationMode::Inner, false), + Token::Word(w) if w.keyword == Keyword::LEFT || w.keyword == Keyword::FULL => { + let outer = matches!(self.peek_nth_token(1).token, Token::Word(o) if o.keyword == Keyword::OUTER); + let base = if w.keyword == Keyword::LEFT { + if outer { + SetOperationMode::LeftOuter + } else { + SetOperationMode::Left + } + } else if outer { + SetOperationMode::FullOuter + } else { + SetOperationMode::Full + }; + (base, outer) + } + _ => return (None, 0), + }; + let tokens = if has_outer { 2 } else { 1 }; + // Only treat this as a set-op mode if a set operator follows. + if self + .parse_set_operator(&self.peek_nth_token(tokens).token) + .is_some() + { + (Some(base), tokens) + } else { + (None, 0) + } + } + + /// Parse the optional column-matching clause that follows the set-operation + /// quantifier: `BY NAME [ON (cols)]` or `[STRICT] CORRESPONDING [BY (cols)]`. + fn parse_set_operation_column_match( + &mut self, + ) -> Result, ParserError> { + let parse_columns = |parser: &mut Self| -> Result, ParserError> { + parser.expect_token(&Token::LParen)?; + let columns = parser.parse_comma_separated(Parser::parse_identifier)?; + parser.expect_token(&Token::RParen)?; + Ok(columns) + }; + if self.parse_keywords(&[Keyword::BY, Keyword::NAME]) { + let columns = self + .parse_keyword(Keyword::ON) + .then(|| parse_columns(self)) + .transpose()?; + return Ok(Some(SetOperationColumnMatch { + kind: SetOperationColumnMatchKind::ByName, + strict: false, + columns, + })); + } + // `STRICT` is only consumed as part of `STRICT CORRESPONDING`. + let strict = matches!(self.peek_token().token, Token::Word(w) if w.keyword == Keyword::STRICT) + && matches!(self.peek_nth_token(1).token, Token::Word(w) if w.keyword == Keyword::CORRESPONDING); + if strict { + self.next_token(); + } + if self.parse_keyword(Keyword::CORRESPONDING) { + let columns = self + .parse_keyword(Keyword::BY) + .then(|| parse_columns(self)) + .transpose()?; + return Ok(Some(SetOperationColumnMatch { + kind: SetOperationColumnMatchKind::Corresponding, + strict, + columns, + })); + } + Ok(None) + } + /// Parse a restricted `SELECT` statement (no CTEs / `UNION` / `ORDER BY`) pub fn parse_select(&mut self) -> Result { let mut from_first = None; diff --git a/tests/sqlparser_bigquery.rs b/tests/sqlparser_bigquery.rs index 6b14aeebc1..3cf1e6a2ca 100644 --- a/tests/sqlparser_bigquery.rs +++ b/tests/sqlparser_bigquery.rs @@ -256,6 +256,105 @@ fn parse_big_query_non_reserved_column_alias() { bigquery().verified_stmt(sql); } +#[test] +fn parse_set_operation_by_name() { + fn set_op( + stmt: Statement, + ) -> ( + SetOperator, + SetQuantifier, + Option, + Option, + ) { + let Statement::Query(query) = stmt else { + panic!("expected a query"); + }; + match *query.body { + SetExpr::SetOperation { + op, + set_quantifier, + mode, + column_match, + .. + } => (op, set_quantifier, mode, column_match), + other => panic!("expected a set operation, got {other:?}"), + } + } + + // Bare `BY NAME`. + let (op, q, mode, cm) = + set_op(bigquery().verified_stmt("SELECT 1 AS a UNION ALL BY NAME SELECT 1 AS a")); + assert_eq!(op, SetOperator::Union); + assert_eq!(q, SetQuantifier::All); + assert_eq!(mode, None); + assert_eq!( + cm, + Some(SetOperationColumnMatch { + kind: SetOperationColumnMatchKind::ByName, + strict: false, + columns: None, + }) + ); + + // Prefix mode before the operator. + let (_, q, mode, cm) = set_op( + bigquery() + .verified_stmt("SELECT 1 AS b, 2 AS a INNER UNION ALL BY NAME SELECT 3 AS b, 4 AS a"), + ); + assert_eq!(q, SetQuantifier::All); + assert_eq!(mode, Some(SetOperationMode::Inner)); + assert_eq!(cm.unwrap().kind, SetOperationColumnMatchKind::ByName); + + // Prefix mode with `OUTER` plus an explicit `ON (...)` list. + let (_, _, mode, cm) = set_op(bigquery().verified_stmt( + "SELECT 1 AS a, 2 AS b FULL OUTER UNION ALL BY NAME ON (a, b) SELECT 3 AS b, 4 AS a", + )); + assert_eq!(mode, Some(SetOperationMode::FullOuter)); + assert_eq!( + cm, + Some(SetOperationColumnMatch { + kind: SetOperationColumnMatchKind::ByName, + strict: false, + columns: Some(vec![Ident::new("a"), Ident::new("b")]), + }) + ); + + // `CORRESPONDING` is a distinct spelling from `BY NAME` and round-trips. + let (_, _, mode, cm) = set_op( + bigquery().verified_stmt("SELECT 1 AS a LEFT UNION ALL CORRESPONDING BY (a) SELECT 4 AS a"), + ); + assert_eq!(mode, Some(SetOperationMode::Left)); + assert_eq!( + cm, + Some(SetOperationColumnMatch { + kind: SetOperationColumnMatchKind::Corresponding, + strict: false, + columns: Some(vec![Ident::new("a")]), + }) + ); + + // `STRICT CORRESPONDING` round-trips with the strict flag set. + let (_, _, mode, cm) = set_op( + bigquery().verified_stmt("SELECT 1 AS a UNION ALL STRICT CORRESPONDING SELECT 2 AS a"), + ); + assert_eq!(mode, None); + assert_eq!( + cm, + Some(SetOperationColumnMatch { + kind: SetOperationColumnMatchKind::Corresponding, + strict: true, + columns: None, + }) + ); + + // A prefix mode after an unaliased select item is a mode, not that item's + // alias, so it must not be swallowed. + let (_, _, mode, cm) = + set_op(bigquery().verified_stmt("SELECT 1 INNER UNION ALL BY NAME SELECT 2")); + assert_eq!(mode, Some(SetOperationMode::Inner)); + assert_eq!(cm.unwrap().kind, SetOperationColumnMatchKind::ByName); +} + #[test] fn parse_at_at_identifier() { bigquery().verified_stmt("SELECT @@error.stack_trace, @@error.message"); diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index edcd7fd99d..45a73fc6f8 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -13904,6 +13904,8 @@ fn tests_select_values_without_parens_and_set_op() { SetExpr::SetOperation { op, set_quantifier: _, + mode: _, + column_match: _, left, right, } => { diff --git a/tests/sqlparser_duckdb.rs b/tests/sqlparser_duckdb.rs index df62685808..0fce68a041 100644 --- a/tests/sqlparser_duckdb.rs +++ b/tests/sqlparser_duckdb.rs @@ -261,13 +261,19 @@ fn test_select_union_by_name() { let q3 = "SELECT * FROM capitals UNION DISTINCT BY NAME SELECT * FROM weather"; for (ast, expected_quantifier) in &[ - (duckdb().verified_query(q1), SetQuantifier::ByName), - (duckdb().verified_query(q2), SetQuantifier::AllByName), - (duckdb().verified_query(q3), SetQuantifier::DistinctByName), + (duckdb().verified_query(q1), SetQuantifier::None), + (duckdb().verified_query(q2), SetQuantifier::All), + (duckdb().verified_query(q3), SetQuantifier::Distinct), ] { let expected = Box::::new(SetExpr::SetOperation { op: SetOperator::Union, set_quantifier: *expected_quantifier, + mode: None, + column_match: Some(SetOperationColumnMatch { + kind: SetOperationColumnMatchKind::ByName, + strict: false, + columns: None, + }), left: Box::::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), optimizer_hints: vec![], diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index d912136839..0a6a8f71d3 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -3403,6 +3403,8 @@ fn parse_array_subquery_expr() { body: Box::new(SetExpr::SetOperation { op: SetOperator::Union, set_quantifier: SetQuantifier::None, + mode: None, + column_match: None, left: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), optimizer_hints: vec![],