From abb2cf76757b53afb4df843084e7e76e147d8f5d Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Fri, 30 Jan 2026 09:20:20 +0100 Subject: [PATCH 01/16] [Oracle] Optional START WITH for CONNECT BY --- src/ast/query.rs | 19 +++++++++++--- src/ast/spans.rs | 3 ++- src/keywords.rs | 1 + src/parser/mod.rs | 20 +++++++++----- tests/sqlparser_common.rs | 55 ++++++++++++++++++++++++++++++++++++--- 5 files changed, 82 insertions(+), 16 deletions(-) diff --git a/src/ast/query.rs b/src/ast/query.rs index bb2d889f28..44ecf1e9b1 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -1204,23 +1204,34 @@ impl fmt::Display for TableWithJoins { /// Joins a table to itself to process hierarchical data in the table. /// /// See . +/// See #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] pub struct ConnectBy { /// START WITH - pub condition: Expr, + pub condition: Option, /// CONNECT BY pub relationships: Vec, + /// [CONNECT BY] NOCYCLE + pub nocycle: bool, } impl fmt::Display for ConnectBy { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let Self { + condition, + relationships, + nocycle + } = self; + if let Some(condition) = condition { + write!(f, "START WITH {condition} ")?; + } write!( f, - "START WITH {condition} CONNECT BY {relationships}", - condition = self.condition, - relationships = display_comma_separated(&self.relationships) + "CONNECT BY {nocycle}{relationships}", + nocycle = if *nocycle { "NOCYCLE " } else { "" }, + relationships = display_comma_separated(relationships) ) } } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index ffc96ed3e7..c8dce8f90c 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -2286,10 +2286,11 @@ impl Spanned for ConnectBy { let ConnectBy { condition, relationships, + nocycle: _ } = self; union_spans( - core::iter::once(condition.span()).chain(relationships.iter().map(|item| item.span())), + condition.iter().map(Spanned::span).chain(relationships.iter().map(|item| item.span())), ) } } diff --git a/src/keywords.rs b/src/keywords.rs index 2e26bda8ec..f84f4d213a 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -678,6 +678,7 @@ define_keywords!( NOCOMPRESS, NOCREATEDB, NOCREATEROLE, + NOCYCLE, NOINHERIT, NOLOGIN, NONE, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index bddafd1e3c..e63159e50b 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -14280,25 +14280,31 @@ impl<'a> Parser<'a> { /// Parse a `CONNECT BY` clause (Oracle-style hierarchical query support). pub fn parse_connect_by(&mut self) -> Result { - let (condition, relationships) = if self.parse_keywords(&[Keyword::CONNECT, Keyword::BY]) { - let relationships = self.with_state(ParserState::ConnectBy, |parser| { - parser.parse_comma_separated(Parser::parse_expr) + let (condition, relationships, nocycle) = if self.parse_keywords(&[Keyword::CONNECT, Keyword::BY]) { + let (relationships, nocycle) = self.with_state(ParserState::ConnectBy, |parser| { + let nocycle = parser.parse_keyword(Keyword::NOCYCLE); + parser.parse_comma_separated(Parser::parse_expr).map(|exprs| (exprs, nocycle)) })?; - self.expect_keywords(&[Keyword::START, Keyword::WITH])?; - let condition = self.parse_expr()?; - (condition, relationships) + let condition = if self.parse_keywords(&[Keyword::START, Keyword::WITH]) { + Some(self.parse_expr()?) + } else { + None + }; + (condition, relationships, nocycle) } else { self.expect_keywords(&[Keyword::START, Keyword::WITH])?; let condition = self.parse_expr()?; self.expect_keywords(&[Keyword::CONNECT, Keyword::BY])?; + let nocycle = self.parse_keyword(Keyword::NOCYCLE); let relationships = self.with_state(ParserState::ConnectBy, |parser| { parser.parse_comma_separated(Parser::parse_expr) })?; - (condition, relationships) + (Some(condition), relationships, nocycle) }; Ok(ConnectBy { condition, relationships, + nocycle }) } diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index b442ec428a..7fc2bfe93e 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -12675,13 +12675,13 @@ fn parse_connect_by() { window_before_qualify: false, value_table_mode: None, connect_by: Some(ConnectBy { - condition: Expr::BinaryOp { + condition: Some(Expr::BinaryOp { left: Box::new(Expr::Identifier(Ident::new("title"))), op: BinaryOperator::Eq, right: Box::new(Expr::Value( Value::SingleQuotedString("president".to_owned()).with_empty_span(), )), - }, + }), relationships: vec![Expr::BinaryOp { left: Box::new(Expr::Identifier(Ident::new("manager_id"))), op: BinaryOperator::Eq, @@ -12689,6 +12689,7 @@ fn parse_connect_by() { "employee_id", ))))), }], + nocycle: false, }), flavor: SelectFlavor::Standard, }; @@ -12763,13 +12764,13 @@ fn parse_connect_by() { window_before_qualify: false, value_table_mode: None, connect_by: Some(ConnectBy { - condition: Expr::BinaryOp { + condition: Some(Expr::BinaryOp { left: Box::new(Expr::Identifier(Ident::new("title"))), op: BinaryOperator::Eq, right: Box::new(Expr::Value( (Value::SingleQuotedString("president".to_owned(),)).with_empty_span() )), - }, + }), relationships: vec![Expr::BinaryOp { left: Box::new(Expr::Identifier(Ident::new("manager_id"))), op: BinaryOperator::Eq, @@ -12777,6 +12778,7 @@ fn parse_connect_by() { "employee_id", ))))), }], + nocycle: false, }), flavor: SelectFlavor::Standard, } @@ -12803,6 +12805,51 @@ fn parse_connect_by() { "prior" )))] ); + + // no START WITH and NOCYCLE + let connect_by_5 = "SELECT child, parent FROM t CONNECT BY NOCYCLE parent = PRIOR child"; + assert_eq!( + all_dialects_where(|d| d.supports_connect_by()).verified_only_select(connect_by_5), + Select { + select_token: AttachedToken::empty(), + optimizer_hint: None, + distinct: None, + top: None, + top_before_distinct: false, + projection: vec![ + SelectItem::UnnamedExpr(Expr::Identifier(Ident::new("child"))), + SelectItem::UnnamedExpr(Expr::Identifier(Ident::new("parent"))), + ], + exclude: None, + from: vec![TableWithJoins { + relation: table_from_name(ObjectName::from(vec![Ident::new("t")])), + joins: vec![], + }], + into: None, + lateral_views: vec![], + prewhere: None, + selection: None, + group_by: GroupByExpr::Expressions(vec![], vec![]), + cluster_by: vec![], + distribute_by: vec![], + sort_by: vec![], + having: None, + named_window: vec![], + qualify: None, + window_before_qualify: false, + value_table_mode: None, + connect_by: Some(ConnectBy { + condition: None, + relationships: vec![Expr::BinaryOp { + left: Expr::Identifier(Ident::new("parent")).into(), + op: BinaryOperator::Eq, + right: Expr::Prior(Expr::Identifier(Ident::new("child")).into()).into(), + }], + nocycle: true, + }), + flavor: SelectFlavor::Standard, + } + ); } #[test] From c3e092a321ac5a6ce71f5660db3ea95340a7cae4 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Fri, 30 Jan 2026 10:59:59 +0100 Subject: [PATCH 02/16] [Oracle] Parse CONNECT BY before GROUP BY --- src/ast/query.rs | 12 ++++++------ src/parser/mod.rs | 22 +++++++++++----------- tests/sqlparser_common.rs | 20 ++++++++++++++------ 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/ast/query.rs b/src/ast/query.rs index 44ecf1e9b1..7a89bbf4b2 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -480,6 +480,8 @@ pub struct Select { pub prewhere: Option, /// WHERE pub selection: Option, + /// STARTING WITH .. CONNECT BY + pub connect_by: Option, /// GROUP BY pub group_by: GroupByExpr, /// CLUSTER BY (Hive) @@ -501,8 +503,6 @@ pub struct Select { pub window_before_qualify: bool, /// BigQuery syntax: `SELECT AS VALUE | SELECT AS STRUCT` pub value_table_mode: Option, - /// STARTING WITH .. CONNECT BY - pub connect_by: Option, /// Was this a FROM-first query? pub flavor: SelectFlavor, } @@ -585,6 +585,10 @@ impl fmt::Display for Select { SpaceOrNewline.fmt(f)?; Indent(selection).fmt(f)?; } + if let Some(ref connect_by) = self.connect_by { + SpaceOrNewline.fmt(f)?; + connect_by.fmt(f)?; + } match &self.group_by { GroupByExpr::All(_) => { SpaceOrNewline.fmt(f)?; @@ -648,10 +652,6 @@ impl fmt::Display for Select { display_comma_separated(&self.named_window).fmt(f)?; } } - if let Some(ref connect_by) = self.connect_by { - SpaceOrNewline.fmt(f)?; - connect_by.fmt(f)?; - } Ok(()) } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index e63159e50b..f8e68a00e3 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -14032,6 +14032,17 @@ impl<'a> Parser<'a> { None }; + let connect_by = if self.dialect.supports_connect_by() + && self + .parse_one_of_keywords(&[Keyword::START, Keyword::CONNECT]) + .is_some() + { + self.prev_token(); + Some(self.parse_connect_by()?) + } else { + None + }; + let group_by = self .parse_optional_group_by()? .unwrap_or_else(|| GroupByExpr::Expressions(vec![], vec![])); @@ -14084,17 +14095,6 @@ impl<'a> Parser<'a> { Default::default() }; - let connect_by = if self.dialect.supports_connect_by() - && self - .parse_one_of_keywords(&[Keyword::START, Keyword::CONNECT]) - .is_some() - { - self.prev_token(); - Some(self.parse_connect_by()?) - } else { - None - }; - Ok(Select { select_token: AttachedToken(select_token), optimizer_hint, diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 7fc2bfe93e..0ec50ad168 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -12644,6 +12644,8 @@ fn parse_map_access_expr() { #[test] fn parse_connect_by() { + let dialects = all_dialects_where(|d| d.supports_connect_by()); + let expect_query = Select { select_token: AttachedToken::empty(), optimizer_hint: None, @@ -12702,7 +12704,7 @@ fn parse_connect_by() { ); assert_eq!( - all_dialects_where(|d| d.supports_connect_by()).verified_only_select(connect_by_1), + dialects.verified_only_select(connect_by_1), expect_query ); @@ -12714,8 +12716,7 @@ fn parse_connect_by() { "ORDER BY employee_id" ); assert_eq!( - all_dialects_where(|d| d.supports_connect_by()) - .verified_only_select_with_canonical(connect_by_2, connect_by_1), + dialects.verified_only_select_with_canonical(connect_by_2, connect_by_1), expect_query ); @@ -12728,7 +12729,7 @@ fn parse_connect_by() { "ORDER BY employee_id" ); assert_eq!( - all_dialects_where(|d| d.supports_connect_by()).verified_only_select(connect_by_3), + dialects.verified_only_select(connect_by_3), Select { select_token: AttachedToken::empty(), optimizer_hint: None, @@ -12791,7 +12792,7 @@ fn parse_connect_by() { "WHERE employee_id <> 42 ", "ORDER BY employee_id" ); - all_dialects_where(|d| d.supports_connect_by()) + dialects .parse_sql_statements(connect_by_4) .expect_err("should have failed"); @@ -12809,7 +12810,7 @@ fn parse_connect_by() { // no START WITH and NOCYCLE let connect_by_5 = "SELECT child, parent FROM t CONNECT BY NOCYCLE parent = PRIOR child"; assert_eq!( - all_dialects_where(|d| d.supports_connect_by()).verified_only_select(connect_by_5), + dialects.verified_only_select(connect_by_5), Select { select_token: AttachedToken::empty(), optimizer_hint: None, @@ -12850,6 +12851,13 @@ fn parse_connect_by() { flavor: SelectFlavor::Standard, } ); + + // ~ CONNECT BY after WHERE and before GROUP BY + dialects.verified_only_select("SELECT 0 FROM t WHERE 1 = 1 CONNECT BY 2 = 2 GROUP BY 3"); + dialects.verified_only_select("SELECT 0 FROM t WHERE 1 = 1 START WITH 'a' = 'a' CONNECT BY 2 = 2 GROUP BY 3"); + dialects.verified_only_select_with_canonical( + "SELECT 0 FROM t WHERE 1 = 1 CONNECT BY 2 = 2 START WITH 'a' = 'a' GROUP BY 3", + "SELECT 0 FROM t WHERE 1 = 1 START WITH 'a' = 'a' CONNECT BY 2 = 2 GROUP BY 3"); } #[test] From 4b65891a70933856e0e90eb454e8ca69d8da4342 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Fri, 30 Jan 2026 13:39:51 +0100 Subject: [PATCH 03/16] [Oracle] Support CONNECT_BY_ROOT --- src/dialect/oracle.rs | 8 +++++++- tests/sqlparser_oracle.rs | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/dialect/oracle.rs b/src/dialect/oracle.rs index a72d5d7a81..9535fbdb3b 100644 --- a/src/dialect/oracle.rs +++ b/src/dialect/oracle.rs @@ -22,7 +22,9 @@ use crate::{ tokenizer::Token, }; -use super::{Dialect, Precedence}; +use super::{Dialect, Precedence, keywords::Keyword}; + +const RESERVED_KEYWORDS_FOR_SELECT_ITEM_OPERATOR: [Keyword; 1] = [Keyword::CONNECT_BY_ROOT]; /// A [`Dialect`] for [Oracle Databases](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/index.html) #[derive(Debug, Default)] @@ -96,6 +98,10 @@ impl Dialect for OracleDialect { true } + fn get_reserved_keywords_for_select_item_operator(&self) -> &[Keyword] { + &RESERVED_KEYWORDS_FOR_SELECT_ITEM_OPERATOR + } + fn supports_quote_delimited_string(&self) -> bool { true } diff --git a/tests/sqlparser_oracle.rs b/tests/sqlparser_oracle.rs index 1c12f868f4..7a42926642 100644 --- a/tests/sqlparser_oracle.rs +++ b/tests/sqlparser_oracle.rs @@ -388,3 +388,27 @@ fn test_optimizer_hints() { VALUES (ps.person_id, ps.first_name, ps.last_name, ps.title)", ); } + +#[test] +fn test_connect_by() { + let oracle_dialect = oracle(); + + oracle_dialect.verified_only_select( + "SELECT last_name AS \"Employee\", CONNECT_BY_ISCYCLE AS \"Cycle\", \ + LEVEL, \ + SYS_CONNECT_BY_PATH(last_name, '/') AS \"Path\" \ + FROM employees \ + WHERE level <= 3 AND department_id = 80 \ + START WITH last_name = 'King' \ + CONNECT BY NOCYCLE PRIOR employee_id = manager_id AND LEVEL <= 4 \ + ORDER BY \"Employee\", \"Cycle\", LEVEL, \"Path\""); + + // ~ CONNECT_BY_ROOT + oracle_dialect.verified_only_select( + "SELECT last_name AS \"Employee\", CONNECT_BY_ROOT last_name AS \"Manager\", \ + LEVEL - 1 AS \"Pathlen\", SYS_CONNECT_BY_PATH(last_name, '/') AS \"Path\" \ + FROM employees \ + WHERE LEVEL > 1 AND department_id = 110 \ + CONNECT BY PRIOR employee_id = manager_id \ + ORDER BY \"Employee\", \"Manager\", \"Pathlen\", \"Path\""); +} From 068fd8399864e995cdc04b06ebb90e84bb0a9959 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Fri, 30 Jan 2026 13:40:31 +0100 Subject: [PATCH 04/16] Cargo fmt --- src/ast/query.rs | 2 +- src/ast/spans.rs | 7 +++++-- src/dialect/oracle.rs | 2 +- src/parser/mod.rs | 10 +++++++--- tests/sqlparser_common.rs | 12 ++++++------ tests/sqlparser_oracle.rs | 6 ++++-- 6 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/ast/query.rs b/src/ast/query.rs index 7a89bbf4b2..4e0be4c37c 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -1222,7 +1222,7 @@ impl fmt::Display for ConnectBy { let Self { condition, relationships, - nocycle + nocycle, } = self; if let Some(condition) = condition { write!(f, "START WITH {condition} ")?; diff --git a/src/ast/spans.rs b/src/ast/spans.rs index c8dce8f90c..d3de9bfcfb 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -2286,11 +2286,14 @@ impl Spanned for ConnectBy { let ConnectBy { condition, relationships, - nocycle: _ + nocycle: _, } = self; union_spans( - condition.iter().map(Spanned::span).chain(relationships.iter().map(|item| item.span())), + condition + .iter() + .map(Spanned::span) + .chain(relationships.iter().map(|item| item.span())), ) } } diff --git a/src/dialect/oracle.rs b/src/dialect/oracle.rs index 9535fbdb3b..71b0fdb5c7 100644 --- a/src/dialect/oracle.rs +++ b/src/dialect/oracle.rs @@ -22,7 +22,7 @@ use crate::{ tokenizer::Token, }; -use super::{Dialect, Precedence, keywords::Keyword}; +use super::{keywords::Keyword, Dialect, Precedence}; const RESERVED_KEYWORDS_FOR_SELECT_ITEM_OPERATOR: [Keyword; 1] = [Keyword::CONNECT_BY_ROOT]; diff --git a/src/parser/mod.rs b/src/parser/mod.rs index f8e68a00e3..32c5ec6b2e 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -14280,10 +14280,14 @@ impl<'a> Parser<'a> { /// Parse a `CONNECT BY` clause (Oracle-style hierarchical query support). pub fn parse_connect_by(&mut self) -> Result { - let (condition, relationships, nocycle) = if self.parse_keywords(&[Keyword::CONNECT, Keyword::BY]) { + let (condition, relationships, nocycle) = if self + .parse_keywords(&[Keyword::CONNECT, Keyword::BY]) + { let (relationships, nocycle) = self.with_state(ParserState::ConnectBy, |parser| { let nocycle = parser.parse_keyword(Keyword::NOCYCLE); - parser.parse_comma_separated(Parser::parse_expr).map(|exprs| (exprs, nocycle)) + parser + .parse_comma_separated(Parser::parse_expr) + .map(|exprs| (exprs, nocycle)) })?; let condition = if self.parse_keywords(&[Keyword::START, Keyword::WITH]) { Some(self.parse_expr()?) @@ -14304,7 +14308,7 @@ impl<'a> Parser<'a> { Ok(ConnectBy { condition, relationships, - nocycle + nocycle, }) } diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 0ec50ad168..9c5449a242 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -12703,10 +12703,7 @@ fn parse_connect_by() { "ORDER BY employee_id" ); - assert_eq!( - dialects.verified_only_select(connect_by_1), - expect_query - ); + assert_eq!(dialects.verified_only_select(connect_by_1), expect_query); // CONNECT BY can come before START WITH let connect_by_2 = concat!( @@ -12854,10 +12851,13 @@ fn parse_connect_by() { // ~ CONNECT BY after WHERE and before GROUP BY dialects.verified_only_select("SELECT 0 FROM t WHERE 1 = 1 CONNECT BY 2 = 2 GROUP BY 3"); - dialects.verified_only_select("SELECT 0 FROM t WHERE 1 = 1 START WITH 'a' = 'a' CONNECT BY 2 = 2 GROUP BY 3"); + dialects.verified_only_select( + "SELECT 0 FROM t WHERE 1 = 1 START WITH 'a' = 'a' CONNECT BY 2 = 2 GROUP BY 3", + ); dialects.verified_only_select_with_canonical( "SELECT 0 FROM t WHERE 1 = 1 CONNECT BY 2 = 2 START WITH 'a' = 'a' GROUP BY 3", - "SELECT 0 FROM t WHERE 1 = 1 START WITH 'a' = 'a' CONNECT BY 2 = 2 GROUP BY 3"); + "SELECT 0 FROM t WHERE 1 = 1 START WITH 'a' = 'a' CONNECT BY 2 = 2 GROUP BY 3", + ); } #[test] diff --git a/tests/sqlparser_oracle.rs b/tests/sqlparser_oracle.rs index 7a42926642..2ffba63b77 100644 --- a/tests/sqlparser_oracle.rs +++ b/tests/sqlparser_oracle.rs @@ -401,7 +401,8 @@ fn test_connect_by() { WHERE level <= 3 AND department_id = 80 \ START WITH last_name = 'King' \ CONNECT BY NOCYCLE PRIOR employee_id = manager_id AND LEVEL <= 4 \ - ORDER BY \"Employee\", \"Cycle\", LEVEL, \"Path\""); + ORDER BY \"Employee\", \"Cycle\", LEVEL, \"Path\"", + ); // ~ CONNECT_BY_ROOT oracle_dialect.verified_only_select( @@ -410,5 +411,6 @@ fn test_connect_by() { FROM employees \ WHERE LEVEL > 1 AND department_id = 110 \ CONNECT BY PRIOR employee_id = manager_id \ - ORDER BY \"Employee\", \"Manager\", \"Pathlen\", \"Path\""); + ORDER BY \"Employee\", \"Manager\", \"Pathlen\", \"Path\"", + ); } From df8253de8d206a445fe17c57262617f3f117f26c Mon Sep 17 00:00:00 2001 From: xitep Date: Fri, 30 Jan 2026 14:20:42 +0100 Subject: [PATCH 05/16] Apply suggestion from @xitep --- src/ast/query.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ast/query.rs b/src/ast/query.rs index 4e0be4c37c..bdfb003112 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -480,7 +480,7 @@ pub struct Select { pub prewhere: Option, /// WHERE pub selection: Option, - /// STARTING WITH .. CONNECT BY + /// START WITH .. CONNECT BY pub connect_by: Option, /// GROUP BY pub group_by: GroupByExpr, From 0b941e9a0b21c6268c2aca2973cf463ac8534ae2 Mon Sep 17 00:00:00 2001 From: xitep Date: Fri, 30 Jan 2026 14:24:59 +0100 Subject: [PATCH 06/16] Update src/ast/query.rs --- src/ast/query.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ast/query.rs b/src/ast/query.rs index bdfb003112..6c60079bc9 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -480,7 +480,7 @@ pub struct Select { pub prewhere: Option, /// WHERE pub selection: Option, - /// START WITH .. CONNECT BY + /// [START WITH ..] CONNECT BY .. pub connect_by: Option, /// GROUP BY pub group_by: GroupByExpr, From 16b764eec59dab5dd748829706f6f4f16b592f1c Mon Sep 17 00:00:00 2001 From: xitep Date: Fri, 30 Jan 2026 17:23:48 +0100 Subject: [PATCH 07/16] Update tests/sqlparser_common.rs Co-authored-by: Ifeanyi Ubah --- tests/sqlparser_common.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 9c5449a242..1895caf1b3 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -12849,7 +12849,7 @@ fn parse_connect_by() { } ); - // ~ CONNECT BY after WHERE and before GROUP BY + // CONNECT BY after WHERE and before GROUP BY dialects.verified_only_select("SELECT 0 FROM t WHERE 1 = 1 CONNECT BY 2 = 2 GROUP BY 3"); dialects.verified_only_select( "SELECT 0 FROM t WHERE 1 = 1 START WITH 'a' = 'a' CONNECT BY 2 = 2 GROUP BY 3", From 532bdbae4a4dfedb2503bf1fc58b4332a5d9f056 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Fri, 30 Jan 2026 17:25:55 +0100 Subject: [PATCH 08/16] Prefer "peek" over "consume/rewind" token --- src/parser/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 32c5ec6b2e..5ef52bac2f 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -14034,10 +14034,9 @@ impl<'a> Parser<'a> { let connect_by = if self.dialect.supports_connect_by() && self - .parse_one_of_keywords(&[Keyword::START, Keyword::CONNECT]) + .peek_one_of_keywords(&[Keyword::START, Keyword::CONNECT]) .is_some() { - self.prev_token(); Some(self.parse_connect_by()?) } else { None From f8df94ac9dafb3798e2de647a81cee831c7f8821 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Fri, 30 Jan 2026 17:26:05 +0100 Subject: [PATCH 09/16] Syntax docs --- src/ast/query.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ast/query.rs b/src/ast/query.rs index 6c60079bc9..26fa8601f0 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -1210,10 +1210,17 @@ impl fmt::Display for TableWithJoins { #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] pub struct ConnectBy { /// START WITH + /// + /// Optional on [Oracle](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/Hierarchical-Queries.html#GUID-0118DF1D-B9A9-41EB-8556-C6E7D6A5A84E) + /// when comming _after_ the `CONNECT BY`. pub condition: Option, + /// CONNECT BY pub relationships: Vec, + /// [CONNECT BY] NOCYCLE + /// + /// Optional on [Oracle](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/Hierarchical-Queries.html#GUID-0118DF1D-B9A9-41EB-8556-C6E7D6A5A84E__GUID-5377971A-F518-47E4-8781-F06FEB3EF993) pub nocycle: bool, } From f3359c682f908780ff7ab9648d806bc1d303d600 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Fri, 30 Jan 2026 18:27:36 +0100 Subject: [PATCH 10/16] Preserve order of START WITH / CONNECT BY; simplify parsing --- src/ast/mod.rs | 2 +- src/ast/query.rs | 54 +++++------ src/ast/spans.rs | 22 ++--- src/parser/mod.rs | 57 ++++-------- tests/sqlparser_bigquery.rs | 4 +- tests/sqlparser_clickhouse.rs | 2 +- tests/sqlparser_common.rs | 166 ++++++++++++++++++++++------------ tests/sqlparser_duckdb.rs | 4 +- tests/sqlparser_mssql.rs | 6 +- tests/sqlparser_mysql.rs | 16 ++-- tests/sqlparser_postgres.rs | 6 +- 11 files changed, 178 insertions(+), 161 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index a26c14ef7b..a595196958 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -87,7 +87,7 @@ pub use self::dml::{ }; pub use self::operator::{BinaryOperator, UnaryOperator}; pub use self::query::{ - AfterMatchSkip, ConnectBy, Cte, CteAsMaterialized, Distinct, EmptyMatchesMode, + AfterMatchSkip, ConnectByKind, Cte, CteAsMaterialized, Distinct, EmptyMatchesMode, ExceptSelectItem, ExcludeSelectItem, ExprWithAlias, ExprWithAliasAndOrderBy, Fetch, ForClause, ForJson, ForXml, FormatClause, GroupByExpr, GroupByWithModifier, IdentWithAlias, IlikeSelectItem, InputFormatClause, Interpolate, InterpolateExpr, Join, JoinConstraint, diff --git a/src/ast/query.rs b/src/ast/query.rs index 26fa8601f0..414f836746 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -481,7 +481,7 @@ pub struct Select { /// WHERE pub selection: Option, /// [START WITH ..] CONNECT BY .. - pub connect_by: Option, + pub connect_by: Vec, /// GROUP BY pub group_by: GroupByExpr, /// CLUSTER BY (Hive) @@ -585,9 +585,9 @@ impl fmt::Display for Select { SpaceOrNewline.fmt(f)?; Indent(selection).fmt(f)?; } - if let Some(ref connect_by) = self.connect_by { + for clause in &self.connect_by { SpaceOrNewline.fmt(f)?; - connect_by.fmt(f)?; + clause.fmt(f)?; } match &self.group_by { GroupByExpr::All(_) => { @@ -1208,38 +1208,38 @@ impl fmt::Display for TableWithJoins { #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] -pub struct ConnectBy { +pub enum ConnectByKind { + /// CONNECT BY + ConnectBy { + /// the join conditions denoting the hierarchical relationship + relationships: Vec, + + /// [CONNECT BY] NOCYCLE + /// + /// Optional on [Oracle](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/Hierarchical-Queries.html#GUID-0118DF1D-B9A9-41EB-8556-C6E7D6A5A84E__GUID-5377971A-F518-47E4-8781-F06FEB3EF993) + nocycle: bool, + }, + /// START WITH /// /// Optional on [Oracle](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/Hierarchical-Queries.html#GUID-0118DF1D-B9A9-41EB-8556-C6E7D6A5A84E) /// when comming _after_ the `CONNECT BY`. - pub condition: Option, - - /// CONNECT BY - pub relationships: Vec, - - /// [CONNECT BY] NOCYCLE - /// - /// Optional on [Oracle](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/Hierarchical-Queries.html#GUID-0118DF1D-B9A9-41EB-8556-C6E7D6A5A84E__GUID-5377971A-F518-47E4-8781-F06FEB3EF993) - pub nocycle: bool, + StartWith(Box), } -impl fmt::Display for ConnectBy { +impl fmt::Display for ConnectByKind { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let Self { - condition, - relationships, - nocycle, - } = self; - if let Some(condition) = condition { - write!(f, "START WITH {condition} ")?; + match self { + ConnectByKind::ConnectBy { relationships, nocycle } => { + write!(f, "CONNECT BY {nocycle}{relationships}", + nocycle = if *nocycle { "NOCYCLE " } else { "" }, + relationships = display_comma_separated(relationships) + ) + }, + ConnectByKind::StartWith(condition) => { + write!(f, "START WITH {condition}") + }, } - write!( - f, - "CONNECT BY {nocycle}{relationships}", - nocycle = if *nocycle { "NOCYCLE " } else { "" }, - relationships = display_comma_separated(relationships) - ) } } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index d3de9bfcfb..3f163812af 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -32,7 +32,7 @@ use super::{ AlterIndexOperation, AlterTableOperation, Analyze, Array, Assignment, AssignmentTarget, AttachedToken, BeginEndStatements, CaseStatement, CloseCursor, ClusteredIndex, ColumnDef, ColumnOption, ColumnOptionDef, ConditionalStatementBlock, ConditionalStatements, - ConflictTarget, ConnectBy, ConstraintCharacteristics, CopySource, CreateIndex, CreateTable, + ConflictTarget, ConnectByKind, ConstraintCharacteristics, CopySource, CreateIndex, CreateTable, CreateTableOptions, Cte, Delete, DoUpdate, ExceptSelectItem, ExcludeSelectItem, Expr, ExprWithAlias, Fetch, ForValues, FromTable, Function, FunctionArg, FunctionArgExpr, FunctionArgumentClause, FunctionArgumentList, FunctionArguments, GroupByExpr, HavingBound, @@ -2281,20 +2281,14 @@ impl Spanned for Select { } } -impl Spanned for ConnectBy { +impl Spanned for ConnectByKind { fn span(&self) -> Span { - let ConnectBy { - condition, - relationships, - nocycle: _, - } = self; - - union_spans( - condition - .iter() - .map(Spanned::span) - .chain(relationships.iter().map(|item| item.span())), - ) + match self { + ConnectByKind::ConnectBy { relationships, nocycle: _ } => { + union_spans(relationships.iter().map(|item| item.span())) + } + ConnectByKind::StartWith(expr) => expr.span(), + } } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 5ef52bac2f..4645f59200 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -13921,7 +13921,7 @@ impl<'a> Parser<'a> { window_before_qualify: false, qualify: None, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::FromFirstNoSelect, }); } @@ -14032,15 +14032,7 @@ impl<'a> Parser<'a> { None }; - let connect_by = if self.dialect.supports_connect_by() - && self - .peek_one_of_keywords(&[Keyword::START, Keyword::CONNECT]) - .is_some() - { - Some(self.parse_connect_by()?) - } else { - None - }; + let connect_by = self.maybe_parse_connect_by()?; let group_by = self .parse_optional_group_by()? @@ -14278,37 +14270,22 @@ impl<'a> Parser<'a> { } /// Parse a `CONNECT BY` clause (Oracle-style hierarchical query support). - pub fn parse_connect_by(&mut self) -> Result { - let (condition, relationships, nocycle) = if self - .parse_keywords(&[Keyword::CONNECT, Keyword::BY]) - { - let (relationships, nocycle) = self.with_state(ParserState::ConnectBy, |parser| { - let nocycle = parser.parse_keyword(Keyword::NOCYCLE); - parser - .parse_comma_separated(Parser::parse_expr) - .map(|exprs| (exprs, nocycle)) - })?; - let condition = if self.parse_keywords(&[Keyword::START, Keyword::WITH]) { - Some(self.parse_expr()?) + pub fn maybe_parse_connect_by(&mut self) -> Result, ParserError> { + let mut clauses = Vec::with_capacity(2); + loop { + if self.parse_keywords(&[Keyword::START, Keyword::WITH]) { + clauses.push(ConnectByKind::StartWith(self.parse_expr()?.into())); + } else if self.parse_keywords(&[Keyword::CONNECT, Keyword::BY]) { + let nocycle = self.parse_keyword(Keyword::NOCYCLE); + let relationships = self.with_state(ParserState::ConnectBy, |parser| { + parser.parse_comma_separated(Parser::parse_expr) + })?; + clauses.push(ConnectByKind::ConnectBy { relationships, nocycle }); } else { - None - }; - (condition, relationships, nocycle) - } else { - self.expect_keywords(&[Keyword::START, Keyword::WITH])?; - let condition = self.parse_expr()?; - self.expect_keywords(&[Keyword::CONNECT, Keyword::BY])?; - let nocycle = self.parse_keyword(Keyword::NOCYCLE); - let relationships = self.with_state(ParserState::ConnectBy, |parser| { - parser.parse_comma_separated(Parser::parse_expr) - })?; - (Some(condition), relationships, nocycle) - }; - Ok(ConnectBy { - condition, - relationships, - nocycle, - }) + break; + } + } + Ok(clauses) } /// Parse `CREATE TABLE x AS TABLE y` diff --git a/tests/sqlparser_bigquery.rs b/tests/sqlparser_bigquery.rs index d5ad63735f..cf843ea2b3 100644 --- a/tests/sqlparser_bigquery.rs +++ b/tests/sqlparser_bigquery.rs @@ -2711,7 +2711,7 @@ fn test_export_data() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), order_by: Some(OrderBy { @@ -2817,7 +2817,7 @@ fn test_export_data() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), order_by: Some(OrderBy { diff --git a/tests/sqlparser_clickhouse.rs b/tests/sqlparser_clickhouse.rs index db83291179..b8b4e33708 100644 --- a/tests/sqlparser_clickhouse.rs +++ b/tests/sqlparser_clickhouse.rs @@ -103,7 +103,7 @@ fn parse_map_access_expr() { window_before_qualify: false, qualify: None, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }, select diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 1895caf1b3..52afa9ba2f 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -501,7 +501,7 @@ fn parse_update_set_from() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), order_by: None, @@ -5959,7 +5959,7 @@ fn test_parse_named_window() { qualify: None, window_before_qualify: true, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }; assert_eq!(actual_select_only, expected); @@ -6634,7 +6634,7 @@ fn parse_interval_and_or_xor() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), order_by: None, @@ -8972,7 +8972,7 @@ fn lateral_function() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }; assert_eq!(actual_select_only, expected); @@ -9960,7 +9960,7 @@ fn parse_merge() { window_before_qualify: false, qualify: None, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), order_by: None, @@ -12360,7 +12360,7 @@ fn parse_unload() { window_before_qualify: false, qualify: None, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), with: None, @@ -12646,7 +12646,14 @@ fn parse_map_access_expr() { fn parse_connect_by() { let dialects = all_dialects_where(|d| d.supports_connect_by()); - let expect_query = Select { + let connect_by_1 = concat!( + "SELECT employee_id, manager_id, title FROM employees ", + "START WITH title = 'president' ", + "CONNECT BY manager_id = PRIOR employee_id ", + "ORDER BY employee_id" + ); + + assert_eq!(dialects.verified_only_select(connect_by_1), Select { select_token: AttachedToken::empty(), optimizer_hint: None, distinct: None, @@ -12676,34 +12683,26 @@ fn parse_connect_by() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: Some(ConnectBy { - condition: Some(Expr::BinaryOp { + connect_by: vec![ + ConnectByKind::StartWith(Expr::BinaryOp { left: Box::new(Expr::Identifier(Ident::new("title"))), op: BinaryOperator::Eq, right: Box::new(Expr::Value( Value::SingleQuotedString("president".to_owned()).with_empty_span(), )), - }), - relationships: vec![Expr::BinaryOp { - left: Box::new(Expr::Identifier(Ident::new("manager_id"))), - op: BinaryOperator::Eq, - right: Box::new(Expr::Prior(Box::new(Expr::Identifier(Ident::new( - "employee_id", - ))))), + }.into()), + ConnectByKind::ConnectBy { + relationships: vec![Expr::BinaryOp { + left: Box::new(Expr::Identifier(Ident::new("manager_id"))), + op: BinaryOperator::Eq, + right: Box::new(Expr::Prior(Box::new(Expr::Identifier(Ident::new( + "employee_id", + ))))), + }], + nocycle: false, }], - nocycle: false, - }), flavor: SelectFlavor::Standard, - }; - - let connect_by_1 = concat!( - "SELECT employee_id, manager_id, title FROM employees ", - "START WITH title = 'president' ", - "CONNECT BY manager_id = PRIOR employee_id ", - "ORDER BY employee_id" - ); - - assert_eq!(dialects.verified_only_select(connect_by_1), expect_query); + }); // CONNECT BY can come before START WITH let connect_by_2 = concat!( @@ -12713,8 +12712,56 @@ fn parse_connect_by() { "ORDER BY employee_id" ); assert_eq!( - dialects.verified_only_select_with_canonical(connect_by_2, connect_by_1), - expect_query + dialects.verified_only_select(connect_by_2), + Select { + select_token: AttachedToken::empty(), + optimizer_hint: None, + distinct: None, + top: None, + top_before_distinct: false, + projection: vec![ + SelectItem::UnnamedExpr(Expr::Identifier(Ident::new("employee_id"))), + SelectItem::UnnamedExpr(Expr::Identifier(Ident::new("manager_id"))), + SelectItem::UnnamedExpr(Expr::Identifier(Ident::new("title"))), + ], + exclude: None, + from: vec![TableWithJoins { + relation: table_from_name(ObjectName::from(vec![Ident::new("employees")])), + joins: vec![], + }], + into: None, + lateral_views: vec![], + prewhere: None, + selection: None, + group_by: GroupByExpr::Expressions(vec![], vec![]), + cluster_by: vec![], + distribute_by: vec![], + sort_by: vec![], + having: None, + named_window: vec![], + qualify: None, + window_before_qualify: false, + value_table_mode: None, + connect_by: vec![ + ConnectByKind::ConnectBy { + relationships: vec![Expr::BinaryOp { + left: Box::new(Expr::Identifier(Ident::new("manager_id"))), + op: BinaryOperator::Eq, + right: Box::new(Expr::Prior(Box::new(Expr::Identifier(Ident::new( + "employee_id", + ))))), + }], + nocycle: false, + }, + ConnectByKind::StartWith(Expr::BinaryOp { + left: Box::new(Expr::Identifier(Ident::new("title"))), + op: BinaryOperator::Eq, + right: Box::new(Expr::Value( + Value::SingleQuotedString("president".to_owned()).with_empty_span(), + )), + }.into())], + flavor: SelectFlavor::Standard, + } ); // WHERE must come before CONNECT BY @@ -12761,23 +12808,24 @@ fn parse_connect_by() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: Some(ConnectBy { - condition: Some(Expr::BinaryOp { - left: Box::new(Expr::Identifier(Ident::new("title"))), - op: BinaryOperator::Eq, - right: Box::new(Expr::Value( - (Value::SingleQuotedString("president".to_owned(),)).with_empty_span() - )), - }), - relationships: vec![Expr::BinaryOp { - left: Box::new(Expr::Identifier(Ident::new("manager_id"))), - op: BinaryOperator::Eq, - right: Box::new(Expr::Prior(Box::new(Expr::Identifier(Ident::new( - "employee_id", - ))))), + connect_by: vec![ + ConnectByKind::StartWith(Expr::BinaryOp { + left: Box::new(Expr::Identifier(Ident::new("title"))), + op: BinaryOperator::Eq, + right: Box::new(Expr::Value( + (Value::SingleQuotedString("president".to_owned(),)).with_empty_span() + )), + }.into()), + ConnectByKind::ConnectBy { + relationships: vec![Expr::BinaryOp { + left: Box::new(Expr::Identifier(Ident::new("manager_id"))), + op: BinaryOperator::Eq, + right: Box::new(Expr::Prior(Box::new(Expr::Identifier(Ident::new( + "employee_id", + ))))), + }], + nocycle: false, }], - nocycle: false, - }), flavor: SelectFlavor::Standard, } ); @@ -12836,15 +12884,15 @@ fn parse_connect_by() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: Some(ConnectBy { - condition: None, - relationships: vec![Expr::BinaryOp { - left: Expr::Identifier(Ident::new("parent")).into(), - op: BinaryOperator::Eq, - right: Expr::Prior(Expr::Identifier(Ident::new("child")).into()).into(), + connect_by: vec![ + ConnectByKind::ConnectBy { + relationships: vec![Expr::BinaryOp { + left: Expr::Identifier(Ident::new("parent")).into(), + op: BinaryOperator::Eq, + right: Expr::Prior(Expr::Identifier(Ident::new("child")).into()).into(), + }], + nocycle: true, }], - nocycle: true, - }), flavor: SelectFlavor::Standard, } ); @@ -12854,10 +12902,8 @@ fn parse_connect_by() { dialects.verified_only_select( "SELECT 0 FROM t WHERE 1 = 1 START WITH 'a' = 'a' CONNECT BY 2 = 2 GROUP BY 3", ); - dialects.verified_only_select_with_canonical( - "SELECT 0 FROM t WHERE 1 = 1 CONNECT BY 2 = 2 START WITH 'a' = 'a' GROUP BY 3", - "SELECT 0 FROM t WHERE 1 = 1 START WITH 'a' = 'a' CONNECT BY 2 = 2 GROUP BY 3", - ); + dialects.verified_only_select( + "SELECT 0 FROM t WHERE 1 = 1 CONNECT BY 2 = 2 START WITH 'a' = 'a' GROUP BY 3"); } #[test] @@ -13756,7 +13802,7 @@ fn test_extract_seconds_ok() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), order_by: None, @@ -15889,7 +15935,7 @@ fn test_select_from_first() { window_before_qualify: false, qualify: None, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor, }))), order_by: None, diff --git a/tests/sqlparser_duckdb.rs b/tests/sqlparser_duckdb.rs index e408d997b3..b9ae26491f 100644 --- a/tests/sqlparser_duckdb.rs +++ b/tests/sqlparser_duckdb.rs @@ -294,7 +294,7 @@ fn test_select_union_by_name() { window_before_qualify: false, qualify: None, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), right: Box::::new(SetExpr::Select(Box::new(Select { @@ -327,7 +327,7 @@ fn test_select_union_by_name() { window_before_qualify: false, qualify: None, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), }); diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index 7f60934271..84b8658b0e 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -164,7 +164,7 @@ fn parse_create_procedure() { window_before_qualify: false, qualify: None, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))) }))], @@ -1392,7 +1392,7 @@ fn parse_substring_in_select() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), order_by: None, @@ -1536,7 +1536,7 @@ fn parse_mssql_declare() { window_before_qualify: false, qualify: None, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))) })) diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index d1e718f4b8..601b6c4f5c 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -1460,7 +1460,7 @@ fn parse_escaped_quote_identifiers_with_escape() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), order_by: None, @@ -1517,7 +1517,7 @@ fn parse_escaped_quote_identifiers_with_no_escape() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), order_by: None, @@ -1566,7 +1566,7 @@ fn parse_escaped_backticks_with_escape() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), order_by: None, @@ -1619,7 +1619,7 @@ fn parse_escaped_backticks_with_no_escape() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), order_by: None, @@ -2443,7 +2443,7 @@ fn parse_select_with_numeric_prefix_column_name() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))) ); @@ -2620,7 +2620,7 @@ fn parse_select_with_concatenation_of_exp_number_and_numeric_prefix_column() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))) ); @@ -3268,7 +3268,7 @@ fn parse_substring_in_select() { window_before_qualify: false, qualify: None, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), order_by: None, @@ -3578,7 +3578,7 @@ fn parse_hex_string_introducer() { qualify: None, value_table_mode: None, into: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), order_by: None, diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 4ce8ed8b45..5853be7ebb 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -1331,7 +1331,7 @@ fn parse_copy_to() { sort_by: vec![], qualify: None, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), order_by: None, @@ -3096,7 +3096,7 @@ fn parse_array_subquery_expr() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), right: Box::new(SetExpr::Select(Box::new(Select { @@ -3124,7 +3124,7 @@ fn parse_array_subquery_expr() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: None, + connect_by: vec![], flavor: SelectFlavor::Standard, }))), }), From 515fbfb0c49730c11ee7a9834c6157d0648321f2 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Fri, 30 Jan 2026 18:28:25 +0100 Subject: [PATCH 11/16] Cargo fmt --- src/ast/query.rs | 13 +++-- src/ast/spans.rs | 7 +-- src/parser/mod.rs | 5 +- tests/sqlparser_common.rs | 107 +++++++++++++++++++++----------------- 4 files changed, 75 insertions(+), 57 deletions(-) diff --git a/src/ast/query.rs b/src/ast/query.rs index 414f836746..f2a291c9ac 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -1230,15 +1230,20 @@ pub enum ConnectByKind { impl fmt::Display for ConnectByKind { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { - ConnectByKind::ConnectBy { relationships, nocycle } => { - write!(f, "CONNECT BY {nocycle}{relationships}", + ConnectByKind::ConnectBy { + relationships, + nocycle, + } => { + write!( + f, + "CONNECT BY {nocycle}{relationships}", nocycle = if *nocycle { "NOCYCLE " } else { "" }, relationships = display_comma_separated(relationships) ) - }, + } ConnectByKind::StartWith(condition) => { write!(f, "START WITH {condition}") - }, + } } } } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 3f163812af..214ec1201b 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -2284,9 +2284,10 @@ impl Spanned for Select { impl Spanned for ConnectByKind { fn span(&self) -> Span { match self { - ConnectByKind::ConnectBy { relationships, nocycle: _ } => { - union_spans(relationships.iter().map(|item| item.span())) - } + ConnectByKind::ConnectBy { + relationships, + nocycle: _, + } => union_spans(relationships.iter().map(|item| item.span())), ConnectByKind::StartWith(expr) => expr.span(), } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 4645f59200..3117267c4b 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -14280,7 +14280,10 @@ impl<'a> Parser<'a> { let relationships = self.with_state(ParserState::ConnectBy, |parser| { parser.parse_comma_separated(Parser::parse_expr) })?; - clauses.push(ConnectByKind::ConnectBy { relationships, nocycle }); + clauses.push(ConnectByKind::ConnectBy { + relationships, + nocycle, + }); } else { break; } diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 52afa9ba2f..c1c891ed96 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -12653,56 +12653,63 @@ fn parse_connect_by() { "ORDER BY employee_id" ); - assert_eq!(dialects.verified_only_select(connect_by_1), Select { - select_token: AttachedToken::empty(), - optimizer_hint: None, - distinct: None, - select_modifiers: None, - top: None, - top_before_distinct: false, - projection: vec![ - SelectItem::UnnamedExpr(Expr::Identifier(Ident::new("employee_id"))), - SelectItem::UnnamedExpr(Expr::Identifier(Ident::new("manager_id"))), - SelectItem::UnnamedExpr(Expr::Identifier(Ident::new("title"))), - ], - exclude: None, - from: vec![TableWithJoins { - relation: table_from_name(ObjectName::from(vec![Ident::new("employees")])), - joins: vec![], - }], - into: None, - lateral_views: vec![], - prewhere: None, - selection: None, - group_by: GroupByExpr::Expressions(vec![], vec![]), - cluster_by: vec![], - distribute_by: vec![], - sort_by: vec![], - having: None, - named_window: vec![], - qualify: None, - window_before_qualify: false, - value_table_mode: None, - connect_by: vec![ - ConnectByKind::StartWith(Expr::BinaryOp { - left: Box::new(Expr::Identifier(Ident::new("title"))), - op: BinaryOperator::Eq, - right: Box::new(Expr::Value( - Value::SingleQuotedString("president".to_owned()).with_empty_span(), - )), - }.into()), - ConnectByKind::ConnectBy { - relationships: vec![Expr::BinaryOp { - left: Box::new(Expr::Identifier(Ident::new("manager_id"))), - op: BinaryOperator::Eq, - right: Box::new(Expr::Prior(Box::new(Expr::Identifier(Ident::new( - "employee_id", - ))))), - }], - nocycle: false, + assert_eq!( + dialects.verified_only_select(connect_by_1), + Select { + select_token: AttachedToken::empty(), + optimizer_hint: None, + distinct: None, + select_modifiers: None, + top: None, + top_before_distinct: false, + projection: vec![ + SelectItem::UnnamedExpr(Expr::Identifier(Ident::new("employee_id"))), + SelectItem::UnnamedExpr(Expr::Identifier(Ident::new("manager_id"))), + SelectItem::UnnamedExpr(Expr::Identifier(Ident::new("title"))), + ], + exclude: None, + from: vec![TableWithJoins { + relation: table_from_name(ObjectName::from(vec![Ident::new("employees")])), + joins: vec![], }], - flavor: SelectFlavor::Standard, - }); + into: None, + lateral_views: vec![], + prewhere: None, + selection: None, + group_by: GroupByExpr::Expressions(vec![], vec![]), + cluster_by: vec![], + distribute_by: vec![], + sort_by: vec![], + having: None, + named_window: vec![], + qualify: None, + window_before_qualify: false, + value_table_mode: None, + connect_by: vec![ + ConnectByKind::StartWith( + Expr::BinaryOp { + left: Box::new(Expr::Identifier(Ident::new("title"))), + op: BinaryOperator::Eq, + right: Box::new(Expr::Value( + Value::SingleQuotedString("president".to_owned()).with_empty_span(), + )), + } + .into() + ), + ConnectByKind::ConnectBy { + relationships: vec![Expr::BinaryOp { + left: Box::new(Expr::Identifier(Ident::new("manager_id"))), + op: BinaryOperator::Eq, + right: Box::new(Expr::Prior(Box::new(Expr::Identifier(Ident::new( + "employee_id", + ))))), + }], + nocycle: false, + } + ], + flavor: SelectFlavor::Standard, + } + ); // CONNECT BY can come before START WITH let connect_by_2 = concat!( @@ -12717,6 +12724,7 @@ fn parse_connect_by() { select_token: AttachedToken::empty(), optimizer_hint: None, distinct: None, + select_modifiers: None, top: None, top_before_distinct: false, projection: vec![ @@ -12860,6 +12868,7 @@ fn parse_connect_by() { select_token: AttachedToken::empty(), optimizer_hint: None, distinct: None, + select_modifiers: None, top: None, top_before_distinct: false, projection: vec![ From 9a0278e58739bac7060ce293b1aa579ea2623fb7 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Fri, 30 Jan 2026 18:40:59 +0100 Subject: [PATCH 12/16] Comment style --- tests/sqlparser_oracle.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/sqlparser_oracle.rs b/tests/sqlparser_oracle.rs index 2ffba63b77..0dbccdb5eb 100644 --- a/tests/sqlparser_oracle.rs +++ b/tests/sqlparser_oracle.rs @@ -209,7 +209,7 @@ fn parse_quote_delimited_string() { #[test] fn parse_invalid_quote_delimited_strings() { let dialect = all_dialects_where(|d| d.supports_quote_delimited_string()); - // ~ invalid quote delimiter + // invalid quote delimiter for q in [' ', '\t', '\r', '\n'] { assert_eq!( dialect.parse_sql_statements(&format!("SELECT Q'{q}abc{q}' FROM dual")), @@ -219,7 +219,7 @@ fn parse_invalid_quote_delimited_strings() { "with quote char {q:?}" ); } - // ~ invalid eof after quote + // invalid eof after quote assert_eq!( dialect.parse_sql_statements("SELECT Q'"), Err(ParserError::TokenizerError( @@ -227,7 +227,7 @@ fn parse_invalid_quote_delimited_strings() { )), "with EOF quote char" ); - // ~ unterminated string + // unterminated string assert_eq!( dialect.parse_sql_statements("SELECT Q'|asdfa...."), Err(ParserError::TokenizerError( @@ -338,7 +338,7 @@ fn parse_national_quote_delimited_string_but_is_a_word() { fn test_optimizer_hints() { let oracle_dialect = oracle(); - // ~ selects + // selects let select = oracle_dialect.verified_only_select_with_canonical( "SELECT /*+one two three*/ /*+not a hint!*/ 1 FROM dual", "SELECT /*+one two three*/ 1 FROM dual", @@ -369,16 +369,16 @@ fn test_optimizer_hints() { Some(" one two three /* asdf */\n") ); - // ~ inserts + // inserts oracle_dialect.verified_stmt("INSERT /*+ append */ INTO t1 SELECT * FROM all_objects"); - // ~ updates + // updates oracle_dialect.verified_stmt("UPDATE /*+ DISABLE_PARALLEL_DML */ table_name SET column1 = 1"); - // ~ deletes + // deletes oracle_dialect.verified_stmt("DELETE --+ ENABLE_PARALLEL_DML\n FROM table_name"); - // ~ merges + // merges oracle_dialect.verified_stmt( "MERGE /*+ CLUSTERING */ INTO people_target pt \ USING people_source ps \ @@ -404,7 +404,7 @@ fn test_connect_by() { ORDER BY \"Employee\", \"Cycle\", LEVEL, \"Path\"", ); - // ~ CONNECT_BY_ROOT + // CONNECT_BY_ROOT oracle_dialect.verified_only_select( "SELECT last_name AS \"Employee\", CONNECT_BY_ROOT last_name AS \"Manager\", \ LEVEL - 1 AS \"Pathlen\", SYS_CONNECT_BY_PATH(last_name, '/') AS \"Path\" \ From f32369b5df8669e87b1e84ed18e8e49c44b5b081 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Sat, 31 Jan 2026 12:26:37 +0100 Subject: [PATCH 13/16] Begin tokens of START WITH / CONNECT BY clauses --- src/ast/query.rs | 23 +++++++++++---- src/ast/spans.rs | 17 +++++++---- src/parser/mod.rs | 49 ++++++++++++++++++++----------- tests/sqlparser_common.rs | 62 ++++++++++++++++++++++++--------------- 4 files changed, 100 insertions(+), 51 deletions(-) diff --git a/src/ast/query.rs b/src/ast/query.rs index f2a291c9ac..b8f605be54 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -1211,28 +1211,38 @@ impl fmt::Display for TableWithJoins { pub enum ConnectByKind { /// CONNECT BY ConnectBy { - /// the join conditions denoting the hierarchical relationship - relationships: Vec, + /// the `CONNECT` token + connect_token: AttachedToken, /// [CONNECT BY] NOCYCLE /// /// Optional on [Oracle](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/Hierarchical-Queries.html#GUID-0118DF1D-B9A9-41EB-8556-C6E7D6A5A84E__GUID-5377971A-F518-47E4-8781-F06FEB3EF993) nocycle: bool, + + /// join conditions denoting the hierarchical relationship + relationships: Vec, }, /// START WITH /// /// Optional on [Oracle](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/Hierarchical-Queries.html#GUID-0118DF1D-B9A9-41EB-8556-C6E7D6A5A84E) /// when comming _after_ the `CONNECT BY`. - StartWith(Box), + StartWith { + /// the `START` token + start_token: AttachedToken, + + /// condition selecting the root rows of the hierarchy + condition: Box, + }, } impl fmt::Display for ConnectByKind { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { ConnectByKind::ConnectBy { - relationships, + connect_token: _, nocycle, + relationships, } => { write!( f, @@ -1241,7 +1251,10 @@ impl fmt::Display for ConnectByKind { relationships = display_comma_separated(relationships) ) } - ConnectByKind::StartWith(condition) => { + ConnectByKind::StartWith { + start_token: _, + condition, + } => { write!(f, "START WITH {condition}") } } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 214ec1201b..16a9a926f7 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -2269,14 +2269,14 @@ impl Spanned for Select { .chain(lateral_views.iter().map(|item| item.span())) .chain(prewhere.iter().map(|item| item.span())) .chain(selection.iter().map(|item| item.span())) + .chain(connect_by.iter().map(|item| item.span())) .chain(core::iter::once(group_by.span())) .chain(cluster_by.iter().map(|item| item.span())) .chain(distribute_by.iter().map(|item| item.span())) .chain(sort_by.iter().map(|item| item.span())) .chain(having.iter().map(|item| item.span())) .chain(named_window.iter().map(|item| item.span())) - .chain(qualify.iter().map(|item| item.span())) - .chain(connect_by.iter().map(|item| item.span())), + .chain(qualify.iter().map(|item| item.span())), ) } } @@ -2285,10 +2285,17 @@ impl Spanned for ConnectByKind { fn span(&self) -> Span { match self { ConnectByKind::ConnectBy { - relationships, + connect_token, nocycle: _, - } => union_spans(relationships.iter().map(|item| item.span())), - ConnectByKind::StartWith(expr) => expr.span(), + relationships, + } => union_spans( + core::iter::once(connect_token.0.span()) + .chain(relationships.last().iter().map(|item| item.span())), + ), + ConnectByKind::StartWith { + start_token, + condition, + } => union_spans([start_token.0.span(), condition.span()].into_iter()), } } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 3117267c4b..ff6c39e762 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4521,16 +4521,29 @@ impl<'a> Parser<'a> { /// consumed and returns false #[must_use] pub fn parse_keywords(&mut self, keywords: &[Keyword]) -> bool { - let index = self.index; - for &keyword in keywords { - if !self.parse_keyword(keyword) { - // println!("parse_keywords aborting .. did not find {:?}", keyword); - // reset index and return immediately - self.index = index; - return false; + self.parse_keywords_(keywords).is_some() + } + + /// Just like [Self::parse_keywords], but - upon success - returns the + /// token index of the first keyword. + #[must_use] + fn parse_keywords_(&mut self, keywords: &[Keyword]) -> Option { + let start_index = self.index; + let mut first_keyword_index = None; + match keywords { + [keyword, keywords @ ..] if self.parse_keyword(*keyword) => { + first_keyword_index = Some(self.index.saturating_sub(1)); + for &keyword in keywords { + if !self.parse_keyword(keyword) { + self.index = start_index; + first_keyword_index = None; + break; + } + } } + _ => {} } - true + first_keyword_index } /// If the current token is one of the given `keywords`, returns the keyword @@ -14273,16 +14286,18 @@ impl<'a> Parser<'a> { pub fn maybe_parse_connect_by(&mut self) -> Result, ParserError> { let mut clauses = Vec::with_capacity(2); loop { - if self.parse_keywords(&[Keyword::START, Keyword::WITH]) { - clauses.push(ConnectByKind::StartWith(self.parse_expr()?.into())); - } else if self.parse_keywords(&[Keyword::CONNECT, Keyword::BY]) { - let nocycle = self.parse_keyword(Keyword::NOCYCLE); - let relationships = self.with_state(ParserState::ConnectBy, |parser| { - parser.parse_comma_separated(Parser::parse_expr) - })?; + if let Some(idx) = self.parse_keywords_(&[Keyword::START, Keyword::WITH]) { + clauses.push(ConnectByKind::StartWith { + start_token: self.token_at(idx).clone().into(), + condition: self.parse_expr()?.into(), + }); + } else if let Some(idx) = self.parse_keywords_(&[Keyword::CONNECT, Keyword::BY]) { clauses.push(ConnectByKind::ConnectBy { - relationships, - nocycle, + connect_token: self.token_at(idx).clone().into(), + nocycle: self.parse_keyword(Keyword::NOCYCLE), + relationships: self.with_state(ParserState::ConnectBy, |parser| { + parser.parse_comma_separated(Parser::parse_expr) + })?, }); } else { break; diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index c1c891ed96..148ef9c796 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -12686,8 +12686,9 @@ fn parse_connect_by() { window_before_qualify: false, value_table_mode: None, connect_by: vec![ - ConnectByKind::StartWith( - Expr::BinaryOp { + ConnectByKind::StartWith { + start_token: AttachedToken::empty(), + condition: Expr::BinaryOp { left: Box::new(Expr::Identifier(Ident::new("title"))), op: BinaryOperator::Eq, right: Box::new(Expr::Value( @@ -12695,8 +12696,10 @@ fn parse_connect_by() { )), } .into() - ), + }, ConnectByKind::ConnectBy { + connect_token: AttachedToken::empty(), + nocycle: false, relationships: vec![Expr::BinaryOp { left: Box::new(Expr::Identifier(Ident::new("manager_id"))), op: BinaryOperator::Eq, @@ -12704,7 +12707,6 @@ fn parse_connect_by() { "employee_id", ))))), }], - nocycle: false, } ], flavor: SelectFlavor::Standard, @@ -12752,6 +12754,8 @@ fn parse_connect_by() { value_table_mode: None, connect_by: vec![ ConnectByKind::ConnectBy { + connect_token: AttachedToken::empty(), + nocycle: false, relationships: vec![Expr::BinaryOp { left: Box::new(Expr::Identifier(Ident::new("manager_id"))), op: BinaryOperator::Eq, @@ -12759,15 +12763,19 @@ fn parse_connect_by() { "employee_id", ))))), }], - nocycle: false, }, - ConnectByKind::StartWith(Expr::BinaryOp { - left: Box::new(Expr::Identifier(Ident::new("title"))), - op: BinaryOperator::Eq, - right: Box::new(Expr::Value( - Value::SingleQuotedString("president".to_owned()).with_empty_span(), - )), - }.into())], + ConnectByKind::StartWith { + start_token: AttachedToken::empty(), + condition: Expr::BinaryOp { + left: Box::new(Expr::Identifier(Ident::new("title"))), + op: BinaryOperator::Eq, + right: Box::new(Expr::Value( + Value::SingleQuotedString("president".to_owned()).with_empty_span(), + )), + } + .into() + }, + ], flavor: SelectFlavor::Standard, } ); @@ -12817,14 +12825,20 @@ fn parse_connect_by() { window_before_qualify: false, value_table_mode: None, connect_by: vec![ - ConnectByKind::StartWith(Expr::BinaryOp { + ConnectByKind::StartWith { + start_token: AttachedToken::empty(), + condition: Expr::BinaryOp { left: Box::new(Expr::Identifier(Ident::new("title"))), op: BinaryOperator::Eq, right: Box::new(Expr::Value( (Value::SingleQuotedString("president".to_owned(),)).with_empty_span() )), - }.into()), + } + .into() + }, ConnectByKind::ConnectBy { + connect_token: AttachedToken::empty(), + nocycle: false, relationships: vec![Expr::BinaryOp { left: Box::new(Expr::Identifier(Ident::new("manager_id"))), op: BinaryOperator::Eq, @@ -12832,8 +12846,8 @@ fn parse_connect_by() { "employee_id", ))))), }], - nocycle: false, - }], + } + ], flavor: SelectFlavor::Standard, } ); @@ -12893,15 +12907,15 @@ fn parse_connect_by() { qualify: None, window_before_qualify: false, value_table_mode: None, - connect_by: vec![ - ConnectByKind::ConnectBy { - relationships: vec![Expr::BinaryOp { - left: Expr::Identifier(Ident::new("parent")).into(), - op: BinaryOperator::Eq, - right: Expr::Prior(Expr::Identifier(Ident::new("child")).into()).into(), - }], - nocycle: true, + connect_by: vec![ConnectByKind::ConnectBy { + connect_token: AttachedToken::empty(), + nocycle: true, + relationships: vec![Expr::BinaryOp { + left: Expr::Identifier(Ident::new("parent")).into(), + op: BinaryOperator::Eq, + right: Expr::Prior(Expr::Identifier(Ident::new("child")).into()).into(), }], + }], flavor: SelectFlavor::Standard, } ); From 11b454bb08a5df9625ff16ac9c6fdf13d8185fc2 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Tue, 3 Feb 2026 20:08:13 +0100 Subject: [PATCH 14/16] Rename method --- src/parser/mod.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index ff6c39e762..7f4659e4d7 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4521,13 +4521,13 @@ impl<'a> Parser<'a> { /// consumed and returns false #[must_use] pub fn parse_keywords(&mut self, keywords: &[Keyword]) -> bool { - self.parse_keywords_(keywords).is_some() + self.parse_keywords_indexed(keywords).is_some() } /// Just like [Self::parse_keywords], but - upon success - returns the /// token index of the first keyword. #[must_use] - fn parse_keywords_(&mut self, keywords: &[Keyword]) -> Option { + fn parse_keywords_indexed(&mut self, keywords: &[Keyword]) -> Option { let start_index = self.index; let mut first_keyword_index = None; match keywords { @@ -14286,12 +14286,13 @@ impl<'a> Parser<'a> { pub fn maybe_parse_connect_by(&mut self) -> Result, ParserError> { let mut clauses = Vec::with_capacity(2); loop { - if let Some(idx) = self.parse_keywords_(&[Keyword::START, Keyword::WITH]) { + if let Some(idx) = self.parse_keywords_indexed(&[Keyword::START, Keyword::WITH]) { clauses.push(ConnectByKind::StartWith { start_token: self.token_at(idx).clone().into(), condition: self.parse_expr()?.into(), }); - } else if let Some(idx) = self.parse_keywords_(&[Keyword::CONNECT, Keyword::BY]) { + } else if let Some(idx) = self.parse_keywords_indexed(&[Keyword::CONNECT, Keyword::BY]) + { clauses.push(ConnectByKind::ConnectBy { connect_token: self.token_at(idx).clone().into(), nocycle: self.parse_keyword(Keyword::NOCYCLE), From 2f5b533eeec288cd7ae507b765e27d0e5326c5a8 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Tue, 3 Feb 2026 20:11:59 +0100 Subject: [PATCH 15/16] Rewrite method --- src/parser/mod.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 7f4659e4d7..5b1f59900b 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4530,18 +4530,14 @@ impl<'a> Parser<'a> { fn parse_keywords_indexed(&mut self, keywords: &[Keyword]) -> Option { let start_index = self.index; let mut first_keyword_index = None; - match keywords { - [keyword, keywords @ ..] if self.parse_keyword(*keyword) => { + for &keyword in keywords { + if !self.parse_keyword(keyword) { + self.index = start_index; + return None; + } + if first_keyword_index.is_none() { first_keyword_index = Some(self.index.saturating_sub(1)); - for &keyword in keywords { - if !self.parse_keyword(keyword) { - self.index = start_index; - first_keyword_index = None; - break; - } - } } - _ => {} } first_keyword_index } From 2a70f792054cecd7f97245606aca706bd98a3436 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Thu, 5 Feb 2026 18:07:09 +0100 Subject: [PATCH 16/16] Cargo fmt --- tests/sqlparser_common.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 148ef9c796..4f63e4a3e3 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -12926,7 +12926,8 @@ fn parse_connect_by() { "SELECT 0 FROM t WHERE 1 = 1 START WITH 'a' = 'a' CONNECT BY 2 = 2 GROUP BY 3", ); dialects.verified_only_select( - "SELECT 0 FROM t WHERE 1 = 1 CONNECT BY 2 = 2 START WITH 'a' = 'a' GROUP BY 3"); + "SELECT 0 FROM t WHERE 1 = 1 CONNECT BY 2 = 2 START WITH 'a' = 'a' GROUP BY 3", + ); } #[test]