From 51f458e2c6c02e9c1c06b0d7467f3d31e9a419ca Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:09:57 +0000 Subject: [PATCH 1/2] Parse Snowflake ALTER USER and ALTER SHARE, and fix three GRANT trees - ALTER USER gets typed trees for every documented action: credential ADD, MODIFY, REMOVE and ROTATE (key pairs, programmatic access tokens and PAT, workload identities), MFA methods, delegated authorizations, policies with FORCE, tags, properties and UNSET. The name may be left out for the current user, as Snowflake reads it. - ALTER SHARE parses ADD and REMOVE ACCOUNTS (SHARE_RESTRICTIONS on ADD only), SET with ACCOUNTS lists, SET TAG and UNSET, instead of falling back to an opaque command. - SHOW GRANTS accepts what Snowflake parses: a grantee kind with no name at the end, ROLE !, APPLICATION ROLE ., OF and ON ; IN APPLICATION PACKAGE takes an unquoted name and ends the statement. - GRANT and REVOKE: CALLER and INHERITED CALLER qualify every privilege in the list, a SERVICE ROLE keeps each part's quoting, and a quoted class name keeps its quotes and case. --- .../src/parser/_helpers/ddl/mod.rs | 1 + .../parser/_helpers/ddl/snowflake_alter.rs | 3 + .../_helpers/ddl/snowflake_alter_account.rs | 2 +- .../_helpers/ddl/snowflake_alter_tables.rs | 24 +- .../_helpers/ddl/snowflake_alter_user.rs | 288 ++++++++++++++++++ .../parser/_helpers/ddl/snowflake_grant.rs | 41 ++- .../src/parser/_helpers/ddl/snowflake_show.rs | 45 ++- .../tests/parser/test_snowflake_alter_gaps.rs | 18 ++ .../tests/parser/test_snowflake_fixes.rs | 4 +- .../tests/parser/test_snowflake_grant.rs | 24 ++ 10 files changed, 413 insertions(+), 37 deletions(-) create mode 100644 crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_user.rs diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs index 0293e6c07..f2bb4f863 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs @@ -13,6 +13,7 @@ pub(crate) mod snowflake_alter_dynamic; pub(crate) mod snowflake_alter_objects; pub(crate) mod snowflake_alter_resources; pub(crate) mod snowflake_alter_tables; +pub(crate) mod snowflake_alter_user; pub(crate) mod snowflake_commands; pub(crate) mod snowflake_create_clone; pub(crate) mod snowflake_create_from; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter.rs index bdaf8b1c2..b17dd59c4 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter.rs @@ -65,6 +65,9 @@ impl Parser { "Snowflake has ALTER ONLINE FEATURE TABLE and ALTER FEATURE POLICY only", )); } + if self.match_identifier("USER") { + return self.parse_alter_user().map(Some); + } if self.match_identifier("VARIABLE") { return self.parse_alter_variable().map(Some); } diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_account.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_account.rs index 4966a610d..0a042210f 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_account.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_account.rs @@ -207,7 +207,7 @@ impl Parser { } /// Parse `. [ , ... ]`, refusing a reserved keyword as either part. - fn parse_account_names(&mut self) -> Result> { + pub(super) fn parse_account_names(&mut self) -> Result> { let mut accounts: Vec = Vec::new(); loop { let mut parts: Vec = vec![self.parse_simple_name()?]; 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 index 052d1e68c..1a20056d3 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_tables.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_tables.rs @@ -58,7 +58,7 @@ impl Parser { } 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(); + return self.parse_alter_share().map(Some); } else { Ok(None) }; @@ -174,28 +174,6 @@ impl Parser { Ok(Some(alter_object(kind, head, action, parts))) } - /// Parse ALTER SHARE: UNSET COMMENT typed; ADD, REMOVE, SET and other UNSETs 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", None | Some(";")) => { - return Err(self.parse_error("Expected a property after UNSET")) - } - ("ADD" | "REMOVE" | "SET" | "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()?))) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_user.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_user.rs new file mode 100644 index 000000000..4a59239b3 --- /dev/null +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_user.rs @@ -0,0 +1,288 @@ +//! Snowflake ALTER USER (credentials, MFA, delegated authorizations, policies, properties, tags) and ALTER SHARE. + +use crate::expressions::names::models::TableRef; +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, property, property_kind}; +use crate::parser::models::Parser; +use crate::tokens::models::TokenType; +use crate::tokens::types::Result; + +/// The words that open an ALTER USER action; a user name is absent when one of them comes first. +const USER_ACTIONS: &[&str] = &[ + "ABORT", "ADD", "ENROLL", "MODIFY", "REMOVE", "RENAME", "RESET", "ROTATE", "SET", "UNSET", +]; + +/// The credential kinds ADD, MODIFY, REMOVE and ROTATE take, longest first; PAT is the short spelling. +const CREDENTIALS: &[&[&str]] = &[ + &["PROGRAMMATIC", "ACCESS", "TOKEN"], + &["WORKLOAD", "IDENTITY"], + &["KEY", "PAIR"], + &["PAT"], +]; + +/// The policies a user SET or UNSET names. +const USER_POLICIES: &[&[&str]] = &[ + &["AUTHENTICATION", "POLICY"], + &["INGRESS", "POLICY"], + &["PASSWORD", "POLICY"], + &["SESSION", "POLICY"], +]; + +/// The operands and properties of one action. +type ActionParts = (Vec, Vec); + +/// An action's words with its operands and properties. +type Action = (String, ActionParts); + +impl Parser { + /// Parse ALTER USER after its kind; the name may be left out for the current user. + pub(super) fn parse_alter_user(&mut self) -> Result { + let if_exists = self.match_keywords(&[TokenType::If, TokenType::Exists]); + let unnamed = self + .text_at(0) + .is_some_and(|word| USER_ACTIONS.contains(&word.as_str())) + && !self + .text_at(1) + .is_some_and(|word| USER_ACTIONS.contains(&word.as_str())); + let name = match unnamed { + true => TableRef::new(""), + false => self.parse_object_name()?, + }; + let action = self.expect_alter_words(&[ + &["ABORT", "ALL", "QUERIES"], + &["ENROLL", "MFA"], + &["RESET", "PASSWORD"], + &["RENAME", "TO"], + &["ADD"], + &["MODIFY"], + &["REMOVE"], + &["ROTATE"], + &["SET"], + &["UNSET"], + ])?; + let (action, parts): Action = match action.as_str() { + "RENAME TO" => (action, (vec![self.parse_user_operand()?], vec![])), + "ADD" | "MODIFY" | "REMOVE" | "ROTATE" => self.parse_user_credential_action(action)?, + "SET" | "UNSET" => self.parse_user_set(action)?, + _ => (action, (vec![], vec![])), + }; + Ok(alter_object("USER", (name, if_exists), action, parts)) + } + + /// The object of ADD, MODIFY, REMOVE or ROTATE: a credential, an MFA method or a delegated authorization. + fn parse_user_credential_action(&mut self, mut action: String) -> Result { + let verb = action.clone(); + if self.match_text_seq(&["MFA", "METHOD"]) { + action.push_str(" MFA METHOD"); + return self.parse_user_mfa_method(&verb, action); + } + if self.check_identifier("DELEGATED") && matches!(verb.as_str(), "ADD" | "REMOVE") { + self.skip(); + action.push_str(" DELEGATED"); + return self.parse_user_delegation(&verb, action); + } + let kind = self.expect_alter_words(CREDENTIALS)?; + let kind = match kind.as_str() { + "PAT" => "PROGRAMMATIC ACCESS TOKEN".to_string(), + _ => kind, + }; + action.push(' '); + action.push_str(&kind); + let token = matches!(kind.as_str(), "PROGRAMMATIC ACCESS TOKEN"); + if token && matches!(verb.as_str(), "ADD") && self.check(TokenType::If) { + self.expect_alter_words(&[&["IF", "NOT", "EXISTS"]])?; + action.push_str(" IF NOT EXISTS"); + } else if token && self.match_keywords(&[TokenType::If, TokenType::Exists]) { + action.push_str(" IF EXISTS"); + } + let operands = vec![self.parse_user_operand()?]; + let properties = match verb.as_str() { + "MODIFY" => { + let change = self.expect_alter_words(&[&["RENAME", "TO"], &["SET"], &["UNSET"]])?; + action.push(' '); + action.push_str(&change); + let parts: ActionParts = match change.as_str() { + "RENAME TO" => ( + vec![operands[0].clone(), self.parse_user_operand()?], + vec![], + ), + "SET" => (operands, self.parse_user_properties()?), + _ => (operands, self.parse_user_unset_names()?), + }; + return Ok((action, parts)); + } + "REMOVE" => vec![], + _ if self.is_statement_end_at(0) => vec![], + _ => self.parse_user_properties()?, + }; + Ok((action, (operands, properties))) + } + + /// ADD MFA METHOD OTP [ COUNT = n ], REMOVE MFA METHOD or MODIFY MFA METHOD SET COMMENT = ''. + fn parse_user_mfa_method(&mut self, verb: &str, mut action: String) -> Result { + if matches!(verb, "ROTATE") { + return Err(self.parse_error("Snowflake has no ROTATE MFA METHOD")); + } + let method = self.parse_user_operand()?; + if matches!(verb, "MODIFY") { + action.push_str(" SET"); + self.expect_alter_words(&[&["SET"]])?; + return Ok((action, (vec![method], self.parse_user_properties()?))); + } + let properties = match verb { + "ADD" if !self.is_statement_end_at(0) => self.parse_user_properties()?, + _ => vec![], + }; + Ok((action, (vec![method], properties))) + } + + /// ADD DELEGATED AUTHORIZATION OF ROLE TO SECURITY INTEGRATION , or its REMOVE forms, AUTHORIZATIONS [ OF ANY ROLE ] included. + fn parse_user_delegation(&mut self, verb: &str, mut action: String) -> Result { + let mut operands: Vec = Vec::new(); + let what = match verb { + "ADD" => self.expect_alter_words(&[&["AUTHORIZATION", "OF", "ROLE"]])?, + _ => self.expect_alter_words(&[ + &["AUTHORIZATION", "OF", "ROLE"], + &["AUTHORIZATIONS", "OF", "ANY", "ROLE"], + &["AUTHORIZATIONS"], + ])?, + }; + action.push(' '); + action.push_str(&what); + if matches!(what.as_str(), "AUTHORIZATION OF ROLE") { + operands.push(self.parse_user_operand()?); + } + let preposition = match verb { + "ADD" => self.expect_alter_words(&[&["TO", "SECURITY", "INTEGRATION"]])?, + _ => self.expect_alter_words(&[&["FROM", "SECURITY", "INTEGRATION"]])?, + }; + action.push(' '); + action.push_str(&preposition); + operands.push(self.parse_user_operand()?); + Ok((action, (operands, vec![]))) + } + + /// SET or UNSET: a policy, tags, or properties and parameters. + fn parse_user_set(&mut self, mut action: String) -> Result { + let set = matches!(action.as_str(), "SET"); + if let Some(policy) = USER_POLICIES + .iter() + .find(|words| self.match_text_seq(words)) + { + action.push(' '); + action.push_str(&policy.join(" ")); + if !set { + return Ok((action, (vec![], vec![]))); + } + let operands = vec![self.parse_user_operand()?]; + if self.match_identifier("FORCE") { + action.push_str(" FORCE"); + } + return Ok((action, (operands, vec![]))); + } + if self.check_identifier("TAG") && !self.check_next(TokenType::Eq) { + action.push_str(" TAG"); + let tags = self.parse_alter_object_properties(set, false)?; + return Ok((action, (vec![], tags))); + } + let properties = match set { + true => self.parse_user_properties()?, + false => self.parse_user_unset_names()?, + }; + Ok((action, (vec![], properties))) + } + + /// ` = ` properties separated by blanks or commas; a value may be a parenthesized list. + fn parse_user_properties(&mut self) -> Result> { + let mut properties: Vec = Vec::new(); + loop { + let name = self.parse_simple_name()?; + self.expect_token(TokenType::Eq)?; + let value = match self.check(TokenType::LParen) { + true => self.parse_routine_value_list()?, + false => self.parse_object_value()?, + }; + properties.push(property(&property_kind(&name), None, Some(value))); + let comma = self.match_token(TokenType::Comma); + if self.is_statement_end_at(0) { + if comma { + return Err(self.parse_error("Expected a property after the comma")); + } + return Ok(properties); + } + } + } + + /// UNSET names, separated by commas. + fn parse_user_unset_names(&mut self) -> Result> { + let mut properties: Vec = Vec::new(); + loop { + let name = self.parse_simple_name()?; + properties.push(property(&property_kind(&name), None, None)); + if !self.match_token(TokenType::Comma) { + return Ok(properties); + } + } + } + + /// A user operand: a credential, method, role, integration or policy name. + fn parse_user_operand(&mut self) -> Result { + Ok(Expression::Table(Box::new(self.parse_object_name()?))) + } + + /// Parse ALTER SHARE after its kind: ADD or REMOVE ACCOUNTS, SET and UNSET. + pub(super) fn parse_alter_share(&mut self) -> Result { + let head = self.parse_alter_object_head()?; + let mut action = self.expect_alter_words(&[&["ADD"], &["REMOVE"], &["SET"], &["UNSET"]])?; + let parts: ActionParts = match action.as_str() { + "ADD" | "REMOVE" => { + self.expect_alter_words(&[&["ACCOUNTS"]])?; + action.push_str(" ACCOUNTS"); + self.expect_token(TokenType::Eq)?; + let accounts = self.parse_account_names()?; + let mut properties: Vec = Vec::new(); + if matches!(action.as_str(), "ADD ACCOUNTS") + && self.match_identifier("SHARE_RESTRICTIONS") + { + self.expect_token(TokenType::Eq)?; + properties.push(property( + "SHARE_RESTRICTIONS", + None, + Some(self.parse_object_value()?), + )); + } + (accounts, properties) + } + "SET" if self.check_identifier("TAG") && !self.check_next(TokenType::Eq) => { + action.push_str(" TAG"); + (vec![], self.parse_alter_object_properties(true, false)?) + } + "SET" => (vec![], self.parse_share_set()?), + _ => (vec![], self.parse_alter_object_properties(false, false)?), + }; + Ok(alter_object("SHARE", head, action, parts)) + } + + /// A share's SET properties; ACCOUNTS takes a list of accounts. + fn parse_share_set(&mut self) -> Result> { + let mut properties: Vec = Vec::new(); + loop { + let name = self.parse_simple_name()?; + self.expect_token(TokenType::Eq)?; + let kind = property_kind(&name); + let value = match kind.as_str() { + "ACCOUNTS" => Expression::Tuple(Box::new(Tuple { + expressions: self.parse_account_names()?, + })), + _ => self.parse_object_value()?, + }; + properties.push(property(&kind, None, Some(value))); + self.match_token(TokenType::Comma); + if self.is_statement_end_at(0) { + return Ok(properties); + } + } + } +} diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_grant.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_grant.rs index 8b23019ee..f2f4ebaef 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_grant.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_grant.rs @@ -52,7 +52,7 @@ impl Parser { let service = self.parse_grant_securable()?; self.expect_token(TokenType::Exclamation)?; let role = self.parse_grant_name_part()?; - Identifier::new(format!("{}!{}", service.name, role.name)) + Identifier::new(format!("{}!{}", written(&service), written(&role))) } else { self.parse_grant_securable()? }; @@ -70,6 +70,7 @@ impl Parser { while self.match_token(TokenType::Comma) { privileges.push(self.parse_snowflake_privilege()?); } + qualify_privileges(&mut privileges); self.expect_token(TokenType::On)?; let inherited = privileges .iter() @@ -233,7 +234,11 @@ impl Parser { .is_some_and(|first| kind.contains(' ') && self.check_identifier(first)) }); generic.then(|| { - let word = self.peek_text().to_ascii_uppercase(); + let word = if self.check(TokenType::QuotedIdentifier) { + format!("\"{}\"", self.peek_text()) + } else { + self.peek_text().to_ascii_uppercase() + }; self.skip(); word }) @@ -376,3 +381,35 @@ fn privilege(name: String) -> Privilege { columns: Vec::new(), } } + +/// A name part as written: a quoted part keeps its quotes, so its case and blanks stay distinct. +fn written(part: &Identifier) -> String { + if part.quoted { + format!("\"{}\"", part.name) + } else { + part.name.clone() + } +} + +/// INHERITED and CALLER qualify the whole privilege list, so every later privilege takes the first one's qualifier. +fn qualify_privileges(privileges: &mut [Privilege]) { + let Some((first, rest)) = privileges.split_first_mut() else { + return; + }; + let words: Vec<&str> = first.name.split(' ').collect(); + let mut qualifiers: Vec<&str> = Vec::new(); + for word in ["INHERITED", "CALLER"] { + if words.contains(&word) { + qualifiers.push(word); + } + } + let qualifier: String = qualifiers.join(" "); + if qualifier.is_empty() || first.name.starts_with("ALL ") { + return; + } + for privilege in rest { + if !privilege.name.starts_with(&qualifier) { + privilege.name = format!("{qualifier} {}", privilege.name); + } + } +} diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_show.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_show.rs index 7c63d9b96..75abf7ccb 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_show.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_show.rs @@ -261,14 +261,17 @@ impl Parser { let (clause, show) = self.parse_show_grants_to(show)?; (Some(clause), show) } else if self.match_text_seq(&["OF"]) { - let words = self.expect_alter_words(&[ + let kinds: &[&[&str]] = &[ &["APPLICATION", "ROLE"], &["SERVICE", "ROLE"], &["DATABASE", "ROLE"], &["ROLE"], &["SHARE"], - ])?; - (Some(format!("OF {words}")), show) + ]; + match kinds.iter().find(|words| self.match_text_seq(words)) { + Some(words) => (Some(format!("OF {}", words.join(" "))), show), + None => (Some("OF".to_owned()), show), + } } else if self.match_token(TokenType::In) { self.expect_alter_words(&[&["DCM", "PROJECT"]])?; (Some("IN DCM PROJECT".to_owned()), show) @@ -276,17 +279,41 @@ impl Parser { (None, show) }; if let Some(clause) = clause { - if show.scope.is_none() && clause != ON_ACCOUNT { - show.scope = Some(match clause.ends_with("SERVICE ROLE") { - true => self.parse_show_instance_role()?, - false => self.parse_show_name()?, + let bare = !future + && self.is_statement_end_at(0) + && matches!( + clause.as_str(), + "TO USER" + | "TO ROLE" + | "TO SHARE" + | "TO APPLICATION" + | "TO APPLICATION ROLE" + | "OF ROLE" + | "OF SHARE" + ); + if show.scope.is_none() && clause != ON_ACCOUNT && !bare { + show.scope = Some(match clause.as_str() { + "TO SERVICE ROLE" | "OF SERVICE ROLE" => self.parse_show_instance_role()?, + "TO ROLE" if self.check_next(TokenType::Exclamation) => { + self.parse_show_instance_role()? + } + "TO APPLICATION ROLE" if self.match_token(TokenType::Dot) => { + self.parse_show_name()? + } + _ => self.parse_show_name()?, }); } show.scope_kind = Some(clause); } if show.scope_kind.as_deref() == Some("TO SHARE") && self.match_token(TokenType::In) { self.expect_alter_words(&[&["APPLICATION", "PACKAGE"]])?; + if self.check(TokenType::QuotedIdentifier) { + return Err( + self.parse_error("Snowflake takes an unquoted application package name") + ); + } show.target = Some(self.parse_show_name()?); + return Ok(Expression::Show(Box::new(show))); } if self.match_token(TokenType::Limit) { if !self.check(TokenType::Number) { @@ -319,10 +346,10 @@ impl Parser { { kind.push(self.advance_text()?.to_ascii_uppercase()); } + show.scope = Some(self.parse_show_name()?); if kind.is_empty() { - return Err(self.parse_error("Expected an object kind after ON")); + return Ok(("ON".to_owned(), show)); } - show.scope = Some(self.parse_show_name()?); if self.check(TokenType::LParen) { show.privileges = self.parse_function_param_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 index 95e0cdeb9..933e4f4e1 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_alter_gaps.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_alter_gaps.rs @@ -180,6 +180,24 @@ fn given_snowflake_comment_drop_and_call_statements_when_parsing_then_only_docum #[test] fn given_snowflake_table_actions_and_method_calls_when_parsing_then_the_tree_keeps_their_parts() { let test_cases = [ + ProjectionTestCase { + description: "ALTER USER without a name acts on the current user", + sql: "ALTER USER ADD PAT t1 DAYS_TO_EXPIRY = 1", + pointer: "/0/alter_object/action", + expected_value: json!("ADD PROGRAMMATIC ACCESS TOKEN"), + }, + ProjectionTestCase { + description: "a modified credential keeps its change", + sql: "ALTER USER u1 MODIFY KEY PAIR k1 UNSET DISABLED , COMMENT", + pointer: "/0/alter_object/properties/1/kind", + expected_value: json!("COMMENT"), + }, + ProjectionTestCase { + description: "a share's accounts keep their organization part", + sql: "ALTER SHARE s1 ADD ACCOUNTS = o1.a1 SHARE_RESTRICTIONS = TRUE", + pointer: "/0/alter_object/operands/0/table/schema/name", + expected_value: json!("o1"), + }, ProjectionTestCase { description: "an Iceberg action names its kind", sql: "ALTER ICEBERG TABLE v1 UPGRADE TO ICEBERG VERSION 3", diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs index 9d23fef72..98d41c27d 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs @@ -455,9 +455,9 @@ fn given_a_snowflake_show_of_grants_or_policies_when_parsing_then_its_clauses_ar expected_kind: Some("show"), }, SnowflakeVerdictTestCase { - description: "keeps SHOW GRANTS TO SHARE v1 IN APPLICATION PACKAGE v1 LIMIT 1 ;", + description: "LIMIT after IN APPLICATION PACKAGE (recorded errno 1003)", sql: "SHOW GRANTS TO SHARE v1 IN APPLICATION PACKAGE v1 LIMIT 1 ;", - expected_kind: Some("show"), + expected_kind: None, }, SnowflakeVerdictTestCase { description: "keeps SHOW GRANTS OF APPLICATION ROLE v1 LIMIT 1 ;", diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_grant.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_grant.rs index b79053219..ed99c360c 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_grant.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_grant.rs @@ -173,6 +173,30 @@ fn given_a_snowflake_grant_or_revoke_when_parsing_then_the_tree_keeps_its_parts( pointer: "/0/grant/privileges/0/name", expected_value: json!("CREATE SNOWFLAKE.ML.FORECAST"), }, + ProjectionTestCase { + description: "CALLER qualifies every privilege of the list", + sql: "GRANT CALLER USAGE , SELECT ON TABLE t1 TO ROLE r1", + pointer: "/0/grant/privileges/1/name", + expected_value: json!("CALLER SELECT"), + }, + ProjectionTestCase { + description: "INHERITED CALLER qualifies every privilege of a revoked list", + sql: "REVOKE INHERITED CALLER USAGE , SELECT ON ALL TABLES IN ACCOUNT FROM ROLE r1", + pointer: "/0/revoke/privileges/1/name", + expected_value: json!("INHERITED CALLER SELECT"), + }, + ProjectionTestCase { + description: "a service role keeps the quoting of each part", + sql: "GRANT SERVICE ROLE \"s 1\"!r1 TO ROLE r2", + pointer: "/0/grant/securable/name", + expected_value: json!("\"s 1\"!r1"), + }, + ProjectionTestCase { + description: "a quoted class name keeps its quotes and case", + sql: "GRANT OWNERSHIP ON \"c 1\" i1 TO ROLE r1", + pointer: "/0/grant/kind", + expected_value: json!("\"c 1\""), + }, ProjectionTestCase { description: "ownership keeps COPY CURRENT GRANTS", sql: "GRANT OWNERSHIP ON TABLE t1 TO ROLE r1 COPY CURRENT GRANTS", From 97044b8f206dd72610bf0658daf5d1115ffe6bfa Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:15:10 +0000 Subject: [PATCH 2/2] Accept the last Snowflake clone and replication rows - CREATE DYNAMIC TABLE ... CLONE takes WAREHOUSE as a string as well as a name, as Snowflake parses it. - SHOW DATABASES and SHOW SHARES IN REPLICATION GROUP may leave the group name out at the end of the statement, as Snowflake parses them. --- .../parser/_helpers/ddl/snowflake_create_clone.rs | 11 +++++------ .../src/parser/_helpers/ddl/snowflake_show.rs | 1 + .../tests/parser/test_snowflake_create_clone.rs | 12 ++++++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_clone.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_clone.rs index 319a87a31..6355537be 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_clone.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_clone.rs @@ -85,12 +85,11 @@ impl Parser { properties.push(property("TARGET_LAG", None, Some(value))); } else if dynamic && self.match_text_seq(&["WAREHOUSE"]) { self.expect_token(TokenType::Eq)?; - let warehouse = self.parse_object_name()?; - properties.push(property( - "WAREHOUSE", - None, - Some(Expression::Table(Box::new(warehouse))), - )); + let warehouse = match self.check(TokenType::String) { + true => self.parse_string_value()?, + false => Expression::Table(Box::new(self.parse_object_name()?)), + }; + properties.push(property("WAREHOUSE", None, Some(warehouse))); } else if dynamic && self.match_text_seq(&["EXECUTE"]) { self.expect_alter_words(&[&["AS", "USER"]])?; let user = self.parse_object_name()?; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_show.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_show.rs index 75abf7ccb..55e84944f 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_show.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_show.rs @@ -179,6 +179,7 @@ impl Parser { "ACCOUNT" => !groups, "DATABASE" | "SCHEMA" => !matches!(kind, "DATABASE ROLES" | "SCHEMA VARIABLES"), "TABLE" | "VIEW" => true, + "REPLICATION GROUP" => matches!(kind, "DATABASES" | "SHARES"), _ => false, }; let ends = self.is_at_end() diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs index 44affb7eb..a0821e21f 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs @@ -8,6 +8,18 @@ use crate::test_types::{AcceptanceTestCase, ProjectionTestCase}; #[test] fn given_a_snowflake_clone_or_replica_when_parsing_then_only_documented_forms_are_accepted() { let test_cases = [ + AcceptanceTestCase { + description: "a dynamic table clone names its warehouse as a string (recorded semantic error)", + sql: "CREATE DYNAMIC TABLE v1 CLONE v1 TARGET_LAG = '1 minute' WAREHOUSE = 'v1' ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "SHOW DATABASES IN REPLICATION GROUP may leave the group out (recorded semantic error)", + sql: "SHOW DATABASES IN REPLICATION GROUP ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, AcceptanceTestCase { description: "an alert clones (recorded success)", sql: "CREATE OR REPLACE ALERT v1 CLONE v1 ;",