From 043285e19a4704d3f369e138764d44d16f013794 Mon Sep 17 00:00:00 2001 From: Andriy Romanov Date: Wed, 4 Mar 2026 14:42:08 -0800 Subject: [PATCH 1/4] Fixed CHANGES keyword parsing for snowflake --- src/ast/query.rs | 23 +++++++++++++++++++++++ src/keywords.rs | 1 + src/parser/mod.rs | 14 ++++++++++++++ tests/sqlparser_snowflake.rs | 28 ++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+) diff --git a/src/ast/query.rs b/src/ast/query.rs index 440928ed7..d588bbf44 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -2578,6 +2578,23 @@ pub enum TableVersion { /// When the table version is defined using a function. /// For example: `SELECT * FROM tbl AT(TIMESTAMP => '2020-08-14 09:30:00')` Function(Expr), + /// Snowflake CHANGES clause for change tracking queries. + /// For example: + /// ```sql + /// SELECT * FROM t + /// CHANGES(INFORMATION => DEFAULT) + /// AT(TIMESTAMP => TO_TIMESTAMP_TZ('...')) + /// END(TIMESTAMP => TO_TIMESTAMP_TZ('...')) + /// ``` + /// + Changes { + /// The `CHANGES(INFORMATION => ...)` function-call expression. + changes: Expr, + /// The `AT(TIMESTAMP => ...)` function-call expression. + at: Expr, + /// The optional `END(TIMESTAMP => ...)` function-call expression. + end: Option, + }, } impl Display for TableVersion { @@ -2587,6 +2604,12 @@ impl Display for TableVersion { TableVersion::TimestampAsOf(e) => write!(f, "TIMESTAMP AS OF {e}")?, TableVersion::VersionAsOf(e) => write!(f, "VERSION AS OF {e}")?, TableVersion::Function(func) => write!(f, "{func}")?, + TableVersion::Changes { changes, at, end } => { + write!(f, "{changes} {at}")?; + if let Some(end) = end { + write!(f, " {end}")?; + } + } } Ok(()) } diff --git a/src/keywords.rs b/src/keywords.rs index 9ea85fd3a..b47ab9db6 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -202,6 +202,7 @@ define_keywords!( CENTURY, CHAIN, CHANGE, + CHANGES, CHANGE_TRACKING, CHANNEL, CHAR, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 274449ff7..311e4d695 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -16305,6 +16305,20 @@ impl<'a> Parser<'a> { { let expr = self.parse_expr()?; return Ok(Some(TableVersion::ForSystemTimeAsOf(expr))); + } else if self.peek_keyword(Keyword::CHANGES) { + // Snowflake CHANGES clause: + // CHANGES(INFORMATION => ...) AT(...) [END(...)] + let changes_name = self.parse_object_name(true)?; + let changes = self.parse_function(changes_name)?; + let at_name = self.parse_object_name(true)?; + let at = self.parse_function(at_name)?; + let end = if self.peek_keyword(Keyword::END) { + let end_name = self.parse_object_name(true)?; + Some(self.parse_function(end_name)?) + } else { + None + }; + return Ok(Some(TableVersion::Changes { changes, at, end })); } else if self.peek_keyword(Keyword::AT) || self.peek_keyword(Keyword::BEFORE) { let func_name = self.parse_object_name(true)?; let func = self.parse_function(func_name)?; diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 265f8a9ae..c477c679c 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -3963,6 +3963,34 @@ fn test_timetravel_at_before() { .verified_only_select("SELECT * FROM tbl BEFORE(TIMESTAMP => '2024-12-15 00:00:00')"); } +#[test] +fn test_changes_clause() { + // CHANGES with AT and END + snowflake().verified_stmt( + "SELECT a FROM \"PCH_ODS_FIDELIO\".\"SRC_VW_SYS_ACC_MASTER\" CHANGES(INFORMATION => DEFAULT) AT(TIMESTAMP => TO_TIMESTAMP_TZ('2026-02-18 11:23:19.660000000')) END(TIMESTAMP => TO_TIMESTAMP_TZ('2026-02-18 11:38:30.211000000'))", + ); + + // CHANGES with AT only (no END) + snowflake().verified_stmt( + "SELECT a FROM t CHANGES(INFORMATION => DEFAULT) AT(TIMESTAMP => TO_TIMESTAMP_TZ('2026-02-18 11:23:19.660000000'))", + ); + + // CHANGES with APPEND_ONLY + snowflake().verified_stmt( + "SELECT a FROM t CHANGES(INFORMATION => APPEND_ONLY) AT(TIMESTAMP => TO_TIMESTAMP_TZ('2026-01-01 00:00:00'))", + ); + + // CHANGES with OFFSET + snowflake().verified_stmt( + "SELECT a FROM t CHANGES(INFORMATION => DEFAULT) AT(OFFSET => -60)", + ); + + // CHANGES with STATEMENT + snowflake().verified_stmt( + "SELECT a FROM t CHANGES(INFORMATION => DEFAULT) AT(STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726')", + ); +} + #[test] fn test_grant_account_global_privileges() { let privileges = vec![ From 55fe1b0f6e07340ac73adbe0b2690f2489e83242 Mon Sep 17 00:00:00 2001 From: Andriy Romanov Date: Wed, 4 Mar 2026 14:52:18 -0800 Subject: [PATCH 2/4] Fmt fix --- tests/sqlparser_snowflake.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index c477c679c..c32f140b9 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -3981,9 +3981,7 @@ fn test_changes_clause() { ); // CHANGES with OFFSET - snowflake().verified_stmt( - "SELECT a FROM t CHANGES(INFORMATION => DEFAULT) AT(OFFSET => -60)", - ); + snowflake().verified_stmt("SELECT a FROM t CHANGES(INFORMATION => DEFAULT) AT(OFFSET => -60)"); // CHANGES with STATEMENT snowflake().verified_stmt( From 319af8b7afa5b49bbb32defae8bb23ae3f08d238 Mon Sep 17 00:00:00 2001 From: Andriy Romanov Date: Mon, 9 Mar 2026 16:49:29 -0700 Subject: [PATCH 3/4] Update based on comments --- src/ast/query.rs | 2 +- src/parser/mod.rs | 38 ++++++++++++++++++++++++------------ tests/sqlparser_snowflake.rs | 2 +- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/ast/query.rs b/src/ast/query.rs index d588bbf44..ca74db440 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -2578,7 +2578,7 @@ pub enum TableVersion { /// When the table version is defined using a function. /// For example: `SELECT * FROM tbl AT(TIMESTAMP => '2020-08-14 09:30:00')` Function(Expr), - /// Snowflake CHANGES clause for change tracking queries. + /// Snowflake `CHANGES` clause for change tracking queries. /// For example: /// ```sql /// SELECT * FROM t diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 311e4d695..e11d8d6b5 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -16306,19 +16306,7 @@ impl<'a> Parser<'a> { let expr = self.parse_expr()?; return Ok(Some(TableVersion::ForSystemTimeAsOf(expr))); } else if self.peek_keyword(Keyword::CHANGES) { - // Snowflake CHANGES clause: - // CHANGES(INFORMATION => ...) AT(...) [END(...)] - let changes_name = self.parse_object_name(true)?; - let changes = self.parse_function(changes_name)?; - let at_name = self.parse_object_name(true)?; - let at = self.parse_function(at_name)?; - let end = if self.peek_keyword(Keyword::END) { - let end_name = self.parse_object_name(true)?; - Some(self.parse_function(end_name)?) - } else { - None - }; - return Ok(Some(TableVersion::Changes { changes, at, end })); + return self.parse_table_version_changes().map(Some); } else if self.peek_keyword(Keyword::AT) || self.peek_keyword(Keyword::BEFORE) { let func_name = self.parse_object_name(true)?; let func = self.parse_function(func_name)?; @@ -16334,6 +16322,30 @@ impl<'a> Parser<'a> { Ok(None) } + /// Parses the Snowflake `CHANGES` clause for change tracking queries. + /// + /// Syntax: + /// ```sql + /// CHANGES (INFORMATION => DEFAULT) + /// AT (TIMESTAMP => ) + /// [END (TIMESTAMP => )] + /// ``` + /// + /// + fn parse_table_version_changes(&mut self) -> Result { + let changes_name = self.parse_object_name(true)?; + let changes = self.parse_function(changes_name)?; + let at_name = self.parse_object_name(true)?; + let at = self.parse_function(at_name)?; + let end = if self.peek_keyword(Keyword::END) { + let end_name = self.parse_object_name(true)?; + Some(self.parse_function(end_name)?) + } else { + None + }; + Ok(TableVersion::Changes { changes, at, end }) + } + /// Parses MySQL's JSON_TABLE column definition. /// For example: `id INT EXISTS PATH '$' DEFAULT '0' ON EMPTY ERROR ON ERROR` pub fn parse_json_table_column_def(&mut self) -> Result { diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index c32f140b9..839a4107f 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -3967,7 +3967,7 @@ fn test_timetravel_at_before() { fn test_changes_clause() { // CHANGES with AT and END snowflake().verified_stmt( - "SELECT a FROM \"PCH_ODS_FIDELIO\".\"SRC_VW_SYS_ACC_MASTER\" CHANGES(INFORMATION => DEFAULT) AT(TIMESTAMP => TO_TIMESTAMP_TZ('2026-02-18 11:23:19.660000000')) END(TIMESTAMP => TO_TIMESTAMP_TZ('2026-02-18 11:38:30.211000000'))", + r#"SELECT a FROM "PCH_ODS_FIDELIO"."SRC_VW_SYS_ACC_MASTER" CHANGES(INFORMATION => DEFAULT) AT(TIMESTAMP => TO_TIMESTAMP_TZ('2026-02-18 11:23:19.660000000')) END(TIMESTAMP => TO_TIMESTAMP_TZ('2026-02-18 11:38:30.211000000'))"#, ); // CHANGES with AT only (no END) From c38312feeeb8f7d6b0e4d2543e84b0e00730b17e Mon Sep 17 00:00:00 2001 From: Andriy Romanov Date: Mon, 9 Mar 2026 17:00:19 -0700 Subject: [PATCH 4/4] Avoid tests error --- tests/sqlparser_snowflake.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 839a4107f..d39f92670 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -3208,7 +3208,10 @@ fn parse_view_column_descriptions() { #[test] fn test_parentheses_overflow() { - let max_nesting_level: usize = 25; + // Use a modest nesting level to avoid actual stack overflow on + // CI runners with small thread stacks (debug builds use large frames + // and each nesting level adds extra depth via maybe_parse). + let max_nesting_level: usize = 20; // Verify the recursion check is not too wasteful (num of parentheses within budget) let slack = 3;