From f3685b358411ba43f0f9b6fa7f50ed9de1b2b541 Mon Sep 17 00:00:00 2001 From: osipovartem Date: Fri, 13 Nov 2026 15:21:35 +0300 Subject: [PATCH] Snowflake: preserve CREATE SCHEMA literal options --- src/ast/mod.rs | 9 + src/dialect/mod.rs | 8 + src/dialect/snowflake.rs | 4 + src/parser/mod.rs | 29 +++ tests/sqlparser_snowflake.rs | 349 +++++++++++++++++++++++++++++++++++ 5 files changed, 399 insertions(+) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 3fd56ffd44..41cc7b546c 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -4417,6 +4417,11 @@ pub enum Statement { /// /// [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/create-clone#databases-schemas) clone: Option, + /// Space-separated Snowflake `COMMENT` and `DATA_RETENTION_TIME_IN_DAYS` assignments. + /// Stored as [`SqlOption::KeyValue`] entries in source order, retaining duplicates + /// and literal values for the execution consumer to validate. + #[cfg_attr(feature = "serde", serde(default))] + schema_options: Vec, }, /// ```sql /// CREATE DATABASE @@ -6217,6 +6222,7 @@ impl fmt::Display for Statement { options, default_collate_spec, clone, + schema_options, } => { write!( f, @@ -6240,6 +6246,9 @@ impl fmt::Display for Statement { if let Some(clone) = clone { write!(f, " CLONE {clone}")?; } + for option in schema_options { + write!(f, " {option}")?; + } Ok(()) } Statement::Assert { condition, message } => { diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 4a2bd234f9..87780fd3dd 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1501,6 +1501,14 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if `CREATE SCHEMA` supports space-separated `COMMENT = ` + /// and `DATA_RETENTION_TIME_IN_DAYS = ` suffix options. + /// + /// [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/create-schema) + fn supports_create_schema_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 d85a33e6ff..db9bee85b9 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -745,6 +745,10 @@ impl Dialect for SnowflakeDialect { true } + fn supports_create_schema_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 6eff8ec3f5..8c40775e27 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5208,6 +5208,18 @@ impl<'a> Parser<'a> { self.parse_create_index(true).map(Into::into) } else if self.parse_keyword(Keyword::VIRTUAL) { self.parse_create_virtual_table() + } else if self.peek_keyword(Keyword::SCHEMA) + && (or_alter + || global.is_some() + || transient + || temporary + || persistent + || create_view_params.is_some()) + { + self.expected_ref( + "an object type supporting the CREATE modifiers", + self.peek_token_ref(), + ) } else if self.parse_keyword(Keyword::SCHEMA) { self.parse_create_schema() } else if self.parse_keyword(Keyword::DATABASE) { @@ -5476,6 +5488,22 @@ impl<'a> Parser<'a> { None }; + let mut schema_options = Vec::new(); + if self.dialect.supports_create_schema_options() { + while self + .peek_one_of_keywords(&[Keyword::COMMENT, Keyword::DATA_RETENTION_TIME_IN_DAYS]) + .is_some() + { + let key = self.parse_identifier()?; + self.expect_token(&Token::Eq)?; + let value = match self.peek_token_ref().token { + Token::Number(..) | Token::Plus | Token::Minus => self.parse_number()?, + _ => Expr::Value(self.parse_value()?), + }; + schema_options.push(SqlOption::KeyValue { key, value }); + } + } + Ok(Statement::CreateSchema { schema_name, if_not_exists, @@ -5483,6 +5511,7 @@ impl<'a> Parser<'a> { options, default_collate_spec, clone, + schema_options, }) } diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index c653c903fa..733e63b847 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -6739,3 +6739,352 @@ fn snowflake_table_comment_null_is_metadata_without_expression_nodes() { let result = visit_expressions(&statement, |_| ControlFlow::Break("unexpected expression")); assert_eq!(result, ControlFlow::Continue(())); } + +#[test] +fn snowflake_create_schema_options() { + for sql in [ + "CREATE SCHEMA demo", + "CREATE SCHEMA demo COMMENT = 'description'", + "CREATE SCHEMA demo COMMENT = ''", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 0", + "CREATE SCHEMA IF NOT EXISTS db.demo DATA_RETENTION_TIME_IN_DAYS = 1 COMMENT = 'description'", + "CREATE SCHEMA db.demo COMMENT = 'description' DATA_RETENTION_TIME_IN_DAYS = 90", + "CREATE SCHEMA db.demo CLONE source.demo DATA_RETENTION_TIME_IN_DAYS = 1 COMMENT = 'clone'", + "CREATE SCHEMA IDENTIFIER('db.demo') COMMENT = 'description'", + ] { + snowflake().verified_stmt(sql); + } + + assert_eq!( + snowflake().verified_stmt( + "CREATE SCHEMA IF NOT EXISTS db.demo COMMENT = 'description' DATA_RETENTION_TIME_IN_DAYS = 7" + ), + Statement::CreateSchema { + schema_name: SchemaName::Simple(ObjectName::from(vec![ + Ident::new("db"), + Ident::new("demo"), + ])), + if_not_exists: true, + with: None, + options: None, + default_collate_spec: None, + clone: None, + schema_options: vec![ + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(Value::SingleQuotedString("description".into()).into()), + }, + SqlOption::KeyValue { + key: Ident::new("DATA_RETENTION_TIME_IN_DAYS"), + value: Expr::Value(Value::Number("7".parse().unwrap(), false).into()), + }, + ], + } + ); +} + +#[test] +fn snowflake_create_schema_options_preserve_duplicates_and_invalid_literals() { + let Statement::CreateSchema { schema_options, .. } = snowflake().verified_stmt( + "CREATE SCHEMA demo COMMENT = NULL DATA_RETENTION_TIME_IN_DAYS = -1 COMMENT = 42 DATA_RETENTION_TIME_IN_DAYS = 'invalid'", + ) else { + panic!("expected CREATE SCHEMA"); + }; + assert_eq!( + schema_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( + Value::Number("1".parse().unwrap(), false).into() + )), + }, + }, + SqlOption::KeyValue { + key: Ident::new("COMMENT"), + value: Expr::Value(Value::Number("42".parse().unwrap(), false).into()), + }, + SqlOption::KeyValue { + key: Ident::new("DATA_RETENTION_TIME_IN_DAYS"), + value: Expr::Value(Value::SingleQuotedString("invalid".into()).into()), + }, + ] + ); + + for value in [ + "-1", + "+1", + "1.5", + "999999999999999999999999999999", + "'1'", + "true", + "false", + "NULL", + ] { + snowflake().verified_stmt(&format!( + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = {value}" + )); + } + snowflake().one_statement_parses_to( + "CREATE SCHEMA demo COMMENT = TRUE", + "CREATE SCHEMA demo COMMENT = true", + ); + + let exponent = number("1e3"); + let Statement::CreateSchema { schema_options, .. } = snowflake().one_statement_parses_to( + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1e3", + &format!("CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = {exponent}"), + ) else { + panic!("expected CREATE SCHEMA"); + }; + assert_eq!( + schema_options, + vec![SqlOption::KeyValue { + key: Ident::new("DATA_RETENTION_TIME_IN_DAYS"), + value: Expr::Value(exponent.into()), + }] + ); +} + +#[test] +fn snowflake_create_schema_options_comments_and_whitespace() { + snowflake().one_statement_parses_to( + r#" + CREATE /* schema */ SCHEMA IF NOT EXISTS "db"."demo" + -- option order is retained + comment /* key */ = /* value */ 'owner''s schema' + data_retention_time_in_days + = -- retention + 7 /* trailing comment */; + "#, + r#"CREATE SCHEMA IF NOT EXISTS "db"."demo" comment = 'owner''s schema' data_retention_time_in_days = 7"#, + ); +} + +#[test] +fn snowflake_create_schema_options_keep_quoted_sql_and_statement_boundaries() { + for sql in [ + r#"CREATE SCHEMA "db;DROP DATABASE prod"."demo" COMMENT = 'x''; DROP SCHEMA prod; -- /* */ DATA_RETENTION_TIME_IN_DAYS = 0' DATA_RETENTION_TIME_IN_DAYS = 7; /* ; ignored */ SELECT 1;"#, + r#"CREATE SCHEMA "db;DROP DATABASE prod"."demo" COMMENT = $$x'; DROP SCHEMA prod; -- /* */ DATA_RETENTION_TIME_IN_DAYS = 0$$ DATA_RETENTION_TIME_IN_DAYS = 7; SELECT 1;"#, + ] { + let statements = snowflake().parse_sql_statements(sql).unwrap(); + assert_eq!(statements.len(), 2); + let Statement::CreateSchema { + schema_name, + schema_options, + .. + } = &statements[0] + else { + panic!("expected CREATE SCHEMA"); + }; + assert_eq!( + *schema_name, + SchemaName::Simple(ObjectName::from(vec![ + Ident::with_quote('"', "db;DROP DATABASE prod"), + Ident::with_quote('"', "demo"), + ])) + ); + let SqlOption::KeyValue { + value: Expr::Value(value), + .. + } = &schema_options[0] + else { + panic!("expected literal COMMENT"); + }; + match &value.value { + Value::SingleQuotedString(value) => assert_eq!( + value, + "x'; DROP SCHEMA prod; -- /* */ DATA_RETENTION_TIME_IN_DAYS = 0" + ), + Value::DollarQuotedString(value) => assert_eq!( + value.value, + "x'; DROP SCHEMA prod; -- /* */ DATA_RETENTION_TIME_IN_DAYS = 0" + ), + value => panic!("unexpected comment: {value:?}"), + } + assert_eq!(schema_options.len(), 2); + assert_eq!(statements[1].to_string(), "SELECT 1"); + snowflake().verified_stmt(&statements[0].to_string()); + } + + for sql in [ + "CREATE SCHEMA demo; SELECT 1", + "CREATE SCHEMA demo COMMENT = 'kept'; SELECT 1", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1; SELECT 1", + "CREATE SCHEMA demo COMMENT = 'kept' DATA_RETENTION_TIME_IN_DAYS = 1; SELECT 1", + ] { + let statements = snowflake().parse_sql_statements(sql).unwrap(); + assert_eq!(statements.len(), 2, "{sql}"); + assert_eq!(statements[1].to_string(), "SELECT 1"); + } +} + +#[test] +fn snowflake_create_schema_options_reject_malformed_suffixes() { + for sql in [ + "CREATE SCHEMA demo COMMENT", + "CREATE SCHEMA demo COMMENT 'missing equals'", + "CREATE SCHEMA demo COMMENT =", + "CREATE SCHEMA demo COMMENT =; SELECT 1", + "CREATE SCHEMA demo COMMENT = 'unterminated", + "CREATE SCHEMA demo COMMENT = = 'extra equals'", + "CREATE SCHEMA demo COMMENT = 'x' COMMENT =", + "CREATE SCHEMA demo COMMENT = DATA_RETENTION_TIME_IN_DAYS = 1", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS 1", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS =", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = +", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = -'1'", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1.5.2", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1, COMMENT = 'x'", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = (1)", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1 + 1", + "CREATE SCHEMA demo COMMENT = 'a' || 'b'", + "CREATE SCHEMA demo COMMENT = concat('a', 'b')", + "CREATE SCHEMA demo COMMENT = (SELECT 'x')", + "CREATE SCHEMA demo UNKNOWN_OPTION = 1", + "CREATE SCHEMA demo COMMENT = 'x' UNKNOWN_OPTION = 1", + "CREATE SCHEMA demo COMMENT = 'x' SELECT 1", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1 CREATE SCHEMA other", + ] { + assert!(snowflake().parse_sql_statements(sql).is_err(), "{sql}"); + } +} + +#[test] +fn snowflake_create_schema_options_do_not_add_other_schema_modifiers() { + use sqlparser::dialect::DuckDbDialect; + use sqlparser::keywords::Keyword; + use sqlparser::parser::Parser; + + for sql in [ + "CREATE OR ALTER SCHEMA demo COMMENT = 'x'", + "CREATE TEMPORARY SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1", + "CREATE OR REPLACE SCHEMA demo", + "CREATE OR REPLACE SCHEMA demo COMMENT = 'x'", + "CREATE TRANSIENT SCHEMA demo", + "CREATE TRANSIENT SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 0", + "CREATE SCHEMA demo WITH MANAGED ACCESS", + "CREATE SCHEMA demo WITH MANAGED ACCESS COMMENT = 'x'", + ] { + assert!(snowflake().parse_sql_statements(sql).is_err(), "{sql}"); + } + + for dialect in [ + &SnowflakeDialect as &dyn Dialect, + &GenericDialect, + &DuckDbDialect, + ] { + for modifier in [ + "OR REPLACE", + "OR ALTER", + "TEMP", + "TEMPORARY", + "LOCAL", + "GLOBAL", + "TRANSIENT", + "PERSISTENT", + "LOCAL TEMPORARY", + "GLOBAL TEMP", + "OR ALTER TEMPORARY", + "ALGORITHM = UNDEFINED", + "DEFINER = owner", + "SQL SECURITY DEFINER", + "SQL SECURITY INVOKER", + "ALGORITHM = MERGE DEFINER = owner SQL SECURITY INVOKER", + ] { + for suffix in ["", " COMMENT = 'x'", " DATA_RETENTION_TIME_IN_DAYS = 1"] { + let sql = format!("CREATE {modifier} SCHEMA demo{suffix}"); + assert!( + Parser::parse_sql(dialect, &sql).is_err(), + "{dialect:?}: {sql}" + ); + + // Exercise consumed modifiers without relying on dialect fallback rejection. + let mut parser = Parser::new(dialect).try_with_sql(&sql).unwrap(); + parser.expect_keyword_is(Keyword::CREATE).unwrap(); + assert!( + parser.parse_create().is_err(), + "shared CREATE: {dialect:?}: {sql}" + ); + } + } + } +} + +#[test] +fn snowflake_create_schema_options_are_dialect_gated() { + use sqlparser::parser::Parser; + + for dialect in all_dialects().dialects { + if dialect.is::() { + assert!(dialect.supports_create_schema_options()); + } else { + assert!(!dialect.supports_create_schema_options()); + for sql in [ + "CREATE SCHEMA demo COMMENT = 'x'", + "CREATE SCHEMA demo DATA_RETENTION_TIME_IN_DAYS = 1", + ] { + assert!( + Parser::parse_sql(&*dialect, sql).is_err(), + "{dialect:?}: {sql}" + ); + } + } + let statements = Parser::parse_sql(&*dialect, "CREATE SCHEMA demo").unwrap(); + let Statement::CreateSchema { schema_options, .. } = &statements[0] else { + panic!("expected CREATE SCHEMA"); + }; + assert!(schema_options.is_empty()); + } + + all_dialects().verified_stmt("CREATE SCHEMA demo AUTHORIZATION owner"); + all_dialects().verified_stmt("CREATE SCHEMA demo WITH (location = '/tmp/demo')"); + all_dialects() + .verified_stmt("CREATE SCHEMA demo DEFAULT COLLATE 'und:ci' OPTIONS(location = 'US')"); +} + +#[cfg(all(feature = "serde", feature = "serde_json"))] +#[test] +fn snowflake_create_schema_options_serde_round_trip_and_default() { + let statement = snowflake().verified_stmt( + "CREATE SCHEMA demo COMMENT = NULL DATA_RETENTION_TIME_IN_DAYS = -1 COMMENT = 'kept'", + ); + let encoded = serde_json::to_string(&statement).unwrap(); + assert_eq!( + serde_json::from_str::(&encoded).unwrap(), + statement + ); + + let plain = snowflake().verified_stmt("CREATE SCHEMA demo"); + let mut old = serde_json::to_value(&plain).unwrap(); + assert!(old["CreateSchema"] + .as_object_mut() + .unwrap() + .remove("schema_options") + .is_some()); + assert_eq!(serde_json::from_value::(old).unwrap(), plain); +} + +#[cfg(feature = "visitor")] +#[test] +fn snowflake_create_schema_options_expose_literal_expressions_to_visitors() { + use core::ops::ControlFlow; + use sqlparser::ast::visit_expressions; + + let statement = snowflake() + .verified_stmt("CREATE SCHEMA demo COMMENT = 'kept' DATA_RETENTION_TIME_IN_DAYS = 7"); + 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'", "7"]); +}