From 22c99556b93a656126b2eeac68d19cb898b2961c Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:58:25 +0000 Subject: [PATCH 1/4] fix(syntax): parse Snowflake ALTER ALERT, ONLINE FEATURE TABLE and VARIABLE as a typed statement These statements parse to AlterObject instead of the opaque Command fallback: - ALERT: RESUME/SUSPEND, SET, UNSET, FROM TEMPLATE ... SET, MODIFY CONDITION EXISTS ( ) and MODIFY ACTION - ONLINE FEATURE TABLE: SUSPEND/RESUME, REFRESH, RENAME TO, SET, and UNSET TAG [ , ... ] - VARIABLE: RENAME TO, SET/DROP DEFAULT, SET/UNSET COMMENT ALTER ONLINE followed by anything other than FEATURE TABLE, and ALTER FEATURE followed by anything other than POLICY, are now syntax errors in Snowflake, as recorded. --- .../parser/_helpers/ddl/snowflake_alter.rs | 116 +++++++++++++++++- .../tests/parser/test_snowflake_alter.rs | 72 +++++++++++ 2 files changed, 187 insertions(+), 1 deletion(-) 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 338ba936a..9bc4cb652 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter.rs @@ -1,4 +1,4 @@ -//! Snowflake ALTER of object kinds with no statement of their own: EXPERIMENT, SNAPSHOT, SNAPSHOT SET, TAG, TASK and TASK GRAPH. +//! Snowflake ALTER of object kinds that have no statement of their own, such as TASK, TAG or ALERT. use crate::expressions::names::models::Identifier; use crate::expressions::names::models::TableRef; @@ -48,6 +48,22 @@ impl Parser { if self.match_identifier("EXPERIMENT") { return self.parse_alter_experiment().map(Some); } + if self.match_identifier("ALERT") { + return self.parse_alter_alert().map(Some); + } + if self.match_text_seq(&["ONLINE", "FEATURE", "TABLE"]) { + return self.parse_alter_online_feature_table().map(Some); + } + if self.check_identifier("ONLINE") + || self.check_identifier("FEATURE") && !self.check_next_identifier("POLICY") + { + return Err(self.parse_error( + "Snowflake has ALTER ONLINE FEATURE TABLE and ALTER FEATURE POLICY only", + )); + } + if self.match_identifier("VARIABLE") { + return self.parse_alter_variable().map(Some); + } if !self.is_at_end() && !SNOWFLAKE_ALTER_KINDS.contains(&self.peek_text().to_ascii_uppercase().as_str()) { @@ -493,6 +509,104 @@ impl Parser { (operands, Vec::new()), )) } + + /// Parse ALTER ALERT after its kind. + fn parse_alter_alert(&mut self) -> Result { + let head = self.parse_alter_object_head()?; + let mut action = self.expect_alter_words(&[ + &["RESUME"], + &["SUSPEND"], + &["FROM", "TEMPLATE"], + &["MODIFY", "CONDITION", "EXISTS"], + &["MODIFY", "ACTION"], + &["SET"], + &["UNSET"], + ])?; + let mut operands: Vec = Vec::new(); + let mut properties: Vec = Vec::new(); + match action.as_str() { + "FROM TEMPLATE" => { + operands.push(Expression::Table(Box::new(self.parse_object_name()?))); + action.push_str(&format!(" {}", self.expect_alter_words(&[&["SET"]])?)); + properties = self.parse_alter_object_properties(true, false)?; + } + "MODIFY CONDITION EXISTS" => { + self.expect_token(TokenType::LParen)?; + operands.push(self.parse_statement()?); + self.expect_token(TokenType::RParen)?; + } + "MODIFY ACTION" => operands.push(self.parse_statement()?), + "SET" => properties = self.parse_alter_object_properties(true, false)?, + "UNSET" => properties = self.parse_alter_object_properties(false, false)?, + _ => {} + } + Ok(alter_object("ALERT", head, action, (operands, properties))) + } + + /// Parse ALTER ONLINE FEATURE TABLE after its kind. + fn parse_alter_online_feature_table(&mut self) -> Result { + let head = self.parse_alter_object_head()?; + let action = self.expect_alter_words(&[ + &["SUSPEND"], + &["RESUME"], + &["REFRESH"], + &["RENAME", "TO"], + &["SET"], + &["UNSET"], + ])?; + let mut operands: Vec = Vec::new(); + let mut properties: Vec = Vec::new(); + match action.as_str() { + "RENAME TO" => operands.push(Expression::Table(Box::new(self.parse_object_name()?))), + "SET" => properties = self.parse_alter_object_properties(true, false)?, + "UNSET" => { + self.expect_alter_words(&[&["TAG"]])?; + loop { + let name = self.parse_simple_qualified_name()?; + properties.push(property("TAG", Some(name), None)); + if !self.match_token(TokenType::Comma) { + break; + } + } + } + _ => {} + } + Ok(alter_object( + "ONLINE FEATURE TABLE", + head, + action, + (operands, properties), + )) + } + + /// Parse ALTER VARIABLE after its kind. + fn parse_alter_variable(&mut self) -> Result { + let head = self.parse_alter_object_head()?; + let action = self.expect_alter_words(&[ + &["RENAME", "TO"], + &["SET", "DEFAULT"], + &["DROP", "DEFAULT"], + &["SET", "COMMENT"], + &["UNSET", "COMMENT"], + ])?; + let mut operands: Vec = Vec::new(); + let mut properties: Vec = Vec::new(); + match action.as_str() { + "RENAME TO" => operands.push(Expression::Table(Box::new(self.parse_object_name()?))), + "SET DEFAULT" => operands.push(self.parse_expression()?), + "SET COMMENT" => { + self.expect_token(TokenType::Eq)?; + properties.push(property("COMMENT", None, Some(self.parse_string_value()?))); + } + _ => {} + } + Ok(alter_object( + "VARIABLE", + head, + action, + (operands, properties), + )) + } } /// An ALTER of `kind` on the named object, with its action, operands and properties. diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_alter.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_alter.rs index 908397846..087b12b78 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_alter.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_alter.rs @@ -101,6 +101,60 @@ fn given_a_snowflake_alter_of_an_object_kind_when_parsing_then_recorded_rejectio dialect: Dialect::Snowflake, expected_accepted: false, }, + AcceptanceTestCase { + description: "an alert's condition is a parenthesised query (recorded accepted)", + sql: "ALTER ALERT IF EXISTS v1 MODIFY CONDITION EXISTS ( SELECT 1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "an alert from a template sets its properties (recorded semantic error)", + sql: "ALTER ALERT IF EXISTS v1 FROM TEMPLATE v1 SET WAREHOUSE = 'm' SCHEDULE = '1 MINUTE' COMMENT = 'm' RUNBOOK = 'm' SUSPEND_ALERT_AFTER_NUM_FAILURES = 1 TEMPLATE_PARAMS = 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "an online feature table refreshes (recorded semantic error)", + sql: "ALTER ONLINE FEATURE TABLE \"SELECT\" REFRESH ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a variable drops its default (recorded semantic error)", + sql: "ALTER VARIABLE v1 DROP DEFAULT ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "an alert property value is missing (recorded errno 1003)", + sql: "ALTER ALERT v1 FROM TEMPLATE v1 SET WAREHOUSE = ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "ONLINE without FEATURE TABLE is no ALTER (recorded errno 1003)", + sql: "ALTER ONLINE TABLE v1 REFRESH ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "FEATURE TABLE without ONLINE is no ALTER (recorded errno 1003)", + sql: "ALTER FEATURE TABLE v1 REFRESH ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "UNSET TAG needs a tag name (recorded errno 1003)", + sql: "ALTER ONLINE FEATURE TABLE v1 UNSET TAG ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a string is no variable name (recorded errno 1003)", + sql: "ALTER VARIABLE 'v1' DROP DEFAULT ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, AcceptanceTestCase { description: "other dialects keep their ALTER fallback", sql: "ALTER v1 ADD RUN v1", @@ -152,6 +206,24 @@ fn given_a_snowflake_alter_of_an_object_kind_when_parsing_then_the_tree_names_ki pointer: "/0/alter_object/properties/0/kind", expected_value: json!("LOCK_TIMEOUT"), }, + ProjectionTestCase { + description: "ONLINE FEATURE TABLE is one kind", + sql: "ALTER ONLINE FEATURE TABLE v1 SUSPEND", + pointer: "/0/alter_object/kind", + expected_value: json!("ONLINE FEATURE TABLE"), + }, + ProjectionTestCase { + description: "an alert from a template names its action whole", + sql: "ALTER ALERT v1 FROM TEMPLATE t1 SET COMMENT = 'm'", + pointer: "/0/alter_object/action", + expected_value: json!("FROM TEMPLATE SET"), + }, + ProjectionTestCase { + description: "a variable's new default is its operand", + sql: "ALTER VARIABLE v1 SET DEFAULT 42", + pointer: "/0/alter_object/operands/0/literal/value", + expected_value: json!("42"), + }, ProjectionTestCase { description: "IF EXISTS is kept", sql: "ALTER TAG IF EXISTS v1 RENAME TO v2", From 7dd47e51c557b477f43b44175cd234b8de81f66c Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:55:28 +0000 Subject: [PATCH 2/4] fix(syntax): parse Snowflake ALTER FUNCTION, PROCEDURE, SCHEMA, STAGE and service objects ALTER FUNCTION (UDF, external, service and data metric functions) and ALTER PROCEDURE keep their argument types, a TABLE( ... ) argument as a tuple. ALTER AGENT, COMPUTE POOL, CONTACT, DBT PROJECT, DCM PROJECT, EVENT ROUTING TABLE, EXTERNAL AGENT, EXTERNAL TABLE, FILE FORMAT, GATEWAY, GIT REPOSITORY, LISTING, MODEL, MODEL MONITOR, NOTEBOOK, SCHEMA, SERVICE and STAGE parse into AlterObject. Following the recorded receipts: property names are open, a SET of TAG or CONTACT pairs sets nothing else, a stage path ends at whitespace, an incomplete kind such as ALTER COMPUTE is a syntax error, and only external tables add partitions. --- .../src/expressions/schema_objects/models.rs | 3 + .../src/parser/_helpers/ddl/alter.rs | 6 + .../src/parser/_helpers/ddl/mod.rs | 1 + .../parser/_helpers/ddl/snowflake_alter.rs | 142 ++++- .../_helpers/ddl/snowflake_alter_resources.rs | 522 ++++++++++++++++++ .../tests/parser/test_snowflake_alter.rs | 90 +++ 6 files changed, 752 insertions(+), 12 deletions(-) create mode 100644 crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_resources.rs diff --git a/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs b/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs index b2012faf1..50402aa4f 100644 --- a/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs +++ b/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs @@ -1113,6 +1113,9 @@ pub struct AlterObject { pub kind: String, /// The object name pub name: TableRef, + /// The argument types that identify a function or procedure; a TABLE( ... ) argument is a tuple + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub arguments: Vec, /// IF EXISTS clause #[serde(default)] pub if_exists: bool, diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/alter.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/alter.rs index d4656cd67..45a103243 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/alter.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/alter.rs @@ -971,6 +971,12 @@ impl Parser { } fn parse_alter_add_partition(&mut self) -> Result { + if matches!( + self.config.dialect, + Some(crate::tokens::models::DialectType::Snowflake) + ) { + return Err(self.parse_error("Snowflake adds partitions to external tables only")); + } self.skip(); self.expect_token(TokenType::LParen)?; let mut partition_exprs: Vec = Vec::new(); diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs index d1ad98a35..bcc1c2284 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs @@ -8,4 +8,5 @@ pub(crate) mod create_table_forms; pub(crate) mod explain; pub(crate) mod scripting; pub(crate) mod snowflake_alter; +pub(crate) mod snowflake_alter_resources; pub(crate) mod table_options; 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 9bc4cb652..f92b640db 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter.rs @@ -5,6 +5,7 @@ use crate::expressions::names::models::TableRef; use crate::expressions::schema_objects::models::AlterObject; use crate::expressions::schema_objects::models::ObjectProperty; use crate::expressions::tree::models::Expression; +use crate::expressions::types::models::Tuple; use crate::parser::constants::SNOWFLAKE_ALTER_KINDS; use crate::parser::constants::TASK_ACTIONS; use crate::parser::models::Parser; @@ -64,6 +65,12 @@ impl Parser { if self.match_identifier("VARIABLE") { return self.parse_alter_variable().map(Some); } + if self.match_token(TokenType::Function) || self.match_identifier("FUNCTION") { + return self.parse_alter_function().map(Some); + } + if let Some(alter) = self.parse_alter_snowflake_resource()? { + return Ok(Some(alter)); + } if !self.is_at_end() && !SNOWFLAKE_ALTER_KINDS.contains(&self.peek_text().to_ascii_uppercase().as_str()) { @@ -76,20 +83,20 @@ impl Parser { } /// The uppercase text of the token `offset` tokens ahead, if there is one. - fn text_at(&self, offset: usize) -> Option { + pub(super) fn text_at(&self, offset: usize) -> Option { self.tokens .get(self.current + offset) .map(|token| token.text.to_ascii_uppercase()) } /// Parse `[ IF EXISTS ] ` after the kind. - fn parse_alter_object_head(&mut self) -> Result<(TableRef, bool)> { + pub(super) fn parse_alter_object_head(&mut self) -> Result<(TableRef, bool)> { let if_exists = self.match_keywords(&[TokenType::If, TokenType::Exists]); Ok((self.parse_object_name()?, if_exists)) } /// Parse an object name: an identifier, never a string or an unquoted reserved keyword. - fn parse_object_name(&mut self) -> Result { + pub(super) fn parse_object_name(&mut self) -> Result { if self.check(TokenType::String) || self.is_snowflake_reserved_keyword() || !self.is_object_name_start() @@ -107,7 +114,7 @@ impl Parser { } /// Parse a simple name, such as a contact purpose or a property: an identifier, not a reserved keyword. - fn parse_simple_name(&mut self) -> Result { + pub(super) fn parse_simple_name(&mut self) -> Result { if self.check(TokenType::String) || self.is_snowflake_reserved_keyword() || !self.is_object_name_start() @@ -118,7 +125,7 @@ impl Parser { } /// Parse a property value: a literal, TRUE, FALSE or a name. - fn parse_object_value(&mut self) -> Result { + pub(super) fn parse_object_value(&mut self) -> Result { if self.check(TokenType::String) || self.check(TokenType::Number) || self.check(TokenType::DollarString) @@ -142,7 +149,7 @@ impl Parser { } /// Parse a string literal value. - fn parse_string_value(&mut self) -> Result { + pub(super) fn parse_string_value(&mut self) -> Result { if !self.check(TokenType::String) { return Err(self.parse_error("Expected a string")); } @@ -150,7 +157,7 @@ impl Parser { } /// Consume one of `choices`, each a sequence of words, and return its words joined. - fn expect_alter_words(&mut self, choices: &[&[&str]]) -> Result { + pub(super) fn expect_alter_words(&mut self, choices: &[&[&str]]) -> Result { match choices.iter().find(|words| self.match_text_seq(words)) { Some(words) => Ok(words.join(" ")), None => Err(self.parse_error(format!( @@ -165,7 +172,7 @@ impl Parser { } /// Parse a comma-separated list of object names. - fn parse_object_name_list(&mut self) -> Result> { + pub(super) fn parse_object_name_list(&mut self) -> Result> { let mut names = vec![Expression::Table(Box::new(self.parse_object_name()?))]; while self.match_token(TokenType::Comma) { names.push(Expression::Table(Box::new(self.parse_object_name()?))); @@ -174,7 +181,7 @@ impl Parser { } /// Parse the properties of a SET (`set`) or UNSET action; `graph` admits TASK_DEFAULTS and always reads CONTACT as contacts. - fn parse_alter_object_properties( + pub(super) fn parse_alter_object_properties( &mut self, set: bool, graph: bool, @@ -261,7 +268,7 @@ impl Parser { } /// Parse a possibly qualified name, such as a tag name, as one identifier. - fn parse_simple_qualified_name(&mut self) -> Result { + pub(super) fn parse_simple_qualified_name(&mut self) -> Result { let mut name = self.parse_simple_name()?; while self.match_token(TokenType::Dot) { let part = self.parse_simple_name()?; @@ -607,10 +614,116 @@ impl Parser { (operands, properties), )) } + + /// Parse ALTER FUNCTION after its kind: UDF, external, service and data metric functions. + fn parse_alter_function(&mut self) -> Result { + let head = self.parse_alter_object_head()?; + let arguments = self.parse_routine_signature()?; + let mut action = self.expect_alter_words(&[&["RENAME", "TO"], &["SET"], &["UNSET"]])?; + let mut operands: Vec = Vec::new(); + let mut properties: Vec = Vec::new(); + match action.as_str() { + "RENAME TO" => operands.push(Expression::Table(Box::new(self.parse_object_name()?))), + "SET" if self.check_identifier("SECURE") && self.is_statement_end_at(1) => { + self.skip(); + action.push_str(" SECURE"); + } + "SET" => properties = self.parse_routine_set_properties()?, + _ => properties = self.parse_alter_object_properties(false, false)?, + } + let Expression::AlterObject(mut alter) = + alter_object("FUNCTION", head, action, (operands, properties)) + else { + return Err(self.parse_error("Expected an ALTER statement")); + }; + alter.arguments = arguments; + Ok(Expression::AlterObject(alter)) + } + + /// Whether the token `offset` tokens ahead ends the statement. + pub(super) fn is_statement_end_at(&self, offset: usize) -> bool { + self.tokens + .get(self.current + offset) + .is_none_or(|token| token.token_type == TokenType::Semicolon) + } + + /// Parse `( [ , ... ] )` or `( TABLE( , ... ) [ , TABLE( ... ) ] )` after a routine name. + pub(super) fn parse_routine_signature(&mut self) -> Result> { + self.expect_token(TokenType::LParen)?; + let mut arguments: Vec = Vec::new(); + if self.match_token(TokenType::RParen) { + return Ok(arguments); + } + loop { + if self.check(TokenType::Table) && self.check_next(TokenType::LParen) { + self.current += 2; + let mut columns = vec![Expression::DataType(self.parse_data_type()?)]; + while self.match_token(TokenType::Comma) { + columns.push(Expression::DataType(self.parse_data_type()?)); + } + self.expect_token(TokenType::RParen)?; + arguments.push(Expression::Tuple(Box::new(Tuple { + expressions: columns, + }))); + } else { + arguments.push(Expression::DataType(self.parse_data_type()?)); + } + if !self.match_token(TokenType::Comma) { + break; + } + } + self.expect_token(TokenType::RParen)?; + Ok(arguments) + } + + /// Parse the properties after a routine's SET: TAG pairs, or ` = ` with list values. + fn parse_routine_set_properties(&mut self) -> Result> { + if self.check_identifier("TAG") && !self.check_next(TokenType::Eq) { + return self.parse_alter_object_properties(true, false); + } + let mut properties: Vec = Vec::new(); + loop { + let name = self.parse_simple_name()?; + self.expect_token(TokenType::Eq)?; + let value = if self.check(TokenType::LParen) { + self.parse_routine_value_list()? + } else { + self.parse_object_value()? + }; + properties.push(property(&name.name.to_ascii_uppercase(), None, Some(value))); + self.match_token(TokenType::Comma); + if self.is_at_end() || self.check(TokenType::Semicolon) { + return Ok(properties); + } + } + } + + /// Parse `( [ [ , ... ] ] )`, each item a value or a `'' = ` pair. + pub(super) fn parse_routine_value_list(&mut self) -> Result { + self.expect_token(TokenType::LParen)?; + let mut items: Vec = Vec::new(); + if !self.check(TokenType::RParen) { + loop { + let item = self.parse_object_value()?; + items.push(if self.match_token(TokenType::Eq) { + Expression::Tuple(Box::new(Tuple { + expressions: vec![item, self.parse_object_value()?], + })) + } else { + item + }); + if !self.match_token(TokenType::Comma) { + break; + } + } + } + self.expect_token(TokenType::RParen)?; + Ok(Expression::Tuple(Box::new(Tuple { expressions: items }))) + } } /// An ALTER of `kind` on the named object, with its action, operands and properties. -fn alter_object( +pub(super) fn alter_object( kind: &str, (name, if_exists): (TableRef, bool), action: String, @@ -619,6 +732,7 @@ fn alter_object( Expression::AlterObject(Box::new(AlterObject { kind: kind.to_owned(), name, + arguments: Vec::new(), if_exists, action, operands, @@ -627,7 +741,11 @@ fn alter_object( } /// One property of kind `kind`. -fn property(kind: &str, name: Option, value: Option) -> ObjectProperty { +pub(super) fn property( + kind: &str, + name: Option, + value: Option, +) -> ObjectProperty { ObjectProperty { kind: kind.to_owned(), name, 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 new file mode 100644 index 000000000..c9efcb22f --- /dev/null +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_resources.rs @@ -0,0 +1,522 @@ +//! Parser ddl: Snowflake ALTER of schemas, stages, file formats, routines, services and project objects. + +use super::snowflake_alter::alter_object; +use super::snowflake_alter::property; +use crate::expressions::names::models::Literal; +use crate::expressions::schema_objects::models::ObjectProperty; +use crate::expressions::tree::models::Expression; +use crate::expressions::types::models::Tuple; +use crate::parser::models::Parser; +use crate::tokens::models::TokenType; +use crate::tokens::types::Result; + +/// The object kinds this module parses, as the words that follow ALTER. +const RESOURCE_KINDS: &[&[&str]] = &[ + &["COMPUTE", "POOL"], + &["DBT", "PROJECT"], + &["DCM", "PROJECT"], + &["EVENT", "ROUTING", "TABLE"], + &["EXTERNAL", "AGENT"], + &["EXTERNAL", "TABLE"], + &["FILE", "FORMAT"], + &["GIT", "REPOSITORY"], + &["MODEL", "MONITOR"], + &["AGENT"], + &["CONTACT"], + &["GATEWAY"], + &["LISTING"], + &["MODEL"], + &["NOTEBOOK"], + &["PROCEDURE"], + &["SCHEMA"], + &["SERVICE"], + &["STAGE"], +]; + +/// The operands and properties of one ALTER action. +type ActionParts = (Vec, Vec); + +impl Parser { + /// Parse a Snowflake ALTER of a kind in RESOURCE_KINDS, or None when the next words name another kind. + pub(super) fn parse_alter_snowflake_resource(&mut self) -> Result> { + let model_named_monitor = self.check_identifier("MODEL") + && self.check_next_identifier("MONITOR") + && self + .text_at(2) + .is_some_and(|text| text == "SET" || text == "UNSET"); + let words: &[&str] = if model_named_monitor && self.match_identifier("MODEL") { + &["MODEL"] + } else if let Some(words) = RESOURCE_KINDS + .iter() + .find(|words| self.match_text_seq(words)) + { + words + } else if let Some(kind) = self.partial_resource_kind() { + return Err(self.parse_error(format!("Snowflake has no ALTER {kind}"))); + } else { + return Ok(None); + }; + let kind = words.join(" "); + let head = self.parse_alter_object_head()?; + let arguments = if kind == "PROCEDURE" { + self.parse_routine_signature()? + } else { + Vec::new() + }; + let (action, parts) = self.parse_resource_action(&kind)?; + let Expression::AlterObject(mut alter) = alter_object(&kind, head, action, parts) else { + return Err(self.parse_error("Expected an ALTER statement")); + }; + alter.arguments = arguments; + Ok(Some(Expression::AlterObject(alter))) + } + + /// 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)?; + let second = self.text_at(1).unwrap_or_default(); + let complete: &[&str] = match first.as_str() { + "COMPUTE" => &["POOL"], + "DBT" | "DCM" => &["PROJECT"], + "GIT" => &["REPOSITORY"], + "FILE" => &["FORMAT"], + "EXTERNAL" => &["AGENT", "TABLE", "VOLUME", "CONSUMER", "ACCESS"], + "EVENT" => &["ROUTING"], + _ => return None, + }; + let complete = complete.contains(&second.as_str()) + && (first != "EVENT" || self.text_at(2).as_deref() == Some("TABLE")); + (!complete).then(|| format!("{first} {second}").trim_end().to_owned()) + } + + /// Parse the action of an ALTER of `kind` and return its keywords with its operands and properties. + fn parse_resource_action(&mut self, kind: &str) -> Result<(String, ActionParts)> { + let choices: &[&[&str]] = match kind { + "AGENT" => &[&["MODIFY", "LIVE", "VERSION", "SET"], &["SET"], &["UNSET"]], + "COMPUTE POOL" => &[ + &["STOP", "ALL"], + &["SUSPEND"], + &["RESUME"], + &["SET"], + &["UNSET"], + ], + "DBT PROJECT" => &[&["DEPLOY", "FROM"], &["RENAME", "TO"], &["SET"], &["UNSET"]], + "DCM PROJECT" => &[&["DROP", "DEPLOYMENT"], &["SET"], &["UNSET"]], + "EVENT ROUTING TABLE" => &[ + &["MODIFY", "RULE"], + &["RENAME", "TO"], + &["FORCE", "SET", "RULES"], + &["FORCE", "SET", "RULE"], + &["FORCE", "UNSET", "RULE"], + &["SET", "RULES"], + &["SET", "RULE"], + &["UNSET", "RULE"], + ], + "EXTERNAL AGENT" => &[&["ADD", "VERSION"], &["SET"], &["UNSET"]], + "EXTERNAL TABLE" => &[ + &["ADD", "FILES"], + &["REMOVE", "FILES"], + &["ADD", "PARTITION"], + &["DROP", "PARTITION", "LOCATION"], + &["REFRESH"], + &["SET"], + &["UNSET"], + ], + "GATEWAY" => &[&["FROM", "SPECIFICATION"]], + "GIT REPOSITORY" => &[&["FETCH"], &["SET"], &["UNSET"]], + "LISTING" => &[ + &["PUBLISH"], + &["UNPUBLISH"], + &["REVIEW"], + &["ADD", "TARGETS"], + &["REMOVE", "TARGETS"], + &["ADD", "VERSION"], + &["AS"], + &["RENAME", "TO"], + &["SET"], + &["UNSET"], + ], + "MODEL" => &[ + &["ADD", "VERSION"], + &["DROP", "VERSION"], + &["MODIFY", "VERSION"], + &["VERSION"], + &["RENAME", "TO"], + &["SET"], + &["UNSET"], + ], + "MODEL MONITOR" => &[ + &["ADD"], + &["DROP"], + &["SUSPEND"], + &["RESUME"], + &["SET"], + &["UNSET"], + ], + "PROCEDURE" => &[&["EXECUTE", "AS"], &["RENAME", "TO"], &["SET"], &["UNSET"]], + "SCHEMA" => &[ + &["ENABLE", "MANAGED", "ACCESS"], + &["DISABLE", "MANAGED", "ACCESS"], + &["RENAME", "TO"], + &["SWAP", "WITH"], + &["SET"], + &["UNSET"], + ], + "SERVICE" => &[ + &["FROM"], + &["RESTORE", "VOLUME"], + &["SUSPEND"], + &["RESUME"], + &["SET"], + &["UNSET"], + ], + "STAGE" => &[&["REFRESH"], &["RENAME", "TO"], &["SET"], &["UNSET"]], + _ => &[&["RENAME", "TO"], &["SET"], &["UNSET"]], + }; + let mut action = self.expect_alter_words(choices)?; + let mut operands: Vec = Vec::new(); + let mut properties: Vec = Vec::new(); + match action.as_str() { + "RENAME TO" | "SWAP WITH" | "DROP DEPLOYMENT" | "DROP VERSION" => { + operands.push(self.parse_name_operand()?); + } + "SET" if kind == "AGENT" && !self.check_identifier("TAG") => { + let name = self.parse_simple_name()?; + self.expect_token(TokenType::Eq)?; + if !self.is_literal_value() { + return Err(self.parse_error("Expected a literal value")); + } + let value = self.parse_object_value()?; + properties.push(property(&name.name.to_ascii_uppercase(), None, Some(value))); + } + "SET" | "MODIFY LIVE VERSION SET" => properties = self.parse_resource_set()?, + "UNSET" => properties = self.parse_resource_unset()?, + "STOP ALL" => { + if self.match_text_seq(&["OF", "TYPE"]) { + action.push_str(" OF TYPE"); + operands = self.parse_object_name_list()?; + } + } + "DEPLOY FROM" | "DROP PARTITION LOCATION" | "AS" => { + operands.push(self.parse_location_value()?); + if action == "AS" { + properties = self.parse_resource_properties()?; + } + } + "FROM SPECIFICATION" => operands.push(self.parse_object_value()?), + "MODIFY RULE" => { + operands.push(self.parse_name_operand()?); + action.push(' '); + action.push_str(&self.expect_alter_words(&[&["RENAME", "TO"]])?); + operands.push(self.parse_name_operand()?); + } + "SET RULE" | "FORCE SET RULE" => { + operands.push(self.parse_name_operand()?); + properties = self.parse_resource_set()?; + } + "SET RULES" | "FORCE SET RULES" => loop { + let rule = self.parse_simple_name()?; + self.expect_token(TokenType::Eq)?; + properties.push(property("RULE", Some(rule), Some(self.parse_value_list()?))); + if !self.match_token(TokenType::Comma) { + break; + } + }, + "UNSET RULE" | "FORCE UNSET RULE" => operands.push(self.parse_name_operand()?), + "ADD FILES" | "REMOVE FILES" => operands.push(self.parse_value_list()?), + "ADD PARTITION" => { + self.expect_token(TokenType::LParen)?; + loop { + let column = self.parse_simple_name()?; + self.expect_token(TokenType::Eq)?; + properties.push(property( + "PARTITION", + Some(column), + Some(self.parse_object_value()?), + )); + if !self.match_token(TokenType::Comma) { + break; + } + } + self.expect_token(TokenType::RParen)?; + action.push_str(" LOCATION"); + self.expect_alter_words(&[&["LOCATION"]])?; + operands.push(self.parse_location_value()?); + } + "REFRESH" if kind == "STAGE" => properties = self.parse_resource_properties()?, + "REFRESH" => { + if self.check(TokenType::String) { + operands.push(self.parse_string_value()?); + } + } + "ADD TARGETS" | "REMOVE TARGETS" => operands.push(self.parse_resource_value()?), + "ADD VERSION" if kind == "EXTERNAL AGENT" => operands.push(self.parse_name_operand()?), + "ADD VERSION" if kind == "LISTING" => { + if self.match_keywords(&[TokenType::If, TokenType::Not, TokenType::Exists]) { + action.push_str(" IF NOT EXISTS"); + } + if !self.check(TokenType::From) { + operands.push(self.parse_name_operand()?); + } + self.expect_token(TokenType::From)?; + operands.push(self.parse_location_value()?); + properties = self.parse_resource_properties()?; + } + "ADD VERSION" => { + operands.push(self.parse_name_operand()?); + self.expect_token(TokenType::From)?; + if self.match_identifier("MODEL") { + action.push_str(" FROM MODEL"); + operands.push(self.parse_name_operand()?); + if self.match_identifier("VERSION") { + operands.push(self.parse_name_operand()?); + } + } else { + operands.push(self.parse_location_value()?); + } + } + "MODIFY VERSION" => { + operands.push(self.parse_name_operand()?); + self.expect_alter_words(&[&["SET"]])?; + action.push_str(" SET"); + properties = self.parse_resource_set()?; + } + "VERSION" => { + operands.push(self.parse_name_operand()?); + action.push(' '); + action + .push_str(&self.expect_alter_words(&[&["SET", "ALIAS"], &["UNSET", "ALIAS"]])?); + if action.starts_with("VERSION SET") { + self.expect_token(TokenType::Eq)?; + operands.push(self.parse_name_operand()?); + } + } + "ADD" | "DROP" => { + action.push_str(" SEGMENT_COLUMN"); + self.expect_alter_words(&[&["SEGMENT_COLUMN"]])?; + self.expect_token(TokenType::Eq)?; + operands.push(self.parse_object_value()?); + } + "EXECUTE AS" => { + action.push(' '); + action.push_str(&self.expect_alter_words(&[ + &["OWNER"], + &["CALLER"], + &["RESTRICTED", "CALLER"], + ])?); + } + "FROM" => { + if self.is_stage_path() { + operands.push(self.parse_stage_path()?); + } else if !self.check(TokenType::Identifier) && !self.check(TokenType::Var) { + operands.push(self.parse_object_value()?); + return Ok((action, (operands, properties))); + } + properties = self.parse_service_source()?; + if properties.is_empty() && operands.is_empty() { + return Err(self.parse_error("Expected a specification")); + } + } + "RESTORE VOLUME" => { + operands.push(self.parse_name_operand()?); + self.expect_alter_words(&[&["INSTANCES"]])?; + let mut instances = vec![self.parse_object_value()?]; + while self.match_token(TokenType::Comma) { + instances.push(self.parse_object_value()?); + } + operands.push(Expression::Tuple(Box::new(Tuple { + expressions: instances, + }))); + self.expect_alter_words(&[&["FROM", "SNAPSHOT"]])?; + operands.push(self.parse_name_operand()?); + } + _ => {} + } + Ok((action, (operands, properties))) + } + + /// Parse an object name operand. + fn parse_name_operand(&mut self) -> Result { + Ok(Expression::Table(Box::new(self.parse_object_name()?))) + } + + /// Parse a location: a stage path or a string. + fn parse_location_value(&mut self) -> Result { + if self.is_stage_path() { + return self.parse_stage_path(); + } + self.parse_string_value() + } + + /// Whether the current token starts a stage path. + fn is_stage_path(&self) -> bool { + !self.is_at_end() && self.peek_text().starts_with('@') + } + + /// Parse a stage path, which like Snowflake's ends at the first whitespace: `@"s 1"` is not one path. + fn parse_stage_path(&mut self) -> Result { + let mut path = self.peek_text().to_owned(); + let mut end = self.peek().span.end; + self.skip(); + while !self.is_at_end() + && self.peek().span.start == end + && !matches!( + self.peek().token_type, + TokenType::Semicolon | TokenType::Comma | TokenType::LParen | TokenType::RParen + ) + { + if self.check(TokenType::String) || self.peek_text().chars().any(char::is_whitespace) { + return Err(self.parse_error("A stage path ends at whitespace")); + } + path.push_str(self.peek_text()); + end = self.peek().span.end; + self.skip(); + } + Ok(Expression::Literal(Box::new(Literal::String(path)))) + } + + /// Parse a service's source after FROM: SPECIFICATION or SPECIFICATION_TEMPLATE with a value, ` = ` and USING ( ... ). + fn parse_service_source(&mut self) -> Result> { + let mut properties: Vec = Vec::new(); + while !self.is_at_end() && !self.check(TokenType::Semicolon) { + if self.check(TokenType::Using) || self.check_identifier("USING") { + self.skip(); + properties.push(property("USING", None, Some(self.parse_value_list()?))); + continue; + } + let name = self.parse_simple_name()?; + let upper = name.name.to_ascii_uppercase(); + let bare = matches!(upper.as_str(), "SPECIFICATION" | "SPECIFICATION_TEMPLATE") + && !self.check(TokenType::Eq); + if !bare { + self.expect_token(TokenType::Eq)?; + } + properties.push(property(&upper, None, Some(self.parse_object_value()?))); + } + Ok(properties) + } + + /// Whether the current token is a literal: a string, a number, TRUE or FALSE. + fn is_literal_value(&self) -> bool { + self.check(TokenType::String) + || self.check(TokenType::DollarString) + || self.check(TokenType::Number) + || self.check(TokenType::True) + || self.check(TokenType::False) + } + + /// Parse the properties of a SET: at least one TAG pair, CONTACT pair or ` = `. + fn parse_resource_set(&mut self) -> Result> { + let properties = self.parse_resource_properties()?; + if properties.is_empty() { + return Err(self.parse_error("Expected a property to set")); + } + let pairs = properties + .iter() + .filter(|item| item.name.is_some()) + .map(|item| item.kind.as_str()) + .collect::>(); + if !pairs.is_empty() + && (pairs.len() > 1 || properties.iter().any(|item| item.name.is_none())) + { + return Err(self.parse_error("A SET of TAG or CONTACT pairs sets nothing else")); + } + Ok(properties) + } + + /// Parse zero or more TAG pairs, CONTACT pairs and ` = ` properties, optionally comma-separated. + fn parse_resource_properties(&mut self) -> Result> { + let mut properties: Vec = Vec::new(); + while !self.is_at_end() && !self.check(TokenType::Semicolon) { + let pairs = ["TAG", "CONTACT"] + .into_iter() + .find(|word| self.check_identifier(word) && !self.check_next(TokenType::Eq)); + if let Some(word) = pairs { + self.skip(); + loop { + let name = self.parse_simple_qualified_name()?; + self.expect_token(TokenType::Eq)?; + let value = if word == "CONTACT" { + self.parse_name_operand()? + } else { + self.parse_object_value()? + }; + properties.push(property(word, Some(name), Some(value))); + if !self.match_token(TokenType::Comma) { + break; + } + } + } else { + let name = self.parse_simple_name()?; + self.expect_token(TokenType::Eq)?; + let value = self.parse_resource_value()?; + properties.push(property(&name.name.to_ascii_uppercase(), None, Some(value))); + self.match_token(TokenType::Comma); + } + } + Ok(properties) + } + + /// Parse the names an UNSET removes: TAG names, CONTACT purposes or properties, comma-separated. + fn parse_resource_unset(&mut self) -> Result> { + let mut properties: Vec = Vec::new(); + loop { + let pairs = ["TAG", "CONTACT"].into_iter().find(|word| { + self.check_identifier(word) + && !self.check_next(TokenType::Comma) + && !self.check_next(TokenType::Semicolon) + && self.current + 1 < self.tokens.len() + }); + if let Some(word) = pairs { + self.skip(); + loop { + let name = self.parse_simple_qualified_name()?; + properties.push(property(word, Some(name), None)); + if !self.match_token(TokenType::Comma) { + break; + } + } + } else { + let name = self.parse_simple_name()?; + properties.push(property(&name.name.to_ascii_uppercase(), None, None)); + } + if !self.match_token(TokenType::Comma) { + return Ok(properties); + } + } + } + + /// Parse a property value: a parenthesized list, a stage path or a single value. + fn parse_resource_value(&mut self) -> Result { + if self.check(TokenType::LParen) { + return self.parse_value_list(); + } + if self.is_stage_path() { + return self.parse_stage_path(); + } + self.parse_object_value() + } + + /// Parse `( [ [ , ] ... ] )`, each item a value or a ` = ` or ` => ` pair. + fn parse_value_list(&mut self) -> Result { + self.expect_token(TokenType::LParen)?; + let mut items: Vec = Vec::new(); + while !self.match_token(TokenType::RParen) { + let item = self.parse_resource_value()?; + items.push( + if self.match_token(TokenType::Eq) || self.match_token(TokenType::FArrow) { + Expression::Tuple(Box::new(Tuple { + expressions: vec![item, self.parse_resource_value()?], + })) + } else { + item + }, + ); + self.match_token(TokenType::Comma); + if self.is_at_end() { + return Err(self.parse_error("Expected )")); + } + } + Ok(Expression::Tuple(Box::new(Tuple { expressions: items }))) + } +} diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_alter.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_alter.rs index 087b12b78..427cf9749 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_alter.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_alter.rs @@ -155,6 +155,84 @@ fn given_a_snowflake_alter_of_an_object_kind_when_parsing_then_recorded_rejectio dialect: Dialect::Snowflake, expected_accepted: false, }, + AcceptanceTestCase { + description: "a data metric function is named by its TABLE( ... ) signature (recorded semantic error)", + sql: "ALTER FUNCTION v1 ( TABLE ( INT ) , TABLE ( INT ) ) SET SECURE ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a procedure runs as a restricted caller (recorded semantic error)", + sql: "ALTER PROCEDURE v1 ( ) EXECUTE AS RESTRICTED CALLER ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a stage path ends at whitespace, so the quoted name splits (recorded errno 1003)", + sql: "ALTER MODEL v1 ADD VERSION v1 FROM @\"s1 Q\" ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a stage path may name a reserved keyword (recorded semantic error)", + sql: "ALTER MODEL v1 ADD VERSION v1 FROM @SELECT ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a model named MONITOR unsets its comment (recorded semantic error)", + sql: "ALTER MODEL MONITOR UNSET COMMENT ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "TAG pairs are a SET of their own (every recorded mix is errno 1003)", + sql: "ALTER COMPUTE POOL v1 SET MIN_NODES = 1 TAG v1 = 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "an agent sets one property at a time (recorded errno 1003)", + sql: "ALTER AGENT v1 SET COMMENT = 'm' PROFILE = 'm' SECURE = TRUE ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "COMPUTE without POOL is no kind (recorded errno 1003)", + sql: "ALTER COMPUTE v1 SUSPEND ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "Snowflake has no ALTER EVENT TABLE (recorded errno 1003)", + sql: "ALTER EVENT TABLE v1 UNSET RULE v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "IF EXISTS comes before an external table's name (recorded errno 1003)", + sql: "ALTER EXTERNAL TABLE v1 IF EXISTS ADD PARTITION ( v1 = 'm' ) LOCATION 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a partition column is no string (recorded errno 1003)", + sql: "ALTER EXTERNAL TABLE v1 ADD PARTITION ( 'v1' = 'm' ) LOCATION 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "only external tables add partitions (recorded errno 1003)", + sql: "ALTER TABLE v1 ADD PARTITION ( v1 = 'm' ) LOCATION 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "an inline specification takes no USING (recorded errno 1003)", + sql: "ALTER SERVICE v1 FROM $$m: 1$$ USING ( v1 => 1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, AcceptanceTestCase { description: "other dialects keep their ALTER fallback", sql: "ALTER v1 ADD RUN v1", @@ -224,6 +302,18 @@ fn given_a_snowflake_alter_of_an_object_kind_when_parsing_then_the_tree_names_ki pointer: "/0/alter_object/operands/0/literal/value", expected_value: json!("42"), }, + ProjectionTestCase { + description: "a function's argument types are kept, a TABLE( ... ) argument as a tuple", + sql: "ALTER FUNCTION v1 ( TABLE ( INT , TEXT ) ) UNSET TAG t1", + pointer: "/0/alter_object/arguments/0/tuple/expressions/1/data_type/data_type", + expected_value: json!("text"), + }, + ProjectionTestCase { + description: "COMPUTE POOL is one kind", + sql: "ALTER COMPUTE POOL v1 STOP ALL", + pointer: "/0/alter_object/kind", + expected_value: json!("COMPUTE POOL"), + }, ProjectionTestCase { description: "IF EXISTS is kept", sql: "ALTER TAG IF EXISTS v1 RENAME TO v2", From 17fc4534a150ec5dcc5a2eee2b503b85f3e837b9 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:08:32 +0000 Subject: [PATCH 3/4] refactor(syntax): split the Snowflake ALTER resource actions into small parsers Each group of actions (model versions, listing versions, service sources, event routing rules and the remaining clauses) has its own parser that takes and returns the action words, and kind and action are matched together rather than compared. --- .../_helpers/ddl/snowflake_alter_resources.rs | 478 ++++++++++-------- 1 file changed, 258 insertions(+), 220 deletions(-) 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 c9efcb22f..d4a92ca4a 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,11 +1,11 @@ //! Parser ddl: Snowflake ALTER of schemas, stages, file formats, routines, services and project objects. -use super::snowflake_alter::alter_object; -use super::snowflake_alter::property; use crate::expressions::names::models::Literal; 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; @@ -36,14 +36,98 @@ const RESOURCE_KINDS: &[&[&str]] = &[ /// The operands and properties of one ALTER action. type ActionParts = (Vec, Vec); +/// The actions an ALTER of `kind` takes, each as its words. +fn resource_actions(kind: &str) -> &'static [&'static [&'static str]] { + match kind { + "AGENT" => &[&["MODIFY", "LIVE", "VERSION", "SET"], &["SET"], &["UNSET"]], + "COMPUTE POOL" => &[ + &["STOP", "ALL"], + &["SUSPEND"], + &["RESUME"], + &["SET"], + &["UNSET"], + ], + "DBT PROJECT" => &[&["DEPLOY", "FROM"], &["RENAME", "TO"], &["SET"], &["UNSET"]], + "DCM PROJECT" => &[&["DROP", "DEPLOYMENT"], &["SET"], &["UNSET"]], + "EVENT ROUTING TABLE" => &[ + &["MODIFY", "RULE"], + &["RENAME", "TO"], + &["FORCE", "SET", "RULES"], + &["FORCE", "SET", "RULE"], + &["FORCE", "UNSET", "RULE"], + &["SET", "RULES"], + &["SET", "RULE"], + &["UNSET", "RULE"], + ], + "EXTERNAL AGENT" => &[&["ADD", "VERSION"], &["SET"], &["UNSET"]], + "EXTERNAL TABLE" => &[ + &["ADD", "FILES"], + &["REMOVE", "FILES"], + &["ADD", "PARTITION"], + &["DROP", "PARTITION", "LOCATION"], + &["REFRESH"], + &["SET"], + &["UNSET"], + ], + "GATEWAY" => &[&["FROM", "SPECIFICATION"]], + "GIT REPOSITORY" => &[&["FETCH"], &["SET"], &["UNSET"]], + "LISTING" => &[ + &["PUBLISH"], + &["UNPUBLISH"], + &["REVIEW"], + &["ADD", "TARGETS"], + &["REMOVE", "TARGETS"], + &["ADD", "VERSION"], + &["AS"], + &["RENAME", "TO"], + &["SET"], + &["UNSET"], + ], + "MODEL" => &[ + &["ADD", "VERSION"], + &["DROP", "VERSION"], + &["MODIFY", "VERSION"], + &["VERSION"], + &["RENAME", "TO"], + &["SET"], + &["UNSET"], + ], + "MODEL MONITOR" => &[ + &["ADD"], + &["DROP"], + &["SUSPEND"], + &["RESUME"], + &["SET"], + &["UNSET"], + ], + "PROCEDURE" => &[&["EXECUTE", "AS"], &["RENAME", "TO"], &["SET"], &["UNSET"]], + "SCHEMA" => &[ + &["ENABLE", "MANAGED", "ACCESS"], + &["DISABLE", "MANAGED", "ACCESS"], + &["RENAME", "TO"], + &["SWAP", "WITH"], + &["SET"], + &["UNSET"], + ], + "SERVICE" => &[ + &["FROM"], + &["RESTORE", "VOLUME"], + &["SUSPEND"], + &["RESUME"], + &["SET"], + &["UNSET"], + ], + "STAGE" => &[&["REFRESH"], &["RENAME", "TO"], &["SET"], &["UNSET"]], + _ => &[&["RENAME", "TO"], &["SET"], &["UNSET"]], + } +} + impl Parser { /// Parse a Snowflake ALTER of a kind in RESOURCE_KINDS, or None when the next words name another kind. pub(super) fn parse_alter_snowflake_resource(&mut self) -> Result> { let model_named_monitor = self.check_identifier("MODEL") && self.check_next_identifier("MONITOR") - && self - .text_at(2) - .is_some_and(|text| text == "SET" || text == "UNSET"); + && matches!(self.text_at(2).as_deref(), Some("SET" | "UNSET")); let words: &[&str] = if model_named_monitor && self.match_identifier("MODEL") { &["MODEL"] } else if let Some(words) = RESOURCE_KINDS @@ -58,12 +142,12 @@ impl Parser { }; let kind = words.join(" "); let head = self.parse_alter_object_head()?; - let arguments = if kind == "PROCEDURE" { - self.parse_routine_signature()? - } else { - Vec::new() + let arguments = match kind.as_str() { + "PROCEDURE" => self.parse_routine_signature()?, + _ => Vec::new(), }; - let (action, parts) = self.parse_resource_action(&kind)?; + let action = self.expect_alter_words(resource_actions(&kind))?; + let (action, parts) = self.parse_resource_action(&kind, action)?; let Expression::AlterObject(mut alter) = alter_object(&kind, head, action, parts) else { return Err(self.parse_error("Expected an ALTER statement")); }; @@ -75,195 +159,110 @@ impl Parser { fn partial_resource_kind(&self) -> Option { let first = self.text_at(0)?; let second = self.text_at(1).unwrap_or_default(); - let complete: &[&str] = match first.as_str() { - "COMPUTE" => &["POOL"], - "DBT" | "DCM" => &["PROJECT"], - "GIT" => &["REPOSITORY"], - "FILE" => &["FORMAT"], - "EXTERNAL" => &["AGENT", "TABLE", "VOLUME", "CONSUMER", "ACCESS"], - "EVENT" => &["ROUTING"], + let complete = match (first.as_str(), second.as_str()) { + ("COMPUTE", "POOL") + | ("DBT" | "DCM", "PROJECT") + | ("GIT", "REPOSITORY") + | ("FILE", "FORMAT") + | ("EXTERNAL", "AGENT" | "TABLE" | "VOLUME" | "CONSUMER" | "ACCESS") => true, + ("EVENT", "ROUTING") => matches!(self.text_at(2).as_deref(), Some("TABLE")), + ("COMPUTE" | "DBT" | "DCM" | "GIT" | "FILE" | "EXTERNAL" | "EVENT", _) => false, _ => return None, }; - let complete = complete.contains(&second.as_str()) - && (first != "EVENT" || self.text_at(2).as_deref() == Some("TABLE")); (!complete).then(|| format!("{first} {second}").trim_end().to_owned()) } - /// Parse the action of an ALTER of `kind` and return its keywords with its operands and properties. - fn parse_resource_action(&mut self, kind: &str) -> Result<(String, ActionParts)> { - let choices: &[&[&str]] = match kind { - "AGENT" => &[&["MODIFY", "LIVE", "VERSION", "SET"], &["SET"], &["UNSET"]], - "COMPUTE POOL" => &[ - &["STOP", "ALL"], - &["SUSPEND"], - &["RESUME"], - &["SET"], - &["UNSET"], - ], - "DBT PROJECT" => &[&["DEPLOY", "FROM"], &["RENAME", "TO"], &["SET"], &["UNSET"]], - "DCM PROJECT" => &[&["DROP", "DEPLOYMENT"], &["SET"], &["UNSET"]], - "EVENT ROUTING TABLE" => &[ - &["MODIFY", "RULE"], - &["RENAME", "TO"], - &["FORCE", "SET", "RULES"], - &["FORCE", "SET", "RULE"], - &["FORCE", "UNSET", "RULE"], - &["SET", "RULES"], - &["SET", "RULE"], - &["UNSET", "RULE"], - ], - "EXTERNAL AGENT" => &[&["ADD", "VERSION"], &["SET"], &["UNSET"]], - "EXTERNAL TABLE" => &[ - &["ADD", "FILES"], - &["REMOVE", "FILES"], - &["ADD", "PARTITION"], - &["DROP", "PARTITION", "LOCATION"], - &["REFRESH"], - &["SET"], - &["UNSET"], - ], - "GATEWAY" => &[&["FROM", "SPECIFICATION"]], - "GIT REPOSITORY" => &[&["FETCH"], &["SET"], &["UNSET"]], - "LISTING" => &[ - &["PUBLISH"], - &["UNPUBLISH"], - &["REVIEW"], - &["ADD", "TARGETS"], - &["REMOVE", "TARGETS"], - &["ADD", "VERSION"], - &["AS"], - &["RENAME", "TO"], - &["SET"], - &["UNSET"], - ], - "MODEL" => &[ - &["ADD", "VERSION"], - &["DROP", "VERSION"], - &["MODIFY", "VERSION"], - &["VERSION"], - &["RENAME", "TO"], - &["SET"], - &["UNSET"], - ], - "MODEL MONITOR" => &[ - &["ADD"], - &["DROP"], - &["SUSPEND"], - &["RESUME"], - &["SET"], - &["UNSET"], - ], - "PROCEDURE" => &[&["EXECUTE", "AS"], &["RENAME", "TO"], &["SET"], &["UNSET"]], - "SCHEMA" => &[ - &["ENABLE", "MANAGED", "ACCESS"], - &["DISABLE", "MANAGED", "ACCESS"], - &["RENAME", "TO"], - &["SWAP", "WITH"], - &["SET"], - &["UNSET"], - ], - "SERVICE" => &[ - &["FROM"], - &["RESTORE", "VOLUME"], - &["SUSPEND"], - &["RESUME"], - &["SET"], - &["UNSET"], - ], - "STAGE" => &[&["REFRESH"], &["RENAME", "TO"], &["SET"], &["UNSET"]], - _ => &[&["RENAME", "TO"], &["SET"], &["UNSET"]], - }; - let mut action = self.expect_alter_words(choices)?; - let mut operands: Vec = Vec::new(); - let mut properties: Vec = Vec::new(); - match action.as_str() { - "RENAME TO" | "SWAP WITH" | "DROP DEPLOYMENT" | "DROP VERSION" => { - operands.push(self.parse_name_operand()?); + /// Parse the operands and properties of `action` on `kind`, extending `action` with its later keywords. + fn parse_resource_action( + &mut self, + kind: &str, + action: String, + ) -> Result<(String, ActionParts)> { + let key = action.clone(); + let parts = match (kind, key.as_str()) { + ("AGENT", "SET") if !self.check_identifier("TAG") => { + (vec![], vec![self.parse_literal_property()?]) } - "SET" if kind == "AGENT" && !self.check_identifier("TAG") => { - let name = self.parse_simple_name()?; - self.expect_token(TokenType::Eq)?; - if !self.is_literal_value() { - return Err(self.parse_error("Expected a literal value")); - } - let value = self.parse_object_value()?; - properties.push(property(&name.name.to_ascii_uppercase(), None, Some(value))); + (_, "SET" | "MODIFY LIVE VERSION SET") => (vec![], self.parse_resource_set()?), + (_, "UNSET") => (vec![], self.parse_resource_unset()?), + ( + _, + "RENAME TO" | "SWAP WITH" | "DROP DEPLOYMENT" | "DROP VERSION" | "UNSET RULE" + | "FORCE UNSET RULE", + ) + | ("EXTERNAL AGENT", "ADD VERSION") => (vec![self.parse_name_operand()?], vec![]), + (_, "DEPLOY FROM" | "DROP PARTITION LOCATION") => { + (vec![self.parse_location_value()?], vec![]) } - "SET" | "MODIFY LIVE VERSION SET" => properties = self.parse_resource_set()?, - "UNSET" => properties = self.parse_resource_unset()?, - "STOP ALL" => { - if self.match_text_seq(&["OF", "TYPE"]) { - action.push_str(" OF TYPE"); - operands = self.parse_object_name_list()?; - } + (_, "AS") => { + let manifest = self.parse_location_value()?; + (vec![manifest], self.parse_resource_properties()?) } - "DEPLOY FROM" | "DROP PARTITION LOCATION" | "AS" => { - operands.push(self.parse_location_value()?); - if action == "AS" { - properties = self.parse_resource_properties()?; - } + (_, "FROM SPECIFICATION") => (vec![self.parse_object_value()?], vec![]), + (_, "ADD FILES" | "REMOVE FILES") => (vec![self.parse_value_list()?], vec![]), + (_, "ADD TARGETS" | "REMOVE TARGETS") => (vec![self.parse_resource_value()?], vec![]), + ("STAGE", "REFRESH") => (vec![], self.parse_resource_properties()?), + (_, "REFRESH") if self.check(TokenType::String) => { + (vec![self.parse_string_value()?], vec![]) } - "FROM SPECIFICATION" => operands.push(self.parse_object_value()?), - "MODIFY RULE" => { - operands.push(self.parse_name_operand()?); - action.push(' '); - action.push_str(&self.expect_alter_words(&[&["RENAME", "TO"]])?); - operands.push(self.parse_name_operand()?); + ("LISTING", "ADD VERSION") => return self.parse_listing_version(action), + (_, "ADD VERSION" | "MODIFY VERSION" | "VERSION") => { + return self.parse_model_version(action) } - "SET RULE" | "FORCE SET RULE" => { - operands.push(self.parse_name_operand()?); - properties = self.parse_resource_set()?; + (_, "FROM") => self.parse_service_from()?, + ( + _, + "MODIFY RULE" | "SET RULE" | "FORCE SET RULE" | "SET RULES" | "FORCE SET RULES", + ) => return self.parse_routing_rule(action), + _ => return self.parse_resource_clause(action), + }; + Ok((action, parts)) + } + + /// Parse an agent's SET: one property with a literal value. + fn parse_literal_property(&mut self) -> Result { + let name = self.parse_simple_name()?; + self.expect_token(TokenType::Eq)?; + if !self.is_literal_value() { + return Err(self.parse_error("Expected a literal value")); + } + let value = self.parse_object_value()?; + Ok(property(&name.name.to_ascii_uppercase(), None, Some(value))) + } + + /// Parse a listing's ADD VERSION after its keywords: `[ [ IF NOT EXISTS ] ] FROM ` and properties. + fn parse_listing_version(&mut self, mut action: String) -> Result<(String, ActionParts)> { + if self.match_keywords(&[TokenType::If, TokenType::Not, TokenType::Exists]) { + action.push_str(" IF NOT EXISTS"); + } + let mut operands: Vec = Vec::new(); + if !self.check(TokenType::From) { + operands.push(self.parse_name_operand()?); + } + self.expect_token(TokenType::From)?; + operands.push(self.parse_location_value()?); + Ok((action, (operands, self.parse_resource_properties()?))) + } + + /// Parse a model's ADD VERSION, MODIFY VERSION or VERSION after its keywords. + fn parse_model_version(&mut self, mut action: String) -> Result<(String, ActionParts)> { + let mut operands = vec![self.parse_name_operand()?]; + match action.as_str() { + "MODIFY VERSION" => { + self.expect_alter_words(&[&["SET"]])?; + action.push_str(" SET"); + return Ok((action, (operands, self.parse_resource_set()?))); } - "SET RULES" | "FORCE SET RULES" => loop { - let rule = self.parse_simple_name()?; - self.expect_token(TokenType::Eq)?; - properties.push(property("RULE", Some(rule), Some(self.parse_value_list()?))); - if !self.match_token(TokenType::Comma) { - break; - } - }, - "UNSET RULE" | "FORCE UNSET RULE" => operands.push(self.parse_name_operand()?), - "ADD FILES" | "REMOVE FILES" => operands.push(self.parse_value_list()?), - "ADD PARTITION" => { - self.expect_token(TokenType::LParen)?; - loop { - let column = self.parse_simple_name()?; + "VERSION" => { + let alias = self.expect_alter_words(&[&["SET", "ALIAS"], &["UNSET", "ALIAS"]])?; + if let "SET ALIAS" = alias.as_str() { self.expect_token(TokenType::Eq)?; - properties.push(property( - "PARTITION", - Some(column), - Some(self.parse_object_value()?), - )); - if !self.match_token(TokenType::Comma) { - break; - } - } - self.expect_token(TokenType::RParen)?; - action.push_str(" LOCATION"); - self.expect_alter_words(&[&["LOCATION"]])?; - operands.push(self.parse_location_value()?); - } - "REFRESH" if kind == "STAGE" => properties = self.parse_resource_properties()?, - "REFRESH" => { - if self.check(TokenType::String) { - operands.push(self.parse_string_value()?); - } - } - "ADD TARGETS" | "REMOVE TARGETS" => operands.push(self.parse_resource_value()?), - "ADD VERSION" if kind == "EXTERNAL AGENT" => operands.push(self.parse_name_operand()?), - "ADD VERSION" if kind == "LISTING" => { - if self.match_keywords(&[TokenType::If, TokenType::Not, TokenType::Exists]) { - action.push_str(" IF NOT EXISTS"); - } - if !self.check(TokenType::From) { operands.push(self.parse_name_operand()?); } - self.expect_token(TokenType::From)?; - operands.push(self.parse_location_value()?); - properties = self.parse_resource_properties()?; + action.push(' '); + action.push_str(&alias); } - "ADD VERSION" => { - operands.push(self.parse_name_operand()?); + _ => { self.expect_token(TokenType::From)?; if self.match_identifier("MODEL") { action.push_str(" FROM MODEL"); @@ -275,47 +274,87 @@ impl Parser { operands.push(self.parse_location_value()?); } } - "MODIFY VERSION" => { - operands.push(self.parse_name_operand()?); - self.expect_alter_words(&[&["SET"]])?; - action.push_str(" SET"); - properties = self.parse_resource_set()?; + } + Ok((action, (operands, vec![]))) + } + + /// Parse a service's FROM: an inline specification, or an optional stage path and the source's properties. + fn parse_service_from(&mut self) -> Result { + let staged = self.is_stage_path(); + if !staged && !self.check(TokenType::Identifier) && !self.check(TokenType::Var) { + return Ok((vec![self.parse_object_value()?], vec![])); + } + let operands = if staged { + vec![self.parse_stage_path()?] + } else { + Vec::new() + }; + let properties = self.parse_service_source()?; + if properties.is_empty() && operands.is_empty() { + return Err(self.parse_error("Expected a specification")); + } + Ok((operands, properties)) + } + + /// Parse an event routing table's rule action after its keywords. + fn parse_routing_rule(&mut self, mut action: String) -> Result<(String, ActionParts)> { + if action.ends_with("RULES") { + let mut properties: Vec = Vec::new(); + loop { + let rule = self.parse_simple_name()?; + self.expect_token(TokenType::Eq)?; + properties.push(property("RULE", Some(rule), Some(self.parse_value_list()?))); + if !self.match_token(TokenType::Comma) { + return Ok((action, (vec![], properties))); + } } - "VERSION" => { - operands.push(self.parse_name_operand()?); + } + let mut operands = vec![self.parse_name_operand()?]; + if action.starts_with("MODIFY") { + action.push(' '); + action.push_str(&self.expect_alter_words(&[&["RENAME", "TO"]])?); + operands.push(self.parse_name_operand()?); + return Ok((action, (operands, vec![]))); + } + Ok((action, (operands, self.parse_resource_set()?))) + } + + /// Parse the rest of the remaining actions: STOP ALL, EXECUTE AS, segment columns, partitions and volumes. + fn parse_resource_clause(&mut self, mut action: String) -> Result<(String, ActionParts)> { + let mut operands: Vec = Vec::new(); + let mut properties: Vec = Vec::new(); + match action.as_str() { + "STOP ALL" if self.match_text_seq(&["OF", "TYPE"]) => { + action.push_str(" OF TYPE"); + operands = self.parse_object_name_list()?; + } + "EXECUTE AS" => { + let caller = + self.expect_alter_words(&[&["OWNER"], &["CALLER"], &["RESTRICTED", "CALLER"]])?; action.push(' '); - action - .push_str(&self.expect_alter_words(&[&["SET", "ALIAS"], &["UNSET", "ALIAS"]])?); - if action.starts_with("VERSION SET") { - self.expect_token(TokenType::Eq)?; - operands.push(self.parse_name_operand()?); - } + action.push_str(&caller); } "ADD" | "DROP" => { - action.push_str(" SEGMENT_COLUMN"); self.expect_alter_words(&[&["SEGMENT_COLUMN"]])?; + action.push_str(" SEGMENT_COLUMN"); self.expect_token(TokenType::Eq)?; operands.push(self.parse_object_value()?); } - "EXECUTE AS" => { - action.push(' '); - action.push_str(&self.expect_alter_words(&[ - &["OWNER"], - &["CALLER"], - &["RESTRICTED", "CALLER"], - ])?); - } - "FROM" => { - if self.is_stage_path() { - operands.push(self.parse_stage_path()?); - } else if !self.check(TokenType::Identifier) && !self.check(TokenType::Var) { - operands.push(self.parse_object_value()?); - return Ok((action, (operands, properties))); - } - properties = self.parse_service_source()?; - if properties.is_empty() && operands.is_empty() { - return Err(self.parse_error("Expected a specification")); + "ADD PARTITION" => { + self.expect_token(TokenType::LParen)?; + loop { + let column = self.parse_simple_name()?; + self.expect_token(TokenType::Eq)?; + let value = self.parse_object_value()?; + properties.push(property("PARTITION", Some(column), Some(value))); + if !self.match_token(TokenType::Comma) { + break; + } } + self.expect_token(TokenType::RParen)?; + self.expect_alter_words(&[&["LOCATION"]])?; + action.push_str(" LOCATION"); + operands.push(self.parse_location_value()?); } "RESTORE VOLUME" => { operands.push(self.parse_name_operand()?); @@ -436,10 +475,9 @@ impl Parser { loop { let name = self.parse_simple_qualified_name()?; self.expect_token(TokenType::Eq)?; - let value = if word == "CONTACT" { - self.parse_name_operand()? - } else { - self.parse_object_value()? + let value = match word { + "CONTACT" => self.parse_name_operand()?, + _ => self.parse_object_value()?, }; properties.push(property(word, Some(name), Some(value))); if !self.match_token(TokenType::Comma) { From 6ca671e1e4565f54666fccf9375bc0916fa9230f Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:40:58 +0000 Subject: [PATCH 4/4] fix(syntax): parse Snowflake ALTER of pipes, streams, volumes, datasets and materialized views ALTER ARTIFACT REPOSITORY, DATASET, EXTERNAL VOLUME, MATERIALIZED VIEW, PIPE, RESTRICTED SESSION SCOPE (and RSS), STREAM and TYPE parse into AlterObject through the resource kinds. Following the recorded receipts: a restricted session scope adds and removes its body without AS (ADD AS is errno 1003), a dataset version comes from a query, and ARTIFACT, MATERIALIZED, RESTRICTED SESSION and VOLUME alone are no kind. --- .../_helpers/ddl/snowflake_alter_resources.rs | 111 +++++++++++++++++- .../tests/parser/test_snowflake_alter.rs | 24 ++++ 2 files changed, 131 insertions(+), 4 deletions(-) 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 d4a92ca4a..c9a8d6a2d 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 @@ -12,6 +12,10 @@ use crate::tokens::types::Result; /// The object kinds this module parses, as the words that follow ALTER. const RESOURCE_KINDS: &[&[&str]] = &[ + &["RESTRICTED", "SESSION", "SCOPE"], + &["ARTIFACT", "REPOSITORY"], + &["EXTERNAL", "VOLUME"], + &["MATERIALIZED", "VIEW"], &["COMPUTE", "POOL"], &["DBT", "PROJECT"], &["DCM", "PROJECT"], @@ -22,6 +26,11 @@ const RESOURCE_KINDS: &[&[&str]] = &[ &["GIT", "REPOSITORY"], &["MODEL", "MONITOR"], &["AGENT"], + &["DATASET"], + &["PIPE"], + &["RSS"], + &["STREAM"], + &["TYPE"], &["CONTACT"], &["GATEWAY"], &["LISTING"], @@ -117,7 +126,35 @@ fn resource_actions(kind: &str) -> &'static [&'static [&'static str]] { &["SET"], &["UNSET"], ], - "STAGE" => &[&["REFRESH"], &["RENAME", "TO"], &["SET"], &["UNSET"]], + "STAGE" | "PIPE" => &[&["REFRESH"], &["RENAME", "TO"], &["SET"], &["UNSET"]], + "RESTRICTED SESSION SCOPE" | "RSS" => &[ + &["ADD"], + &["REMOVE"], + &["AS"], + &["RENAME", "TO"], + &["SET"], + &["UNSET"], + ], + "EXTERNAL VOLUME" => &[ + &["ADD", "STORAGE_LOCATION"], + &["REMOVE", "STORAGE_LOCATION"], + &["UPDATE", "STORAGE_LOCATION"], + &["RENAME", "TO"], + &["SET"], + &["UNSET"], + ], + "DATASET" => &[&["ADD", "VERSION"], &["DROP", "VERSION"]], + "MATERIALIZED VIEW" => &[ + &["CLUSTER", "BY"], + &["DROP", "CLUSTERING", "KEY"], + &["SUSPEND", "RECLUSTER"], + &["RESUME", "RECLUSTER"], + &["SUSPEND"], + &["RESUME"], + &["RENAME", "TO"], + &["SET"], + &["UNSET"], + ], _ => &[&["RENAME", "TO"], &["SET"], &["UNSET"]], } } @@ -164,9 +201,16 @@ impl Parser { | ("DBT" | "DCM", "PROJECT") | ("GIT", "REPOSITORY") | ("FILE", "FORMAT") + | ("ARTIFACT", "REPOSITORY") + | ("MATERIALIZED", "VIEW") | ("EXTERNAL", "AGENT" | "TABLE" | "VOLUME" | "CONSUMER" | "ACCESS") => true, ("EVENT", "ROUTING") => matches!(self.text_at(2).as_deref(), Some("TABLE")), - ("COMPUTE" | "DBT" | "DCM" | "GIT" | "FILE" | "EXTERNAL" | "EVENT", _) => false, + ("RESTRICTED", "SESSION") => matches!(self.text_at(2).as_deref(), Some("SCOPE")), + ( + "COMPUTE" | "DBT" | "DCM" | "GIT" | "FILE" | "EXTERNAL" | "EVENT" | "ARTIFACT" + | "MATERIALIZED" | "RESTRICTED" | "VOLUME", + _, + ) => false, _ => return None, }; (!complete).then(|| format!("{first} {second}").trim_end().to_owned()) @@ -180,6 +224,15 @@ impl Parser { ) -> Result<(String, ActionParts)> { let key = action.clone(); let parts = match (kind, key.as_str()) { + ("MATERIALIZED VIEW", "SET") if self.check_identifier("SECURE") => { + self.skip(); + let mut properties = vec![property("SECURE", None, None)]; + properties.extend(self.parse_resource_properties()?); + (vec![], properties) + } + ("DATASET", "DROP VERSION") | (_, "REMOVE STORAGE_LOCATION") => { + (vec![self.parse_string_value()?], vec![]) + } ("AGENT", "SET") if !self.check_identifier("TAG") => { (vec![], vec![self.parse_literal_property()?]) } @@ -194,14 +247,28 @@ impl Parser { (_, "DEPLOY FROM" | "DROP PARTITION LOCATION") => { (vec![self.parse_location_value()?], vec![]) } - (_, "AS") => { + ("LISTING", "AS") => { let manifest = self.parse_location_value()?; (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 TARGETS" | "REMOVE TARGETS") => (vec![self.parse_resource_value()?], vec![]), - ("STAGE", "REFRESH") => (vec![], self.parse_resource_properties()?), + ("STAGE" | "PIPE", "REFRESH") => (vec![], self.parse_resource_properties()?), + ("RESTRICTED SESSION SCOPE" | "RSS", "ADD" | "REMOVE" | "AS") => { + (vec![self.parse_text_value()?], vec![]) + } + ("DATASET", "ADD VERSION") => return self.parse_dataset_version(action), + (_, "ADD STORAGE_LOCATION") => { + self.expect_token(TokenType::Eq)?; + (vec![self.parse_value_list()?], vec![]) + } + (_, "UPDATE STORAGE_LOCATION") => { + self.expect_token(TokenType::Eq)?; + let location = self.parse_object_value()?; + (vec![location], self.parse_resource_set()?) + } + (_, "CLUSTER BY") => (vec![self.parse_expression_list_in_parens()?], vec![]), (_, "REFRESH") if self.check(TokenType::String) => { (vec![self.parse_string_value()?], vec![]) } @@ -219,6 +286,34 @@ impl Parser { Ok((action, parts)) } + /// Parse a dataset's ADD VERSION after its keywords: `'' FROM ( ) [ PARTITION BY ]` and properties. + fn parse_dataset_version(&mut self, mut action: String) -> Result<(String, ActionParts)> { + let mut operands = vec![self.parse_string_value()?]; + self.expect_token(TokenType::From)?; + self.expect_token(TokenType::LParen)?; + if !self.check(TokenType::Select) && !self.check(TokenType::With) { + return Err(self.parse_error("Expected a query")); + } + operands.push(self.parse_statement()?); + self.expect_token(TokenType::RParen)?; + if self.match_keywords(&[TokenType::Partition, TokenType::By]) { + action.push_str(" PARTITION BY"); + operands.push(self.parse_expression()?); + } + Ok((action, (operands, self.parse_resource_properties()?))) + } + + /// Parse `( [ , ... ] )` as one tuple. + fn parse_expression_list_in_parens(&mut self) -> Result { + self.expect_token(TokenType::LParen)?; + let mut expressions = vec![self.parse_expression()?]; + while self.match_token(TokenType::Comma) { + expressions.push(self.parse_expression()?); + } + self.expect_token(TokenType::RParen)?; + Ok(Expression::Tuple(Box::new(Tuple { expressions }))) + } + /// Parse an agent's SET: one property with a literal value. fn parse_literal_property(&mut self) -> Result { let name = self.parse_simple_name()?; @@ -387,6 +482,14 @@ impl Parser { self.parse_string_value() } + /// Parse a string or dollar-quoted string. + fn parse_text_value(&mut self) -> Result { + if !self.check(TokenType::String) && !self.check(TokenType::DollarString) { + return Err(self.parse_error("Expected a string")); + } + self.parse_primary() + } + /// Whether the current token starts a stage path. fn is_stage_path(&self) -> bool { !self.is_at_end() && self.peek_text().starts_with('@') diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_alter.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_alter.rs index 427cf9749..0a5a35b6d 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_alter.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_alter.rs @@ -233,6 +233,30 @@ fn given_a_snowflake_alter_of_an_object_kind_when_parsing_then_recorded_rejectio dialect: Dialect::Snowflake, expected_accepted: false, }, + AcceptanceTestCase { + description: "a restricted session scope adds its body without AS; ADD AS is refused (recorded errno 1003)", + sql: "ALTER RESTRICTED SESSION SCOPE v1 ADD AS $$m: 1 $$ ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a restricted session scope takes a dollar-quoted definition (recorded semantic error)", + sql: "ALTER RSS v1 AS $$v1 $$ ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a dataset version comes from a query, not an expression (recorded errno 1003)", + sql: "ALTER DATASET v1 ADD VERSION 'm' FROM ( 1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "VOLUME alone is no kind (recorded errno 1003)", + sql: "ALTER VOLUME v1 REMOVE STORAGE_LOCATION 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, AcceptanceTestCase { description: "other dialects keep their ALTER fallback", sql: "ALTER v1 ADD RUN v1",