From 9450333c8b84fc267d74130c229a326efd885107 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:20:53 +0000 Subject: [PATCH 01/14] Parse more recorded Snowflake functions, expressions and scripting (WIP, exact check pending) A qualified call takes a bare query argument, as data metric functions do (SNOWFLAKE.CORE.NULL_COUNT(SELECT c FROM t)); a call may leave out the schema (db..f(x)); the ! method syntax needs a name or MODEL( ) receiver and a method; stage references refuse @'x', @"x y" and @; INTERVAL types keep a precision of 1 to 9 (0 to 9 for seconds after TO) and need a field; DECLARE takes nested procedures; an unquoted reserved word is no later name part; BEGIN WORK/TRANSACTION NAME keeps the name. --- .../src/expressions/statements/models.rs | 14 ++ .../src/parser/_helpers/ddl/transactions.rs | 16 ++ .../_helpers/expressions/comparisons.rs | 4 +- .../src/parser/_helpers/expressions/paths.rs | 5 + .../parser/_helpers/expressions/primary.rs | 6 + .../src/parser/_helpers/functions/generic.rs | 3 + .../src/parser/_helpers/functions/mod.rs | 1 + .../_helpers/functions/snowflake_calls.rs | 140 ++++++++++++++++ .../parser/_helpers/statements/procedures.rs | 35 ++-- .../parser/_helpers/statements/scripting.rs | 26 ++- .../parser/_helpers/types/cast_base_types.rs | 1 + .../src/parser/_helpers/types/casts.rs | 46 +++++- .../sqlquack-syntax/src/parser/constants.rs | 9 ++ .../tests/parser/test_scripting.rs | 2 +- .../tests/parser/test_snowflake_recorded.rs | 150 ++++++++++++++++++ 15 files changed, 435 insertions(+), 23 deletions(-) create mode 100644 crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs diff --git a/crates/sqlquack-syntax/src/expressions/statements/models.rs b/crates/sqlquack-syntax/src/expressions/statements/models.rs index 130c6e502..01e83f960 100644 --- a/crates/sqlquack-syntax/src/expressions/statements/models.rs +++ b/crates/sqlquack-syntax/src/expressions/statements/models.rs @@ -1603,6 +1603,7 @@ pub enum ScriptingDeclaration { Cursor(ScriptingCursor), Resultset(ScriptingResultset), Exception(ScriptingException), + Procedure(Box), } /// `name [type] [{DEFAULT | :=} expression]`. @@ -1678,6 +1679,19 @@ pub struct ScriptingException { pub message: Option, } +/// `name PROCEDURE ( [ , ...] ) RETURNS { | TABLE ( ... ) } AS `. +#[derive(sqlquack_syntax_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ScriptingProcedure { + #[serde(default, skip_serializing_if = "SourceSpan::is_absent")] + #[ast(skip)] + pub span: SourceSpan, + pub name: Identifier, + pub parameters: Vec, + pub return_type: Option, + pub returns_table: Option>, + pub body: ScriptingBlock, +} + /// One body member, terminated by its own semicolon. #[derive(sqlquack_syntax_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/transactions.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/transactions.rs index e91ffacd6..9ac56d0e6 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/transactions.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/transactions.rs @@ -210,6 +210,22 @@ impl Parser { || self.check_identifier("IMMEDIATE") || self.check_identifier("EXCLUSIVE"); + let kind_then_name = (self.check(TokenType::Transaction) || self.check_identifier("WORK")) + && self + .peek_nth(1) + .is_some_and(|token| token.text.eq_ignore_ascii_case("NAME")); + if self.is_snowflake() && kind_then_name { + self.skip(); + self.skip(); + let name = self.expect_identifier_or_safe_keyword_with_quoted()?; + return Ok(Expression::Transaction(Box::new(Transaction { + this: Some(Box::new(Expression::Identifier(name))), + modes: None, + mark: Some(Box::new(Expression::Identifier(Identifier::new( + "TRANSACTION".to_string(), + )))), + }))); + } if self.is_snowflake() && self.check_identifier("NAME") { self.refuse_snowflake_nameless_transaction()?; self.skip(); diff --git a/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs b/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs index 420197bce..8f9a5ca52 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs @@ -1935,7 +1935,9 @@ impl Parser { } } - while self.match_token(TokenType::Exclamation) { + while self.check(TokenType::Exclamation) { + self.refuse_snowflake_bang_call(&expr)?; + self.skip(); let attr = self.parse_primary()?; expr = Expression::ModelAttribute(Box::new(ModelAttribute { this: Box::new(expr), diff --git a/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs b/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs index 6bd9eec38..174e70e5d 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs @@ -1088,6 +1088,10 @@ impl Parser { } if self.match_token(TokenType::Dot) { + if let Some(call) = self.parse_snowflake_default_schema_call(ident.clone())? { + return self.maybe_parse_subscript(call); + } + self.refuse_snowflake_reserved_part()?; if self.match_token(TokenType::Star) { let star = self.parse_star_modifiers(Some(ident))?; let star_expr = Expression::Star(star); @@ -1220,6 +1224,7 @@ impl Parser { if self.tokens.is_empty() { return Err(self.end_of_input_error()); } + self.refuse_snowflake_stage_reference()?; if self.check(TokenType::If) && !self.if_expr_ruled_out.contains(&self.current) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/expressions/primary.rs b/crates/sqlquack-syntax/src/parser/_helpers/expressions/primary.rs index 73fa84e38..abc3678e1 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/expressions/primary.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/expressions/primary.rs @@ -1547,6 +1547,12 @@ impl Parser { } if self.match_token(TokenType::Dot) { + if let Some(call) = self.parse_snowflake_default_schema_call( + Identifier::new(name.clone()).with_span(token.span), + )? { + return self.maybe_parse_subscript(call); + } + self.refuse_snowflake_reserved_part()?; if self.match_token(TokenType::Star) { let ident = Identifier::new(name).with_span(token.span); let star = self.parse_star_modifiers(Some(ident))?; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs index a72f3ad51..cc68e844a 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs @@ -437,6 +437,9 @@ impl Parser { } pub(crate) fn parse_method_arguments_for(&mut self, method: &str) -> Result> { + if let Some(query) = self.parse_snowflake_query_argument()? { + return Ok(vec![query]); + } self.parse_function_arguments_in_context(Some(method), true) } diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/mod.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/mod.rs index 7b14c7e93..9068476ef 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/mod.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/mod.rs @@ -3,5 +3,6 @@ pub(crate) mod generic; pub(crate) mod registry; pub(crate) mod registry_functions; +pub(crate) mod snowflake_calls; pub(crate) mod token_sets; pub(crate) mod typed; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs new file mode 100644 index 000000000..c6c81c937 --- /dev/null +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs @@ -0,0 +1,140 @@ +//! Snowflake call arguments the shared argument list does not read. + +use crate::expressions::names::models::Column; +use crate::expressions::names::models::Identifier; +use crate::expressions::queries::models::Subquery; +use crate::expressions::tree::models::Expression; +use crate::parser::models::Parser; +use crate::tokens::models::SourceSpan; +use crate::tokens::models::TokenType; +use crate::tokens::types::Result; + +impl Parser { + /// A qualified Snowflake call's lone query argument, as in `SNOWFLAKE.CORE.NULL_COUNT(SELECT c FROM t)`. + pub(crate) fn parse_snowflake_query_argument(&mut self) -> Result> { + let query_start = self.check(TokenType::Select) || self.check(TokenType::With); + let column = matches!( + self.peek_nth(1).map(|token| token.token_type), + Some(TokenType::Comma | TokenType::RParen | TokenType::Dot) + ); + if !self.is_snowflake() || !query_start || column { + return Ok(None); + } + let query = self.parse_statement()?; + Ok(Some(Expression::Subquery(Box::new(Subquery { + this: query, + alias: None, + column_aliases: Vec::new(), + alias_explicit_as: false, + alias_keyword: None, + order_by: None, + limit: None, + offset: None, + distribute_by: None, + sort_by: None, + cluster_by: None, + lateral: false, + modifiers_inside: false, + trailing_comments: Vec::new(), + inferred_type: None, + span: SourceSpan::default(), + })))) + } + + /// Refuses a Snowflake `!( )` without a name or `MODEL( )` receiver, or a method. + pub(crate) fn refuse_snowflake_bang_call(&self, receiver: &Expression) -> Result<()> { + if !self.is_snowflake() { + return Ok(()); + } + let model_args = match receiver { + Expression::Function(call) if call.name.eq_ignore_ascii_case("MODEL") => { + Some(&call.args) + } + _ => None, + }; + let bad_model = model_args.is_some_and(|args| { + args.is_empty() || args.iter().any(|arg| !matches!(arg, Expression::Column(_))) + }); + let bad_receiver = matches!( + receiver, + Expression::Literal(_) | Expression::Paren(_) | Expression::Tuple(_) + ); + let no_method = self.peek_nth(1).map(|token| token.token_type) == Some(TokenType::LParen); + match bad_model || bad_receiver || no_method { + true => { + Err(self.parse_error("Expected a name or MODEL( ) before ! and a method after it")) + } + false => Ok(()), + } + } + + /// Refuses a Snowflake stage reference `@'x'`, `@"x y"` with blanks in its quotes, or `@`. + pub(crate) fn refuse_snowflake_stage_reference(&self) -> Result<()> { + if !self.is_snowflake() { + return Ok(()); + } + let next = self.peek_nth(1); + let split = self.check(TokenType::DAt) + && next.is_some_and(|token| match token.token_type { + TokenType::String => true, + TokenType::QuotedIdentifier => token.text.contains(char::is_whitespace), + _ => false, + }); + let text = self.peek_text(); + let word = text + .strip_prefix('@') + .and_then(|rest| rest.split(['.', '/']).next()) + .unwrap_or_default() + .to_ascii_uppercase(); + let reserved = self.check(TokenType::Var) + && crate::parser::constants::SNOWFLAKE_RESERVED_KEYWORDS.contains(&word.as_str()); + match split || reserved { + true => Err(self.parse_error("Expected a stage name after @")), + false => Ok(()), + } + } + + /// Refuses an unquoted Snowflake reserved word as a later part of a dotted name, as in `a . SELECT`. + pub(crate) fn refuse_snowflake_reserved_part(&self) -> Result<()> { + let word = self.peek_text().to_ascii_uppercase(); + let reserved = !self.check(TokenType::QuotedIdentifier) + && crate::parser::constants::SNOWFLAKE_RESERVED_KEYWORDS.contains(&word.as_str()); + match self.is_snowflake() && reserved { + true => Err(self.parse_error(format!( + "Reserved word {word} cannot be an unquoted name part" + ))), + false => Ok(()), + } + } + + /// Snowflake's `..( ... )`, the schema left out; it is kept as an empty part. + pub(crate) fn parse_snowflake_default_schema_call( + &mut self, + database: Identifier, + ) -> Result> { + let call = self.check(TokenType::Dot) + && self.peek_nth(2).map(|token| token.token_type) == Some(TokenType::LParen); + if !self.is_snowflake() || !call { + return Ok(None); + } + self.skip(); + let method = self.expect_identifier_or_keyword_with_quoted()?; + self.expect_token(TokenType::LParen)?; + let args = match self.check(TokenType::RParen) { + true => Vec::new(), + false => self.parse_method_arguments_for(&method.name)?, + }; + self.expect_token(TokenType::RParen)?; + let schema = Expression::Column(Box::new(Column { + name: Identifier::new(String::new()), + table: Some(database), + join_mark: false, + trailing_comments: Vec::new(), + span: None, + inferred_type: None, + })); + Ok(Some( + self.parse_method_call_expression(schema, method, args), + )) + } +} diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/procedures.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/procedures.rs index 28a604189..d602505f7 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/procedures.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/procedures.rs @@ -8,6 +8,7 @@ use crate::expressions::schema_objects::models::CreateProcedure; use crate::expressions::schema_objects::models::FunctionBody; use crate::expressions::schema_objects::models::FunctionParameter; use crate::expressions::tree::models::Expression; +use crate::expressions::types::models::DataType; use crate::expressions::types::models::StructField; use crate::parser::models::Dialect; use crate::parser::models::Parser; @@ -30,18 +31,7 @@ impl Parser { pub(crate) fn parse_anonymous_procedure(&mut self, name: &Identifier) -> Result { self.expect_token(TokenType::Procedure)?; let parameters = self.parse_anonymous_procedure_parameters()?; - if !self.match_token(TokenType::Returns) { - return Err(self.parse_error("Expected RETURNS after the procedure parameters")); - } - let (return_type, returns_table) = if self.match_token(TokenType::Table) { - (None, Some(self.parse_procedure_table_columns()?)) - } else { - let data_type = self.parse_data_type()?; - if !self.match_keywords(&[TokenType::Not, TokenType::Null]) { - self.match_token(TokenType::Null); - } - (Some(data_type), None) - }; + let (return_type, returns_table) = self.parse_procedure_returns()?; if !self.match_token(TokenType::Language) { return Err(self.parse_error("Expected LANGUAGE after the procedure return type")); } @@ -60,8 +50,27 @@ impl Parser { Ok(Expression::CreateProcedure(Box::new(procedure))) } + /// `RETURNS { [[NOT] NULL] | TABLE ( ... ) }` after the parameters. + pub(crate) fn parse_procedure_returns( + &mut self, + ) -> Result<(Option, Option>)> { + if !self.match_token(TokenType::Returns) { + return Err(self.parse_error("Expected RETURNS after the procedure parameters")); + } + if self.match_token(TokenType::Table) { + return Ok((None, Some(self.parse_procedure_table_columns()?))); + } + let data_type = self.parse_data_type()?; + if !self.match_keywords(&[TokenType::Not, TokenType::Null]) { + self.match_token(TokenType::Null); + } + Ok((Some(data_type), None)) + } + /// `( [ [DEFAULT ]] [, ...] )` with no empty or trailing entries. - fn parse_anonymous_procedure_parameters(&mut self) -> Result> { + pub(crate) fn parse_anonymous_procedure_parameters( + &mut self, + ) -> Result> { self.expect_token(TokenType::LParen)?; let mut parameters: Vec = Vec::new(); while !self.check(TokenType::RParen) { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/scripting.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/scripting.rs index b3a48bbcd..1201c6a92 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/scripting.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/scripting.rs @@ -8,9 +8,9 @@ use crate::expressions::statements::models::{ ScriptingExceptionHandler, ScriptingFetch, ScriptingFor, ScriptingForIteration, ScriptingHandledExceptions, ScriptingHandlerAction, ScriptingIf, ScriptingJump, ScriptingJumpKeyword, ScriptingLet, ScriptingLoop, ScriptingLoopKeyword, ScriptingName, - ScriptingNull, ScriptingOpen, ScriptingRaise, ScriptingRange, ScriptingRepeat, - ScriptingResultset, ScriptingResultsetDefault, ScriptingReturn, ScriptingReturnValue, - ScriptingSql, ScriptingStatement, ScriptingVariable, ScriptingWhile, + ScriptingNull, ScriptingOpen, ScriptingProcedure, ScriptingRaise, ScriptingRange, + ScriptingRepeat, ScriptingResultset, ScriptingResultsetDefault, ScriptingReturn, + ScriptingReturnValue, ScriptingSql, ScriptingStatement, ScriptingVariable, ScriptingWhile, }; use crate::expressions::tree::models::Expression; use crate::parser::models::Parser; @@ -204,10 +204,24 @@ impl Parser { if !is_let && self.match_text_seq(&["EXCEPTION"]) { return self.parse_scripting_exception(start, name); } + if !is_let && self.match_text_seq(&["PROCEDURE"]) { + let parameters = self.parse_anonymous_procedure_parameters()?; + let (return_type, returns_table) = self.parse_procedure_returns()?; + self.expect_token(TokenType::As)?; + let body = self.with_parser_depth(Self::parse_scripting_block_members)?; + return Ok(ScriptingDeclaration::Procedure(Box::new( + ScriptingProcedure { + span: self.source_range(start), + name, + parameters, + return_type, + returns_table, + body, + }, + ))); + } if self.check_text_seq(&["PROCEDURE"]) { - return Err(self.parse_error( - "SCRIPTING_PENDING: nested procedure declarations are not parsed yet", - )); + return Err(self.parse_error("Expected DECLARE before a nested procedure")); } let data_type = if self.check(TokenType::ColonEq) || self.check_text_seq(&["DEFAULT"]) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/types/cast_base_types.rs b/crates/sqlquack-syntax/src/parser/_helpers/types/cast_base_types.rs index 625fdbf36..dcf40fd0d 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/types/cast_base_types.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/types/cast_base_types.rs @@ -458,6 +458,7 @@ impl Parser { } else { None }; + self.refuse_snowflake_bare_interval(&unit, &to)?; DataType::Interval { unit, to } }) } diff --git a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs index 6d03c529e..b6e44d377 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs @@ -232,9 +232,50 @@ impl Parser { name.eq_ignore_ascii_case("INT128") || name.eq_ignore_ascii_case("HUGEINT") } - /// Vertica interval qualifiers may carry a seconds precision: `SECOND(3)`. + /// Reads Snowflake's `( n )` after an interval field; Vertica's `SECOND(3)` is read elsewhere. pub(crate) fn parse_interval_field_precision(&mut self, unit: String) -> Result { - Ok(unit) + use crate::parser::constants::{ + SECOND_PREVIOUS_OFFSET, SNOWFLAKE_INTERVAL_FRACTION, SNOWFLAKE_INTERVAL_LEADING, + SNOWFLAKE_INTERVAL_SECOND, + }; + if !self.is_snowflake() || !self.check(TokenType::LParen) { + return Ok(unit); + } + let after_to = self.current >= SECOND_PREVIOUS_OFFSET + && self.tokens[self.current - SECOND_PREVIOUS_OFFSET].token_type == TokenType::To; + let range = match after_to && unit == SNOWFLAKE_INTERVAL_SECOND { + true => SNOWFLAKE_INTERVAL_FRACTION, + false => SNOWFLAKE_INTERVAL_LEADING, + }; + self.skip(); + let number = self.check(TokenType::Number); + match self.peek_text().parse::() { + Ok(digits) if number && range.contains(&digits) => { + self.skip(); + self.expect_token(TokenType::RParen)?; + Ok(format!("{unit}({digits})")) + } + _ => Err(self.parse_error(format!( + "Expected an interval precision from {} to {}", + range.start(), + range.end() + ))), + } + } + + /// Refuses Snowflake's INTERVAL type without a field, or with TO and no field after it. + pub(crate) fn refuse_snowflake_bare_interval( + &self, + unit: &Option, + to: &Option, + ) -> Result<()> { + let dangling_to = to.is_none() && self.previous().token_type == TokenType::To; + match self.is_snowflake() && (unit.is_none() || dangling_to) { + true => { + Err(self.parse_error("Expected an interval field such as DAY or YEAR TO MONTH")) + } + false => Ok(()), + } } /// Parse a data type. @@ -867,6 +908,7 @@ impl Parser { } else { None }; + self.refuse_snowflake_bare_interval(&unit, &to)?; Ok(DataType::Interval { unit, to }) } diff --git a/crates/sqlquack-syntax/src/parser/constants.rs b/crates/sqlquack-syntax/src/parser/constants.rs index 591fd9ed9..016ae49c9 100644 --- a/crates/sqlquack-syntax/src/parser/constants.rs +++ b/crates/sqlquack-syntax/src/parser/constants.rs @@ -875,3 +875,12 @@ pub(crate) const SNOWFLAKE_SHOW_REQUIRED_CONTAINERS: &[(&str, &[&str])] = &[ ], ), ]; + +/// The interval field whose precision after TO counts fractional digits. +pub(crate) const SNOWFLAKE_INTERVAL_SECOND: &str = "SECOND"; + +/// Snowflake's recorded leading-field interval precisions, as in `INTERVAL YEAR ( 9 )`. +pub(crate) const SNOWFLAKE_INTERVAL_LEADING: std::ops::RangeInclusive = 1..=9; + +/// Snowflake's recorded fractional-second precisions after TO, as in `DAY TO SECOND ( 0 )`. +pub(crate) const SNOWFLAKE_INTERVAL_FRACTION: std::ops::RangeInclusive = 0..=9; diff --git a/crates/sqlquack-syntax/tests/parser/test_scripting.rs b/crates/sqlquack-syntax/tests/parser/test_scripting.rs index 01581a8a8..395bf4314 100644 --- a/crates/sqlquack-syntax/tests/parser/test_scripting.rs +++ b/crates/sqlquack-syntax/tests/parser/test_scripting.rs @@ -250,7 +250,7 @@ fn given_recorded_block_verdicts_when_parsing_then_acceptance_matches_snowflake( ScriptingVerdictTestCase { description: "recorded rejected: FETCH without INTO", sql: "DECLARE\n first_amount NUMBER;\n orders_cursor CURSOR FOR SELECT 17;\nBEGIN\n OPEN orders_cursor;\n FETCH orders_cursor first_amount;\n RETURN first_amount;\nEND;", expected_error: Some("Expected Into") }, ScriptingVerdictTestCase { description: "recorded rejected: LOOP closed by END WHILE", sql: "BEGIN\n LOOP\n BREAK;\n END WHILE;\nEND;", expected_error: Some("Expected END LOOP") }, ScriptingVerdictTestCase { description: "recorded rejected: statement without semicolon before END", sql: "BEGIN\n RETURN 17\nEND;", expected_error: Some("Expected Semicolon") }, - ScriptingVerdictTestCase { description: "recorded accepted but not parsed yet: nested procedure stays an explicit gap", sql: "DECLARE\n adjust PROCEDURE (amount NUMBER) RETURNS NUMBER AS BEGIN RETURN amount + 17; END;\nBEGIN\n RETURN 31;\nEND;", expected_error: Some("SCRIPTING_PENDING") }, + ScriptingVerdictTestCase { description: "recorded accepted: a nested procedure declaration", sql: "DECLARE\n adjust PROCEDURE (amount NUMBER) RETURNS NUMBER AS BEGIN RETURN amount + 17; END;\nBEGIN\n RETURN 31;\nEND;", expected_error: None }, ScriptingVerdictTestCase { description: "recorded accepted: block closed by END IF", sql: "BEGIN\n RETURN 17;\nEND IF;", expected_error: None }, ScriptingVerdictTestCase { description: "recorded accepted: block closed by END CASE", sql: "BEGIN\n RETURN 17;\nEND CASE;", expected_error: None }, ScriptingVerdictTestCase { description: "recorded accepted: keyword label after END LOOP", sql: "BEGIN\n LOOP\n BREAK;\n END LOOP WHILE;\n RETURN 17;\nEND;", expected_error: None }, diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs index 70ae687f8..1a50a8b63 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs @@ -140,6 +140,126 @@ fn given_recorded_snowflake_cases_when_parsing_then_acceptance_matches_snowflake dialect: Dialect::Snowflake, expected_accepted: false, }, + AcceptanceTestCase { + description: "a data metric function takes a bare query (recorded semantic error)", + sql: "SELECT SNOWFLAKE . CORE . NULL_COUNT ( SELECT c1 FROM t1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "an unqualified call takes no bare query", + sql: "SELECT COUNT ( SELECT 1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a model method needs a name before ! (recorded errno 1003)", + sql: "SELECT ( m1 )!predict ( * ) FROM t1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "MODEL( ) takes names, not strings (recorded errno 1003)", + sql: "SELECT MODEL ( 'v1' )!predict ( 1 ) FROM t1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a method call needs a method after ! (recorded errno 1003)", + sql: "SELECT svc1! ( v1 , v1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "MODEL( )!method parses (recorded success)", + sql: "SELECT MODEL ( m1 , v1 )!predict ( 1 ) FROM t1 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a stage reference is no string (recorded errno 1003)", + sql: "SELECT GET_STAGE_LOCATION ( @'v1' ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a quoted stage name has no blanks (recorded errno 1003)", + sql: "SELECT GET_STAGE_LOCATION ( @\"v1 Q\" ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a stage name is no reserved word (recorded errno 1003)", + sql: "SELECT GET_STAGE_LOCATION ( @SELECT ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a quoted reserved stage name parses (recorded semantic error)", + sql: "SELECT GET_STAGE_LOCATION ( @\"SELECT\" ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "an interval type takes a precision (recorded success)", + sql: "SELECT CAST ( NULL AS INTERVAL DAY TO SECOND ( 3 ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "an interval leading precision is 1 to 9 (recorded errno 1003)", + sql: "SELECT CAST ( NULL AS INTERVAL YEAR ( 0 ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "seconds after TO take precision 0 (recorded success)", + sql: "SELECT CAST ( NULL AS INTERVAL DAY TO SECOND ( 0 ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "an interval type needs a field (recorded errno 1003)", + sql: "SELECT CAST ( NULL AS INTERVAL ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "TO needs a field after it (recorded errno 1003)", + sql: "SELECT CAST ( NULL AS INTERVAL DAY TO ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a block declares a nested procedure (recorded success)", + sql: "DECLARE p1 PROCEDURE ( v1 INT ) RETURNS TABLE ( c1 INT ) AS BEGIN RETURN 1 ; END ; BEGIN RETURN 1 ; END ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a reserved word is no later name part (recorded errno 1003)", + sql: "SELECT SYSTEM$GET_SERVICE_STATUS ( v1 . SELECT . svc1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a function call may leave out the schema (recorded semantic error)", + sql: "SELECT SNOWFLAKE . . AGENT_RUN ( v1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a name without a call keeps its schema (recorded errno 1003)", + sql: "SELECT SYSTEM$GET_SERVICE_STATUS ( v1 . . svc1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "BEGIN WORK NAME names the transaction (recorded success)", + sql: "BEGIN WORK NAME v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, ]; for test_case in test_cases { @@ -173,6 +293,36 @@ fn given_snowflake_create_and_begin_name_when_parsing_then_the_tree_keeps_the_el pointer: "/0/select/expressions/0/window_function/this/function/args", expected_value: json!([]), }, + ProjectionTestCase { + description: "BEGIN WORK NAME keeps the name", + sql: "BEGIN WORK NAME \"v1 Q\"", + pointer: "/0/transaction/this/identifier/quoted", + expected_value: json!(true), + }, + ProjectionTestCase { + description: "an interval type keeps its precision", + sql: "SELECT CAST ( NULL AS INTERVAL DAY TO SECOND ( 3 ) )", + pointer: "/0/select/expressions/0/cast/to/to", + expected_value: json!("SECOND(3)"), + }, + ProjectionTestCase { + description: "a nested procedure keeps its return columns", + sql: "DECLARE p1 PROCEDURE ( ) RETURNS TABLE ( c1 INT ) AS BEGIN RETURN 1 ; END ; BEGIN RETURN 1 ; END ;", + pointer: "/0/scripting_block/declarations/0/procedure/returns_table/0/name", + expected_value: json!("c1"), + }, + ProjectionTestCase { + description: "a call without a schema keeps the empty part", + sql: "SELECT d1 . . f1 ( 1 )", + pointer: "/0/select/expressions/0/method_call/this/column/name/name", + expected_value: json!(""), + }, + ProjectionTestCase { + description: "a data metric function keeps its query argument", + sql: "SELECT SNOWFLAKE . CORE . NULL_COUNT ( SELECT c1 FROM t1 )", + pointer: "/0/select/expressions/0/method_call/args/0/subquery/this/select/from/expressions/0/table/name/name", + expected_value: json!("t1"), + }, ]; for test_case in test_cases { From 61edb8086321a270b00fc73297259617940e2eac Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:29:50 +0000 Subject: [PATCH 02/14] Match Snowflake on ESCAPE aliases, bind names, bare stage arguments and empty lambdas A trailing ESCAPE with nothing after it is an alias, : is a bind variable and :"x" is refused, a bare @ before , or ) is a stage argument, and ( ) -> x is refused. Two corpus inputs the stage-name refusal now rejects are listed as expected rejections. --- .../polyglot-check/expected_differences.toml | 14 ++++++++ .../tests/runs/test_exact.rs | 2 ++ .../_helpers/expressions/comparisons.rs | 16 ++++----- .../src/parser/_helpers/expressions/paths.rs | 6 ++-- .../parser/_helpers/expressions/primary.rs | 13 ++++++- .../_helpers/functions/snowflake_calls.rs | 25 +++++++++++++ .../tests/parser/test_snowflake_recorded.rs | 36 +++++++++++++++++++ 7 files changed, 100 insertions(+), 12 deletions(-) diff --git a/corpora/polyglot-check/expected_differences.toml b/corpora/polyglot-check/expected_differences.toml index f0cbc4f69..ff5a07e4c 100644 --- a/corpora/polyglot-check/expected_differences.toml +++ b/corpora/polyglot-check/expected_differences.toml @@ -1130,3 +1130,17 @@ id = "snowflake_scripting_block" marker = "BEGIN" sqlquack_node = "scripting_block" note = "a Snowflake Scripting block parses into its declarations, statements and handlers; crates/sqlquack-syntax/tests/parser/test_scripting.rs pins the tree" + +[[rejection]] +id = "snowflake_stage_reserved_name" +sql = "SELECT @select" +dialects = ["Snowflake"] +sqlquack_error = "Expected a stage name after @" +note = "Snowflake reads @ as a stage reference and refuses a bare reserved word as the stage name (recorded syntax_error)" + +[[rejection]] +id = "snowflake_stage_quoted_blank_name" +sql = "SELECT @`a b`" +dialects = ["Snowflake"] +sqlquack_error = "Expected a stage name after @" +note = "Snowflake refuses a stage reference whose quoted name holds a blank (recorded syntax_error); backticks are no Snowflake quote either" diff --git a/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs b/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs index a492a1020..a67a24842 100644 --- a/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs +++ b/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs @@ -49,6 +49,8 @@ fn given_committed_corpora_when_parsed_by_both_parsers_then_results_are_identica ("snowflake_show_profile".to_owned(), 1), ("snowflake_show_groups".to_owned(), 1), ("snowflake_show_create_table".to_owned(), 1), + ("snowflake_stage_reserved_name".to_owned(), 1), + ("snowflake_stage_quoted_blank_name".to_owned(), 1), ]), differences: Vec::new(), tokens_lost: Vec::new(), diff --git a/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs b/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs index 8f9a5ca52..266c328e8 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs @@ -395,7 +395,7 @@ impl Parser { None }; let right = self.parse_bitwise_or()?; - let escape = if self.match_token(TokenType::Escape) { + let escape = if self.match_like_escape() { Some(self.parse_primary()?) } else { None @@ -424,7 +424,7 @@ impl Parser { None }; let right = self.parse_bitwise_or()?; - let escape = if self.match_token(TokenType::Escape) { + let escape = if self.match_like_escape() { Some(self.parse_primary()?) } else { None @@ -446,7 +446,7 @@ impl Parser { self.skip(); self.skip(); let pattern = self.parse_bitwise_or()?; - let escape = if self.match_token(TokenType::Escape) { + let escape = if self.match_like_escape() { Some(self.parse_primary()?) } else { None @@ -479,7 +479,7 @@ impl Parser { self.skip(); self.skip(); let pattern = self.parse_bitwise_or()?; - let escape = if self.match_token(TokenType::Escape) { + let escape = if self.match_like_escape() { Some(self.parse_primary()?) } else { None @@ -496,7 +496,7 @@ impl Parser { fn parse_not_like_comparison(&mut self, left: Expression) -> Result { Ok({ let right = self.parse_bitwise_or()?; - let escape = if self.match_token(TokenType::Escape) { + let escape = if self.match_like_escape() { Some(self.parse_primary()?) } else { None @@ -517,7 +517,7 @@ impl Parser { fn parse_not_ilike_comparison(&mut self, left: Expression) -> Result { Ok({ let right = self.parse_bitwise_or()?; - let escape = if self.match_token(TokenType::Escape) { + let escape = if self.match_like_escape() { Some(self.parse_primary()?) } else { None @@ -720,7 +720,7 @@ impl Parser { None }; let right = self.parse_bitwise_or()?; - let escape = if self.match_token(TokenType::Escape) { + let escape = if self.match_like_escape() { Some(self.parse_primary()?) } else { None @@ -750,7 +750,7 @@ impl Parser { None }; let right = self.parse_bitwise_or()?; - let escape = if self.match_token(TokenType::Escape) { + let escape = if self.match_like_escape() { Some(self.parse_primary()?) } else { None diff --git a/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs b/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs index 174e70e5d..66b06e15a 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs @@ -647,6 +647,9 @@ impl Parser { fn parse_empty_parentheses(&mut self) -> Result { self.skip(); + if self.config.dialect == Some(crate::tokens::models::DialectType::Snowflake) { + return Err(self.parse_error("Expected an expression in the parentheses")); + } if self.config.dialect != Some(crate::tokens::models::DialectType::DuckDB) && (self.match_token(TokenType::Arrow) || self.match_token(TokenType::FArrow)) { @@ -658,9 +661,6 @@ impl Parser { parameter_types: Vec::new(), }))); } - if self.config.dialect == Some(crate::tokens::models::DialectType::Snowflake) { - return Err(self.parse_error("Expected an expression in the parentheses")); - } self.maybe_parse_subscript(Expression::Tuple(Box::new(Tuple { expressions: Vec::new(), }))) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/expressions/primary.rs b/crates/sqlquack-syntax/src/parser/_helpers/expressions/primary.rs index abc3678e1..f994654a9 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/expressions/primary.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/expressions/primary.rs @@ -1672,6 +1672,10 @@ impl Parser { } else if self.check(TokenType::Number) || self.peek().token_type.is_keyword() { let token = self.advance()?; (token.text, false, false) + } else if self.is_snowflake() + && (self.check(TokenType::Comma) || self.check(TokenType::RParen)) + { + return Ok(Expression::column("@")); } else { return Err(self.parse_error("Expected variable name after @")); }; @@ -1700,7 +1704,14 @@ impl Parser { } return Err(self.parse_error(format!("Invalid colon parameter: :{}", num_token.text))); } - if self.is_identifier_token() || self.is_safe_keyword_as_identifier() { + let snowflake = self.is_snowflake(); + if snowflake && self.check(TokenType::QuotedIdentifier) { + return Err(self.parse_error("Expected an unquoted bind variable name after :")); + } + if self.is_identifier_token() + || self.is_safe_keyword_as_identifier() + || (snowflake && self.peek().token_type.is_keyword()) + { let name = self.advance_text()?; Ok(Expression::Parameter(Box::new(Parameter { name: Some(name), diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs index c6c81c937..4172e3953 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs @@ -10,6 +10,31 @@ use crate::tokens::models::TokenType; use crate::tokens::types::Result; impl Parser { + /// Consumes a pattern's `ESCAPE`, which Snowflake reads as an alias when nothing follows it. + pub(crate) fn match_like_escape(&mut self) -> bool { + let alias = self.is_snowflake() + && matches!( + self.peek_nth(1).map(|token| token.token_type), + None | Some( + TokenType::Eof + | TokenType::Semicolon + | TokenType::Comma + | TokenType::RParen + | TokenType::From + | TokenType::Where + | TokenType::Group + | TokenType::GroupBy + | TokenType::Order + | TokenType::OrderBy + | TokenType::Having + | TokenType::Qualify + | TokenType::Limit + | TokenType::Union + ) + ); + self.check(TokenType::Escape) && !alias && self.match_token(TokenType::Escape) + } + /// A qualified Snowflake call's lone query argument, as in `SNOWFLAKE.CORE.NULL_COUNT(SELECT c FROM t)`. pub(crate) fn parse_snowflake_query_argument(&mut self) -> Result> { let query_start = self.check(TokenType::Select) || self.check(TokenType::With); diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs index 1a50a8b63..d062b6fff 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs @@ -260,6 +260,42 @@ fn given_recorded_snowflake_cases_when_parsing_then_acceptance_matches_snowflake dialect: Dialect::Snowflake, expected_accepted: true, }, + AcceptanceTestCase { + description: "a trailing ESCAPE is an alias (recorded semantic error)", + sql: "SELECT v1 LIKE ANY ( 'm' ) ESCAPE ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "ESCAPE takes a string (recorded success)", + sql: "SELECT v1 ILIKE 'm' ESCAPE '!' ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a lambda needs a parameter (recorded errno 1003)", + sql: "SELECT ACCUMULATE ( 1 , ( ) -> 0 , ( s , x ) -> s + x , ( s1 , s2 ) -> s1 + s2 , s -> s ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a bind variable is no quoted name (recorded errno 1003)", + sql: "SELECT :\"v1 Q\" ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a bind variable may be a keyword (recorded semantic error)", + sql: "SELECT :SELECT ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a bare @ is a stage argument (recorded semantic error)", + sql: "SELECT GET_STAGE_LOCATION ( @) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, ]; for test_case in test_cases { From 832a76879e8a6dd4fa83700a0c60bd74da5ca9b1 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:48:40 +0000 Subject: [PATCH 03/14] Parse Snowflake's flow operator, list-valued ANY/ALL and empty STRING( ) ->> chains statements as an Operator node and is no longer a JSON operator in Snowflake expressions. ANY/ALL take a value list, STRING( ) takes empty parentheses, and only SELECT is refused as a later dotted name part, the one word recorded. Corpus inputs refused by recorded rules (a bare INTERVAL type, ->> inside expressions, a string stage name) are listed as expected rejections. --- .../polyglot-check/expected_differences.toml | 140 ++++++++++++++++++ .../tests/runs/test_exact.rs | 26 +++- .../_helpers/ddl/snowflake_statements.rs | 17 +++ .../_helpers/expressions/comparisons.rs | 8 +- .../src/parser/_helpers/functions/generic.rs | 1 + .../_helpers/functions/snowflake_calls.rs | 21 ++- .../src/parser/_helpers/statements/entry.rs | 3 +- .../parser/_helpers/statements/scripting.rs | 2 +- .../src/parser/_helpers/types/casts.rs | 7 + .../sqlquack-syntax/src/parser/constants.rs | 3 + .../tests/parser/test_snowflake_recorded.rs | 36 +++++ 11 files changed, 257 insertions(+), 7 deletions(-) diff --git a/corpora/polyglot-check/expected_differences.toml b/corpora/polyglot-check/expected_differences.toml index 1202b7d85..03d46be2e 100644 --- a/corpora/polyglot-check/expected_differences.toml +++ b/corpora/polyglot-check/expected_differences.toml @@ -1156,3 +1156,143 @@ sql = "SELECT @`a b`" dialects = ["Snowflake"] sqlquack_error = "Expected a stage name after @" note = "Snowflake refuses a stage reference whose quoted name holds a blank (recorded syntax_error); backticks are no Snowflake quote either" + +[[rejection]] +id = "snowflake_stage_string_name" +sql = "SELECT @JOIN, @'foo'" +dialects = ["Snowflake"] +sqlquack_error = "Expected a stage name after @" +note = "Snowflake reads @ as a stage reference and refuses a string as the stage name (recorded syntax_error for @'v1')" + +[[rejection]] +id = "snowflake_bare_interval_type_double_colon" +sql = "SELECT interval '00:00:01'::interval AS foo" +dialects = ["Snowflake"] +sqlquack_error = "Expected an interval field such as DAY or YEAR TO MONTH" +note = "Snowflake's INTERVAL type needs a field (recorded errno 1003 for CAST ( NULL AS INTERVAL ))" + +[[rejection]] +id = "snowflake_bare_interval_type_cast" +sql = "SELECT CAST(INTERVAL '00:00:01' AS INTERVAL) AS foo" +dialects = ["Snowflake"] +sqlquack_error = "Expected an interval field such as DAY or YEAR TO MONTH" +note = "Snowflake's INTERVAL type needs a field (recorded errno 1003 for CAST ( NULL AS INTERVAL ))" + +[[rejection]] +id = "snowflake_bare_interval_type_double_colon_window" +sql = "SELECT ROW_NUMBER() OVER(PARTITION BY event_time + interval '00:00:01'::interval) AS foo FROM t" +dialects = ["Snowflake"] +sqlquack_error = "Expected an interval field such as DAY or YEAR TO MONTH" +note = "Snowflake's INTERVAL type needs a field (recorded errno 1003 for CAST ( NULL AS INTERVAL ))" + +[[rejection]] +id = "snowflake_bare_interval_type_cast_window" +sql = "SELECT ROW_NUMBER() OVER (PARTITION BY event_time + CAST(INTERVAL '00:00:01' AS INTERVAL)) AS foo FROM t" +dialects = ["Snowflake"] +sqlquack_error = "Expected an interval field such as DAY or YEAR TO MONTH" +note = "Snowflake's INTERVAL type needs a field (recorded errno 1003 for CAST ( NULL AS INTERVAL ))" + +[[rejection]] +id = "snowflake_bare_interval_type_cast_string" +sql = "CAST('1 DAY' AS INTERVAL)" +dialects = ["Snowflake"] +sqlquack_error = "Expected an interval field such as DAY or YEAR TO MONTH" +note = "Snowflake's INTERVAL type needs a field (recorded errno 1003 for CAST ( NULL AS INTERVAL ))" + +[[rejection]] +id = "snowflake_flow_operator_in_expression_1" +sql = "SELECT col FROM t WHERE NOT (col ->> '$.id') IN ('b')" +dialects = ["Snowflake"] +sqlquack_error = "Expected RParen, got DArrow" +note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" + +[[rejection]] +id = "snowflake_flow_operator_in_expression_2" +sql = "SELECT '[' || 1 || ']' ->> 0" +dialects = ["Snowflake"] +sqlquack_error = "Expected a statement, not an expression" +note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" + +[[rejection]] +id = "snowflake_flow_operator_in_expression_3" +sql = "SELECT '[' || 1 || ']' -> 0 ->> 0" +dialects = ["Snowflake"] +sqlquack_error = "Expected a statement, not an expression" +note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" + +[[rejection]] +id = "snowflake_flow_operator_in_expression_4" +sql = "SELECT '[1]' ->> 0 || 'x'" +dialects = ["Snowflake"] +sqlquack_error = "Expected a statement, not an expression" +note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" + +[[rejection]] +id = "snowflake_flow_operator_in_expression_5" +sql = "SELECT payload ->> 0 + 1" +dialects = ["Snowflake"] +sqlquack_error = "Expected a statement, not an expression" +note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" + +[[rejection]] +id = "snowflake_flow_operator_in_expression_6" +sql = "SELECT payload ->> 'a' || 'b'" +dialects = ["Snowflake"] +sqlquack_error = "Expected a statement, not an expression" +note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" + +[[rejection]] +id = "snowflake_flow_operator_in_expression_7" +sql = "SELECT payload ->> 0 + 1 = other" +dialects = ["Snowflake"] +sqlquack_error = "Expected a statement, not an expression" +note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" + +[[rejection]] +id = "snowflake_flow_operator_in_expression_8" +sql = "SELECT payload -> 'a' ->> 0" +dialects = ["Snowflake"] +sqlquack_error = "Expected a statement, not an expression" +note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" + +[[rejection]] +id = "snowflake_flow_operator_in_expression_9" +sql = "SELECT payload -> 'a' || 'b' ->> 0" +dialects = ["Snowflake"] +sqlquack_error = "Expected a statement, not an expression" +note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" + +[[rejection]] +id = "snowflake_flow_operator_in_expression_10" +sql = "SELECT payload -> ('a' || ('b' ->> 0))" +dialects = ["Snowflake"] +sqlquack_error = "Expected RParen, got DArrow" +note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" + +[[rejection]] +id = "snowflake_flow_operator_in_expression_11" +sql = "SELECT payload -> concat('a', other ->> 0)" +dialects = ["Snowflake"] +sqlquack_error = "Expected RParen, got DArrow" +note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" + +[[rejection]] +id = "snowflake_type_bare_interval_cast" +sql = "SELECT CAST(x AS INTERVAL)" +dialects = ["Snowflake"] +sqlquack_error = "Expected an interval field such as DAY or YEAR TO MONTH" +note = "Snowflake's INTERVAL type needs a field (recorded errno 1003 for CAST ( NULL AS INTERVAL ))" + +[[rejection]] +id = "snowflake_type_bare_interval_try_cast" +sql = "SELECT TRY_CAST(x AS INTERVAL)" +dialects = ["Snowflake"] +sqlquack_error = "Expected an interval field such as DAY or YEAR TO MONTH" +note = "Snowflake's INTERVAL type needs a field (recorded errno 1003 for CAST ( NULL AS INTERVAL ))" + +[[rejection]] +id = "snowflake_type_bare_interval_double_colon" +sql = "SELECT x::INTERVAL" +dialects = ["Snowflake"] +sqlquack_error = "Expected an interval field such as DAY or YEAR TO MONTH" +note = "Snowflake's INTERVAL type needs a field (recorded errno 1003 for CAST ( NULL AS INTERVAL ))" diff --git a/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs b/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs index a67a24842..ec23a86f0 100644 --- a/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs +++ b/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs @@ -51,6 +51,17 @@ fn given_committed_corpora_when_parsed_by_both_parsers_then_results_are_identica ("snowflake_show_create_table".to_owned(), 1), ("snowflake_stage_reserved_name".to_owned(), 1), ("snowflake_stage_quoted_blank_name".to_owned(), 1), + ("snowflake_flow_operator_in_expression_1".to_owned(), 1), + ("snowflake_flow_operator_in_expression_2".to_owned(), 1), + ("snowflake_flow_operator_in_expression_3".to_owned(), 1), + ("snowflake_flow_operator_in_expression_4".to_owned(), 1), + ("snowflake_flow_operator_in_expression_5".to_owned(), 1), + ("snowflake_flow_operator_in_expression_6".to_owned(), 1), + ("snowflake_flow_operator_in_expression_7".to_owned(), 1), + ("snowflake_flow_operator_in_expression_8".to_owned(), 1), + ("snowflake_flow_operator_in_expression_9".to_owned(), 1), + ("snowflake_flow_operator_in_expression_10".to_owned(), 1), + ("snowflake_flow_operator_in_expression_11".to_owned(), 1), ]), differences: Vec::new(), tokens_lost: Vec::new(), @@ -89,6 +100,15 @@ fn given_committed_corpora_when_parsed_by_both_parsers_then_results_are_identica ("snowflake_describe_formatted".to_owned(), 1), ("snowflake_describe_query".to_owned(), 1), ("snowflake_describe_bare_quoted_name".to_owned(), 1), + ("snowflake_stage_string_name".to_owned(), 1), + ("snowflake_bare_interval_type_double_colon".to_owned(), 1), + ("snowflake_bare_interval_type_cast".to_owned(), 1), + ( + "snowflake_bare_interval_type_double_colon_window".to_owned(), + 1, + ), + ("snowflake_bare_interval_type_cast_window".to_owned(), 1), + ("snowflake_bare_interval_type_cast_string".to_owned(), 1), ]), differences: Vec::new(), tokens_lost: Vec::new(), @@ -159,7 +179,11 @@ fn given_committed_corpora_when_parsed_by_both_parsers_then_results_are_identica entries: load_corpus(&corpora.join("snowflake_types.json")), expected_outcome: ExactOutcome { loaded: true, - rejected: BTreeMap::new(), + rejected: BTreeMap::from([ + ("snowflake_type_bare_interval_cast".to_owned(), 1), + ("snowflake_type_bare_interval_try_cast".to_owned(), 1), + ("snowflake_type_bare_interval_double_colon".to_owned(), 1), + ]), differences: Vec::new(), tokens_lost: Vec::new(), type_names: Vec::new(), diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_statements.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_statements.rs index 59f099ce8..cbbf42848 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_statements.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_statements.rs @@ -1,5 +1,7 @@ //! Snowflake's rules on session, transaction, EXPLAIN and SHOW statements. +use crate::expressions::functions::models::Operator; +use crate::expressions::names::models::Identifier; use crate::expressions::statements::models::Describe; use crate::expressions::tree::models::Expression; use crate::parser::constants::{SHARED_DESCRIBE_KINDS, SNOWFLAKE_DESCRIBE_KINDS}; @@ -13,6 +15,21 @@ impl Parser { self.config.dialect == Some(crate::tokens::models::DialectType::Snowflake) } + /// Chains Snowflake statements with the flow operator, as in `SELECT 1 ->> SELECT * FROM $1`. + pub(crate) fn parse_snowflake_flow(&mut self, first: Expression) -> Result { + let mut flow = first; + while self.is_snowflake() && self.match_token(TokenType::DArrow) { + let next = self.parse_statement()?; + flow = Expression::Operator(Box::new(Operator { + this: Box::new(flow), + operator: Some(Box::new(Expression::Identifier(Identifier::new("->>")))), + expression: Box::new(next), + comments: Vec::new(), + })); + } + Ok(flow) + } + /// Refuses Snowflake's `START TRANSACTION NAME` without a name after it. pub(crate) fn refuse_snowflake_nameless_transaction(&self) -> Result<()> { let named = self.peek_nth(1).is_some_and(|token| { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs b/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs index 266c328e8..f9fc5b918 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs @@ -909,7 +909,7 @@ impl Parser { if self.match_token(TokenType::Any) || self.match_token(TokenType::Some) { let was_any = self.previous_token_type() == Some(TokenType::Any); self.expect_token(TokenType::LParen)?; - let inner = self.parse_statement()?; + let inner = self.parse_quantified_operand()?; self.expect_token(TokenType::RParen)?; let subquery = if was_any { self.maybe_wrap_in_subquery(inner) @@ -923,7 +923,7 @@ impl Parser { })) } else if self.match_token(TokenType::All) { self.expect_token(TokenType::LParen)?; - let inner = self.parse_statement()?; + let inner = self.parse_quantified_operand()?; self.expect_token(TokenType::RParen)?; let subquery = self.maybe_wrap_in_subquery(inner); Expression::All(Box::new(QuantifiedExpr { @@ -1445,7 +1445,9 @@ impl Parser { inferred_type: None, span: SourceSpan::default(), })); - } else if self.check(TokenType::Arrow) || self.check(TokenType::DArrow) { + } else if self.check(TokenType::Arrow) + || (self.check(TokenType::DArrow) && !self.is_snowflake()) + { let scalar = self.match_token(TokenType::DArrow); if !scalar { self.skip(); diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs index cc68e844a..f592a26d1 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs @@ -1010,6 +1010,7 @@ impl Parser { self.config.dialect, Some(crate::tokens::models::DialectType::DuckDB) | Some(crate::tokens::models::DialectType::SQLite) + | Some(crate::tokens::models::DialectType::Snowflake) ) && self.match_token(TokenType::DArrow) { let path = self.parse_json_path_operand()?; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs index 4172e3953..2d38fc215 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs @@ -10,6 +10,25 @@ use crate::tokens::models::TokenType; use crate::tokens::types::Result; impl Parser { + /// The operand of `ANY`/`ALL`, which Snowflake also reads as a list of values, as in `1 = ALL ( 1 )`. + pub(crate) fn parse_quantified_operand(&mut self) -> Result { + let query = self.check(TokenType::Select) + || self.check(TokenType::With) + || self.check(TokenType::LParen); + if !self.is_snowflake() || query { + return self.parse_statement(); + } + let mut values = self.parse_expression_list()?; + match values.len() { + 1 => Ok(values.remove(0)), + _ => Ok(Expression::Tuple(Box::new( + crate::expressions::types::models::Tuple { + expressions: values, + }, + ))), + } + } + /// Consumes a pattern's `ESCAPE`, which Snowflake reads as an alias when nothing follows it. pub(crate) fn match_like_escape(&mut self) -> bool { let alias = self.is_snowflake() @@ -123,7 +142,7 @@ impl Parser { pub(crate) fn refuse_snowflake_reserved_part(&self) -> Result<()> { let word = self.peek_text().to_ascii_uppercase(); let reserved = !self.check(TokenType::QuotedIdentifier) - && crate::parser::constants::SNOWFLAKE_RESERVED_KEYWORDS.contains(&word.as_str()); + && crate::parser::constants::SNOWFLAKE_REFUSED_NAME_PARTS.contains(&word.as_str()); match self.is_snowflake() && reserved { true => Err(self.parse_error(format!( "Reserved word {word} cannot be an unquoted name part" diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/entry.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/entry.rs index b35b61b27..a1f9f46d3 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/entry.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/entry.rs @@ -269,7 +269,8 @@ impl Parser { while !self.is_at_end() { let start = self.current; - let mut stmt = self.parse_statement()?; + let first = self.parse_statement()?; + let mut stmt = self.parse_snowflake_flow(first)?; stmt = self.spanned(start, stmt); if self.check(TokenType::Semicolon) { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/scripting.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/scripting.rs index 1201c6a92..05dcaa1a2 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/scripting.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/scripting.rs @@ -754,7 +754,7 @@ impl Parser { /// An optional label, written directly before the statement's semicolon. fn parse_scripting_label(&mut self) -> Result> { - if self.is_at_end() || self.check(TokenType::Semicolon) { + if self.is_at_end() || self.check(TokenType::Semicolon) || self.check(TokenType::DArrow) { return Ok(None); } self.expect_scripting_name().map(Some) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs index b6e44d377..ea81605d5 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs @@ -403,6 +403,13 @@ impl Parser { } } "STRING" => { + let empty = self.is_snowflake() + && self.check(TokenType::LParen) + && self.check_next(TokenType::RParen); + if empty { + self.skip(); + self.skip(); + } let length = if self.match_token(TokenType::LParen) { let n = self.expect_number()? as u32; self.expect_token(TokenType::RParen)?; diff --git a/crates/sqlquack-syntax/src/parser/constants.rs b/crates/sqlquack-syntax/src/parser/constants.rs index 016ae49c9..f9d5df977 100644 --- a/crates/sqlquack-syntax/src/parser/constants.rs +++ b/crates/sqlquack-syntax/src/parser/constants.rs @@ -227,6 +227,9 @@ pub(crate) const SQLITE_RANGE_FOLLOWERS: [crate::tokens::models::TokenType; 9] = crate::tokens::models::TokenType::Match, ]; +/// Reserved words Snowflake is recorded refusing as an unquoted later part of a dotted name. +pub(crate) const SNOWFLAKE_REFUSED_NAME_PARTS: &[&str] = &["SELECT"]; + /// Snowflake's fully reserved keywords; its position-limited keywords are not listed. pub(crate) const SNOWFLAKE_RESERVED_KEYWORDS: &[&str] = &[ "ALL", diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs index d062b6fff..7295be68b 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs @@ -296,6 +296,36 @@ fn given_recorded_snowflake_cases_when_parsing_then_acceptance_matches_snowflake dialect: Dialect::Snowflake, expected_accepted: true, }, + AcceptanceTestCase { + description: "ALL takes a list of values (recorded semantic error)", + sql: "SELECT 1 = ALL ( 1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "STRING takes empty parentheses (recorded success)", + sql: "SELECT CAST ( NULL AS STRING ( ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "statements chain with the flow operator (recorded success)", + sql: "SELECT 1 ->> SELECT 2 ->> SELECT 3 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a block chains with the flow operator (recorded success)", + sql: "BEGIN BEGIN NULL ; END ; END ->> SELECT 2 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "the flow operator needs a statement after it (recorded errno 1003)", + sql: "SELECT 1 ->> 1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, ]; for test_case in test_cases { @@ -359,6 +389,12 @@ fn given_snowflake_create_and_begin_name_when_parsing_then_the_tree_keeps_the_el pointer: "/0/select/expressions/0/method_call/args/0/subquery/this/select/from/expressions/0/table/name/name", expected_value: json!("t1"), }, + ProjectionTestCase { + description: "the flow operator keeps the statement after it", + sql: "SELECT 1 ->> SELECT 2", + pointer: "/0/operator/expression/select/expressions/0/literal/value", + expected_value: json!("2"), + }, ]; for test_case in test_cases { From 36dda6b3dee825638bf0731686a18ebc1a46817f Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:57:17 +0000 Subject: [PATCH 04/14] Parse Snowflake's ** array spread and IPADDRESS literals; keep ->> as flow only under faithful calls ** in a call's arguments is a Variadic node, like PostgreSQL's VARIADIC, and IPADDRESS '' is a typed literal. The flow operator and the refusal of ->> as a JSON operator apply only with Snowflake's faithful-call rules, so the polyglot comparison keeps its JSON reading and needs no expected rejections for it. --- .../polyglot-check/expected_differences.toml | 77 ------------------- .../tests/runs/test_exact.rs | 11 --- .../_helpers/ddl/snowflake_statements.rs | 2 +- .../_helpers/expressions/comparisons.rs | 6 +- .../parser/_helpers/expressions/primary.rs | 16 +--- .../src/parser/_helpers/functions/generic.rs | 4 +- .../_helpers/functions/snowflake_calls.rs | 24 ++++++ .../parser/_helpers/statements/scripting.rs | 5 +- .../sqlquack-syntax/src/parser/constants.rs | 3 + .../tests/parser/test_snowflake_recorded.rs | 24 ++++++ 10 files changed, 64 insertions(+), 108 deletions(-) diff --git a/corpora/polyglot-check/expected_differences.toml b/corpora/polyglot-check/expected_differences.toml index 03d46be2e..9aa233a9f 100644 --- a/corpora/polyglot-check/expected_differences.toml +++ b/corpora/polyglot-check/expected_differences.toml @@ -1199,83 +1199,6 @@ dialects = ["Snowflake"] sqlquack_error = "Expected an interval field such as DAY or YEAR TO MONTH" note = "Snowflake's INTERVAL type needs a field (recorded errno 1003 for CAST ( NULL AS INTERVAL ))" -[[rejection]] -id = "snowflake_flow_operator_in_expression_1" -sql = "SELECT col FROM t WHERE NOT (col ->> '$.id') IN ('b')" -dialects = ["Snowflake"] -sqlquack_error = "Expected RParen, got DArrow" -note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" - -[[rejection]] -id = "snowflake_flow_operator_in_expression_2" -sql = "SELECT '[' || 1 || ']' ->> 0" -dialects = ["Snowflake"] -sqlquack_error = "Expected a statement, not an expression" -note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" - -[[rejection]] -id = "snowflake_flow_operator_in_expression_3" -sql = "SELECT '[' || 1 || ']' -> 0 ->> 0" -dialects = ["Snowflake"] -sqlquack_error = "Expected a statement, not an expression" -note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" - -[[rejection]] -id = "snowflake_flow_operator_in_expression_4" -sql = "SELECT '[1]' ->> 0 || 'x'" -dialects = ["Snowflake"] -sqlquack_error = "Expected a statement, not an expression" -note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" - -[[rejection]] -id = "snowflake_flow_operator_in_expression_5" -sql = "SELECT payload ->> 0 + 1" -dialects = ["Snowflake"] -sqlquack_error = "Expected a statement, not an expression" -note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" - -[[rejection]] -id = "snowflake_flow_operator_in_expression_6" -sql = "SELECT payload ->> 'a' || 'b'" -dialects = ["Snowflake"] -sqlquack_error = "Expected a statement, not an expression" -note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" - -[[rejection]] -id = "snowflake_flow_operator_in_expression_7" -sql = "SELECT payload ->> 0 + 1 = other" -dialects = ["Snowflake"] -sqlquack_error = "Expected a statement, not an expression" -note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" - -[[rejection]] -id = "snowflake_flow_operator_in_expression_8" -sql = "SELECT payload -> 'a' ->> 0" -dialects = ["Snowflake"] -sqlquack_error = "Expected a statement, not an expression" -note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" - -[[rejection]] -id = "snowflake_flow_operator_in_expression_9" -sql = "SELECT payload -> 'a' || 'b' ->> 0" -dialects = ["Snowflake"] -sqlquack_error = "Expected a statement, not an expression" -note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" - -[[rejection]] -id = "snowflake_flow_operator_in_expression_10" -sql = "SELECT payload -> ('a' || ('b' ->> 0))" -dialects = ["Snowflake"] -sqlquack_error = "Expected RParen, got DArrow" -note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" - -[[rejection]] -id = "snowflake_flow_operator_in_expression_11" -sql = "SELECT payload -> concat('a', other ->> 0)" -dialects = ["Snowflake"] -sqlquack_error = "Expected RParen, got DArrow" -note = "Snowflake reads ->> only as the flow operator between statements, not as a JSON operator (recorded syntax_error for SELECT 1 ->> 1)" - [[rejection]] id = "snowflake_type_bare_interval_cast" sql = "SELECT CAST(x AS INTERVAL)" diff --git a/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs b/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs index ec23a86f0..9d6ad6295 100644 --- a/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs +++ b/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs @@ -51,17 +51,6 @@ fn given_committed_corpora_when_parsed_by_both_parsers_then_results_are_identica ("snowflake_show_create_table".to_owned(), 1), ("snowflake_stage_reserved_name".to_owned(), 1), ("snowflake_stage_quoted_blank_name".to_owned(), 1), - ("snowflake_flow_operator_in_expression_1".to_owned(), 1), - ("snowflake_flow_operator_in_expression_2".to_owned(), 1), - ("snowflake_flow_operator_in_expression_3".to_owned(), 1), - ("snowflake_flow_operator_in_expression_4".to_owned(), 1), - ("snowflake_flow_operator_in_expression_5".to_owned(), 1), - ("snowflake_flow_operator_in_expression_6".to_owned(), 1), - ("snowflake_flow_operator_in_expression_7".to_owned(), 1), - ("snowflake_flow_operator_in_expression_8".to_owned(), 1), - ("snowflake_flow_operator_in_expression_9".to_owned(), 1), - ("snowflake_flow_operator_in_expression_10".to_owned(), 1), - ("snowflake_flow_operator_in_expression_11".to_owned(), 1), ]), differences: Vec::new(), tokens_lost: Vec::new(), diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_statements.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_statements.rs index cbbf42848..6f69a2790 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_statements.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_statements.rs @@ -18,7 +18,7 @@ impl Parser { /// Chains Snowflake statements with the flow operator, as in `SELECT 1 ->> SELECT * FROM $1`. pub(crate) fn parse_snowflake_flow(&mut self, first: Expression) -> Result { let mut flow = first; - while self.is_snowflake() && self.match_token(TokenType::DArrow) { + while self.applies_snowflake_context_rules() && self.match_token(TokenType::DArrow) { let next = self.parse_statement()?; flow = Expression::Operator(Box::new(Operator { this: Box::new(flow), diff --git a/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs b/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs index f9fc5b918..674b6c3ae 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/expressions/comparisons.rs @@ -1446,7 +1446,7 @@ impl Parser { span: SourceSpan::default(), })); } else if self.check(TokenType::Arrow) - || (self.check(TokenType::DArrow) && !self.is_snowflake()) + || (self.check(TokenType::DArrow) && !self.applies_snowflake_context_rules()) { let scalar = self.match_token(TokenType::DArrow); if !scalar { @@ -1613,7 +1613,9 @@ impl Parser { | "DATERANGE" ) || self.config.dialect == Some(crate::tokens::models::DialectType::DuckDB) - && DataType::from_unsigned_name(&type_name).is_some(); + && DataType::from_unsigned_name(&type_name).is_some() + || self.is_snowflake() + && crate::parser::constants::SNOWFLAKE_LITERAL_TYPES.contains(&type_name.as_str()); if !is_type_literal_type { return Ok(None); diff --git a/crates/sqlquack-syntax/src/parser/_helpers/expressions/primary.rs b/crates/sqlquack-syntax/src/parser/_helpers/expressions/primary.rs index f994654a9..4940fbea1 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/expressions/primary.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/expressions/primary.rs @@ -32,7 +32,6 @@ use crate::parser::constants::ODBC_TIME_ESCAPE; use crate::parser::constants::PYFORMAT_PARAMETER_SUFFIX; use crate::parser::constants::SECOND_PREVIOUS_OFFSET; use crate::parser::constants::STRUCT; -use crate::parser::constants::VARIADIC_KEYWORD; use crate::parser::models::Parser; use crate::tokens::models::SourceSpan; use crate::tokens::models::TokenType; @@ -51,19 +50,8 @@ impl Parser { return Ok(connect_by_root); } - if matches!( - self.config.dialect, - Some(crate::tokens::models::DialectType::PostgreSQL) - ) && self.check(TokenType::Var) - && self.peek_text().eq_ignore_ascii_case(VARIADIC_KEYWORD) - { - self.skip(); - let this = self.parse_bitwise_or()?; - return Ok(Expression::Variadic(Box::new( - crate::expressions::statements::models::Variadic { - this: Box::new(this), - }, - ))); + if let Some(variadic) = self.parse_variadic_argument()? { + return Ok(variadic); } if self.match_token(TokenType::LBracket) { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs index f592a26d1..6caaddda7 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs @@ -1010,8 +1010,8 @@ impl Parser { self.config.dialect, Some(crate::tokens::models::DialectType::DuckDB) | Some(crate::tokens::models::DialectType::SQLite) - | Some(crate::tokens::models::DialectType::Snowflake) - ) && self.match_token(TokenType::DArrow) + ) && !self.applies_snowflake_context_rules() + && self.match_token(TokenType::DArrow) { let path = self.parse_json_path_operand()?; expr = Expression::JsonExtractScalar(Box::new(JsonExtractFunc { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs index 2d38fc215..5fb44baf9 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs @@ -4,12 +4,36 @@ use crate::expressions::names::models::Column; use crate::expressions::names::models::Identifier; use crate::expressions::queries::models::Subquery; use crate::expressions::tree::models::Expression; +use crate::parser::constants::VARIADIC_KEYWORD; use crate::parser::models::Parser; use crate::tokens::models::SourceSpan; use crate::tokens::models::TokenType; use crate::tokens::types::Result; impl Parser { + /// An array spread into a call's arguments: PostgreSQL's `VARIADIC ` or Snowflake's `** `. + pub(crate) fn parse_variadic_argument(&mut self) -> Result> { + let postgres_variadic = self.config.dialect + == Some(crate::tokens::models::DialectType::PostgreSQL) + && self.check(TokenType::Var) + && self.peek_text().eq_ignore_ascii_case(VARIADIC_KEYWORD); + let argument = matches!( + self.previous_token_type(), + Some(TokenType::LParen | TokenType::Comma) + ); + let snowflake_spread = self.is_snowflake() && argument && self.check(TokenType::DStar); + if !postgres_variadic && !snowflake_spread { + return Ok(None); + } + self.skip(); + let this = self.parse_bitwise_or()?; + Ok(Some(Expression::Variadic(Box::new( + crate::expressions::statements::models::Variadic { + this: Box::new(this), + }, + )))) + } + /// The operand of `ANY`/`ALL`, which Snowflake also reads as a list of values, as in `1 = ALL ( 1 )`. pub(crate) fn parse_quantified_operand(&mut self) -> Result { let query = self.check(TokenType::Select) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/scripting.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/scripting.rs index 05dcaa1a2..f11f7d804 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/scripting.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/scripting.rs @@ -754,7 +754,10 @@ impl Parser { /// An optional label, written directly before the statement's semicolon. fn parse_scripting_label(&mut self) -> Result> { - if self.is_at_end() || self.check(TokenType::Semicolon) || self.check(TokenType::DArrow) { + if self.is_at_end() + || self.check(TokenType::Semicolon) + || (self.check(TokenType::DArrow) && self.applies_snowflake_context_rules()) + { return Ok(None); } self.expect_scripting_name().map(Some) diff --git a/crates/sqlquack-syntax/src/parser/constants.rs b/crates/sqlquack-syntax/src/parser/constants.rs index f9d5df977..4e6405a94 100644 --- a/crates/sqlquack-syntax/src/parser/constants.rs +++ b/crates/sqlquack-syntax/src/parser/constants.rs @@ -227,6 +227,9 @@ pub(crate) const SQLITE_RANGE_FOLLOWERS: [crate::tokens::models::TokenType; 9] = crate::tokens::models::TokenType::Match, ]; +/// Snowflake types written before a string as a typed literal, beyond the shared ones. +pub(crate) const SNOWFLAKE_LITERAL_TYPES: &[&str] = &["IPADDRESS"]; + /// Reserved words Snowflake is recorded refusing as an unquoted later part of a dotted name. pub(crate) const SNOWFLAKE_REFUSED_NAME_PARTS: &[&str] = &["SELECT"]; diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs index 7295be68b..ee7edad58 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs @@ -326,6 +326,24 @@ fn given_recorded_snowflake_cases_when_parsing_then_acceptance_matches_snowflake dialect: Dialect::Snowflake, expected_accepted: false, }, + AcceptanceTestCase { + description: "** spreads an array into arguments (recorded success)", + sql: "SELECT GREATEST ( ** [ 1 , 2 ] ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "** needs an array after it (recorded errno 1003)", + sql: "SELECT GREATEST ( ** ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "IPADDRESS types a string literal (recorded success)", + sql: "SELECT IPADDRESS '192.0.2.1' ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, ]; for test_case in test_cases { @@ -395,6 +413,12 @@ fn given_snowflake_create_and_begin_name_when_parsing_then_the_tree_keeps_the_el pointer: "/0/operator/expression/select/expressions/0/literal/value", expected_value: json!("2"), }, + ProjectionTestCase { + description: "** keeps the array it spreads", + sql: "SELECT GREATEST ( ** [ 1 , 2 ] )", + pointer: "/0/select/expressions/0/function/args/0/variadic/this/array_func/expressions/1/literal/value", + expected_value: json!("2"), + }, ]; for test_case in test_cases { From 534f6c202169ab29abbb88ce6aebeec4e2f59047 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:18:30 +0000 Subject: [PATCH 05/14] Refuse a name as a Snowflake interval's value and a string after a path colon INTERVAL m1 YEAR and v1 :'v1' are recorded syntax errors. Both refusals apply only with Snowflake's faithful-call rules. --- .../src/parser/_helpers/expressions/paths.rs | 2 ++ .../src/parser/_helpers/functions/generic.rs | 1 + .../_helpers/functions/snowflake_calls.rs | 20 +++++++++++++++++++ .../tests/parser/test_snowflake_recorded.rs | 12 +++++++++++ 4 files changed, 35 insertions(+) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs b/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs index 66b06e15a..fb24f6c76 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/expressions/paths.rs @@ -89,6 +89,8 @@ impl Parser { had_initial_component = true; } else if self.check(TokenType::LBracket) { had_initial_component = true; + } else { + self.refuse_snowflake_string_path()?; } if !had_initial_component { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs index 6caaddda7..15afae9b1 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs @@ -1592,6 +1592,7 @@ impl Parser { return Ok(None); } + self.refuse_snowflake_interval_name(value.as_ref())?; let unit = self.try_parse_interval_unit()?; let is_generic = self.config.dialect.is_none() diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs index 5fb44baf9..222000361 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/snowflake_calls.rs @@ -11,6 +11,26 @@ use crate::tokens::models::TokenType; use crate::tokens::types::Result; impl Parser { + /// Refuses a string after a Snowflake path colon, as in `v1 :'v1'`. + pub(crate) fn refuse_snowflake_string_path(&self) -> Result<()> { + match self.applies_snowflake_context_rules() && self.check(TokenType::String) { + true => Err(self.parse_error("Expected a path element, not a string, after :")), + false => Ok(()), + } + } + + /// Refuses a name as a Snowflake interval's value, as in `INTERVAL m1 YEAR`; the value is a string. + pub(crate) fn refuse_snowflake_interval_name(&self, value: Option<&Expression>) -> Result<()> { + let name = matches!( + value, + Some(Expression::Column(_) | Expression::Identifier(_)) + ); + match self.applies_snowflake_context_rules() && name { + true => Err(self.parse_error("Expected a string as the interval's value")), + false => Ok(()), + } + } + /// An array spread into a call's arguments: PostgreSQL's `VARIADIC ` or Snowflake's `** `. pub(crate) fn parse_variadic_argument(&mut self) -> Result> { let postgres_variadic = self.config.dialect diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs index ee7edad58..bed4c70b3 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs @@ -344,6 +344,18 @@ fn given_recorded_snowflake_cases_when_parsing_then_acceptance_matches_snowflake dialect: Dialect::Snowflake, expected_accepted: true, }, + AcceptanceTestCase { + description: "an interval's value is a string, not a name (recorded errno 1003)", + sql: "SELECT INTERVAL m1 YEAR ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a path colon takes no string (recorded errno 1003)", + sql: "SELECT v1 :'v1' ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, ]; for test_case in test_cases { From 61da1914f243da891a44a60b167299420bbb8955 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:18:31 +0000 Subject: [PATCH 06/14] Join every changed case to Snowflake's receipts in the coverage diff, whatever it expects The snapshot took Snowflake's verdict from a case's checked phase, which is none for every observe case, so a receipted observe case the parser stopped accepting never reached the diff (#55's three regressions, audit 38bed222). Each case result now carries what Snowflake's parser did, by every syntax receipt of its SQL, setup and session whatever the case id: parsed when all stopped after parsing with an errno other than 1003, refused when all are a positioned 1003 or a 921, and nothing for a positionless 1003 or receipts that disagree. The diff fails on a parsed case the parser now rejects, as before, and also on a refused case it now accepts. --- .../src/check/_helpers/join.rs | 26 +++- .../src/check/_helpers/receipts.rs | 35 +++++- .../src/check/models.rs | 12 ++ .../src/check/tests/helpers.rs | 5 + .../src/cli/_helpers/commands.rs | 7 +- .../src/formats/constants.rs | 6 + .../src/report/main/diff_snapshots.rs | 5 + .../src/report/main/snapshot.rs | 19 +-- .../src/report/models.rs | 8 ++ .../src/report/tests.rs | 2 + .../src/report/tests/helpers.rs | 53 +++++++- .../src/report/tests/test_parser_changes.rs | 116 ++++++++++++++++++ .../src/report/tests/test_types.rs | 13 ++ 13 files changed, 294 insertions(+), 13 deletions(-) create mode 100644 crates/sqlquack-syntax-coverage/src/report/tests/test_parser_changes.rs diff --git a/crates/sqlquack-syntax-coverage/src/check/_helpers/join.rs b/crates/sqlquack-syntax-coverage/src/check/_helpers/join.rs index 50f13d9eb..2d8801066 100644 --- a/crates/sqlquack-syntax-coverage/src/check/_helpers/join.rs +++ b/crates/sqlquack-syntax-coverage/src/check/_helpers/join.rs @@ -51,20 +51,44 @@ fn receipts_by_case(coverage: &SyntaxCoverage) -> BTreeMap<&str, Vec<&Receipt>> receipts } +/// The receipts of each statement, by its SQL, setup and session hashes, whatever the case id. +fn receipts_by_statement(coverage: &SyntaxCoverage) -> BTreeMap<(&str, &str, &str), Vec<&Receipt>> { + let mut receipts: BTreeMap<(&str, &str, &str), Vec<&Receipt>> = BTreeMap::new(); + for receipt in &coverage.receipts { + receipts + .entry(( + receipt.sql_sha256.as_str(), + receipt.setup_sha256.as_str(), + receipt.session_sha256.as_str(), + )) + .or_default() + .push(receipt); + } + receipts +} + /// Every join the checker needs, with each case judged against `parse`. pub(crate) fn join<'a>( coverage: &'a SyntaxCoverage, parse: &dyn Fn(&str) -> LocalVerdict, ) -> Joined<'a> { let receipts: BTreeMap<&str, Vec<&Receipt>> = receipts_by_case(coverage); + let statements: BTreeMap<(&str, &str, &str), Vec<&Receipt>> = receipts_by_statement(coverage); let mut cases: BTreeMap<&str, CaseResult> = BTreeMap::new(); for case in coverage.cases.values().flatten() { let found: &[&Receipt] = receipts .get(case.case_id.as_str()) .map_or(&[], Vec::as_slice); + let statement: &[&Receipt] = statements + .get(&( + case.sql_sha256.as_str(), + case.setup_sha256.as_str(), + case.session_sha256.as_str(), + )) + .map_or(&[], Vec::as_slice); cases.insert( case.case_id.as_str(), - case_result(case, found, parse(&case.sql)), + case_result(case, found, statement, parse(&case.sql)), ); } Joined { diff --git a/crates/sqlquack-syntax-coverage/src/check/_helpers/receipts.rs b/crates/sqlquack-syntax-coverage/src/check/_helpers/receipts.rs index 84f269362..165c40a34 100644 --- a/crates/sqlquack-syntax-coverage/src/check/_helpers/receipts.rs +++ b/crates/sqlquack-syntax-coverage/src/check/_helpers/receipts.rs @@ -2,7 +2,8 @@ use std::collections::{BTreeMap, BTreeSet}; -use crate::check::models::{CaseResult, LocalVerdict, Reason}; +use crate::check::models::{CaseResult, LocalVerdict, Reason, SnowflakeSyntax}; +use crate::formats::constants::{SYNTAX_ERRNO, SYNTAX_ERRNO_42601}; use crate::formats::models::{AccountSetting, Case, Expect, Phase, Receipt}; /// A syntax receipt bound to exactly this case's SQL, setup, session and capture, with a query id. @@ -61,8 +62,35 @@ fn agreement(phase: Phase, local: &LocalVerdict, terminates: bool) -> Result CaseResult { +/// What Snowflake's parser did by the syntax receipts of the case's statement; none unless all agree. +fn snowflake_syntax(statement_receipts: &[&Receipt]) -> Option { + let verdicts: BTreeSet> = statement_receipts + .iter() + .filter(|receipt| receipt.measure.is_none()) + .map(|receipt| match (receipt.phase, receipt.errno) { + (Phase::SyntaxError, Some(errno)) + if errno == SYNTAX_ERRNO || errno == SYNTAX_ERRNO_42601 => + { + Some(SnowflakeSyntax::Refused) + } + (Phase::SyntaxError, _) => None, + (_, Some(errno)) if errno == SYNTAX_ERRNO => None, + _ => Some(SnowflakeSyntax::Parsed), + }) + .collect(); + match verdicts.len() { + 1 => verdicts.into_iter().next().flatten(), + _ => None, + } +} + +/// The case's result against its receipts, its statement's receipts and the parser's verdict. +pub(crate) fn case_result( + case: &Case, + receipts: &[&Receipt], + statement_receipts: &[&Receipt], + local: LocalVerdict, +) -> CaseResult { let (phase, reason, semantic_only): (Option, Option, bool) = match receipt_phase(case, receipts) { _ if case.expect == Some(Expect::Observe) => { @@ -84,6 +112,7 @@ pub(crate) fn case_result(case: &Case, receipts: &[&Receipt], local: LocalVerdic local, reason, semantic_only, + snowflake: snowflake_syntax(statement_receipts), account_state: receipts .iter() .filter(|receipt| admissible(case, receipt)) diff --git a/crates/sqlquack-syntax-coverage/src/check/models.rs b/crates/sqlquack-syntax-coverage/src/check/models.rs index 405fc97b5..7c6eb3b2e 100644 --- a/crates/sqlquack-syntax-coverage/src/check/models.rs +++ b/crates/sqlquack-syntax-coverage/src/check/models.rs @@ -76,6 +76,16 @@ pub enum Reason { BlockedByHold, } +/// What Snowflake's parser did with a case's statement, by every receipt of its SQL, setup and session. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SnowflakeSyntax { + /// Every receipt stopped after parsing or succeeded, with an errno other than 1003. + Parsed, + /// Every receipt is a syntax error: errno 1003 with a parse position, or errno 921. + Refused, +} + /// One case's result: its receipts against the current parser. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct CaseResult { @@ -89,6 +99,8 @@ pub struct CaseResult { pub reason: Option, /// Agrees, while Snowflake stopped after parsing (semantic, runtime or unsupported phase). pub semantic_only: bool, + /// What Snowflake's parser did, whatever the case expects; none on disagreeing or no receipts. + pub snowflake: Option, /// The account parameters its admissible receipts ran under (decision D120). #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub account_state: BTreeMap, diff --git a/crates/sqlquack-syntax-coverage/src/check/tests/helpers.rs b/crates/sqlquack-syntax-coverage/src/check/tests/helpers.rs index e5b8b9547..2c6bff14e 100644 --- a/crates/sqlquack-syntax-coverage/src/check/tests/helpers.rs +++ b/crates/sqlquack-syntax-coverage/src/check/tests/helpers.rs @@ -66,6 +66,11 @@ pub(crate) fn reshaped(sql: &str) -> LocalVerdict { vec![structural(sql), accepted_as("another tree")].swap_remove(usize::from(sql == "SELECT 17")) } +/// `structural`, except that `SELECT 17` is rejected. +pub(crate) fn rejecting_select_17(sql: &str) -> LocalVerdict { + vec![structural(sql), LocalVerdict::Rejected].swap_remove(usize::from(sql == "SELECT 17")) +} + /// `structural`, except that `SELECT FROM` crashes instead of being rejected. pub(crate) fn crashing_on_select_from(sql: &str) -> LocalVerdict { vec![structural(sql), crashing(sql)].swap_remove(usize::from(sql == "SELECT FROM")) diff --git a/crates/sqlquack-syntax-coverage/src/cli/_helpers/commands.rs b/crates/sqlquack-syntax-coverage/src/cli/_helpers/commands.rs index 806de8ec8..d03334744 100644 --- a/crates/sqlquack-syntax-coverage/src/cli/_helpers/commands.rs +++ b/crates/sqlquack-syntax-coverage/src/cli/_helpers/commands.rs @@ -339,13 +339,17 @@ fn read_snapshot(path: &Path) -> Result { /// Lines naming each non-empty list of the diff. fn diff_lines(diff: &SnapshotDiff) -> Vec { - let lists: [(&str, &Vec); 8] = [ + let lists: [(&str, &Vec); 9] = [ ("REGRESSION forms lost", &diff.forms_lost), ("REGRESSION obligations lost", &diff.obligations_lost), ( "REGRESSION Snowflake-parsed cases the parser now rejects", &diff.parsed_cases_refused, ), + ( + "REGRESSION Snowflake syntax errors the parser now accepts", + &diff.syntax_error_cases_accepted, + ), ("CATALOGUE files changed", &diff.catalogue_files_changed), ("CATALOGUE forms added", &diff.forms_added), ("CATALOGUE forms removed", &diff.forms_removed), @@ -382,6 +386,7 @@ pub(crate) fn diff( &diff.forms_lost, &diff.obligations_lost, &diff.parsed_cases_refused, + &diff.syntax_error_cases_accepted, &diff.catalogue_files_changed, &diff.forms_added, &diff.forms_removed, diff --git a/crates/sqlquack-syntax-coverage/src/formats/constants.rs b/crates/sqlquack-syntax-coverage/src/formats/constants.rs index a8b13e9a0..64d269e26 100644 --- a/crates/sqlquack-syntax-coverage/src/formats/constants.rs +++ b/crates/sqlquack-syntax-coverage/src/formats/constants.rs @@ -88,3 +88,9 @@ pub const DCM_LINE: &str = "line "; /// What separates an inner error's line from its position. pub const DCM_POSITION: &str = " at position "; + +/// Snowflake's errno for a syntax error; with a parse position it is a refusal by the parser. +pub const SYNTAX_ERRNO: i64 = 1003; + +/// Snowflake's errno 921, SQLSTATE 42601, reviewed as a syntax refusal. +pub const SYNTAX_ERRNO_42601: i64 = 921; diff --git a/crates/sqlquack-syntax-coverage/src/report/main/diff_snapshots.rs b/crates/sqlquack-syntax-coverage/src/report/main/diff_snapshots.rs index 0f6b9d04d..8b5035512 100644 --- a/crates/sqlquack-syntax-coverage/src/report/main/diff_snapshots.rs +++ b/crates/sqlquack-syntax-coverage/src/report/main/diff_snapshots.rs @@ -59,5 +59,10 @@ pub fn diff_snapshots(base: &Snapshot, head: &Snapshot) -> SnapshotDiff { .intersection(&head.refused_cases) .cloned() .collect(), + syntax_error_cases_accepted: base + .syntax_error_cases_rejected + .intersection(&head.syntax_error_cases_accepted) + .cloned() + .collect(), } } diff --git a/crates/sqlquack-syntax-coverage/src/report/main/snapshot.rs b/crates/sqlquack-syntax-coverage/src/report/main/snapshot.rs index da9fc384c..9eefea5da 100644 --- a/crates/sqlquack-syntax-coverage/src/report/main/snapshot.rs +++ b/crates/sqlquack-syntax-coverage/src/report/main/snapshot.rs @@ -2,9 +2,8 @@ use std::collections::{BTreeMap, BTreeSet}; -use crate::check::models::{CheckResult, LocalVerdict}; +use crate::check::models::{CheckResult, LocalVerdict, SnowflakeSyntax}; use crate::formats::constants::{ALIASES_FILE, CATALOGUE_DIR, SNAPSHOT_FORMAT, SOURCES_FILE}; -use crate::formats::models::Phase; use crate::report::models::Snapshot; /// Whether a coverage path belongs to the catalogue rather than to obligations or evidence. @@ -12,15 +11,19 @@ fn is_catalogue_file(path: &str) -> bool { path == SOURCES_FILE || path == ALIASES_FILE || path.starts_with(&format!("{CATALOGUE_DIR}/")) } -/// The receipted cases Snowflake parsed whose local verdict `accepted` says whether it parsed too. -fn snowflake_parsed(check: &CheckResult, accepted: bool) -> BTreeSet { +/// Cases whose receipts say `snowflake` and whose parser verdict is acceptance when `accepted`. +fn by_snowflake( + check: &CheckResult, + snowflake: SnowflakeSyntax, + accepted: bool, +) -> BTreeSet { check .forms .iter() .flat_map(|form| &form.obligations) .flat_map(|obligation| &obligation.cases) .filter(|case| { - case.phase.is_some_and(|phase| phase != Phase::SyntaxError) + case.snowflake == Some(snowflake) && matches!(case.local, LocalVerdict::Accepted { .. }) == accepted && !matches!(case.local, LocalVerdict::Failed { .. }) }) @@ -55,7 +58,9 @@ pub fn snapshot( .filter(|obligation| obligation.required && obligation.reason.is_none()) .map(|obligation| obligation.id.clone()) .collect(), - parsed_cases: snowflake_parsed(check, true), - refused_cases: snowflake_parsed(check, false), + parsed_cases: by_snowflake(check, SnowflakeSyntax::Parsed, true), + refused_cases: by_snowflake(check, SnowflakeSyntax::Parsed, false), + syntax_error_cases_rejected: by_snowflake(check, SnowflakeSyntax::Refused, false), + syntax_error_cases_accepted: by_snowflake(check, SnowflakeSyntax::Refused, true), } } diff --git a/crates/sqlquack-syntax-coverage/src/report/models.rs b/crates/sqlquack-syntax-coverage/src/report/models.rs index 4aea498f4..da7fbed1a 100644 --- a/crates/sqlquack-syntax-coverage/src/report/models.rs +++ b/crates/sqlquack-syntax-coverage/src/report/models.rs @@ -64,6 +64,12 @@ pub struct Snapshot { /// Receipted cases Snowflake parsed that the parser rejects. #[serde(default)] pub refused_cases: BTreeSet, + /// Receipted cases Snowflake refused as syntax that the parser rejects too. + #[serde(default)] + pub syntax_error_cases_rejected: BTreeSet, + /// Receipted cases Snowflake refused as syntax that the parser accepts, opaque or not. + #[serde(default)] + pub syntax_error_cases_accepted: BTreeSet, } /// One snapshot against a later one; coverage and catalogue changes kept apart. @@ -82,4 +88,6 @@ pub struct SnapshotDiff { pub obligations_gained: Vec, /// Cases Snowflake parsed that the base parser accepted, opaque or not, and the head rejects. pub parsed_cases_refused: Vec, + /// Cases Snowflake refused as syntax that the base parser rejected and the head accepts. + pub syntax_error_cases_accepted: Vec, } diff --git a/crates/sqlquack-syntax-coverage/src/report/tests.rs b/crates/sqlquack-syntax-coverage/src/report/tests.rs index 76c33936a..53dc6cf3b 100644 --- a/crates/sqlquack-syntax-coverage/src/report/tests.rs +++ b/crates/sqlquack-syntax-coverage/src/report/tests.rs @@ -2,5 +2,7 @@ mod helpers; #[path = "tests/test_diff_snapshots.rs"] mod test_diff_snapshots; +#[path = "tests/test_parser_changes.rs"] +mod test_parser_changes; #[path = "tests/test_types.rs"] mod test_types; diff --git a/crates/sqlquack-syntax-coverage/src/report/tests/helpers.rs b/crates/sqlquack-syntax-coverage/src/report/tests/helpers.rs index 4758a98dd..f51b61131 100644 --- a/crates/sqlquack-syntax-coverage/src/report/tests/helpers.rs +++ b/crates/sqlquack-syntax-coverage/src/report/tests/helpers.rs @@ -1,6 +1,14 @@ use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use crate::check::main::check_coverage::check_coverage; +use crate::check::models::{FormState, LocalVerdict}; +use crate::check::tests::helpers::{ + TREE, case_line, replace, rewrite_select_17, select_17, with_fields, +}; +use crate::formats::main::load_coverage::load_coverage; +use crate::report::main::snapshot::snapshot; -use crate::check::models::FormState; use crate::formats::constants::SNAPSHOT_FORMAT; use crate::report::models::Snapshot; @@ -23,5 +31,48 @@ pub(crate) fn base() -> Snapshot { satisfied_obligations: BTreeSet::from(["sf.query.select.o001".to_owned()]), parsed_cases: BTreeSet::from(["sf.query.select.k1".to_owned()]), refused_cases: BTreeSet::from(["sf.query.with.k2".to_owned()]), + syntax_error_cases_rejected: BTreeSet::from(["sf.query.select.k3".to_owned()]), + syntax_error_cases_accepted: BTreeSet::new(), } } + +/// Makes `select-17` an observe case whose receipt records `phase` and `errno`. +pub(crate) fn observed_select_17(root: &Path, phase: &str, errno: i64) { + rewrite_select_17( + root, + &with_fields(&select_17(), serde_json::json!({"expect": "observe"})), + ); + replace( + root, + "receipts/run-1.jsonl", + "\"phase\":\"accepted\"", + &format!("\"errno\":{errno},\"phase\":\"{phase}\""), + ); +} + +/// Makes `select-from` an observe case whose receipt is a syntax error with `errno`. +pub(crate) fn observed_select_from(root: &Path, errno: i64) { + let case: String = case_line("select-from", "sf.query.select.o002", "SELECT FROM", &[]); + replace( + root, + "cases/query.jsonl", + &case, + &with_fields(&case, serde_json::json!({"expect": "observe"})), + ); + replace( + root, + "receipts/run-1.jsonl", + "\"phase\":\"syntax_error\"", + &format!("\"errno\":{errno},\"phase\":\"syntax_error\""), + ); +} + +/// The snapshot of the tree at `root` under `parse`. +pub(crate) fn snapshot_with(root: &Path, parse: fn(&str) -> LocalVerdict) -> Snapshot { + let coverage = load_coverage(root).expect("load"); + snapshot( + &check_coverage(&coverage, Some(TREE), &parse), + &coverage.file_hashes, + "catalogue", + ) +} diff --git a/crates/sqlquack-syntax-coverage/src/report/tests/test_parser_changes.rs b/crates/sqlquack-syntax-coverage/src/report/tests/test_parser_changes.rs new file mode 100644 index 000000000..7380f43e2 --- /dev/null +++ b/crates/sqlquack-syntax-coverage/src/report/tests/test_parser_changes.rs @@ -0,0 +1,116 @@ +use crate::check::tests::helpers::{ + append_receipt, opaque, receipt_line, rejecting_select_17, select_17, structural, + verified_tree, with_fields, +}; +use crate::report::main::diff_snapshots::diff_snapshots; +use crate::report::models::SnapshotDiff; +use crate::report::tests::helpers::{observed_select_17, observed_select_from, snapshot_with}; +use crate::report::tests::test_types::ParserChangeTestCase; + +/// The shapes of the four regressions #55 brought on observe rows (audit 38bed222), and controls. +#[test] +fn given_receipted_cases_of_any_expectation_when_the_parser_changes_then_the_diff_flags_contradictions() + { + let test_cases = [ + ParserChangeTestCase { + description: "an observe case Snowflake accepted, now rejected (create-database-role)", + mutate: |root| observed_select_17(root, "accepted", 0), + base: structural, + head: rejecting_select_17, + expected_parsed_cases_refused: vec!["select-17"], + expected_syntax_error_cases_accepted: Vec::new(), + }, + ParserChangeTestCase { + description: "an observe case that failed semantic analysis, now rejected (database-role-clone)", + mutate: |root| observed_select_17(root, "semantic_error", 2003), + base: structural, + head: rejecting_select_17, + expected_parsed_cases_refused: vec!["select-17"], + expected_syntax_error_cases_accepted: Vec::new(), + }, + ParserChangeTestCase { + description: "an observe case Snowflake parsed, opaque before and rejected now (create-stream-clone)", + mutate: |root| observed_select_17(root, "semantic_error", 1422), + base: opaque, + head: rejecting_select_17, + expected_parsed_cases_refused: vec!["select-17"], + expected_syntax_error_cases_accepted: Vec::new(), + }, + ParserChangeTestCase { + description: "an observe case refused with a positioned 1003, now accepted (create-database)", + mutate: |root| observed_select_from(root, 1003), + base: structural, + head: opaque, + expected_parsed_cases_refused: Vec::new(), + expected_syntax_error_cases_accepted: vec!["select-from"], + }, + ParserChangeTestCase { + description: "an observe case refused with 921, now accepted", + mutate: |root| observed_select_from(root, 921), + base: structural, + head: opaque, + expected_parsed_cases_refused: Vec::new(), + expected_syntax_error_cases_accepted: vec!["select-from"], + }, + ParserChangeTestCase { + description: "a positionless 1003 is no parse, so its rejection is not flagged", + mutate: |root| observed_select_17(root, "semantic_error", 1003), + base: structural, + head: rejecting_select_17, + expected_parsed_cases_refused: Vec::new(), + expected_syntax_error_cases_accepted: Vec::new(), + }, + ParserChangeTestCase { + description: "receipts that disagree on the statement are not used", + mutate: |root| { + observed_select_17(root, "accepted", 0); + let receipt: String = receipt_line(&select_17(), "syntax_error"); + append_receipt( + root, + &with_fields( + &receipt, + serde_json::json!({"errno": 1003, "query_id": "query-again", "run_id": "run-2"}), + ), + ); + }, + base: structural, + head: rejecting_select_17, + expected_parsed_cases_refused: Vec::new(), + expected_syntax_error_cases_accepted: Vec::new(), + }, + ParserChangeTestCase { + description: "an unchanged parser flags nothing", + mutate: |root| observed_select_17(root, "accepted", 0), + base: structural, + head: structural, + expected_parsed_cases_refused: Vec::new(), + expected_syntax_error_cases_accepted: Vec::new(), + }, + ]; + for test_case in test_cases { + let directory = tempfile::tempdir().expect("temporary directory"); + verified_tree(directory.path()); + (test_case.mutate)(directory.path()); + let diff: SnapshotDiff = diff_snapshots( + &snapshot_with(directory.path(), test_case.base), + &snapshot_with(directory.path(), test_case.head), + ); + assert_eq!( + (diff.parsed_cases_refused, diff.syntax_error_cases_accepted), + ( + test_case + .expected_parsed_cases_refused + .iter() + .map(|id| (*id).to_owned()) + .collect::>(), + test_case + .expected_syntax_error_cases_accepted + .iter() + .map(|id| (*id).to_owned()) + .collect::>(), + ), + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-syntax-coverage/src/report/tests/test_types.rs b/crates/sqlquack-syntax-coverage/src/report/tests/test_types.rs index c92fb7fbb..f11eeb416 100644 --- a/crates/sqlquack-syntax-coverage/src/report/tests/test_types.rs +++ b/crates/sqlquack-syntax-coverage/src/report/tests/test_types.rs @@ -1,3 +1,6 @@ +use std::path::Path; + +use crate::check::models::LocalVerdict; use crate::report::models::{Snapshot, SnapshotDiff}; /// A change from the base snapshot, and the diff it must produce. @@ -6,3 +9,13 @@ pub(crate) struct DiffTestCase { pub(crate) head: fn(Snapshot) -> Snapshot, pub(crate) expected_diff: SnapshotDiff, } + +/// Receipts and expectations set by `mutate`, a base and a head parser, and what the diff must flag. +pub(crate) struct ParserChangeTestCase { + pub(crate) description: &'static str, + pub(crate) mutate: fn(&Path), + pub(crate) base: fn(&str) -> LocalVerdict, + pub(crate) head: fn(&str) -> LocalVerdict, + pub(crate) expected_parsed_cases_refused: Vec<&'static str>, + pub(crate) expected_syntax_error_cases_accepted: Vec<&'static str>, +} From 94ecdee22ba60161e73899f73903b56cd4989182 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:32:10 +0000 Subject: [PATCH 07/14] Refuse a comma after a Snowflake CAST's type CAST ( NULL AS INTERVAL SECOND ( 9 ) , ( 3 ) ) is a recorded syntax error that the interval precision change had let through as CAST's comma format. The refusal applies only with Snowflake's faithful-call rules. --- crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs | 2 +- .../sqlquack-syntax/tests/parser/test_snowflake_recorded.rs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs index ea81605d5..dcf5b6f14 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs @@ -148,7 +148,7 @@ impl Parser { fmt_expr }; Some(Box::new(fmt_with_tz)) - } else if self.match_token(TokenType::Comma) { + } else if !self.applies_snowflake_context_rules() && self.match_token(TokenType::Comma) { Some(Box::new(self.parse_expression()?)) } else { None diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs index bed4c70b3..0a8480973 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs @@ -356,6 +356,12 @@ fn given_recorded_snowflake_cases_when_parsing_then_acceptance_matches_snowflake dialect: Dialect::Snowflake, expected_accepted: false, }, + AcceptanceTestCase { + description: "CAST takes nothing after its type (recorded errno 1003)", + sql: "SELECT CAST ( NULL AS INTERVAL SECOND ( 9 ) , ( 3 ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, ]; for test_case in test_cases { From 6c66b27d072a4dab5a384c425482c29bbde70a83 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:42:05 +0000 Subject: [PATCH 08/14] Accept NOT NULL on a Snowflake structured ARRAY element and MAP value --- .../src/classification/main/cast_type_name.rs | 2 +- .../src/lineage/_helpers/checks/families.rs | 2 +- .../src/expressions/types/models.rs | 15 ++++++++++++--- .../src/parser/_helpers/types/casts.rs | 18 +++++++++++++++++- .../src/parser/_helpers/types/data_types.rs | 3 +++ .../tests/parser/test_snowflake_recorded.rs | 12 ++++++++++++ 6 files changed, 46 insertions(+), 6 deletions(-) diff --git a/crates/sqlquack-core/src/classification/main/cast_type_name.rs b/crates/sqlquack-core/src/classification/main/cast_type_name.rs index b525b24c8..a55057a16 100644 --- a/crates/sqlquack-core/src/classification/main/cast_type_name.rs +++ b/crates/sqlquack-core/src/classification/main/cast_type_name.rs @@ -46,7 +46,7 @@ pub fn cast_type_name(data_type: &DataType) -> String { DataType::Enum { .. } => "ENUM", DataType::Set { .. } => "SET", DataType::Union { .. } => "UNION", - DataType::Nullable { inner } => return cast_type_name(inner), + DataType::Nullable { inner } | DataType::NotNull { inner } => return cast_type_name(inner), DataType::Custom { name } => return custom_type_name(name), DataType::Geometry { .. } => "GEOMETRY", DataType::Geography { .. } => "GEOGRAPHY", diff --git a/crates/sqlquack-semantics/src/lineage/_helpers/checks/families.rs b/crates/sqlquack-semantics/src/lineage/_helpers/checks/families.rs index 59d2a5905..f6cd4a653 100644 --- a/crates/sqlquack-semantics/src/lineage/_helpers/checks/families.rs +++ b/crates/sqlquack-semantics/src/lineage/_helpers/checks/families.rs @@ -215,7 +215,7 @@ pub(crate) fn data_type_family(data_type: &DataType) -> Family { | DataType::Union { .. } | DataType::Geometry { .. } | DataType::Geography { .. } => Family::Struct, - DataType::Nullable { inner } => data_type_family(inner), + DataType::Nullable { inner } | DataType::NotNull { inner } => data_type_family(inner), DataType::Custom { name } => type_family(name), _ => Family::Unknown, } diff --git a/crates/sqlquack-syntax/src/expressions/types/models.rs b/crates/sqlquack-syntax/src/expressions/types/models.rs index b98872572..bd94ad8bc 100644 --- a/crates/sqlquack-syntax/src/expressions/types/models.rs +++ b/crates/sqlquack-syntax/src/expressions/types/models.rs @@ -299,6 +299,11 @@ pub enum DataType { inner: Box, }, + /// An element or value type declared NOT NULL, as in Snowflake's `ARRAY ( INT NOT NULL )`. + NotNull { + inner: Box, + }, + Custom { name: String, }, @@ -334,7 +339,7 @@ impl DataType { DataType::Array { element_type, .. } | DataType::List { element_type } => { element_type.contains_unknown() } - DataType::Nullable { inner } => inner.contains_unknown(), + DataType::Nullable { inner } | DataType::NotNull { inner } => inner.contains_unknown(), DataType::Map { key_type, value_type, @@ -362,8 +367,12 @@ impl DataType { } match (self, refined) { (DataType::Unknown, _) => true, - (DataType::Nullable { inner }, other) => inner.matches_shape_of(other), - (this, DataType::Nullable { inner }) => this.matches_shape_of(inner), + (DataType::Nullable { inner } | DataType::NotNull { inner }, other) => { + inner.matches_shape_of(other) + } + (this, DataType::Nullable { inner } | DataType::NotNull { inner }) => { + this.matches_shape_of(inner) + } ( DataType::Array { element_type, .. } | DataType::List { element_type }, DataType::Array { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs index dcf5b6f14..c3466ce1c 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs @@ -1232,6 +1232,7 @@ impl Parser { }); } let mut element = self.parse_data_type()?; + element = self.parse_snowflake_not_null(element); while let Some(close) = closes.pop() { if close == TokenType::Gt { self.expect_gt()?; @@ -1250,6 +1251,20 @@ impl Parser { Ok(element) } + /// `inner` with a trailing Snowflake `NOT NULL` on a structured type's element or value. + fn parse_snowflake_not_null(&mut self, inner: DataType) -> DataType { + let not_null = + self.is_snowflake() && self.check(TokenType::Not) && self.check_next(TokenType::Null); + if !not_null { + return inner; + } + self.skip(); + self.skip(); + DataType::NotNull { + inner: Box::new(inner), + } + } + #[inline(never)] pub(crate) fn parse_map_data_type(&mut self) -> Result { if self.match_token(TokenType::Lt) { @@ -1273,7 +1288,8 @@ impl Parser { } else if self.match_token(TokenType::LParen) { let key_type = self.parse_data_type()?; self.expect_token(TokenType::Comma)?; - let value_type = self.parse_data_type()?; + let parsed_value = self.parse_data_type()?; + let value_type = self.parse_snowflake_not_null(parsed_value); self.expect_token(TokenType::RParen)?; Ok(DataType::Map { key_type: Box::new(key_type), diff --git a/crates/sqlquack-syntax/src/parser/_helpers/types/data_types.rs b/crates/sqlquack-syntax/src/parser/_helpers/types/data_types.rs index 68976472e..b5d5761df 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/types/data_types.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/types/data_types.rs @@ -471,6 +471,9 @@ impl Parser { DataType::Nullable { inner } => { format!("Nullable({})", self.data_type_to_string(inner)) } + DataType::NotNull { inner } => { + format!("{} NOT NULL", self.data_type_to_string(inner)) + } DataType::Custom { name } => name.clone(), _ => format!("{:?}", dt), } diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs index 0a8480973..ea08f5a7f 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs @@ -362,6 +362,18 @@ fn given_recorded_snowflake_cases_when_parsing_then_acceptance_matches_snowflake dialect: Dialect::Snowflake, expected_accepted: false, }, + AcceptanceTestCase { + description: "a structured ARRAY's element may be NOT NULL (recorded success)", + sql: "SELECT CAST ( NULL AS ARRAY ( INT NOT NULL ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a structured MAP's value may be NOT NULL (recorded success)", + sql: "SELECT CAST ( NULL AS MAP ( VARCHAR , INT NOT NULL ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, ]; for test_case in test_cases { From 5c6c025c662453d5211ca70e148d2712fe1b968e Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:05:24 +0000 Subject: [PATCH 09/14] Parse Snowflake CALL, EXECUTE USING POLICY_CONTEXT and frame bounds as Snowflake does - In faithful Snowflake mode a plain CALL parses as a Call node, as WITH ... CALL already did, so malformed argument lists are refused instead of kept as a command. - EXECUTE USING POLICY_CONTEXT ( => , ... ) AS parses as an ExecuteUsingPolicyContext node. - A string is refused as a window frame bound value. --- .../src/expressions/statements/models.rs | 8 +++++ .../expressions/tree/_helpers/accessors.rs | 1 + .../src/expressions/tree/_helpers/api.rs | 1 + .../src/expressions/tree/models.rs | 2 ++ .../src/parser/_helpers/ddl/commands.rs | 31 +++++++++++++++++++ .../src/parser/_helpers/functions/generic.rs | 5 +++ .../parser/_helpers/statements/procedures.rs | 9 ++++-- .../tests/parser/test_snowflake_recorded.rs | 30 ++++++++++++++++++ 8 files changed, 85 insertions(+), 2 deletions(-) diff --git a/crates/sqlquack-syntax/src/expressions/statements/models.rs b/crates/sqlquack-syntax/src/expressions/statements/models.rs index 01e83f960..c830aec53 100644 --- a/crates/sqlquack-syntax/src/expressions/statements/models.rs +++ b/crates/sqlquack-syntax/src/expressions/statements/models.rs @@ -352,6 +352,14 @@ pub struct ExecuteStatement { pub suffix: Option, } +/// Snowflake `EXECUTE USING POLICY_CONTEXT ( => , ... ) AS `. +#[derive(sqlquack_syntax_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ExecuteUsingPolicyContext { + /// The named arguments of POLICY_CONTEXT. + pub arguments: Vec, + pub query: Expression, +} + /// Snowflake `EXECUTE ALERT | TASK [ GRAPH ] | DCM PROJECT ` with its action. #[derive(sqlquack_syntax_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ExecuteObject { diff --git a/crates/sqlquack-syntax/src/expressions/tree/_helpers/accessors.rs b/crates/sqlquack-syntax/src/expressions/tree/_helpers/accessors.rs index ba44b4728..e6e9d5297 100644 --- a/crates/sqlquack-syntax/src/expressions/tree/_helpers/accessors.rs +++ b/crates/sqlquack-syntax/src/expressions/tree/_helpers/accessors.rs @@ -133,6 +133,7 @@ impl Expression { | Expression::Prepare(_) | Expression::Execute(_) | Expression::ExecuteObject(_) + | Expression::ExecuteUsingPolicyContext(_) | Expression::Declare(_) | Expression::Refresh(_) | Expression::AlterSession(_) diff --git a/crates/sqlquack-syntax/src/expressions/tree/_helpers/api.rs b/crates/sqlquack-syntax/src/expressions/tree/_helpers/api.rs index 7e97818e9..92113e03f 100644 --- a/crates/sqlquack-syntax/src/expressions/tree/_helpers/api.rs +++ b/crates/sqlquack-syntax/src/expressions/tree/_helpers/api.rs @@ -446,6 +446,7 @@ impl Expression { Expression::Prepare(_) => "prepare", Expression::Execute(_) => "execute", Expression::ExecuteObject(_) => "execute_object", + Expression::ExecuteUsingPolicyContext(_) => "execute_using_policy_context", Expression::Raw(_) => "raw", Expression::CreateTask(_) => "create_task", Expression::Paren(_) => "paren", diff --git a/crates/sqlquack-syntax/src/expressions/tree/models.rs b/crates/sqlquack-syntax/src/expressions/tree/models.rs index beed444ea..250eb74d7 100644 --- a/crates/sqlquack-syntax/src/expressions/tree/models.rs +++ b/crates/sqlquack-syntax/src/expressions/tree/models.rs @@ -387,6 +387,7 @@ use crate::expressions::statements::models::Detach; use crate::expressions::statements::models::DropPartition; use crate::expressions::statements::models::ExecuteObject; use crate::expressions::statements::models::ExecuteStatement; +use crate::expressions::statements::models::ExecuteUsingPolicyContext; use crate::expressions::statements::models::Get; use crate::expressions::statements::models::Grant; use crate::expressions::statements::models::GrantPrincipal; @@ -1222,6 +1223,7 @@ pub enum Expression { /// EXEC/EXECUTE statement (TSQL stored procedure call) Execute(Box), ExecuteObject(Box), + ExecuteUsingPolicyContext(Box), /// Snowflake CREATE TASK statement CreateTask(Box), diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs index e20765603..c6963815a 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs @@ -25,6 +25,7 @@ use crate::expressions::statements::models::Comment; use crate::expressions::statements::models::ExecuteObject; use crate::expressions::statements::models::ExecuteParameter; use crate::expressions::statements::models::ExecuteStatement; +use crate::expressions::statements::models::ExecuteUsingPolicyContext; use crate::expressions::statements::models::Grant; use crate::expressions::statements::models::GrantPrincipal; use crate::expressions::statements::models::Kill; @@ -93,6 +94,9 @@ impl Parser { if let Some(call) = self.parse_snowflake_method_call()? { return Ok(call); } + if self.applies_snowflake_context_rules() { + return self.parse_call_target(None); + } let mut tokens = vec![("CALL".to_string(), call_token.token_type)]; while !self.is_at_end() && !self.check(TokenType::Semicolon) { @@ -159,6 +163,30 @@ impl Parser { ) } + /// The rest of Snowflake `EXECUTE USING POLICY_CONTEXT ( => , ... ) AS `. + fn parse_execute_using_policy_context(&mut self) -> Result { + self.expect_token(TokenType::LParen)?; + let arguments = self.parse_function_arguments()?; + if arguments + .iter() + .any(|argument| !matches!(argument, Expression::NamedArgument(_))) + { + return Err(self.parse_error("POLICY_CONTEXT takes only named arguments")); + } + self.expect_token(TokenType::RParen)?; + self.expect_token(TokenType::As)?; + if !(self.check(TokenType::Select) + || self.check(TokenType::With) + || self.check(TokenType::LParen)) + { + return Err(self.parse_error("Expected a query after POLICY_CONTEXT ( ... ) AS")); + } + let query = self.parse_statement()?; + Ok(Expression::ExecuteUsingPolicyContext(Box::new( + ExecuteUsingPolicyContext { arguments, query }, + ))) + } + /// Snowflake `EXECUTE ALERT | TASK [ GRAPH ] | DCM PROJECT ` with its action, or none. fn parse_snowflake_execute_object(&mut self) -> Result> { let start = self.current; @@ -221,6 +249,9 @@ impl Parser { if let Some(statement) = self.parse_snowflake_execute_object()? { return Ok(statement); } + if self.match_text_seq(&["USING", "POLICY_CONTEXT"]) { + return self.parse_execute_using_policy_context(); + } if self.current + 1 >= self.tokens.len() || self.check_next(TokenType::Semicolon) { return Err(self.parse_error("Expected an object kind and name after EXECUTE")); } diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs index 15afae9b1..5429b2b9a 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs @@ -1496,6 +1496,11 @@ impl Parser { } } else { let expr = self.parse_addition()?; + if self.applies_snowflake_context_rules() + && matches!(&expr, Expression::Literal(literal) if matches!(**literal, Literal::String(_))) + { + return Err(self.parse_error("A Snowflake frame bound value is not a string")); + } if self.match_token(TokenType::Preceding) { let text = self.tokens[self.current - 1].text.to_string(); Ok((WindowFrameBound::Preceding(Box::new(expr)), Some(text))) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/procedures.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/procedures.rs index d602505f7..a171fe02f 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/procedures.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/procedures.rs @@ -206,12 +206,17 @@ impl Parser { Ok(FunctionBody::Statements(statements)) } - /// `CALL ( [[ =>] , ...] ) [INTO :]` after a WITH clause. + /// `CALL ( ... ) [INTO :]` after a WITH clause. pub(crate) fn parse_call_with(&mut self, with: With) -> Result { if !self.check_identifier("CALL") { return Err(self.parse_error("Expected CALL after an anonymous procedure")); } self.skip(); + self.parse_call_target(Some(with)) + } + + /// ` ( [[ =>] , ...] ) [INTO :]` after CALL. + pub(crate) fn parse_call_target(&mut self, with: Option) -> Result { let mut name = vec![self.expect_identifier_or_keyword_with_quoted()?]; while self.match_token(TokenType::Dot) { name.push(self.expect_identifier_or_keyword_with_quoted()?); @@ -241,7 +246,7 @@ impl Parser { arguments, asynchronous: false, hints: Vec::new(), - with: Some(with), + with, into, }))) } diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs index ea08f5a7f..d7d291b87 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs @@ -374,6 +374,36 @@ fn given_recorded_snowflake_cases_when_parsing_then_acceptance_matches_snowflake dialect: Dialect::Snowflake, expected_accepted: true, }, + AcceptanceTestCase { + description: "CALL needs its argument list closed (recorded errno 1003)", + sql: "CALL SET_SYS_CONTEXT ( 'm' , 'm' , 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "CALL takes no empty argument (recorded errno 1003)", + sql: "CALL SET_SYS_CONTEXT ( 'm' , , 'm' ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a frame bound is not a string (recorded errno 1003)", + sql: "SELECT SUM ( ) OVER ( ROWS 'x' PRECEDING ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "EXECUTE USING POLICY_CONTEXT runs a query (recorded runtime error)", + sql: "EXECUTE USING POLICY_CONTEXT ( v1 => 'm' ) AS SELECT 1 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "POLICY_CONTEXT ( ... ) AS needs a query (recorded errno 1003)", + sql: "EXECUTE USING POLICY_CONTEXT ( v1 => 'm' ) AS 1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, ]; for test_case in test_cases { From 5f3eced9582df34ef91b152d3c9bf48fa66f8c44 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:23:46 +0000 Subject: [PATCH 10/14] Parse Snowflake ADD, DROP and MODIFY DATA METRIC FUNCTION in ALTER TABLE and ALTER VIEW A DataMetricFunctionAction holds the items, each a function name, its ON arguments and its clauses (SENSITIVITY, ANOMALY_DETECTION, EXPECTATION changes, WITHIN GROUP ... GROUP LIMIT, EXECUTE AS ROLE, FILTER, SUSPEND and RESUME). The ON list takes columns, then optionally TABLE ( ( , ... ) ) or a lambda; the column list may be empty, as Snowflake's receipts show. --- .../src/expressions/columns/models.rs | 50 ++++ .../src/expressions/schema_objects/models.rs | 1 + .../src/parser/_helpers/ddl/alter.rs | 4 +- .../src/parser/_helpers/ddl/mod.rs | 1 + .../_helpers/ddl/snowflake_data_metric.rs | 234 ++++++++++++++++++ .../parser/_helpers/expressions/operators.rs | 4 +- .../tests/parser/test_snowflake_recorded.rs | 36 +++ 7 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_data_metric.rs diff --git a/crates/sqlquack-syntax/src/expressions/columns/models.rs b/crates/sqlquack-syntax/src/expressions/columns/models.rs index 18c0a4553..f4a642ffb 100644 --- a/crates/sqlquack-syntax/src/expressions/columns/models.rs +++ b/crates/sqlquack-syntax/src/expressions/columns/models.rs @@ -551,9 +551,59 @@ pub enum ColumnPosition { After(Identifier), } +/// Snowflake `{ ADD | DROP | MODIFY } DATA METRIC FUNCTION ON ( ... ) [ ], ...`. +#[derive(sqlquack_syntax_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DataMetricFunctionAction { + /// `ADD`, `DROP` or `MODIFY`. + pub kind: String, + pub items: Vec, +} + +/// One ` ON ( , ... )` of a data metric function action, with its clauses. +#[derive(sqlquack_syntax_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DataMetricFunctionItem { + pub name: TableRef, + pub arguments: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub clauses: Vec, +} + +/// A clause after a data metric function's arguments. +#[derive(sqlquack_syntax_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum DataMetricFunctionClause { + /// `SENSITIVITY = ` or `ANOMALY_DETECTION = `. + Property { + name: String, + value: Expression, + }, + /// `[ ADD | DROP | MODIFY ] EXPECTATION , ...`. + Expectations { + action: Option, + expectations: Vec, + }, + /// `WITHIN GROUP ( , ... ) [ GROUP LIMIT ]`. + WithinGroup { + columns: Vec, + limit: Option, + }, + ExecuteAsRole(Identifier), + /// `FILTER ( [ ] )`. + Filter(Option), + /// `SUSPEND` or `RESUME`. + State(String), +} + +/// A data metric function expectation: its name and, except under DROP, its condition. +#[derive(sqlquack_syntax_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DataMetricExpectation { + pub name: Identifier, + pub condition: Option, +} + /// Actions for ALTER TABLE #[derive(sqlquack_syntax_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum AlterTableAction { + DataMetricFunction(DataMetricFunctionAction), AddColumn { column: ColumnDef, if_not_exists: bool, diff --git a/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs b/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs index 9b0424ec6..5734e3439 100644 --- a/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs +++ b/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs @@ -215,6 +215,7 @@ pub struct AlterView { pub enum AlterViewAction { /// Rename the view Rename(Box), + DataMetricFunction(crate::expressions::columns::models::DataMetricFunctionAction), /// Change owner OwnerTo(Identifier), /// Set schema diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/alter.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/alter.rs index dae43e905..90e6a6e1d 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/alter.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/alter.rs @@ -39,7 +39,9 @@ use crate::tokens::types::Result; impl Parser { /// Parse ALTER TABLE action pub(crate) fn parse_alter_action(&mut self) -> Result { - if self.match_token(TokenType::Add) { + if let Some(action) = self.parse_data_metric_function_action()? { + Ok(AlterTableAction::DataMetricFunction(action)) + } else if self.match_token(TokenType::Add) { self.parse_alter_add_action() } else if self.match_token(TokenType::Drop) { self.parse_alter_drop_action() diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs index 382133520..0ecfb8c3c 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs @@ -15,6 +15,7 @@ pub(crate) mod snowflake_alter_tables; pub(crate) mod snowflake_commands; pub(crate) mod snowflake_create_clone; pub(crate) mod snowflake_create_from; +pub(crate) mod snowflake_data_metric; pub(crate) mod snowflake_show; pub(crate) mod snowflake_statements; pub(crate) mod table_options; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_data_metric.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_data_metric.rs new file mode 100644 index 000000000..5e3375100 --- /dev/null +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_data_metric.rs @@ -0,0 +1,234 @@ +//! Snowflake data metric function actions in ALTER TABLE and ALTER VIEW. + +use crate::expressions::columns::models::DataMetricExpectation; +use crate::expressions::columns::models::DataMetricFunctionAction; +use crate::expressions::columns::models::DataMetricFunctionClause; +use crate::expressions::columns::models::DataMetricFunctionItem; +use crate::expressions::tree::models::Expression; +use crate::parser::models::Parser; +use crate::tokens::models::TokenType; +use crate::tokens::types::Result; + +impl Parser { + /// `{ ADD | DROP | MODIFY } DATA METRIC FUNCTION , ...` in a Snowflake ALTER TABLE or VIEW, or none. + pub(crate) fn parse_data_metric_function_action( + &mut self, + ) -> Result> { + if !self.is_snowflake() { + return Ok(None); + } + let kind = if self.check(TokenType::Add) { + "ADD" + } else if self.check(TokenType::Drop) { + "DROP" + } else if self.check_identifier("MODIFY") { + "MODIFY" + } else { + return Ok(None); + }; + let start = self.current; + self.skip(); + if !self.match_text_seq(&["DATA", "METRIC", "FUNCTION"]) { + self.current = start; + return Ok(None); + } + let mut items = vec![self.parse_data_metric_function_item(kind)?]; + while self.match_token(TokenType::Comma) { + items.push(self.parse_data_metric_function_item(kind)?); + } + Ok(Some(DataMetricFunctionAction { + kind: kind.to_owned(), + items, + })) + } + + /// ` ON ( [ , ... ] )` with the clauses its action allows. + fn parse_data_metric_function_item(&mut self, kind: &str) -> Result { + let name = self.parse_object_name_ref()?; + if !self.match_token(TokenType::On) { + return Err(self.parse_error("Expected ON after a data metric function name")); + } + let arguments = self.parse_data_metric_arguments()?; + let mut clauses: Vec = Vec::new(); + match kind { + "ADD" => { + while let Some(clause) = self.parse_data_metric_add_clause()? { + clauses.push(clause); + } + } + "MODIFY" => clauses.push(self.parse_data_metric_modify_clause()?), + _ => {} + } + Ok(DataMetricFunctionItem { + name, + arguments, + clauses, + }) + } + + /// `( [ , ... ] [ , TABLE ( ( , ... ) ) | , ] )`; the column list may be empty. + fn parse_data_metric_arguments(&mut self) -> Result> { + self.expect_token(TokenType::LParen)?; + let leading_comma = self.match_token(TokenType::Comma); + let arguments = if self.check(TokenType::RParen) && !leading_comma { + Vec::new() + } else { + self.parse_function_arguments()? + }; + self.expect_token(TokenType::RParen)?; + let column = |argument: &Expression| matches!(argument, Expression::Column(_)); + let last_allowed = |argument: &Expression| match argument { + Expression::Lambda(_) => true, + Expression::Function(table) if table.name.eq_ignore_ascii_case("TABLE") => { + matches!(table.args.as_slice(), [Expression::Function(source)] + if !source.args.is_empty() && source.args.iter().all(column)) + } + _ => false, + }; + let valid = match arguments.split_last() { + None => true, + Some((last, columns)) if leading_comma => columns.is_empty() && last_allowed(last), + Some((last, columns)) => { + columns.iter().all(column) && (column(last) || last_allowed(last)) + } + }; + if !valid { + return Err(self.parse_error( + "A data metric function takes columns, then optionally TABLE ( ( , ... ) ) or a lambda", + )); + } + Ok(arguments) + } + + /// A clause of ADD DATA METRIC FUNCTION, or none. + fn parse_data_metric_add_clause(&mut self) -> Result> { + if self.check_identifier("SENSITIVITY") || self.check_identifier("ANOMALY_DETECTION") { + let name = self.advance_text()?.to_ascii_uppercase(); + self.expect_token(TokenType::Eq)?; + let value = self.parse_primary()?; + return Ok(Some(DataMetricFunctionClause::Property { name, value })); + } + if self.match_identifier("EXPECTATION") { + return Ok(Some(DataMetricFunctionClause::Expectations { + action: None, + expectations: self.parse_data_metric_expectations(true)?, + })); + } + if self.match_text_seq(&["WITHIN", "GROUP"]) { + let columns = self.parse_wrapped_csv_expressions()?; + if columns.is_empty() + || !columns + .iter() + .all(|column| matches!(column, Expression::Column(_))) + { + return Err(self.parse_error("WITHIN GROUP takes columns")); + } + let limit = if self.match_text_seq(&["GROUP", "LIMIT"]) { + Some(self.parse_primary()?) + } else { + None + }; + return Ok(Some(DataMetricFunctionClause::WithinGroup { + columns, + limit, + })); + } + if self.match_text_seq(&["EXECUTE", "AS", "ROLE"]) { + let role = self.expect_identifier_or_keyword_with_quoted()?; + self.reject_reserved_name_part(&role)?; + return Ok(Some(DataMetricFunctionClause::ExecuteAsRole(role))); + } + if self.check(TokenType::Filter) { + let filter = self.parse_data_metric_filter()?; + if filter == DataMetricFunctionClause::Filter(None) { + return Err( + self.parse_error("ADD DATA METRIC FUNCTION ... FILTER needs a condition") + ); + } + return Ok(Some(filter)); + } + Ok(None) + } + + /// The one clause of a MODIFY DATA METRIC FUNCTION item. + fn parse_data_metric_modify_clause(&mut self) -> Result { + if self.check_identifier("SUSPEND") || self.check_identifier("RESUME") { + return Ok(DataMetricFunctionClause::State( + self.advance_text()?.to_ascii_uppercase(), + )); + } + if self.check(TokenType::Filter) { + return self.parse_data_metric_filter(); + } + let (action, with_condition) = if self.match_token(TokenType::Add) { + ("ADD", true) + } else if self.match_token(TokenType::Drop) { + ("DROP", false) + } else if self.match_identifier("MODIFY") { + ("MODIFY", true) + } else { + return Err(self.parse_error( + "Expected SUSPEND, RESUME, FILTER or an EXPECTATION change after the arguments", + )); + }; + if !self.match_identifier("EXPECTATION") { + return Err(self.parse_error("Expected EXPECTATION")); + } + Ok(DataMetricFunctionClause::Expectations { + action: Some(action.to_owned()), + expectations: self.parse_data_metric_expectations(with_condition)?, + }) + } + + /// `FILTER ( [ ] )`. + fn parse_data_metric_filter(&mut self) -> Result { + self.skip(); + self.expect_token(TokenType::LParen)?; + let condition = if self.check(TokenType::RParen) { + None + } else { + Some(self.parse_expression()?) + }; + self.expect_token(TokenType::RParen)?; + Ok(DataMetricFunctionClause::Filter(condition)) + } + + /// ` ( ), ...`, or bare names under DROP; a comma before ` ON` ends the list. + fn parse_data_metric_expectations( + &mut self, + with_condition: bool, + ) -> Result> { + let mut expectations: Vec = Vec::new(); + loop { + let name = self.expect_identifier_or_keyword_with_quoted()?; + self.reject_reserved_name_part(&name)?; + let condition = if with_condition { + self.expect_token(TokenType::LParen)?; + let condition = self.parse_expression()?; + self.expect_token(TokenType::RParen)?; + Some(condition) + } else { + None + }; + expectations.push(DataMetricExpectation { name, condition }); + let next = self + .tokens + .get(self.current + 1) + .map(|token| token.token_type); + let after = self + .tokens + .get(self.current + 2) + .map(|token| token.token_type); + let continues = if with_condition { + after == Some(TokenType::LParen) + } else { + !matches!(after, Some(TokenType::On) | Some(TokenType::Dot)) + && next != Some(TokenType::Semicolon) + }; + if !(self.check(TokenType::Comma) && continues) { + return Ok(expectations); + } + self.skip(); + } + } +} diff --git a/crates/sqlquack-syntax/src/parser/_helpers/expressions/operators.rs b/crates/sqlquack-syntax/src/parser/_helpers/expressions/operators.rs index 42401f1b5..73bdd10a7 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/expressions/operators.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/expressions/operators.rs @@ -1377,7 +1377,9 @@ impl Parser { None }; - if self.match_token(TokenType::Rename) { + if let Some(action) = self.parse_data_metric_function_action()? { + actions.push(AlterViewAction::DataMetricFunction(action)); + } else if self.match_token(TokenType::Rename) { self.expect_token(TokenType::To)?; actions.push(AlterViewAction::Rename(Box::new(self.parse_table_ref()?))); } else if self.match_identifier("OWNER") { diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs index d7d291b87..d1fb5bcc3 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs @@ -404,6 +404,42 @@ fn given_recorded_snowflake_cases_when_parsing_then_acceptance_matches_snowflake dialect: Dialect::Snowflake, expected_accepted: false, }, + AcceptanceTestCase { + description: "MODIFY DATA METRIC FUNCTION adds expectations (recorded semantic error)", + sql: "ALTER TABLE v1 MODIFY DATA METRIC FUNCTION v1 ON ( v1 ) ADD EXPECTATION v1 ( 1 ) , v1 ( 1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a data metric function's column list may be empty before TABLE (recorded semantic error)", + sql: "ALTER TABLE v1 ADD DATA METRIC FUNCTION SNOWFLAKE . CORE . ROW_COUNT_DIFFERENCE ON ( , TABLE ( v1 ( v1 ) ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a data metric function's TABLE source names columns (recorded errno 1003)", + sql: "ALTER TABLE v1 ADD DATA METRIC FUNCTION SNOWFLAKE . CORE . ROW_COUNT_DIFFERENCE ON ( v1 , TABLE ( v1 ( ) ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "MODIFY DATA METRIC FUNCTION may clear its filter (recorded semantic error)", + sql: "ALTER TABLE v1 MODIFY DATA METRIC FUNCTION v1 ON ( v1 ) FILTER ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "ADD DATA METRIC FUNCTION needs a filter condition (recorded errno 1003)", + sql: "ALTER TABLE v1 ADD DATA METRIC FUNCTION v1 ON ( v1 ) FILTER ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "ALTER VIEW adds data metric functions (recorded semantic error)", + sql: "ALTER VIEW v1 ADD DATA METRIC FUNCTION v1 ON ( v1 ) , \"SELECT\" ON ( v1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, ]; for test_case in test_cases { From 7b2075b081827644d35820e25aca324b97fae066 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:32:37 +0000 Subject: [PATCH 11/14] Keep a CALL name's empty schema part, as in SNOWFLAKE..ANOMALY_INSIGHTS!METHOD( ) The structured CALL refused the 26 receipted class method calls that leave the schema out; the coverage diff's parsed-now-refused line caught them. --- .../parser/_helpers/ddl/snowflake_commands.rs | 51 +++++++++++-------- .../parser/_helpers/statements/procedures.rs | 8 +-- .../tests/parser/test_snowflake_recorded.rs | 6 +++ 3 files changed, 37 insertions(+), 28 deletions(-) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_commands.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_commands.rs index d5ae29475..816b412f4 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_commands.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_commands.rs @@ -1,5 +1,6 @@ //! Snowflake's rules on COMMENT ON names and values, and its class instance method calls. +use crate::expressions::names::models::Identifier; use crate::expressions::operators::models::Call; use crate::expressions::tree::models::Expression; use crate::parser::constants::SNOWFLAKE_RESERVED_KEYWORDS; @@ -30,6 +31,21 @@ impl Parser { Ok(()) } + /// A called object's dotted name; an empty middle part (`..`) is kept as an empty part. + pub(crate) fn parse_call_name(&mut self) -> Result> { + let mut name = vec![self.expect_identifier_or_keyword_with_quoted()?]; + while self.match_token(TokenType::Dot) { + if name.len() == 1 && self.match_token(TokenType::Dot) { + name.push(Identifier::new("")); + } + name.push(self.expect_identifier_or_keyword_with_quoted()?); + } + for part in &name { + self.reject_reserved_name_part(part)?; + } + Ok(name) + } + /// Parse a Snowflake class instance method call, `!( [ , ... ] )`, after CALL; None for any other CALL. pub(super) fn parse_snowflake_method_call(&mut self) -> Result> { if !self.is_snowflake() { @@ -38,30 +54,23 @@ impl Parser { if self.check(TokenType::Exclamation) { return Err(self.parse_error("Expected an instance name before !")); } - let mut offset = 0; - while self - .tokens - .get(self.current + offset + 1) - .map(|token| token.token_type) - == Some(TokenType::Dot) - { - offset += 2; + let token_type = |offset: usize| { + self.tokens + .get(self.current + offset) + .map(|token| token.token_type) + }; + let mut offset = 1; + while token_type(offset) == Some(TokenType::Dot) { + offset += if token_type(offset + 1) == Some(TokenType::Dot) { + 3 + } else { + 2 + }; } - if self - .tokens - .get(self.current + offset + 1) - .map(|token| token.token_type) - != Some(TokenType::Exclamation) - { + if token_type(offset) != Some(TokenType::Exclamation) { return Ok(None); } - let mut name = vec![self.expect_identifier_or_keyword_with_quoted()?]; - while self.match_token(TokenType::Dot) { - name.push(self.expect_identifier_or_keyword_with_quoted()?); - } - for part in &name { - self.reject_reserved_name_part(part)?; - } + let name = self.parse_call_name()?; self.expect_token(TokenType::Exclamation)?; if !self.is_identifier_token() { return Err(self.parse_error("Expected a method name after !")); diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/procedures.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/procedures.rs index a171fe02f..223defc3d 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/procedures.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/procedures.rs @@ -217,13 +217,7 @@ impl Parser { /// ` ( [[ =>] , ...] ) [INTO :]` after CALL. pub(crate) fn parse_call_target(&mut self, with: Option) -> Result { - let mut name = vec![self.expect_identifier_or_keyword_with_quoted()?]; - while self.match_token(TokenType::Dot) { - name.push(self.expect_identifier_or_keyword_with_quoted()?); - } - for part in &name { - self.reject_reserved_name_part(part)?; - } + let name = self.parse_call_name()?; self.expect_token(TokenType::LParen)?; let arguments = if self.check(TokenType::RParen) { Vec::new() diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs index d1fb5bcc3..f8bbc7010 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs @@ -380,6 +380,12 @@ fn given_recorded_snowflake_cases_when_parsing_then_acceptance_matches_snowflake dialect: Dialect::Snowflake, expected_accepted: false, }, + AcceptanceTestCase { + description: "CALL names a class method with the schema left out (recorded semantic error)", + sql: "CALL SNOWFLAKE . . ANOMALY_INSIGHTS!DROP_MONITOR ( 'm' ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, AcceptanceTestCase { description: "CALL takes no empty argument (recorded errno 1003)", sql: "CALL SET_SYS_CONTEXT ( 'm' , , 'm' ) ;", From 95e0ac5632ddcbea9029ddc7567b341ce90d7a74 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:47:08 +0000 Subject: [PATCH 12/14] Keep the structured NOT NULL, data metric function and POLICY_CONTEXT rules to faithful Snowflake parsing The exact check compares the polyglot modes with sqlglot, which refuses ARRAY(VARCHAR NOT NULL); these Snowflake-only rules now apply where the CALL and frame-bound rules do. --- crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs | 4 +++- .../src/parser/_helpers/ddl/snowflake_data_metric.rs | 2 +- crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs | 5 +++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs index c6963815a..2aec00511 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs @@ -249,7 +249,9 @@ impl Parser { if let Some(statement) = self.parse_snowflake_execute_object()? { return Ok(statement); } - if self.match_text_seq(&["USING", "POLICY_CONTEXT"]) { + if self.applies_snowflake_context_rules() + && self.match_text_seq(&["USING", "POLICY_CONTEXT"]) + { return self.parse_execute_using_policy_context(); } if self.current + 1 >= self.tokens.len() || self.check_next(TokenType::Semicolon) { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_data_metric.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_data_metric.rs index 5e3375100..8a6953dfa 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_data_metric.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_data_metric.rs @@ -14,7 +14,7 @@ impl Parser { pub(crate) fn parse_data_metric_function_action( &mut self, ) -> Result> { - if !self.is_snowflake() { + if !self.applies_snowflake_context_rules() { return Ok(None); } let kind = if self.check(TokenType::Add) { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs index c3466ce1c..f12821c52 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs @@ -1253,8 +1253,9 @@ impl Parser { /// `inner` with a trailing Snowflake `NOT NULL` on a structured type's element or value. fn parse_snowflake_not_null(&mut self, inner: DataType) -> DataType { - let not_null = - self.is_snowflake() && self.check(TokenType::Not) && self.check_next(TokenType::Null); + let not_null = self.applies_snowflake_context_rules() + && self.check(TokenType::Not) + && self.check_next(TokenType::Null); if !not_null { return inner; } From 5ae92e0c28f2e522e152fdc4971598dc769efbad Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:54:44 +0000 Subject: [PATCH 13/14] Compare a NOT NULL structured element's base type name with polyglot's polyglot has no NOT NULL element type, so the type-name check unwraps the wrapper before converting; it compares base names (ARRAY, MAP) only. --- .../src/comparison/_helpers/outcomes.rs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/sqlquack-polyglot-check/src/comparison/_helpers/outcomes.rs b/crates/sqlquack-polyglot-check/src/comparison/_helpers/outcomes.rs index 88fd8e7fc..27baf5351 100644 --- a/crates/sqlquack-polyglot-check/src/comparison/_helpers/outcomes.rs +++ b/crates/sqlquack-polyglot-check/src/comparison/_helpers/outcomes.rs @@ -42,13 +42,38 @@ pub(crate) fn lossless_failure(sql: &str, dialect: Dialect, parsed: bool) -> Opt } } +/// `value` with each Snowflake `NOT NULL` element wrapper replaced by its inner type, which polyglot lacks. +fn without_not_null(value: serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Object(mut object) + if object.get("data_type").and_then(serde_json::Value::as_str) == Some("not_null") => + { + object + .remove("inner") + .map(without_not_null) + .unwrap_or(serde_json::Value::Object(object)) + } + serde_json::Value::Object(object) => serde_json::Value::Object( + object + .into_iter() + .map(|(key, field)| (key, without_not_null(field))) + .collect(), + ), + serde_json::Value::Array(items) => { + serde_json::Value::Array(items.into_iter().map(without_not_null).collect()) + } + other => other, + } +} + /// polyglot's Snowflake rendering of `data_type`, cut to its upper-case base name. pub(crate) fn polyglot_base_type_name( data_type: &sqlquack_syntax::DataType, dialect: polyglot_sql::DialectType, ) -> String { - let converted = - serde_json::to_value(data_type).and_then(serde_json::from_value::); + let converted = serde_json::to_value(data_type) + .map(without_not_null) + .and_then(serde_json::from_value::); let theirs = match converted { Ok(theirs) => theirs, Err(_) => return "".to_owned(), From f3c75ab3a132fdd62bf299818f9780b51f846900 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:03:16 +0000 Subject: [PATCH 14/14] Move the Snowflake EXECUTE object and POLICY_CONTEXT parsers to snowflake_commands.rs commands.rs passed fensu's 2,000-line limit after merging main; these Snowflake EXECUTE forms sit with the other Snowflake commands. --- .../src/parser/_helpers/ddl/commands.rs | 79 ------------------- .../parser/_helpers/ddl/snowflake_commands.rs | 79 +++++++++++++++++++ 2 files changed, 79 insertions(+), 79 deletions(-) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs index e6cf6d85b..8c7ebd5be 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs @@ -22,10 +22,8 @@ use crate::expressions::schema_objects::models::FunctionUsingResource; use crate::expressions::schema_objects::models::SqlDataAccess; use crate::expressions::statements::models::Command; use crate::expressions::statements::models::Comment; -use crate::expressions::statements::models::ExecuteObject; use crate::expressions::statements::models::ExecuteParameter; use crate::expressions::statements::models::ExecuteStatement; -use crate::expressions::statements::models::ExecuteUsingPolicyContext; use crate::expressions::statements::models::Grant; use crate::expressions::statements::models::GrantPrincipal; use crate::expressions::statements::models::Kill; @@ -40,7 +38,6 @@ use crate::expressions::tables::models::CollateProperty; use crate::expressions::tables::models::Property; use crate::expressions::tree::models::Expression; use crate::expressions::types::models::DataType; -use crate::parser::constants::EXECUTE_DCM_PROJECT; use crate::parser::constants::FUNCTION; use crate::parser::constants::PROCEDURE; use crate::parser::constants::QUALIFIED_NAME_PARTS; @@ -163,82 +160,6 @@ impl Parser { ) } - /// The rest of Snowflake `EXECUTE USING POLICY_CONTEXT ( => , ... ) AS `. - fn parse_execute_using_policy_context(&mut self) -> Result { - self.expect_token(TokenType::LParen)?; - let arguments = self.parse_function_arguments()?; - if arguments - .iter() - .any(|argument| !matches!(argument, Expression::NamedArgument(_))) - { - return Err(self.parse_error("POLICY_CONTEXT takes only named arguments")); - } - self.expect_token(TokenType::RParen)?; - self.expect_token(TokenType::As)?; - if !(self.check(TokenType::Select) - || self.check(TokenType::With) - || self.check(TokenType::LParen)) - { - return Err(self.parse_error("Expected a query after POLICY_CONTEXT ( ... ) AS")); - } - let query = self.parse_statement()?; - Ok(Expression::ExecuteUsingPolicyContext(Box::new( - ExecuteUsingPolicyContext { arguments, query }, - ))) - } - - /// Snowflake `EXECUTE ALERT | TASK [ GRAPH ] | DCM PROJECT ` with its action, or none. - fn parse_snowflake_execute_object(&mut self) -> Result> { - let start = self.current; - let kind = if self.match_text_seq(&["ALERT"]) { - "ALERT" - } else if self.match_text_seq(&["DCM", "PROJECT"]) { - EXECUTE_DCM_PROJECT - } else if self.match_text_seq(&["TASK", "GRAPH"]) { - "TASK GRAPH" - } else if self.match_text_seq(&["TASK"]) { - "TASK" - } else { - return Ok(None); - }; - let name = self.parse_object_name_ref()?; - let mut node = ExecuteObject { - kind: kind.to_owned(), - name, - action: None, - group: None, - alias: None, - config: None, - }; - if kind.starts_with("TASK") { - if self.match_text_seq(&["RETRY", "LAST"]) { - node.action = Some("RETRY LAST".to_owned()); - } else if self.match_text_seq(&["RETRY", "GRAPH", "RUN", "GROUP"]) { - node.action = Some("RETRY GRAPH RUN".to_owned()); - if !self.check(TokenType::String) { - return Err(self.parse_error("Expected the graph run group as a string")); - } - node.group = Some(self.parse_primary()?); - } else if self.match_text_seq(&["USING", "CONFIG"]) { - self.expect_token(TokenType::Eq)?; - node.config = Some(self.parse_primary()?); - } - } else if kind == EXECUTE_DCM_PROJECT { - if self.match_text_seq(&["PURGE"]) { - node.action = Some("PURGE".to_owned()); - if self.match_token(TokenType::As) { - node.alias = Some(self.expect_identifier_or_keyword_with_quoted()?); - } - } else if self.match_text_seq(&["TEST", "ALL"]) { - node.action = Some("TEST ALL".to_owned()); - } else { - self.current = start; - return Ok(None); - } - } - Ok(Some(Expression::ExecuteObject(Box::new(node)))) - } - /// Parses TSQL EXEC/EXECUTE [schema.]procedure [@param = value, ...]. pub(crate) fn parse_execute(&mut self) -> Result { self.expect_token(TokenType::Execute)?; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_commands.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_commands.rs index 816b412f4..326320716 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_commands.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_commands.rs @@ -2,7 +2,10 @@ use crate::expressions::names::models::Identifier; use crate::expressions::operators::models::Call; +use crate::expressions::statements::models::ExecuteObject; +use crate::expressions::statements::models::ExecuteUsingPolicyContext; use crate::expressions::tree::models::Expression; +use crate::parser::constants::EXECUTE_DCM_PROJECT; use crate::parser::constants::SNOWFLAKE_RESERVED_KEYWORDS; use crate::parser::models::Parser; use crate::tokens::models::TokenType; @@ -94,4 +97,80 @@ impl Parser { into: None, })))) } + + /// The rest of Snowflake `EXECUTE USING POLICY_CONTEXT ( => , ... ) AS `. + pub(super) fn parse_execute_using_policy_context(&mut self) -> Result { + self.expect_token(TokenType::LParen)?; + let arguments = self.parse_function_arguments()?; + if arguments + .iter() + .any(|argument| !matches!(argument, Expression::NamedArgument(_))) + { + return Err(self.parse_error("POLICY_CONTEXT takes only named arguments")); + } + self.expect_token(TokenType::RParen)?; + self.expect_token(TokenType::As)?; + if !(self.check(TokenType::Select) + || self.check(TokenType::With) + || self.check(TokenType::LParen)) + { + return Err(self.parse_error("Expected a query after POLICY_CONTEXT ( ... ) AS")); + } + let query = self.parse_statement()?; + Ok(Expression::ExecuteUsingPolicyContext(Box::new( + ExecuteUsingPolicyContext { arguments, query }, + ))) + } + + /// Snowflake `EXECUTE ALERT | TASK [ GRAPH ] | DCM PROJECT ` with its action, or none. + pub(super) fn parse_snowflake_execute_object(&mut self) -> Result> { + let start = self.current; + let kind = if self.match_text_seq(&["ALERT"]) { + "ALERT" + } else if self.match_text_seq(&["DCM", "PROJECT"]) { + EXECUTE_DCM_PROJECT + } else if self.match_text_seq(&["TASK", "GRAPH"]) { + "TASK GRAPH" + } else if self.match_text_seq(&["TASK"]) { + "TASK" + } else { + return Ok(None); + }; + let name = self.parse_object_name_ref()?; + let mut node = ExecuteObject { + kind: kind.to_owned(), + name, + action: None, + group: None, + alias: None, + config: None, + }; + if kind.starts_with("TASK") { + if self.match_text_seq(&["RETRY", "LAST"]) { + node.action = Some("RETRY LAST".to_owned()); + } else if self.match_text_seq(&["RETRY", "GRAPH", "RUN", "GROUP"]) { + node.action = Some("RETRY GRAPH RUN".to_owned()); + if !self.check(TokenType::String) { + return Err(self.parse_error("Expected the graph run group as a string")); + } + node.group = Some(self.parse_primary()?); + } else if self.match_text_seq(&["USING", "CONFIG"]) { + self.expect_token(TokenType::Eq)?; + node.config = Some(self.parse_primary()?); + } + } else if kind == EXECUTE_DCM_PROJECT { + if self.match_text_seq(&["PURGE"]) { + node.action = Some("PURGE".to_owned()); + if self.match_token(TokenType::As) { + node.alias = Some(self.expect_identifier_or_keyword_with_quoted()?); + } + } else if self.match_text_seq(&["TEST", "ALL"]) { + node.action = Some("TEST ALL".to_owned()); + } else { + self.current = start; + return Ok(None); + } + } + Ok(Some(Expression::ExecuteObject(Box::new(node)))) + } }