From d41f6b9f523523a34bfb8d1aaa4f4c7225d3242c Mon Sep 17 00:00:00 2001 From: Andriy Romanov Date: Wed, 4 Mar 2026 14:18:41 -0800 Subject: [PATCH 1/2] Fix STORAGE LIFECYCLE POLICY for snowflake queries --- src/ast/ddl.rs | 13 ++++++--- src/ast/helpers/stmt_create_table.rs | 17 ++++++++++-- src/ast/mod.rs | 24 +++++++++++++++++ src/ast/spans.rs | 1 + src/dialect/snowflake.rs | 16 ++++++++++- src/keywords.rs | 1 + tests/sqlparser_duckdb.rs | 1 + tests/sqlparser_mssql.rs | 2 ++ tests/sqlparser_postgres.rs | 1 + tests/sqlparser_snowflake.rs | 40 ++++++++++++++++++++++++++++ 10 files changed, 110 insertions(+), 6 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 895959a3d..d3011a3c7 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -48,9 +48,9 @@ use crate::ast::{ HiveFormat, HiveIOFormat, HiveRowFormat, HiveSetLocation, Ident, InitializeKind, MySQLColumnPosition, ObjectName, OnCommit, OneOrManyWithParens, OperateFunctionArg, OrderByExpr, ProjectionSelect, Query, RefreshModeKind, RowAccessPolicy, SequenceOptions, - Spanned, SqlOption, StorageSerializationPolicy, TableVersion, Tag, TriggerEvent, - TriggerExecBody, TriggerObject, TriggerPeriod, TriggerReferencing, Value, ValueWithSpan, - WrappedCollection, + Spanned, SqlOption, StorageLifecyclePolicy, StorageSerializationPolicy, TableVersion, Tag, + TriggerEvent, TriggerExecBody, TriggerObject, TriggerPeriod, TriggerReferencing, Value, + ValueWithSpan, WrappedCollection, }; use crate::display_utils::{DisplayCommaSeparated, Indent, NewLine, SpaceOrNewline}; use crate::keywords::Keyword; @@ -2999,6 +2999,9 @@ pub struct CreateTable { /// Snowflake "WITH ROW ACCESS POLICY" clause /// pub with_row_access_policy: Option, + /// Snowflake "WITH STORAGE LIFECYCLE POLICY" clause + /// + pub with_storage_lifecycle_policy: Option, /// Snowflake "WITH TAG" clause /// pub with_tags: Option>, @@ -3300,6 +3303,10 @@ impl fmt::Display for CreateTable { write!(f, " {row_access_policy}",)?; } + if let Some(storage_lifecycle_policy) = &self.with_storage_lifecycle_policy { + write!(f, " {storage_lifecycle_policy}",)?; + } + if let Some(tag) = &self.with_tags { write!(f, " WITH TAG ({})", display_comma_separated(tag.as_slice()))?; } diff --git a/src/ast/helpers/stmt_create_table.rs b/src/ast/helpers/stmt_create_table.rs index 258f9c835..8190a7bd9 100644 --- a/src/ast/helpers/stmt_create_table.rs +++ b/src/ast/helpers/stmt_create_table.rs @@ -28,8 +28,8 @@ use crate::ast::{ ClusteredBy, ColumnDef, CommentDef, CreateTable, CreateTableLikeKind, CreateTableOptions, DistStyle, Expr, FileFormat, ForValues, HiveDistributionStyle, HiveFormat, Ident, InitializeKind, ObjectName, OnCommit, OneOrManyWithParens, Query, RefreshModeKind, - RowAccessPolicy, Statement, StorageSerializationPolicy, TableConstraint, TableVersion, Tag, - WrappedCollection, + RowAccessPolicy, Statement, StorageLifecyclePolicy, StorageSerializationPolicy, + TableConstraint, TableVersion, Tag, WrappedCollection, }; use crate::parser::ParserError; @@ -147,6 +147,8 @@ pub struct CreateTableBuilder { pub with_aggregation_policy: Option, /// Optional row access policy applied to the table. pub with_row_access_policy: Option, + /// Optional storage lifecycle policy applied to the table. + pub with_storage_lifecycle_policy: Option, /// Optional tags/labels attached to the table metadata. pub with_tags: Option>, /// Optional base location for staged data. @@ -222,6 +224,7 @@ impl CreateTableBuilder { default_ddl_collation: None, with_aggregation_policy: None, with_row_access_policy: None, + with_storage_lifecycle_policy: None, with_tags: None, base_location: None, external_volume: None, @@ -448,6 +451,14 @@ impl CreateTableBuilder { self.with_row_access_policy = with_row_access_policy; self } + /// Attach a storage lifecycle policy to the table. + pub fn with_storage_lifecycle_policy( + mut self, + with_storage_lifecycle_policy: Option, + ) -> Self { + self.with_storage_lifecycle_policy = with_storage_lifecycle_policy; + self + } /// Attach tags/labels to the table metadata. pub fn with_tags(mut self, with_tags: Option>) -> Self { self.with_tags = with_tags; @@ -565,6 +576,7 @@ impl CreateTableBuilder { default_ddl_collation: self.default_ddl_collation, with_aggregation_policy: self.with_aggregation_policy, with_row_access_policy: self.with_row_access_policy, + with_storage_lifecycle_policy: self.with_storage_lifecycle_policy, with_tags: self.with_tags, base_location: self.base_location, external_volume: self.external_volume, @@ -642,6 +654,7 @@ impl From for CreateTableBuilder { default_ddl_collation: table.default_ddl_collation, with_aggregation_policy: table.with_aggregation_policy, with_row_access_policy: table.with_row_access_policy, + with_storage_lifecycle_policy: table.with_storage_lifecycle_policy, with_tags: table.with_tags, base_location: table.base_location, external_volume: table.external_volume, diff --git a/src/ast/mod.rs b/src/ast/mod.rs index e201f7842..d6440542b 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -10350,6 +10350,30 @@ impl Display for RowAccessPolicy { } } +/// Snowflake `[ WITH ] STORAGE LIFECYCLE POLICY ON ( [ , ... ] )` +/// +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct StorageLifecyclePolicy { + /// The fully-qualified policy object name. + pub policy: ObjectName, + /// Column names the policy applies to. + pub on: Vec, +} + +impl Display for StorageLifecyclePolicy { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "WITH STORAGE LIFECYCLE POLICY {} ON ({})", + self.policy, + display_comma_separated(self.on.as_slice()) + ) + } +} + /// Snowflake `WITH TAG ( tag_name = '', ...)` /// /// diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 3f73af408..ed77306c9 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -569,6 +569,7 @@ impl Spanned for CreateTable { default_ddl_collation: _, // string, no span with_aggregation_policy: _, // todo, Snowflake specific with_row_access_policy: _, // todo, Snowflake specific + with_storage_lifecycle_policy: _, // todo, Snowflake specific with_tags: _, // todo, Snowflake specific external_volume: _, // todo, Snowflake specific base_location: _, // todo, Snowflake specific diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index f756c4159..e5f77ee7f 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -33,7 +33,7 @@ use crate::ast::{ IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, InitializeKind, Insert, MultiTableInsertIntoClause, MultiTableInsertType, MultiTableInsertValue, MultiTableInsertValues, MultiTableInsertWhenClause, ObjectName, ObjectNamePart, - RefreshModeKind, RowAccessPolicy, ShowObjects, SqlOption, Statement, + RefreshModeKind, RowAccessPolicy, ShowObjects, SqlOption, Statement, StorageLifecyclePolicy, StorageSerializationPolicy, TableObject, TagsColumnOption, Value, WrappedCollection, }; use crate::dialect::{Dialect, Precedence}; @@ -906,6 +906,7 @@ pub fn parse_create_table( Keyword::WITH => { parser.expect_one_of_keywords(&[ Keyword::AGGREGATION, + Keyword::STORAGE, Keyword::TAG, Keyword::ROW, ])?; @@ -927,6 +928,19 @@ pub fn parse_create_table( builder = builder.with_row_access_policy(Some(RowAccessPolicy::new(policy, columns))) } + Keyword::STORAGE => { + parser.expect_keywords(&[Keyword::LIFECYCLE, Keyword::POLICY])?; + let policy = parser.parse_object_name(false)?; + parser.expect_keyword_is(Keyword::ON)?; + parser.expect_token(&Token::LParen)?; + let columns = parser.parse_comma_separated(|p| p.parse_identifier())?; + parser.expect_token(&Token::RParen)?; + + builder = builder.with_storage_lifecycle_policy(Some(StorageLifecyclePolicy { + policy, + on: columns, + })) + } Keyword::TAG => { parser.expect_token(&Token::LParen)?; let tags = parser.parse_comma_separated(Parser::parse_tag)?; diff --git a/src/keywords.rs b/src/keywords.rs index 37a822270..5a224b6c0 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -573,6 +573,7 @@ define_keywords!( LEFT, LEFTARG, LEVEL, + LIFECYCLE, LIKE, LIKE_REGEX, LIMIT, diff --git a/tests/sqlparser_duckdb.rs b/tests/sqlparser_duckdb.rs index 0512053a4..6b1bdabe6 100644 --- a/tests/sqlparser_duckdb.rs +++ b/tests/sqlparser_duckdb.rs @@ -775,6 +775,7 @@ fn test_duckdb_union_datatype() { default_ddl_collation: Default::default(), with_aggregation_policy: Default::default(), with_row_access_policy: Default::default(), + with_storage_lifecycle_policy: Default::default(), with_tags: Default::default(), base_location: Default::default(), external_volume: Default::default(), diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index aa31b6327..4d4092ea1 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -1993,6 +1993,7 @@ fn parse_create_table_with_valid_options() { default_ddl_collation: None, with_aggregation_policy: None, with_row_access_policy: None, + with_storage_lifecycle_policy: None, with_tags: None, base_location: None, external_volume: None, @@ -2163,6 +2164,7 @@ fn parse_create_table_with_identity_column() { default_ddl_collation: None, with_aggregation_policy: None, with_row_access_policy: None, + with_storage_lifecycle_policy: None, with_tags: None, base_location: None, external_volume: None, diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index c83860ff5..1d879ce62 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -6461,6 +6461,7 @@ fn parse_trigger_related_functions() { default_ddl_collation: None, with_aggregation_policy: None, with_row_access_policy: None, + with_storage_lifecycle_policy: None, with_tags: None, base_location: None, external_volume: None, diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index c51cf3bdf..35770d373 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -286,6 +286,46 @@ fn test_snowflake_create_table_with_row_access_policy() { } } +#[test] +fn test_snowflake_create_table_with_storage_lifecycle_policy() { + // WITH keyword + match snowflake().verified_stmt( + "CREATE TABLE IF NOT EXISTS my_table (a NUMBER(38, 0), b VARIANT) WITH STORAGE LIFECYCLE POLICY dba.global_settings.my_policy ON (a)", + ) { + Statement::CreateTable(CreateTable { + name, + with_storage_lifecycle_policy, + .. + }) => { + assert_eq!("my_table", name.to_string()); + let policy = with_storage_lifecycle_policy.unwrap(); + assert_eq!("dba.global_settings.my_policy", policy.policy.to_string()); + assert_eq!(vec![Ident::new("a")], policy.on); + } + _ => unreachable!(), + } + + // Without WITH keyword + match snowflake() + .parse_sql_statements( + "CREATE TABLE my_table (a NUMBER(38,0)) STORAGE LIFECYCLE POLICY my_policy ON (a, b)", + ) + .unwrap() + .pop() + .unwrap() + { + Statement::CreateTable(CreateTable { + with_storage_lifecycle_policy, + .. + }) => { + let policy = with_storage_lifecycle_policy.unwrap(); + assert_eq!("my_policy", policy.policy.to_string()); + assert_eq!(vec![Ident::new("a"), Ident::new("b")], policy.on); + } + _ => unreachable!(), + } +} + #[test] fn test_snowflake_create_table_with_tag() { match snowflake() From f8e857396ec51c8bb6a69d3b5adb0bd7a27c01f6 Mon Sep 17 00:00:00 2001 From: Andriy Romanov Date: Mon, 9 Mar 2026 17:04:01 -0700 Subject: [PATCH 2/2] Update based on comments --- src/ast/ddl.rs | 2 +- tests/sqlparser_snowflake.rs | 24 +++++------------------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index d3011a3c7..0febeef75 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -2999,7 +2999,7 @@ pub struct CreateTable { /// Snowflake "WITH ROW ACCESS POLICY" clause /// pub with_row_access_policy: Option, - /// Snowflake "WITH STORAGE LIFECYCLE POLICY" clause + /// Snowflake `WITH STORAGE LIFECYCLE POLICY` clause /// pub with_storage_lifecycle_policy: Option, /// Snowflake "WITH TAG" clause diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 35770d373..d418aa0cf 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -305,25 +305,11 @@ fn test_snowflake_create_table_with_storage_lifecycle_policy() { _ => unreachable!(), } - // Without WITH keyword - match snowflake() - .parse_sql_statements( - "CREATE TABLE my_table (a NUMBER(38,0)) STORAGE LIFECYCLE POLICY my_policy ON (a, b)", - ) - .unwrap() - .pop() - .unwrap() - { - Statement::CreateTable(CreateTable { - with_storage_lifecycle_policy, - .. - }) => { - let policy = with_storage_lifecycle_policy.unwrap(); - assert_eq!("my_policy", policy.policy.to_string()); - assert_eq!(vec![Ident::new("a"), Ident::new("b")], policy.on); - } - _ => unreachable!(), - } + // Without WITH keyword — canonicalizes to WITH form + snowflake().one_statement_parses_to( + "CREATE TABLE my_table (a NUMBER(38, 0)) STORAGE LIFECYCLE POLICY my_policy ON (a, b)", + "CREATE TABLE my_table (a NUMBER(38, 0)) WITH STORAGE LIFECYCLE POLICY my_policy ON (a, b)", + ); } #[test]