diff --git a/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs b/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs index fdcb2b7ea..0ccfb5f6c 100644 --- a/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs +++ b/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs @@ -1122,6 +1122,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 623be43ee..db77228bc 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 338ba936a..f92b640db 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter.rs @@ -1,10 +1,11 @@ -//! 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; 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; @@ -48,6 +49,28 @@ 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.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()) { @@ -60,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() @@ -91,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() @@ -102,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) @@ -126,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")); } @@ -134,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!( @@ -149,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()?))); @@ -158,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, @@ -245,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()?; @@ -493,10 +516,214 @@ 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), + )) + } + + /// 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, @@ -505,6 +732,7 @@ fn alter_object( Expression::AlterObject(Box::new(AlterObject { kind: kind.to_owned(), name, + arguments: Vec::new(), if_exists, action, operands, @@ -513,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..c9a8d6a2d --- /dev/null +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter_resources.rs @@ -0,0 +1,663 @@ +//! Parser ddl: Snowflake ALTER of schemas, stages, file formats, routines, services and project objects. + +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; + +/// 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"], + &["EVENT", "ROUTING", "TABLE"], + &["EXTERNAL", "AGENT"], + &["EXTERNAL", "TABLE"], + &["FILE", "FORMAT"], + &["GIT", "REPOSITORY"], + &["MODEL", "MONITOR"], + &["AGENT"], + &["DATASET"], + &["PIPE"], + &["RSS"], + &["STREAM"], + &["TYPE"], + &["CONTACT"], + &["GATEWAY"], + &["LISTING"], + &["MODEL"], + &["NOTEBOOK"], + &["PROCEDURE"], + &["SCHEMA"], + &["SERVICE"], + &["STAGE"], +]; + +/// 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" | "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"]], + } +} + +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") + && 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 + .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 = match kind.as_str() { + "PROCEDURE" => self.parse_routine_signature()?, + _ => Vec::new(), + }; + 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")); + }; + 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 = match (first.as_str(), second.as_str()) { + ("COMPUTE", "POOL") + | ("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")), + ("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()) + } + + /// 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()) { + ("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()?]) + } + (_, "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![]) + } + ("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" | "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![]) + } + ("LISTING", "ADD VERSION") => return self.parse_listing_version(action), + (_, "ADD VERSION" | "MODIFY VERSION" | "VERSION") => { + return self.parse_model_version(action) + } + (_, "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 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()?; + 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()?))); + } + "VERSION" => { + let alias = self.expect_alter_words(&[&["SET", "ALIAS"], &["UNSET", "ALIAS"]])?; + if let "SET ALIAS" = alias.as_str() { + self.expect_token(TokenType::Eq)?; + operands.push(self.parse_name_operand()?); + } + action.push(' '); + action.push_str(&alias); + } + _ => { + 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()?); + } + } + } + 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))); + } + } + } + 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(&caller); + } + "ADD" | "DROP" => { + self.expect_alter_words(&[&["SEGMENT_COLUMN"]])?; + action.push_str(" SEGMENT_COLUMN"); + self.expect_token(TokenType::Eq)?; + operands.push(self.parse_object_value()?); + } + "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()?); + 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() + } + + /// 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('@') + } + + /// 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 = 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) { + 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 908397846..0a5a35b6d 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_alter.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_alter.rs @@ -101,6 +101,162 @@ 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: "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: "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", @@ -152,6 +308,36 @@ 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: "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",