From 7fdc000a3309ef6ef79dc3c863aca0951ae93edb Mon Sep 17 00:00:00 2001 From: osipovartem Date: Fri, 13 Nov 2026 19:39:20 +0300 Subject: [PATCH] Parse native Snowflake ALTER DATABASE property operations --- src/ast/ddl.rs | 57 +++++ src/ast/mod.rs | 55 +++-- src/ast/spans.rs | 24 +- src/dialect/mod.rs | 9 + src/dialect/snowflake.rs | 4 + src/parser/mod.rs | 83 +++++-- tests/sqlparser_snowflake.rs | 426 +++++++++++++++++++++++++++++++++++ 7 files changed, 618 insertions(+), 40 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index c6804b76ca..a6f73e90cc 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -3794,6 +3794,63 @@ impl fmt::Display for CreateConnector { } } +/// A native Snowflake `ALTER DATABASE` property operation. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AlterDatabaseOperation { + /// Set primitive database properties without an `OPTIONS (...)` wrapper. + SetOptions { + /// Ordered assignments, including duplicates; values are not semantically validated. + options: Vec, + }, + /// Reset database properties to their defaults. + UnsetOptions { + /// Ordered property names, including duplicates. + options: Vec, + }, +} + +impl fmt::Display for AlterDatabaseOperation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::SetOptions { options } => write!(f, "SET {}", display_separated(options, " ")), + Self::UnsetOptions { options } => { + write!(f, "UNSET {}", display_comma_separated(options)) + } + } + } +} + +/// A Snowflake `ALTER DATABASE` (`Statement::AlterDatabase`) statement. +/// +/// See [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/alter-database). +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct AlterDatabase { + /// The account-level database name, not a table relation. + pub name: ObjectName, + /// Whether `IF EXISTS` was specified. + pub if_exists: bool, + /// The property operations to perform on the database. + pub operations: Vec, +} + +impl fmt::Display for AlterDatabase { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "ALTER DATABASE ")?; + if self.if_exists { + write!(f, "IF EXISTS ")?; + } + write!(f, "{}", self.name)?; + for operation in &self.operations { + write!(f, " {operation}")?; + } + Ok(()) + } +} + /// An `ALTER SCHEMA` (`Statement::AlterSchema`) operation. /// /// See [BigQuery](https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#alter_schema_collate_statement) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 41cc7b546c..4b1d8c3c2a 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -67,27 +67,28 @@ pub use self::dcl::{ }; pub use self::ddl::{ Alignment, AlterCollation, AlterCollationOperation, AlterColumnOperation, AlterConnectorOwner, - AlterFunction, AlterFunctionAction, AlterFunctionKind, AlterFunctionOperation, - AlterIndexOperation, AlterOperator, AlterOperatorClass, AlterOperatorClassOperation, - AlterOperatorFamily, AlterOperatorFamilyOperation, AlterOperatorOperation, AlterPolicy, - AlterPolicyOperation, AlterSchema, AlterSchemaOperation, AlterTable, AlterTableAlgorithm, - AlterTableLock, AlterTableOperation, AlterTableType, AlterType, AlterTypeAddValue, - AlterTypeAddValuePosition, AlterTypeOperation, AlterTypeRename, AlterTypeRenameValue, - ClusteredBy, ColumnDef, ColumnOption, ColumnOptionDef, ColumnOptions, ColumnPolicy, - ColumnPolicyProperty, ConstraintCharacteristics, CreateCollation, CreateCollationDefinition, - CreateConnector, CreateDomain, CreateExtension, CreateFunction, CreateIndex, CreateOperator, - CreateOperatorClass, CreateOperatorFamily, CreatePolicy, CreatePolicyCommand, CreatePolicyType, - CreateTable, CreateTrigger, CreateView, Deduplicate, DeferrableInitial, DistStyle, - DropBehavior, DropExtension, DropFunction, DropOperator, DropOperatorClass, DropOperatorFamily, - DropOperatorSignature, DropPolicy, DropTrigger, ForValues, FunctionReturnType, GeneratedAs, - GeneratedExpressionMode, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, - IdentityPropertyKind, IdentityPropertyOrder, IndexColumn, IndexOption, IndexType, - KeyOrIndexDisplay, Msck, NullsDistinctOption, OperatorArgTypes, OperatorClassItem, - OperatorFamilyDropItem, OperatorFamilyItem, OperatorOption, OperatorPurpose, Owner, Partition, - PartitionBoundValue, ProcedureParam, ReferentialAction, RenameTableNameKind, ReplicaIdentity, - TagsColumnOption, TriggerObjectKind, Truncate, UserDefinedTypeCompositeAttributeDef, - UserDefinedTypeInternalLength, UserDefinedTypeRangeOption, UserDefinedTypeRepresentation, - UserDefinedTypeSqlDefinitionOption, UserDefinedTypeStorage, ViewColumnDef, + AlterDatabase, AlterDatabaseOperation, AlterFunction, AlterFunctionAction, AlterFunctionKind, + AlterFunctionOperation, AlterIndexOperation, AlterOperator, AlterOperatorClass, + AlterOperatorClassOperation, AlterOperatorFamily, AlterOperatorFamilyOperation, + AlterOperatorOperation, AlterPolicy, AlterPolicyOperation, AlterSchema, AlterSchemaOperation, + AlterTable, AlterTableAlgorithm, AlterTableLock, AlterTableOperation, AlterTableType, + AlterType, AlterTypeAddValue, AlterTypeAddValuePosition, AlterTypeOperation, AlterTypeRename, + AlterTypeRenameValue, ClusteredBy, ColumnDef, ColumnOption, ColumnOptionDef, ColumnOptions, + ColumnPolicy, ColumnPolicyProperty, ConstraintCharacteristics, CreateCollation, + CreateCollationDefinition, CreateConnector, CreateDomain, CreateExtension, CreateFunction, + CreateIndex, CreateOperator, CreateOperatorClass, CreateOperatorFamily, CreatePolicy, + CreatePolicyCommand, CreatePolicyType, CreateTable, CreateTrigger, CreateView, Deduplicate, + DeferrableInitial, DistStyle, DropBehavior, DropExtension, DropFunction, DropOperator, + DropOperatorClass, DropOperatorFamily, DropOperatorSignature, DropPolicy, DropTrigger, + ForValues, FunctionReturnType, GeneratedAs, GeneratedExpressionMode, IdentityParameters, + IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, + IndexColumn, IndexOption, IndexType, KeyOrIndexDisplay, Msck, NullsDistinctOption, + OperatorArgTypes, OperatorClassItem, OperatorFamilyDropItem, OperatorFamilyItem, + OperatorOption, OperatorPurpose, Owner, Partition, PartitionBoundValue, ProcedureParam, + ReferentialAction, RenameTableNameKind, ReplicaIdentity, TagsColumnOption, TriggerObjectKind, + Truncate, UserDefinedTypeCompositeAttributeDef, UserDefinedTypeInternalLength, + UserDefinedTypeRangeOption, UserDefinedTypeRepresentation, UserDefinedTypeSqlDefinitionOption, + UserDefinedTypeStorage, ViewColumnDef, }; pub use self::dml::{ Delete, Insert, Merge, MergeAction, MergeClause, MergeClauseKind, MergeInsertExpr, @@ -3777,6 +3778,11 @@ pub enum Statement { /// See [BigQuery](https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#alter_schema_collate_statement) AlterSchema(AlterSchema), /// ```sql + /// ALTER DATABASE + /// ``` + /// See [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/alter-database) + AlterDatabase(AlterDatabase), + /// ```sql /// ALTER INDEX /// ``` AlterIndex { @@ -6767,6 +6773,7 @@ impl fmt::Display for Statement { Statement::DescribeWarehouse(s) => write!(f, "{s}"), Statement::DropWarehouse(s) => write!(f, "{s}"), Statement::AlterSchema(s) => write!(f, "{s}"), + Statement::AlterDatabase(s) => write!(f, "{s}"), Statement::Vacuum(s) => write!(f, "{s}"), Statement::AlterUser(s) => write!(f, "{s}"), Statement::Reset(s) => write!(f, "{s}"), @@ -12761,6 +12768,12 @@ impl From for Statement { } } +impl From for Statement { + fn from(a: AlterDatabase) -> Self { + Self::AlterDatabase(a) + } +} + impl From for Statement { fn from(a: AlterFunction) -> Self { Self::AlterFunction(a) diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 4842d17a33..c0fe578ce8 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -17,9 +17,9 @@ use crate::{ ast::{ - ddl::AlterSchema, query::SelectItemQualifiedWildcardKind, AlterSchemaOperation, AlterTable, - ColumnOptions, CreateOperator, CreateOperatorClass, CreateOperatorFamily, CreateView, - ExportData, Owner, TypedString, + ddl::AlterSchema, query::SelectItemQualifiedWildcardKind, AlterDatabase, + AlterDatabaseOperation, AlterSchemaOperation, AlterTable, ColumnOptions, CreateOperator, + CreateOperatorClass, CreateOperatorFamily, CreateView, ExportData, Owner, TypedString, }, tokenizer::TokenWithSpan, }; @@ -544,6 +544,7 @@ impl Spanned for Statement { ), Statement::CreateUser(..) => Span::empty(), Statement::AlterSchema(s) => s.span(), + Statement::AlterDatabase(s) => s.span(), Statement::Vacuum(..) => Span::empty(), Statement::AlterUser(..) => Span::empty(), Statement::Reset(..) => Span::empty(), @@ -2474,6 +2475,23 @@ impl Spanned for OpenStatement { } } +impl Spanned for AlterDatabaseOperation { + fn span(&self) -> Span { + match self { + Self::SetOptions { options } => union_spans(options.iter().map(|i| i.span())), + Self::UnsetOptions { options } => union_spans(options.iter().map(|i| i.span)), + } + } +} + +impl Spanned for AlterDatabase { + fn span(&self) -> Span { + union_spans( + core::iter::once(self.name.span()).chain(self.operations.iter().map(|i| i.span())), + ) + } +} + impl Spanned for AlterSchemaOperation { fn span(&self) -> Span { match self { diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 80f544c3af..26361388fe 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1518,6 +1518,15 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if `ALTER DATABASE` supports native `SET key = ` + /// assignments separated by whitespace or commas, and `UNSET key, ...`. + /// This does not enable structured properties or other database operations. + /// + /// [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/alter-database) + fn supports_alter_database_options(&self) -> bool { + false + } + /// Returns true if the dialect supports the `USING` clause in an `ALTER COLUMN` statement. /// Example: /// ```sql diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index e8a7270656..63ccfa9799 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -753,6 +753,10 @@ impl Dialect for SnowflakeDialect { true } + fn supports_alter_database_options(&self) -> bool { + true + } + fn supports_comma_separated_drop_column_list(&self) -> bool { true } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 69fd740046..15fbd225fb 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -10770,6 +10770,10 @@ impl<'a> Parser<'a> { /// Parse an `ALTER ` statement and dispatch to the appropriate alter handler. pub fn parse_alter(&mut self) -> Result { + if self.peek_keyword(Keyword::DATABASE) && self.dialect.supports_alter_database_options() { + self.prev_token(); + return self.parse_alter_database(); + } let object_type = self.expect_one_of_keywords(&[ Keyword::VIEW, Keyword::TYPE, @@ -11543,6 +11547,34 @@ impl<'a> Parser<'a> { }) } + /// Parse a native `ALTER DATABASE` primitive property statement. + pub fn parse_alter_database(&mut self) -> Result { + if !self.dialect.supports_alter_database_options() { + return self.expected_ref( + "dialect supporting ALTER DATABASE properties", + self.peek_token_ref(), + ); + } + self.expect_keywords(&[Keyword::ALTER, Keyword::DATABASE])?; + let if_exists = self.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = self.parse_object_name(false)?; + let operation = if self.parse_keyword(Keyword::SET) { + let options = self.parse_alter_namespace_set_options("database")?; + AlterDatabaseOperation::SetOptions { options } + } else if self.parse_keyword(Keyword::UNSET) { + let options = self.parse_alter_namespace_unset_options("database")?; + AlterDatabaseOperation::UnsetOptions { options } + } else { + return self.expected_ref("ALTER DATABASE property operation", self.peek_token_ref()); + }; + Ok(AlterDatabase { + name, + if_exists, + operations: vec![operation], + } + .into()) + } + /// Parse an `ALTER SCHEMA` statement. /// /// Supports operations such as setting options, renaming, adding/dropping replicas, and changing owner. @@ -11575,18 +11607,11 @@ impl<'a> Parser<'a> { let owner = self.parse_owner()?; AlterSchemaOperation::OwnerTo { owner } } else if self.dialect.supports_alter_schema_options() && self.parse_keyword(Keyword::SET) { - let options = self.parse_alter_schema_set_options()?; + let options = self.parse_alter_namespace_set_options("schema")?; AlterSchemaOperation::SetOptions { options } } else if self.dialect.supports_alter_schema_options() && self.parse_keyword(Keyword::UNSET) { - let options = self.parse_comma_separated_with_trailing_commas( - Self::parse_alter_schema_option_name, - false, - Self::is_reserved_for_column_alias, - )?; - if !matches!(self.peek_token_ref().token, Token::EOF | Token::SemiColon) { - return self.expected_ref("comma or statement end", self.peek_token_ref()); - } + let options = self.parse_alter_namespace_unset_options("schema")?; AlterSchemaOperation::UnsetOptions { options } } else { return self.expected_ref("ALTER SCHEMA operation", self.peek_token_ref()); @@ -11598,21 +11623,45 @@ impl<'a> Parser<'a> { })) } - fn parse_alter_schema_option_name(&mut self) -> Result { + fn parse_alter_namespace_option_name( + &mut self, + object_type: &str, + ) -> Result { if !matches!(self.peek_token_ref().token, Token::Word(_)) || self .peek_one_of_keywords(&[Keyword::TAG, Keyword::CONTACT]) .is_some() { - return self.expected_ref("primitive schema property name", self.peek_token_ref()); + return self.expected_ref( + &format!("primitive {object_type} property name"), + self.peek_token_ref(), + ); } self.parse_identifier() } - fn parse_alter_schema_set_options(&mut self) -> Result, ParserError> { + fn parse_alter_namespace_unset_options( + &mut self, + object_type: &str, + ) -> Result, ParserError> { + let options = self.parse_comma_separated_with_trailing_commas( + |parser| parser.parse_alter_namespace_option_name(object_type), + false, + Self::is_reserved_for_column_alias, + )?; + if !matches!(self.peek_token_ref().token, Token::EOF | Token::SemiColon) { + return self.expected_ref("comma or statement end", self.peek_token_ref()); + } + Ok(options) + } + + fn parse_alter_namespace_set_options( + &mut self, + object_type: &str, + ) -> Result, ParserError> { let mut options = Vec::new(); loop { - let key = self.parse_alter_schema_option_name()?; + let key = self.parse_alter_namespace_option_name(object_type)?; self.expect_token(&Token::Eq)?; let value = match self.peek_token_ref().token { Token::Plus | Token::Minus @@ -11626,8 +11675,10 @@ impl<'a> Parser<'a> { | Token::Placeholder(_) | Token::Colon | Token::AtSign => { - return self - .expected_ref("primitive schema property literal", self.peek_token_ref()); + return self.expected_ref( + &format!("primitive {object_type} property literal"), + self.peek_token_ref(), + ); } _ => Expr::Value(self.parse_value()?), }; @@ -11645,7 +11696,7 @@ impl<'a> Parser<'a> { Token::Word(_) if space_separated => {} _ => { return self.expected_ref( - "schema property separator or statement end", + &format!("{object_type} property separator or statement end"), self.peek_token_ref(), ) } diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index e6642f29a9..622a06eab2 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -7535,3 +7535,429 @@ fn snowflake_alter_schema_options_visitors_do_not_treat_names_as_relations() { assert_eq!(result, ControlFlow::Continue(())); } } + +#[test] +fn snowflake_alter_database_comment_fixtures() { + for (sql, name, if_exists, operation) in [ + ( + "ALTER DATABASE DB_META SET COMMENT = 'database metadata'", + "DB_META", + false, + AlterDatabaseOperation::SetOptions { + options: vec![SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value( + Value::SingleQuotedString("database metadata".into()).into(), + ), + }], + }, + ), + ( + "ALTER DATABASE DB_META UNSET COMMENT", + "DB_META", + false, + AlterDatabaseOperation::UnsetOptions { + options: vec![Ident::new("COMMENT")], + }, + ), + ( + "ALTER DATABASE IF EXISTS DB_META_MISSING SET COMMENT = 'missing'", + "DB_META_MISSING", + true, + AlterDatabaseOperation::SetOptions { + options: vec![SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(Value::SingleQuotedString("missing".into()).into()), + }], + }, + ), + ( + "ALTER DATABASE IF EXISTS DB_META_MISSING UNSET COMMENT", + "DB_META_MISSING", + true, + AlterDatabaseOperation::UnsetOptions { + options: vec![Ident::new("COMMENT")], + }, + ), + ] { + let statement = snowflake().verified_stmt(sql); + let expected = AlterDatabase { + name: ObjectName::from(vec![Ident::new(name)]), + if_exists, + operations: vec![operation], + }; + assert_eq!(statement, Statement::AlterDatabase(expected.clone())); + assert_eq!(Statement::from(expected), statement.clone()); + } +} + +#[test] +fn snowflake_alter_database_options_preserve_primitive_literals_and_duplicates() { + for value in [ + "NULL", + "true", + "false", + "-1", + "+1", + "1.5", + "999999999999999999999999999999", + "''", + "'invalid'", + "$$opaque; -- /* */$$", + ] { + snowflake().verified_stmt(&format!("ALTER DATABASE DB_META SET COMMENT = {value}")); + } + snowflake().one_statement_parses_to( + "ALTER DATABASE DB_META SET COMMENT = TRUE DATA_RETENTION_TIME_IN_DAYS = 1e3", + &format!( + "ALTER DATABASE DB_META SET COMMENT = true DATA_RETENTION_TIME_IN_DAYS = {}", + number("1e3") + ), + ); + let Statement::AlterDatabase(database) = snowflake().one_statement_parses_to( + r#"ALTER /* object */ DATABASE IF EXISTS "DB_META" SET + COMMENT /* key */ = NULL, + DATA_RETENTION_TIME_IN_DAYS = -- value + -1 COMMENT='x'/* separator */COMMENT = 42;"#, + r#"ALTER DATABASE IF EXISTS "DB_META" SET COMMENT = NULL DATA_RETENTION_TIME_IN_DAYS = -1 COMMENT = 'x' COMMENT = 42"#, + ) else { + panic!("expected ALTER DATABASE"); + }; + assert_eq!( + database.operations, + vec![AlterDatabaseOperation::SetOptions { + options: vec![ + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(Value::Null.into()) + }, + SqlOption::KeyValue { + key: Ident::new("DATA_RETENTION_TIME_IN_DAYS"), + value: Expr::UnaryOp { + op: UnaryOperator::Minus, + expr: Box::new(Expr::Value(number("1").into())), + } + }, + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(Value::SingleQuotedString("x".into()).into()) + }, + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(number("42").into()) + }, + ], + }] + ); + snowflake() + .verified_stmt(r#"ALTER DATABASE "DB_META" SET "COMMENT" = 'x' FUTURE_PROPERTY = false"#); + let Statement::AlterDatabase(database) = snowflake().one_statement_parses_to( + r#"ALTER DATABASE DB_META UNSET COMMENT /* comma */, -- property + DATA_RETENTION_TIME_IN_DAYS, COMMENT;"#, + "ALTER DATABASE DB_META UNSET COMMENT, DATA_RETENTION_TIME_IN_DAYS, COMMENT", + ) else { + panic!("expected ALTER DATABASE"); + }; + assert_eq!( + database.operations, + vec![AlterDatabaseOperation::UnsetOptions { + options: vec![ + Ident::new("COMMENT"), + Ident::new("DATA_RETENTION_TIME_IN_DAYS"), + Ident::new("COMMENT") + ], + }] + ); +} + +#[test] +fn snowflake_alter_database_options_reject_malformed_and_complex_forms() { + use sqlparser::parser::Parser; + + for suffix in [ + "", + "SET", + "SET; SELECT 1", + "SET ()", + "SET (COMMENT = 'x')", + "SET OPTIONS (COMMENT = 'x')", + "SET , COMMENT = 'x'", + "SET COMMENT", + "SET COMMENT 'missing equals'", + "SET COMMENT =", + "SET COMMENT =; SELECT 1", + "SET COMMENT = = 'x'", + "SET COMMENT = 'unterminated", + "SET COMMENT = 'x',", + "SET COMMENT = 'x',; SELECT 1", + "SET COMMENT = 'x',, COMMENT = 'y'", + "SET COMMENT = 'x'COMMENT = 'y'", + "SET COMMENT = 1COMMENT = 'y'", + "SET COMMENT = 'x' SELECT 1", + "SET COMMENT = 'x' UNSET COMMENT", + "SET COMMENT = 'x', UNSET COMMENT", + "SET COMMENT = concat('a', 'b')", + "SET COMMENT = (SELECT 'x')", + "SET COMMENT = 'a' || 'b'", + "SET COMMENT = 1 + 1", + "SET COMMENT = (1)", + "SET COMMENT = [1]", + "SET COMMENT = DEFAULT", + "SET COMMENT = ?", + "SET COMMENT = :value", + "SET COMMENT = @value", + "SET COMMENT = +?", + "SET COMMENT = +", + "SET COMMENT = -'1'", + "SET COMMENT = -NULL", + "SET 'COMMENT' = 'x'", + "SET TAG t = 'v'", + "SET TAG = 'v'", + "SET COMMENT = 'x', TAG t = 'v'", + "SET CONTACT STEWARD = person", + "SET COMMENT = 'x' CONTACT STEWARD = person", + "SET FEATURE POLICY policy_name", + "UNSET", + "UNSET; SELECT 1", + "UNSET ()", + "UNSET (COMMENT)", + "UNSET , COMMENT", + "UNSET COMMENT,", + "UNSET COMMENT,; SELECT 1", + "UNSET COMMENT,, COMMENT", + "UNSET COMMENT DATA_RETENTION_TIME_IN_DAYS", + "UNSET COMMENT = 'x'", + "UNSET COMMENT, DATA_RETENTION_TIME_IN_DAYS = 1", + "UNSET 'COMMENT'", + "UNSET 1", + "UNSET TAG", + "UNSET COMMENT, TAG t", + "UNSET CONTACT", + "UNSET CONTACT STEWARD", + "UNSET FEATURE POLICY", + "SWAP WITH other", + "RENAME TO other", + "REFRESH", + "ENABLE REPLICATION TO ACCOUNTS other", + "DISABLE REPLICATION", + "PRIMARY", + "SET COMMENT = 'x' CLONE other", + "SET COMMENT = 'x' RENAME TO other", + ] { + let sql = format!("ALTER DATABASE DB_META {suffix}"); + for trailing_commas in [false, true] { + let result = Parser::new(&SnowflakeDialect) + .with_options(ParserOptions::new().with_trailing_commas(trailing_commas)) + .try_with_sql(&sql) + .and_then(|mut parser| parser.parse_statements()); + assert!(result.is_err(), "trailing_commas={trailing_commas}: {sql}"); + } + } + for sql in [ + "ALTER DATABASE IF EXISTS SET COMMENT = 'x'", + "ALTER DATABASE IF NOT EXISTS DB_META UNSET COMMENT", + ] { + assert!(snowflake().parse_sql_statements(sql).is_err(), "{sql}"); + } +} + +#[test] +fn snowflake_alter_database_options_are_dialect_gated() { + use sqlparser::parser::Parser; + + for dialect in all_dialects().dialects { + assert_eq!( + dialect.supports_alter_database_options(), + dialect.is::() + ); + for sql in [ + "ALTER DATABASE DB_META SET COMMENT = 'x'", + "ALTER DATABASE IF EXISTS DB_META_MISSING UNSET COMMENT", + ] { + assert_eq!( + Parser::parse_sql(&*dialect, sql).is_ok(), + dialect.is::(), + "{dialect:?}: {sql}" + ); + let mut parser = Parser::new(&*dialect).try_with_sql(sql).unwrap(); + assert_eq!( + parser.parse_alter_database().is_ok(), + dialect.is::(), + "direct parser: {dialect:?}: {sql}" + ); + } + } + + #[derive(Debug)] + struct DatabaseOptionsDialect; + impl Dialect for DatabaseOptionsDialect { + fn is_identifier_start(&self, ch: char) -> bool { + ch.is_ascii_alphabetic() || ch == '_' + } + fn is_identifier_part(&self, ch: char) -> bool { + self.is_identifier_start(ch) || ch.is_ascii_digit() + } + fn supports_alter_database_options(&self) -> bool { + true + } + } + assert!(!DatabaseOptionsDialect.supports_alter_schema_options()); + let sql = "ALTER DATABASE DB_META SET COMMENT = 'x', DATA_RETENTION_TIME_IN_DAYS = -1"; + assert_eq!( + Parser::parse_sql(&DatabaseOptionsDialect, sql).unwrap(), + Parser::parse_sql(&SnowflakeDialect, sql).unwrap() + ); +} + +#[test] +fn snowflake_alter_database_options_preserve_database_and_schema_boundaries() { + let statements = snowflake().parse_sql_statements( + r#"ALTER DATABASE "DB_META; DROP DATABASE prod" SET COMMENT = 'x''; DROP DATABASE prod; -- /* */'; + ALTER SCHEMA DB_META.PUBLIC SET COMMENT = $$x'; -- /* */$$; + ALTER DATABASE DB_META UNSET COMMENT; + ALTER SCHEMA DB_META.PUBLIC UNSET COMMENT; + SELECT status FROM status_log;"#, + ).unwrap(); + assert_eq!(statements.len(), 5); + let Statement::AlterDatabase(database) = &statements[0] else { + panic!("expected ALTER DATABASE"); + }; + assert_eq!( + database.name, + ObjectName::from(vec![Ident::with_quote('"', "DB_META; DROP DATABASE prod")]) + ); + assert_eq!( + database.operations, + vec![AlterDatabaseOperation::SetOptions { + options: vec![SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value( + Value::SingleQuotedString("x'; DROP DATABASE prod; -- /* */".into()).into() + ) + }], + }] + ); + assert!(matches!(&statements[1], Statement::AlterSchema(_))); + assert!(matches!(&statements[2], Statement::AlterDatabase(_))); + assert!(matches!(&statements[3], Statement::AlterSchema(_))); + assert_eq!(statements[4].to_string(), "SELECT status FROM status_log"); + for statement in statements { + assert_eq!(snowflake().verified_stmt(&statement.to_string()), statement); + } + all_dialects().verified_stmt("ALTER SCHEMA DB_META.PUBLIC SET OPTIONS (description = 'kept')"); +} + +#[test] +fn snowflake_alter_database_options_spans_and_unconsumed_delimiters() { + use sqlparser::parser::Parser; + + for (sql, operation_span, statement_span) in [ + ( + r#"ALTER DATABASE DB_META SET + COMMENT = 'kept', + FLAG = true"#, + Span::new(Location::new(2, 2), Location::new(3, 13)), + Span::new(Location::new(1, 16), Location::new(3, 13)), + ), + ( + r#"ALTER DATABASE DB_META UNSET + COMMENT, + FLAG"#, + Span::new(Location::new(2, 2), Location::new(3, 6)), + Span::new(Location::new(1, 16), Location::new(3, 6)), + ), + ] { + let statements = Parser::parse_sql(&SnowflakeDialect, sql).unwrap(); + let Statement::AlterDatabase(database) = &statements[0] else { + panic!("expected ALTER DATABASE"); + }; + assert_eq!(database.operations[0].span(), operation_span); + assert_eq!(database.span(), statement_span); + assert_eq!(statements[0].span(), statement_span); + } + for suffix in [ + "SET COMMENT = 'x'", + "UNSET COMMENT", + "SET", + "SET COMMENT", + "SET COMMENT =", + "SET COMMENT = 'x',", + "UNSET", + "UNSET COMMENT,", + ] { + let sql = format!("ALTER DATABASE DB_META {suffix}; SELECT 1"); + let mut parser = Parser::new(&SnowflakeDialect).try_with_sql(&sql).unwrap(); + assert_eq!( + parser.parse_alter_database().is_ok(), + matches!(suffix, "SET COMMENT = 'x'" | "UNSET COMMENT") + ); + assert_eq!(parser.peek_token_ref().token, Token::SemiColon, "{sql}"); + } +} + +#[cfg(all(feature = "serde", feature = "serde_json"))] +#[test] +fn snowflake_alter_database_options_serde_round_trip() { + for (sql, variant) in [ + ( + "ALTER DATABASE IF EXISTS DB_META SET COMMENT = NULL COMMENT = 'kept'", + "SetOptions", + ), + ( + "ALTER DATABASE DB_META UNSET COMMENT, COMMENT", + "UnsetOptions", + ), + ] { + let statement = snowflake().verified_stmt(sql); + let encoded = serde_json::to_value(&statement).unwrap(); + assert!(encoded["AlterDatabase"]["operations"][0][variant]["options"].is_array()); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + statement + ); + } +} + +#[cfg(feature = "visitor")] +#[test] +fn snowflake_alter_database_options_visitors_do_not_treat_names_as_relations() { + use core::ops::ControlFlow; + use sqlparser::ast::{visit_expressions, visit_expressions_mut, visit_relations}; + + let mut statement = snowflake().verified_stmt( + "ALTER DATABASE DB_META SET COMMENT = 'kept' DATA_RETENTION_TIME_IN_DAYS = -1", + ); + let mut expressions = Vec::new(); + let result = visit_expressions(&statement, |expr| { + expressions.push(expr.to_string()); + ControlFlow::<()>::Continue(()) + }); + assert_eq!(result, ControlFlow::Continue(())); + assert_eq!(expressions, vec!["'kept'", "-1", "1"]); + let result = visit_expressions_mut(&mut statement, |expr| { + if let Expr::Value(ValueWithSpan { + value: Value::SingleQuotedString(value), + .. + }) = expr + { + *value = "changed".into(); + } + ControlFlow::<()>::Continue(()) + }); + assert_eq!(result, ControlFlow::Continue(())); + assert_eq!( + statement.to_string(), + "ALTER DATABASE DB_META SET COMMENT = 'changed' DATA_RETENTION_TIME_IN_DAYS = -1" + ); + let unset = snowflake().verified_stmt("ALTER DATABASE DB_META UNSET COMMENT, COMMENT"); + assert_eq!( + visit_expressions(&unset, |_| ControlFlow::Break("unexpected expression")), + ControlFlow::Continue(()) + ); + for statement in [statement, unset] { + assert_eq!( + visit_relations(&statement, |_| ControlFlow::Break("unexpected relation")), + ControlFlow::Continue(()) + ); + } +}