From 94ab017738d837827b87d542cd0221f9bcd3ba81 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:14:42 +0000 Subject: [PATCH 1/2] fix(syntax): Snowflake ALTER, DROP, COMMENT ON and class method gaps - ALTER SEQUENCE RENAME TO and UNSET COMMENT; ALTER VIEW RENAME TO. - ALTER ICEBERG TABLE version tags (CREATE [OR REPLACE], REPLACE, ALTER, DROP), REPLACE PARTITION BY ... WITH, UPGRADE TO ICEBERG VERSION and OPTIMIZE ( REMOVE_POSITION_DELETES ), which plain ALTER TABLE refuses. - ALTER TABLE ADD STORAGE LIFECYCLE POLICY ... ON ( cols ) and DROP STORAGE LIFECYCLE POLICY; RENAME needs TO, and RENAME TO / SWAP WITH refuse an unquoted reserved word, as does the table or view name. - ALTER EXTERNAL TABLE ADD/REMOVE FILES take a non-empty string list; ALTER SHARE UNSET COMMENT is typed; ALTER USER GROUP is refused. - DROP of 35 security object kinds is typed instead of a command fallback; DROP DATABASE keeps CASCADE/RESTRICT; DROP FUNCTION refuses an empty or string TABLE ( ) argument. - COMMENT ON refuses reserved name parts and a non-string value. - CALL !( ... ) is a typed call with the method as its member, and refuses a missing instance, method or parenthesis. --- .../src/expressions/schema_objects/models.rs | 8 + .../src/parser/_helpers/ddl/commands.rs | 14 + .../src/parser/_helpers/ddl/constraints.rs | 37 +-- .../src/parser/_helpers/ddl/mod.rs | 2 + .../_helpers/ddl/snowflake_alter_objects.rs | 2 +- .../_helpers/ddl/snowflake_alter_resources.rs | 2 +- .../_helpers/ddl/snowflake_alter_tables.rs | 240 ++++++++++++++++++ .../parser/_helpers/ddl/snowflake_commands.rs | 88 +++++++ .../parser/_helpers/expressions/operators.rs | 10 + .../sqlquack-syntax/src/parser/constants.rs | 35 +++ crates/sqlquack-syntax/tests/parser.rs | 2 + .../tests/parser/test_snowflake_alter_gaps.rs | 237 +++++++++++++++++ 12 files changed, 648 insertions(+), 29 deletions(-) create mode 100644 crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_tables.rs create mode 100644 crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_commands.rs create mode 100644 crates/sqlquack-syntax/tests/parser/test_snowflake_alter_gaps.rs diff --git a/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs b/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs index 9d8d947e3..3eefaa369 100644 --- a/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs +++ b/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs @@ -397,6 +397,12 @@ pub struct DropDatabase { /// ClickHouse: SYNC modifier #[serde(default)] pub sync: bool, + /// Snowflake CASCADE, left out of the tree when absent + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub cascade: bool, + /// Snowflake RESTRICT, left out of the tree when absent + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub restrict: bool, } impl DropDatabase { @@ -405,6 +411,8 @@ impl DropDatabase { name: Identifier::new(name), if_exists: false, sync: false, + cascade: false, + restrict: false, } } } diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs index efda8ea80..f9dfe1cc8 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs @@ -90,6 +90,9 @@ impl Parser { /// Parses CALL procedure(args, ...). pub(crate) fn parse_call(&mut self) -> Result { let call_token = self.advance()?; + if let Some(call) = self.parse_snowflake_method_call()? { + return Ok(call); + } let mut tokens = vec![("CALL".to_string(), call_token.token_type)]; while !self.is_at_end() && !self.check(TokenType::Semicolon) { @@ -732,6 +735,7 @@ impl Parser { Expression::Identifier(Identifier::new(name_str)) } else { + self.check_snowflake_comment_name()?; self.parse_qualified_name()? }; @@ -740,6 +744,12 @@ impl Parser { } else { return Err(self.parse_error("Expected IS in COMMENT ON statement")); } + if self.is_snowflake() + && !self.check(TokenType::String) + && !self.check(TokenType::DollarString) + { + return Err(self.parse_error("Expected a string after IS")); + } let expression = self.parse_primary()?; @@ -1457,11 +1467,15 @@ impl Parser { let name = self.expect_identifier_or_safe_keyword_with_quoted()?; let sync = { false }; + let cascade = self.match_token(TokenType::Cascade); + let restrict = !cascade && self.match_token(TokenType::Restrict); Ok(Expression::DropDatabase(Box::new(DropDatabase { name, if_exists, sync, + cascade, + restrict, }))) } diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/constraints.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/constraints.rs index e950c5ac3..d8d0a7f79 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/constraints.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/constraints.rs @@ -1419,33 +1419,6 @@ impl Parser { { return self.parse_drop_snowflake_object(kind.join(" ")); } - let text_upper = self.peek_text().to_ascii_uppercase(); - let is_snowflake_drop = - matches!(text_upper.as_str(), "INTEGRATION" | "NETWORK" | "SHARE"); - if is_snowflake_drop { - self.skip(); - let mut tokens: Vec<(String, TokenType)> = vec![ - ("DROP".to_string(), TokenType::Var), - ( - self.previous_text().to_ascii_uppercase(), - self.previous().token_type, - ), - ]; - while !self.is_at_end() && !self.check(TokenType::Semicolon) { - let token = self.advance()?; - let text = if token.token_type == TokenType::QuotedIdentifier { - format!("\"{}\"", token.text) - } else if token.token_type == TokenType::String { - format!("'{}'", token.text) - } else { - token.text.to_string() - }; - tokens.push((text, token.token_type)); - } - return Ok(Expression::Command(Box::new(Command { - this: self.join_command_tokens(tokens), - }))); - } } Err(self.parse_error(format!( "Expected TABLE, VIEW, INDEX, SCHEMA, DATABASE, FUNCTION, PROCEDURE, SEQUENCE, TRIGGER, TYPE, or NAMESPACE after DROP, got {:?}", @@ -1581,6 +1554,16 @@ impl Parser { } else { None }; + if matches!( + self.config.dialect, + Some(crate::tokens::models::DialectType::Snowflake) + ) { + if let Some(expr) = + self.parse_alter_snowflake_table_action(alter_table_modifier.is_some())? + { + return Ok(expr); + } + } match self.peek().token_type { TokenType::Table => self.parse_alter_table(alter_table_modifier), diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs index 76375dcc6..43d258b86 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs @@ -11,6 +11,8 @@ pub(crate) mod snowflake_alter; pub(crate) mod snowflake_alter_account; pub(crate) mod snowflake_alter_objects; pub(crate) mod snowflake_alter_resources; +pub(crate) mod snowflake_alter_tables; +pub(crate) mod snowflake_commands; pub(crate) mod snowflake_statements; pub(crate) mod table_options; pub(crate) mod transactions; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_objects.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_objects.rs index 45655ecca..a845126b2 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_objects.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_objects.rs @@ -144,7 +144,7 @@ impl Parser { } /// Parse a parenthesized list of string literals. - fn parse_string_list(&mut self) -> Result { + pub(super) fn parse_string_list(&mut self) -> Result { self.expect_token(TokenType::LParen)?; let mut items = vec![self.parse_string_value()?]; while self.match_token(TokenType::Comma) { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_resources.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_resources.rs index 899218854..98459fab1 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_resources.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_resources.rs @@ -281,7 +281,7 @@ impl Parser { (vec![manifest], self.parse_resource_properties()?) } (_, "FROM SPECIFICATION") => (vec![self.parse_object_value()?], vec![]), - (_, "ADD FILES" | "REMOVE FILES") => (vec![self.parse_value_list()?], vec![]), + (_, "ADD FILES" | "REMOVE FILES") => (vec![self.parse_string_list()?], vec![]), (_, "ADD TARGETS" | "REMOVE TARGETS") => (vec![self.parse_resource_value()?], vec![]), ("STAGE" | "PIPE", "REFRESH") => (vec![], self.parse_resource_properties()?), ("NETWORK POLICY" | "DATA MOVEMENT POLICY", "ADD" | "REMOVE") => { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_tables.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_tables.rs new file mode 100644 index 000000000..a24737cb9 --- /dev/null +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_tables.rs @@ -0,0 +1,240 @@ +//! Snowflake ALTER actions of TABLE, ICEBERG TABLE, SEQUENCE, VIEW and SHARE that the generic ALTER parsers lack. + +use crate::expressions::names::models::Identifier; +use crate::expressions::schema_objects::models::ObjectProperty; +use crate::expressions::tree::models::Expression; +use crate::expressions::types::models::Tuple; +use crate::parser::_helpers::ddl::snowflake_alter::alter_object; +use crate::parser::_helpers::ddl::snowflake_alter::property; +use crate::parser::models::Parser; +use crate::tokens::models::TokenType; +use crate::tokens::types::Result; + +/// The actions only an ICEBERG TABLE takes, as their leading words. +const ICEBERG_ACTIONS: &[&[&str]] = &[ + &["CREATE", "OR", "REPLACE", "VERSION_TAG"], + &["CREATE", "VERSION_TAG"], + &["REPLACE", "VERSION_TAG"], + &["ALTER", "VERSION_TAG"], + &["DROP", "VERSION_TAG"], + &["REPLACE", "PARTITION", "BY"], + &["UPGRADE", "TO", "ICEBERG", "VERSION"], + &["OPTIMIZE"], +]; + +/// The storage lifecycle policy actions of an ALTER TABLE. +const LIFECYCLE_ACTIONS: &[&[&str]] = &[ + &["ADD", "STORAGE", "LIFECYCLE", "POLICY"], + &["DROP", "STORAGE", "LIFECYCLE", "POLICY"], +]; + +/// The retention units of a version tag's RETAIN. +const RETAIN_UNITS: &[&[&str]] = &[&["DAYS"], &["HOURS"], &["MINUTES"]]; + +/// The operands and properties of one ALTER action. +type ActionParts = (Vec, Vec); + +impl Parser { + /// Parse a Snowflake ALTER action this module owns, or None (position unchanged) for any other. + pub(crate) fn parse_alter_snowflake_table_action( + &mut self, + iceberg: bool, + ) -> Result> { + if self.check_identifier("USER") + && self + .peek_nth(1) + .is_some_and(|token| token.token_type != TokenType::QuotedIdentifier) + && self.text_at(1).as_deref() == Some("GROUP") + { + return Err(self.parse_error("Snowflake has ALTER ORGANIZATION USER GROUP")); + } + let start = self.current; + let found = if self.match_token(TokenType::Table) { + self.parse_alter_table_extension(iceberg) + } else if iceberg { + Ok(None) + } else if self.match_token(TokenType::Sequence) { + self.parse_alter_kind_extension("SEQUENCE", &[&["RENAME", "TO"], &["UNSET"]]) + } else if self.match_token(TokenType::View) { + self.parse_alter_kind_extension("VIEW", &[&["RENAME", "TO"]]) + } else if self.match_identifier("SHARE") { + return self.parse_alter_share(); + } else { + Ok(None) + }; + let found = found?; + if found.is_none() { + self.current = start; + } + Ok(found) + } + + /// Parse the head and an ICEBERG or lifecycle action of an ALTER [ ICEBERG ] TABLE, or None. + fn parse_alter_table_extension(&mut self, iceberg: bool) -> Result> { + let head = self.parse_alter_object_head()?; + if !iceberg && self.match_text_seq(&["RENAME"]) && !self.check(TokenType::Column) { + self.expect_token(TokenType::To)?; + self.parse_object_name()?; + return Ok(None); + } + if !iceberg && self.match_text_seq(&["SWAP", "WITH"]) { + self.parse_object_name()?; + return Ok(None); + } + if !iceberg && self.check_identifier("OPTIMIZE") { + return Err(self.parse_error("OPTIMIZE is an action of ALTER ICEBERG TABLE")); + } + if !iceberg && self.check(TokenType::Set) && self.check_next(TokenType::Semicolon) { + return Err(self.parse_error("SET names at least one property")); + } + let choices = if iceberg { + ICEBERG_ACTIONS + } else { + LIFECYCLE_ACTIONS + }; + let Some(words) = choices.iter().find(|words| self.match_text_seq(words)) else { + return Ok(None); + }; + let kind = if iceberg { "ICEBERG TABLE" } else { "TABLE" }; + let (action, parts) = self.parse_table_extension_action(words.join(" "))?; + Ok(Some(alter_object(kind, head, action, parts))) + } + + /// Parse what follows the words of a table extension action. + fn parse_table_extension_action( + &mut self, + mut action: String, + ) -> Result<(String, ActionParts)> { + let parts = match action.as_str() { + "CREATE VERSION_TAG" | "CREATE OR REPLACE VERSION_TAG" => { + if self.match_keywords(&[TokenType::If, TokenType::Not, TokenType::Exists]) { + action.push_str(" IF NOT EXISTS"); + } + let tag = self.parse_tag_operand()?; + let mut properties: Vec = Vec::new(); + if self.match_text_seq(&["AS", "OF"]) { + properties = self.parse_version_point()?; + } + (vec![tag], properties) + } + "REPLACE VERSION_TAG" | "ALTER VERSION_TAG" => { + let tag = self.parse_tag_operand()?; + self.expect_alter_words(&[&["AS", "OF"]])?; + (vec![tag], self.parse_version_point()?) + } + "DROP VERSION_TAG" => { + if self.match_keywords(&[TokenType::If, TokenType::Exists]) { + action.push_str(" IF EXISTS"); + } + (vec![self.parse_tag_operand()?], vec![]) + } + "REPLACE PARTITION BY" => { + let old = self.parse_partition_expression()?; + self.expect_token(TokenType::With)?; + (vec![old, self.parse_partition_expression()?], vec![]) + } + "UPGRADE TO ICEBERG VERSION" => (vec![self.parse_number_operand()?], vec![]), + "OPTIMIZE" => { + self.expect_token(TokenType::LParen)?; + self.expect_alter_words(&[&["REMOVE_POSITION_DELETES"]])?; + self.expect_token(TokenType::RParen)?; + ( + vec![], + vec![property("REMOVE_POSITION_DELETES", None, None)], + ) + } + "ADD STORAGE LIFECYCLE POLICY" => { + let policy = self.parse_tag_operand()?; + self.expect_token(TokenType::On)?; + (vec![policy, self.parse_column_names()?], vec![]) + } + _ => (vec![], vec![]), + }; + Ok((action, parts)) + } + + /// Parse the head and one of `actions` of an ALTER `kind`, or None for another action. + fn parse_alter_kind_extension( + &mut self, + kind: &str, + actions: &[&[&str]], + ) -> Result> { + let head = self.parse_alter_object_head()?; + let Some(words) = actions.iter().find(|words| self.match_text_seq(words)) else { + return Ok(None); + }; + let action = words.join(" "); + let parts: ActionParts = match action.as_str() { + "UNSET" => { + self.expect_alter_words(&[&["COMMENT"]])?; + (vec![], vec![property("COMMENT", None, None)]) + } + _ => (vec![self.parse_tag_operand()?], vec![]), + }; + Ok(Some(alter_object(kind, head, action, parts))) + } + + /// Parse ALTER SHARE: UNSET COMMENT typed; ADD, REMOVE, SET and UNSET TAG are left to the fallback. + fn parse_alter_share(&mut self) -> Result> { + let start = self.current - 1; + let head = self.parse_alter_object_head()?; + if self.match_text_seq(&["UNSET", "COMMENT"]) { + let parts = (vec![], vec![property("COMMENT", None, None)]); + return Ok(Some(alter_object("SHARE", head, "UNSET".to_owned(), parts))); + } + match ( + self.text_at(0).unwrap_or_default().as_str(), + self.text_at(1).as_deref(), + ) { + ("UNSET", Some("TAG")) | ("ADD" | "REMOVE" | "SET", _) => {} + ("UNSET", _) => return Err(self.parse_error("Expected COMMENT or TAG after UNSET")), + _ => return Err(self.parse_error("Expected ADD, REMOVE, SET or UNSET")), + } + self.current = start; + Ok(None) + } + + /// Parse a name operand: an identifier, never a string or an unquoted reserved keyword. + fn parse_tag_operand(&mut self) -> Result { + Ok(Expression::Table(Box::new(self.parse_object_name()?))) + } + + /// Parse `{ VERSION | TIMESTAMP } [ RETAIN { DAYS | HOURS | MINUTES } ]`. + fn parse_version_point(&mut self) -> Result> { + let point = self.expect_alter_words(&[&["VERSION"], &["TIMESTAMP"]])?; + let mut properties = vec![property(&point, None, Some(self.parse_number_operand()?))]; + if self.match_identifier("RETAIN") { + let count = self.parse_number_operand()?; + let unit = self.expect_alter_words(RETAIN_UNITS)?; + properties.push(property("RETAIN", Some(Identifier::new(unit)), Some(count))); + } + Ok(properties) + } + + /// Parse an integer literal. + fn parse_number_operand(&mut self) -> Result { + if !self.check(TokenType::Number) { + return Err(self.parse_error("Expected a number")); + } + self.parse_primary() + } + + /// Parse `( )`, one partition expression. + fn parse_partition_expression(&mut self) -> Result { + self.expect_token(TokenType::LParen)?; + let expression = self.parse_expression()?; + self.expect_token(TokenType::RParen)?; + Ok(expression) + } + + /// Parse `( [ , ... ] )`. + fn parse_column_names(&mut self) -> Result { + self.expect_token(TokenType::LParen)?; + let mut expressions = vec![self.parse_tag_operand()?]; + while self.match_token(TokenType::Comma) { + expressions.push(self.parse_tag_operand()?); + } + self.expect_token(TokenType::RParen)?; + Ok(Expression::Tuple(Box::new(Tuple { expressions }))) + } +} diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_commands.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_commands.rs new file mode 100644 index 000000000..d5ae29475 --- /dev/null +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_commands.rs @@ -0,0 +1,88 @@ +//! Snowflake's rules on COMMENT ON names and values, and its class instance method calls. + +use crate::expressions::operators::models::Call; +use crate::expressions::tree::models::Expression; +use crate::parser::constants::SNOWFLAKE_RESERVED_KEYWORDS; +use crate::parser::models::Parser; +use crate::tokens::models::TokenType; +use crate::tokens::types::Result; + +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<()> { + if !self.is_snowflake() { + return Ok(()); + } + let mut offset = 0; + while let Some(token) = self.tokens.get(self.current + offset) { + if token.token_type != TokenType::QuotedIdentifier + && SNOWFLAKE_RESERVED_KEYWORDS + .iter() + .any(|keyword| token.text.eq_ignore_ascii_case(keyword)) + { + return Err(self.parse_error("Expected an object name")); + } + match self.tokens.get(self.current + offset + 1) { + Some(next) if next.token_type == TokenType::Dot => offset += 2, + _ => return Ok(()), + } + } + Ok(()) + } + + /// 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() { + return Ok(None); + } + 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; + } + if self + .tokens + .get(self.current + offset + 1) + .map(|token| token.token_type) + != 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)?; + } + self.expect_token(TokenType::Exclamation)?; + if !self.is_identifier_token() { + return Err(self.parse_error("Expected a method name after !")); + } + let member = self.expect_identifier_or_keyword_with_quoted()?; + self.expect_token(TokenType::LParen)?; + let arguments = if self.check(TokenType::RParen) { + Vec::new() + } else { + self.parse_function_arguments()? + }; + self.expect_token(TokenType::RParen)?; + Ok(Some(Expression::Call(Box::new(Call { + source_dialect: None, + name, + member: Some(member), + arguments, + asynchronous: false, + hints: Vec::new(), + with: None, + into: None, + })))) + } +} diff --git a/crates/sqlquack-syntax/src/parser/_helpers/expressions/operators.rs b/crates/sqlquack-syntax/src/parser/_helpers/expressions/operators.rs index ca33f4fa5..b6fc86fe1 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/expressions/operators.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/expressions/operators.rs @@ -231,6 +231,16 @@ impl Parser { let mut types: Vec = Vec::new(); if !self.check(TokenType::RParen) { loop { + if self.check(TokenType::Table) + && self.check_next(TokenType::LParen) + && self.tokens.get(self.current + 2).is_some_and(|token| { + matches!(token.token_type, TokenType::RParen | TokenType::String) + }) + { + return Err( + self.parse_error("A TABLE argument names at least one data type") + ); + } types.push(self.parse_data_type()?); if !self.match_token(TokenType::Comma) { break; diff --git a/crates/sqlquack-syntax/src/parser/constants.rs b/crates/sqlquack-syntax/src/parser/constants.rs index aa96172ec..2cc662cf1 100644 --- a/crates/sqlquack-syntax/src/parser/constants.rs +++ b/crates/sqlquack-syntax/src/parser/constants.rs @@ -391,52 +391,87 @@ pub(crate) const TASK_ACTIONS: &[&str] = &[ /// Snowflake DROP object kinds, each listed before any kind it prefixes. pub(crate) const SNOWFLAKE_DROP_KINDS: &[&[&str]] = &[ &["AGENT"], + &["AGGREGATION", "POLICY"], &["ALERT"], + &["API", "INTEGRATION"], &["APPLICATION", "PACKAGE"], &["APPLICATION", "SERVICE"], &["APPLICATION"], &["ARTIFACT", "REPOSITORY"], + &["AUTHENTICATION", "POLICY"], + &["BACKUP", "POLICY"], &["BACKUP", "SET"], + &["CATALOG", "INTEGRATION"], &["COMPUTE", "POOL"], + &["CONNECTION"], &["CONTACT"], &["CORTEX", "SEARCH", "SERVICE"], + &["DATA", "MOVEMENT", "POLICY"], &["DATA", "MOVEMENT", "RULE"], &["DBT", "PROJECT"], &["DCM", "PROJECT"], &["DYNAMIC", "TABLE"], &["EVENT", "ROUTING", "TABLE"], &["EXPERIMENT"], + &["EXTERNAL", "ACCESS", "INTEGRATION"], &["EXTERNAL", "AGENT"], &["EXTERNAL", "CONSUMER"], &["EXTERNAL", "TABLE"], &["EXTERNAL", "VOLUME"], + &["FAILOVER", "GROUP"], + &["FEATURE", "POLICY"], &["FILE", "FORMAT"], &["GATEWAY"], &["GIT", "REPOSITORY"], &["IMAGE", "REPOSITORY"], + &["INGRESS", "POLICY"], + &["INTEGRATION"], + &["JOIN", "POLICY"], &["LISTING"], + &["MAINTENANCE", "POLICY"], + &["MASKING", "POLICY"], &["MCP", "SERVER"], &["MODEL", "MONITOR"], &["MODEL"], + &["MULTI", "PARTY", "APPROVAL", "POLICY"], + &["NETWORK", "POLICY"], + &["NETWORK", "RULE"], &["NOTEBOOK"], + &["NOTIFICATION", "INTEGRATION"], &["ONLINE", "FEATURE", "TABLE"], &["OPENFLOW", "CONNECTOR"], &["OPENFLOW", "DEPLOYMENT"], &["OPENFLOW", "RUNTIME"], + &["ORGANIZATION", "USER", "GROUP"], + &["PACKAGES", "POLICY"], + &["PASSWORD", "POLICY"], &["PIPE"], &["POSTGRES", "INSTANCE"], + &["PRIVACY", "POLICY"], + &["PROJECTION", "POLICY"], + &["REPLICATION", "GROUP"], + &["RESOURCE", "MONITOR"], &["RESTRICTED", "SESSION", "SCOPE"], + &["ROLE"], + &["ROW", "ACCESS", "POLICY"], &["RSS"], + &["SECRET"], + &["SECURITY", "INTEGRATION"], &["SEMANTIC", "VIEW"], &["SERVICE"], + &["SESSION", "POLICY"], + &["SHARE"], &["SNAPSHOT", "SET"], &["SNAPSHOT"], &["STAGE"], + &["STORAGE", "INTEGRATION"], + &["STORAGE", "LIFECYCLE", "POLICY"], &["STREAMLIT"], &["STREAM"], &["TAG"], &["TASK", "GRAPH"], &["TASK"], + &["USER"], &["VARIABLE"], &["WAREHOUSE"], ]; diff --git a/crates/sqlquack-syntax/tests/parser.rs b/crates/sqlquack-syntax/tests/parser.rs index bafcf5e43..4c2829a67 100644 --- a/crates/sqlquack-syntax/tests/parser.rs +++ b/crates/sqlquack-syntax/tests/parser.rs @@ -10,5 +10,7 @@ mod test_located; mod test_scripting; #[path = "parser/test_snowflake_alter.rs"] mod test_snowflake_alter; +#[path = "parser/test_snowflake_alter_gaps.rs"] +mod test_snowflake_alter_gaps; #[path = "parser/test_types.rs"] mod test_types; diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_alter_gaps.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_alter_gaps.rs new file mode 100644 index 000000000..d8830f7d2 --- /dev/null +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_alter_gaps.rs @@ -0,0 +1,237 @@ +use serde_json::json; + +use sqlquack_syntax::{CallMode, Dialect}; + +use crate::helpers::{accepted, statements}; +use crate::test_types::{AcceptanceTestCase, ProjectionTestCase}; + +#[test] +fn given_snowflake_table_sequence_and_share_actions_when_parsing_then_only_documented_forms_are_accepted( +) { + let test_cases = [ + AcceptanceTestCase { description: "a sequence is renamed (recorded semantic error)", sql: "ALTER SEQUENCE v1 RENAME TO v2 ;", dialect: Dialect::Snowflake, expected_accepted: true }, + AcceptanceTestCase { description: "a sequence's comment is unset", sql: "ALTER SEQUENCE IF EXISTS v1 UNSET COMMENT ;", dialect: Dialect::Snowflake, expected_accepted: true }, + AcceptanceTestCase { description: "UNSET on a sequence names COMMENT", sql: "ALTER SEQUENCE v1 UNSET ;", dialect: Dialect::Snowflake, expected_accepted: false }, + AcceptanceTestCase { description: "a version tag is created with its point and retention", sql: "ALTER ICEBERG TABLE v1 CREATE OR REPLACE VERSION_TAG IF NOT EXISTS t1 AS OF VERSION 5 RETAIN 3 DAYS ;", dialect: Dialect::Snowflake, expected_accepted: true }, + AcceptanceTestCase { description: "RETAIN counts DAYS, HOURS or MINUTES", sql: "ALTER ICEBERG TABLE v1 CREATE VERSION_TAG t1 AS OF VERSION 5 RETAIN 3 WEEKS ;", dialect: Dialect::Snowflake, expected_accepted: false }, + AcceptanceTestCase { description: "REPLACE VERSION_TAG needs AS OF", sql: "ALTER ICEBERG TABLE v1 REPLACE VERSION_TAG t1 ;", dialect: Dialect::Snowflake, expected_accepted: false }, + AcceptanceTestCase { description: "a version tag is dropped", sql: "ALTER ICEBERG TABLE v1 DROP VERSION_TAG IF EXISTS t1 ;", dialect: Dialect::Snowflake, expected_accepted: true }, + AcceptanceTestCase { description: "a partition spec is replaced", sql: "ALTER ICEBERG TABLE v1 REPLACE PARTITION BY ( a ) WITH ( b ) ;", dialect: Dialect::Snowflake, expected_accepted: true }, + AcceptanceTestCase { description: "the Iceberg version is a number", sql: "ALTER ICEBERG TABLE v1 UPGRADE TO ICEBERG VERSION v3 ;", dialect: Dialect::Snowflake, expected_accepted: false }, + AcceptanceTestCase { description: "an Iceberg table drops its position deletes", sql: "ALTER ICEBERG TABLE v1 OPTIMIZE ( REMOVE_POSITION_DELETES ) ;", dialect: Dialect::Snowflake, expected_accepted: true }, + AcceptanceTestCase { description: "OPTIMIZE belongs to ICEBERG TABLE", sql: "ALTER TABLE v1 OPTIMIZE ( REMOVE_POSITION_DELETES ) ;", dialect: Dialect::Snowflake, expected_accepted: false }, + AcceptanceTestCase { description: "an Iceberg table keeps its other actions", sql: "ALTER ICEBERG TABLE v1 SET COMMENT = 'm' ;", dialect: Dialect::Snowflake, expected_accepted: true }, + AcceptanceTestCase { description: "a storage lifecycle policy is added on columns", sql: "ALTER TABLE v1 ADD STORAGE LIFECYCLE POLICY p1 ON ( a , b ) ;", dialect: Dialect::Snowflake, expected_accepted: true }, + AcceptanceTestCase { description: "the policy's column list is not empty", sql: "ALTER TABLE v1 ADD STORAGE LIFECYCLE POLICY p1 ON ( ) ;", dialect: Dialect::Snowflake, expected_accepted: false }, + AcceptanceTestCase { description: "a storage lifecycle policy is dropped", sql: "ALTER TABLE v1 DROP STORAGE LIFECYCLE POLICY ;", dialect: Dialect::Snowflake, expected_accepted: true }, + AcceptanceTestCase { description: "RENAME needs TO (recorded syntax error)", sql: "ALTER TABLE v1 RENAME v1 ;", dialect: Dialect::Snowflake, expected_accepted: false }, + AcceptanceTestCase { description: "a reserved word is no new name (recorded syntax error)", sql: "ALTER TABLE v1 RENAME TO SELECT ;", dialect: Dialect::Snowflake, expected_accepted: false }, + AcceptanceTestCase { description: "a quoted reserved word is a new name", sql: "ALTER TABLE v1 RENAME TO \"SELECT\" ;", dialect: Dialect::Snowflake, expected_accepted: true }, + AcceptanceTestCase { description: "a column is still renamed", sql: "ALTER TABLE v1 RENAME COLUMN a TO b ;", dialect: Dialect::Snowflake, expected_accepted: true }, + AcceptanceTestCase { description: "a reserved word is no swap target (recorded syntax error)", sql: "ALTER TABLE v1 SWAP WITH SELECT ;", dialect: Dialect::Snowflake, expected_accepted: false }, + AcceptanceTestCase { description: "a reserved word is no table name (recorded syntax error)", sql: "ALTER TABLE SELECT SWAP WITH v1 ;", dialect: Dialect::Snowflake, expected_accepted: false }, + AcceptanceTestCase { description: "a view is renamed when it exists", sql: "ALTER VIEW IF EXISTS v1 RENAME TO v2 ;", dialect: Dialect::Snowflake, expected_accepted: true }, + AcceptanceTestCase { description: "a reserved word is no view name (recorded syntax error)", sql: "ALTER VIEW SELECT RENAME TO v1 ;", dialect: Dialect::Snowflake, expected_accepted: false }, + AcceptanceTestCase { description: "files are listed as strings", sql: "ALTER EXTERNAL TABLE v1 ADD FILES ( 'a/b.csv' , 'c.csv' ) ;", dialect: Dialect::Snowflake, expected_accepted: true }, + AcceptanceTestCase { description: "an empty file list (recorded syntax error)", sql: "ALTER EXTERNAL TABLE v1 ADD FILES ( ) ;", dialect: Dialect::Snowflake, expected_accepted: false }, + AcceptanceTestCase { description: "a file is no name (recorded syntax error)", sql: "ALTER EXTERNAL TABLE v1 REMOVE FILES ( m1 ) ;", dialect: Dialect::Snowflake, expected_accepted: false }, + AcceptanceTestCase { description: "a share unsets its comment (recorded semantic error)", sql: "ALTER SHARE v1 UNSET COMMENT ;", dialect: Dialect::Snowflake, expected_accepted: true }, + AcceptanceTestCase { description: "a share keeps its ADD action", sql: "ALTER SHARE v1 ADD ACCOUNTS = a1 ;", dialect: Dialect::Snowflake, expected_accepted: true }, + AcceptanceTestCase { description: "a share unsets COMMENT or TAG only", sql: "ALTER SHARE v1 UNSET m1 ;", dialect: Dialect::Snowflake, expected_accepted: false }, + AcceptanceTestCase { description: "a user group is an organization user group (recorded syntax error)", sql: "ALTER USER GROUP v1 ADD ORGANIZATION USERS v1 ;", dialect: Dialect::Snowflake, expected_accepted: false }, + AcceptanceTestCase { description: "an organization user group adds users", sql: "ALTER ORGANIZATION USER GROUP IF EXISTS v1 ADD ORGANIZATION USERS v1 , v1 ;", dialect: Dialect::Snowflake, expected_accepted: true }, + ]; + + for test_case in test_cases { + assert_eq!( + accepted(test_case.sql, test_case.dialect), + test_case.expected_accepted, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_snowflake_comment_drop_and_call_statements_when_parsing_then_only_documented_forms_are_accepted( +) { + let test_cases = [ + AcceptanceTestCase { + description: "a comment is a string (recorded syntax error)", + sql: "COMMENT ON TABLE v1 IS m1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a reserved word is no column (recorded syntax error)", + sql: "COMMENT ON COLUMN v1 . SELECT IS 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a quoted reserved word is a column", + sql: "COMMENT ON COLUMN v1 . \"SELECT\" IS 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "other dialects keep their COMMENT ON values", + sql: "COMMENT ON TABLE t1 IS NULL", + dialect: Dialect::PostgreSQL, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a masking policy is dropped", + sql: "DROP MASKING POLICY IF EXISTS p1 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a reserved word is no role name", + sql: "DROP ROLE SELECT ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a database is dropped with RESTRICT (recorded semantic error)", + sql: "DROP DATABASE v1 RESTRICT ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a table argument type is not empty", + sql: "DROP FUNCTION f1 ( TABLE ( ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a table argument names its types", + sql: "DROP FUNCTION f1 ( TABLE ( INT ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "an instance method is called", + sql: "CALL d1.s1.b1!ADD_TAG ( 'm' , ( SELECT 1 ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a quoted reserved word is an instance (recorded semantic error)", + sql: "CALL \"SELECT\"!GET_CONFIG ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "an unquoted reserved word is no instance (recorded syntax error)", + sql: "CALL SELECT!GET_CONFIG ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a method needs its instance (recorded syntax error)", + sql: "CALL !GET_CONFIG ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a method needs its name (recorded syntax error)", + sql: "CALL v1! ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a method call needs its parentheses (recorded syntax error)", + sql: "CALL v1!GET_CONFIG ( ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a trailing argument is missing (recorded syntax error)", + sql: "CALL v1!ADD_TAG ( 'm' , ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a procedure call is unchanged", + sql: "CALL p1 ( 1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + ]; + + for test_case in test_cases { + assert_eq!( + accepted(test_case.sql, test_case.dialect), + test_case.expected_accepted, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_snowflake_table_actions_and_method_calls_when_parsing_then_the_tree_keeps_their_parts() { + let test_cases = [ + ProjectionTestCase { + description: "an Iceberg action names its kind", + sql: "ALTER ICEBERG TABLE v1 UPGRADE TO ICEBERG VERSION 3", + pointer: "/0/alter_object/kind", + expected_value: json!("ICEBERG TABLE"), + }, + ProjectionTestCase { + description: "IF NOT EXISTS is part of the version tag action", + sql: "ALTER ICEBERG TABLE v1 CREATE VERSION_TAG IF NOT EXISTS t1", + pointer: "/0/alter_object/action", + expected_value: json!("CREATE VERSION_TAG IF NOT EXISTS"), + }, + ProjectionTestCase { + description: "RETAIN keeps its unit", + sql: "ALTER ICEBERG TABLE v1 ALTER VERSION_TAG t1 AS OF TIMESTAMP 1 RETAIN 2 HOURS", + pointer: "/0/alter_object/properties/1/name/name", + expected_value: json!("HOURS"), + }, + ProjectionTestCase { + description: "the policy's columns are its second operand", + sql: "ALTER TABLE v1 ADD STORAGE LIFECYCLE POLICY p1 ON ( a , b )", + pointer: "/0/alter_object/operands/1/tuple/expressions/1/table/name/name", + expected_value: json!("b"), + }, + ProjectionTestCase { + description: "a sequence's new name is its operand", + sql: "ALTER SEQUENCE v1 RENAME TO v2", + pointer: "/0/alter_object/operands/0/table/name/name", + expected_value: json!("v2"), + }, + ProjectionTestCase { + description: "CASCADE is kept on DROP DATABASE", + sql: "DROP DATABASE v1 CASCADE", + pointer: "/0/drop_database/cascade", + expected_value: json!(true), + }, + ProjectionTestCase { + description: "the method is the call's member", + sql: "CALL d1.s1.b1!GET_CONFIG ( )", + pointer: "/0/call/member/name", + expected_value: json!("GET_CONFIG"), + }, + ProjectionTestCase { + description: "the instance keeps its qualified name", + sql: "CALL d1.s1.b1!GET_CONFIG ( )", + pointer: "/0/call/name/2/name", + expected_value: json!("b1"), + }, + ]; + + for test_case in test_cases { + assert_eq!( + statements(test_case.sql, Dialect::Snowflake, CallMode::Faithful) + .pointer(test_case.pointer) + .cloned() + .unwrap_or_default(), + test_case.expected_value, + "{}", + test_case.description + ); + } +} From e60fedc7e7fb491b655e33d53ea2a8c2548830ee Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:57:19 +0000 Subject: [PATCH 2/2] fix(syntax): COMMENT ON accepts a national string value sqlglot's Snowflake identity fixture writes COMMENT ON ... IS N'...'; the string check refused it, failing the polyglot exact-match run. --- crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs | 1 + .../tests/parser/test_snowflake_alter_gaps.rs | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs index f9dfe1cc8..b4d230804 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs @@ -747,6 +747,7 @@ impl Parser { if self.is_snowflake() && !self.check(TokenType::String) && !self.check(TokenType::DollarString) + && !self.check(TokenType::NationalString) { return Err(self.parse_error("Expected a string after IS")); } diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_alter_gaps.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_alter_gaps.rs index d8830f7d2..793451abf 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_alter_gaps.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_alter_gaps.rs @@ -68,6 +68,12 @@ fn given_snowflake_comment_drop_and_call_statements_when_parsing_then_only_docum dialect: Dialect::Snowflake, expected_accepted: false, }, + AcceptanceTestCase { + description: "a national string is a string (sqlglot's Snowflake identity fixture)", + sql: "COMMENT ON TABLE my_schema.my_table IS N'National String'", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, AcceptanceTestCase { description: "a quoted reserved word is a column", sql: "COMMENT ON COLUMN v1 . \"SELECT\" IS 'm' ;",