From c27895756c134772a9c09508b4e1e8bdbf2731cc Mon Sep 17 00:00:00 2001 From: Yoav Cohen Date: Mon, 21 Jul 2025 09:50:15 +0300 Subject: [PATCH 1/3] Snowflake: CREATE DYNAMIC TABLE --- src/ast/dml.rs | 45 +++++++++++++- src/ast/helpers/stmt_create_table.rs | 90 +++++++++++++++++++++------- src/ast/mod.rs | 42 +++++++++++++ src/ast/spans.rs | 7 +++ src/dialect/snowflake.rs | 66 +++++++++++++++----- src/keywords.rs | 8 +++ tests/sqlparser_duckdb.rs | 9 ++- tests/sqlparser_mssql.rs | 18 +++++- tests/sqlparser_postgres.rs | 9 ++- tests/sqlparser_snowflake.rs | 88 +++++++++++++++------------ 10 files changed, 303 insertions(+), 79 deletions(-) diff --git a/src/ast/dml.rs b/src/ast/dml.rs index e179f5d70..3a97a082f 100644 --- a/src/ast/dml.rs +++ b/src/ast/dml.rs @@ -29,7 +29,10 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "visitor")] use sqlparser_derive::{Visit, VisitMut}; -use crate::display_utils::{indented_list, DisplayCommaSeparated, Indent, NewLine, SpaceOrNewline}; +use crate::{ + ast::{InitializeKind, RefreshModeKind, TableVersion}, + display_utils::{indented_list, DisplayCommaSeparated, Indent, NewLine, SpaceOrNewline}, +}; pub use super::ddl::{ColumnDef, TableConstraint}; @@ -135,6 +138,7 @@ pub struct CreateTable { pub or_replace: bool, pub temporary: bool, pub external: bool, + pub dynamic: bool, pub global: Option, pub if_not_exists: bool, pub transient: bool, @@ -155,6 +159,7 @@ pub struct CreateTable { pub without_rowid: bool, pub like: Option, pub clone: Option, + pub version: Option, // For Hive dialect, the table comment is after the column definitions without `=`, // so the `comment` field is optional and different than the comment field in the general options list. // [Hive](https://cwiki.apache.org/confluence/display/Hive/LanguageManual+DDL#LanguageManualDDL-CreateTable) @@ -232,6 +237,21 @@ pub struct CreateTable { /// Snowflake "STORAGE_SERIALIZATION_POLICY" clause for Iceberg tables /// pub storage_serialization_policy: Option, + /// Snowflake "TARGET_LAG" clause for dybamic tables + /// + pub target_lag: Option, + /// Snowflake "WAREHOUSE" clause for dybamic tables + /// + pub warehouse: Option, + /// Snowflake "REFRESH_MODE" clause for dybamic tables + /// + pub refresh_mode: Option, + /// Snowflake "INITIALIZE" clause for dybamic tables + /// + pub initialize: Option, + /// Snowflake "REQUIRE USER" clause for dybamic tables + /// + pub require_user: bool, } impl Display for CreateTable { @@ -245,7 +265,7 @@ impl Display for CreateTable { // `CREATE TABLE t (a INT) AS SELECT a from t2` write!( f, - "CREATE {or_replace}{external}{global}{temporary}{transient}{volatile}{iceberg}TABLE {if_not_exists}{name}", + "CREATE {or_replace}{external}{global}{temporary}{transient}{volatile}{iceberg}{dynamic}TABLE {if_not_exists}{name}", or_replace = if self.or_replace { "OR REPLACE " } else { "" }, external = if self.external { "EXTERNAL " } else { "" }, global = self.global @@ -263,6 +283,7 @@ impl Display for CreateTable { volatile = if self.volatile { "VOLATILE " } else { "" }, // Only for Snowflake iceberg = if self.iceberg { "ICEBERG " } else { "" }, + dynamic = if self.dynamic { "DYNAMIC " } else { "" }, name = self.name, )?; if let Some(on_cluster) = &self.on_cluster { @@ -480,6 +501,26 @@ impl Display for CreateTable { write!(f, " WITH TAG ({})", display_comma_separated(tag.as_slice()))?; } + if let Some(target_lag) = &self.target_lag { + write!(f, " TARGET_LAG='{target_lag}'")?; + } + + if let Some(warehouse) = &self.warehouse { + write!(f, " WAREHOUSE={warehouse}")?; + } + + if let Some(refresh_mode) = &self.refresh_mode { + write!(f, " REFRESH_MODE={refresh_mode}")?; + } + + if let Some(initialize) = &self.initialize { + write!(f, " INITIALIZE={initialize}")?; + } + + if self.require_user { + write!(f, " REQUIRE USER")?; + } + if self.on_commit.is_some() { let on_commit = match self.on_commit { Some(OnCommit::DeleteRows) => "ON COMMIT DELETE ROWS", diff --git a/src/ast/helpers/stmt_create_table.rs b/src/ast/helpers/stmt_create_table.rs index 60b8fb2a0..68d641da4 100644 --- a/src/ast/helpers/stmt_create_table.rs +++ b/src/ast/helpers/stmt_create_table.rs @@ -27,9 +27,9 @@ use sqlparser_derive::{Visit, VisitMut}; use super::super::dml::CreateTable; use crate::ast::{ ClusteredBy, ColumnDef, CommentDef, CreateTableOptions, Expr, FileFormat, - HiveDistributionStyle, HiveFormat, Ident, ObjectName, OnCommit, OneOrManyWithParens, Query, - RowAccessPolicy, Statement, StorageSerializationPolicy, TableConstraint, Tag, - WrappedCollection, + HiveDistributionStyle, HiveFormat, Ident, InitializeKind, ObjectName, OnCommit, + OneOrManyWithParens, Query, RefreshModeKind, RowAccessPolicy, Statement, + StorageSerializationPolicy, TableConstraint, TableVersion, Tag, WrappedCollection, }; use crate::parser::ParserError; @@ -73,6 +73,7 @@ pub struct CreateTableBuilder { pub transient: bool, pub volatile: bool, pub iceberg: bool, + pub dynamic: bool, pub name: ObjectName, pub columns: Vec, pub constraints: Vec, @@ -84,6 +85,7 @@ pub struct CreateTableBuilder { pub without_rowid: bool, pub like: Option, pub clone: Option, + pub version: Option, pub comment: Option, pub on_commit: Option, pub on_cluster: Option, @@ -109,6 +111,11 @@ pub struct CreateTableBuilder { pub catalog_sync: Option, pub storage_serialization_policy: Option, pub table_options: CreateTableOptions, + pub target_lag: Option, + pub warehouse: Option, + pub refresh_mode: Option, + pub initialize: Option, + pub require_user: bool, } impl CreateTableBuilder { @@ -122,6 +129,7 @@ impl CreateTableBuilder { transient: false, volatile: false, iceberg: false, + dynamic: false, name, columns: vec![], constraints: vec![], @@ -133,6 +141,7 @@ impl CreateTableBuilder { without_rowid: false, like: None, clone: None, + version: None, comment: None, on_commit: None, on_cluster: None, @@ -158,6 +167,11 @@ impl CreateTableBuilder { catalog_sync: None, storage_serialization_policy: None, table_options: CreateTableOptions::None, + target_lag: None, + warehouse: None, + refresh_mode: None, + initialize: None, + require_user: false, } } pub fn or_replace(mut self, or_replace: bool) -> Self { @@ -200,6 +214,11 @@ impl CreateTableBuilder { self } + pub fn dynamic(mut self, dynamic: bool) -> Self { + self.dynamic = dynamic; + self + } + pub fn columns(mut self, columns: Vec) -> Self { self.columns = columns; self @@ -249,6 +268,11 @@ impl CreateTableBuilder { self } + pub fn version(mut self, version: Option) -> Self { + self.version = version; + self + } + pub fn comment_after_column_def(mut self, comment: Option) -> Self { self.comment = comment; self @@ -383,24 +407,29 @@ impl CreateTableBuilder { self } - /// Returns true if the statement has exactly one source of info on the schema of the new table. - /// This is Snowflake-specific, some dialects allow more than one source. - pub(crate) fn validate_schema_info(&self) -> bool { - let mut sources = 0; - if !self.columns.is_empty() { - sources += 1; - } - if self.query.is_some() { - sources += 1; - } - if self.like.is_some() { - sources += 1; - } - if self.clone.is_some() { - sources += 1; - } + pub fn target_lag(mut self, target_lag: Option) -> Self { + self.target_lag = target_lag; + self + } + + pub fn warehouse(mut self, warehouse: Option) -> Self { + self.warehouse = warehouse; + self + } - sources == 1 + pub fn refresh_mode(mut self, refresh_mode: Option) -> Self { + self.refresh_mode = refresh_mode; + self + } + + pub fn initialize(mut self, initialize: Option) -> Self { + self.initialize = initialize; + self + } + + pub fn require_user(mut self, require_user: bool) -> Self { + self.require_user = require_user; + self } pub fn build(self) -> Statement { @@ -413,6 +442,7 @@ impl CreateTableBuilder { transient: self.transient, volatile: self.volatile, iceberg: self.iceberg, + dynamic: self.dynamic, name: self.name, columns: self.columns, constraints: self.constraints, @@ -424,6 +454,7 @@ impl CreateTableBuilder { without_rowid: self.without_rowid, like: self.like, clone: self.clone, + version: self.version, comment: self.comment, on_commit: self.on_commit, on_cluster: self.on_cluster, @@ -449,6 +480,11 @@ impl CreateTableBuilder { catalog_sync: self.catalog_sync, storage_serialization_policy: self.storage_serialization_policy, table_options: self.table_options, + target_lag: self.target_lag, + warehouse: self.warehouse, + refresh_mode: self.refresh_mode, + initialize: self.initialize, + require_user: self.require_user, }) } } @@ -469,6 +505,7 @@ impl TryFrom for CreateTableBuilder { transient, volatile, iceberg, + dynamic, name, columns, constraints, @@ -480,6 +517,7 @@ impl TryFrom for CreateTableBuilder { without_rowid, like, clone, + version, comment, on_commit, on_cluster, @@ -505,6 +543,11 @@ impl TryFrom for CreateTableBuilder { catalog_sync, storage_serialization_policy, table_options, + target_lag, + warehouse, + refresh_mode, + initialize, + require_user, }) => Ok(Self { or_replace, temporary, @@ -512,6 +555,7 @@ impl TryFrom for CreateTableBuilder { global, if_not_exists, transient, + dynamic, name, columns, constraints, @@ -523,6 +567,7 @@ impl TryFrom for CreateTableBuilder { without_rowid, like, clone, + version, comment, on_commit, on_cluster, @@ -550,6 +595,11 @@ impl TryFrom for CreateTableBuilder { catalog_sync, storage_serialization_policy, table_options, + target_lag, + warehouse, + refresh_mode, + initialize, + require_user, }), _ => Err(ParserError::ParserError(format!( "Expected create table statement, but received: {stmt}" diff --git a/src/ast/mod.rs b/src/ast/mod.rs index d83591364..dc620b7f9 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -10175,6 +10175,48 @@ impl fmt::Display for CreateUser { } } +/// Specifies the refresh mode for the dynamic table. +/// +/// [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/create-dynamic-table) +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum RefreshModeKind { + Auto, + Full, + Incremental, +} + +impl fmt::Display for RefreshModeKind { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + RefreshModeKind::Auto => write!(f, "AUTO"), + RefreshModeKind::Full => write!(f, "FULL"), + RefreshModeKind::Incremental => write!(f, "INCREMENTAL"), + } + } +} + +/// Specifies the behavior of the initial refresh of the dynamic table. +/// +/// [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/create-dynamic-table) +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum InitializeKind { + OnCreate, + OnSchedule, +} + +impl fmt::Display for InitializeKind { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + InitializeKind::OnCreate => write!(f, "ON_CREATE"), + InitializeKind::OnSchedule => write!(f, "ON_SCHEDULE"), + } + } +} + #[cfg(test)] mod tests { use crate::tokenizer::Location; diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 91523925e..4fb94806a 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -563,6 +563,7 @@ impl Spanned for CreateTable { temporary: _, // bool external: _, // bool global: _, // bool + dynamic: _, // bool if_not_exists: _, // bool transient: _, // bool volatile: _, // bool @@ -603,6 +604,12 @@ impl Spanned for CreateTable { catalog_sync: _, // todo, Snowflake specific storage_serialization_policy: _, table_options, + target_lag: _, + warehouse: _, + version: _, + refresh_mode: _, + initialize: _, + require_user: _, } = self; union_spans( diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 26432b3f0..1e2c76188 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -27,8 +27,8 @@ use crate::ast::helpers::stmt_data_loading::{ use crate::ast::{ ColumnOption, ColumnPolicy, ColumnPolicyProperty, CopyIntoSnowflakeKind, DollarQuotedString, Ident, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, - IdentityPropertyOrder, ObjectName, ObjectNamePart, RowAccessPolicy, ShowObjects, SqlOption, - Statement, TagsColumnOption, WrappedCollection, + IdentityPropertyOrder, InitializeKind, ObjectName, ObjectNamePart, RefreshModeKind, + RowAccessPolicy, ShowObjects, SqlOption, Statement, TagsColumnOption, WrappedCollection, }; use crate::dialect::{Dialect, Precedence}; use crate::keywords::Keyword; @@ -158,6 +158,8 @@ impl Dialect for SnowflakeDialect { _ => None, }; + let dynamic = parser.parse_keyword(Keyword::DYNAMIC); + let mut temporary = false; let mut volatile = false; let mut transient = false; @@ -182,7 +184,7 @@ impl Dialect for SnowflakeDialect { return Some(parse_create_stage(or_replace, temporary, parser)); } else if parser.parse_keyword(Keyword::TABLE) { return Some(parse_create_table( - or_replace, global, temporary, volatile, transient, iceberg, parser, + or_replace, global, temporary, volatile, transient, iceberg, dynamic, parser, )); } else { // need to go back with the cursor @@ -526,6 +528,7 @@ fn parse_alter_session(parser: &mut Parser, set: bool) -> Result /// +#[allow(clippy::too_many_arguments)] pub fn parse_create_table( or_replace: bool, global: Option, @@ -533,6 +536,7 @@ pub fn parse_create_table( volatile: bool, transient: bool, iceberg: bool, + dynamic: bool, parser: &mut Parser, ) -> Result { let if_not_exists = parser.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); @@ -546,6 +550,7 @@ pub fn parse_create_table( .volatile(volatile) .iceberg(iceberg) .global(global) + .dynamic(dynamic) .hive_formats(Some(Default::default())); // Snowflake does not enforce order of the parameters in the statement. The parser needs to @@ -697,6 +702,49 @@ pub fn parse_create_table( Keyword::IF if parser.parse_keywords(&[Keyword::NOT, Keyword::EXISTS]) => { builder = builder.if_not_exists(true); } + Keyword::TARGET_LAG => { + parser.expect_token(&Token::Eq)?; + let target_lag = parser.parse_literal_string()?; + builder = builder.target_lag(Some(target_lag)); + } + Keyword::WAREHOUSE => { + parser.expect_token(&Token::Eq)?; + let warehouse = parser.parse_identifier()?; + builder = builder.warehouse(Some(warehouse)); + } + Keyword::AT | Keyword::BEFORE => { + parser.prev_token(); + let version = parser.maybe_parse_table_version()?; + builder = builder.version(version); + } + Keyword::REFRESH_MODE => { + parser.expect_token(&Token::Eq)?; + let refresh_mode = match parser.parse_one_of_keywords(&[ + Keyword::AUTO, + Keyword::FULL, + Keyword::INCREMENTAL, + ]) { + Some(Keyword::AUTO) => Some(RefreshModeKind::Auto), + Some(Keyword::FULL) => Some(RefreshModeKind::Full), + Some(Keyword::INCREMENTAL) => Some(RefreshModeKind::Incremental), + _ => return parser.expected("AUTO, FULL or INCREMENTAL", next_token), + }; + builder = builder.refresh_mode(refresh_mode); + } + Keyword::INITIALIZE => { + parser.expect_token(&Token::Eq)?; + let initialize = match parser + .parse_one_of_keywords(&[Keyword::ON_CREATE, Keyword::ON_SCHEDULE]) + { + Some(Keyword::ON_CREATE) => Some(InitializeKind::OnCreate), + Some(Keyword::ON_SCHEDULE) => Some(InitializeKind::OnSchedule), + _ => return parser.expected("ON_CREATE or ON_SCHEDULE", next_token), + }; + builder = builder.initialize(initialize); + } + Keyword::REQUIRE if parser.parse_keyword(Keyword::USER) => { + builder = builder.require_user(true); + } _ => { return parser.expected("end of statement", next_token); } @@ -707,21 +755,9 @@ pub fn parse_create_table( builder = builder.columns(columns).constraints(constraints); } Token::EOF => { - if !builder.validate_schema_info() { - return Err(ParserError::ParserError( - "unexpected end of input".to_string(), - )); - } - break; } Token::SemiColon => { - if !builder.validate_schema_info() { - return Err(ParserError::ParserError( - "unexpected end of input".to_string(), - )); - } - parser.prev_token(); break; } diff --git a/src/keywords.rs b/src/keywords.rs index 7781939bc..06f5aee20 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -296,6 +296,7 @@ define_keywords!( DOMAIN, DOUBLE, DOW, + DOWNSTREAM, DOY, DROP, DRY, @@ -438,10 +439,12 @@ define_keywords!( INCLUDE, INCLUDE_NULL_VALUES, INCREMENT, + INCREMENTAL, INDEX, INDICATOR, INHERIT, INHERITS, + INITIALIZE, INITIALLY, INNER, INOUT, @@ -633,6 +636,8 @@ define_keywords!( ON, ONE, ONLY, + ON_CREATE, + ON_SCHEDULE, OPEN, OPENJSON, OPERATE, @@ -734,6 +739,7 @@ define_keywords!( REF, REFERENCES, REFERENCING, + REFRESH_MODE, REGCLASS, REGEXP, REGR_AVGX, @@ -759,6 +765,7 @@ define_keywords!( REPLICA, REPLICATE, REPLICATION, + REQUIRE, RESET, RESOLVE, RESOURCE, @@ -895,6 +902,7 @@ define_keywords!( TABLESPACE, TAG, TARGET, + TARGET_LAG, TASK, TBLPROPERTIES, TEMP, diff --git a/tests/sqlparser_duckdb.rs b/tests/sqlparser_duckdb.rs index fe14b7ba5..5bad73365 100644 --- a/tests/sqlparser_duckdb.rs +++ b/tests/sqlparser_duckdb.rs @@ -700,6 +700,7 @@ fn test_duckdb_union_datatype() { transient: Default::default(), volatile: Default::default(), iceberg: Default::default(), + dynamic: Default::default(), name: ObjectName::from(vec!["tbl1".into()]), columns: vec![ ColumnDef { @@ -774,7 +775,13 @@ fn test_duckdb_union_datatype() { catalog: Default::default(), catalog_sync: Default::default(), storage_serialization_policy: Default::default(), - table_options: CreateTableOptions::None + table_options: CreateTableOptions::None, + target_lag: None, + warehouse: None, + version: None, + refresh_mode: None, + initialize: None, + require_user: Default::default(), }), stmt ); diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index 50c6448d9..2cd527267 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -1848,6 +1848,7 @@ fn parse_create_table_with_valid_options() { temporary: false, external: false, global: None, + dynamic: false, if_not_exists: false, transient: false, volatile: false, @@ -1924,7 +1925,13 @@ fn parse_create_table_with_valid_options() { catalog: None, catalog_sync: None, storage_serialization_policy: None, - table_options: CreateTableOptions::With(with_options) + table_options: CreateTableOptions::With(with_options), + target_lag: None, + warehouse: None, + version: None, + refresh_mode: None, + initialize: None, + require_user: false, }) ); } @@ -2031,6 +2038,7 @@ fn parse_create_table_with_identity_column() { temporary: false, external: false, global: None, + dynamic: false, if_not_exists: false, transient: false, volatile: false, @@ -2088,7 +2096,13 @@ fn parse_create_table_with_identity_column() { catalog: None, catalog_sync: None, storage_serialization_policy: None, - table_options: CreateTableOptions::None + table_options: CreateTableOptions::None, + target_lag: None, + warehouse: None, + version: None, + refresh_mode: None, + initialize: None, + require_user: false, }), ); } diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index d163096c4..1e36e3821 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -5839,6 +5839,7 @@ fn parse_trigger_related_functions() { temporary: false, external: false, global: None, + dynamic: false, if_not_exists: false, transient: false, volatile: false, @@ -5904,7 +5905,13 @@ fn parse_trigger_related_functions() { catalog: None, catalog_sync: None, storage_serialization_policy: None, - table_options: CreateTableOptions::None + table_options: CreateTableOptions::None, + target_lag: None, + warehouse: None, + version: None, + refresh_mode: None, + initialize: None, + require_user: false, } ); diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 7edb0d069..f8643627e 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -528,23 +528,6 @@ fn test_snowflake_create_table_comment() { } } -#[test] -fn test_snowflake_create_table_incomplete_statement() { - assert_eq!( - snowflake().parse_sql_statements("CREATE TABLE my_table"), - Err(ParserError::ParserError( - "unexpected end of input".to_string() - )) - ); - - assert_eq!( - snowflake().parse_sql_statements("CREATE TABLE my_table; (c int)"), - Err(ParserError::ParserError( - "unexpected end of input".to_string() - )) - ); -} - #[test] fn test_snowflake_single_line_tokenize() { let sql = "CREATE TABLE# this is a comment \ntable_1"; @@ -1019,27 +1002,6 @@ fn test_snowflake_create_table_trailing_options() { .unwrap(); } -#[test] -fn test_snowflake_create_table_valid_schema_info() { - // Validate there's exactly one source of information on the schema of the new table - assert_eq!( - snowflake() - .parse_sql_statements("CREATE TABLE dst") - .is_err(), - true - ); - assert_eq!( - snowflake().parse_sql_statements("CREATE OR REPLACE TEMP TABLE dst LIKE src AS (SELECT * FROM CUSTOMERS) ON COMMIT PRESERVE ROWS").is_err(), - true - ); - assert_eq!( - snowflake() - .parse_sql_statements("CREATE OR REPLACE TEMP TABLE dst CLONE customers LIKE customer2") - .is_err(), - true - ); -} - #[test] fn parse_sf_create_or_replace_view_with_comment_missing_equal() { assert!(snowflake_and_generic() @@ -1104,6 +1066,56 @@ fn parse_sf_create_table_or_view_with_dollar_quoted_comment() { ); } +#[test] +fn parse_create_dynamic_table() { + snowflake().verified_stmt(r#"CREATE OR REPLACE DYNAMIC TABLE my_dynamic_table TARGET_LAG='20 minutes' WAREHOUSE=mywh AS SELECT product_id, product_name FROM staging_table"#); + snowflake() + .parse_sql_statements( + r#" +CREATE DYNAMIC ICEBERG TABLE my_dynamic_table (date TIMESTAMP_NTZ, id NUMBER, content STRING) + TARGET_LAG = '20 minutes' + WAREHOUSE = mywh + EXTERNAL_VOLUME = 'my_external_volume' + CATALOG = 'SNOWFLAKE' + BASE_LOCATION = 'my_iceberg_table' + AS + SELECT product_id, product_name FROM staging_table; + "#, + ) + .unwrap(); + + snowflake() + .parse_sql_statements( + r#" +CREATE DYNAMIC TABLE my_dynamic_table (date TIMESTAMP_NTZ, id NUMBER, content VARIANT) + TARGET_LAG = '20 minutes' + WAREHOUSE = mywh + CLUSTER BY (date, id) + AS + SELECT product_id, product_name FROM staging_table; + "#, + ) + .unwrap(); + + snowflake().parse_sql_statements(r#" +CREATE DYNAMIC TABLE my_cloned_dynamic_table CLONE my_dynamic_table AT (TIMESTAMP => TO_TIMESTAMP_TZ('04/05/2013 01:02:03', 'mm/dd/yyyy hh24:mi:ss')); + "#).unwrap(); + + snowflake() + .parse_sql_statements( + r#" +CREATE DYNAMIC TABLE my_dynamic_table + TARGET_LAG = 'DOWNSTREAM' + WAREHOUSE = mywh + INITIALIZE = on_schedule + REQUIRE USER + AS + SELECT product_id, product_name FROM staging_table; + "#, + ) + .unwrap(); +} + #[test] fn test_sf_derived_table_in_parenthesis() { // Nesting a subquery in an extra set of parentheses is non-standard, From e6dbd03bee587ad247ec574d80fe4b927995ede8 Mon Sep 17 00:00:00 2001 From: Yoav Cohen Date: Thu, 24 Jul 2025 18:15:36 +0300 Subject: [PATCH 2/3] Fix SQL serialization --- src/ast/dml.rs | 16 +++--- src/ast/query.rs | 6 +-- tests/sqlparser_snowflake.rs | 97 ++++++++++++++++++------------------ 3 files changed, 62 insertions(+), 57 deletions(-) diff --git a/src/ast/dml.rs b/src/ast/dml.rs index 3a97a082f..9ea563b89 100644 --- a/src/ast/dml.rs +++ b/src/ast/dml.rs @@ -265,7 +265,7 @@ impl Display for CreateTable { // `CREATE TABLE t (a INT) AS SELECT a from t2` write!( f, - "CREATE {or_replace}{external}{global}{temporary}{transient}{volatile}{iceberg}{dynamic}TABLE {if_not_exists}{name}", + "CREATE {or_replace}{external}{global}{temporary}{transient}{volatile}{dynamic}{iceberg}TABLE {if_not_exists}{name}", or_replace = if self.or_replace { "OR REPLACE " } else { "" }, external = if self.external { "EXTERNAL " } else { "" }, global = self.global @@ -325,6 +325,10 @@ impl Display for CreateTable { write!(f, " CLONE {c}")?; } + if let Some(version) = &self.version { + write!(f, " {version}")?; + } + match &self.hive_distribution { HiveDistributionStyle::PARTITIONED { columns } => { write!(f, " PARTITIONED BY ({})", display_comma_separated(columns))?; @@ -427,27 +431,27 @@ impl Display for CreateTable { write!(f, " {options}")?; } if let Some(external_volume) = self.external_volume.as_ref() { - write!(f, " EXTERNAL_VOLUME = '{external_volume}'")?; + write!(f, " EXTERNAL_VOLUME='{external_volume}'")?; } if let Some(catalog) = self.catalog.as_ref() { - write!(f, " CATALOG = '{catalog}'")?; + write!(f, " CATALOG='{catalog}'")?; } if self.iceberg { if let Some(base_location) = self.base_location.as_ref() { - write!(f, " BASE_LOCATION = '{base_location}'")?; + write!(f, " BASE_LOCATION='{base_location}'")?; } } if let Some(catalog_sync) = self.catalog_sync.as_ref() { - write!(f, " CATALOG_SYNC = '{catalog_sync}'")?; + write!(f, " CATALOG_SYNC='{catalog_sync}'")?; } if let Some(storage_serialization_policy) = self.storage_serialization_policy.as_ref() { write!( f, - " STORAGE_SERIALIZATION_POLICY = {storage_serialization_policy}" + " STORAGE_SERIALIZATION_POLICY={storage_serialization_policy}" )?; } diff --git a/src/ast/query.rs b/src/ast/query.rs index 7ffb64d9b..473cef27a 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -1883,7 +1883,7 @@ impl fmt::Display for TableFactor { write!(f, " WITH ({})", display_comma_separated(with_hints))?; } if let Some(version) = version { - write!(f, "{version}")?; + write!(f, " {version}")?; } if let Some(TableSampleKind::AfterTableAlias(sample)) = sample { write!(f, " {sample}")?; @@ -2178,8 +2178,8 @@ pub enum TableVersion { impl Display for TableVersion { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { - TableVersion::ForSystemTimeAsOf(e) => write!(f, " FOR SYSTEM_TIME AS OF {e}")?, - TableVersion::Function(func) => write!(f, " {func}")?, + TableVersion::ForSystemTimeAsOf(e) => write!(f, "FOR SYSTEM_TIME AS OF {e}")?, + TableVersion::Function(func) => write!(f, "{func}")?, } Ok(()) } diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index f8643627e..8678d3134 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -906,8 +906,8 @@ fn test_snowflake_create_table_with_several_column_options() { #[test] fn test_snowflake_create_iceberg_table_all_options() { match snowflake().verified_stmt("CREATE ICEBERG TABLE my_table (a INT, b INT) \ - CLUSTER BY (a, b) EXTERNAL_VOLUME = 'volume' CATALOG = 'SNOWFLAKE' BASE_LOCATION = 'relative/path' CATALOG_SYNC = 'OPEN_CATALOG' \ - STORAGE_SERIALIZATION_POLICY = COMPATIBLE COPY GRANTS CHANGE_TRACKING=TRUE DATA_RETENTION_TIME_IN_DAYS=5 MAX_DATA_EXTENSION_TIME_IN_DAYS=10 \ + CLUSTER BY (a, b) EXTERNAL_VOLUME='volume' CATALOG='SNOWFLAKE' BASE_LOCATION='relative/path' CATALOG_SYNC='OPEN_CATALOG' \ + STORAGE_SERIALIZATION_POLICY=COMPATIBLE COPY GRANTS CHANGE_TRACKING=TRUE DATA_RETENTION_TIME_IN_DAYS=5 MAX_DATA_EXTENSION_TIME_IN_DAYS=10 \ WITH AGGREGATION POLICY policy_name WITH ROW ACCESS POLICY policy_name ON (a) WITH TAG (A='TAG A', B='TAG B')") { Statement::CreateTable(CreateTable { name, cluster_by, base_location, @@ -955,7 +955,7 @@ fn test_snowflake_create_iceberg_table_all_options() { #[test] fn test_snowflake_create_iceberg_table() { match snowflake() - .verified_stmt("CREATE ICEBERG TABLE my_table (a INT) BASE_LOCATION = 'relative_path'") + .verified_stmt("CREATE ICEBERG TABLE my_table (a INT) BASE_LOCATION='relative_path'") { Statement::CreateTable(CreateTable { name, @@ -1069,51 +1069,55 @@ fn parse_sf_create_table_or_view_with_dollar_quoted_comment() { #[test] fn parse_create_dynamic_table() { snowflake().verified_stmt(r#"CREATE OR REPLACE DYNAMIC TABLE my_dynamic_table TARGET_LAG='20 minutes' WAREHOUSE=mywh AS SELECT product_id, product_name FROM staging_table"#); - snowflake() - .parse_sql_statements( - r#" -CREATE DYNAMIC ICEBERG TABLE my_dynamic_table (date TIMESTAMP_NTZ, id NUMBER, content STRING) - TARGET_LAG = '20 minutes' - WAREHOUSE = mywh - EXTERNAL_VOLUME = 'my_external_volume' - CATALOG = 'SNOWFLAKE' - BASE_LOCATION = 'my_iceberg_table' - AS - SELECT product_id, product_name FROM staging_table; - "#, - ) - .unwrap(); + snowflake().verified_stmt(concat!( + "CREATE DYNAMIC ICEBERG TABLE my_dynamic_table (date TIMESTAMP_NTZ, id NUMBER, content STRING)", + " EXTERNAL_VOLUME='my_external_volume'", + " CATALOG='SNOWFLAKE'", + " BASE_LOCATION='my_iceberg_table'", + " TARGET_LAG='20 minutes'", + " WAREHOUSE=mywh", + " AS SELECT product_id, product_name FROM staging_table" + )); - snowflake() - .parse_sql_statements( - r#" -CREATE DYNAMIC TABLE my_dynamic_table (date TIMESTAMP_NTZ, id NUMBER, content VARIANT) - TARGET_LAG = '20 minutes' - WAREHOUSE = mywh - CLUSTER BY (date, id) - AS - SELECT product_id, product_name FROM staging_table; - "#, - ) - .unwrap(); + snowflake().verified_stmt(concat!( + "CREATE DYNAMIC TABLE my_dynamic_table (date TIMESTAMP_NTZ, id NUMBER, content VARIANT)", + " CLUSTER BY (date, id)", + " TARGET_LAG='20 minutes'", + " WAREHOUSE=mywh", + " AS SELECT product_id, product_name FROM staging_table" + )); + + snowflake().verified_stmt(concat!( + "CREATE DYNAMIC TABLE my_cloned_dynamic_table", + " CLONE my_dynamic_table", + " AT(TIMESTAMP => TO_TIMESTAMP_TZ('04/05/2013 01:02:03', 'mm/dd/yyyy hh24:mi:ss'))" + )); - snowflake().parse_sql_statements(r#" -CREATE DYNAMIC TABLE my_cloned_dynamic_table CLONE my_dynamic_table AT (TIMESTAMP => TO_TIMESTAMP_TZ('04/05/2013 01:02:03', 'mm/dd/yyyy hh24:mi:ss')); - "#).unwrap(); + snowflake().verified_stmt(concat!( + "CREATE DYNAMIC TABLE my_cloned_dynamic_table", + " CLONE my_dynamic_table", + " BEFORE(OFFSET => TO_TIMESTAMP_TZ('04/05/2013 01:02:03', 'mm/dd/yyyy hh24:mi:ss'))" + )); + + snowflake().verified_stmt(concat!( + "CREATE DYNAMIC TABLE my_dynamic_table", + " TARGET_LAG='DOWNSTREAM'", + " WAREHOUSE=mywh", + " INITIALIZE=ON_SCHEDULE", + " REQUIRE USER", + " AS SELECT product_id, product_name FROM staging_table" + )); + + snowflake().verified_stmt(concat!( + "CREATE DYNAMIC TABLE my_dynamic_table", + " TARGET_LAG='DOWNSTREAM'", + " WAREHOUSE=mywh", + " REFRESH_MODE=AUTO", + " INITIALIZE=ON_SCHEDULE", + " REQUIRE USER", + " AS SELECT product_id, product_name FROM staging_table" + )); - snowflake() - .parse_sql_statements( - r#" -CREATE DYNAMIC TABLE my_dynamic_table - TARGET_LAG = 'DOWNSTREAM' - WAREHOUSE = mywh - INITIALIZE = on_schedule - REQUIRE USER - AS - SELECT product_id, product_name FROM staging_table; - "#, - ) - .unwrap(); } #[test] @@ -4512,7 +4516,4 @@ fn test_snowflake_identifier_function() { .is_err(), true ); - - snowflake().verified_stmt("GRANT ROLE IDENTIFIER('AAA') TO USER IDENTIFIER('AAA')"); - snowflake().verified_stmt("REVOKE ROLE IDENTIFIER('AAA') FROM USER IDENTIFIER('AAA')"); } From e20435797f132309cca273143322f41fef9864a7 Mon Sep 17 00:00:00 2001 From: Yoav Cohen Date: Thu, 24 Jul 2025 18:20:02 +0300 Subject: [PATCH 3/3] Fix format --- tests/sqlparser_snowflake.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 8678d3134..e2cbfccfe 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -1117,7 +1117,6 @@ fn parse_create_dynamic_table() { " REQUIRE USER", " AS SELECT product_id, product_name FROM staging_table" )); - } #[test]