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_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_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..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() @@ -261,14 +262,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 +280,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 +347,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_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 ;", 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",