From cd6ea0cc946c09af91b7d3175b02293d356830ec Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:37:37 +0000 Subject: [PATCH 1/2] Parse the Disagreement-list receipts in B's families (WIP) Snowflake receipts disagree with the parser on these statements: - A known function called with empty parentheses parses in Snowflake (errno 938 is semantic). When a typed function's own parse fails on `name ( )`, the faithful Snowflake parse now keeps a plain call. Keyword-syntax names (CAST, EXTRACT, TRIM, ...) are excluded until receipts cover them. - CAST to a bare PRECISION or VARYING is a syntax error (1003, import 159). - EXECUTE DCM PROJECT takes DEPLOY [AS], PLAN [DELTA] ... [OUTPUT_PATH] and PREVIEW ... USING CONFIGURATION ... FROM [LIMIT]; EXECUTE needs a known kind; EXECUTE IMMEDIATE needs a statement, string, variable or FROM path. - ALTER SESSION SET needs an assignment with a value and takes assignments separated by spaces; SET needs a non-reserved variable name. - SHOW PARAMETERS takes IN USER [name]; IN TABLE takes an object name. - CREATE EXPERIMENT ... TYPE = ... [FROM SPECIFICATION] is a typed CreateObject, and CREATE TYPE = without a kind is refused. Against 56,748 receipted statements at evidence cd8b9c66: 283 now match, 0 regress. Receiver-less `!SPCS_*` calls stay unparsed. --- .../src/parser/_helpers/ddl/commands.rs | 10 +- .../src/parser/_helpers/ddl/scripting.rs | 14 +- .../parser/_helpers/ddl/snowflake_commands.rs | 179 ++++++++++++++++++ .../_helpers/ddl/snowflake_create_from.rs | 21 +- .../src/parser/_helpers/functions/typed.rs | 19 +- .../src/parser/_helpers/statements/analyze.rs | 13 +- .../src/parser/_helpers/statements/dml.rs | 3 + .../src/parser/_helpers/statements/entry.rs | 1 + .../src/parser/_helpers/types/casts.rs | 4 + .../sqlquack-syntax/src/parser/constants.rs | 3 + .../tests/parser/test_snowflake_fixes.rs | 2 +- .../tests/parser/test_snowflake_recorded.rs | 135 +++++++++++++ 12 files changed, 398 insertions(+), 6 deletions(-) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs index 2ccd6daa7..afa5a0976 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs @@ -177,7 +177,10 @@ impl Parser { { return self.parse_execute_using_policy_context(); } - if self.current + 1 >= self.tokens.len() || self.check_next(TokenType::Semicolon) { + if self.current + 1 >= self.tokens.len() + || self.check_next(TokenType::Semicolon) + || self.applies_snowflake_context_rules() + { return Err(self.parse_error("Expected an object kind and name after EXECUTE")); } } @@ -835,6 +838,11 @@ impl Parser { break; } + if self.applies_snowflake_context_rules() + && (self.check(TokenType::Eq) || self.is_snowflake_reserved_keyword()) + { + return Err(self.parse_error("Expected a variable name")); + } let name = self.parse_set_item_name()?; if self.match_token(TokenType::Eq) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/scripting.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/scripting.rs index 641a04ca3..c5a8481e1 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/scripting.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/scripting.rs @@ -18,6 +18,7 @@ use crate::expressions::tree::models::Expression; use crate::expressions::types::models::Tuple; use crate::parser::constants::ENGINE; use crate::parser::constants::MUTEX; +use crate::parser::constants::PARAMETERS; use crate::parser::constants::PLAN; use crate::parser::constants::REPRODUCTION; use crate::parser::constants::SECOND_PREVIOUS_OFFSET; @@ -654,6 +655,7 @@ impl Parser { fn parse_show_scope(&mut self, this: &str) -> Result<(Option, Option)> { Ok(if self.match_token(TokenType::In) { + let snowflake = self.applies_snowflake_context_rules(); let (kind, scope_obj) = if self.match_keyword("ACCOUNT") { (Some("ACCOUNT".to_string()), None) } else if self.match_token(TokenType::Database) { @@ -689,7 +691,10 @@ impl Parser { && !self.check(TokenType::Semicolon) && !self.check_keyword_text("STARTS") { - let table = self.parse_table_ref()?; + let table = match snowflake { + true => self.parse_object_name()?, + false => self.parse_table_ref()?, + }; Some(Expression::Table(Box::new(table))) } else { None @@ -708,6 +713,13 @@ impl Parser { None }; (Some("VIEW".to_string()), scope_obj) + } else if snowflake && this == PARAMETERS && self.match_identifier("USER") { + let named = !self.is_at_end() && !self.check(TokenType::Semicolon); + let scope_obj = match named { + true => Some(Expression::Table(Box::new(self.parse_object_name()?))), + false => None, + }; + (Some("USER".to_string()), scope_obj) } else if self.match_keyword("CLASS") { let scope_obj = if !self.is_at_end() { let table = self.parse_table_ref()?; 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 d2f7d318b..12b5110d9 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_commands.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_commands.rs @@ -3,16 +3,22 @@ use crate::expressions::names::models::Identifier; use crate::expressions::operators::models::Call; use crate::expressions::schema_objects::models::ExecuteObject; +use crate::expressions::schema_objects::models::ObjectProperty; use crate::expressions::statements::models::CreateTask; use crate::expressions::statements::models::ExecuteUsingPolicyContext; use crate::expressions::tree::models::Expression; +use crate::parser::_helpers::ddl::snowflake_alter::property; use crate::parser::constants::EXECUTE_DCM_PROJECT; use crate::parser::constants::SNOWFLAKE_RESERVED_KEYWORDS; use crate::parser::models::Parser; +use crate::tokens::models::ParserToken; use crate::tokens::models::TokenType; use crate::tokens::types::Result; use crate::traversal::types::ExpressionWalk; +/// The action, alias and properties of an EXECUTE DCM PROJECT deploy, plan or preview. +type DcmRun = (String, Option, Vec); + impl Parser { /// In Snowflake, refuse an unquoted reserved keyword in any dotted part of a COMMENT ON name. pub(super) fn check_snowflake_comment_name(&self) -> Result<()> { @@ -181,6 +187,10 @@ impl Parser { } } else if self.match_text_seq(&["TEST", "ALL"]) { node.action = Some("TEST ALL".to_owned()); + } else if let Some((action, alias, properties)) = self.parse_dcm_project_run()? { + node.action = Some(action); + node.alias = alias; + node.properties = properties; } else { self.current = start; return Ok(None); @@ -190,6 +200,73 @@ impl Parser { } } +impl Parser { + /// Reads `DEPLOY [ AS ] `, `PLAN [ DELTA ] [ OUTPUT_PATH '' ]` or `PREVIEW USING CONFIGURATION FROM '' [ LIMIT ]` of EXECUTE DCM PROJECT; none for other actions. + fn parse_dcm_project_run(&mut self) -> Result> { + if self.match_text_seq(&["DEPLOY"]) { + let alias = match self.match_token(TokenType::As) { + true => Some(self.parse_simple_name()?), + false => None, + }; + return Ok(Some(( + "DEPLOY".to_owned(), + alias, + self.parse_dcm_project_source()?, + ))); + } + if self.match_text_seq(&["PLAN"]) { + let action = match self.match_text_seq(&["DELTA"]) { + true => "PLAN DELTA", + false => "PLAN", + }; + let mut properties = self.parse_dcm_project_source()?; + if self.match_text_seq(&["OUTPUT_PATH"]) { + properties.push(property( + "OUTPUT_PATH", + None, + Some(self.parse_string_value()?), + )); + } + return Ok(Some((action.to_owned(), None, properties))); + } + if !self.match_text_seq(&["PREVIEW"]) { + return Ok(None); + } + let table = Expression::Table(Box::new(self.parse_object_name()?)); + let mut properties = vec![property("PREVIEW", None, Some(table))]; + if !self.check_text_seq(&["USING"]) { + return Err(self.parse_error("Expected USING CONFIGURATION")); + } + properties.extend(self.parse_dcm_project_source()?); + if self.match_token(TokenType::Limit) { + if !self.check(TokenType::Number) { + return Err(self.parse_error("Expected a row count after LIMIT")); + } + properties.push(property("LIMIT", None, Some(self.parse_primary()?))); + } + Ok(Some(("PREVIEW".to_owned(), None, properties))) + } + + /// Reads `[ USING CONFIGURATION ] FROM ''` or `FROM DEPLOYMENT `. + fn parse_dcm_project_source(&mut self) -> Result> { + let mut properties: Vec = Vec::new(); + let configured = self.match_text_seq(&["USING"]); + if configured { + self.expect_alter_words(&[&["CONFIGURATION"]])?; + let config = Expression::Table(Box::new(self.parse_object_name()?)); + properties.push(property("USING CONFIGURATION", None, Some(config))); + } + self.expect_token(TokenType::From)?; + if !configured && self.match_text_seq(&["DEPLOYMENT"]) { + let deployment = Expression::Table(Box::new(self.parse_object_name()?)); + properties.push(property("FROM DEPLOYMENT", None, Some(deployment))); + } else { + properties.push(property("FROM", None, Some(self.parse_string_value()?))); + } + Ok(properties) + } +} + impl Parser { /// Refuses a Snowflake GET/LIST/REMOVE lacking a stage, with a literal stage part, an empty option or GET's `file://` target. pub(crate) fn check_snowflake_stage_command(&self, needs_target: bool) -> Result<()> { @@ -231,6 +308,108 @@ impl Parser { Ok(()) } + /// Refuses the EXECUTE IMMEDIATE shapes Snowflake rejects: no target, a stage without FROM, a broken FROM location or option, and an empty or unbalanced USING list. + pub(crate) fn check_snowflake_execute_immediate(&self) -> Result<()> { + if !self.applies_snowflake_context_rules() { + return Ok(()); + } + let rest = &self.tokens[(self.current + 1).min(self.tokens.len())..]; + let end = rest + .iter() + .position(|token| token.token_type == TokenType::Semicolon) + .unwrap_or(rest.len()); + let tokens = &rest[..end]; + let refuse = |message: &str| Err(self.parse_error(message)); + let Some(first) = tokens.first() else { + return refuse("Expected a statement, a variable or FROM after EXECUTE IMMEDIATE"); + }; + if first.token_type == TokenType::DAt || first.text.starts_with('@') { + return refuse("Expected FROM before the stage"); + } + if first.token_type != TokenType::From { + let target = match first.token_type { + TokenType::Dollar | TokenType::Parameter if first.text.len() <= 1 => { + match tokens.get(1) { + Some(name) if name.span.start == first.span.end => 2, + _ => return refuse("Expected a variable name after $"), + } + } + _ => 1, + }; + return match &tokens[target..] { + [] => Ok(()), + [using, list @ ..] if using.text.eq_ignore_ascii_case("USING") => { + Self::check_immediate_list(list, false).map_or_else( + || refuse("Expected a non-empty USING ( ... ) list"), + |_| Ok(()), + ) + } + _ => refuse("Expected USING or the end after the EXECUTE IMMEDIATE target"), + }; + } + let location = &tokens[1..]; + let Some(head) = location.first() else { + return refuse("Expected a stage or a path after FROM"); + }; + let mut next = 1; + if head.token_type != TokenType::String { + while location + .get(next) + .is_some_and(|token| token.span.start == location[next - 1].span.end) + { + next += 1; + } + if location[..next] + .iter() + .any(|token| token.token_type == TokenType::String) + { + return refuse("A stage location part cannot be a string literal"); + } + } + let mut options = &location[next..]; + if options + .first() + .is_some_and(|token| token.text.eq_ignore_ascii_case("USING")) + { + match Self::check_immediate_list(&options[1..], true) { + Some(used) => options = &options[1 + used..], + None => return refuse("Expected USING ( => , ... )"), + } + } + match options { + [] => Ok(()), + [dry_run, eq, value] + if dry_run.text.eq_ignore_ascii_case("DRY_RUN") + && eq.token_type == TokenType::Eq + && matches!(value.token_type, TokenType::True | TokenType::False) => + { + Ok(()) + } + _ => refuse("Expected USING, DRY_RUN = TRUE | FALSE or the end after the location"), + } + } + + /// The tokens a non-empty `( , ... )` list takes, each item ` => ` when `named`; none when it is malformed. + fn check_immediate_list(tokens: &[ParserToken], named: bool) -> Option { + if tokens.first()?.token_type != TokenType::LParen { + return None; + } + let close = tokens + .iter() + .position(|token| token.token_type == TokenType::RParen)?; + let items = &tokens[1..close]; + if items.is_empty() { + return None; + } + let well_formed = items + .split(|token| token.token_type == TokenType::Comma) + .all(|item| match named { + true => matches!(item, [name, arrow, _, ..] if name.token_type != TokenType::FArrow && arrow.token_type == TokenType::FArrow), + false => !item.is_empty(), + }); + well_formed.then_some(close + 1) + } + /// Snowflake DCM `DEFINE`, refused for kinds kept as opaque text or not yet checked closely. pub(crate) fn parse_snowflake_define(&mut self) -> Result { let definition = self.parse_create()?; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_from.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_from.rs index d161b5273..21f0c791a 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_from.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_from.rs @@ -150,7 +150,26 @@ impl Parser { operands.push(self.parse_primary()?); "SPECIFICATION" } - _ if self.check(TokenType::Type) || self.check_identifier("TYPE") => return Ok(None), + _ if self.check(TokenType::Type) || self.check_identifier("TYPE") => { + self.skip(); + self.expect_token(TokenType::Eq)?; + let experiment_type = match self.check(TokenType::String) { + true => self.parse_primary()?, + false => Expression::Identifier(self.parse_simple_name()?), + }; + properties.push(property("TYPE", None, Some(experiment_type))); + if !self.check(TokenType::From) { + "" + } else { + self.skip(); + self.expect_alter_words(&[&["SPECIFICATION"]])?; + if !self.check(TokenType::String) && !self.check(TokenType::DollarString) { + return Err(self.parse_error("Expected the specification text")); + } + operands.push(self.parse_primary()?); + "SPECIFICATION" + } + } _ => "", }; if !self.is_at_end() && !self.check(TokenType::Semicolon) { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs index ece629793..7563768ff 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs @@ -265,7 +265,24 @@ impl Parser { return Ok(expr); } let comments = self.call_comments(saved); - let typed = self.parse_typed_function_lowered(name, upper_name, quoted)?; + let typed = match self.parse_typed_function_lowered(name, upper_name, quoted) { + Ok(typed) => typed, + Err(_) + if self.tokens[saved].token_type == TokenType::RParen + && !crate::functions::names::main::faithful_keyword_syntax_name::faithful_keyword_syntax_name(upper_name) => + { + self.restore_comments(comments); + self.current = saved; + let mut expr = self.parse_generic_function(name, quoted)?; + if self.config.record_call_spans { + self.record_call_span(&mut expr, saved); + } + self.faithful_calls + .insert(saved, (expr.clone(), self.current)); + return Ok(expr); + } + Err(error) => return Err(error), + }; let mut expr = match Self::call_name(&typed) { Some(kept) if kept.eq_ignore_ascii_case(name) => typed, _ if crate::functions::names::main::faithful_keyword_syntax_name::faithful_keyword_syntax_name(upper_name) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/analyze.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/analyze.rs index 20a0a9cf4..4c75e4296 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/analyze.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/analyze.rs @@ -1289,12 +1289,23 @@ impl Parser { /// Parses ALTER SESSION SET/UNSET. pub(crate) fn parse_alter_session(&mut self) -> Result> { if self.match_token(TokenType::Set) { + let snowflake = self.applies_snowflake_context_rules(); let mut expressions: Vec = Vec::new(); loop { + let valueless = self.check_next(TokenType::Eq) + && self.tokens.get(self.current + 2).is_none_or(|token| { + matches!(token.token_type, TokenType::Semicolon | TokenType::Comma) + }); + if snowflake && (self.is_at_end() || self.check(TokenType::Semicolon) || valueless) + { + return Err(self.parse_error("Expected = ")); + } if let Some(item) = self.parse_set_item_assignment()? { expressions.push(item); } - if !self.match_token(TokenType::Comma) { + let listed = self.match_token(TokenType::Comma) + || snowflake && !self.is_at_end() && self.check_next(TokenType::Eq); + if !listed { break; } } diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs index 6fa4cd8e5..d9e5e7a6b 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs @@ -483,6 +483,9 @@ impl Parser { ["CLONE", "FROM", "AS", "USING", "LIKE", "("] .iter() .any(|word| t.text.eq_ignore_ascii_case(word)) + || t.text.eq_ignore_ascii_case("TYPE") + && self.tokens.get(self.current + 2).map(|eq| eq.token_type) + == Some(TokenType::Eq) }) { return Err(self.parse_error("Expected an object kind and a name")); diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/entry.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/entry.rs index d00b724d5..99a8960b6 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/entry.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/entry.rs @@ -487,6 +487,7 @@ impl Parser { == Some(true) { self.skip(); + self.check_snowflake_execute_immediate()?; self.parse_command()?.ok_or_else(|| { self.parse_error("Failed to parse EXECUTE IMMEDIATE statement") }) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs index a1a4703d2..3f90c8ae7 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs @@ -534,6 +534,10 @@ impl Parser { "TIMESTAMPLTZ" | "TIMESTAMP_LTZ" => self.parse_timestampltz_data_type(), "TIMESTAMPNTZ" | "TIMESTAMP_NTZ" => self.parse_timestampntz_data_type(), "INTERVAL" => self.parse_interval_data_type(), + "PRECISION" | "VARYING" if self.applies_snowflake_context_rules() => Err(self + .parse_error(format!( + "{name} only follows DOUBLE or CHARACTER in a Snowflake type" + ))), _ => self.parse_other_scalar_data_type(name, raw_name), } } diff --git a/crates/sqlquack-syntax/src/parser/constants.rs b/crates/sqlquack-syntax/src/parser/constants.rs index a304c98d2..eb8b9270d 100644 --- a/crates/sqlquack-syntax/src/parser/constants.rs +++ b/crates/sqlquack-syntax/src/parser/constants.rs @@ -116,6 +116,9 @@ pub(crate) const PERCENT_SIGN: &str = "%"; /// The text "PLAN", as comparisons test it. pub(crate) const PLAN: &str = "PLAN"; +/// The text "PARAMETERS", as comparisons test it. +pub(crate) const PARAMETERS: &str = "PARAMETERS"; + /// The text "PROCEDURE", as comparisons test it. pub(crate) const PROCEDURE: &str = "PROCEDURE"; diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs index f75f89d38..031e48604 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs @@ -776,7 +776,7 @@ fn given_a_snowflake_create_model_gateway_or_experiment_when_parsing_then_its_so SnowflakeVerdictTestCase { description: "keeps CREATE EXPERIMENT v1 TYPE = 'AI_FUNCTION_EVALUATION' FROM SPECIFICATIO", sql: "CREATE EXPERIMENT v1 TYPE = 'AI_FUNCTION_EVALUATION' FROM SPECIFICATION $$v1 $$ ;", - expected_kind: Some("raw"), + expected_kind: Some("create_object"), }, SnowflakeVerdictTestCase { description: "keeps ALTER TABLE v1 MODIFY DATA METRIC FUNCTION v1 ON ( v1 ) SUSPEND ;", diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs index 6fb860578..a63ed16ca 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs @@ -1048,3 +1048,138 @@ fn given_expression_gap_receipts_when_parsing_then_acceptance_matches_snowflake( ); } } + +#[test] +fn given_disagreement_receipts_when_parsing_then_acceptance_matches_snowflake() { + let test_cases = [ + AcceptanceTestCase { + description: "a typed function parses with no arguments (recorded errno 938, import 155)", + sql: "SELECT TO_TIMESTAMP ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "an aggregate parses with no arguments before OVER (recorded errno 938, import 19)", + sql: "SELECT COUNT_IF ( ) OVER ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "PRECISION alone is no type (recorded errno 1003, import 159)", + sql: "SELECT CAST ( NULL AS PRECISION ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "VARYING alone is no type (recorded errno 1003, import 159)", + sql: "SELECT CAST ( NULL AS VARYING ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "CREATE ... TYPE = needs an object kind (recorded errno 1003, import 66)", + sql: "CREATE v1 TYPE = 'AI_FUNCTION_EVALUATION' FROM SPECIFICATION $$v1 $$ ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "ALTER SESSION SET needs an assignment (recorded errno 1003, import 20)", + sql: "ALTER SESSION SET ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a session parameter needs a value (recorded errno 1003, import 20)", + sql: "ALTER SESSION SET BINARY_INPUT_FORMAT = ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "session assignments may be separated by spaces (recorded success, import 20)", + sql: "ALTER SESSION SET QUERY_TAG = 'q1' TIMEZONE = 'UTC' WEEK_START = 1 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "SET needs a variable name (recorded errno 1003, import 20)", + sql: "SET = 1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a reserved word is no variable name (recorded errno 1003, import 20)", + sql: "SET CURRENT = 1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "SHOW PARAMETERS takes IN USER with a name (recorded errno 2003, import 51)", + sql: "SHOW PARAMETERS IN USER v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "SHOW PARAMETERS IN TABLE needs an object name (recorded errno 1003, import 20)", + sql: "SHOW PARAMETERS IN TABLE SELECT ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "EXECUTE needs a known kind (recorded errno 1003, import 21)", + sql: "EXECUTE GRAPH v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "EXECUTE PROJECT needs DBT (recorded errno 1003, import 21)", + sql: "EXECUTE PROJECT v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "EXECUTE DCM PROJECT deploys from a source (recorded errno 1011, import 21)", + sql: "EXECUTE DCM PROJECT v1 DEPLOY FROM 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "EXECUTE DCM PROJECT plans from a source (recorded errno 1011, import 21)", + sql: "EXECUTE DCM PROJECT v1 PLAN FROM 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "EXECUTE DCM PROJECT previews with a configuration (recorded errno 1011, import 21)", + sql: "EXECUTE DCM PROJECT v1 PREVIEW v1 USING CONFIGURATION v1 FROM 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "EXECUTE IMMEDIATE needs a statement (recorded errno 1003, import 21)", + sql: "EXECUTE IMMEDIATE ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "EXECUTE IMMEDIATE takes no bare $ (recorded errno 1003, import 21)", + sql: "EXECUTE IMMEDIATE $ ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "EXECUTE IMMEDIATE FROM takes no quoted stage name (recorded errno 1003, import 21)", + sql: "EXECUTE IMMEDIATE FROM @'v1'.s1/p1/f1.sql ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + ]; + + for test_case in test_cases { + assert_eq!( + accepted(test_case.sql, test_case.dialect), + test_case.expected_accepted, + "{}", + test_case.description + ); + } +} From 66ef083ec192f7074e28108f8befc0b97e118475 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:53:02 +0000 Subject: [PATCH 2/2] Refuse only CAST, TRY_CAST, EXTRACT and POSITION with empty parentheses Import 160 records them as syntax errors (1003); SUBSTR, SUBSTRING, NORMALIZE, LTRIM, CONVERT, OVERLAY and TRIM parse empty and fail semantically (938, 2139), so they now take the plain-call fallback. --- .../src/parser/_helpers/functions/typed.rs | 3 +- .../sqlquack-syntax/src/parser/constants.rs | 3 ++ .../tests/parser/test_snowflake_recorded.rs | 30 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs index 7563768ff..0f6105c63 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs @@ -43,6 +43,7 @@ use crate::expressions::typed_functions::models::ValueFunc; use crate::expressions::types::models::DataType; use crate::expressions::types::models::Tuple; use crate::parser::constants::BINARY_ARITY; +use crate::parser::constants::EMPTY_CALL_SYNTAX_ERRORS; use crate::parser::constants::FIRST_VALUE; use crate::parser::constants::LEAD; use crate::parser::constants::TIME_TO_TIME_STR; @@ -269,7 +270,7 @@ impl Parser { Ok(typed) => typed, Err(_) if self.tokens[saved].token_type == TokenType::RParen - && !crate::functions::names::main::faithful_keyword_syntax_name::faithful_keyword_syntax_name(upper_name) => + && !EMPTY_CALL_SYNTAX_ERRORS.contains(&upper_name) => { self.restore_comments(comments); self.current = saved; diff --git a/crates/sqlquack-syntax/src/parser/constants.rs b/crates/sqlquack-syntax/src/parser/constants.rs index eb8b9270d..19196eda1 100644 --- a/crates/sqlquack-syntax/src/parser/constants.rs +++ b/crates/sqlquack-syntax/src/parser/constants.rs @@ -119,6 +119,9 @@ pub(crate) const PLAN: &str = "PLAN"; /// The text "PARAMETERS", as comparisons test it. pub(crate) const PARAMETERS: &str = "PARAMETERS"; +/// Calls Snowflake refuses with empty parentheses (errno 1003, import 160); other names parse. +pub(crate) const EMPTY_CALL_SYNTAX_ERRORS: &[&str] = &["CAST", "TRY_CAST", "EXTRACT", "POSITION"]; + /// The text "PROCEDURE", as comparisons test it. pub(crate) const PROCEDURE: &str = "PROCEDURE"; diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs index a63ed16ca..0985e2bd9 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs @@ -1064,6 +1064,36 @@ fn given_disagreement_receipts_when_parsing_then_acceptance_matches_snowflake() dialect: Dialect::Snowflake, expected_accepted: true, }, + AcceptanceTestCase { + description: "TRIM parses with no arguments (recorded errno 938, import 19)", + sql: "SELECT TRIM ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "SUBSTRING parses with no arguments (recorded errno 938, import 160)", + sql: "SELECT SUBSTRING ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "CONVERT parses with no arguments (recorded errno 2139, import 160)", + sql: "SELECT CONVERT ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "CAST needs its operand and type (recorded errno 1003, import 160)", + sql: "SELECT CAST ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "EXTRACT needs its part and operand (recorded errno 1003, import 160)", + sql: "SELECT EXTRACT ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, AcceptanceTestCase { description: "PRECISION alone is no type (recorded errno 1003, import 159)", sql: "SELECT CAST ( NULL AS PRECISION ) ;",