diff --git a/src/ast/query.rs b/src/ast/query.rs index bbdd7540a..b7d63204b 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -2655,6 +2655,8 @@ pub struct Join { /// ClickHouse supports the optional `GLOBAL` keyword before the join operator. /// See [ClickHouse](https://clickhouse.com/docs/en/sql-reference/statements/select/join) pub global: bool, + /// Snowflake `DIRECTED` join-order directive. + pub directed: bool, /// The join operator and its constraint (INNER/LEFT/RIGHT/CROSS/ASOF/etc.). pub join_operator: JoinOperator, } @@ -2685,52 +2687,53 @@ impl fmt::Display for Join { if self.global { write!(f, "GLOBAL ")?; } + let directed = if self.directed { "DIRECTED " } else { "" }; match &self.join_operator { JoinOperator::Join(constraint) => f.write_fmt(format_args!( - "{}JOIN {}{}", + "{}{directed}JOIN {}{}", prefix(constraint), self.relation, suffix(constraint) )), JoinOperator::Inner(constraint) => f.write_fmt(format_args!( - "{}INNER JOIN {}{}", + "{}INNER {directed}JOIN {}{}", prefix(constraint), self.relation, suffix(constraint) )), JoinOperator::Left(constraint) => f.write_fmt(format_args!( - "{}LEFT JOIN {}{}", + "{}LEFT {directed}JOIN {}{}", prefix(constraint), self.relation, suffix(constraint) )), JoinOperator::LeftOuter(constraint) => f.write_fmt(format_args!( - "{}LEFT OUTER JOIN {}{}", + "{}LEFT OUTER {directed}JOIN {}{}", prefix(constraint), self.relation, suffix(constraint) )), JoinOperator::Right(constraint) => f.write_fmt(format_args!( - "{}RIGHT JOIN {}{}", + "{}RIGHT {directed}JOIN {}{}", prefix(constraint), self.relation, suffix(constraint) )), JoinOperator::RightOuter(constraint) => f.write_fmt(format_args!( - "{}RIGHT OUTER JOIN {}{}", + "{}RIGHT OUTER {directed}JOIN {}{}", prefix(constraint), self.relation, suffix(constraint) )), JoinOperator::FullOuter(constraint) => f.write_fmt(format_args!( - "{}FULL JOIN {}{}", + "{}FULL {directed}JOIN {}{}", prefix(constraint), self.relation, suffix(constraint) )), JoinOperator::CrossJoin(constraint) => f.write_fmt(format_args!( - "CROSS JOIN {}{}", + "CROSS {directed}JOIN {}{}", self.relation, suffix(constraint) )), diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 8afa2f65a..58ad2b41a 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -2220,7 +2220,8 @@ impl Spanned for Join { fn span(&self) -> Span { let Join { relation, - global: _, // bool + global: _, // bool + directed: _, // bool join_operator, } = self; diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 5599a7779..02309cf7c 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -433,6 +433,11 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect supports Snowflake-style `DIRECTED` joins. + fn supports_directed_join(&self) -> bool { + false + } + /// Returns true if the dialect supports CONNECT BY. fn supports_connect_by(&self) -> bool { false diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index ce2a8c440..098399800 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -483,6 +483,10 @@ impl Dialect for SnowflakeDialect { false } + fn supports_directed_join(&self) -> bool { + true + } + fn is_reserved_for_identifier(&self, kw: Keyword) -> bool { // Unreserve some keywords that Snowflake accepts as identifiers // See: https://docs.snowflake.com/en/sql-reference/reserved-keywords @@ -589,6 +593,8 @@ impl Dialect for SnowflakeDialect { Keyword::GLOBAL if parser.peek_keyword(Keyword::FULL) => false, + Keyword::DIRECTED if parser.peek_keyword(Keyword::JOIN) => false, + // Reserved keywords by the Snowflake dialect, which seem to be less strictive // than what is listed in `keywords::RESERVED_FOR_TABLE_ALIAS`. The following // keywords were tested with the this statement: `SELECT .* FROM tbl `. diff --git a/src/keywords.rs b/src/keywords.rs index 6e847ee1b..5c141bf4b 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -336,6 +336,7 @@ define_keywords!( DETAIL, DETERMINISTIC, DIMENSIONS, + DIRECTED, DIRECTORY, DISABLE, DISCARD, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index bfc9c2cf3..269607296 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -15784,9 +15784,11 @@ impl<'a> Parser<'a> { loop { let global = self.parse_keyword(Keyword::GLOBAL); let join = if self.parse_keyword(Keyword::CROSS) { + let directed = + self.dialect.supports_directed_join() && self.parse_keyword(Keyword::DIRECTED); let join_operator = if self.parse_keyword(Keyword::JOIN) { JoinOperator::CrossJoin(JoinConstraint::None) - } else if self.parse_keyword(Keyword::APPLY) { + } else if !directed && self.parse_keyword(Keyword::APPLY) { // MSSQL extension, similar to CROSS JOIN LATERAL JoinOperator::CrossApply } else { @@ -15804,6 +15806,7 @@ impl<'a> Parser<'a> { Join { relation, global, + directed, join_operator, } } else if self.parse_keyword(Keyword::OUTER) { @@ -15812,6 +15815,7 @@ impl<'a> Parser<'a> { Join { relation: self.parse_table_factor()?, global, + directed: false, join_operator: JoinOperator::OuterApply, } } else if self.parse_keyword(Keyword::ASOF) { @@ -15822,6 +15826,7 @@ impl<'a> Parser<'a> { Join { relation, global, + directed: false, join_operator: JoinOperator::AsOf { match_condition, constraint: self.parse_join_constraint(false)?, @@ -15834,6 +15839,7 @@ impl<'a> Parser<'a> { Join { relation: self.parse_table_factor()?, global, + directed: false, join_operator: JoinOperator::InnerArrayJoin, } } else if self.dialect.supports_array_join_syntax() @@ -15843,6 +15849,7 @@ impl<'a> Parser<'a> { Join { relation: self.parse_table_factor()?, global, + directed: false, join_operator: JoinOperator::LeftArrayJoin, } } else if self.dialect.supports_array_join_syntax() @@ -15852,10 +15859,12 @@ impl<'a> Parser<'a> { Join { relation: self.parse_table_factor()?, global, + directed: false, join_operator: JoinOperator::ArrayJoin, } } else { let natural = self.parse_keyword(Keyword::NATURAL); + let mut directed = false; let peek_keyword = if let Token::Word(w) = &self.peek_token_ref().token { w.keyword } else { @@ -15865,6 +15874,10 @@ impl<'a> Parser<'a> { let join_operator_type = match peek_keyword { Keyword::INNER | Keyword::JOIN => { let inner = self.parse_keyword(Keyword::INNER); // [ INNER ] + if inner { + directed = self.dialect.supports_directed_join() + && self.parse_keyword(Keyword::DIRECTED); + } self.expect_keyword_is(Keyword::JOIN)?; if inner { JoinOperator::Inner @@ -15879,10 +15892,13 @@ impl<'a> Parser<'a> { Keyword::OUTER, Keyword::SEMI, Keyword::ANTI, + Keyword::DIRECTED, Keyword::JOIN, ]); match join_type { Some(Keyword::OUTER) => { + directed = self.dialect.supports_directed_join() + && self.parse_keyword(Keyword::DIRECTED); self.expect_keyword_is(Keyword::JOIN)?; if is_left { JoinOperator::LeftOuter @@ -15906,6 +15922,15 @@ impl<'a> Parser<'a> { JoinOperator::RightAnti } } + Some(Keyword::DIRECTED) if self.dialect.supports_directed_join() => { + directed = true; + self.expect_keyword_is(Keyword::JOIN)?; + if is_left { + JoinOperator::Left + } else { + JoinOperator::Right + } + } Some(Keyword::JOIN) => { if is_left { JoinOperator::Left @@ -15915,7 +15940,7 @@ impl<'a> Parser<'a> { } _ => { return Err(ParserError::ParserError(format!( - "expected OUTER, SEMI, ANTI or JOIN after {kw:?}" + "expected OUTER, SEMI, ANTI, DIRECTED or JOIN after {kw:?}" ))) } } @@ -15933,6 +15958,8 @@ impl<'a> Parser<'a> { Keyword::FULL => { let _ = self.next_token(); // consume FULL let _ = self.parse_keyword(Keyword::OUTER); // [ OUTER ] + directed = self.dialect.supports_directed_join() + && self.parse_keyword(Keyword::DIRECTED); self.expect_keyword_is(Keyword::JOIN)?; JoinOperator::FullOuter } @@ -15967,6 +15994,7 @@ impl<'a> Parser<'a> { Join { relation, global, + directed, join_operator: join_operator_type(join_constraint), } }; diff --git a/src/test_utils.rs b/src/test_utils.rs index c4d1d0db2..7d1714267 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -432,6 +432,7 @@ pub fn join(relation: TableFactor) -> Join { Join { relation, global: false, + directed: false, join_operator: JoinOperator::Join(JoinConstraint::Natural), } } diff --git a/tests/sqlparser_bigquery.rs b/tests/sqlparser_bigquery.rs index afb619082..827d72761 100644 --- a/tests/sqlparser_bigquery.rs +++ b/tests/sqlparser_bigquery.rs @@ -1786,6 +1786,7 @@ fn parse_join_constraint_unnest_alias() { with_ordinality: false, }, global: false, + directed: false, join_operator: JoinOperator::Join(JoinConstraint::On(Expr::BinaryOp { left: Box::new(Expr::Identifier("c1".into())), op: BinaryOperator::Eq, diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 9fd1234a1..e07c0e397 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -7326,6 +7326,7 @@ fn parse_implicit_join() { joins: vec![Join { relation: table_from_name(ObjectName::from(vec!["t1b".into()])), global: false, + directed: false, join_operator: JoinOperator::Join(JoinConstraint::Natural), }], }, @@ -7334,6 +7335,7 @@ fn parse_implicit_join() { joins: vec![Join { relation: table_from_name(ObjectName::from(vec!["t2b".into()])), global: false, + directed: false, join_operator: JoinOperator::Join(JoinConstraint::Natural), }], }, @@ -7350,6 +7352,7 @@ fn parse_cross_join() { Join { relation: table_from_name(ObjectName::from(vec![Ident::new("t2")])), global: false, + directed: false, join_operator: JoinOperator::CrossJoin(JoinConstraint::None), }, only(only(select.from).joins), @@ -7362,6 +7365,7 @@ fn parse_cross_join_constraint() { Join { relation: table_from_name(ObjectName::from(vec![Ident::new("t2")])), global: false, + directed: false, join_operator: JoinOperator::CrossJoin(constraint), } } @@ -7411,6 +7415,7 @@ fn parse_joins_on() { index_hints: vec![], }, global, + directed: false, join_operator: f(JoinConstraint::On(Expr::BinaryOp { left: Box::new(Expr::Identifier("c1".into())), op: BinaryOperator::Eq, @@ -7553,6 +7558,7 @@ fn parse_joins_using() { index_hints: vec![], }, global: false, + directed: false, join_operator: f(JoinConstraint::Using(vec![ObjectName::from(vec![ "c1".into() ])])), @@ -7648,6 +7654,7 @@ fn parse_natural_join() { index_hints: vec![], }, global: false, + directed: false, join_operator: f(JoinConstraint::Natural), } } @@ -7953,6 +7960,7 @@ fn parse_derived_tables() { joins: vec![Join { relation: table_from_name(ObjectName::from(vec!["t2".into()])), global: false, + directed: false, join_operator: JoinOperator::Join(JoinConstraint::Natural), }], }), @@ -9055,6 +9063,7 @@ fn lateral_function() { alias: None, }, global: false, + directed: false, join_operator: JoinOperator::Left(JoinConstraint::None), }], }], diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index 1e053da78..ceac1b60a 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -522,6 +522,7 @@ fn parse_mssql_openjson() { alias: table_alias(true, "B") }, global: false, + directed: false, join_operator: JoinOperator::CrossApply }] }], @@ -576,6 +577,7 @@ fn parse_mssql_openjson() { alias: table_alias(true, "B") }, global: false, + directed: false, join_operator: JoinOperator::CrossApply }] }], @@ -630,6 +632,7 @@ fn parse_mssql_openjson() { alias: table_alias(true, "B") }, global: false, + directed: false, join_operator: JoinOperator::CrossApply }] }], @@ -664,6 +667,7 @@ fn parse_mssql_openjson() { alias: table_alias(true, "B") }, global: false, + directed: false, join_operator: JoinOperator::CrossApply }] }], @@ -698,6 +702,7 @@ fn parse_mssql_openjson() { alias: table_alias(true, "B") }, global: false, + directed: false, join_operator: JoinOperator::CrossApply }] }], diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index 5c4f4a8c3..5eb8a5a5d 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -2756,6 +2756,7 @@ fn parse_update_with_joins() { index_hints: vec![], }, global: false, + directed: false, join_operator: JoinOperator::Join(JoinConstraint::On(Expr::BinaryOp { left: Box::new(Expr::CompoundIdentifier(vec![ Ident::new("o"), diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 86315b1ef..9a51264e0 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -5712,6 +5712,7 @@ fn parse_join_constraint_unnest_alias() { with_ordinality: false, }, global: false, + directed: false, join_operator: JoinOperator::Join(JoinConstraint::On(Expr::BinaryOp { left: Box::new(Expr::Identifier("c1".into())), op: BinaryOperator::Eq, diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 4160fb0ae..c4f91e7e9 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -3230,6 +3230,7 @@ fn asof_joins() { joins: vec![Join { relation: table_with_alias("quotes_unixtime", true, "qu"), global: false, + directed: false, join_operator: JoinOperator::AsOf { match_condition: Expr::BinaryOp { left: Box::new(Expr::CompoundIdentifier(vec![ @@ -3295,6 +3296,36 @@ fn asof_joins() { )); } +#[test] +fn directed_joins() { + for sql in [ + "SELECT * FROM t1 INNER DIRECTED JOIN t2 ON t1.id = t2.id", + "SELECT * FROM t1 LEFT DIRECTED JOIN t2 USING(id)", + "SELECT * FROM t1 LEFT OUTER DIRECTED JOIN t2 ON t1.id = t2.id", + "SELECT * FROM t1 RIGHT DIRECTED JOIN t2 ON t1.id = t2.id", + "SELECT * FROM t1 RIGHT OUTER DIRECTED JOIN t2 ON t1.id = t2.id", + "SELECT * FROM t1 FULL DIRECTED JOIN t2 ON t1.id = t2.id", + "SELECT * FROM t1 CROSS DIRECTED JOIN t2", + "SELECT * FROM t1 NATURAL INNER DIRECTED JOIN t2", + ] { + let query = snowflake().verified_only_select(sql); + assert!(query.from[0].joins[0].directed, "{sql}"); + } + + assert!( + snowflake() + .parse_sql_statements("SELECT * FROM t1 DIRECTED JOIN t2") + .is_err(), + "Snowflake requires an explicit join type before DIRECTED" + ); + assert!( + TestedDialects::new(vec![Box::new(GenericDialect {})]) + .parse_sql_statements("SELECT * FROM t1 INNER DIRECTED JOIN t2") + .is_err(), + "DIRECTED must remain Snowflake-dialect specific" + ); +} + #[test] fn test_parse_position() { snowflake().verified_query("SELECT position('an', 'banana', 1)"); @@ -4506,6 +4537,7 @@ fn test_nested_join_without_parentheses() { index_hints: vec![], }, global: false, + directed: false, join_operator: JoinOperator::Inner(JoinConstraint::On(Expr::BinaryOp { left: Box::new(Expr::CompoundIdentifier(vec![ Ident::new("p".to_string()), @@ -4522,6 +4554,7 @@ fn test_nested_join_without_parentheses() { alias: None }, global: false, + directed: false, join_operator: JoinOperator::Inner(JoinConstraint::On(Expr::BinaryOp { left: Box::new(Expr::CompoundIdentifier(vec![ Ident::new("c".to_string()), @@ -4573,6 +4606,7 @@ fn test_nested_join_without_parentheses() { index_hints: vec![], }, global: false, + directed: false, join_operator: JoinOperator::Join(JoinConstraint::On(Expr::BinaryOp { left: Box::new(Expr::CompoundIdentifier(vec![ Ident::new("p".to_string()), @@ -4589,6 +4623,7 @@ fn test_nested_join_without_parentheses() { alias: None }, global: false, + directed: false, join_operator: JoinOperator::Join(JoinConstraint::On(Expr::BinaryOp { left: Box::new(Expr::CompoundIdentifier(vec![ Ident::new("c".to_string()), @@ -4640,6 +4675,7 @@ fn test_nested_join_without_parentheses() { index_hints: vec![], }, global: false, + directed: false, join_operator: JoinOperator::Left(JoinConstraint::On(Expr::BinaryOp { left: Box::new(Expr::CompoundIdentifier(vec![ Ident::new("p".to_string()), @@ -4656,6 +4692,7 @@ fn test_nested_join_without_parentheses() { alias: None }, global: false, + directed: false, join_operator: JoinOperator::Left(JoinConstraint::On(Expr::BinaryOp { left: Box::new(Expr::CompoundIdentifier(vec![ Ident::new("c".to_string()), @@ -4707,6 +4744,7 @@ fn test_nested_join_without_parentheses() { index_hints: vec![], }, global: false, + directed: false, join_operator: JoinOperator::Right(JoinConstraint::On(Expr::BinaryOp { left: Box::new(Expr::CompoundIdentifier(vec![ Ident::new("p".to_string()), @@ -4723,6 +4761,7 @@ fn test_nested_join_without_parentheses() { alias: None }, global: false, + directed: false, join_operator: JoinOperator::Right(JoinConstraint::On(Expr::BinaryOp { left: Box::new(Expr::CompoundIdentifier(vec![ Ident::new("c".to_string()), @@ -4774,6 +4813,7 @@ fn test_nested_join_without_parentheses() { index_hints: vec![], }, global: false, + directed: false, join_operator: JoinOperator::FullOuter(JoinConstraint::On( Expr::BinaryOp { left: Box::new(Expr::CompoundIdentifier(vec![ @@ -4792,6 +4832,7 @@ fn test_nested_join_without_parentheses() { alias: None }, global: false, + directed: false, join_operator: JoinOperator::FullOuter(JoinConstraint::On(Expr::BinaryOp { left: Box::new(Expr::CompoundIdentifier(vec![ Ident::new("c".to_string()),