From 985b73c08be284d1bad5cf4089cf065c7c93cd6d Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:08:55 +0000 Subject: [PATCH 1/2] Parse the remaining security_admin SHOW, DROP and DESCRIBE forms - SHOW AUTHENTICATION, INGRESS, PASSWORD and SESSION POLICIES take ON ACCOUNT or ON USER after LIKE; IN no longer follows LIMIT or FROM. - IN APPLICATION, IN DATABASE, IN SCHEMA and IN ORGANIZATION PROFILE may omit the name where Snowflake reads it so; APPLICATION ROLES and DATABASE ROLES accept a bare name. - SHOW CALLER GRANTS reads ON as SHOW GRANTS does and TO ROLE, DATABASE ROLE, DATABASE or a name; USER FUNCTIONS and USER PROCEDURES are Snowflake SHOW kinds. - OPENFLOW DATA PLANE INTEGRATIONS takes no clauses. - After IN ACCOUNT or ON ACCOUNT, a LIMIT that ends the statement is the account's name, as Snowflake reads it; DATABASES and SHARES IN FAILOVER GROUP, ROLES and ENDPOINTS IN SERVICE, and ROLES, FUNCTIONS and PROCEDURES IN CLASS may omit the name. - DROP and DESCRIBE gain the organization, profile, application role and snapshot policy kinds, and read a trailing kind word as the name when the shorter kind exists. - ALTER ORGANIZATION RENAME TO is typed; ALTER ROW ACCESS, PRIVACY and PROJECTION POLICY are typed, and their incomplete kinds are rejected. Against main abd037fd, 138 receipted cases now match Snowflake (ddl 30, security_admin 108) and none regress. --- .../src/parser/_helpers/ddl/constraints.rs | 15 +++ .../_helpers/ddl/snowflake_alter_account.rs | 10 ++ .../_helpers/ddl/snowflake_alter_resources.rs | 5 +- .../src/parser/_helpers/ddl/snowflake_show.rs | 76 +++++++++-- .../_helpers/ddl/snowflake_statements.rs | 12 +- .../sqlquack-syntax/src/parser/constants.rs | 19 ++- .../tests/parser/test_snowflake_fixes.rs | 121 ++++++++++++++++++ 7 files changed, 243 insertions(+), 15 deletions(-) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/constraints.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/constraints.rs index 01ee1244b..2889f03a9 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/constraints.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/constraints.rs @@ -1364,6 +1364,13 @@ impl Parser { return self.parse_drop_table_with_iceberg(leading_comments, true); } + let snowflake = matches!( + self.config.dialect, + Some(crate::tokens::models::DialectType::Snowflake) + ); + if snowflake && self.match_text_seq(&["DATABASE", "ROLE"]) { + return self.parse_drop_snowflake_object("DATABASE ROLE".to_owned()); + } match self.peek().token_type { TokenType::Table => self.parse_drop_table_with_iceberg(leading_comments, false), TokenType::View => self.parse_drop_view(false), @@ -1420,6 +1427,14 @@ impl Parser { .iter() .find(|kind| self.match_text_seq(kind)) { + let shorter = &kind[..kind.len() - 1]; + let kind_word_named = kind.len() > 1 + && self.is_statement_end_at(0) + && SNOWFLAKE_DROP_KINDS.contains(&shorter); + if kind_word_named { + self.current -= 1; + return self.parse_drop_snowflake_object(shorter.join(" ")); + } return self.parse_drop_snowflake_object(kind.join(" ")); } } 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 0a042210f..fa108fcf9 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 @@ -498,6 +498,16 @@ impl Parser { (vec![], properties), )); } + let renames = self + .tokens + .get(self.current + 1) + .is_some_and(|token| token.text.eq_ignore_ascii_case("RENAME")); + if renames { + let head = self.parse_alter_object_head()?; + let action = self.expect_alter_words(&[&["RENAME", "TO"]])?; + let parts: ActionParts = (vec![self.parse_group_name()?], vec![]); + return Ok(alter_object("ORGANIZATION", head, action, parts)); + } if self.match_identifier("PROFILE") { return self.parse_alter_organization_profile(); } 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 72133820a..236b49d58 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 @@ -14,6 +14,7 @@ use crate::tokens::types::Result; /// The object kinds this module parses, as the words that follow ALTER. const RESOURCE_KINDS: &[&[&str]] = &[ &["MULTI", "PARTY", "APPROVAL", "POLICY"], + &["ROW", "ACCESS", "POLICY"], &["STORAGE", "LIFECYCLE", "POLICY"], &["DATA", "MOVEMENT", "POLICY"], &["AGGREGATION", "POLICY"], @@ -28,6 +29,8 @@ const RESOURCE_KINDS: &[&[&str]] = &[ &["NETWORK", "RULE"], &["PACKAGES", "POLICY"], &["PASSWORD", "POLICY"], + &["PRIVACY", "POLICY"], + &["PROJECTION", "POLICY"], &["SESSION", "POLICY"], &["SNAPSHOT", "POLICY"], &["RESTRICTED", "SESSION", "SCOPE"], @@ -242,7 +245,7 @@ impl Parser { "COMPUTE" | "DBT" | "DCM" | "GIT" | "FILE" | "EXTERNAL" | "EVENT" | "ARTIFACT" | "MATERIALIZED" | "RESTRICTED" | "VOLUME" | "AGGREGATION" | "AUTHENTICATION" | "BACKUP" | "DATA" | "INGRESS" | "JOIN" | "MAINTENANCE" | "MASKING" | "MULTI" - | "NETWORK" | "PACKAGES" | "PASSWORD", + | "NETWORK" | "PACKAGES" | "PASSWORD" | "PRIVACY" | "PROJECTION" | "ROW", _, ) => false, _ => return None, 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 55e84944f..dfa6dd2da 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_show.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_show.rs @@ -24,6 +24,9 @@ impl Parser { } return self.parse_snowflake_show_grants(future).map(Some); } + if self.match_text_seq(&["CALLER", "GRANTS"]) { + return self.parse_snowflake_show_caller_grants().map(Some); + } let class = self.applies_snowflake_context_rules() && self.check_next(TokenType::Dot); let shared = SHARED_SHOW_KINDS .iter() @@ -45,6 +48,9 @@ impl Parser { show.target = target; show.terse = terse; show.history = self.match_identifier("HISTORY"); + if matches!(kind.as_str(), "OPENFLOW DATA PLANE INTEGRATIONS") { + return Ok(Some(Expression::Show(Box::new(show)))); + } if matches!(kind.as_str(), "DELEGATED AUTHORIZATIONS BY USER") { show.for_target = Some(self.parse_show_name()?); return Ok(Some(Expression::Show(Box::new(show)))); @@ -74,11 +80,12 @@ impl Parser { if self.match_token(TokenType::Like) { show.like = Some(self.parse_show_string("LIKE")?); } + let mut show = self.parse_show_on_user(&kind, show)?; let required = SNOWFLAKE_SHOW_REQUIRED_CONTAINERS .iter() .find(|(name, _)| *name == kind) .map(|(_, containers)| *containers); - if required.is_some() || self.check(TokenType::In) { + if show.scope_kind.is_none() && (required.is_some() || self.check(TokenType::In)) { self.expect_token(TokenType::In)?; let (container, name) = self.parse_show_container(&kind)?; let allowed = |container: &str| match required { @@ -114,11 +121,6 @@ impl Parser { show.from = Some(self.parse_show_string("FROM")?); } } - if show.scope_kind.is_none() && show.scope.is_none() && self.match_token(TokenType::In) { - let (container, name) = self.parse_show_container(&kind)?; - show.scope_kind = container; - show.scope = name; - } if self.match_token(TokenType::With) { show = self.parse_show_with(&kind, show)?; } @@ -160,6 +162,11 @@ impl Parser { } } + /// Whether a LIMIT ends the statement after ACCOUNT, where Snowflake reads it as the account's name. + fn is_limit_name(&self) -> bool { + self.check(TokenType::Limit) && self.is_statement_end_at(1) + } + /// An object name, kept as written. fn parse_show_name(&mut self) -> Result { Ok(Expression::Table(Box::new(self.parse_object_name()?))) @@ -177,9 +184,16 @@ impl Parser { let groups = matches!(kind, "FAILOVER GROUPS" | "REPLICATION GROUPS"); let optional = match container.as_str() { "ACCOUNT" => !groups, - "DATABASE" | "SCHEMA" => !matches!(kind, "DATABASE ROLES" | "SCHEMA VARIABLES"), + "DATABASE" | "SCHEMA" => !matches!(kind, "SCHEMA VARIABLES"), + "APPLICATION" => !matches!( + kind, + "PRIVILEGES" | "REFERENCES" | "TELEMETRY EVENT DEFINITIONS" + ), "TABLE" | "VIEW" => true, - "REPLICATION GROUP" => matches!(kind, "DATABASES" | "SHARES"), + "REPLICATION GROUP" | "FAILOVER GROUP" => matches!(kind, "DATABASES" | "SHARES"), + "SERVICE" => matches!(kind, "ROLES" | "ENDPOINTS"), + "CLASS" => matches!(kind, "ROLES" | "FUNCTIONS" | "PROCEDURES"), + "ORGANIZATION PROFILE" => true, _ => false, }; let ends = self.is_at_end() @@ -188,6 +202,7 @@ impl Parser { || self.check(TokenType::Limit) || self.check_identifier("STARTS"); let name = match (container.as_str(), optional && ends) { + ("ACCOUNT", _) if self.is_limit_name() => Some(self.parse_show_name()?), (_, true) => None, ("ACCOUNT", _) if !groups => None, _ => Some(self.parse_show_name()?), @@ -243,6 +258,48 @@ impl Parser { } } + /// `[ ON ACCOUNT | ON USER ]` after LIKE, for the policy kinds a user can be shown with. + fn parse_show_on_user(&mut self, kind: &str, mut show: Show) -> Result { + let on_user = matches!( + kind, + "AUTHENTICATION POLICIES" + | "INGRESS POLICIES" + | "PASSWORD POLICIES" + | "SESSION POLICIES" + ); + if on_user && self.match_token(TokenType::On) { + let on = self.expect_alter_words(&[&["ACCOUNT"], &["USER"]])?; + if on == USER { + show.scope = Some(self.parse_show_name()?); + } + show.scope_kind = Some(format!("ON {on}")); + } + Ok(show) + } + + /// SHOW CALLER GRANTS: ON as for SHOW GRANTS; TO takes ROLE, DATABASE ROLE, DATABASE or a name. + fn parse_snowflake_show_caller_grants(&mut self) -> Result { + if !self.match_token(TokenType::To) { + let mut grants = self.parse_snowflake_show_grants(false)?; + if let Expression::Show(show) = &mut grants { + show.this = "CALLER GRANTS".to_owned(); + } + return Ok(grants); + } + let mut show = Show::new("CALLER GRANTS".to_owned()); + let kinds: &[&[&str]] = &[&["DATABASE", "ROLE"], &["ROLE"], &["DATABASE"]]; + let kind = kinds.iter().find(|words| self.match_text_seq(words)); + show.scope_kind = Some(match kind { + Some(words) => format!("TO {}", words.join(" ")), + None => "TO".to_owned(), + }); + let bare_role = kind.is_some_and(|words| words == &["ROLE"]) && self.is_statement_end_at(0); + if !bare_role { + show.scope = Some(self.parse_show_name()?); + } + Ok(Expression::Show(Box::new(show))) + } + /// SHOW [ FUTURE ] GRANTS after GRANTS: its one ON, TO, OF or IN clause, then LIMIT. fn parse_snowflake_show_grants(&mut self, future: bool) -> Result { let show = Show::new(if future { "FUTURE GRANTS" } else { "GRANTS" }.to_owned()); @@ -332,6 +389,9 @@ impl Parser { /// The clause after SHOW GRANTS ON: ACCOUNT, or an object kind of one or more words and the object's name. fn parse_show_grants_on(&mut self, mut show: Show) -> Result<(String, Show)> { if self.match_identifier(ACCOUNT) { + if self.is_limit_name() { + show.scope = Some(self.parse_show_name()?); + } return Ok((ON_ACCOUNT.to_owned(), show)); } let mut kind: Vec = Vec::new(); diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_statements.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_statements.rs index 59b752557..9a4f4cf26 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_statements.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_statements.rs @@ -92,7 +92,17 @@ impl Parser { let Some(kind) = SNOWFLAKE_DESCRIBE_KINDS .iter() .find(|kind| self.match_text_seq(kind)) - .map(|kind| kind.join(" ")) + .map(|kind| { + let shorter = &kind[..kind.len() - 1]; + let kind_word_named = kind.len() > 1 + && self.is_statement_end_at(0) + && SNOWFLAKE_DESCRIBE_KINDS.contains(&shorter); + if kind_word_named { + self.current -= 1; + return shorter.join(" "); + } + kind.join(" ") + }) else { let shared = SHARED_DESCRIBE_KINDS .iter() diff --git a/crates/sqlquack-syntax/src/parser/constants.rs b/crates/sqlquack-syntax/src/parser/constants.rs index ac25e0b6e..a304c98d2 100644 --- a/crates/sqlquack-syntax/src/parser/constants.rs +++ b/crates/sqlquack-syntax/src/parser/constants.rs @@ -423,6 +423,7 @@ pub(crate) const SNOWFLAKE_DROP_KINDS: &[&[&str]] = &[ &["API", "INTEGRATION"], &["APPLICATION", "PACKAGE"], &["APPLICATION", "SERVICE"], + &["APPLICATION", "ROLE"], &["APPLICATION"], &["ARTIFACT", "REPOSITORY"], &["AUTHENTICATION", "POLICY"], @@ -470,11 +471,15 @@ pub(crate) const SNOWFLAKE_DROP_KINDS: &[&[&str]] = &[ &["OPENFLOW", "DEPLOYMENT"], &["OPENFLOW", "RUNTIME"], &["ORGANIZATION", "USER", "GROUP"], + &["ORGANIZATION", "USER"], + &["ORGANIZATION", "PROFILE"], + &["ORGANIZATION"], &["PACKAGES", "POLICY"], &["PASSWORD", "POLICY"], &["PIPE"], &["POSTGRES", "INSTANCE"], &["PRIVACY", "POLICY"], + &["PROFILE"], &["PROJECTION", "POLICY"], &["REPLICATION", "GROUP"], &["RESOURCE", "MONITOR"], @@ -488,6 +493,7 @@ pub(crate) const SNOWFLAKE_DROP_KINDS: &[&[&str]] = &[ &["SERVICE"], &["SESSION", "POLICY"], &["SHARE"], + &["SNAPSHOT", "POLICY"], &["SNAPSHOT", "SET"], &["SNAPSHOT"], &["STAGE"], @@ -547,6 +553,8 @@ pub(crate) const SNOWFLAKE_DESCRIBE_KINDS: &[&[&str]] = &[ &["OPENFLOW", "DEPLOYMENT"], &["OPENFLOW", "RUNTIME"], &["ORGANIZATION", "PROFILE"], + &["ORGANIZATION"], + &["PROFILE"], &["PIPE"], &["POSTGRES", "INSTANCE"], &["RESTRICTED", "SESSION", "SCOPE"], @@ -624,6 +632,8 @@ pub(crate) const SNOWFLAKE_SHOW_KINDS: &[&[&str]] = &[ &["DELEGATED", "AUTHORIZATIONS", "BY", "USER"], &["MULTI", "PARTY", "APPROVAL", "POLICIES"], &["OPENFLOW", "DATA", "PLANE", "INTEGRATIONS"], + &["USER", "FUNCTIONS"], + &["USER", "PROCEDURES"], &["AVAILABLE", "ORGANIZATION", "PROFILES"], &["CORTEX", "BASE", "MODELS"], &["DATA", "METRIC", "FUNCTIONS"], @@ -799,7 +809,6 @@ pub(crate) const SNOWFLAKE_UNDROP_KINDS: &[&str] = &[ /// SHOW kinds the shared SHOW still reads for Snowflake, by their leading words. pub(crate) const SHARED_SHOW_KINDS: &[&[&str]] = &[ - &["CALLER"], &["CORTEX", "SEARCH", "SERVICES"], &["EXPORTED", "KEYS"], &["IMPORTED", "KEYS"], @@ -809,8 +818,6 @@ pub(crate) const SHARED_SHOW_KINDS: &[&[&str]] = &[ &["JOB"], &["PARAMETERS"], &["SERVICES"], - &["USER", "FUNCTIONS"], - &["USER", "PROCEDURES"], ]; /// The containers a Snowflake SHOW ... IN names, each before any container it prefixes. @@ -828,6 +835,7 @@ pub(crate) const SNOWFLAKE_SHOW_CONTAINERS: &[&[&str]] = &[ &["IMAGE", "REPOSITORY"], &["OPENFLOW", "CONNECTOR"], &["ORGANIZATION", "PROFILE"], + &["ORGANIZATION"], &["REPLICATION", "GROUP"], &["SNAPSHOT", "SET"], &["ACCOUNT"], @@ -872,10 +880,10 @@ pub(crate) const SNOWFLAKE_SHOW_EXTRA_CONTAINERS: &[(&str, &[&str])] = &[ /// Snowflake SHOW kinds that need IN and one of these containers; "" is a bare name. pub(crate) const SNOWFLAKE_SHOW_REQUIRED_CONTAINERS: &[(&str, &[&str])] = &[ - ("APPLICATION ROLES", &["APPLICATION"]), + ("APPLICATION ROLES", &["APPLICATION", ""]), ("AVAILABLE OFFERS", &["LISTING"]), ("BACKUPS", &["BACKUP SET"]), - ("DATABASE ROLES", &["DATABASE"]), + ("DATABASE ROLES", &["DATABASE", ""]), ("DEPLOYMENTS", &["DCM PROJECT"]), ("ENDPOINTS", &["SERVICE"]), ("ENTITIES", &["DCM PROJECT"]), @@ -911,6 +919,7 @@ pub(crate) const SNOWFLAKE_SHOW_REQUIRED_CONTAINERS: &[(&str, &[&str])] = &[ "MODEL", "OPENFLOW CONNECTOR", "ORGANIZATION PROFILE", + "ORGANIZATION", ], ), ]; diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs index 099a1d391..e8ffa70cd 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs @@ -1105,3 +1105,124 @@ fn given_a_snowflake_table_or_view_when_parsing_then_its_clauses_are_complete() ); } } + +#[test] +fn given_snowflake_security_admin_show_drop_and_describe_when_parsing_then_the_recorded_verdicts_hold( +) { + let test_cases = [ + SnowflakeVerdictTestCase { + description: "a policy list is scoped to a user after LIKE (recorded parse)", + sql: "SHOW AUTHENTICATION POLICIES LIKE 'm' ON USER v1 STARTS WITH 'm' LIMIT 1 ;", + expected_kind: Some("show"), + }, + SnowflakeVerdictTestCase { + description: "IN after FROM (recorded errno 1003)", + sql: "SHOW ROW ACCESS POLICIES LIKE 'm' LIMIT 1 FROM 'm' IN ACCOUNT ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "IN APPLICATION without a name (recorded parse)", + sql: "SHOW TABLES IN APPLICATION ;", + expected_kind: Some("show"), + }, + SnowflakeVerdictTestCase { + description: "database roles in a bare name (recorded parse)", + sql: "SHOW DATABASE ROLES IN v1 ;", + expected_kind: Some("show"), + }, + SnowflakeVerdictTestCase { + description: "versions in an organization profile (recorded parse)", + sql: "SHOW VERSIONS IN ORGANIZATION v1 ;", + expected_kind: Some("show"), + }, + SnowflakeVerdictTestCase { + description: "LIMIT ending the statement names the account (recorded parse)", + sql: "SHOW BACKUP POLICIES IN ACCOUNT LIMIT ;", + expected_kind: Some("show"), + }, + SnowflakeVerdictTestCase { + description: "LIMIT names the account of SHOW GRANTS ON ACCOUNT (recorded parse)", + sql: "SHOW GRANTS ON ACCOUNT LIMIT ;", + expected_kind: Some("show"), + }, + SnowflakeVerdictTestCase { + description: "shares in a failover group without its name (recorded parse)", + sql: "SHOW SHARES IN FAILOVER GROUP ;", + expected_kind: Some("show"), + }, + SnowflakeVerdictTestCase { + description: "listings need the failover group's name (recorded errno 1003)", + sql: "SHOW LISTINGS IN FAILOVER GROUP ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "roles in a class without its name (recorded parse)", + sql: "SHOW ROLES IN CLASS ;", + expected_kind: Some("show"), + }, + SnowflakeVerdictTestCase { + description: "service containers need the service's name (recorded errno 1003)", + sql: "SHOW SERVICE CONTAINERS IN SERVICE ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "Openflow data plane integrations take no LIMIT (recorded errno 1003)", + sql: "SHOW OPENFLOW DATA PLANE INTEGRATIONS LIMIT 1 ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "caller grants ON need an object (recorded errno 1003)", + sql: "SHOW CALLER GRANTS ON ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "a reserved word is no package name (recorded errno 1003)", + sql: "SHOW USER FUNCTIONS IN APPLICATION PACKAGE SELECT ;", + expected_kind: None, + }, + SnowflakeVerdictTestCase { + description: "a quoted database role is dropped (recorded parse)", + sql: "DROP DATABASE ROLE \"v1 Q\" ;", + expected_kind: Some("drop_object"), + }, + SnowflakeVerdictTestCase { + description: "PROFILE names an organization (recorded parse)", + sql: "DROP ORGANIZATION PROFILE ;", + expected_kind: Some("drop_object"), + }, + SnowflakeVerdictTestCase { + description: "a quoted snapshot policy is dropped (recorded parse)", + sql: "DROP SNAPSHOT POLICY \"SELECT\" ;", + expected_kind: Some("drop_object"), + }, + SnowflakeVerdictTestCase { + description: "PROFILE names an organization to describe (recorded parse)", + sql: "DESC ORGANIZATION PROFILE ;", + expected_kind: Some("describe"), + }, + SnowflakeVerdictTestCase { + description: "GRAPH names a task to describe (recorded parse)", + sql: "DESC TASK GRAPH ;", + expected_kind: Some("describe"), + }, + SnowflakeVerdictTestCase { + description: "an organization is renamed (recorded parse)", + sql: "ALTER ORGANIZATION v1 RENAME TO v1 ;", + expected_kind: Some("alter_object"), + }, + SnowflakeVerdictTestCase { + description: "PROFILE names an organization to rename (recorded parse)", + sql: "ALTER ORGANIZATION PROFILE RENAME TO v1 ;", + expected_kind: Some("alter_object"), + }, + ]; + + for test_case in test_cases { + assert_eq!( + snowflake_kind(test_case.sql), + test_case.expected_kind.map(str::to_owned), + "{}", + test_case.description + ); + } +} From f93bd77e5c3b457f0243923e62a499e036a8e7b8 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:33:10 +0000 Subject: [PATCH 2/2] Type ALTER SECRET and ALTER RESOURCE MONITOR; REFRESH takes one key - ALTER SECRET is a resource kind with one SET action; its name follows the object-name rules, so a string, a bare reserved word or a missing SET is rejected as Snowflake rejects them. - ALTER RESOURCE MONITOR reads `[ SET ... ] [ TRIGGERS ON PERCENT DO ... ]`, both optional, with CREATE's property and trigger items; an incomplete ALTER RESOURCE is rejected. - ALTER [ SECURITY ] INTEGRATION ... REFRESH names exactly one key: Snowflake rejects a bare REFRESH and SAML2_SNOWFLAKE_PRIVATE_KEY with METADATA_URL (recorded errno 1003). Against main abd037fd, with the previous commit, 156 receipted cases now match Snowflake (ddl 30, security_admin 126) and none regress. --- .../_helpers/ddl/snowflake_alter_account.rs | 18 ++++----- .../_helpers/ddl/snowflake_alter_resources.rs | 38 ++++++++++++++++++- .../ddl/snowflake_create_properties.rs | 2 +- .../tests/parser/test_snowflake_fixes.rs | 27 +++++++++++++ 4 files changed, 73 insertions(+), 12 deletions(-) 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 fa108fcf9..9c4c83549 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 @@ -384,16 +384,14 @@ impl Parser { "SET" => (vec![], self.parse_integration_set()?), "UNSET" => (vec![], self.parse_group_unset()?), _ => { - while !self.is_at_end() && !self.check(TokenType::Semicolon) { - let key = self.expect_alter_words(&[ - &["OAUTH_CLIENT_SECRET_2"], - &["OAUTH_CLIENT_SECRET"], - &["SAML2_SNOWFLAKE_PRIVATE_KEY"], - &["METADATA_URL"], - ])?; - action.push(' '); - action.push_str(&key); - } + let key = self.expect_alter_words(&[ + &["OAUTH_CLIENT_SECRET_2"], + &["OAUTH_CLIENT_SECRET"], + &["SAML2_SNOWFLAKE_PRIVATE_KEY"], + &["METADATA_URL"], + ])?; + action.push(' '); + action.push_str(&key); (vec![], vec![]) } }; 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 236b49d58..f8028a0a5 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 @@ -1,6 +1,7 @@ //! Parser ddl: Snowflake ALTER of schemas, stages, file formats, routines, services and project objects. use crate::expressions::names::models::Literal; +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; @@ -46,6 +47,7 @@ const RESOURCE_KINDS: &[&[&str]] = &[ &["FILE", "FORMAT"], &["GIT", "REPOSITORY"], &["MODEL", "MONITOR"], + &["RESOURCE", "MONITOR"], &["AGENT"], &["DATASET"], &["PIPE"], @@ -59,6 +61,7 @@ const RESOURCE_KINDS: &[&[&str]] = &[ &["NOTEBOOK"], &["PROCEDURE"], &["SCHEMA"], + &["SECRET"], &["SERVICE"], &["STAGE"], ]; @@ -79,6 +82,7 @@ fn resource_actions(kind: &str) -> &'static [&'static [&'static str]] { ], "DBT PROJECT" => &[&["DEPLOY", "FROM"], &["RENAME", "TO"], &["SET"], &["UNSET"]], "DCM PROJECT" => &[&["DROP", "DEPLOYMENT"], &["SET"], &["UNSET"]], + "SECRET" => &[&["SET"]], "EVENT ROUTING TABLE" => &[ &["MODIFY", "RULE"], &["RENAME", "TO"], @@ -210,6 +214,9 @@ impl Parser { }; let kind = words.join(" "); let head = self.parse_alter_object_head()?; + if matches!(kind.as_str(), "RESOURCE MONITOR") { + return self.parse_alter_resource_monitor(head).map(Some); + } let arguments = match kind.as_str() { "PROCEDURE" => self.parse_routine_signature()?, _ => Vec::new(), @@ -223,6 +230,34 @@ impl Parser { Ok(Some(Expression::AlterObject(alter))) } + /// ALTER RESOURCE MONITOR after its name: `[ SET ... ] [ TRIGGERS ON PERCENT DO ... ]`. + fn parse_alter_resource_monitor(&mut self, head: (TableRef, bool)) -> Result { + let mut words: Vec<&str> = Vec::new(); + let mut properties: Vec = Vec::new(); + if self.match_text_seq(&["SET"]) { + words.push("SET"); + while !self.is_statement_end_at(0) && !self.check_identifier("TRIGGERS") { + properties.extend(self.parse_create_property_item()?); + } + if properties.is_empty() { + return Err(self.parse_error("Expected a property to set")); + } + } + if self.check_identifier("TRIGGERS") { + words.push("TRIGGERS"); + properties.extend(self.parse_create_property_item()?); + } + if !self.is_statement_end_at(0) { + return Err(self.parse_error("Expected SET, TRIGGERS or the end of the statement")); + } + Ok(alter_object( + "RESOURCE MONITOR", + head, + words.join(" "), + (vec![], properties), + )) + } + /// The words of an incomplete multi-word kind, such as `COMPUTE` without `POOL`, if the next words are one. fn partial_resource_kind(&self) -> Option { let first = self.text_at(0)?; @@ -245,7 +280,8 @@ impl Parser { "COMPUTE" | "DBT" | "DCM" | "GIT" | "FILE" | "EXTERNAL" | "EVENT" | "ARTIFACT" | "MATERIALIZED" | "RESTRICTED" | "VOLUME" | "AGGREGATION" | "AUTHENTICATION" | "BACKUP" | "DATA" | "INGRESS" | "JOIN" | "MAINTENANCE" | "MASKING" | "MULTI" - | "NETWORK" | "PACKAGES" | "PASSWORD" | "PRIVACY" | "PROJECTION" | "ROW", + | "NETWORK" | "PACKAGES" | "PASSWORD" | "PRIVACY" | "PROJECTION" | "RESOURCE" + | "ROW", _, ) => false, _ => return None, diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_properties.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_properties.rs index c0baf846d..bf9bc6d09 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_properties.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_properties.rs @@ -195,7 +195,7 @@ impl Parser { } /// One item: TAG ( = , ... ), TRIGGERS ON PERCENT DO ..., AS , IGNORE EDITION CHECK, or = . - fn parse_create_property_item(&mut self) -> Result> { + pub(super) fn parse_create_property_item(&mut self) -> Result> { if self.check_identifier("TAG") && self.check_next(TokenType::LParen) { self.skip(); self.skip(); diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs index e8ffa70cd..f75f89d38 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_fixes.rs @@ -1226,3 +1226,30 @@ fn given_snowflake_security_admin_show_drop_and_describe_when_parsing_then_the_r ); } } + +#[test] +fn given_snowflake_secret_resource_monitor_and_integration_alters_when_parsing_then_the_recorded_verdicts_hold( +) { + let test_cases = [ + SnowflakeVerdictTestCase { description: "a secret sets a property (recorded parse)", sql: "ALTER SECRET v1 SET SECRET_STRING = 'm' ;", expected_kind: Some("alter_object") }, + SnowflakeVerdictTestCase { description: "a quoted reserved word names a secret (recorded parse)", sql: "ALTER SECRET \"SELECT\" SET COMMENT = 'm' ;", expected_kind: Some("alter_object") }, + SnowflakeVerdictTestCase { description: "a string is no secret name (recorded errno 1003)", sql: "ALTER SECRET 'v1' SET COMMENT = 'm' ;", expected_kind: None }, + SnowflakeVerdictTestCase { description: "a secret's property needs SET (recorded errno 1003)", sql: "ALTER SECRET v1 COMMENT = 'm' ;", expected_kind: None }, + SnowflakeVerdictTestCase { description: "a resource monitor sets its quota (recorded parse)", sql: "ALTER RESOURCE MONITOR v1 SET CREDIT_QUOTA = 1 ;", expected_kind: Some("alter_object") }, + SnowflakeVerdictTestCase { description: "a resource monitor sets properties and triggers (recorded parse)", sql: "ALTER RESOURCE MONITOR IF EXISTS v1 SET CREDIT_QUOTA = 1 FREQUENCY = MONTHLY TRIGGERS ON 80 PERCENT DO SUSPEND ON 90 PERCENT DO NOTIFY ;", expected_kind: Some("alter_object") }, + SnowflakeVerdictTestCase { description: "a trigger needs PERCENT", sql: "ALTER RESOURCE MONITOR v1 TRIGGERS ON 80 DO SUSPEND ;", expected_kind: None }, + SnowflakeVerdictTestCase { description: "SET needs a property", sql: "ALTER RESOURCE MONITOR v1 SET ;", expected_kind: None }, + SnowflakeVerdictTestCase { description: "an integration refreshes one secret (recorded parse)", sql: "ALTER INTEGRATION v1 REFRESH OAUTH_CLIENT_SECRET_2 ;", expected_kind: Some("alter_object") }, + SnowflakeVerdictTestCase { description: "REFRESH names what it refreshes (recorded errno 1003)", sql: "ALTER INTEGRATION \"v1 Q\" REFRESH ;", expected_kind: None }, + SnowflakeVerdictTestCase { description: "REFRESH names one key (recorded errno 1003)", sql: "ALTER SECURITY INTEGRATION v1 REFRESH SAML2_SNOWFLAKE_PRIVATE_KEY METADATA_URL ;", expected_kind: None }, + ]; + + for test_case in test_cases { + assert_eq!( + snowflake_kind(test_case.sql), + test_case.expected_kind.map(str::to_owned), + "{}", + test_case.description + ); + } +}