From 068850e127510a3e13a715db3862024a0c45e42b Mon Sep 17 00:00:00 2001 From: sabir Date: Tue, 23 Jun 2026 02:57:48 +0200 Subject: [PATCH 01/59] Snowflake: parse CREATE OR REPLACE ROLE Thread or_replace through parse_create_role and CreateRole AST node so CREATE OR REPLACE ROLE parses and round-trips. --- src/ast/dcl.rs | 5 ++++- src/parser/mod.rs | 9 +++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/ast/dcl.rs b/src/ast/dcl.rs index 3c50a81c06..fcc15c7da3 100644 --- a/src/ast/dcl.rs +++ b/src/ast/dcl.rs @@ -311,6 +311,8 @@ impl fmt::Display for SecondaryRoles { pub struct CreateRole { /// Role names to create. pub names: Vec, + /// Whether `OR REPLACE` was specified. + pub or_replace: bool, /// Whether `IF NOT EXISTS` was specified. pub if_not_exists: bool, // Postgres @@ -353,7 +355,8 @@ impl fmt::Display for CreateRole { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, - "CREATE ROLE {if_not_exists}{names}{superuser}{create_db}{create_role}{inherit}{login}{replication}{bypassrls}", + "CREATE {or_replace}ROLE {if_not_exists}{names}{superuser}{create_db}{create_role}{inherit}{login}{replication}{bypassrls}", + or_replace = if self.or_replace { "OR REPLACE " } else { "" }, if_not_exists = if self.if_not_exists { "IF NOT EXISTS " } else { "" }, names = display_separated(&self.names, ", "), superuser = match self.superuser { diff --git a/src/parser/mod.rs b/src/parser/mod.rs index b44490b461..3120ff4bf3 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5452,9 +5452,11 @@ impl<'a> Parser<'a> { self.parse_create_procedure(or_alter, or_replace) } else if self.parse_keyword(Keyword::SCHEMA) { self.parse_create_schema(or_replace, transient) + } else if self.parse_keyword(Keyword::ROLE) { + self.parse_create_role(or_replace).map(Into::into) } else if or_replace { self.expected_ref( - "[EXTERNAL] TABLE or [MATERIALIZED] VIEW or FUNCTION or WAREHOUSE or TASK or PROCEDURE or SCHEMA after CREATE OR REPLACE", + "[EXTERNAL] TABLE or [MATERIALIZED] VIEW or FUNCTION or WAREHOUSE or TASK or PROCEDURE or SCHEMA or ROLE after CREATE OR REPLACE", self.peek_token_ref(), ) } else if self.parse_keyword(Keyword::EXTENSION) { @@ -5467,8 +5469,6 @@ impl<'a> Parser<'a> { self.parse_create_virtual_table() } else if self.parse_keyword(Keyword::DATABASE) { self.parse_create_database() - } else if self.parse_keyword(Keyword::ROLE) { - self.parse_create_role().map(Into::into) } else if self.parse_keyword(Keyword::SEQUENCE) { self.parse_create_sequence(temporary) } else if self.parse_keyword(Keyword::COLLATION) { @@ -7071,7 +7071,7 @@ impl<'a> Parser<'a> { } /// Parse a `CREATE ROLE` statement. - pub fn parse_create_role(&mut self) -> Result { + pub fn parse_create_role(&mut self, or_replace: bool) -> Result { let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); let names = self.parse_comma_separated(|p| p.parse_object_name(false))?; @@ -7275,6 +7275,7 @@ impl<'a> Parser<'a> { Ok(CreateRole { names, + or_replace, if_not_exists, login, inherit, From a541ba81c2245a0a62bcfb97c4de06b7220cf01e Mon Sep 17 00:00:00 2001 From: sabir Date: Tue, 23 Jun 2026 05:20:21 +0200 Subject: [PATCH 02/59] Snowflake: parse CREATE/ALTER/DROP ROW ACCESS POLICY --- src/ast/ddl.rs | 13 ++++++ src/ast/mod.rs | 97 ++++++++++++++++++++++++++++++++++++++++ src/ast/spans.rs | 7 +++ src/dialect/snowflake.rs | 90 +++++++++++++++++++++++++++++++++++++ src/keywords.rs | 1 + src/parser/mod.rs | 10 +++++ 6 files changed, 218 insertions(+) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index a8821fc5b2..027de1454e 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -459,6 +459,13 @@ pub enum AlterTableOperation { }, /// Remove the clustering key from the table. DropClusteringKey, + /// `DROP ROW ACCESS POLICY ` (Snowflake). + DropRowAccessPolicy { + /// The row access policy name being detached. + policy_name: ObjectName, + }, + /// `DROP ALL ROW ACCESS POLICIES` (Snowflake). + DropAllRowAccessPolicies, /// Redshift `ALTER SORTKEY (column_list)` /// AlterSortKey { @@ -1008,6 +1015,12 @@ impl fmt::Display for AlterTableOperation { write!(f, "DROP CLUSTERING KEY")?; Ok(()) } + AlterTableOperation::DropRowAccessPolicy { policy_name } => { + write!(f, "DROP ROW ACCESS POLICY {policy_name}") + } + AlterTableOperation::DropAllRowAccessPolicies => { + write!(f, "DROP ALL ROW ACCESS POLICIES") + } AlterTableOperation::AlterSortKey { columns } => { write!(f, "ALTER SORTKEY({})", display_comma_separated(columns))?; Ok(()) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index dbbc9103fc..e92bdb1ca7 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -5080,6 +5080,59 @@ pub enum Statement { show_options: ShowStatementOptions, }, /// ```sql + /// CREATE [OR REPLACE] ROW ACCESS POLICY [IF NOT EXISTS] + /// AS () RETURNS BOOLEAN -> + /// ``` + /// See + CreateRowAccessPolicy { + /// `OR REPLACE` flag. + or_replace: bool, + /// `IF NOT EXISTS` flag. + if_not_exists: bool, + /// Policy name. + name: ObjectName, + /// Signature arguments (name + type). + args: Vec, + /// The declared return type (`BOOLEAN`). + return_type: DataType, + /// The policy body expression after `->`. + policy_expr: Expr, + }, + /// ```sql + /// ALTER ROW ACCESS POLICY [IF EXISTS] RENAME TO + /// ``` + AlterRowAccessPolicy { + /// `IF EXISTS` flag. + if_exists: bool, + /// Policy name. + name: ObjectName, + /// New policy name. + new_name: ObjectName, + }, + /// ```sql + /// DROP ROW ACCESS POLICY [IF EXISTS] + /// ``` + DropRowAccessPolicy { + /// `IF EXISTS` flag. + if_exists: bool, + /// Policy name. + name: ObjectName, + }, + /// ```sql + /// DESC[RIBE] ROW ACCESS POLICY + /// ``` + DescribeRowAccessPolicy { + /// Policy name. + name: ObjectName, + }, + /// ```sql + /// SHOW ROW ACCESS POLICIES [ LIKE '' ] + /// ``` + ShowRowAccessPolicies { + /// Optional `LIKE` filter. + filter: Option, + }, + /// ```sql /// CREATE [OR REPLACE] CATALOG INTEGRATION [IF NOT EXISTS] ... /// ``` /// See @@ -7219,6 +7272,50 @@ impl fmt::Display for Statement { terse = if *terse { "TERSE " } else { "" }, ) } + Statement::CreateRowAccessPolicy { + or_replace, + if_not_exists, + name, + args, + return_type, + policy_expr, + } => { + write!( + f, + "CREATE {or_replace}ROW ACCESS POLICY {if_not_exists}{name} AS ({args}) RETURNS {return_type} -> {policy_expr}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, + args = display_comma_separated(args), + ) + } + Statement::AlterRowAccessPolicy { + if_exists, + name, + new_name, + } => { + write!( + f, + "ALTER ROW ACCESS POLICY {if_exists}{name} RENAME TO {new_name}", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + ) + } + Statement::DropRowAccessPolicy { if_exists, name } => { + write!( + f, + "DROP ROW ACCESS POLICY {if_exists}{name}", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + ) + } + Statement::DescribeRowAccessPolicy { name } => { + write!(f, "DESCRIBE ROW ACCESS POLICY {name}") + } + Statement::ShowRowAccessPolicies { filter } => { + write!(f, "SHOW ROW ACCESS POLICIES")?; + if let Some(filter) = filter { + write!(f, " {filter}")?; + } + Ok(()) + } Statement::CreateCatalogIntegration { or_replace, if_not_exists, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index c442aa519a..43aad94d80 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -553,6 +553,11 @@ impl Spanned for Statement { Statement::DescribeFileFormat { .. } => Span::empty(), Statement::ShowFileFormats { .. } => Span::empty(), Statement::ShowStages { .. } => Span::empty(), + Statement::CreateRowAccessPolicy { .. } => Span::empty(), + Statement::AlterRowAccessPolicy { .. } => Span::empty(), + Statement::DropRowAccessPolicy { .. } => Span::empty(), + Statement::DescribeRowAccessPolicy { .. } => Span::empty(), + Statement::ShowRowAccessPolicies { .. } => Span::empty(), Statement::CreateCatalogIntegration { .. } => Span::empty(), Statement::DropCatalogIntegration { .. } => Span::empty(), Statement::ShowCatalogIntegrations { .. } => Span::empty(), @@ -1206,6 +1211,8 @@ impl Spanned for AlterTableOperation { partition, } => name.span.union_opt(&partition.as_ref().map(|i| i.span)), AlterTableOperation::DisableRowLevelSecurity => Span::empty(), + AlterTableOperation::DropRowAccessPolicy { .. } => Span::empty(), + AlterTableOperation::DropAllRowAccessPolicies => Span::empty(), AlterTableOperation::DisableRule { name } => name.span, AlterTableOperation::DisableTrigger { name } => name.span, AlterTableOperation::DropConstraint { diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 3df12ccc92..a0da1a3873 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -32,6 +32,7 @@ use crate::ast::{ CatalogSource, CatalogSyncNamespaceMode, CatalogTableFormat, ColumnOption, ColumnPolicy, ColumnPolicyProperty, ContactEntry, CopyIntoSnowflakeKind, CreateTable, CreateTableLikeKind, DollarQuotedString, ExternalVolumeEncryption, ExternalVolumeStorageLocation, Ident, + OperateFunctionArg, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, InitializeKind, Insert, MultiTableInsertIntoClause, MultiTableInsertType, MultiTableInsertValue, MultiTableInsertValues, @@ -304,6 +305,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_alter_stage(parser)); } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::ROW, Keyword::ACCESS, Keyword::POLICY]) { + // ALTER ROW ACCESS POLICY + return Some(parse_alter_row_access_policy(parser)); + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::SESSION]) { // ALTER SESSION let set = match parser.parse_one_of_keywords(&[Keyword::SET, Keyword::UNSET]) { @@ -329,6 +335,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_drop_file_format(parser)); } + if parser.parse_keywords(&[Keyword::DROP, Keyword::ROW, Keyword::ACCESS, Keyword::POLICY]) { + // DROP ROW ACCESS POLICY + return Some(parse_drop_row_access_policy(parser)); + } + if parser .parse_one_of_keywords(&[Keyword::DESC, Keyword::DESCRIBE]) .is_some() @@ -345,6 +356,10 @@ impl Dialect for SnowflakeDialect { // DESC[RIBE] WAREHOUSE return Some(parse_describe_warehouse(parser)); } + if parser.parse_keywords(&[Keyword::ROW, Keyword::ACCESS, Keyword::POLICY]) { + // DESC[RIBE] ROW ACCESS POLICY + return Some(parse_describe_row_access_policy(parser)); + } // not handled — put back DESC/DESCRIBE parser.prev_token(); } @@ -364,6 +379,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_create_catalog_integration(or_replace, parser)); } + // CREATE [OR REPLACE] ROW ACCESS POLICY + if parser.parse_keywords(&[Keyword::ROW, Keyword::ACCESS, Keyword::POLICY]) { + return Some(parse_create_row_access_policy(or_replace, parser)); + } + // LOCAL | GLOBAL let global = match parser.parse_one_of_keywords(&[Keyword::LOCAL, Keyword::GLOBAL]) { Some(Keyword::LOCAL) => Some(false), @@ -472,6 +492,9 @@ impl Dialect for SnowflakeDialect { if parser.parse_keyword(Keyword::STAGES) { return Some(parse_show_stages(terse, parser)); } + if parser.parse_keywords(&[Keyword::ROW, Keyword::ACCESS, Keyword::POLICIES]) { + return Some(parse_show_row_access_policies(parser)); + } //Give back Keyword::TERSE if terse { parser.prev_token(); @@ -2534,6 +2557,73 @@ fn parse_describe_warehouse(parser: &mut Parser) -> Result +/// AS ( [, ...]) RETURNS -> ` +fn parse_create_row_access_policy( + or_replace: bool, + parser: &mut Parser, +) -> Result { + let if_not_exists = parser.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + parser.expect_keyword_is(Keyword::AS)?; + parser.expect_token(&Token::LParen)?; + let args = parser.parse_comma_separated(|p| { + let arg_name = p.parse_identifier()?; + let data_type = p.parse_data_type()?; + Ok(OperateFunctionArg { + mode: None, + name: Some(arg_name), + data_type, + default_expr: None, + }) + })?; + parser.expect_token(&Token::RParen)?; + parser.expect_keyword_is(Keyword::RETURNS)?; + let return_type = parser.parse_data_type()?; + parser.expect_token(&Token::Arrow)?; + let policy_expr = parser.parse_expr()?; + Ok(Statement::CreateRowAccessPolicy { + or_replace, + if_not_exists, + name, + args, + return_type, + policy_expr, + }) +} + +/// Parse `ALTER ROW ACCESS POLICY [IF EXISTS] RENAME TO ` +fn parse_alter_row_access_policy(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + parser.expect_keywords(&[Keyword::RENAME, Keyword::TO])?; + let new_name = parser.parse_object_name(false)?; + Ok(Statement::AlterRowAccessPolicy { + if_exists, + name, + new_name, + }) +} + +/// Parse `DROP ROW ACCESS POLICY [IF EXISTS] ` +fn parse_drop_row_access_policy(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + Ok(Statement::DropRowAccessPolicy { if_exists, name }) +} + +/// Parse `DESC[RIBE] ROW ACCESS POLICY ` +fn parse_describe_row_access_policy(parser: &mut Parser) -> Result { + let name = parser.parse_object_name(false)?; + Ok(Statement::DescribeRowAccessPolicy { name }) +} + +/// Parse `SHOW ROW ACCESS POLICIES [LIKE '']` +fn parse_show_row_access_policies(parser: &mut Parser) -> Result { + let filter = parser.parse_show_statement_filter()?; + Ok(Statement::ShowRowAccessPolicies { filter }) +} + /// Parse `SHOW WAREHOUSES [LIKE '']` fn parse_show_warehouses(parser: &mut Parser) -> Result { let filter = parser.parse_show_statement_filter()?; diff --git a/src/keywords.rs b/src/keywords.rs index 743329d971..2c5021be0e 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -830,6 +830,7 @@ define_keywords!( PLANS, POINT, POLARIS, + POLICIES, POLICY, POLYGON, POOL, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 3120ff4bf3..e4fbe62db4 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -10919,6 +10919,16 @@ impl<'a> Parser<'a> { AlterTableOperation::DropProjection { if_exists, name } } else if self.parse_keywords(&[Keyword::CLUSTERING, Keyword::KEY]) { AlterTableOperation::DropClusteringKey + } else if self.parse_keywords(&[ + Keyword::ALL, + Keyword::ROW, + Keyword::ACCESS, + Keyword::POLICIES, + ]) { + AlterTableOperation::DropAllRowAccessPolicies + } else if self.parse_keywords(&[Keyword::ROW, Keyword::ACCESS, Keyword::POLICY]) { + let policy_name = self.parse_object_name(false)?; + AlterTableOperation::DropRowAccessPolicy { policy_name } } else { let has_column_keyword = self.parse_keyword(Keyword::COLUMN); // [ COLUMN ] let if_exists = self.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); From 98cc6848ca58b308551e73bf9614fecf0f8469e0 Mon Sep 17 00:00:00 2001 From: sabir Date: Tue, 23 Jun 2026 03:50:09 +0200 Subject: [PATCH 03/59] Snowflake: parse CREATE / DROP DATABASE ROLE --- src/ast/mod.rs | 35 +++++++++++++++++++++++++++++++++++ src/ast/spans.rs | 1 + src/dialect/snowflake.rs | 3 +++ src/parser/mod.rs | 34 ++++++++++++++++++++++++++++++++-- tests/sqlparser_snowflake.rs | 10 ++++++++++ 5 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index e92bdb1ca7..18b4561c96 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -3987,6 +3987,21 @@ pub enum Statement { /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createrole.html) CreateRole(CreateRole), /// ```sql + /// CREATE [OR REPLACE] DATABASE ROLE [IF NOT EXISTS] [COMMENT = '...'] + /// ``` + /// Snowflake database role (scoped to a database, distinct from an + /// account-level role). + CreateDatabaseRole { + /// `true` when `OR REPLACE` was specified. + or_replace: bool, + /// `true` when `IF NOT EXISTS` was specified. + if_not_exists: bool, + /// The (optionally database-qualified) role name. + name: ObjectName, + /// Optional `COMMENT = '...'` clause. + comment: Option, + }, + /// ```sql /// CREATE SECRET /// ``` /// See [DuckDB](https://duckdb.org/docs/sql/statements/create_secret.html) @@ -6290,6 +6305,23 @@ impl fmt::Display for Statement { write!(f, "{drop_operator_class}") } Statement::CreateRole(create_role) => write!(f, "{create_role}"), + Statement::CreateDatabaseRole { + or_replace, + if_not_exists, + name, + comment, + } => { + write!( + f, + "CREATE {or_replace}DATABASE ROLE {if_not_exists}{name}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, + )?; + if let Some(comment) = comment { + write!(f, " COMMENT = '{}'", value::escape_single_quote_string(comment))?; + } + Ok(()) + } Statement::CreateSecret { or_replace, temporary, @@ -9683,6 +9715,8 @@ pub enum ObjectType { Database, /// A role. Role, + /// A database role (Snowflake). + DatabaseRole, /// A sequence. Sequence, /// A stage. @@ -9710,6 +9744,7 @@ impl fmt::Display for ObjectType { ObjectType::Schema => "SCHEMA", ObjectType::Database => "DATABASE", ObjectType::Role => "ROLE", + ObjectType::DatabaseRole => "DATABASE ROLE", ObjectType::Sequence => "SEQUENCE", ObjectType::Stage => "STAGE", ObjectType::Type => "TYPE", diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 43aad94d80..cedd07acf4 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -393,6 +393,7 @@ impl Spanned for Statement { ), Statement::CreateIndex(create_index) => create_index.span(), Statement::CreateRole(create_role) => create_role.span(), + Statement::CreateDatabaseRole { name, .. } => name.span(), Statement::CreateExtension(create_extension) => create_extension.span(), Statement::CreateCollation(create_collation) => create_collation.span(), Statement::DropExtension(drop_extension) => drop_extension.span(), diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index a0da1a3873..d71a63e4a8 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -434,6 +434,9 @@ impl Dialect for SnowflakeDialect { .map(Into::into), ); } else if parser.parse_keyword(Keyword::DATABASE) { + if parser.parse_keyword(Keyword::ROLE) { + return Some(parser.parse_create_database_role(or_replace)); + } return Some(parse_create_database(or_replace, transient, parser)); } else { // Not a Snowflake-specific CREATE form — rewind the consumed diff --git a/src/parser/mod.rs b/src/parser/mod.rs index e4fbe62db4..a526a2ed28 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5468,7 +5468,11 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::VIRTUAL) { self.parse_create_virtual_table() } else if self.parse_keyword(Keyword::DATABASE) { - self.parse_create_database() + if self.parse_keyword(Keyword::ROLE) { + self.parse_create_database_role(or_replace) + } else { + self.parse_create_database() + } } else if self.parse_keyword(Keyword::SEQUENCE) { self.parse_create_sequence(temporary) } else if self.parse_keyword(Keyword::COLLATION) { @@ -7296,6 +7300,28 @@ impl<'a> Parser<'a> { }) } + /// Parse a Snowflake `CREATE [OR REPLACE] DATABASE ROLE` statement. The + /// leading `DATABASE ROLE` keywords have already been consumed. + pub fn parse_create_database_role( + &mut self, + or_replace: bool, + ) -> Result { + let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = self.parse_object_name(false)?; + let comment = if self.parse_keyword(Keyword::COMMENT) { + self.expect_token(&Token::Eq)?; + Some(self.parse_literal_string()?) + } else { + None + }; + Ok(Statement::CreateDatabaseRole { + or_replace, + if_not_exists, + name, + comment, + }) + } + /// Parse an `OWNER` clause. pub fn parse_owner(&mut self) -> Result { let owner = match self.parse_one_of_keywords(&[Keyword::CURRENT_USER, Keyword::CURRENT_ROLE, Keyword::SESSION_USER]) { @@ -7777,7 +7803,11 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::SCHEMA) { ObjectType::Schema } else if self.parse_keyword(Keyword::DATABASE) { - ObjectType::Database + if self.parse_keyword(Keyword::ROLE) { + ObjectType::DatabaseRole + } else { + ObjectType::Database + } } else if self.parse_keyword(Keyword::SEQUENCE) { ObjectType::Sequence } else if self.parse_keyword(Keyword::STAGE) { diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 244d422961..80d029f10c 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -4351,6 +4351,16 @@ fn test_grant_database_role_to() { snowflake_and_generic().verified_stmt("GRANT DATABASE ROLE db1.sc1.r1 TO ROLE db1.sc1.r2"); } +#[test] +fn test_create_database_role() { + snowflake().verified_stmt("CREATE DATABASE ROLE r1"); + snowflake().verified_stmt("CREATE OR REPLACE DATABASE ROLE r1"); + snowflake().verified_stmt("CREATE DATABASE ROLE IF NOT EXISTS db1.r1"); + snowflake().verified_stmt("CREATE DATABASE ROLE db1.\"role123\" COMMENT = 'hi'"); + snowflake().verified_stmt("DROP DATABASE ROLE r1"); + snowflake().verified_stmt("DROP DATABASE ROLE IF EXISTS db1.\"role123\""); +} + #[test] fn test_alter_session() { assert_eq!( From 3fd1798008d8e6b50309acfc5bd62add22c08ae2 Mon Sep 17 00:00:00 2001 From: sabir Date: Tue, 23 Jun 2026 06:48:31 +0200 Subject: [PATCH 04/59] Snowflake: parse CREATE USER options without '=' separator --- src/dialect/snowflake.rs | 26 +++++++++++++------------- src/parser/alter.rs | 4 ++-- src/parser/mod.rs | 21 +++++++++++++++------ tests/sqlparser_common.rs | 5 +++++ 4 files changed, 35 insertions(+), 21 deletions(-) diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index d71a63e4a8..504ce119dc 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -1411,17 +1411,17 @@ fn parse_stage_properties(parser: &mut Parser) -> Result` // is sugar for `FILE_FORMAT = (FORMAT_NAME = )` — @@ -1441,7 +1441,7 @@ fn parse_stage_properties(parser: &mut Parser) -> Result Result { // FILE_FORMAT if parser.parse_keyword(Keyword::FILE_FORMAT) { parser.expect_token(&Token::Eq)?; - file_format = parser.parse_key_value_options(true, &[])?.options; + file_format = parser.parse_key_value_options(true, &[], false)?.options; // PARTITION BY } else if parser.parse_keywords(&[Keyword::PARTITION, Keyword::BY]) { partition = Some(Box::new(parser.parse_expr()?)) @@ -1687,14 +1687,14 @@ pub fn parse_copy_into(parser: &mut Parser) -> Result { // COPY OPTIONS } else if parser.parse_keyword(Keyword::COPY_OPTIONS) { parser.expect_token(&Token::Eq)?; - copy_options = parser.parse_key_value_options(true, &[])?.options; + copy_options = parser.parse_key_value_options(true, &[], false)?.options; } else { match parser.next_token().token { Token::SemiColon | Token::EOF => break, Token::Comma => continue, // In `COPY INTO ` the copy options do not have a shared key // like in `COPY INTO ` - Token::Word(key) => copy_options.push(parser.parse_key_value_option(&key)?), + Token::Word(key) => copy_options.push(parser.parse_key_value_option(&key, false)?), _ => { return parser .expected_ref("another copy option, ; or EOF'", parser.peek_token_ref()) @@ -1857,7 +1857,7 @@ fn parse_stage_params(parser: &mut Parser) -> Result Result { parser.advance_token(); if set { - let option = parser.parse_key_value_option(&key)?; + let option = parser.parse_key_value_option(&key, false)?; options.push(option); } else { options.push(KeyValueOption { @@ -2437,7 +2437,7 @@ fn parse_create_file_format( if matches!(parser.peek_token().token, Token::EOF | Token::SemiColon) { break; } - let parsed = parser.parse_key_value_options(false, &[Keyword::COMMENT])?; + let parsed = parser.parse_key_value_options(false, &[Keyword::COMMENT], false)?; if parsed.options.is_empty() { break; } @@ -2472,7 +2472,7 @@ fn parse_alter_file_format(parser: &mut Parser) -> Result { }; let set_tag = if self.parse_keywords(&[Keyword::SET, Keyword::TAG]) { - self.parse_key_value_options(false, &[])? + self.parse_key_value_options(false, &[], false)? } else { KeyValueOptions { delimiter: KeyValueOptionsDelimiter::Comma, @@ -277,7 +277,7 @@ impl Parser<'_> { }; let set_props = if self.parse_keyword(Keyword::SET) { - self.parse_key_value_options(false, &[])? + self.parse_key_value_options(false, &[], false)? } else { KeyValueOptions { delimiter: KeyValueOptionsDelimiter::Comma, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index a526a2ed28..9420f6c9fa 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5501,11 +5501,11 @@ impl<'a> Parser<'a> { let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); let name = self.parse_identifier()?; let options = self - .parse_key_value_options(false, &[Keyword::WITH, Keyword::TAG])? + .parse_key_value_options(false, &[Keyword::WITH, Keyword::TAG], true)? .options; let with_tags = self.parse_keyword(Keyword::WITH); let tags = if self.parse_keyword(Keyword::TAG) { - self.parse_key_value_options(true, &[])?.options + self.parse_key_value_options(true, &[], false)?.options } else { vec![] }; @@ -21258,6 +21258,7 @@ impl<'a> Parser<'a> { &mut self, parenthesized: bool, end_words: &[Keyword], + optional_equals: bool, ) -> Result { let mut options: Vec = Vec::new(); let mut delimiter = KeyValueOptionsDelimiter::Space; @@ -21279,7 +21280,7 @@ impl<'a> Parser<'a> { continue; } Token::Word(w) if !end_words.contains(&w.keyword) => { - options.push(self.parse_key_value_option(&w)?) + options.push(self.parse_key_value_option(&w, optional_equals)?) } Token::Word(w) if end_words.contains(&w.keyword) => { self.prev_token(); @@ -21297,12 +21298,20 @@ impl<'a> Parser<'a> { Ok(KeyValueOptions { delimiter, options }) } - /// Parses a `KEY = VALUE` construct based on the specified key + /// Parses a `KEY = VALUE` construct based on the specified key. + /// + /// When `optional_equals` is set the `=` separator may be omitted + /// (`KEY VALUE`), as Snowflake accepts for `CREATE USER` options. pub(crate) fn parse_key_value_option( &mut self, key: &Word, + optional_equals: bool, ) -> Result { - self.expect_token(&Token::Eq)?; + if optional_equals { + let _ = self.consume_token(&Token::Eq); + } else { + self.expect_token(&Token::Eq)?; + } let peeked_token = self.peek_token(); match peeked_token.token { Token::SingleQuotedString(_) => Ok(KeyValueOption { @@ -21347,7 +21356,7 @@ impl<'a> Parser<'a> { None => Ok(KeyValueOption { option_name: key.value.clone(), option_value: KeyValueOptionKind::KeyValueOptions(Box::new( - self.parse_key_value_options(true, &[])?, + self.parse_key_value_options(true, &[], optional_equals)?, )), }), } diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 9f2311be92..7cb4313576 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -17773,6 +17773,11 @@ fn parse_create_user() { verified_stmt("CREATE OR REPLACE USER u1"); verified_stmt("CREATE OR REPLACE USER IF NOT EXISTS u1"); verified_stmt("CREATE OR REPLACE USER IF NOT EXISTS u1 PASSWORD='secret'"); + // Snowflake accepts options without the `=` separator. + one_statement_parses_to( + "CREATE USER u1 PASSWORD 'secret'", + "CREATE USER u1 PASSWORD='secret'", + ); let dialects = all_dialects_where(|d| d.supports_boolean_literals()); dialects.one_statement_parses_to( "CREATE OR REPLACE USER IF NOT EXISTS u1 PASSWORD='secret' MUST_CHANGE_PASSWORD=TRUE", From 84c7e3c54e58f94c9b6b7021bc3790130cf81f85 Mon Sep 17 00:00:00 2001 From: Przemyslaw Denkiewicz Date: Mon, 22 Jun 2026 21:27:45 +0200 Subject: [PATCH 05/59] Snowflake: always double quotes in EscapeQuotedString The quote-escaper kept an 'already escaped' peek-ahead heuristic that left an adjacent quote pair untouched. A decoded value containing an embedded empty-string literal ('') is indistinguishable from a pre-escaped quote, so it was under-escaped. Quoted-string AST nodes hold the decoded value, so escaping is the pure inverse: double every quote unconditionally. Updates the with_unescape(false) round-trip tests, which relied on the heuristic, to assert the new quote-doubled serialization. --- src/ast/value.rs | 59 ++++++++----------------------------- tests/sqlparser_bigquery.rs | 31 ++++++++++++++++++- tests/sqlparser_common.rs | 17 +++++++++-- tests/sqlparser_mysql.rs | 46 ++++++++++++++++------------- 4 files changed, 82 insertions(+), 71 deletions(-) diff --git a/src/ast/value.rs b/src/ast/value.rs index 5f069f36cc..780a3a261e 100644 --- a/src/ast/value.rs +++ b/src/ast/value.rs @@ -537,56 +537,21 @@ pub struct EscapeQuotedString<'a> { impl fmt::Display for EscapeQuotedString<'_> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - // EscapeQuotedString doesn't know which mode of escape was - // chosen by the user. So this code must to correctly display - // strings without knowing if the strings are already escaped - // or not. - // - // If the quote symbol in the string is repeated twice, OR, if - // the quote symbol is after backslash, display all the chars - // without any escape. However, if the quote symbol is used - // just between usual chars, `fmt()` should display it twice." - // - // The following table has examples - // - // | original query | mode | AST Node | serialized | - // | ------------- | --------- | -------------------------------------------------- | ------------ | - // | `"A""B""A"` | no-escape | `DoubleQuotedString(String::from("A\"\"B\"\"A"))` | `"A""B""A"` | - // | `"A""B""A"` | default | `DoubleQuotedString(String::from("A\"B\"A"))` | `"A""B""A"` | - // | `"A\"B\"A"` | no-escape | `DoubleQuotedString(String::from("A\\\"B\\\"A"))` | `"A\"B\"A"` | - // | `"A\"B\"A"` | default | `DoubleQuotedString(String::from("A\"B\"A"))` | `"A""B""A"` | + // The quoted-string AST nodes hold the *decoded* value, so escaping is + // the exact inverse of parsing: double every occurrence of the quote + // character. Doubling unconditionally keeps adjacent quotes — such as an + // embedded empty-string literal `''` — correct. A peek-ahead + // "already escaped" heuristic would mistake such a pair for a + // pre-escaped quote and leave it under-escaped. let quote = self.quote; - let mut previous_char = char::default(); let mut start_idx = 0; - let mut peekable_chars = self.string.char_indices().peekable(); - while let Some(&(idx, ch)) = peekable_chars.peek() { - match ch { - char if char == quote => { - if previous_char == '\\' { - // the quote is already escaped with a backslash, skip - peekable_chars.next(); - continue; - } - peekable_chars.next(); - match peekable_chars.peek() { - Some((_, c)) if *c == quote => { - // the quote is already escaped with another quote, skip - peekable_chars.next(); - } - _ => { - // The quote is not escaped. - // Including idx in the range, so the quote at idx will be printed twice: - // in this call to write_str() and in the next one. - f.write_str(&self.string[start_idx..=idx])?; - start_idx = idx; - } - } - } - _ => { - peekable_chars.next(); - } + for (idx, ch) in self.string.char_indices() { + if ch == quote { + // Including idx in the range emits the quote at idx, then + // start_idx is reset to idx so the next write emits it again. + f.write_str(&self.string[start_idx..=idx])?; + start_idx = idx; } - previous_char = ch; } f.write_str(&self.string[start_idx..])?; Ok(()) diff --git a/tests/sqlparser_bigquery.rs b/tests/sqlparser_bigquery.rs index bab18b786d..5683204c57 100644 --- a/tests/sqlparser_bigquery.rs +++ b/tests/sqlparser_bigquery.rs @@ -45,11 +45,40 @@ fn parse_literal_string() { r#""""triple-double'unescaped""", "#, // line 1, column 118 r#"'''triple-single"unescaped'''"#, // line 1, column 131 ); + // With the escaper doubling every quote unconditionally, an unescape=false + // value containing a quote re-serializes to the quote-doubled form, so the + // serialization no longer round-trips verbatim. Parsing (the point of this + // test) is unchanged; assert the parsed AST and the canonical doubled form. + let canonical = concat!( + "SELECT ", + "'single', ", + r#""double", "#, + "'''triple-single''', ", + r#""""triple-double""", "#, + r#"'single\''escaped', "#, + r#"'''triple-single\'escaped''', "#, + r#"'''triple-single'unescaped''', "#, + r#""double\""escaped", "#, + r#""""triple-double\"escaped""", "#, + r#""""triple-double"unescaped""", "#, + r#""""triple-double'unescaped""", "#, + r#"'''triple-single"unescaped'''"#, + ); let dialect = TestedDialects::new_with_options( vec![Box::new(BigQueryDialect {})], ParserOptions::new().with_unescape(false), ); - let select = dialect.verified_only_select(sql); + let statements = dialect.parse_sql_statements(sql).unwrap(); + assert_eq!(1, statements.len()); + let stmt = statements.into_iter().next().unwrap(); + assert_eq!(canonical, stmt.to_string()); + let select = match stmt { + Statement::Query(query) => match *query.body { + SetExpr::Select(s) => *s, + _ => panic!("Expected SetExpr::Select"), + }, + _ => panic!("Expected Query"), + }; assert_eq!(12, select.projection.len()); assert_eq!( &Expr::Value(Value::SingleQuotedString("single".into()).with_empty_span()), diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 7cb4313576..935118b6ca 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -1558,13 +1558,26 @@ fn parse_escaped_single_quote_string_predicate_with_no_escape() { let sql = "SELECT id, fname, lname FROM customer \ WHERE salary <> 'Jim''s salary'"; - let ast = TestedDialects::new_with_options( + // unescape=false keeps the doubled quote in the value; the escaper now + // re-doubles on display (`'Jim''''s salary'`), so this no longer + // round-trips verbatim. Parsing is the point here — assert the AST. + let stmt = TestedDialects::new_with_options( vec![Box::new(MySqlDialect {})], ParserOptions::new() .with_trailing_commas(true) .with_unescape(false), ) - .verified_only_select(sql); + .parse_sql_statements(sql) + .unwrap() + .pop() + .unwrap(); + let ast = match stmt { + Statement::Query(query) => match *query.body { + SetExpr::Select(s) => *s, + _ => panic!("Expected SetExpr::Select"), + }, + _ => panic!("Expected Query"), + }; assert_eq!( Some(Expr::BinaryOp { diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index 89ed6a9ca1..8d85acb157 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -1508,7 +1508,10 @@ fn parse_escaped_quote_identifiers_with_no_escape() { require_semicolon_stmt_delimiter: true, } ) - .verified_stmt(sql), + .parse_sql_statements(sql) + .unwrap() + .pop() + .unwrap(), Statement::Query(Box::new(Query { with: None, body: Box::new(SetExpr::Select(Box::new(Select { @@ -1610,7 +1613,10 @@ fn parse_escaped_backticks_with_no_escape() { vec![Box::new(MySqlDialect {})], ParserOptions::new().with_unescape(false) ) - .verified_stmt(sql), + .parse_sql_statements(sql) + .unwrap() + .pop() + .unwrap(), Statement::Query(Box::new(Query { with: None, body: Box::new(SetExpr::Select(Box::new(Select { @@ -1668,26 +1674,24 @@ fn parse_unterminated_escape() { #[test] fn check_roundtrip_of_escaped_string() { + // The escaper doubles every quote unconditionally, so an unescape=false + // value that contains the quote character re-serializes to the + // quote-doubled form rather than the original spelling. Each case asserts + // the canonical serialization the parser now produces. let options = ParserOptions::new().with_unescape(false); - - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r"SELECT 'I\'m fine'"); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r#"SELECT 'I''m fine'"#); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r"SELECT 'I\\\'m fine'"); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r"SELECT 'I\\\'m fine'"); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r#"SELECT "I\"m fine""#); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r#"SELECT "I""m fine""#); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r#"SELECT "I\\\"m fine""#); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r#"SELECT "I\\\"m fine""#); - TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r#"SELECT "I'm ''fine''""#); + let dialect = TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options); + for (sql, canonical) in [ + (r"SELECT 'I\'m fine'", r"SELECT 'I\''m fine'"), + (r#"SELECT 'I''m fine'"#, r#"SELECT 'I''''m fine'"#), + (r"SELECT 'I\\\'m fine'", r"SELECT 'I\\\''m fine'"), + (r#"SELECT "I\"m fine""#, r#"SELECT "I\""m fine""#), + (r#"SELECT "I""m fine""#, r#"SELECT "I""""m fine""#), + (r#"SELECT "I\\\"m fine""#, r#"SELECT "I\\\""m fine""#), + (r#"SELECT "I'm ''fine''""#, r#"SELECT "I'm ''fine''""#), + ] { + let stmt = dialect.parse_sql_statements(sql).unwrap().pop().unwrap(); + assert_eq!(canonical, stmt.to_string()); + } } #[test] From 49b509336112dd4f785b33e1ea3131d1a2fe68e6 Mon Sep 17 00:00:00 2001 From: sabir Date: Tue, 23 Jun 2026 11:48:03 +0200 Subject: [PATCH 06/59] Snowflake: parse CREATE DATABASE ROLE and qualified class create privileges in GRANT --- src/ast/mod.rs | 7 +++++++ src/parser/mod.rs | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 18b4561c96..d3a9cd2749 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -8432,8 +8432,13 @@ pub enum ActionCreateObjectType { ComputePool, /// A data exchange listing. DataExchangeListing, + /// A class object identified by a qualified name, e.g. + /// `CREATE SNOWFLAKE.ML.ANOMALY_DETECTION`. + Class(ObjectName), /// A database object. Database, + /// A database role object. + DatabaseRole, /// An external volume object. ExternalVolume, /// A failover group object. @@ -8466,7 +8471,9 @@ impl fmt::Display for ActionCreateObjectType { ActionCreateObjectType::ApplicationPackage => write!(f, "APPLICATION PACKAGE"), ActionCreateObjectType::ComputePool => write!(f, "COMPUTE POOL"), ActionCreateObjectType::DataExchangeListing => write!(f, "DATA EXCHANGE LISTING"), + ActionCreateObjectType::Class(name) => write!(f, "{name}"), ActionCreateObjectType::Database => write!(f, "DATABASE"), + ActionCreateObjectType::DatabaseRole => write!(f, "DATABASE ROLE"), ActionCreateObjectType::ExternalVolume => write!(f, "EXTERNAL VOLUME"), ActionCreateObjectType::FailoverGroup => write!(f, "FAILOVER GROUP"), ActionCreateObjectType::Integration => write!(f, "INTEGRATION"), diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9420f6c9fa..ce75764dfc 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -18374,6 +18374,8 @@ impl<'a> Parser<'a> { Some(ActionCreateObjectType::OrganiationListing) } else if self.parse_keywords(&[Keyword::REPLICATION, Keyword::GROUP]) { Some(ActionCreateObjectType::ReplicationGroup) + } else if self.parse_keywords(&[Keyword::DATABASE, Keyword::ROLE]) { + Some(ActionCreateObjectType::DatabaseRole) } // Single-word object types else if self.parse_keyword(Keyword::ACCOUNT) { @@ -18394,6 +18396,14 @@ impl<'a> Parser<'a> { Some(ActionCreateObjectType::User) } else if self.parse_keyword(Keyword::WAREHOUSE) { Some(ActionCreateObjectType::Warehouse) + } else if matches!(self.peek_token_ref().token, Token::Word(ref w) if w.keyword == Keyword::NoKeyword) + { + // Qualified "class" create privilege, e.g. + // `CREATE SNOWFLAKE.ML.ANOMALY_DETECTION`. + self.maybe_parse(|p| p.parse_object_name(false)) + .ok() + .flatten() + .map(ActionCreateObjectType::Class) } else { None } From 3cc037545f2e78bfdf0535e35b6b664b51c977be Mon Sep 17 00:00:00 2001 From: sabir Date: Tue, 23 Jun 2026 20:55:22 +0200 Subject: [PATCH 07/59] Snowflake: parse RLIKE/REGEXP infix form with NULL right operand Co-Authored-By: Claude Opus 4.8 --- src/parser/mod.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index ce75764dfc..4ff0c7a439 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4275,21 +4275,22 @@ impl<'a> Parser<'a> { let negated = self.parse_keyword(Keyword::NOT); let regexp = self.parse_keyword(Keyword::REGEXP); let rlike = self.parse_keyword(Keyword::RLIKE); - let null = if !self.in_column_definition_state() { - self.parse_keyword(Keyword::NULL) - } else { - false - }; if regexp || rlike { - Ok(Expr::RLike { + return Ok(Expr::RLike { negated, expr: Box::new(expr), pattern: Box::new( self.parse_subexpr(self.dialect.prec_value(Precedence::Like))?, ), regexp, - }) - } else if negated && null { + }); + } + let null = if !self.in_column_definition_state() { + self.parse_keyword(Keyword::NULL) + } else { + false + }; + if negated && null { Ok(Expr::IsNotNull(Box::new(expr))) } else if self.parse_keyword(Keyword::IN) { self.parse_in(expr, negated) From af8b7168fec45d710dc8d6405ea91e0dcc0e0733 Mon Sep 17 00:00:00 2001 From: Przemyslaw Denkiewicz Date: Tue, 23 Jun 2026 18:33:05 +0200 Subject: [PATCH 08/59] Snowflake: parse CREATE / DROP ICEBERG TABLE Add CATALOG_TABLE_NAME and AUTO_REFRESH to CreateTable for externally-managed Iceberg tables, accept comma- or space-separated Iceberg options in free order, relax the BASE_LOCATION requirement for externally-managed tables, and parse DROP ICEBERG TABLE [IF EXISTS] [PURGE]. --- src/ast/ddl.rs | 22 +++++- src/ast/helpers/stmt_create_table.rs | 10 +++ src/ast/spans.rs | 2 + src/dialect/snowflake.rs | 14 +++- src/keywords.rs | 2 + src/parser/mod.rs | 4 +- tests/sqlparser_duckdb.rs | 2 + tests/sqlparser_mssql.rs | 4 + tests/sqlparser_postgres.rs | 2 + tests/sqlparser_snowflake.rs | 114 +++++++++++++++++++++++++++ 10 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 027de1454e..f347a8a807 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -3073,6 +3073,12 @@ pub struct CreateTable { /// Snowflake "CATALOG" clause for Iceberg tables /// pub catalog: Option, + /// Snowflake "CATALOG_TABLE_NAME" clause for externally-managed Iceberg tables + /// + pub catalog_table_name: Option, + /// Snowflake "AUTO_REFRESH" clause for externally-managed Iceberg tables + /// + pub auto_refresh: Option, /// Snowflake "CATALOG_SYNC" clause for Iceberg tables /// pub catalog_sync: Option, @@ -3162,8 +3168,10 @@ impl fmt::Display for CreateTable { && self.like.is_none() && self.clone.is_none() && self.partition_of.is_none() + && !self.iceberg { - // PostgreSQL allows `CREATE TABLE t ();`, but requires empty parens + // PostgreSQL allows `CREATE TABLE t ();`, but requires empty parens. + // Externally-managed Iceberg tables legitimately have no column list. f.write_str(" ()")?; } else if let Some(CreateTableLikeKind::Parenthesized(like_in_columns_list)) = &self.like { write!(f, " ({like_in_columns_list})")?; @@ -3307,6 +3315,18 @@ impl fmt::Display for CreateTable { write!(f, " CATALOG='{catalog}'")?; } + if let Some(catalog_table_name) = self.catalog_table_name.as_ref() { + write!(f, " CATALOG_TABLE_NAME='{catalog_table_name}'")?; + } + + if let Some(auto_refresh) = self.auto_refresh { + write!( + f, + " AUTO_REFRESH={}", + if auto_refresh { "TRUE" } else { "FALSE" } + )?; + } + if self.iceberg { if let Some(base_location) = self.base_location.as_ref() { write!(f, " BASE_LOCATION='{base_location}'")?; diff --git a/src/ast/helpers/stmt_create_table.rs b/src/ast/helpers/stmt_create_table.rs index ab2feb6930..892db13064 100644 --- a/src/ast/helpers/stmt_create_table.rs +++ b/src/ast/helpers/stmt_create_table.rs @@ -159,6 +159,10 @@ pub struct CreateTableBuilder { pub external_volume: Option, /// Optional catalog name. pub catalog: Option, + /// Optional externally-managed catalog table name. + pub catalog_table_name: Option, + /// Optional auto-refresh flag for externally-managed Iceberg tables. + pub auto_refresh: Option, /// Optional catalog synchronization option. pub catalog_sync: Option, /// Optional storage serialization policy. @@ -236,6 +240,8 @@ impl CreateTableBuilder { base_location: None, external_volume: None, catalog: None, + catalog_table_name: None, + auto_refresh: None, catalog_sync: None, storage_serialization_policy: None, table_options: CreateTableOptions::None, @@ -606,6 +612,8 @@ impl CreateTableBuilder { base_location: self.base_location, external_volume: self.external_volume, catalog: self.catalog, + catalog_table_name: self.catalog_table_name, + auto_refresh: self.auto_refresh, catalog_sync: self.catalog_sync, storage_serialization_policy: self.storage_serialization_policy, table_options: self.table_options, @@ -687,6 +695,8 @@ impl From for CreateTableBuilder { base_location: table.base_location, external_volume: table.external_volume, catalog: table.catalog, + catalog_table_name: table.catalog_table_name, + auto_refresh: table.auto_refresh, catalog_sync: table.catalog_sync, storage_serialization_policy: table.storage_serialization_policy, table_options: table.table_options, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index cedd07acf4..e009535a7f 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -638,6 +638,8 @@ impl Spanned for CreateTable { external_volume: _, // todo, Snowflake specific base_location: _, // todo, Snowflake specific catalog: _, // todo, Snowflake specific + catalog_table_name: _, // todo, Snowflake specific + auto_refresh: _, // todo, Snowflake specific catalog_sync: _, // todo, Snowflake specific storage_serialization_policy: _, table_options, diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 504ce119dc..3a4f7b9cbd 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -1109,6 +1109,14 @@ pub fn parse_create_table( parser.expect_token(&Token::Eq)?; builder.catalog = Some(parser.parse_literal_string()?); } + Keyword::CATALOG_TABLE_NAME => { + parser.expect_token(&Token::Eq)?; + builder.catalog_table_name = Some(parser.parse_literal_string()?); + } + Keyword::AUTO_REFRESH => { + parser.expect_token(&Token::Eq)?; + builder.auto_refresh = Some(parser.parse_boolean_string()?); + } Keyword::BASE_LOCATION => { parser.expect_token(&Token::Eq)?; builder.base_location = Some(parser.parse_literal_string()?); @@ -1178,6 +1186,8 @@ pub fn parse_create_table( let (columns, constraints) = parser.parse_columns()?; builder = builder.columns(columns).constraints(constraints); } + // Snowflake accepts Iceberg table options either space- or comma-separated. + Token::Comma => {} Token::EOF => { break; } @@ -1198,7 +1208,9 @@ pub fn parse_create_table( builder = builder.table_options(table_options); - if iceberg && builder.base_location.is_none() { + // Managed Iceberg tables require BASE_LOCATION; externally-managed ones + // (identified by CATALOG_TABLE_NAME) do not. + if iceberg && builder.base_location.is_none() && builder.catalog_table_name.is_none() { return Err(ParserError::ParserError( "BASE_LOCATION is required for ICEBERG tables".to_string(), )); diff --git a/src/keywords.rs b/src/keywords.rs index 2c5021be0e..9a9b213745 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -147,6 +147,7 @@ define_keywords!( AUTOEXTEND_SIZE, AUTOINCREMENT, AUTO_INCREMENT, + AUTO_REFRESH, AVG, AVG_ROW_LENGTH, AVRO, @@ -213,6 +214,7 @@ define_keywords!( CATALOG_SYNC, CATALOG_SYNC_NAMESPACE_FLATTEN_DELIMITER, CATALOG_SYNC_NAMESPACE_MODE, + CATALOG_TABLE_NAME, CATALOG_URI, CATCH, CATEGORY, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 4ff0c7a439..a10f8b93c5 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7789,7 +7789,9 @@ impl<'a> Parser<'a> { let persistent = dialect_of!(self is DuckDbDialect) && self.parse_one_of_keywords(&[Keyword::PERSISTENT]).is_some(); - let object_type = if self.parse_keyword(Keyword::TABLE) { + let object_type = if self.parse_keyword(Keyword::TABLE) + || self.parse_keywords(&[Keyword::ICEBERG, Keyword::TABLE]) + { ObjectType::Table } else if self.parse_keyword(Keyword::COLLATION) { ObjectType::Collation diff --git a/tests/sqlparser_duckdb.rs b/tests/sqlparser_duckdb.rs index df62685808..1b5d441a4f 100644 --- a/tests/sqlparser_duckdb.rs +++ b/tests/sqlparser_duckdb.rs @@ -781,6 +781,8 @@ fn test_duckdb_union_datatype() { base_location: Default::default(), external_volume: Default::default(), catalog: Default::default(), + catalog_table_name: Default::default(), + auto_refresh: Default::default(), catalog_sync: Default::default(), storage_serialization_policy: Default::default(), table_options: CreateTableOptions::None, diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index d56f2ac1c3..634ae6a843 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -2006,6 +2006,8 @@ fn parse_create_table_with_valid_options() { base_location: None, external_volume: None, catalog: None, + catalog_table_name: None, + auto_refresh: None, catalog_sync: None, storage_serialization_policy: None, table_options: CreateTableOptions::With(with_options), @@ -2180,6 +2182,8 @@ fn parse_create_table_with_identity_column() { base_location: None, external_volume: None, catalog: None, + catalog_table_name: None, + auto_refresh: None, catalog_sync: None, storage_serialization_policy: None, table_options: CreateTableOptions::None, diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 141ca0bec6..79e101e4ed 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -6710,6 +6710,8 @@ fn parse_trigger_related_functions() { base_location: None, external_volume: None, catalog: None, + catalog_table_name: None, + auto_refresh: None, catalog_sync: None, storage_serialization_policy: None, table_options: CreateTableOptions::None, diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 80d029f10c..18b7aaaf15 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -1051,6 +1051,120 @@ fn test_snowflake_create_iceberg_table_without_location() { ); } +#[test] +fn test_snowflake_create_iceberg_table_comma_separated_options() { + let canonical = "CREATE ICEBERG TABLE my_table (a INT) \ + EXTERNAL_VOLUME='volume' CATALOG='SNOWFLAKE' BASE_LOCATION='relative/path'"; + let with_commas = "CREATE ICEBERG TABLE my_table (a INT) \ + CATALOG='SNOWFLAKE', EXTERNAL_VOLUME='volume', BASE_LOCATION='relative/path'"; + match snowflake().one_statement_parses_to(with_commas, canonical) { + Statement::CreateTable(CreateTable { + iceberg, + columns, + external_volume, + catalog, + base_location, + .. + }) => { + assert!(iceberg); + assert_eq!(1, columns.len()); + assert_eq!("volume", external_volume.unwrap()); + assert_eq!("SNOWFLAKE", catalog.unwrap()); + assert_eq!("relative/path", base_location.unwrap()); + } + _ => unreachable!(), + } +} + +#[test] +fn test_snowflake_create_iceberg_table_externally_managed() { + match snowflake().verified_stmt( + "CREATE OR REPLACE ICEBERG TABLE my_table \ + CATALOG='my_catalog_integration' CATALOG_TABLE_NAME='db.tbl' AUTO_REFRESH=TRUE", + ) { + Statement::CreateTable(CreateTable { + or_replace, + iceberg, + columns, + catalog, + catalog_table_name, + auto_refresh, + base_location, + query, + .. + }) => { + assert!(or_replace); + assert!(iceberg); + assert!(columns.is_empty()); + assert_eq!("my_catalog_integration", catalog.unwrap()); + assert_eq!("db.tbl", catalog_table_name.unwrap()); + assert_eq!(Some(true), auto_refresh); + assert!(base_location.is_none()); + assert!(query.is_none()); + } + _ => unreachable!(), + } +} + +#[test] +fn test_snowflake_create_iceberg_table_ctas() { + match snowflake().verified_stmt( + "CREATE ICEBERG TABLE my_table EXTERNAL_VOLUME='volume' CATALOG='SNOWFLAKE' \ + BASE_LOCATION='relative/path' AS SELECT 1", + ) { + Statement::CreateTable(CreateTable { + iceberg, + columns, + query, + base_location, + .. + }) => { + assert!(iceberg); + assert!(columns.is_empty()); + assert!(query.is_some()); + assert_eq!("relative/path", base_location.unwrap()); + } + _ => unreachable!(), + } +} + +#[test] +fn test_snowflake_drop_iceberg_table() { + match snowflake().one_statement_parses_to( + "DROP ICEBERG TABLE IF EXISTS my_table PURGE", + "DROP TABLE IF EXISTS my_table PURGE", + ) { + Statement::Drop { + object_type, + if_exists, + names, + purge, + .. + } => { + assert_eq!(ObjectType::Table, object_type); + assert!(if_exists); + assert!(purge); + assert_eq!("my_table", names[0].to_string()); + } + _ => unreachable!(), + } + + match snowflake().one_statement_parses_to("DROP ICEBERG TABLE my_table", "DROP TABLE my_table") + { + Statement::Drop { + object_type, + if_exists, + purge, + .. + } => { + assert_eq!(ObjectType::Table, object_type); + assert!(!if_exists); + assert!(!purge); + } + _ => unreachable!(), + } +} + #[test] fn test_snowflake_create_table_trailing_options() { // Serialization to SQL assume that in `CREATE TABLE AS` the options come before the `AS ()` From ba170b1df03e8abd9f063456b9c158c8455b9d81 Mon Sep 17 00:00:00 2001 From: sabir Date: Wed, 24 Jun 2026 03:20:07 +0200 Subject: [PATCH 09/59] Snowflake: parse SHOW PROCEDURES [LIKE] --- src/ast/mod.rs | 14 ++++++++++++++ src/ast/spans.rs | 1 + src/dialect/snowflake.rs | 9 +++++++++ src/keywords.rs | 1 + 4 files changed, 25 insertions(+) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index d3a9cd2749..3cca02ec3d 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -5148,6 +5148,13 @@ pub enum Statement { filter: Option, }, /// ```sql + /// SHOW PROCEDURES [ LIKE '' ] + /// ``` + ShowProcedures { + /// Optional `LIKE` filter. + filter: Option, + }, + /// ```sql /// CREATE [OR REPLACE] CATALOG INTEGRATION [IF NOT EXISTS] ... /// ``` /// See @@ -7348,6 +7355,13 @@ impl fmt::Display for Statement { } Ok(()) } + Statement::ShowProcedures { filter } => { + write!(f, "SHOW PROCEDURES")?; + if let Some(filter) = filter { + write!(f, " {filter}")?; + } + Ok(()) + } Statement::CreateCatalogIntegration { or_replace, if_not_exists, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index e009535a7f..5c660be44e 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -559,6 +559,7 @@ impl Spanned for Statement { Statement::DropRowAccessPolicy { .. } => Span::empty(), Statement::DescribeRowAccessPolicy { .. } => Span::empty(), Statement::ShowRowAccessPolicies { .. } => Span::empty(), + Statement::ShowProcedures { .. } => Span::empty(), Statement::CreateCatalogIntegration { .. } => Span::empty(), Statement::DropCatalogIntegration { .. } => Span::empty(), Statement::ShowCatalogIntegrations { .. } => Span::empty(), diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 3a4f7b9cbd..de38143eed 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -498,6 +498,9 @@ impl Dialect for SnowflakeDialect { if parser.parse_keywords(&[Keyword::ROW, Keyword::ACCESS, Keyword::POLICIES]) { return Some(parse_show_row_access_policies(parser)); } + if parser.parse_keyword(Keyword::PROCEDURES) { + return Some(parse_show_procedures(parser)); + } //Give back Keyword::TERSE if terse { parser.prev_token(); @@ -2639,6 +2642,12 @@ fn parse_show_row_access_policies(parser: &mut Parser) -> Result']` +fn parse_show_procedures(parser: &mut Parser) -> Result { + let filter = parser.parse_show_statement_filter()?; + Ok(Statement::ShowProcedures { filter }) +} + /// Parse `SHOW WAREHOUSES [LIKE '']` fn parse_show_warehouses(parser: &mut Parser) -> Result { let filter = parser.parse_show_statement_filter()?; diff --git a/src/keywords.rs b/src/keywords.rs index 9a9b213745..177a0d666d 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -854,6 +854,7 @@ define_keywords!( PRIOR, PRIVILEGES, PROCEDURE, + PROCEDURES, PROCESSLIST, PROFILE, PROGRAM, From fc7762937086ac1e263b332440b8164a54495630 Mon Sep 17 00:00:00 2001 From: sabir Date: Wed, 24 Jun 2026 05:02:46 +0200 Subject: [PATCH 10/59] Snowflake: parse SHOW [TERSE] SEQUENCES [LIKE/IN ...] --- src/ast/mod.rs | 19 +++++++++++++++++++ src/ast/spans.rs | 1 + src/dialect/snowflake.rs | 12 ++++++++++++ 3 files changed, 32 insertions(+) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 3cca02ec3d..6975ce2494 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -5095,6 +5095,15 @@ pub enum Statement { show_options: ShowStatementOptions, }, /// ```sql + /// SHOW [TERSE] SEQUENCES [ LIKE '' ] [ IN ... ] ... + /// ``` + ShowSequences { + /// Whether to show terse output. + terse: bool, + /// Options controlling the SHOW output (`LIKE` / `IN` / `LIMIT` / ...). + show_options: ShowStatementOptions, + }, + /// ```sql /// CREATE [OR REPLACE] ROW ACCESS POLICY [IF NOT EXISTS] /// AS () RETURNS BOOLEAN -> /// ``` @@ -7311,6 +7320,16 @@ impl fmt::Display for Statement { terse = if *terse { "TERSE " } else { "" }, ) } + Statement::ShowSequences { + terse, + show_options, + } => { + write!( + f, + "SHOW {terse}SEQUENCES{show_options}", + terse = if *terse { "TERSE " } else { "" }, + ) + } Statement::CreateRowAccessPolicy { or_replace, if_not_exists, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 5c660be44e..ee4a349312 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -554,6 +554,7 @@ impl Spanned for Statement { Statement::DescribeFileFormat { .. } => Span::empty(), Statement::ShowFileFormats { .. } => Span::empty(), Statement::ShowStages { .. } => Span::empty(), + Statement::ShowSequences { .. } => Span::empty(), Statement::CreateRowAccessPolicy { .. } => Span::empty(), Statement::AlterRowAccessPolicy { .. } => Span::empty(), Statement::DropRowAccessPolicy { .. } => Span::empty(), diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index de38143eed..2e744152e9 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -495,6 +495,9 @@ impl Dialect for SnowflakeDialect { if parser.parse_keyword(Keyword::STAGES) { return Some(parse_show_stages(terse, parser)); } + if parser.parse_keyword(Keyword::SEQUENCES) { + return Some(parse_show_sequences(terse, parser)); + } if parser.parse_keywords(&[Keyword::ROW, Keyword::ACCESS, Keyword::POLICIES]) { return Some(parse_show_row_access_policies(parser)); } @@ -2569,6 +2572,15 @@ fn parse_show_stages(terse: bool, parser: &mut Parser) -> Result Result { + let show_options = parser.parse_show_stmt_options()?; + Ok(Statement::ShowSequences { + terse, + show_options, + }) +} + /// Parse `DESC[RIBE] WAREHOUSE ` fn parse_describe_warehouse(parser: &mut Parser) -> Result { let name = parser.parse_object_name(false)?; From abdb91a6dda986812b0a55fcfa6b4b4991afa1f3 Mon Sep 17 00:00:00 2001 From: sabir Date: Wed, 24 Jun 2026 05:23:41 +0200 Subject: [PATCH 11/59] Snowflake: parse SHOW [TERSE] PRIMARY/IMPORTED/EXPORTED KEYS --- src/ast/mod.rs | 47 ++++++++++++++++++++++++++++++++++++++++ src/ast/spans.rs | 1 + src/dialect/snowflake.rs | 27 ++++++++++++++++++++++- src/keywords.rs | 1 + 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 6975ce2494..6a25d2abab 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -2502,6 +2502,29 @@ impl fmt::Display for ShowCreateObject { } } +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +/// Key-constraint catalog selected by a `SHOW ... KEYS` statement. +pub enum ShowKeysKind { + /// `SHOW [TERSE] PRIMARY KEYS` + Primary, + /// `SHOW IMPORTED KEYS` + Imported, + /// `SHOW EXPORTED KEYS` + Exported, +} + +impl fmt::Display for ShowKeysKind { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ShowKeysKind::Primary => f.write_str("PRIMARY"), + ShowKeysKind::Imported => f.write_str("IMPORTED"), + ShowKeysKind::Exported => f.write_str("EXPORTED"), + } + } +} + #[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] @@ -5104,6 +5127,19 @@ pub enum Statement { show_options: ShowStatementOptions, }, /// ```sql + /// SHOW [TERSE] PRIMARY KEYS [ IN ... ] + /// SHOW IMPORTED KEYS [ IN ... ] + /// SHOW EXPORTED KEYS [ IN ... ] + /// ``` + ShowKeys { + /// Which key-constraint catalog to show. + kind: ShowKeysKind, + /// Whether to show terse output (only meaningful for `PRIMARY KEYS`). + terse: bool, + /// Options controlling the SHOW output (`LIKE` / `IN` / `LIMIT` / ...). + show_options: ShowStatementOptions, + }, + /// ```sql /// CREATE [OR REPLACE] ROW ACCESS POLICY [IF NOT EXISTS] /// AS () RETURNS BOOLEAN -> /// ``` @@ -7330,6 +7366,17 @@ impl fmt::Display for Statement { terse = if *terse { "TERSE " } else { "" }, ) } + Statement::ShowKeys { + kind, + terse, + show_options, + } => { + write!( + f, + "SHOW {terse}{kind} KEYS{show_options}", + terse = if *terse { "TERSE " } else { "" }, + ) + } Statement::CreateRowAccessPolicy { or_replace, if_not_exists, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index ee4a349312..ca82e7e001 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -555,6 +555,7 @@ impl Spanned for Statement { Statement::ShowFileFormats { .. } => Span::empty(), Statement::ShowStages { .. } => Span::empty(), Statement::ShowSequences { .. } => Span::empty(), + Statement::ShowKeys { .. } => Span::empty(), Statement::CreateRowAccessPolicy { .. } => Span::empty(), Statement::AlterRowAccessPolicy { .. } => Span::empty(), Statement::DropRowAccessPolicy { .. } => Span::empty(), diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 2e744152e9..3f72d1e998 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -37,7 +37,8 @@ use crate::ast::{ IdentityPropertyOrder, InitializeKind, Insert, MultiTableInsertIntoClause, MultiTableInsertType, MultiTableInsertValue, MultiTableInsertValues, MultiTableInsertWhenClause, ObjectName, ObjectNamePart, RefreshModeKind, RowAccessPolicy, - ShowObjects, SqlOption, Statement, StorageLifecyclePolicy, StorageSerializationPolicy, + ShowKeysKind, ShowObjects, SqlOption, Statement, StorageLifecyclePolicy, + StorageSerializationPolicy, TableObject, TagsColumnOption, Value, WrappedCollection, }; use crate::dialect::{Dialect, Precedence}; @@ -498,6 +499,15 @@ impl Dialect for SnowflakeDialect { if parser.parse_keyword(Keyword::SEQUENCES) { return Some(parse_show_sequences(terse, parser)); } + if parser.parse_keyword(Keyword::PRIMARY) { + return Some(parse_show_keys(ShowKeysKind::Primary, terse, parser)); + } + if parser.parse_keyword(Keyword::IMPORTED) { + return Some(parse_show_keys(ShowKeysKind::Imported, terse, parser)); + } + if parser.parse_keyword(Keyword::EXPORTED) { + return Some(parse_show_keys(ShowKeysKind::Exported, terse, parser)); + } if parser.parse_keywords(&[Keyword::ROW, Keyword::ACCESS, Keyword::POLICIES]) { return Some(parse_show_row_access_policies(parser)); } @@ -2581,6 +2591,21 @@ fn parse_show_sequences(terse: bool, parser: &mut Parser) -> Result Result { + parser.expect_keyword(Keyword::KEYS)?; + let show_options = parser.parse_show_stmt_options()?; + Ok(Statement::ShowKeys { + kind, + terse, + show_options, + }) +} + /// Parse `DESC[RIBE] WAREHOUSE ` fn parse_describe_warehouse(parser: &mut Parser) -> Result { let name = parser.parse_object_name(false)?; diff --git a/src/keywords.rs b/src/keywords.rs index 177a0d666d..8ca243a13a 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -428,6 +428,7 @@ define_keywords!( EXPLAIN, EXPLICIT, EXPORT, + EXPORTED, EXTEND, EXTENDED, EXTENSION, From 8d240b227b7612f58d21779e1f5e5b542605ebb0 Mon Sep 17 00:00:00 2001 From: Wojciech Padlo Date: Mon, 22 Jun 2026 17:14:23 +0200 Subject: [PATCH 12/59] Snowflake: FILE_FORMAT = shorthand in COPY INTO --- src/dialect/snowflake.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 3f72d1e998..ed3b481f7e 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -1677,7 +1677,25 @@ pub fn parse_copy_into(parser: &mut Parser) -> Result { // FILE_FORMAT if parser.parse_keyword(Keyword::FILE_FORMAT) { parser.expect_token(&Token::Eq)?; - file_format = parser.parse_key_value_options(true, &[], false)?.options; + if parser.peek_token().token == Token::LParen { + file_format = parser.parse_key_value_options(true, &[], false)?.options; + } else { + // Shorthand `FILE_FORMAT = ''` / `FILE_FORMAT = ` + // is sugar for `FILE_FORMAT = (FORMAT_NAME = )` — + // normalize it (mirrors CREATE STAGE). + let tok = parser.peek_token(); + let value = match tok.token { + Token::Word(w) => { + parser.next_token(); + Value::Placeholder(w.value.clone()).with_span(tok.span) + } + _ => parser.parse_value()?, + }; + file_format = vec![KeyValueOption { + option_name: "FORMAT_NAME".to_string(), + option_value: KeyValueOptionKind::Single(value), + }]; + } // PARTITION BY } else if parser.parse_keywords(&[Keyword::PARTITION, Keyword::BY]) { partition = Some(Box::new(parser.parse_expr()?)) From 6d1428ea08995f49c4d239996895e76a3f621139 Mon Sep 17 00:00:00 2001 From: Wojciech Padlo Date: Mon, 22 Jun 2026 17:46:01 +0200 Subject: [PATCH 13/59] Snowflake: test FILE_FORMAT = shorthand; fix pre-existing rustfmt Add a parser test covering the FILE_FORMAT = COPY INTO shorthand (unquoted ident, quoted string, and the unaffected parenthesized form). Also reflow a pre-existing unformatted block in parser/mod.rs so 'cargo fmt --all -- --check' (CI codestyle) passes. --- src/parser/mod.rs | 17 +++++--- tests/sqlparser_snowflake.rs | 77 ++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index a10f8b93c5..f0a160cea7 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1886,16 +1886,21 @@ impl<'a> Parser<'a> { // callee name (an `ObjectNamePart::Function`) and the trailing // `(args)` is the actual call — mirror the object-name grammar // so the resulting `Expr::Function` matches the CALL path shape. - if self.dialect.is_identifier_generating_function_name(&ident, &[]) { + if self + .dialect + .is_identifier_generating_function_name(&ident, &[]) + { let checkpoint = self.index; self.expect_token(&Token::LParen)?; - let args = self - .parse_comma_separated0(Self::parse_function_args, Token::RParen)?; + let args = + self.parse_comma_separated0(Self::parse_function_args, Token::RParen)?; self.expect_token(&Token::RParen)?; if self.peek_token_ref().token == Token::LParen { - let name = ObjectName(vec![ObjectNamePart::Function( - ObjectNamePartFunction { name: ident, args }, - )]); + let name = + ObjectName(vec![ObjectNamePart::Function(ObjectNamePartFunction { + name: ident, + args, + })]); return self.parse_function(name); } self.index = checkpoint; diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 18b7aaaf15..a23eeea1fc 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -2789,6 +2789,83 @@ fn test_copy_into_file_format() { } } +#[test] +fn test_copy_into_file_format_name_shorthand() { + // Bare `FILE_FORMAT = ` is sugar for + // `FILE_FORMAT = (FORMAT_NAME = )`, for both the location-unload and + // table-load forms. An unquoted identifier parses to a `Placeholder`. + for sql in [ + "COPY INTO @my_stage/out.csv FROM (SELECT * FROM t) FILE_FORMAT = my_format", + "COPY INTO my_table FROM @my_stage FILE_FORMAT = my_format", + ] { + match snowflake() + .parse_sql_statements(sql) + .unwrap() + .pop() + .unwrap() + { + Statement::CopyIntoSnowflake { file_format, .. } => { + assert_eq!( + file_format.options, + vec![KeyValueOption { + option_name: "FORMAT_NAME".to_string(), + option_value: KeyValueOptionKind::Single( + Value::Placeholder("my_format".to_string()).with_empty_span() + ), + }] + ); + } + _ => unreachable!(), + } + } + + // A quoted name parses to a `SingleQuotedString`. + match snowflake() + .parse_sql_statements( + "COPY INTO @my_stage/out.csv FROM (SELECT * FROM t) FILE_FORMAT = 'my_format'", + ) + .unwrap() + .pop() + .unwrap() + { + Statement::CopyIntoSnowflake { file_format, .. } => { + assert_eq!( + file_format.options, + vec![KeyValueOption { + option_name: "FORMAT_NAME".to_string(), + option_value: KeyValueOptionKind::Single( + Value::SingleQuotedString("my_format".to_string()).with_empty_span() + ), + }] + ); + } + _ => unreachable!(), + } + + // The parenthesized form is unaffected. + match snowflake() + .parse_sql_statements( + "COPY INTO @my_stage/out.csv FROM (SELECT * FROM t) FILE_FORMAT = (TYPE = 'CSV')", + ) + .unwrap() + .pop() + .unwrap() + { + Statement::CopyIntoSnowflake { file_format, .. } => { + assert_eq!( + file_format.options, + vec![KeyValueOption { + option_name: "TYPE".to_string(), + option_value: KeyValueOptionKind::Single( + Value::SingleQuotedString("CSV".to_string()).with_empty_span() + ), + }] + ); + } + _ => unreachable!(), + } +} + #[test] fn test_copy_into_copy_options() { let sql = concat!( From 18e99b5ba69345c4ef86872dd7dfc80ad9cfdbf5 Mon Sep 17 00:00:00 2001 From: sabir-akhadov-localstack Date: Thu, 25 Jun 2026 11:14:39 +0200 Subject: [PATCH 14/59] =?UTF-8?q?Snowflake:=20parse=20ALL/FUTURE=20?= =?UTF-8?q?=E2=80=A6=20IN=20DATABASE=20grant=20targets,=20STAGE/FILE=20FOR?= =?UTF-8?q?MAT=20objects,=20WRITE=20+=20CREATE=20TABLE=20privileges=20(#12?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ast/mod.rs | 112 ++++++++++++++++++++++++++++++++++++++++++++++ src/parser/mod.rs | 86 +++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 6a25d2abab..ebb2dcd0a0 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -8376,6 +8376,8 @@ pub enum Action { /// Read access. Read, + /// Write access. + Write, /// Read session-level access. ReadSession, /// References with optional column list. @@ -8469,6 +8471,7 @@ impl fmt::Display for Action { Action::Ownership => f.write_str("OWNERSHIP")?, Action::PurchaseDataExchangeListing => f.write_str("PURCHASE DATA EXCHANGE LISTING")?, Action::Read => f.write_str("READ")?, + Action::Write => f.write_str("WRITE")?, Action::ReadSession => f.write_str("READ SESSION")?, Action::References { .. } => f.write_str("REFERENCES")?, Action::Replicate => f.write_str("REPLICATE")?, @@ -8537,6 +8540,8 @@ pub enum ActionCreateObjectType { Schema, /// A share object. Share, + /// A table object. + Table, /// A user object. User, /// A warehouse object. @@ -8563,6 +8568,7 @@ impl fmt::Display for ActionCreateObjectType { ActionCreateObjectType::Role => write!(f, "ROLE"), ActionCreateObjectType::Schema => write!(f, "SCHEMA"), ActionCreateObjectType::Share => write!(f, "SHARE"), + ActionCreateObjectType::Table => write!(f, "TABLE"), ActionCreateObjectType::User => write!(f, "USER"), ActionCreateObjectType::Warehouse => write!(f, "WAREHOUSE"), } @@ -8895,6 +8901,46 @@ pub enum GrantObjects { /// The target schema names. schemas: Vec, }, + /// Grant privileges on `ALL SCHEMAS IN DATABASE [, ...]` + AllSchemasInDatabase { + /// The target database names. + databases: Vec, + }, + /// Grant privileges on `ALL TABLES IN DATABASE [, ...]` + AllTablesInDatabase { + /// The target database names. + databases: Vec, + }, + /// Grant privileges on `ALL STAGES IN SCHEMA [, ...]` + AllStagesInSchema { + /// The target schema names. + schemas: Vec, + }, + /// Grant privileges on `ALL FILE FORMATS IN SCHEMA [, ...]` + AllFileFormatsInSchema { + /// The target schema names. + schemas: Vec, + }, + /// Grant privileges on `FUTURE TABLES IN DATABASE [, ...]` + FutureTablesInDatabase { + /// The target database names. + databases: Vec, + }, + /// Grant privileges on `FUTURE STAGES IN SCHEMA [, ...]` + FutureStagesInSchema { + /// The target schema names. + schemas: Vec, + }, + /// Grant privileges on `FUTURE FILE FORMATS IN SCHEMA [, ...]` + FutureFileFormatsInSchema { + /// The target schema names. + schemas: Vec, + }, + /// Grant privileges on `FUTURE FUNCTIONS IN SCHEMA [, ...]` + FutureFunctionsInSchema { + /// The target schema names. + schemas: Vec, + }, /// Grant privileges on specific databases Databases(Vec), /// Grant privileges on specific schemas @@ -8913,6 +8959,10 @@ pub enum GrantObjects { ResourceMonitors(Vec), /// Grant privileges on users Users(Vec), + /// Grant privileges on specific stages + Stages(Vec), + /// Grant privileges on specific file formats + FileFormats(Vec), /// Grant privileges on compute pools ComputePools(Vec), /// Grant privileges on connections @@ -9056,12 +9106,74 @@ impl fmt::Display for GrantObjects { display_comma_separated(schemas) ) } + GrantObjects::AllSchemasInDatabase { databases } => { + write!( + f, + "ALL SCHEMAS IN DATABASE {}", + display_comma_separated(databases) + ) + } + GrantObjects::AllTablesInDatabase { databases } => { + write!( + f, + "ALL TABLES IN DATABASE {}", + display_comma_separated(databases) + ) + } + GrantObjects::AllStagesInSchema { schemas } => { + write!( + f, + "ALL STAGES IN SCHEMA {}", + display_comma_separated(schemas) + ) + } + GrantObjects::AllFileFormatsInSchema { schemas } => { + write!( + f, + "ALL FILE FORMATS IN SCHEMA {}", + display_comma_separated(schemas) + ) + } + GrantObjects::FutureTablesInDatabase { databases } => { + write!( + f, + "FUTURE TABLES IN DATABASE {}", + display_comma_separated(databases) + ) + } + GrantObjects::FutureStagesInSchema { schemas } => { + write!( + f, + "FUTURE STAGES IN SCHEMA {}", + display_comma_separated(schemas) + ) + } + GrantObjects::FutureFileFormatsInSchema { schemas } => { + write!( + f, + "FUTURE FILE FORMATS IN SCHEMA {}", + display_comma_separated(schemas) + ) + } + GrantObjects::FutureFunctionsInSchema { schemas } => { + write!( + f, + "FUTURE FUNCTIONS IN SCHEMA {}", + display_comma_separated(schemas) + ) + } GrantObjects::ResourceMonitors(objects) => { write!(f, "RESOURCE MONITOR {}", display_comma_separated(objects)) } GrantObjects::Users(objects) => { write!(f, "USER {}", display_comma_separated(objects)) } + GrantObjects::Stages(objects) => { + write!(f, "STAGE {}", display_comma_separated(objects)) + } + GrantObjects::FileFormats(objects) => { + write!(f, "FILE FORMAT {}", display_comma_separated(objects)) + } GrantObjects::ComputePools(objects) => { write!(f, "COMPUTE POOL {}", display_comma_separated(objects)) } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index f0a160cea7..88f1e53e4b 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -18150,6 +18150,80 @@ impl<'a> Parser<'a> { Some(GrantObjects::FutureSequencesInSchema { schemas: self.parse_comma_separated(|p| p.parse_object_name(false))?, }) + } else if self.parse_keywords(&[ + Keyword::ALL, + Keyword::SCHEMAS, + Keyword::IN, + Keyword::DATABASE, + ]) { + Some(GrantObjects::AllSchemasInDatabase { + databases: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) + } else if self.parse_keywords(&[ + Keyword::ALL, + Keyword::TABLES, + Keyword::IN, + Keyword::DATABASE, + ]) { + Some(GrantObjects::AllTablesInDatabase { + databases: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) + } else if self.parse_keywords(&[ + Keyword::ALL, + Keyword::STAGES, + Keyword::IN, + Keyword::SCHEMA, + ]) { + Some(GrantObjects::AllStagesInSchema { + schemas: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) + } else if self.parse_keywords(&[ + Keyword::ALL, + Keyword::FILE, + Keyword::FORMATS, + Keyword::IN, + Keyword::SCHEMA, + ]) { + Some(GrantObjects::AllFileFormatsInSchema { + schemas: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) + } else if self.parse_keywords(&[ + Keyword::FUTURE, + Keyword::TABLES, + Keyword::IN, + Keyword::DATABASE, + ]) { + Some(GrantObjects::FutureTablesInDatabase { + databases: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) + } else if self.parse_keywords(&[ + Keyword::FUTURE, + Keyword::STAGES, + Keyword::IN, + Keyword::SCHEMA, + ]) { + Some(GrantObjects::FutureStagesInSchema { + schemas: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) + } else if self.parse_keywords(&[ + Keyword::FUTURE, + Keyword::FILE, + Keyword::FORMATS, + Keyword::IN, + Keyword::SCHEMA, + ]) { + Some(GrantObjects::FutureFileFormatsInSchema { + schemas: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) + } else if self.parse_keywords(&[ + Keyword::FUTURE, + Keyword::FUNCTIONS, + Keyword::IN, + Keyword::SCHEMA, + ]) { + Some(GrantObjects::FutureFunctionsInSchema { + schemas: self.parse_comma_separated(|p| p.parse_object_name(false))?, + }) } else if self.parse_keywords(&[Keyword::RESOURCE, Keyword::MONITOR]) { Some(GrantObjects::ResourceMonitors( self.parse_comma_separated(|p| p.parse_object_name(false))?, @@ -18170,6 +18244,14 @@ impl<'a> Parser<'a> { Some(GrantObjects::ExternalVolumes( self.parse_comma_separated(|p| p.parse_object_name(false))?, )) + } else if self.parse_keywords(&[Keyword::FILE, Keyword::FORMAT]) { + Some(GrantObjects::FileFormats( + self.parse_comma_separated(|p| p.parse_object_name(false))?, + )) + } else if self.parse_keyword(Keyword::STAGE) { + Some(GrantObjects::Stages( + self.parse_comma_separated(|p| p.parse_object_name(false))?, + )) } else { let object_type = self.parse_one_of_keywords(&[ Keyword::SEQUENCE, @@ -18334,6 +18416,8 @@ impl<'a> Parser<'a> { }) } else if self.parse_keyword(Keyword::READ) { Ok(Action::Read) + } else if self.parse_keyword(Keyword::WRITE) { + Ok(Action::Write) } else if self.parse_keyword(Keyword::REPLICATE) { Ok(Action::Replicate) } else if self.parse_keyword(Keyword::ROLE) { @@ -18400,6 +18484,8 @@ impl<'a> Parser<'a> { Some(ActionCreateObjectType::Schema) } else if self.parse_keyword(Keyword::SHARE) { Some(ActionCreateObjectType::Share) + } else if self.parse_keyword(Keyword::TABLE) { + Some(ActionCreateObjectType::Table) } else if self.parse_keyword(Keyword::USER) { Some(ActionCreateObjectType::User) } else if self.parse_keyword(Keyword::WAREHOUSE) { From fcc476ed2a1b59a2dc612ee669d0a360dfd28a59 Mon Sep 17 00:00:00 2001 From: sabir Date: Fri, 26 Jun 2026 00:55:12 +0200 Subject: [PATCH 15/59] Snowflake: parse SHOW CONNECTIONS and SHOW SHARES [LIKE] --- src/ast/mod.rs | 28 ++++++++++++++++++++++++++++ src/ast/spans.rs | 2 ++ src/dialect/snowflake.rs | 18 ++++++++++++++++++ src/keywords.rs | 2 ++ 4 files changed, 50 insertions(+) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index ebb2dcd0a0..5dda453da2 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -5200,6 +5200,20 @@ pub enum Statement { filter: Option, }, /// ```sql + /// SHOW CONNECTIONS [ LIKE '' ] + /// ``` + ShowConnections { + /// Optional `LIKE` filter. + filter: Option, + }, + /// ```sql + /// SHOW SHARES [ LIKE '' ] + /// ``` + ShowShares { + /// Optional `LIKE` filter. + filter: Option, + }, + /// ```sql /// CREATE [OR REPLACE] CATALOG INTEGRATION [IF NOT EXISTS] ... /// ``` /// See @@ -7428,6 +7442,20 @@ impl fmt::Display for Statement { } Ok(()) } + Statement::ShowConnections { filter } => { + write!(f, "SHOW CONNECTIONS")?; + if let Some(filter) = filter { + write!(f, " {filter}")?; + } + Ok(()) + } + Statement::ShowShares { filter } => { + write!(f, "SHOW SHARES")?; + if let Some(filter) = filter { + write!(f, " {filter}")?; + } + Ok(()) + } Statement::CreateCatalogIntegration { or_replace, if_not_exists, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index ca82e7e001..7183c514a9 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -562,6 +562,8 @@ impl Spanned for Statement { Statement::DescribeRowAccessPolicy { .. } => Span::empty(), Statement::ShowRowAccessPolicies { .. } => Span::empty(), Statement::ShowProcedures { .. } => Span::empty(), + Statement::ShowConnections { .. } => Span::empty(), + Statement::ShowShares { .. } => Span::empty(), Statement::CreateCatalogIntegration { .. } => Span::empty(), Statement::DropCatalogIntegration { .. } => Span::empty(), Statement::ShowCatalogIntegrations { .. } => Span::empty(), diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index ed3b481f7e..f7a5fec556 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -514,6 +514,12 @@ impl Dialect for SnowflakeDialect { if parser.parse_keyword(Keyword::PROCEDURES) { return Some(parse_show_procedures(parser)); } + if parser.parse_keyword(Keyword::CONNECTIONS) { + return Some(parse_show_connections(parser)); + } + if parser.parse_keyword(Keyword::SHARES) { + return Some(parse_show_shares(parser)); + } //Give back Keyword::TERSE if terse { parser.prev_token(); @@ -2709,6 +2715,18 @@ fn parse_show_warehouses(parser: &mut Parser) -> Result Ok(Statement::ShowWarehouses { filter }) } +/// Parse `SHOW CONNECTIONS [LIKE '']` +fn parse_show_connections(parser: &mut Parser) -> Result { + let filter = parser.parse_show_statement_filter()?; + Ok(Statement::ShowConnections { filter }) +} + +/// Parse `SHOW SHARES [LIKE '']` +fn parse_show_shares(parser: &mut Parser) -> Result { + let filter = parser.parse_show_statement_filter()?; + Ok(Statement::ShowShares { filter }) +} + /// Parse `SHOW ACCOUNTS [HISTORY] [LIKE '']` fn parse_show_accounts(parser: &mut Parser) -> Result { let history = parser.parse_keyword(Keyword::HISTORY); diff --git a/src/keywords.rs b/src/keywords.rs index 8ca243a13a..b0b92ac3f2 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -268,6 +268,7 @@ define_keywords!( CONFLICT, CONNECT, CONNECTION, + CONNECTIONS, CONNECTOR, CONNECT_BY_ROOT, CONSTRAINT, @@ -998,6 +999,7 @@ define_keywords!( SETTINGS, SHARE, SHARED, + SHARES, SHARING, SHOW, SIGNED, From 2df64795f65f2e62c0ea7b9a4a7f70bff533284c Mon Sep 17 00:00:00 2001 From: Przemyslaw Denkiewicz Date: Fri, 26 Jun 2026 12:23:15 +0200 Subject: [PATCH 16/59] Snowflake: parse multi-column ALTER TABLE ADD COLUMN --- src/ast/ddl.rs | 27 +++++++++++++++++++++++ src/ast/spans.rs | 5 +++++ src/dialect/mod.rs | 6 ++++++ src/dialect/snowflake.rs | 4 ++++ src/parser/mod.rs | 37 +++++++++++++++++++++++++------ tests/sqlparser_snowflake.rs | 42 ++++++++++++++++++++++++++++++++++++ 6 files changed, 114 insertions(+), 7 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index f347a8a807..756bf4f4ca 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -143,6 +143,18 @@ pub enum AlterTableOperation { /// MySQL `ALTER TABLE` only [FIRST | AFTER column_name] column_position: Option, }, + /// `ADD [COLUMN] [IF NOT EXISTS] [, , ...]` + /// + /// Snowflake multi-column add: a single `ADD [COLUMN]` keyword followed by + /// a comma-separated list of column definitions. + AddColumns { + /// `[COLUMN]`. + column_keyword: bool, + /// `[IF NOT EXISTS]` + if_not_exists: bool, + /// The column definitions to add. + column_defs: Vec, + }, /// `ADD PROJECTION [IF NOT EXISTS] name ( SELECT [GROUP BY] [ORDER BY])` /// /// Note: this is a ClickHouse-specific operation. @@ -753,6 +765,21 @@ impl fmt::Display for AlterTableOperation { Ok(()) } + AlterTableOperation::AddColumns { + column_keyword, + if_not_exists, + column_defs, + } => { + write!(f, "ADD")?; + if *column_keyword { + write!(f, " COLUMN")?; + } + if *if_not_exists { + write!(f, " IF NOT EXISTS")?; + } + write!(f, " {}", display_comma_separated(column_defs))?; + Ok(()) + } AlterTableOperation::AddProjection { if_not_exists, name, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 7183c514a9..350016dd69 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -1202,6 +1202,11 @@ impl Spanned for AlterTableOperation { column_def, column_position: _, } => column_def.span(), + AlterTableOperation::AddColumns { + column_keyword: _, + if_not_exists: _, + column_defs, + } => union_spans(column_defs.iter().map(|c| c.span())), AlterTableOperation::AddProjection { if_not_exists: _, name, diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 8afe594dc0..2f42260186 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1453,6 +1453,12 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect supports + /// `ALTER TABLE tbl ADD [COLUMN] c1 , ..., cn ` + fn supports_comma_separated_add_column_list(&self) -> bool { + false + } + /// Returns true if the dialect considers the specified ident as a function /// that returns an identifier. Typically used to generate identifiers /// programmatically. diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index f7a5fec556..e76351a141 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -795,6 +795,10 @@ impl Dialect for SnowflakeDialect { true } + fn supports_comma_separated_add_column_list(&self) -> bool { + true + } + fn is_identifier_generating_function_name( &self, ident: &Ident, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 88f1e53e4b..177739a7d2 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -10783,15 +10783,38 @@ impl<'a> Parser<'a> { false }; - let column_def = self.parse_column_def()?; + if self.dialect.supports_comma_separated_add_column_list() { + let mut column_defs = vec![self.parse_column_def()?]; + while self.consume_token(&Token::Comma) { + column_defs.push(self.parse_column_def()?); + } + if column_defs.len() == 1 { + let column_def = column_defs.swap_remove(0); + let column_position = self.parse_column_position()?; + AlterTableOperation::AddColumn { + column_keyword, + if_not_exists, + column_def, + column_position, + } + } else { + AlterTableOperation::AddColumns { + column_keyword, + if_not_exists, + column_defs, + } + } + } else { + let column_def = self.parse_column_def()?; - let column_position = self.parse_column_position()?; + let column_position = self.parse_column_position()?; - AlterTableOperation::AddColumn { - column_keyword, - if_not_exists, - column_def, - column_position, + AlterTableOperation::AddColumn { + column_keyword, + if_not_exists, + column_def, + column_position, + } } } } diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index a23eeea1fc..f9f2ab7c75 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -1892,6 +1892,48 @@ fn test_alter_table_add_column_if_not_exists() { } } +#[test] +fn test_alter_table_add_multiple_columns() { + let sql = "ALTER TABLE tab ADD COLUMN a INT, b TEXT"; + match alter_table_op(snowflake().verified_stmt(sql)) { + AlterTableOperation::AddColumns { + column_keyword, + if_not_exists, + column_defs, + } => { + assert!(column_keyword); + assert!(!if_not_exists); + assert_eq!(column_defs.len(), 2); + assert_eq!(column_defs[0].name.to_string(), "a"); + assert_eq!(column_defs[1].name.to_string(), "b"); + } + _ => unreachable!(), + } + + let sql = "ALTER TABLE tab ADD COLUMN IF NOT EXISTS a INT, b TEXT"; + match alter_table_op(snowflake().verified_stmt(sql)) { + AlterTableOperation::AddColumns { + column_keyword, + if_not_exists, + column_defs, + } => { + assert!(column_keyword); + assert!(if_not_exists); + assert_eq!(column_defs.len(), 2); + } + _ => unreachable!(), + } + + // A single-column list still parses to the AddColumn variant. + let sql = "ALTER TABLE tab ADD COLUMN a INT"; + match alter_table_op(snowflake().verified_stmt(sql)) { + AlterTableOperation::AddColumn { column_def, .. } => { + assert_eq!(column_def.name.to_string(), "a"); + } + _ => unreachable!(), + } +} + #[test] fn test_alter_iceberg_table() { snowflake_and_generic().verified_stmt("ALTER ICEBERG TABLE tbl DROP CLUSTERING KEY"); From 8bab6fcd4d619c57e7261997f1e44910aef4df01 Mon Sep 17 00:00:00 2001 From: Przemyslaw Denkiewicz Date: Mon, 29 Jun 2026 12:22:58 +0200 Subject: [PATCH 17/59] Snowflake: accept dollar-quoted text as a literal string parse_literal_string now consumes Token::DollarQuotedString in the Snowflake dialect, so $$...$$ is accepted anywhere a string literal is expected (e.g. COMMENT ON ... IS $$...$$). --- src/parser/mod.rs | 1 + tests/sqlparser_snowflake.rs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 177739a7d2..282320c1b8 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -12911,6 +12911,7 @@ impl<'a> Parser<'a> { Ok(s) } Token::UnicodeStringLiteral(s) => Ok(s), + Token::DollarQuotedString(s) if dialect_of!(self is SnowflakeDialect) => Ok(s.value), _ => self.expected("literal string", next_token), } } diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index f9f2ab7c75..7ad845114f 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -1253,6 +1253,25 @@ fn parse_sf_create_table_or_view_with_dollar_quoted_comment() { ); } +#[test] +fn parse_sf_dollar_quoted_literal_string() { + // Dollar-quoted text is accepted wherever a literal string is expected. + snowflake().one_statement_parses_to( + "COMMENT ON TABLE my_table IS $$table comment$$", + "COMMENT ON TABLE my_table IS 'table comment'", + ); + snowflake().one_statement_parses_to( + "COMMENT ON COLUMN my_table.col IS $$$$", + "COMMENT ON COLUMN my_table.col IS ''", + ); + + // Other dialects keep rejecting dollar-quoted text in a literal-string + // position. + assert!(TestedDialects::new(vec![Box::new(GenericDialect {})]) + .parse_sql_statements("COMMENT ON TABLE my_table IS $$table comment$$") + .is_err()); +} + #[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"#); From 2540681f65b3a91e9f446de8748371e5a89fa52c Mon Sep 17 00:00:00 2001 From: Przemyslaw Denkiewicz Date: Mon, 29 Jun 2026 17:34:37 +0200 Subject: [PATCH 18/59] Snowflake: parse ALTER TABLE ALTER COLUMN ... COMMENT --- src/ast/ddl.rs | 12 ++++++++ src/ast/spans.rs | 1 + src/dialect/mod.rs | 8 ++++++ src/dialect/snowflake.rs | 4 +++ src/parser/mod.rs | 20 +++++++++++++ tests/sqlparser_snowflake.rs | 56 ++++++++++++++++++++++++++++++++++++ 6 files changed, 101 insertions(+) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 756bf4f4ca..8da401ef99 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -1337,6 +1337,15 @@ pub enum AlterColumnOperation { had_set: bool, }, + /// `COMMENT ''` + /// + /// Snowflake: set the column comment + /// (`ALTER TABLE t ALTER COLUMN c COMMENT ''`). + Comment { + /// The comment text. + comment: String, + }, + /// `ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( sequence_options ) ]` /// /// Note: this is a PostgreSQL-specific operation. @@ -1373,6 +1382,9 @@ impl fmt::Display for AlterColumnOperation { } Ok(()) } + AlterColumnOperation::Comment { comment } => { + write!(f, "COMMENT '{}'", escape_single_quote_string(comment)) + } AlterColumnOperation::AddGenerated { generated_as, sequence_options, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 350016dd69..4b6df9294b 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -996,6 +996,7 @@ impl Spanned for AlterColumnOperation { using, had_set: _, } => using.as_ref().map_or(Span::empty(), |u| u.span()), + AlterColumnOperation::Comment { .. } => Span::empty(), AlterColumnOperation::AddGenerated { .. } => Span::empty(), } } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 2f42260186..2e59458ee8 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1459,6 +1459,14 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect supports setting a column comment via + /// `ALTER TABLE tbl ALTER COLUMN c COMMENT ''`, including the + /// comma-separated multi-column form + /// `ALTER COLUMN c1 COMMENT '', COLUMN c2 COMMENT ''`. + fn supports_alter_column_comment(&self) -> bool { + false + } + /// Returns true if the dialect considers the specified ident as a function /// that returns an identifier. Typically used to generate identifiers /// programmatically. diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index e76351a141..32bba14889 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -799,6 +799,10 @@ impl Dialect for SnowflakeDialect { true } + fn supports_alter_column_comment(&self) -> bool { + true + } + fn is_identifier_generating_function_name( &self, ident: &Ident, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 282320c1b8..6e0b9fb4bc 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -11055,6 +11055,20 @@ impl<'a> Parser<'a> { options, column_position, } + } else if self.dialect.supports_alter_column_comment() + && self.parse_keyword(Keyword::COLUMN) + { + // Continuation of a comma-separated `ALTER COLUMN ... COMMENT` list, + // e.g. `... ALTER COLUMN c1 COMMENT 's1', COLUMN c2 COMMENT 's2'`. + // The second and later items carry `COLUMN` without a leading `ALTER`. + let column_name = self.parse_identifier()?; + self.expect_keyword_is(Keyword::COMMENT)?; + AlterTableOperation::AlterColumn { + column_name, + op: AlterColumnOperation::Comment { + comment: self.parse_literal_string()?, + }, + } } else if self.parse_keyword(Keyword::ALTER) { if self.peek_keyword(Keyword::SORTKEY) { self.prev_token(); @@ -11083,6 +11097,12 @@ impl<'a> Parser<'a> { self.parse_set_data_type(true)? } else if self.parse_keyword(Keyword::TYPE) { self.parse_set_data_type(false)? + } else if self.dialect.supports_alter_column_comment() + && self.parse_keyword(Keyword::COMMENT) + { + AlterColumnOperation::Comment { + comment: self.parse_literal_string()?, + } } else if self.parse_keywords(&[Keyword::ADD, Keyword::GENERATED]) { let generated_as = if self.parse_keyword(Keyword::ALWAYS) { Some(GeneratedAs::Always) diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 7ad845114f..aaac2ec86d 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -1953,6 +1953,62 @@ fn test_alter_table_add_multiple_columns() { } } +#[test] +fn test_alter_table_alter_column_comment() { + let sql = "ALTER TABLE tab ALTER COLUMN c COMMENT 'hello'"; + match alter_table_op(snowflake().verified_stmt(sql)) { + AlterTableOperation::AlterColumn { + column_name, + op: AlterColumnOperation::Comment { comment }, + } => { + assert_eq!(column_name.to_string(), "c"); + assert_eq!(comment, "hello"); + } + _ => unreachable!(), + } + + // Dollar-quoted comment values are accepted as plain string literals and + // canonicalise to single-quoted form on Display. + let sql = "ALTER TABLE tab ALTER COLUMN c COMMENT $$hi$$"; + let canonical = "ALTER TABLE tab ALTER COLUMN c COMMENT 'hi'"; + match alter_table_op(snowflake().one_statement_parses_to(sql, canonical)) { + AlterTableOperation::AlterColumn { + op: AlterColumnOperation::Comment { comment }, + .. + } => assert_eq!(comment, "hi"), + _ => unreachable!(), + } + + // Multi-column form: later items carry `COLUMN` without `ALTER`; Display + // canonicalises each item to the full `ALTER COLUMN` spelling. + let sql = "ALTER TABLE tab ALTER COLUMN c1 COMMENT 's1', COLUMN c2 COMMENT 's2'"; + let canonical = "ALTER TABLE tab ALTER COLUMN c1 COMMENT 's1', ALTER COLUMN c2 COMMENT 's2'"; + match snowflake().one_statement_parses_to(sql, canonical) { + Statement::AlterTable(AlterTable { operations, .. }) => { + assert_eq!(operations.len(), 2); + match (&operations[0], &operations[1]) { + ( + AlterTableOperation::AlterColumn { + column_name: n1, + op: AlterColumnOperation::Comment { comment: c1 }, + }, + AlterTableOperation::AlterColumn { + column_name: n2, + op: AlterColumnOperation::Comment { comment: c2 }, + }, + ) => { + assert_eq!(n1.to_string(), "c1"); + assert_eq!(c1, "s1"); + assert_eq!(n2.to_string(), "c2"); + assert_eq!(c2, "s2"); + } + _ => unreachable!(), + } + } + _ => unreachable!(), + } +} + #[test] fn test_alter_iceberg_table() { snowflake_and_generic().verified_stmt("ALTER ICEBERG TABLE tbl DROP CLUSTERING KEY"); From 17388b988606d00eed0af0730809552478c9a78e Mon Sep 17 00:00:00 2001 From: Przemyslaw Denkiewicz Date: Mon, 29 Jun 2026 22:53:57 +0200 Subject: [PATCH 19/59] Snowflake: accept unquoted OAUTH_ALLOWED_SCOPES with colons (e.g. PRINCIPAL_ROLE:ALL) --- src/ast/mod.rs | 6 ++++- src/dialect/snowflake.rs | 47 ++++++++++++++++++++++++++++++------ tests/sqlparser_snowflake.rs | 35 +++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 5dda453da2..3c5ab699bd 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -6384,7 +6384,11 @@ impl fmt::Display for Statement { if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, )?; if let Some(comment) = comment { - write!(f, " COMMENT = '{}'", value::escape_single_quote_string(comment))?; + write!( + f, + " COMMENT = '{}'", + value::escape_single_quote_string(comment) + )?; } Ok(()) } diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 32bba14889..bdaf9e2d0c 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -32,14 +32,12 @@ use crate::ast::{ CatalogSource, CatalogSyncNamespaceMode, CatalogTableFormat, ColumnOption, ColumnPolicy, ColumnPolicyProperty, ContactEntry, CopyIntoSnowflakeKind, CreateTable, CreateTableLikeKind, DollarQuotedString, ExternalVolumeEncryption, ExternalVolumeStorageLocation, Ident, - OperateFunctionArg, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, InitializeKind, Insert, MultiTableInsertIntoClause, MultiTableInsertType, MultiTableInsertValue, MultiTableInsertValues, - MultiTableInsertWhenClause, ObjectName, ObjectNamePart, RefreshModeKind, RowAccessPolicy, - ShowKeysKind, ShowObjects, SqlOption, Statement, StorageLifecyclePolicy, - StorageSerializationPolicy, - TableObject, TagsColumnOption, Value, WrappedCollection, + MultiTableInsertWhenClause, ObjectName, ObjectNamePart, OperateFunctionArg, RefreshModeKind, + RowAccessPolicy, ShowKeysKind, ShowObjects, SqlOption, Statement, StorageLifecyclePolicy, + StorageSerializationPolicy, TableObject, TagsColumnOption, Value, WrappedCollection, }; use crate::dialect::{Dialect, Precedence}; use crate::keywords::Keyword; @@ -306,7 +304,12 @@ impl Dialect for SnowflakeDialect { return Some(parse_alter_stage(parser)); } - if parser.parse_keywords(&[Keyword::ALTER, Keyword::ROW, Keyword::ACCESS, Keyword::POLICY]) { + if parser.parse_keywords(&[ + Keyword::ALTER, + Keyword::ROW, + Keyword::ACCESS, + Keyword::POLICY, + ]) { // ALTER ROW ACCESS POLICY return Some(parse_alter_row_access_policy(parser)); } @@ -336,7 +339,12 @@ impl Dialect for SnowflakeDialect { return Some(parse_drop_file_format(parser)); } - if parser.parse_keywords(&[Keyword::DROP, Keyword::ROW, Keyword::ACCESS, Keyword::POLICY]) { + if parser.parse_keywords(&[ + Keyword::DROP, + Keyword::ROW, + Keyword::ACCESS, + Keyword::POLICY, + ]) { // DROP ROW ACCESS POLICY return Some(parse_drop_row_access_policy(parser)); } @@ -2889,6 +2897,29 @@ fn parse_catalog_rest_config(parser: &mut Parser) -> Result Result { + let mut scope = parse_oauth_scope_segment(parser)?; + while parser.consume_token(&Token::Colon) { + scope.push(':'); + scope.push_str(&parse_oauth_scope_segment(parser)?); + } + Ok(scope) +} + +/// A single segment of an OAuth scope: a bare word (keyword or not) or a +/// quoted string. +fn parse_oauth_scope_segment(parser: &mut Parser) -> Result { + let token = parser.next_token(); + match token.token { + Token::Word(w) => Ok(w.value), + Token::SingleQuotedString(s) | Token::DoubleQuotedString(s) => Ok(s), + _ => parser.expected("OAUTH scope", token), + } +} + /// Parse the body of `REST_AUTHENTICATION = ( … )`. fn parse_catalog_rest_authentication( parser: &mut Parser, @@ -2917,7 +2948,7 @@ fn parse_catalog_rest_authentication( parser.expect_token(&Token::Eq)?; parser.expect_token(&Token::LParen)?; loop { - oauth_allowed_scopes.push(parser.parse_literal_string()?); + oauth_allowed_scopes.push(parse_oauth_scope(parser)?); if !parser.consume_token(&Token::Comma) { break; } diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index aaac2ec86d..c03b8d8549 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -6497,6 +6497,41 @@ fn test_create_catalog_integration_minimal_iceberg_rest_oauth() { } } +#[test] +fn test_create_catalog_integration_unquoted_oauth_scope_with_colon() { + // Snowflake accepts unquoted scopes that embed a colon; they canonicalize + // to the quoted form on display. + let sql = concat!( + "CREATE CATALOG INTEGRATION my_cat ", + "CATALOG_SOURCE = ICEBERG_REST TABLE_FORMAT = ICEBERG ", + "REST_CONFIG = (CATALOG_URI = 'https://rest.example.com') ", + "REST_AUTHENTICATION = (TYPE = OAUTH OAUTH_CLIENT_ID = 'cid' ", + "OAUTH_CLIENT_SECRET = 'secret' OAUTH_ALLOWED_SCOPES = (PRINCIPAL_ROLE:ALL)) ", + "ENABLED = TRUE", + ); + let canonical = concat!( + "CREATE CATALOG INTEGRATION my_cat ", + "CATALOG_SOURCE = ICEBERG_REST TABLE_FORMAT = ICEBERG ", + "REST_CONFIG = (CATALOG_URI = 'https://rest.example.com') ", + "REST_AUTHENTICATION = (TYPE = OAUTH OAUTH_CLIENT_ID = 'cid' ", + "OAUTH_CLIENT_SECRET = 'secret' OAUTH_ALLOWED_SCOPES = ('PRINCIPAL_ROLE:ALL')) ", + "ENABLED = TRUE", + ); + match snowflake().one_statement_parses_to(sql, canonical) { + Statement::CreateCatalogIntegration { + rest_authentication, + .. + } => { + let auth = rest_authentication.expect("rest_authentication"); + assert_eq!( + vec!["PRINCIPAL_ROLE:ALL".to_string()], + auth.oauth_allowed_scopes + ); + } + _ => unreachable!(), + } +} + #[test] fn test_create_catalog_integration_full_rest_config() { let sql = concat!( From 6b2da6ba3520554658b5fcf4d2390e24f36daf5b Mon Sep 17 00:00:00 2001 From: Przemyslaw Denkiewicz Date: Mon, 29 Jun 2026 23:35:31 +0200 Subject: [PATCH 20/59] Snowflake: don't require BASE_LOCATION for ICEBERG tables bound to an external catalog integration --- src/dialect/snowflake.rs | 15 ++++++++++++--- tests/sqlparser_snowflake.rs | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index bdaf9e2d0c..3651ea9364 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -1246,9 +1246,18 @@ pub fn parse_create_table( builder = builder.table_options(table_options); - // Managed Iceberg tables require BASE_LOCATION; externally-managed ones - // (identified by CATALOG_TABLE_NAME) do not. - if iceberg && builder.base_location.is_none() && builder.catalog_table_name.is_none() { + // Snowflake-managed Iceberg tables require BASE_LOCATION. Tables bound to + // an external catalog integration (an explicit non-SNOWFLAKE CATALOG, or + // CATALOG_TABLE_NAME for externally-managed reads) do not. + let external_catalog = builder + .catalog + .as_deref() + .is_some_and(|c| !c.eq_ignore_ascii_case("SNOWFLAKE")); + if iceberg + && builder.base_location.is_none() + && builder.catalog_table_name.is_none() + && !external_catalog + { return Err(ParserError::ParserError( "BASE_LOCATION is required for ICEBERG tables".to_string(), )); diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index c03b8d8549..e198778eee 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -1106,6 +1106,27 @@ fn test_snowflake_create_iceberg_table_externally_managed() { } } +#[test] +fn test_snowflake_create_iceberg_table_external_catalog_no_base_location() { + match snowflake() + .verified_stmt("CREATE ICEBERG TABLE my_table (c1 TEXT) CATALOG='my_catalog_integration'") + { + Statement::CreateTable(CreateTable { + iceberg, + catalog, + catalog_table_name, + base_location, + .. + }) => { + assert!(iceberg); + assert_eq!("my_catalog_integration", catalog.unwrap()); + assert!(catalog_table_name.is_none()); + assert!(base_location.is_none()); + } + _ => unreachable!(), + } +} + #[test] fn test_snowflake_create_iceberg_table_ctas() { match snowflake().verified_stmt( From 5a99f8a052ad119caca5553d64e6d0e14a012ac6 Mon Sep 17 00:00:00 2001 From: sabir Date: Thu, 2 Jul 2026 12:37:16 +0200 Subject: [PATCH 21/59] Snowflake: parse Python-UDF properties (runtime_version/handler/packages/imports) in CREATE FUNCTION --- src/parser/mod.rs | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 6e0b9fb4bc..6cfc7d6138 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -6052,6 +6052,7 @@ impl<'a> Parser<'a> { body.called_on_null = Some(FunctionCalledOnNull::Strict); } let mut set_params: Vec = Vec::new(); + let mut options: Vec = Vec::new(); loop { fn ensure_not_set(field: &Option, name: &str) -> Result<(), ParserError> { if field.is_some() { @@ -6145,6 +6146,10 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::RETURN) { ensure_not_set(&body.function_body, "RETURN")?; body.function_body = Some(CreateFunctionBody::Return(self.parse_expr()?)); + } else if dialect_of!(self is SnowflakeDialect | GenericDialect) + && self.peek_snowflake_function_property() + { + options.push(self.parse_snowflake_function_property()?); } else { break; } @@ -6168,11 +6173,44 @@ impl<'a> Parser<'a> { if_not_exists: false, using: None, determinism_specifier: None, - options: None, + options: if options.is_empty() { + None + } else { + Some(options) + }, remote_connection: None, }) } + /// True when the next token is a Snowflake Python-UDF property name + /// (`RUNTIME_VERSION`, `HANDLER`, `IMPORTS`, `PACKAGES`). None of these are + /// reserved keywords, so they are recognised by their bare-word spelling. + fn peek_snowflake_function_property(&self) -> bool { + matches!(&self.peek_token_ref().token, Token::Word(w) + if w.quote_style.is_none() + && matches!( + w.value.to_ascii_uppercase().as_str(), + "RUNTIME_VERSION" | "HANDLER" | "IMPORTS" | "PACKAGES" + )) + } + + /// Parse a Snowflake Python-UDF property clause `key = value` or + /// `key = ('a' [, 'b' …])` into an [`SqlOption::KeyValue`]. Parenthesised + /// lists are stored as an [`Expr::Tuple`]. + fn parse_snowflake_function_property(&mut self) -> Result { + let key = self.parse_identifier()?; + self.expect_token(&Token::Eq)?; + let value = if self.peek_token_ref().token == Token::LParen { + self.expect_token(&Token::LParen)?; + let values = self.parse_comma_separated(Parser::parse_expr)?; + self.expect_token(&Token::RParen)?; + Expr::Tuple(values) + } else { + self.parse_expr()? + }; + Ok(SqlOption::KeyValue { key, value }) + } + /// Parse `CREATE FUNCTION` for [Hive] /// /// [Hive]: https://cwiki.apache.org/confluence/display/hive/languagemanual+ddl#LanguageManualDDL-Create/Drop/ReloadFunction From df342d60662cb9557b06076a1c6b8e105954ea60 Mon Sep 17 00:00:00 2001 From: sabir Date: Thu, 2 Jul 2026 19:17:03 +0200 Subject: [PATCH 22/59] Snowflake: parse SHOW PROCEDURES / SHOW FUNCTIONS [LIKE] IN Give ShowProcedures and ShowFunctions the shared ShowStatementOptions so they accept the IN { ACCOUNT | DATABASE | SCHEMA } scope clause (and the bare IN . form) in addition to the LIKE filter, matching the other scoped SHOW variants. --- src/ast/mod.rs | 24 +++++++++--------------- src/dialect/snowflake.rs | 6 +++--- src/parser/mod.rs | 6 +++--- tests/sqlparser_common.rs | 15 +++++++++++++-- 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 3c5ab699bd..4381d2e2a3 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -4397,8 +4397,8 @@ pub enum Statement { /// /// Note: this is a Presto-specific statement. ShowFunctions { - /// Optional filter for which functions to display. - filter: Option, + /// Options controlling the SHOW output (filter, `IN `, etc.). + show_options: ShowStatementOptions, }, /// ```sql /// SHOW @@ -5193,11 +5193,11 @@ pub enum Statement { filter: Option, }, /// ```sql - /// SHOW PROCEDURES [ LIKE '' ] + /// SHOW PROCEDURES [ LIKE '' ] [ IN ] /// ``` ShowProcedures { - /// Optional `LIKE` filter. - filter: Option, + /// Options controlling the SHOW output (filter, `IN `, etc.). + show_options: ShowStatementOptions, }, /// ```sql /// SHOW CONNECTIONS [ LIKE '' ] @@ -6766,11 +6766,8 @@ impl fmt::Display for Statement { )?; Ok(()) } - Statement::ShowFunctions { filter } => { - write!(f, "SHOW FUNCTIONS")?; - if let Some(filter) = filter { - write!(f, " {filter}")?; - } + Statement::ShowFunctions { show_options } => { + write!(f, "SHOW FUNCTIONS{show_options}")?; Ok(()) } Statement::Use(use_expr) => use_expr.fmt(f), @@ -7439,11 +7436,8 @@ impl fmt::Display for Statement { } Ok(()) } - Statement::ShowProcedures { filter } => { - write!(f, "SHOW PROCEDURES")?; - if let Some(filter) = filter { - write!(f, " {filter}")?; - } + Statement::ShowProcedures { show_options } => { + write!(f, "SHOW PROCEDURES{show_options}")?; Ok(()) } Statement::ShowConnections { filter } => { diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 3651ea9364..2173bbd11d 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -2728,10 +2728,10 @@ fn parse_show_row_access_policies(parser: &mut Parser) -> Result']` +/// Parse `SHOW PROCEDURES [LIKE ''] [IN ]` fn parse_show_procedures(parser: &mut Parser) -> Result { - let filter = parser.parse_show_statement_filter()?; - Ok(Statement::ShowProcedures { filter }) + let show_options = parser.parse_show_stmt_options()?; + Ok(Statement::ShowProcedures { show_options }) } /// Parse `SHOW WAREHOUSES [LIKE '']` diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 6cfc7d6138..89d8f55fee 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -16465,10 +16465,10 @@ impl<'a> Parser<'a> { }) } - /// Parse `SHOW FUNCTIONS` and optional filter. + /// Parse `SHOW FUNCTIONS` with optional filter and `IN ` clause. pub fn parse_show_functions(&mut self) -> Result { - let filter = self.parse_show_statement_filter()?; - Ok(Statement::ShowFunctions { filter }) + let show_options = self.parse_show_stmt_options()?; + Ok(Statement::ShowFunctions { show_options }) } /// Parse `SHOW COLLATION` and optional filter. diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 935118b6ca..d2b1c1e513 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -10937,10 +10937,21 @@ fn parse_cursor() { #[test] fn parse_show_functions() { + // Dialects that do not support `LIKE` before `IN` place the filter in the + // suffix position. assert_eq!( - verified_stmt("SHOW FUNCTIONS LIKE 'pattern'"), + all_dialects_where(|d| !d.supports_show_like_before_in()) + .verified_stmt("SHOW FUNCTIONS LIKE 'pattern'"), Statement::ShowFunctions { - filter: Some(ShowStatementFilter::Like("pattern".into())), + show_options: ShowStatementOptions { + show_in: None, + starts_with: None, + limit: None, + limit_from: None, + filter_position: Some(ShowStatementFilterPosition::Suffix( + ShowStatementFilter::Like("pattern".into()) + )), + }, } ); } From 9ae984b70f07e931058236395fa7d4100b9c0a7f Mon Sep 17 00:00:00 2001 From: sabir Date: Sat, 4 Jul 2026 04:01:38 +0200 Subject: [PATCH 23/59] Snowflake: accept a placeholder in COPY INTO stage-name position A wire placeholder (?, $n, ?name) in a COPY INTO stage-name position is carried through as a single identifier part so the bound stage reference can be resolved server-side. --- src/dialect/snowflake.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 2173bbd11d..32f2f440b8 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -1613,6 +1613,10 @@ pub fn parse_snowflake_stage_name(parser: &mut Parser) -> Result Ok(ObjectName::from(vec![Ident::new(s)])), _ => { parser.prev_token(); Ok(parser.parse_object_name(false)?) From 3f8a0edfb7814613b1938ef0ca811eb862bffc40 Mon Sep 17 00:00:00 2001 From: sabir Date: Sat, 4 Jul 2026 12:07:45 +0200 Subject: [PATCH 24/59] Snowflake: parse CREATE/ALTER/DROP TAG and SHOW TAGS Co-Authored-By: Claude Opus 4.8 --- src/ast/mod.rs | 101 +++++++++++++++++++++++++++++++++++++++ src/ast/spans.rs | 4 ++ src/dialect/snowflake.rs | 79 ++++++++++++++++++++++++++++++ src/keywords.rs | 2 + 4 files changed, 186 insertions(+) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 4381d2e2a3..d0b8a67ff7 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -5118,6 +5118,52 @@ pub enum Statement { show_options: ShowStatementOptions, }, /// ```sql + /// CREATE [OR REPLACE] TAG [IF NOT EXISTS] + /// [ ALLOWED_VALUES '' [ , '' ... ] ] [ COMMENT = '' ] + /// ``` + /// See + CreateTag { + /// `OR REPLACE` flag. + or_replace: bool, + /// `IF NOT EXISTS` flag. + if_not_exists: bool, + /// Tag name. + name: ObjectName, + /// Optional `ALLOWED_VALUES` list (stored for SHOW fidelity only). + allowed_values: Vec, + /// Optional comment. + comment: Option, + }, + /// ```sql + /// ALTER TAG [IF EXISTS] RENAME TO + /// ``` + AlterTag { + /// `IF EXISTS` flag. + if_exists: bool, + /// Tag name. + name: ObjectName, + /// New tag name. + new_name: ObjectName, + }, + /// ```sql + /// DROP TAG [IF EXISTS] + /// ``` + DropTag { + /// Tag name. + name: ObjectName, + /// `IF EXISTS` flag. + if_exists: bool, + }, + /// ```sql + /// SHOW TAGS [ LIKE '' ] [ IN ... ] + /// ``` + ShowTags { + /// Whether to show terse output. + terse: bool, + /// Options controlling the SHOW output (`LIKE` / `IN` / ...). + show_options: ShowStatementOptions, + }, + /// ```sql /// SHOW [TERSE] SEQUENCES [ LIKE '' ] [ IN ... ] ... /// ``` ShowSequences { @@ -7371,6 +7417,61 @@ impl fmt::Display for Statement { terse = if *terse { "TERSE " } else { "" }, ) } + Statement::CreateTag { + or_replace, + if_not_exists, + name, + allowed_values, + comment, + } => { + write!( + f, + "CREATE {or_replace}TAG {if_not_exists}{name}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, + )?; + if !allowed_values.is_empty() { + write!(f, " ALLOWED_VALUES ")?; + for (i, v) in allowed_values.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "'{}'", value::escape_single_quote_string(v))?; + } + } + if let Some(ref c) = comment { + write!(f, " COMMENT = '{}'", value::escape_single_quote_string(c))?; + } + Ok(()) + } + Statement::AlterTag { + if_exists, + name, + new_name, + } => { + write!( + f, + "ALTER TAG {if_exists}{name} RENAME TO {new_name}", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + ) + } + Statement::DropTag { name, if_exists } => { + write!( + f, + "DROP TAG {if_exists}{name}", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + ) + } + Statement::ShowTags { + terse, + show_options, + } => { + write!( + f, + "SHOW {terse}TAGS{show_options}", + terse = if *terse { "TERSE " } else { "" }, + ) + } Statement::ShowSequences { terse, show_options, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 4b6df9294b..f349f42b04 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -554,6 +554,10 @@ impl Spanned for Statement { Statement::DescribeFileFormat { .. } => Span::empty(), Statement::ShowFileFormats { .. } => Span::empty(), Statement::ShowStages { .. } => Span::empty(), + Statement::CreateTag { .. } => Span::empty(), + Statement::AlterTag { .. } => Span::empty(), + Statement::DropTag { .. } => Span::empty(), + Statement::ShowTags { .. } => Span::empty(), Statement::ShowSequences { .. } => Span::empty(), Statement::ShowKeys { .. } => Span::empty(), Statement::CreateRowAccessPolicy { .. } => Span::empty(), diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 32f2f440b8..2644ca11d1 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -299,6 +299,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_alter_file_format(parser)); } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::TAG]) { + // ALTER TAG + return Some(parse_alter_tag(parser)); + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::STAGE]) { // ALTER STAGE return Some(parse_alter_stage(parser)); @@ -339,6 +344,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_drop_file_format(parser)); } + if parser.parse_keywords(&[Keyword::DROP, Keyword::TAG]) { + // DROP TAG + return Some(parse_drop_tag(parser)); + } + if parser.parse_keywords(&[ Keyword::DROP, Keyword::ROW, @@ -431,6 +441,11 @@ impl Dialect for SnowflakeDialect { )); } + // CREATE [OR REPLACE] TAG + if parser.parse_keyword(Keyword::TAG) { + return Some(parse_create_tag(or_replace, parser)); + } + if parser.parse_keyword(Keyword::STAGE) { // OK - this is CREATE STAGE statement return Some(parse_create_stage(or_replace, temporary, parser)); @@ -504,6 +519,9 @@ impl Dialect for SnowflakeDialect { if parser.parse_keyword(Keyword::STAGES) { return Some(parse_show_stages(terse, parser)); } + if parser.parse_keyword(Keyword::TAGS) { + return Some(parse_show_tags(terse, parser)); + } if parser.parse_keyword(Keyword::SEQUENCES) { return Some(parse_show_sequences(terse, parser)); } @@ -2635,6 +2653,67 @@ fn parse_show_stages(terse: bool, parser: &mut Parser) -> Result +/// [ ALLOWED_VALUES '' [ , ... ] ] [ COMMENT = '' ]` +fn parse_create_tag(or_replace: bool, parser: &mut Parser) -> Result { + let if_not_exists = parser.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + + let mut allowed_values = Vec::new(); + if parser.parse_keyword(Keyword::ALLOWED_VALUES) { + loop { + allowed_values.push(parser.parse_literal_string()?); + if !parser.consume_token(&Token::Comma) { + break; + } + } + } + + let comment = if parser.parse_keyword(Keyword::COMMENT) { + parser.expect_token(&Token::Eq)?; + Some(parser.parse_comment_value()?) + } else { + None + }; + + Ok(Statement::CreateTag { + or_replace, + if_not_exists, + name, + allowed_values, + comment, + }) +} + +/// Parse `ALTER TAG [IF EXISTS] RENAME TO ` +fn parse_alter_tag(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + parser.expect_keywords(&[Keyword::RENAME, Keyword::TO])?; + let new_name = parser.parse_object_name(false)?; + Ok(Statement::AlterTag { + if_exists, + name, + new_name, + }) +} + +/// Parse `DROP TAG [IF EXISTS] ` +fn parse_drop_tag(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + Ok(Statement::DropTag { name, if_exists }) +} + +/// Parse `SHOW [TERSE] TAGS [ ... ]` +fn parse_show_tags(terse: bool, parser: &mut Parser) -> Result { + let show_options = parser.parse_show_stmt_options()?; + Ok(Statement::ShowTags { + terse, + show_options, + }) +} + /// Parse `SHOW [TERSE] SEQUENCES [ ... ]` fn parse_show_sequences(terse: bool, parser: &mut Parser) -> Result { let show_options = parser.parse_show_stmt_options()?; diff --git a/src/keywords.rs b/src/keywords.rs index b0b92ac3f2..b79986d975 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -115,6 +115,7 @@ define_keywords!( ALIGNMENT, ALL, ALLOCATE, + ALLOWED_VALUES, ALLOWOVERWRITE, ALLOW_WRITES, ALTER, @@ -1092,6 +1093,7 @@ define_keywords!( TABLESPACE, TABLE_FORMAT, TAG, + TAGS, TARGET, TARGET_LAG, TASK, From 7044d97a33a618fd18b63070cdf705963192b01c Mon Sep 17 00:00:00 2001 From: sabir Date: Sun, 5 Jul 2026 10:02:28 +0200 Subject: [PATCH 25/59] Snowflake: parse ALTER SET/UNSET TAG Co-Authored-By: Claude Opus 4.8 --- src/ast/mod.rs | 31 ++++++++++++++++++++++ src/ast/spans.rs | 1 + src/dialect/snowflake.rs | 55 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index d0b8a67ff7..ff1a6f7b4e 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -5155,6 +5155,23 @@ pub enum Statement { if_exists: bool, }, /// ```sql + /// ALTER SET TAG = '' [, ...] + /// ALTER UNSET TAG [, ...] + /// ``` + /// Object-level tag assignment / removal. + SetTags { + /// The domain of the target object (e.g. `DATABASE`). + object_type: ObjectType, + /// The target object name. + object_name: ObjectName, + /// Whether this is `UNSET TAG` (`true`) or `SET TAG` (`false`). + unset: bool, + /// Tags to set (`SET TAG`); empty for `UNSET TAG`. + set_tags: Vec, + /// Tag names to unset (`UNSET TAG`); empty for `SET TAG`. + unset_tags: Vec, + }, + /// ```sql /// SHOW TAGS [ LIKE '' ] [ IN ... ] /// ``` ShowTags { @@ -7462,6 +7479,20 @@ impl fmt::Display for Statement { if_exists = if *if_exists { "IF EXISTS " } else { "" }, ) } + Statement::SetTags { + object_type, + object_name, + unset, + set_tags, + unset_tags, + } => { + write!(f, "ALTER {object_type} {object_name} ")?; + if *unset { + write!(f, "UNSET TAG {}", display_comma_separated(unset_tags)) + } else { + write!(f, "SET TAG {}", display_comma_separated(set_tags)) + } + } Statement::ShowTags { terse, show_options, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index f349f42b04..44b0a2d93b 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -557,6 +557,7 @@ impl Spanned for Statement { Statement::CreateTag { .. } => Span::empty(), Statement::AlterTag { .. } => Span::empty(), Statement::DropTag { .. } => Span::empty(), + Statement::SetTags { .. } => Span::empty(), Statement::ShowTags { .. } => Span::empty(), Statement::ShowSequences { .. } => Span::empty(), Statement::ShowKeys { .. } => Span::empty(), diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 2644ca11d1..15e98320be 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -35,9 +35,10 @@ use crate::ast::{ IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, InitializeKind, Insert, MultiTableInsertIntoClause, MultiTableInsertType, MultiTableInsertValue, MultiTableInsertValues, - MultiTableInsertWhenClause, ObjectName, ObjectNamePart, OperateFunctionArg, RefreshModeKind, - RowAccessPolicy, ShowKeysKind, ShowObjects, SqlOption, Statement, StorageLifecyclePolicy, - StorageSerializationPolicy, TableObject, TagsColumnOption, Value, WrappedCollection, + MultiTableInsertWhenClause, ObjectName, ObjectNamePart, ObjectType, OperateFunctionArg, + RefreshModeKind, RowAccessPolicy, ShowKeysKind, ShowObjects, SqlOption, Statement, + StorageLifecyclePolicy, StorageSerializationPolicy, Tag, TableObject, TagsColumnOption, Value, + WrappedCollection, }; use crate::dialect::{Dialect, Precedence}; use crate::keywords::Keyword; @@ -304,6 +305,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_alter_tag(parser)); } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::DATABASE]) { + // ALTER DATABASE { SET TAG | UNSET TAG } + return Some(parse_alter_object_set_tags(parser, ObjectType::Database)); + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::STAGE]) { // ALTER STAGE return Some(parse_alter_stage(parser)); @@ -2705,6 +2711,49 @@ fn parse_drop_tag(parser: &mut Parser) -> Result { Ok(Statement::DropTag { name, if_exists }) } +/// Parse the tail of `ALTER { SET TAG = '' [, ...] +/// | UNSET TAG [, ...] }` after the object-type keyword has been consumed. +fn parse_alter_object_set_tags( + parser: &mut Parser, + object_type: ObjectType, +) -> Result { + let object_name = parser.parse_object_name(false)?; + let unset = match parser.expect_one_of_keywords(&[Keyword::SET, Keyword::UNSET])? { + Keyword::UNSET => true, + _ => false, + }; + parser.expect_keyword(Keyword::TAG)?; + + let mut set_tags = Vec::new(); + let mut unset_tags = Vec::new(); + if unset { + loop { + unset_tags.push(parser.parse_object_name(false)?); + if !parser.consume_token(&Token::Comma) { + break; + } + } + } else { + loop { + let key = parser.parse_object_name(false)?; + parser.expect_token(&Token::Eq)?; + let value = parser.parse_literal_string()?; + set_tags.push(Tag::new(key, value)); + if !parser.consume_token(&Token::Comma) { + break; + } + } + } + + Ok(Statement::SetTags { + object_type, + object_name, + unset, + set_tags, + unset_tags, + }) +} + /// Parse `SHOW [TERSE] TAGS [ ... ]` fn parse_show_tags(terse: bool, parser: &mut Parser) -> Result { let show_options = parser.parse_show_stmt_options()?; From cd6fd0d816cfa3ae4fdafb7e1eb90958b3260159 Mon Sep 17 00:00:00 2001 From: sabir Date: Sat, 4 Jul 2026 03:19:10 +0200 Subject: [PATCH 26/59] Snowflake: don't consume statement terminator in parse_copy_into MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside a BEGIN … END body the surrounding statement list expects to consume the trailing ';' itself; leave it via prev_token(). --- src/dialect/snowflake.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 15e98320be..01d9d285ba 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -1795,7 +1795,14 @@ pub fn parse_copy_into(parser: &mut Parser) -> Result { copy_options = parser.parse_key_value_options(true, &[], false)?.options; } else { match parser.next_token().token { - Token::SemiColon | Token::EOF => break, + // Leave the statement terminator for the caller: inside a + // `BEGIN … END` body the surrounding statement list expects to + // consume the `;` itself. + Token::SemiColon => { + parser.prev_token(); + break; + } + Token::EOF => break, Token::Comma => continue, // In `COPY INTO ` the copy options do not have a shared key // like in `COPY INTO
` From 448678e8515cc97dc55a554034d0f414a2c75183 Mon Sep 17 00:00:00 2001 From: sabir Date: Sun, 5 Jul 2026 18:41:43 +0200 Subject: [PATCH 27/59] Snowflake: parse PUT/GET file statements inside scripting bodies Recognise PUT/GET file-transfer statements inside a scripting BEGIN...END block as Statement::PutGetFiles so the block parses. Operands are not modelled: an unquoted file:// path triggers the // single-line-comment lexer rule, so the statement tail is scanned at the raw-token level up to its terminator rather than parsed. --- src/ast/mod.rs | 20 +++++++++++++ src/ast/spans.rs | 1 + src/parser/mod.rs | 71 +++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index ff1a6f7b4e..818d899195 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -5823,6 +5823,23 @@ pub enum Statement { /// branches. /// [Snowflake](https://docs.snowflake.com/en/developer-guide/snowflake-scripting/snowflake-scripting) Null, + /// A `PUT` / `GET` file-transfer statement appearing inside a Snowflake + /// Scripting block (procedure / anonymous-block body). + /// + /// ```sql + /// PUT file:///tmp/x.csv @my_stage AUTO_COMPRESS = FALSE; + /// GET @my_stage file:///tmp/dir; + /// ``` + /// + /// Snowflake recognises these inside a body only so the block parses; it + /// rejects executing them ("Unsupported statement type 'PUT_FILES'"). The + /// operands are not modelled — an unquoted `file://` path triggers the `//` + /// single-line-comment lexer rule, so the tail is not tokenizable — only + /// the `get` discriminant is kept. `get = true` for `GET`. + PutGetFiles { + /// `true` for `GET`, `false` for `PUT`. + get: bool, + }, } impl From for Statement { @@ -7864,6 +7881,9 @@ impl fmt::Display for Statement { write!(f, " := {value}") } Statement::Null => write!(f, "NULL"), + Statement::PutGetFiles { get } => { + write!(f, "{}", if *get { "GET" } else { "PUT" }) + } } } } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 44b0a2d93b..1b29aad867 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -575,6 +575,7 @@ impl Spanned for Statement { Statement::Assignment { .. } => Span::empty(), Statement::Let { .. } => Span::empty(), Statement::Null => Span::empty(), + Statement::PutGetFiles { .. } => Span::empty(), } } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 89d8f55fee..e107679f0f 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1063,6 +1063,19 @@ impl<'a> Parser<'a> { _ => {} } + // A `PUT` / `GET` file-transfer statement inside a scripting body. + // These are recognised so the block parses, but never tokenize + // cleanly: an unquoted `file://` path triggers the Snowflake `//` + // single-line-comment rule, which eats the statement's `;` + // terminator and the rest of the line. Scan the raw token stream + // (comments included) to the terminator rather than parse operands, + // and skip the shared `;` expectation below. + if let Some(get) = self.peek_put_get_files() { + self.consume_put_get_files(); + values.push(Statement::PutGetFiles { get }); + continue; + } + let stmt = if self.peek_keyword(Keyword::LET) { self.next_token(); // consume LET let name = self.parse_identifier()?; @@ -1119,6 +1132,54 @@ impl<'a> Parser<'a> { Ok(values) } + /// If the next scripting statement is a `PUT` / `GET` file-transfer + /// statement, return `Some(get)` (`get = true` for `GET`). A `GET` + /// followed by `STACKED` / `DIAGNOSTICS` is `GET DIAGNOSTICS`, not a + /// file transfer, and yields `None`. + fn peek_put_get_files(&self) -> Option { + let Token::Word(w) = &self.peek_nth_token_ref(0).token else { + return None; + }; + if w.quote_style.is_some() { + return None; + } + if w.value.eq_ignore_ascii_case("PUT") { + return Some(false); + } + if w.keyword == Keyword::GET { + if let Token::Word(next) = &self.peek_nth_token_ref(1).token { + if next.value.eq_ignore_ascii_case("STACKED") + || next.value.eq_ignore_ascii_case("DIAGNOSTICS") + { + return None; + } + } + return Some(true); + } + None + } + + /// Consume the raw tokens of a `PUT` / `GET` file-transfer statement up to + /// and including its terminator — either a real `;` (quoted paths keep it) + /// or the `//` single-line comment that swallowed it (unquoted `file://` + /// paths), scanning whitespace/comment tokens directly. + fn consume_put_get_files(&mut self) { + while let Some(tok) = self.tokens.get(self.index) { + match &tok.token { + Token::EOF => break, + Token::SemiColon => { + self.index += 1; + break; + } + Token::Whitespace(Whitespace::SingleLineComment { .. }) => { + self.index += 1; + break; + } + _ => self.index += 1, + } + } + } + /// Parse a `RAISE` statement. /// /// See [Statement::Raise] @@ -6187,11 +6248,11 @@ impl<'a> Parser<'a> { /// reserved keywords, so they are recognised by their bare-word spelling. fn peek_snowflake_function_property(&self) -> bool { matches!(&self.peek_token_ref().token, Token::Word(w) - if w.quote_style.is_none() - && matches!( - w.value.to_ascii_uppercase().as_str(), - "RUNTIME_VERSION" | "HANDLER" | "IMPORTS" | "PACKAGES" - )) + if w.quote_style.is_none() + && matches!( + w.value.to_ascii_uppercase().as_str(), + "RUNTIME_VERSION" | "HANDLER" | "IMPORTS" | "PACKAGES" + )) } /// Parse a Snowflake Python-UDF property clause `key = value` or From b762150280e9d8e77e20ac711c4f8e244cf6a8fe Mon Sep 17 00:00:00 2001 From: sabir Date: Sun, 5 Jul 2026 17:01:51 +0200 Subject: [PATCH 28/59] Snowflake: parse ALTER SCHEMA SET/UNSET TAG --- src/dialect/snowflake.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 01d9d285ba..951ea1ccf8 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -310,6 +310,17 @@ impl Dialect for SnowflakeDialect { return Some(parse_alter_object_set_tags(parser, ObjectType::Database)); } + // ALTER SCHEMA { SET TAG | UNSET TAG } — intercept only the tag + // form; every other ALTER SCHEMA form (RENAME TO, SET OPTIONS, SET + // DEFAULT COLLATE, ADD/DROP REPLICA, OWNER TO) fails the closure and + // falls through to the generic parse_alter_schema. + if let Ok(Some(stmt)) = parser.maybe_parse(|p| { + p.expect_keywords(&[Keyword::ALTER, Keyword::SCHEMA])?; + parse_alter_object_set_tags(p, ObjectType::Schema) + }) { + return Some(Ok(stmt)); + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::STAGE]) { // ALTER STAGE return Some(parse_alter_stage(parser)); From 33afa1c6e546c348b336e936c4c54b12c0e2ab47 Mon Sep 17 00:00:00 2001 From: sabir Date: Tue, 7 Jul 2026 10:30:16 +0200 Subject: [PATCH 29/59] Snowflake: parse type-less column list in CREATE TABLE ... AS Allow a CREATE TABLE column list to name columns without a data type (e.g. CREATE TABLE t(id) AS SELECT 123), deferring type inference to the AS query. Gated behind a new Dialect::supports_create_table_optional_column_type hook, enabled only for SnowflakeDialect; a bare name is accepted only when immediately followed by ',' or ')' so malformed data types still error. --- src/dialect/mod.rs | 8 ++++++++ src/dialect/snowflake.rs | 4 ++++ src/parser/mod.rs | 20 +++++++++++++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 2e59458ee8..383894587b 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -641,6 +641,14 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect allows a column in a `CREATE TABLE` + /// column list to omit its data type, deferring type inference to an + /// `AS ` clause. + /// Example: `CREATE TABLE t (id) AS SELECT 123` + fn supports_create_table_optional_column_type(&self) -> bool { + false + } + /// Returns true if the dialect supports double dot notation for object names /// /// Example diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 951ea1ccf8..5a382f30b6 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -152,6 +152,10 @@ impl Dialect for SnowflakeDialect { true } + fn supports_create_table_optional_column_type(&self) -> bool { + true + } + // Snowflake supports double-dot notation when the schema name is not specified // In this case the default PUBLIC schema is used // diff --git a/src/parser/mod.rs b/src/parser/mod.rs index e107679f0f..31dbbe5411 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -9747,7 +9747,25 @@ impl<'a> Parser<'a> { if let Some(constraint) = self.parse_optional_table_constraint()? { constraints.push(constraint); } else if let Token::Word(_) = &self.peek_token_ref().token { - columns.push(self.parse_column_def()?); + // A bare column name with no data type (`CREATE TABLE t(id) AS + // SELECT ...`) is only accepted when the dialect opts in and the + // name is immediately followed by a column-list terminator, so a + // genuinely malformed data type still surfaces its own error. + if self.dialect.supports_create_table_optional_column_type() + && matches!( + self.peek_nth_token_ref(1).token, + Token::Comma | Token::RParen + ) + { + let name = self.parse_identifier()?; + columns.push(ColumnDef { + name, + data_type: DataType::Unspecified, + options: vec![], + }); + } else { + columns.push(self.parse_column_def()?); + } } else { return self.expected_ref( "column name or constraint definition", From afc556f9c23530fde752ebef400cabaeb914a446 Mon Sep 17 00:00:00 2001 From: sabir Date: Tue, 7 Jul 2026 12:39:23 +0200 Subject: [PATCH 30/59] Snowflake: accept and discard HYBRID table modifier --- src/dialect/snowflake.rs | 8 ++++++++ src/keywords.rs | 1 + 2 files changed, 9 insertions(+) diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 5a382f30b6..ff094ea6eb 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -452,6 +452,11 @@ impl Dialect for SnowflakeDialect { _ => {} } + // CREATE [OR REPLACE] HYBRID TABLE — the "hybrid" property has no + // observable effect here, so the modifier is discarded and the + // statement is parsed as an ordinary table. + let hybrid = parser.parse_keyword(Keyword::HYBRID); + // CREATE [OR REPLACE] [ TEMP | TEMPORARY | VOLATILE ] FILE FORMAT. // For file formats VOLATILE is a synonym of TEMPORARY. if parser.parse_keywords(&[Keyword::FILE, Keyword::FORMAT]) { @@ -498,6 +503,9 @@ impl Dialect for SnowflakeDialect { if temporary || volatile || transient || iceberg { back += 1 } + if hybrid { + back += 1 + } for _i in 0..back { parser.prev_token(); } diff --git a/src/keywords.rs b/src/keywords.rs index b79986d975..b1f2812456 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -519,6 +519,7 @@ define_keywords!( HOUR, HOURS, HUGEINT, + HYBRID, IAM_ROLE, ICEBERG, ICEBERG_REST, From 3aad77cc3218bac7a1746155ce5f144314a17f0a Mon Sep 17 00:00:00 2001 From: sabir Date: Tue, 7 Jul 2026 11:49:07 +0200 Subject: [PATCH 31/59] Snowflake: parse CREATE OR REPLACE SEQUENCE --- src/ast/mod.rs | 6 +++++- src/parser/mod.rs | 13 +++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 818d899195..781c69fd6d 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -5536,6 +5536,8 @@ pub enum Statement { CreateSequence { /// Whether the sequence is temporary. temporary: bool, + /// `OR REPLACE` flag. + or_replace: bool, /// `IF NOT EXISTS` flag. if_not_exists: bool, /// Sequence name. @@ -7108,6 +7110,7 @@ impl fmt::Display for Statement { } Statement::CreateSequence { temporary, + or_replace, if_not_exists, name, data_type, @@ -7123,7 +7126,8 @@ impl fmt::Display for Statement { }; write!( f, - "CREATE {temporary}SEQUENCE {if_not_exists}{name}{as_type}", + "CREATE {or_replace}{temporary}SEQUENCE {if_not_exists}{name}{as_type}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, temporary = if *temporary { "TEMPORARY " } else { "" }, name = name, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 31dbbe5411..7ec6abff5b 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5521,9 +5521,11 @@ impl<'a> Parser<'a> { self.parse_create_schema(or_replace, transient) } else if self.parse_keyword(Keyword::ROLE) { self.parse_create_role(or_replace).map(Into::into) + } else if self.parse_keyword(Keyword::SEQUENCE) { + self.parse_create_sequence(or_replace, temporary) } else if or_replace { self.expected_ref( - "[EXTERNAL] TABLE or [MATERIALIZED] VIEW or FUNCTION or WAREHOUSE or TASK or PROCEDURE or SCHEMA or ROLE after CREATE OR REPLACE", + "[EXTERNAL] TABLE or [MATERIALIZED] VIEW or FUNCTION or WAREHOUSE or TASK or PROCEDURE or SCHEMA or ROLE or SEQUENCE after CREATE OR REPLACE", self.peek_token_ref(), ) } else if self.parse_keyword(Keyword::EXTENSION) { @@ -5540,8 +5542,6 @@ impl<'a> Parser<'a> { } else { self.parse_create_database() } - } else if self.parse_keyword(Keyword::SEQUENCE) { - self.parse_create_sequence(temporary) } else if self.parse_keyword(Keyword::COLLATION) { self.parse_create_collation().map(Into::into) } else if self.parse_keyword(Keyword::TYPE) { @@ -20679,7 +20679,11 @@ impl<'a> Parser<'a> { /// ``` /// /// See [Postgres docs](https://www.postgresql.org/docs/current/sql-createsequence.html) for more details. - pub fn parse_create_sequence(&mut self, temporary: bool) -> Result { + pub fn parse_create_sequence( + &mut self, + or_replace: bool, + temporary: bool, + ) -> Result { //[ IF NOT EXISTS ] let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); //name @@ -20702,6 +20706,7 @@ impl<'a> Parser<'a> { }; Ok(Statement::CreateSequence { temporary, + or_replace, if_not_exists, name, data_type, From 81db47fc211cb8f22a5374f39f66829dfed4d95d Mon Sep 17 00:00:00 2001 From: sabir Date: Tue, 7 Jul 2026 15:22:34 +0200 Subject: [PATCH 32/59] Snowflake: parse CREATE SEQUENCE START/INCREMENT '=' form and ORDER/NOORDER --- src/parser/mod.rs | 73 ++++++++++++++++++------------------ tests/sqlparser_snowflake.rs | 15 ++++++++ 2 files changed, 52 insertions(+), 36 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 7ec6abff5b..0cdecadbad 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -20716,46 +20716,47 @@ impl<'a> Parser<'a> { } fn parse_create_sequence_options(&mut self) -> Result, ParserError> { + // Options may appear in any order (Snowflake) and each keyword may use an + // optional `=` assignment form (`START = 1`, `INCREMENT = 1`). Snowflake's + // `ORDER`/`NOORDER` ordering guarantee is accepted and ignored. let mut sequence_options = vec![]; - //[ INCREMENT [ BY ] increment ] - if self.parse_keywords(&[Keyword::INCREMENT]) { - if self.parse_keywords(&[Keyword::BY]) { - sequence_options.push(SequenceOptions::IncrementBy(self.parse_number()?, true)); - } else { - sequence_options.push(SequenceOptions::IncrementBy(self.parse_number()?, false)); - } - } - //[ MINVALUE minvalue | NO MINVALUE ] - if self.parse_keyword(Keyword::MINVALUE) { - sequence_options.push(SequenceOptions::MinValue(Some(self.parse_number()?))); - } else if self.parse_keywords(&[Keyword::NO, Keyword::MINVALUE]) { - sequence_options.push(SequenceOptions::MinValue(None)); - } - //[ MAXVALUE maxvalue | NO MAXVALUE ] - if self.parse_keywords(&[Keyword::MAXVALUE]) { - sequence_options.push(SequenceOptions::MaxValue(Some(self.parse_number()?))); - } else if self.parse_keywords(&[Keyword::NO, Keyword::MAXVALUE]) { - sequence_options.push(SequenceOptions::MaxValue(None)); - } - - //[ START [ WITH ] start ] - if self.parse_keywords(&[Keyword::START]) { - if self.parse_keywords(&[Keyword::WITH]) { - sequence_options.push(SequenceOptions::StartWith(self.parse_number()?, true)); + loop { + if self.parse_keyword(Keyword::INCREMENT) { + //[ INCREMENT [ BY ] [ = ] increment ] + let by = self.parse_keyword(Keyword::BY); + let _ = self.consume_token(&Token::Eq); + sequence_options.push(SequenceOptions::IncrementBy(self.parse_number()?, by)); + } else if self.parse_keywords(&[Keyword::NO, Keyword::MINVALUE]) { + sequence_options.push(SequenceOptions::MinValue(None)); + } else if self.parse_keyword(Keyword::MINVALUE) { + //[ MINVALUE [ = ] minvalue ] + let _ = self.consume_token(&Token::Eq); + sequence_options.push(SequenceOptions::MinValue(Some(self.parse_number()?))); + } else if self.parse_keywords(&[Keyword::NO, Keyword::MAXVALUE]) { + sequence_options.push(SequenceOptions::MaxValue(None)); + } else if self.parse_keyword(Keyword::MAXVALUE) { + //[ MAXVALUE [ = ] maxvalue ] + let _ = self.consume_token(&Token::Eq); + sequence_options.push(SequenceOptions::MaxValue(Some(self.parse_number()?))); + } else if self.parse_keyword(Keyword::START) { + //[ START [ WITH ] [ = ] start ] + let with = self.parse_keyword(Keyword::WITH); + let _ = self.consume_token(&Token::Eq); + sequence_options.push(SequenceOptions::StartWith(self.parse_number()?, with)); + } else if self.parse_keyword(Keyword::CACHE) { + //[ CACHE [ = ] cache ] + let _ = self.consume_token(&Token::Eq); + sequence_options.push(SequenceOptions::Cache(self.parse_number()?)); + } else if self.parse_keywords(&[Keyword::NO, Keyword::CYCLE]) { + sequence_options.push(SequenceOptions::Cycle(true)); + } else if self.parse_keyword(Keyword::CYCLE) { + sequence_options.push(SequenceOptions::Cycle(false)); + } else if self.parse_keyword(Keyword::ORDER) || self.parse_keyword(Keyword::NOORDER) { + // Snowflake ordering guarantee — accepted, no effect on emulation. } else { - sequence_options.push(SequenceOptions::StartWith(self.parse_number()?, false)); + break; } } - //[ CACHE cache ] - if self.parse_keywords(&[Keyword::CACHE]) { - sequence_options.push(SequenceOptions::Cache(self.parse_number()?)); - } - // [ [ NO ] CYCLE ] - if self.parse_keywords(&[Keyword::NO, Keyword::CYCLE]) { - sequence_options.push(SequenceOptions::Cycle(true)); - } else if self.parse_keywords(&[Keyword::CYCLE]) { - sequence_options.push(SequenceOptions::Cycle(false)); - } Ok(sequence_options) } diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index e198778eee..6ddcc061ae 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -1652,6 +1652,21 @@ fn snowflake() -> TestedDialects { TestedDialects::new(vec![Box::new(SnowflakeDialect {})]) } +#[test] +fn parse_create_sequence_snowflake_options() { + // Snowflake accepts the `=` assignment form, options in any order, and a + // trailing ORDER/NOORDER guarantee. + for sql in [ + "CREATE SEQUENCE seq0 START = 1 INCREMENT = 1 ORDER", + "CREATE SEQUENCE seq0 INCREMENT = 2 START = 5 NOORDER", + "CREATE SEQUENCE seq0 START WITH 1 INCREMENT 1", + ] { + snowflake() + .parse_sql_statements(sql) + .unwrap_or_else(|e| panic!("failed to parse {sql:?}: {e}")); + } +} + fn snowflake_with_recursion_limit(recursion_limit: usize) -> TestedDialects { TestedDialects::new(vec![Box::new(SnowflakeDialect {})]).with_recursion_limit(recursion_limit) } From e739dd1c8e8dcf39b3cf2716f86f41275db9423a Mon Sep 17 00:00:00 2001 From: sabir Date: Tue, 7 Jul 2026 16:30:07 +0200 Subject: [PATCH 33/59] Snowflake: parse TRANSIENT DYNAMIC, bare TARGET_LAG=DOWNSTREAM, INITIALIZATION_WAREHOUSE, SCHEDULER, IMMUTABLE WHERE --- src/ast/ddl.rs | 30 +++++++++++++++-- src/ast/helpers/stmt_create_table.rs | 38 ++++++++++++++++++--- src/ast/spans.rs | 3 ++ src/dialect/snowflake.rs | 49 +++++++++++++++++++++++++--- src/keywords.rs | 2 ++ tests/sqlparser_snowflake.rs | 41 +++++++++++++++++++++++ 6 files changed, 153 insertions(+), 10 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 8da401ef99..957c567351 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -3127,9 +3127,23 @@ pub struct CreateTable { /// Snowflake "TARGET_LAG" clause for dybamic tables /// pub target_lag: Option, - /// Snowflake "WAREHOUSE" clause for dybamic tables + /// Snowflake "WAREHOUSE" clause for dybamic tables. Stored verbatim (not + /// as an `Ident`) so the user's original casing survives — SHOW / GET_DDL + /// echo the warehouse exactly as written. /// - pub warehouse: Option, + pub warehouse: Option, + /// Snowflake "INITIALIZATION_WAREHOUSE" clause for dynamic tables. Stored + /// verbatim for the same case-fidelity reason as `warehouse`. + /// + pub initialization_warehouse: Option, + /// Snowflake "SCHEDULER" clause for dynamic tables (a quoted string or a + /// bare keyword such as `DISABLE`); the value is stored verbatim. + /// + pub scheduler: Option, + /// Snowflake "IMMUTABLE WHERE ()" clause for dynamic tables. + /// Stored as the serialized predicate text so its casing survives. + /// + pub immutable_where: Option, /// Snowflake "REFRESH_MODE" clause for dybamic tables /// pub refresh_mode: Option, @@ -3445,6 +3459,18 @@ impl fmt::Display for CreateTable { write!(f, " WAREHOUSE={warehouse}")?; } + if let Some(initialization_warehouse) = &self.initialization_warehouse { + write!(f, " INITIALIZATION_WAREHOUSE={initialization_warehouse}")?; + } + + if let Some(scheduler) = &self.scheduler { + write!(f, " SCHEDULER='{scheduler}'")?; + } + + if let Some(immutable_where) = &self.immutable_where { + write!(f, " IMMUTABLE WHERE ({immutable_where})")?; + } + if let Some(refresh_mode) = &self.refresh_mode { write!(f, " REFRESH_MODE={refresh_mode}")?; } diff --git a/src/ast/helpers/stmt_create_table.rs b/src/ast/helpers/stmt_create_table.rs index 892db13064..5bc480019d 100644 --- a/src/ast/helpers/stmt_create_table.rs +++ b/src/ast/helpers/stmt_create_table.rs @@ -171,8 +171,14 @@ pub struct CreateTableBuilder { pub table_options: CreateTableOptions, /// Optional target lag configuration. pub target_lag: Option, - /// Optional warehouse identifier. - pub warehouse: Option, + /// Optional warehouse name, stored verbatim. + pub warehouse: Option, + /// Optional initialization warehouse name, stored verbatim. + pub initialization_warehouse: Option, + /// Optional scheduler value (quoted string or bare keyword). + pub scheduler: Option, + /// Optional `IMMUTABLE WHERE` predicate text. + pub immutable_where: Option, /// Optional refresh mode for materialized tables. pub refresh_mode: Option, /// Optional initialization kind for the table. @@ -247,6 +253,9 @@ impl CreateTableBuilder { table_options: CreateTableOptions::None, target_lag: None, warehouse: None, + initialization_warehouse: None, + scheduler: None, + immutable_where: None, refresh_mode: None, initialize: None, require_user: false, @@ -522,11 +531,26 @@ impl CreateTableBuilder { self.target_lag = target_lag; self } - /// Associate the table with a warehouse identifier. - pub fn warehouse(mut self, warehouse: Option) -> Self { + /// Associate the table with a warehouse name (stored verbatim). + pub fn warehouse(mut self, warehouse: Option) -> Self { self.warehouse = warehouse; self } + /// Set the initialization warehouse name (stored verbatim). + pub fn initialization_warehouse(mut self, initialization_warehouse: Option) -> Self { + self.initialization_warehouse = initialization_warehouse; + self + } + /// Set the scheduler value (quoted string or bare keyword). + pub fn scheduler(mut self, scheduler: Option) -> Self { + self.scheduler = scheduler; + self + } + /// Set the `IMMUTABLE WHERE` predicate text. + pub fn immutable_where(mut self, immutable_where: Option) -> Self { + self.immutable_where = immutable_where; + self + } /// Set refresh mode for materialized/managed tables. pub fn refresh_mode(mut self, refresh_mode: Option) -> Self { self.refresh_mode = refresh_mode; @@ -619,6 +643,9 @@ impl CreateTableBuilder { table_options: self.table_options, target_lag: self.target_lag, warehouse: self.warehouse, + initialization_warehouse: self.initialization_warehouse, + scheduler: self.scheduler, + immutable_where: self.immutable_where, refresh_mode: self.refresh_mode, initialize: self.initialize, require_user: self.require_user, @@ -702,6 +729,9 @@ impl From for CreateTableBuilder { table_options: table.table_options, target_lag: table.target_lag, warehouse: table.warehouse, + initialization_warehouse: table.initialization_warehouse, + scheduler: table.scheduler, + immutable_where: table.immutable_where, refresh_mode: table.refresh_mode, initialize: table.initialize, require_user: table.require_user, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 1b29aad867..a4bc570066 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -656,6 +656,9 @@ impl Spanned for CreateTable { table_options, target_lag: _, warehouse: _, + initialization_warehouse: _, + scheduler: _, + immutable_where: _, version: _, refresh_mode: _, initialize: _, diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index ff094ea6eb..85a13696b9 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -431,13 +431,24 @@ impl Dialect for SnowflakeDialect { _ => None, }; - let dynamic = parser.parse_keyword(Keyword::DYNAMIC); - let mut temporary = false; let mut volatile = false; let mut transient = false; let mut iceberg = false; + // Snowflake allows a leading `TRANSIENT` before `DYNAMIC` + // (`CREATE [OR REPLACE] [TRANSIENT] DYNAMIC [ICEBERG] TABLE`); the + // standalone `TRANSIENT` modifier for plain tables is still handled + // by the modifier group below. + let dynamic = if parser.parse_keyword(Keyword::DYNAMIC) { + true + } else if parser.parse_keywords(&[Keyword::TRANSIENT, Keyword::DYNAMIC]) { + transient = true; + true + } else { + false + }; + match parser.parse_one_of_keywords(&[ Keyword::TEMP, Keyword::TEMPORARY, @@ -1221,13 +1232,43 @@ pub fn parse_create_table( } Keyword::TARGET_LAG => { parser.expect_token(&Token::Eq)?; - let target_lag = parser.parse_literal_string()?; + // TARGET_LAG accepts a quoted duration ('1 minute') or the + // bare keyword DOWNSTREAM. + let target_lag = if parser.parse_keyword(Keyword::DOWNSTREAM) { + "DOWNSTREAM".to_string() + } else { + 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)); + builder = builder.warehouse(Some(warehouse.value)); + } + Keyword::INITIALIZATION_WAREHOUSE => { + parser.expect_token(&Token::Eq)?; + let warehouse = parser.parse_identifier()?; + builder = builder.initialization_warehouse(Some(warehouse.value)); + } + Keyword::SCHEDULER => { + parser.expect_token(&Token::Eq)?; + // SCHEDULER accepts a quoted string ('DISABLE') or a bare + // keyword (the GET_DDL spelling, e.g. DISABLE). + let value_token = parser.next_token(); + let scheduler = match &value_token.token { + Token::SingleQuotedString(s) => s.clone(), + Token::Word(w) => w.value.clone(), + _ => return parser.expected("a scheduler value", value_token), + }; + builder = builder.scheduler(Some(scheduler)); + } + Keyword::IMMUTABLE => { + parser.expect_keyword_is(Keyword::WHERE)?; + parser.expect_token(&Token::LParen)?; + let predicate = parser.parse_expr()?; + parser.expect_token(&Token::RParen)?; + builder = builder.immutable_where(Some(predicate.to_string())); } Keyword::AT | Keyword::BEFORE => { parser.prev_token(); diff --git a/src/keywords.rs b/src/keywords.rs index b1f2812456..56133d1e8c 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -545,6 +545,7 @@ define_keywords!( INDICATOR, INHERIT, INHERITS, + INITIALIZATION_WAREHOUSE, INITIALIZE, INITIALLY, INNER, @@ -965,6 +966,7 @@ define_keywords!( SAMPLE, SAVEPOINT, SCHEDULE, + SCHEDULER, SCHEMA, SCHEMAS, SCOPE, diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 6ddcc061ae..9e12ce4975 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -1366,6 +1366,47 @@ fn parse_create_dynamic_table() { )); } +#[test] +fn parse_create_dynamic_table_snowflake_clauses() { + // Leading TRANSIENT, bare TARGET_LAG = DOWNSTREAM, INITIALIZATION_WAREHOUSE, + // SCHEDULER (quoted + bare), and IMMUTABLE WHERE all carry their values in + // the AST. + let sql = concat!( + "CREATE OR REPLACE TRANSIENT DYNAMIC TABLE t", + " TARGET_LAG='1 minute'", + " WAREHOUSE=my_wh", + " INITIALIZATION_WAREHOUSE=init_wh", + " SCHEDULER='DISABLE'", + " IMMUTABLE WHERE (id > 5)", + " AS SELECT id FROM staging_table" + ); + match snowflake().verified_stmt(sql) { + Statement::CreateTable(ct) => { + assert!(ct.transient); + assert!(ct.dynamic); + assert!(ct.or_replace); + assert_eq!(Some("1 minute".to_string()), ct.target_lag); + assert_eq!(Some("my_wh".to_string()), ct.warehouse); + assert_eq!(Some("init_wh".to_string()), ct.initialization_warehouse); + assert_eq!(Some("DISABLE".to_string()), ct.scheduler); + assert_eq!(Some("id > 5".to_string()), ct.immutable_where); + } + other => panic!("expected CreateTable, got {other:?}"), + } + + // Bare TARGET_LAG = DOWNSTREAM and bare SCHEDULER value. + match snowflake().one_statement_parses_to( + "CREATE DYNAMIC TABLE t TARGET_LAG=DOWNSTREAM WAREHOUSE=wh SCHEDULER=disable AS SELECT id FROM s", + "CREATE DYNAMIC TABLE t TARGET_LAG='DOWNSTREAM' WAREHOUSE=wh SCHEDULER='disable' AS SELECT id FROM s", + ) { + Statement::CreateTable(ct) => { + assert_eq!(Some("DOWNSTREAM".to_string()), ct.target_lag); + assert_eq!(Some("disable".to_string()), ct.scheduler); + } + other => panic!("expected CreateTable, got {other:?}"), + } +} + #[test] fn test_sf_derived_table_in_parenthesis() { // Nesting a subquery in an extra set of parentheses is non-standard, From e561165a4cec751257506eea9748dd3cced5e27b Mon Sep 17 00:00:00 2001 From: sabir Date: Tue, 7 Jul 2026 17:22:32 +0200 Subject: [PATCH 34/59] Snowflake: parse DROP/DESC/SHOW DYNAMIC TABLE, carrying dynamic-ness in the AST --- src/ast/mod.rs | 15 ++++++++++++++- src/dialect/snowflake.rs | 12 ++++++++++-- src/parser/mod.rs | 12 +++++++++++- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 781c69fd6d..aeeeefa0de 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -6807,12 +6807,14 @@ impl fmt::Display for Statement { } Statement::ShowObjects(ShowObjects { terse, + dynamic, show_options, }) => { write!( f, - "SHOW {terse}OBJECTS{show_options}", + "SHOW {terse}{kind}{show_options}", terse = if *terse { "TERSE " } else { "" }, + kind = if *dynamic { "DYNAMIC TABLES" } else { "OBJECTS" }, )?; Ok(()) } @@ -10088,6 +10090,9 @@ pub enum ObjectType { View, /// A materialized view. MaterializedView, + /// A dynamic table (Snowflake). + /// + DynamicTable, /// An index. Index, /// A schema. @@ -10121,6 +10126,7 @@ impl fmt::Display for ObjectType { ObjectType::Table => "TABLE", ObjectType::View => "VIEW", ObjectType::MaterializedView => "MATERIALIZED VIEW", + ObjectType::DynamicTable => "DYNAMIC TABLE", ObjectType::Index => "INDEX", ObjectType::Schema => "SCHEMA", ObjectType::Database => "DATABASE", @@ -10292,6 +10298,9 @@ impl fmt::Display for HiveDescribeFormat { pub enum DescribeObjectType { /// `TABLE` Table, + /// `DYNAMIC TABLE` (Snowflake) + /// + DynamicTable, /// `VIEW` View, /// `DATABASE` @@ -10308,6 +10317,7 @@ impl fmt::Display for DescribeObjectType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { DescribeObjectType::Table => "TABLE", + DescribeObjectType::DynamicTable => "DYNAMIC TABLE", DescribeObjectType::View => "VIEW", DescribeObjectType::Database => "DATABASE", DescribeObjectType::Schema => "SCHEMA", @@ -12585,6 +12595,9 @@ impl fmt::Display for ShowCharset { pub struct ShowObjects { /// Whether to show terse output. pub terse: bool, + /// Whether this is `SHOW DYNAMIC TABLES` rather than `SHOW OBJECTS` + /// (Snowflake). Both share the option grammar (`LIKE` / `IN` / …). + pub dynamic: bool, /// Additional options controlling the SHOW output. pub show_options: ShowStatementOptions, } diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 85a13696b9..c3ac4d3e46 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -550,8 +550,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_show_accounts(parser)); } let terse = parser.parse_keyword(Keyword::TERSE); + if parser.parse_keywords(&[Keyword::DYNAMIC, Keyword::TABLES]) { + return Some(parse_show_objects(terse, true, parser)); + } if parser.parse_keyword(Keyword::OBJECTS) { - return Some(parse_show_objects(terse, parser)); + return Some(parse_show_objects(terse, false, parser)); } if parser.parse_keywords(&[Keyword::FILE, Keyword::FORMATS]) { return Some(parse_show_file_formats(terse, parser)); @@ -2183,10 +2186,15 @@ fn parse_column_tags(parser: &mut Parser, with: bool) -> Result -fn parse_show_objects(terse: bool, parser: &mut Parser) -> Result { +fn parse_show_objects( + terse: bool, + dynamic: bool, + parser: &mut Parser, +) -> Result { let show_options = parser.parse_show_stmt_options()?; Ok(Statement::ShowObjects(ShowObjects { terse, + dynamic, show_options, })) } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 0cdecadbad..4ffc754f0d 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7893,7 +7893,9 @@ impl<'a> Parser<'a> { let persistent = dialect_of!(self is DuckDbDialect) && self.parse_one_of_keywords(&[Keyword::PERSISTENT]).is_some(); - let object_type = if self.parse_keyword(Keyword::TABLE) + let object_type = if self.parse_keywords(&[Keyword::DYNAMIC, Keyword::TABLE]) { + ObjectType::DynamicTable + } else if self.parse_keyword(Keyword::TABLE) || self.parse_keywords(&[Keyword::ICEBERG, Keyword::TABLE]) { ObjectType::Table @@ -14854,6 +14856,14 @@ impl<'a> Parser<'a> { query_id: Box::new(query_id), }); } + if self.parse_keywords(&[Keyword::DYNAMIC, Keyword::TABLE]) { + let object_name = self.parse_object_name(false)?; + return Ok(Statement::DescribeObject { + describe_alias, + object_type: DescribeObjectType::DynamicTable, + object_name, + }); + } if let Some(kw) = self.parse_one_of_keywords(&[ Keyword::TABLE, Keyword::VIEW, From 38667da4bd0138a19775ae1c8bc94e8acaab518b Mon Sep 17 00:00:00 2001 From: sabir Date: Tue, 7 Jul 2026 18:07:47 +0200 Subject: [PATCH 35/59] Snowflake: parse ALTER DYNAMIC TABLE SET/UNSET/RENAME/CLUSTER natively --- src/dialect/snowflake.rs | 128 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 122 insertions(+), 6 deletions(-) diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index c3ac4d3e46..86aaef1d05 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -31,12 +31,13 @@ use crate::ast::{ AlterTableOperation, AlterTableType, CatalogRestAuthentication, CatalogRestConfig, CatalogSource, CatalogSyncNamespaceMode, CatalogTableFormat, ColumnOption, ColumnPolicy, ColumnPolicyProperty, ContactEntry, CopyIntoSnowflakeKind, CreateTable, CreateTableLikeKind, - DollarQuotedString, ExternalVolumeEncryption, ExternalVolumeStorageLocation, Ident, + DollarQuotedString, Expr, ExternalVolumeEncryption, ExternalVolumeStorageLocation, Ident, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, InitializeKind, Insert, MultiTableInsertIntoClause, MultiTableInsertType, MultiTableInsertValue, MultiTableInsertValues, MultiTableInsertWhenClause, ObjectName, ObjectNamePart, ObjectType, OperateFunctionArg, - RefreshModeKind, RowAccessPolicy, ShowKeysKind, ShowObjects, SqlOption, Statement, + RefreshModeKind, RenameTableNameKind, RowAccessPolicy, ShowKeysKind, ShowObjects, SqlOption, + Statement, StorageLifecyclePolicy, StorageSerializationPolicy, Tag, TableObject, TagsColumnOption, Value, WrappedCollection, }; @@ -955,21 +956,43 @@ fn parse_file_staging_command(kw: Keyword, parser: &mut Parser) -> Result +/// fn parse_alter_dynamic_table(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); // Use parse_object_name(true) to support IDENTIFIER() function let table_name = parser.parse_object_name(true)?; - // Parse the operation (REFRESH, SUSPEND, or RESUME) let operation = if parser.parse_keyword(Keyword::REFRESH) { AlterTableOperation::Refresh { subpath: None } } else if parser.parse_keyword(Keyword::SUSPEND) { AlterTableOperation::Suspend } else if parser.parse_keyword(Keyword::RESUME) { AlterTableOperation::Resume + } else if parser.parse_keyword(Keyword::RENAME) { + parser.expect_keyword_is(Keyword::TO)?; + let new_name = parser.parse_object_name(false)?; + AlterTableOperation::RenameTable { + table_name: RenameTableNameKind::To(new_name), + } + } else if parser.parse_keywords(&[Keyword::CLUSTER, Keyword::BY]) { + parser.expect_token(&Token::LParen)?; + let exprs = parser.parse_comma_separated(|p| p.parse_expr())?; + parser.expect_token(&Token::RParen)?; + AlterTableOperation::ClusterBy { exprs } + } else if parser.parse_keywords(&[Keyword::DROP, Keyword::CLUSTERING, Keyword::KEY]) { + AlterTableOperation::DropClusteringKey + } else if parser.parse_keyword(Keyword::SET) { + AlterTableOperation::SetOptionsParens { + options: parse_alter_dynamic_table_properties(parser, false)?, + } + } else if parser.parse_keyword(Keyword::UNSET) { + AlterTableOperation::SetOptionsParens { + options: parse_alter_dynamic_table_properties(parser, true)?, + } } else { return parser.expected_ref( - "REFRESH, SUSPEND, or RESUME after ALTER DYNAMIC TABLE", + "REFRESH, SUSPEND, RESUME, RENAME, SET, UNSET, CLUSTER BY, \ + or DROP CLUSTERING KEY after ALTER DYNAMIC TABLE", parser.peek_token_ref(), ); }; @@ -982,7 +1005,7 @@ fn parse_alter_dynamic_table(parser: &mut Parser) -> Result Result` is encoded as ` = NULL`. +fn parse_alter_dynamic_table_properties( + parser: &mut Parser, + unset: bool, +) -> Result, ParserError> { + let mut options = vec![parse_alter_dynamic_table_property(parser, unset)?]; + loop { + let _ = parser.consume_token(&Token::Comma); + if matches!(parser.peek_token().token, Token::EOF | Token::SemiColon) { + break; + } + options.push(parse_alter_dynamic_table_property(parser, unset)?); + } + Ok(options) +} + +/// Parse one `ALTER DYNAMIC TABLE … SET/UNSET` property into an +/// [`SqlOption::KeyValue`]. See [`parse_alter_dynamic_table_properties`]. +fn parse_alter_dynamic_table_property( + parser: &mut Parser, + unset: bool, +) -> Result { + let key_token = parser.next_token(); + let key = match &key_token.token { + Token::Word(w) if w.quote_style.is_none() => w.value.to_uppercase(), + _ => return parser.expected("a dynamic table property name", key_token), + }; + + if key == "IMMUTABLE" { + parser.expect_keyword_is(Keyword::WHERE)?; + let value = if unset { + Value::Null + } else { + parser.expect_token(&Token::LParen)?; + let predicate = parser.parse_expr()?; + parser.expect_token(&Token::RParen)?; + Value::SingleQuotedString(predicate.to_string()) + }; + return Ok(SqlOption::KeyValue { + key: Ident::new("IMMUTABLE_WHERE"), + value: Expr::Value(value.into()), + }); + } + + if unset { + if !matches!( + key.as_str(), + "COMMENT" | "INITIALIZATION_WAREHOUSE" | "SCHEDULER" + ) { + return parser.expected( + "COMMENT, INITIALIZATION_WAREHOUSE, SCHEDULER, or IMMUTABLE WHERE after UNSET", + key_token, + ); + } + return Ok(SqlOption::KeyValue { + key: Ident::new(key), + value: Expr::Value(Value::Null.into()), + }); + } + + if !matches!( + key.as_str(), + "TARGET_LAG" | "WAREHOUSE" | "INITIALIZATION_WAREHOUSE" | "COMMENT" | "SCHEDULER" + ) { + return parser.expected("a dynamic table property name", key_token); + } + parser.expect_token(&Token::Eq)?; + let value_token = parser.next_token(); + let value = match &value_token.token { + Token::SingleQuotedString(s) => s.clone(), + // WAREHOUSE / INITIALIZATION_WAREHOUSE keep the user's verbatim + // spelling; other keyword values (bare DOWNSTREAM) are uppercased. + Token::Word(w) + if w.quote_style.is_none() + && key != "WAREHOUSE" + && key != "INITIALIZATION_WAREHOUSE" => + { + w.value.to_uppercase() + } + Token::Word(w) if w.quote_style.is_none() => w.value.clone(), + _ => return parser.expected("a property value", value_token), + }; + Ok(SqlOption::KeyValue { + key: Ident::new(key), + value: Expr::Value(Value::SingleQuotedString(value).into()), + }) +} + /// Parse snowflake alter external table. /// fn parse_alter_external_table(parser: &mut Parser) -> Result { From 281699d640eaf525d3e5ed8c684aa755484f20b6 Mon Sep 17 00:00:00 2001 From: sabir Date: Thu, 9 Jul 2026 08:24:13 +0200 Subject: [PATCH 36/59] Snowflake: make CHANGES AT/BEFORE bound optional --- src/ast/query.rs | 11 ++++++++--- src/parser/mod.rs | 8 ++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/ast/query.rs b/src/ast/query.rs index 6b8b36eb4c..ccd56fe341 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -2630,8 +2630,10 @@ pub enum TableVersion { Changes { /// The `CHANGES(INFORMATION => ...)` function-call expression. changes: Expr, - /// The `AT(TIMESTAMP => ...)` function-call expression. - at: Expr, + /// The optional `AT(TIMESTAMP => ...)` / `BEFORE(...)` function-call + /// expression. Absent when `CHANGES` is used without an `AT`/`BEFORE` + /// bound. + at: Option, /// The optional `END(TIMESTAMP => ...)` function-call expression. end: Option, }, @@ -2645,7 +2647,10 @@ impl Display for TableVersion { TableVersion::VersionAsOf(e) => write!(f, "VERSION AS OF {e}")?, TableVersion::Function(func) => write!(f, "{func}")?, TableVersion::Changes { changes, at, end } => { - write!(f, "{changes} {at}")?; + write!(f, "{changes}")?; + if let Some(at) = at { + write!(f, " {at}")?; + } if let Some(end) = end { write!(f, " {end}")?; } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 4ffc754f0d..265b7f2993 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -17800,8 +17800,12 @@ impl<'a> Parser<'a> { fn parse_table_version_changes(&mut self) -> Result { let changes_name = self.parse_object_name(true)?; let changes = self.parse_function(changes_name)?; - let at_name = self.parse_object_name(true)?; - let at = self.parse_function(at_name)?; + let at = if self.peek_keyword(Keyword::AT) || self.peek_keyword(Keyword::BEFORE) { + let at_name = self.parse_object_name(true)?; + Some(self.parse_function(at_name)?) + } else { + None + }; let end = if self.peek_keyword(Keyword::END) { let end_name = self.parse_object_name(true)?; Some(self.parse_function(end_name)?) From cc25e59aaabcfa32c70a56d26b25ecafc499861f Mon Sep 17 00:00:00 2001 From: sabir Date: Thu, 9 Jul 2026 15:09:15 +0200 Subject: [PATCH 37/59] Snowflake: parse CREATE [OR REPLACE] STREAM [IF NOT EXISTS] ON TABLE
--- src/ast/mod.rs | 26 ++++++++++++++++++++++++++ src/ast/spans.rs | 1 + src/parser/mod.rs | 16 ++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index aeeeefa0de..abf20cd45d 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -4859,6 +4859,19 @@ pub enum Statement { name: ObjectName, }, /// ```sql + /// CREATE [OR REPLACE] STREAM [IF NOT EXISTS] ON TABLE
+ /// ``` + CreateStream { + /// `OR REPLACE` flag. + or_replace: bool, + /// `IF NOT EXISTS` flag. + if_not_exists: bool, + /// Stream name. + name: ObjectName, + /// The source table the stream tracks (`ON TABLE
`). + source_table: ObjectName, + }, + /// ```sql /// ALTER WAREHOUSE [IF EXISTS] [] /// ``` /// See @@ -7199,6 +7212,19 @@ impl fmt::Display for Statement { if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, ) } + Statement::CreateStream { + or_replace, + if_not_exists, + name, + source_table, + } => { + write!( + f, + "CREATE {or_replace}STREAM {if_not_exists}{name} ON TABLE {source_table}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, + ) + } Statement::AlterWarehouse { name, if_exists, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index a4bc570066..7ad73700b9 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -532,6 +532,7 @@ impl Spanned for Statement { Statement::AlterUser(..) => Span::empty(), Statement::Reset(..) => Span::empty(), Statement::CreateWarehouse { .. } => Span::empty(), + Statement::CreateStream { .. } => Span::empty(), Statement::AlterWarehouse { .. } => Span::empty(), Statement::DescribeWarehouse { .. } => Span::empty(), Statement::ShowWarehouses { .. } => Span::empty(), diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 265b7f2993..a11fc6c58a 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5523,6 +5523,8 @@ impl<'a> Parser<'a> { self.parse_create_role(or_replace).map(Into::into) } else if self.parse_keyword(Keyword::SEQUENCE) { self.parse_create_sequence(or_replace, temporary) + } else if self.parse_keyword(Keyword::STREAM) { + self.parse_create_stream(or_replace) } else if or_replace { self.expected_ref( "[EXTERNAL] TABLE or [MATERIALIZED] VIEW or FUNCTION or WAREHOUSE or TASK or PROCEDURE or SCHEMA or ROLE or SEQUENCE after CREATE OR REPLACE", @@ -5592,6 +5594,20 @@ impl<'a> Parser<'a> { }) } + /// `CREATE [OR REPLACE] STREAM [IF NOT EXISTS] ON TABLE
` + fn parse_create_stream(&mut self, or_replace: bool) -> Result { + let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = self.parse_object_name(false)?; + self.expect_keywords(&[Keyword::ON, Keyword::TABLE])?; + let source_table = self.parse_object_name(false)?; + Ok(Statement::CreateStream { + or_replace, + if_not_exists, + name, + source_table, + }) + } + fn parse_create_warehouse(&mut self, or_replace: bool) -> Result { let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); let name = self.parse_object_name(false)?; From a6a00a89cbdc22fd934ec34c244b0885ac96e9ab Mon Sep 17 00:00:00 2001 From: sabir Date: Thu, 9 Jul 2026 16:06:47 +0200 Subject: [PATCH 38/59] Snowflake: parse SHOW STREAMS / DESCRIBE STREAM / ALTER STREAM COMMENT Adds ShowStreams and AlterStream statements, DescribeObjectType::Stream, the STREAMS keyword, and an EOF-anchored error for the invalid DESCRIBE TABLE STREAM shape (matching real Snowflake). --- src/ast/mod.rs | 70 +++++++++++++++++++++++++++++++++++++++++++++++ src/ast/spans.rs | 2 ++ src/keywords.rs | 1 + src/parser/mod.rs | 46 ++++++++++++++++++++++++++++++- 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index abf20cd45d..91e7e92938 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -5011,6 +5011,29 @@ pub enum Statement { show_options: ShowStatementOptions, }, /// ```sql + /// SHOW [TERSE] STREAMS [LIKE ''] + /// [IN { ACCOUNT | DATABASE | SCHEMA }] + /// ``` + /// See + ShowStreams { + /// `true` when terse output format was requested. + terse: bool, + /// Additional options for `SHOW` statements. + show_options: ShowStatementOptions, + }, + /// ```sql + /// ALTER STREAM [IF EXISTS] { SET COMMENT = '' | UNSET COMMENT } + /// ``` + /// See + AlterStream { + /// `IF EXISTS` flag. + if_exists: bool, + /// Stream name. + name: ObjectName, + /// The alter action. + operation: AlterStreamOperation, + }, + /// ```sql /// CREATE [OR REPLACE] EXTERNAL VOLUME [IF NOT EXISTS] /// ``` /// See @@ -7353,6 +7376,28 @@ impl fmt::Display for Statement { )?; Ok(()) } + Statement::ShowStreams { + terse, + show_options, + } => { + write!( + f, + "SHOW {terse}STREAMS{show_options}", + terse = if *terse { "TERSE " } else { "" }, + )?; + Ok(()) + } + Statement::AlterStream { + if_exists, + name, + operation, + } => { + write!(f, "ALTER STREAM")?; + if *if_exists { + write!(f, " IF EXISTS")?; + } + write!(f, " {name} {operation}") + } Statement::CreateExternalVolume { or_replace, if_not_exists, @@ -10337,6 +10382,8 @@ pub enum DescribeObjectType { Task, /// `STAGE` Stage, + /// `STREAM` + Stream, } impl fmt::Display for DescribeObjectType { @@ -10349,6 +10396,7 @@ impl fmt::Display for DescribeObjectType { DescribeObjectType::Schema => "SCHEMA", DescribeObjectType::Task => "TASK", DescribeObjectType::Stage => "STAGE", + DescribeObjectType::Stream => "STREAM", }) } } @@ -13021,6 +13069,28 @@ impl fmt::Display for AlterTaskAction { } } +/// Action for [`Statement::AlterStream`]. +/// +/// See . +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AlterStreamOperation { + /// `SET COMMENT = ''` + SetComment(String), + /// `UNSET COMMENT` + UnsetComment, +} + +impl fmt::Display for AlterStreamOperation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AlterStreamOperation::SetComment(value) => write!(f, "SET COMMENT = '{value}'"), + AlterStreamOperation::UnsetComment => write!(f, "UNSET COMMENT"), + } + } +} + /// The `CATALOG_SOURCE` of a `CATALOG INTEGRATION`. #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 7ad73700b9..06b78b03f9 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -542,8 +542,10 @@ impl Spanned for Statement { Statement::ShowAccounts { .. } => Span::empty(), Statement::CreateTask { .. } => Span::empty(), Statement::AlterTask { .. } => Span::empty(), + Statement::AlterStream { .. } => Span::empty(), Statement::ExecuteTask { .. } => Span::empty(), Statement::ShowTasks { .. } => Span::empty(), + Statement::ShowStreams { .. } => Span::empty(), Statement::CreateExternalVolume { .. } => Span::empty(), Statement::AlterExternalVolume { .. } => Span::empty(), Statement::DropExternalVolume { .. } => Span::empty(), diff --git a/src/keywords.rs b/src/keywords.rs index 56133d1e8c..7c125b75a5 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -1064,6 +1064,7 @@ define_keywords!( STORED, STRAIGHT_JOIN, STREAM, + STREAMS, STRICT, STRING, STRUCT, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index a11fc6c58a..d1667560b3 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -11459,6 +11459,7 @@ impl<'a> Parser<'a> { Keyword::WAREHOUSE, Keyword::ACCOUNT, Keyword::TASK, + Keyword::STREAM, ])?; match object_type { Keyword::SCHEMA => { @@ -11510,9 +11511,10 @@ impl<'a> Parser<'a> { Keyword::WAREHOUSE => self.parse_alter_warehouse(), Keyword::ACCOUNT => self.parse_alter_account(), Keyword::TASK => self.parse_alter_task(), + Keyword::STREAM => self.parse_alter_stream(), // unreachable because expect_one_of_keywords used above unexpected_keyword => Err(ParserError::ParserError( - format!("Internal parser error: expected any of {{VIEW, TYPE, COLLATION, TABLE, INDEX, FUNCTION, AGGREGATE, ROLE, POLICY, CONNECTOR, ICEBERG, SCHEMA, USER, OPERATOR, WAREHOUSE, ACCOUNT, TASK}}, got {unexpected_keyword:?}"), + format!("Internal parser error: expected any of {{VIEW, TYPE, COLLATION, TABLE, INDEX, FUNCTION, AGGREGATE, ROLE, POLICY, CONNECTOR, ICEBERG, SCHEMA, USER, OPERATOR, WAREHOUSE, ACCOUNT, TASK, STREAM}}, got {unexpected_keyword:?}"), )), } } @@ -11849,6 +11851,27 @@ impl<'a> Parser<'a> { }) } + /// Parse `ALTER STREAM [IF EXISTS] { SET COMMENT = '' | UNSET COMMENT }`. + pub fn parse_alter_stream(&mut self) -> Result { + 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) { + self.expect_keyword(Keyword::COMMENT)?; + self.expect_token(&Token::Eq)?; + AlterStreamOperation::SetComment(self.parse_literal_string()?) + } else if self.parse_keyword(Keyword::UNSET) { + self.expect_keyword(Keyword::COMMENT)?; + AlterStreamOperation::UnsetComment + } else { + return self.expected("SET or UNSET after ALTER STREAM", self.peek_token()); + }; + Ok(Statement::AlterStream { + if_exists, + name, + operation, + }) + } + /// Parse a [Statement::AlterTable] pub fn parse_alter_table(&mut self, iceberg: bool) -> Result { let if_exists = self.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); @@ -14880,6 +14903,15 @@ impl<'a> Parser<'a> { object_name, }); } + // `DESCRIBE TABLE STREAM ` is not valid Snowflake + // syntax (a client such as sqlglot may emit it). Real + // Snowflake consumes the whole statement and then reports an + // unexpected end-of-input; mirror that by draining the name + // and failing at EOF rather than at the second identifier. + if self.parse_keywords(&[Keyword::TABLE, Keyword::STREAM]) { + let _ = self.parse_object_name(false)?; + return self.expected("end of statement", self.peek_token()); + } if let Some(kw) = self.parse_one_of_keywords(&[ Keyword::TABLE, Keyword::VIEW, @@ -14887,6 +14919,7 @@ impl<'a> Parser<'a> { Keyword::SCHEMA, Keyword::TASK, Keyword::STAGE, + Keyword::STREAM, ]) { let object_type = match kw { Keyword::TABLE => DescribeObjectType::Table, @@ -14895,6 +14928,7 @@ impl<'a> Parser<'a> { Keyword::SCHEMA => DescribeObjectType::Schema, Keyword::TASK => DescribeObjectType::Task, Keyword::STAGE => DescribeObjectType::Stage, + Keyword::STREAM => DescribeObjectType::Stream, _ => return self.expected("a describe object type", self.peek_token()), }; let object_name = self.parse_object_name(false)?; @@ -16403,6 +16437,8 @@ impl<'a> Parser<'a> { Ok(self.parse_show_tables(terse, extended, full, external)?) } else if self.parse_keyword(Keyword::TASKS) { Ok(self.parse_show_tasks(terse)?) + } else if self.parse_keyword(Keyword::STREAMS) { + Ok(self.parse_show_streams(terse)?) } else if self.parse_keywords(&[Keyword::MATERIALIZED, Keyword::VIEWS]) { Ok(self.parse_show_views(terse, true)?) } else if self.parse_keyword(Keyword::VIEWS) { @@ -16557,6 +16593,14 @@ impl<'a> Parser<'a> { }) } + fn parse_show_streams(&mut self, terse: bool) -> Result { + let show_options = self.parse_show_stmt_options()?; + Ok(Statement::ShowStreams { + terse, + show_options, + }) + } + fn parse_show_views( &mut self, terse: bool, From c7af4d6f744dfa0e023570f06b6c45761e0a4a21 Mon Sep 17 00:00:00 2001 From: sabir Date: Fri, 10 Jul 2026 11:15:49 +0200 Subject: [PATCH 39/59] Snowflake: parse table-level STAGE_FILE_FORMAT on CREATE TABLE Co-Authored-By: Claude Opus 4.8 --- src/ast/ddl.rs | 8 ++++++++ src/ast/helpers/stmt_create_table.rs | 11 +++++++++++ src/ast/spans.rs | 1 + src/dialect/snowflake.rs | 5 +++++ src/keywords.rs | 1 + tests/sqlparser_mssql.rs | 2 ++ tests/sqlparser_postgres.rs | 1 + 7 files changed, 29 insertions(+) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 957c567351..c94ce9f33b 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -34,6 +34,7 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "visitor")] use sqlparser_derive::{Visit, VisitMut}; +use crate::ast::helpers::key_value_options::KeyValueOptions; use crate::ast::value::escape_single_quote_string; use crate::ast::{ display_comma_separated, display_separated, @@ -3082,6 +3083,9 @@ pub struct CreateTable { /// Snowflake "CHANGE_TRACKING" clause /// pub change_tracking: Option, + /// Snowflake table-level "STAGE_FILE_FORMAT" clause + /// + pub stage_file_format: Option, /// Snowflake "DATA_RETENTION_TIME_IN_DAYS" clause /// pub data_retention_time_in_days: Option, @@ -3417,6 +3421,10 @@ impl fmt::Display for CreateTable { )?; } + if let Some(stage_file_format) = &self.stage_file_format { + write!(f, " STAGE_FILE_FORMAT=({stage_file_format})")?; + } + if let Some(data_retention_time_in_days) = self.data_retention_time_in_days { write!( f, diff --git a/src/ast/helpers/stmt_create_table.rs b/src/ast/helpers/stmt_create_table.rs index 5bc480019d..1cd35a3adb 100644 --- a/src/ast/helpers/stmt_create_table.rs +++ b/src/ast/helpers/stmt_create_table.rs @@ -24,6 +24,7 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "visitor")] use sqlparser_derive::{Visit, VisitMut}; +use crate::ast::helpers::key_value_options::KeyValueOptions; use crate::ast::{ ClusteredBy, ColumnDef, CommentDef, CreateTable, CreateTableLikeKind, CreateTableOptions, DistStyle, Expr, FileFormat, ForValues, HiveDistributionStyle, HiveFormat, Ident, @@ -139,6 +140,8 @@ pub struct CreateTableBuilder { pub enable_schema_evolution: Option, /// Optional change tracking flag. pub change_tracking: Option, + /// Optional table-level stage file format options. + pub stage_file_format: Option, /// Optional data retention time in days. pub data_retention_time_in_days: Option, /// Optional max data extension time in days. @@ -236,6 +239,7 @@ impl CreateTableBuilder { copy_grants: false, enable_schema_evolution: None, change_tracking: None, + stage_file_format: None, data_retention_time_in_days: None, max_data_extension_time_in_days: None, default_ddl_collation: None, @@ -449,6 +453,11 @@ impl CreateTableBuilder { self.change_tracking = change_tracking; self } + /// Set the table-level stage file format options. + pub fn stage_file_format(mut self, stage_file_format: Option) -> Self { + self.stage_file_format = stage_file_format; + self + } /// Set data retention time (in days). pub fn data_retention_time_in_days(mut self, data_retention_time_in_days: Option) -> Self { self.data_retention_time_in_days = data_retention_time_in_days; @@ -626,6 +635,7 @@ impl CreateTableBuilder { copy_grants: self.copy_grants, enable_schema_evolution: self.enable_schema_evolution, change_tracking: self.change_tracking, + stage_file_format: self.stage_file_format, data_retention_time_in_days: self.data_retention_time_in_days, max_data_extension_time_in_days: self.max_data_extension_time_in_days, default_ddl_collation: self.default_ddl_collation, @@ -712,6 +722,7 @@ impl From for CreateTableBuilder { copy_grants: table.copy_grants, enable_schema_evolution: table.enable_schema_evolution, change_tracking: table.change_tracking, + stage_file_format: table.stage_file_format, data_retention_time_in_days: table.data_retention_time_in_days, max_data_extension_time_in_days: table.max_data_extension_time_in_days, default_ddl_collation: table.default_ddl_collation, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 06b78b03f9..6a5ec4b8a6 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -642,6 +642,7 @@ impl Spanned for CreateTable { copy_grants: _, // bool enable_schema_evolution: _, // bool change_tracking: _, // bool + stage_file_format: _, // key-value options, no span data_retention_time_in_days: _, // u64, no span max_data_extension_time_in_days: _, // u64, no span default_ddl_collation: _, // string, no span diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 86aaef1d05..0adfecfe19 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -1249,6 +1249,11 @@ pub fn parse_create_table( parser.expect_token(&Token::Eq)?; builder = builder.change_tracking(Some(parser.parse_boolean_string()?)); } + Keyword::STAGE_FILE_FORMAT => { + parser.expect_token(&Token::Eq)?; + let options = parser.parse_key_value_options(true, &[], false)?; + builder = builder.stage_file_format(Some(options)); + } Keyword::DATA_RETENTION_TIME_IN_DAYS => { parser.expect_token(&Token::Eq)?; let data_retention_time_in_days = parser.parse_literal_uint()?; diff --git a/src/keywords.rs b/src/keywords.rs index 7c125b75a5..6177b30eb7 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -1037,6 +1037,7 @@ define_keywords!( STABLE, STAGE, STAGES, + STAGE_FILE_FORMAT, START, STARTS, STATEMENT, diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index 634ae6a843..668bb00c6b 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -1996,6 +1996,7 @@ fn parse_create_table_with_valid_options() { copy_grants: false, enable_schema_evolution: None, change_tracking: None, + stage_file_format: None, data_retention_time_in_days: None, max_data_extension_time_in_days: None, default_ddl_collation: None, @@ -2172,6 +2173,7 @@ fn parse_create_table_with_identity_column() { copy_grants: false, enable_schema_evolution: None, change_tracking: None, + stage_file_format: None, data_retention_time_in_days: None, max_data_extension_time_in_days: None, default_ddl_collation: None, diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 79e101e4ed..8696959068 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -6700,6 +6700,7 @@ fn parse_trigger_related_functions() { copy_grants: false, enable_schema_evolution: None, change_tracking: None, + stage_file_format: None, data_retention_time_in_days: None, max_data_extension_time_in_days: None, default_ddl_collation: None, From 60d7744356ab94db673e05ec4d53e36479c7228a Mon Sep 17 00:00:00 2001 From: sabir Date: Fri, 10 Jul 2026 13:27:38 +0200 Subject: [PATCH 40/59] Snowflake: lower COPY FILE_FORMAT=() to the TOK_CONSTANT_LIST format-name error --- src/dialect/snowflake.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 0adfecfe19..44c2934349 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -1925,7 +1925,22 @@ pub fn parse_copy_into(parser: &mut Parser) -> Result { if parser.parse_keyword(Keyword::FILE_FORMAT) { parser.expect_token(&Token::Eq)?; if parser.peek_token().token == Token::LParen { + let paren_span = parser.peek_token().span; file_format = parser.parse_key_value_options(true, &[], false)?.options; + if file_format.is_empty() { + // Snowflake parses an empty `FILE_FORMAT = ()` as a reference + // to its internal empty-constant-list token and then resolves + // that as a format name, so the clause fails with + // "File format 'TOK_CONSTANT_LIST' does not exist". Mirror + // that by lowering `()` to `FORMAT_NAME = TOK_CONSTANT_LIST`. + file_format = vec![KeyValueOption { + option_name: "FORMAT_NAME".to_string(), + option_value: KeyValueOptionKind::Single( + Value::Placeholder("TOK_CONSTANT_LIST".to_string()) + .with_span(paren_span), + ), + }]; + } } else { // Shorthand `FILE_FORMAT = ''` / `FILE_FORMAT = ` // is sugar for `FILE_FORMAT = (FORMAT_NAME = )` — From 3e9ee9fd7bc00520c1d436fc6243db6231a6bd93 Mon Sep 17 00:00:00 2001 From: Wojciech Padlo Date: Fri, 10 Jul 2026 14:18:25 +0200 Subject: [PATCH 41/59] Snowflake: stop stage name identifier at comma for comma-join A stage table factor followed by a comma join (e.g. FROM @stage/f.jsonl, LATERAL FLATTEN(...)) failed to parse when the comma abutted the stage path, because parse_stage_name_identifier consumed the comma and then reported the following token (LATERAL) as unexpected. Add a Token::Comma stop-arm mirroring the Period/LParen arms so the comma is pushed back and the shared comma-join/LATERAL handling takes over. --- src/dialect/snowflake.rs | 4 ++++ tests/sqlparser_snowflake.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 44c2934349..96c8b4c0db 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -1794,6 +1794,10 @@ pub fn parse_stage_name_identifier(parser: &mut Parser) -> Result { + parser.prev_token(); + break; + } Token::AtSign => ident.push('@'), Token::Tilde => ident.push('~'), Token::Mod => ident.push('%'), diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 9e12ce4975..275dbbe770 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -5719,6 +5719,34 @@ fn test_select_dollar_column_from_stage() { snowflake().verified_stmt("SELECT $1, $2 FROM @mystage1(file_format => 'myformat')"); } +#[test] +fn test_select_from_stage_comma_join_lateral() { + // Comma immediately abutting the stage path, followed by LATERAL FLATTEN. + snowflake().one_statement_parses_to( + "SELECT value FROM @stage/test.jsonl, LATERAL FLATTEN(input => $1, path => 'attr1')", + "SELECT value FROM @stage/test.jsonl, LATERAL FLATTEN(input => $1, path => 'attr1')", + ); + // Space before the comma parses to the same canonical form. + snowflake().one_statement_parses_to( + "SELECT value FROM @stage/test.jsonl , LATERAL FLATTEN(input => $1, path => 'attr1')", + "SELECT value FROM @stage/test.jsonl, LATERAL FLATTEN(input => $1, path => 'attr1')", + ); + // Comma abutting the path, with a table alias on the stage factor. + let stmt = snowflake() + .parse_sql_statements("SELECT value FROM @stage/test.jsonl t, LATERAL FLATTEN(input => $1)") + .expect("stage factor with alias and comma join should parse"); + assert_eq!( + stmt[0].to_string(), + "SELECT value FROM @stage/test.jsonl t, LATERAL FLATTEN(input => $1)" + ); + // Explicit `AS` alias round-trips. + snowflake() + .verified_stmt("SELECT value FROM @stage/test.jsonl AS t, LATERAL FLATTEN(input => $1)"); + // Plain comma join of a stage factor with an ordinary table. + snowflake().verified_stmt("SELECT * FROM @stage, other_table"); + snowflake().verified_stmt("SELECT * FROM @stage/test.jsonl, other_table"); +} + /// Bare assignment `var := expr` inside `BEGIN...END` scripting blocks. #[test] fn test_scripting_bare_assignment() { From c1dcee96a6e225ee0d8871656a458fb1a9eb1226 Mon Sep 17 00:00:00 2001 From: Wojciech Padlo Date: Fri, 10 Jul 2026 14:24:26 +0200 Subject: [PATCH 42/59] Snowflake tests: match new ShowObjects.dynamic field in SHOW OBJECTS test The ShowObjects struct gained a `dynamic` field but this exhaustive pattern in test_show_objects was not updated, breaking the test crate build. Add `..` so the suite compiles. --- tests/sqlparser_snowflake.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 275dbbe770..181432b13d 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -3868,6 +3868,7 @@ fn test_parse_show_objects() { Statement::ShowObjects(ShowObjects { terse, show_options, + .. }) => { assert!(terse); let name = match show_options.show_in { From ec7bd5ca5e9d356bc015cc7b9934568959fd0a36 Mon Sep 17 00:00:00 2001 From: Wojciech Padlo Date: Fri, 10 Jul 2026 15:04:25 +0200 Subject: [PATCH 43/59] Snowflake: roll back to general select-item for extended COPY data-load projections Dotted sub-paths ($1:a.b), ::TYPE casts (incl. bare $1::INT), and their combinations in COPY INTO
FROM (SELECT ...) data-load transformations previously failed at parse (unexpected '.'/'::') because the dedicated item parser consumed only the leading single-word element and committed. Guard the item as fully consumed (next token is ',' or FROM); otherwise fail so maybe_parse rolls back to the general select-item fallback. --- src/dialect/snowflake.rs | 15 ++++- tests/sqlparser_snowflake.rs | 122 +++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 96c8b4c0db..3c069469c3 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -37,9 +37,8 @@ use crate::ast::{ MultiTableInsertType, MultiTableInsertValue, MultiTableInsertValues, MultiTableInsertWhenClause, ObjectName, ObjectNamePart, ObjectType, OperateFunctionArg, RefreshModeKind, RenameTableNameKind, RowAccessPolicy, ShowKeysKind, ShowObjects, SqlOption, - Statement, - StorageLifecyclePolicy, StorageSerializationPolicy, Tag, TableObject, TagsColumnOption, Value, - WrappedCollection, + Statement, StorageLifecyclePolicy, StorageSerializationPolicy, TableObject, Tag, + TagsColumnOption, Value, WrappedCollection, }; use crate::dialect::{Dialect, Precedence}; use crate::keywords::Keyword; @@ -2129,6 +2128,16 @@ fn parse_select_item_for_data_load( }?); } + // The data-load item grammar only covers [.]$[:] [AS ]. + // If the item was not fully consumed here — e.g. a dotted sub-path (`$1:a.b`) or a + // `::TYPE` cast follows — roll back by failing so the caller's general select-item + // fallback parses the extended shape. Items are separated by `,` and terminated by + // the trailing `FROM @stage`, so anything else means the item continues. + if !matches!(parser.peek_token_ref().token, Token::Comma) && !parser.peek_keyword(Keyword::FROM) + { + return parser.expected_ref("end of data load select item", parser.peek_token_ref()); + } + Ok(StageLoadSelectItem { alias, file_col_num, diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 181432b13d..1b19feb485 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -2906,6 +2906,128 @@ fn test_copy_into_with_transformations() { snowflake().parse_sql_statements(sql1).unwrap(); } +#[test] +fn test_copy_into_data_load_dotted_subpaths_and_casts() { + // Extended data-load projection shapes that the dedicated item parser does not + // cover — dotted sub-paths (`$N:a.b`), `::TYPE` casts (with or without a colon + // element, and bare `$N::TYPE`), and their combinations — must fall back to the + // general select-item parser instead of failing at the stray `.`/`::`. + fn transformations(sql: &str) -> Vec { + match snowflake().verified_stmt(sql) { + Statement::CopyIntoSnowflake { + from_transformations, + .. + } => from_transformations.unwrap(), + _ => unreachable!(), + } + } + + // Dotted sub-path with a cast and alias, mixed with a single-word colon element and + // a bare `$N`. The extended item parses as a general SelectItem; the plain ones stay + // StageLoadSelectItem. + let items = transformations( + "COPY INTO t FROM (SELECT $1:country.name::TEXT AS country_name, $1:id, $2 FROM @stage)", + ); + assert!(matches!(items[0], StageLoadSelectItemKind::SelectItem(_))); + assert_eq!( + items[1], + StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { + alias: None, + file_col_num: 1, + element: Some(Ident::new("id")), + item_as: None, + }) + ); + assert_eq!( + items[2], + StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { + alias: None, + file_col_num: 2, + element: None, + item_as: None, + }) + ); + + // Bare `$N::TYPE` cast, mixed with a plain `$N`. + let items = transformations("COPY INTO t FROM (SELECT $1::INT, $2 FROM @stage)"); + assert!(matches!(items[0], StageLoadSelectItemKind::SelectItem(_))); + assert_eq!( + items[1], + StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { + alias: None, + file_col_num: 2, + element: None, + item_as: None, + }) + ); + + // Colon element with a cast, no dotted sub-path. + let items = transformations("COPY INTO t FROM (SELECT $1:id::INT AS id FROM @stage)"); + assert!(matches!(items[0], StageLoadSelectItemKind::SelectItem(_))); + + // Dotted sub-path without a cast. + let items = transformations("COPY INTO t FROM (SELECT $1:a.b FROM @stage)"); + assert!(matches!(items[0], StageLoadSelectItemKind::SelectItem(_))); +} + +#[test] +fn test_copy_into_data_load_accepted_shapes_preserved() { + // The currently-accepted data-load item shapes must keep parsing as dedicated + // StageLoadSelectItems and keep round-tripping. + let sql = concat!( + "COPY INTO t FROM ", + "(SELECT $1, alias.$2, $3:element, $4:element AS a FROM @stage AS alias)" + ); + match snowflake().verified_stmt(sql) { + Statement::CopyIntoSnowflake { + from_transformations, + .. + } => { + let items = from_transformations.unwrap(); + assert_eq!( + items[0], + StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { + alias: None, + file_col_num: 1, + element: None, + item_as: None, + }) + ); + assert_eq!( + items[1], + StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { + alias: Some(Ident::new("alias")), + file_col_num: 2, + element: None, + item_as: None, + }) + ); + assert_eq!( + items[2], + StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { + alias: None, + file_col_num: 3, + element: Some(Ident::new("element")), + item_as: None, + }) + ); + assert_eq!( + items[3], + StageLoadSelectItemKind::StageLoadSelectItem(StageLoadSelectItem { + alias: None, + file_col_num: 4, + element: Some(Ident::new("element")), + item_as: Some(Ident::new("a")), + }) + ); + } + _ => unreachable!(), + } + + // SELECT * remains handled by the general fallback. + snowflake().verified_stmt("COPY INTO t FROM (SELECT * FROM @stage)"); +} + #[test] fn test_copy_into_file_format() { let sql = concat!( From 988cd0a11b8382af5eebaeaa59bb90e0449052a8 Mon Sep 17 00:00:00 2001 From: sabir-akhadov-localstack Date: Tue, 14 Jul 2026 16:02:50 +0200 Subject: [PATCH 44/59] Snowflake: accept single-quoted procedure body (AS '...') Re-parse a single-quoted CREATE PROCEDURE body the same way as a dollar-quoted one, so string-literal bodies with doubled '' quotes parse into a ConditionalStatements block. --- src/parser/mod.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index d1667560b3..bd47d45ba6 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -20944,9 +20944,12 @@ impl<'a> Parser<'a> { self.expect_keyword_is(Keyword::AS)?; // Snowflake encloses the procedure body in a dollar-quoted string - // (e.g. `AS $$ BEGIN … END $$`). Re-parse its content as a - // conditional-statements block so the body is always typed as - // `ConditionalStatements` regardless of how it was written. + // (e.g. `AS $$ BEGIN … END $$`) or, equivalently, a single-quoted + // string with interior quotes doubled (`AS ' … '`). In both cases + // re-parse the string content as a conditional-statements block so + // the body is always typed as `ConditionalStatements` regardless of + // how it was written. The tokenizer already unescapes doubled `''` + // quotes, so the string value is the plain scripting text. let body = match self.peek_token().token.clone() { Token::DollarQuotedString(dqs) => { self.next_token(); // consume the dollar-quoted string token @@ -20954,6 +20957,12 @@ impl<'a> Parser<'a> { .try_with_sql(&dqs.value)? .parse_conditional_statements(&[Keyword::END])? } + Token::SingleQuotedString(body) => { + self.next_token(); // consume the single-quoted string token + Parser::new(self.dialect) + .try_with_sql(&body)? + .parse_conditional_statements(&[Keyword::END])? + } _ => self.parse_conditional_statements(&[Keyword::END])?, }; From 36b09742e81dab35c9827f4ffb34e0b7227cba81 Mon Sep 17 00:00:00 2001 From: Przemyslaw Denkiewicz Date: Wed, 15 Jul 2026 14:19:17 +0200 Subject: [PATCH 45/59] Snowflake: accept bare column ALTER ... COMMENT continuation (COLUMN optional) --- src/parser/mod.rs | 19 ++++++++++++++++-- tests/sqlparser_snowflake.rs | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index bd47d45ba6..66c9708d17 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -10878,6 +10878,18 @@ impl<'a> Parser<'a> { Ok(AlterTableOperation::AlterSortKey { columns }) } + /// Peek whether the upcoming tokens are a bare ` COMMENT ...` + /// continuation of a comma-separated `ALTER COLUMN ... COMMENT` list, i.e. + /// with the `COLUMN` keyword omitted. This shape is unambiguous against + /// every other `ALTER TABLE` operation, which are all keyword-led. + fn peek_bare_column_comment_continuation(&self) -> bool { + matches!(self.peek_nth_token(0).token, Token::Word(_)) + && matches!( + self.peek_nth_token(1).token, + Token::Word(w) if w.keyword == Keyword::COMMENT + ) + } + /// Parse a single `ALTER TABLE` operation and return an `AlterTableOperation`. pub fn parse_alter_table_operation(&mut self) -> Result { let operation = if self.parse_keyword(Keyword::ADD) { @@ -11191,11 +11203,14 @@ impl<'a> Parser<'a> { column_position, } } else if self.dialect.supports_alter_column_comment() - && self.parse_keyword(Keyword::COLUMN) + && (self.parse_keyword(Keyword::COLUMN) + || self.peek_bare_column_comment_continuation()) { // Continuation of a comma-separated `ALTER COLUMN ... COMMENT` list, // e.g. `... ALTER COLUMN c1 COMMENT 's1', COLUMN c2 COMMENT 's2'`. - // The second and later items carry `COLUMN` without a leading `ALTER`. + // The second and later items carry `COLUMN` without a leading `ALTER`, + // and Snowflake also accepts the bare form with `COLUMN` omitted: + // `... ALTER c1 COMMENT 's1', c2 COMMENT 's2'`. let column_name = self.parse_identifier()?; self.expect_keyword_is(Keyword::COMMENT)?; AlterTableOperation::AlterColumn { diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 1b19feb485..e3ff96ee87 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -2084,6 +2084,45 @@ fn test_alter_table_alter_column_comment() { } _ => unreachable!(), } + + // Bare multi-column form: `COLUMN` omitted on every clause (the shape dbt + // `persist_docs` emits). Canonicalises to the full `ALTER COLUMN` spelling. + let sql = "ALTER TABLE tab ALTER \"ID\" COMMENT 's1', \"NAME\" COMMENT $$$$"; + let canonical = "ALTER TABLE tab ALTER COLUMN \"ID\" COMMENT 's1', ALTER COLUMN \"NAME\" COMMENT ''"; + match snowflake().one_statement_parses_to(sql, canonical) { + Statement::AlterTable(AlterTable { operations, .. }) => { + assert_eq!(operations.len(), 2); + match (&operations[0], &operations[1]) { + ( + AlterTableOperation::AlterColumn { + column_name: n1, + op: AlterColumnOperation::Comment { comment: c1 }, + }, + AlterTableOperation::AlterColumn { + column_name: n2, + op: AlterColumnOperation::Comment { comment: c2 }, + }, + ) => { + assert_eq!(n1.to_string(), "\"ID\""); + assert_eq!(c1, "s1"); + assert_eq!(n2.to_string(), "\"NAME\""); + assert_eq!(c2, ""); + } + _ => unreachable!(), + } + } + _ => unreachable!(), + } + + // Mixed form: `COLUMN` on the first clause, omitted on the second. + let sql = "ALTER TABLE tab ALTER COLUMN c1 COMMENT 's1', c2 COMMENT 's2'"; + let canonical = "ALTER TABLE tab ALTER COLUMN c1 COMMENT 's1', ALTER COLUMN c2 COMMENT 's2'"; + match snowflake().one_statement_parses_to(sql, canonical) { + Statement::AlterTable(AlterTable { operations, .. }) => { + assert_eq!(operations.len(), 2); + } + _ => unreachable!(), + } } #[test] From 1c814f3ffd4e3627151eff80b7490cbf189e6dcd Mon Sep 17 00:00:00 2001 From: sabir-akhadov-localstack Date: Mon, 20 Jul 2026 13:54:27 +0200 Subject: [PATCH 46/59] Snowflake: accept :placeholder as SELECT ... INTO target Extend parse_select_into to accept a colon placeholder (e.g. SELECT ... INTO :var) as the target in dialects that opt in via supports_select_into_placeholder_target (Snowflake scripting). The :name placeholder text is preserved as the object-name part so the round-trip is stable and downstream can distinguish it from a bare table target. Bare-identifier targets are unchanged. --- src/dialect/mod.rs | 7 +++++++ src/dialect/snowflake.rs | 6 ++++++ src/parser/mod.rs | 15 ++++++++++++++- tests/sqlparser_snowflake.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 383894587b..390ec361cf 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1115,6 +1115,13 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if this dialect accepts a colon placeholder as a + /// `SELECT ... INTO` target, e.g. `SELECT ... INTO :var` in Snowflake + /// scripting, where the target is a local variable rather than a table. + fn supports_select_into_placeholder_target(&self) -> bool { + false + } + /// Returns true if this dialect supports `$` as a prefix for money literals /// e.g. `SELECT $123.45` (SQL Server) fn supports_dollar_as_money_prefix(&self) -> bool { diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 3c069469c3..263fab8f0a 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -195,6 +195,12 @@ impl Dialect for SnowflakeDialect { true } + /// Snowflake scripting accepts `SELECT ... INTO :var` where the target is a + /// local variable placeholder rather than a table name. + fn supports_select_into_placeholder_target(&self) -> bool { + true + } + /// See fn supports_for_loop_over_cursor(&self) -> bool { true diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 66c9708d17..9254bc3c69 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -20606,7 +20606,20 @@ impl<'a> Parser<'a> { .is_some(); let unlogged = self.parse_keyword(Keyword::UNLOGGED); let table = self.parse_keyword(Keyword::TABLE); - let name = self.parse_object_name(false)?; + let name = if self.dialect.supports_select_into_placeholder_target() + && self.peek_token_ref().token == Token::Colon + { + // Snowflake scripting: `SELECT ... INTO :var` targets a local + // variable. Keep the `:name` placeholder text as the object-name + // part so the round-trip is preserved and downstream can tell it + // apart from a bare table target. + let placeholder = self.parse_value()?; + ObjectName(vec![ObjectNamePart::Identifier(Ident::new( + placeholder.to_string(), + ))]) + } else { + self.parse_object_name(false)? + }; Ok(SelectInto { temporary, diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index e3ff96ee87..861da52e67 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -8625,3 +8625,29 @@ fn test_show_terse_stages() { _ => unreachable!(), } } + +#[test] +fn test_select_into_placeholder_target() { + // Snowflake scripting: a colon placeholder is a valid `SELECT ... INTO` + // target (a local variable), and it round-trips as `:res`. + let stmt = snowflake().verified_stmt("SELECT :res + t.hmy INTO :res FROM tbl AS t"); + let Statement::Query(query) = stmt else { + unreachable!() + }; + let SetExpr::Select(select) = *query.body else { + unreachable!() + }; + let into = select.into.expect("expected SELECT ... INTO clause"); + assert_eq!(into.name.to_string(), ":res"); + assert!(!into.table); + + // Bare-identifier targets are unaffected. + let stmt = snowflake().verified_stmt("SELECT 1 INTO res FROM tbl"); + let Statement::Query(query) = stmt else { + unreachable!() + }; + let SetExpr::Select(select) = *query.body else { + unreachable!() + }; + assert_eq!(select.into.unwrap().name.to_string(), "res"); +} From 9f387b7fc0477c1ce74816c0f0925893fef019e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 11:21:25 +0200 Subject: [PATCH 47/59] Snowflake: parse DESCRIBE / ALTER / TRUNCATE MATERIALIZED VIEW Add DescribeObjectType::MaterializedView, an AlterTableType::MaterializedView ALTER parser (RENAME / CLUSTER BY / DROP CLUSTERING KEY / SUSPEND / RESUME / SET/UNSET SECURE|COMMENT|CONTACT|DATA_METRIC_SCHEDULE), and a materialized_view marker on TRUNCATE so the emulator can distinguish an MV target. --- src/ast/ddl.rs | 15 ++++- src/ast/mod.rs | 4 ++ src/dialect/snowflake.rs | 120 ++++++++++++++++++++++++++++++++++++ src/parser/mod.rs | 12 +++- tests/sqlparser_common.rs | 1 + tests/sqlparser_postgres.rs | 5 ++ 6 files changed, 155 insertions(+), 2 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index c94ce9f33b..cb973494ca 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -4330,6 +4330,9 @@ pub struct Truncate { pub partitions: Option>, /// TABLE - optional keyword pub table: bool, + /// Snowflake-specific: `MATERIALIZED VIEW` target instead of a table. + /// + pub materialized_view: bool, /// Snowflake/Redshift-specific option: [ IF EXISTS ] pub if_exists: bool, /// Postgres-specific option: [ RESTART IDENTITY | CONTINUE IDENTITY ] @@ -4343,7 +4346,13 @@ pub struct Truncate { impl fmt::Display for Truncate { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let table = if self.table { "TABLE " } else { "" }; + let table = if self.materialized_view { + "MATERIALIZED VIEW " + } else if self.table { + "TABLE " + } else { + "" + }; let if_exists = if self.if_exists { "IF EXISTS " } else { "" }; write!( @@ -4798,6 +4807,9 @@ pub enum AlterTableType { /// External table type /// External, + /// Materialized view type + /// + MaterializedView, } /// ALTER TABLE statement @@ -4832,6 +4844,7 @@ impl fmt::Display for AlterTable { Some(AlterTableType::Iceberg) => write!(f, "ALTER ICEBERG TABLE ")?, Some(AlterTableType::Dynamic) => write!(f, "ALTER DYNAMIC TABLE ")?, Some(AlterTableType::External) => write!(f, "ALTER EXTERNAL TABLE ")?, + Some(AlterTableType::MaterializedView) => write!(f, "ALTER MATERIALIZED VIEW ")?, None => write!(f, "ALTER TABLE ")?, } diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 91e7e92938..e214a1a776 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -10374,6 +10374,9 @@ pub enum DescribeObjectType { DynamicTable, /// `VIEW` View, + /// `MATERIALIZED VIEW` (Snowflake) + /// + MaterializedView, /// `DATABASE` Database, /// `SCHEMA` @@ -10392,6 +10395,7 @@ impl fmt::Display for DescribeObjectType { DescribeObjectType::Table => "TABLE", DescribeObjectType::DynamicTable => "DYNAMIC TABLE", DescribeObjectType::View => "VIEW", + DescribeObjectType::MaterializedView => "MATERIALIZED VIEW", DescribeObjectType::Database => "DATABASE", DescribeObjectType::Schema => "SCHEMA", DescribeObjectType::Task => "TASK", diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 263fab8f0a..fa4e79d91c 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -295,6 +295,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_alter_dynamic_table(parser)); } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::MATERIALIZED, Keyword::VIEW]) { + // ALTER MATERIALIZED VIEW + return Some(parse_alter_materialized_view(parser)); + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::EXTERNAL, Keyword::VOLUME]) { // ALTER EXTERNAL VOLUME return Some(parse_alter_external_volume(parser)); @@ -1113,6 +1118,121 @@ fn parse_alter_dynamic_table_property( }) } +/// Parse snowflake alter materialized view. +/// +/// +/// Every clause except `RENAME TO` is an accept-and-no-op downstream; only the +/// object identity and, for `RENAME TO`, the new name carry meaning. The +/// operation is tagged [`AlterTableType::MaterializedView`] so the emulator can +/// route it to the view machinery. +fn parse_alter_materialized_view(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(true)?; + + let operation = if parser.parse_keyword(Keyword::RENAME) { + parser.expect_keyword_is(Keyword::TO)?; + let new_name = parser.parse_object_name(false)?; + AlterTableOperation::RenameTable { + table_name: RenameTableNameKind::To(new_name), + } + } else if parser.parse_keywords(&[Keyword::CLUSTER, Keyword::BY]) { + parser.expect_token(&Token::LParen)?; + let exprs = parser.parse_comma_separated(|p| p.parse_expr())?; + parser.expect_token(&Token::RParen)?; + AlterTableOperation::ClusterBy { exprs } + } else if parser.parse_keywords(&[Keyword::DROP, Keyword::CLUSTERING, Keyword::KEY]) { + AlterTableOperation::DropClusteringKey + } else if parser.parse_keyword(Keyword::SUSPEND) { + let _ = parser.parse_keyword(Keyword::RECLUSTER); + AlterTableOperation::Suspend + } else if parser.parse_keyword(Keyword::RESUME) { + let _ = parser.parse_keyword(Keyword::RECLUSTER); + AlterTableOperation::Resume + } else if parser.parse_keyword(Keyword::SET) { + AlterTableOperation::SetOptionsParens { + options: parse_alter_materialized_view_properties(parser, false)?, + } + } else if parser.parse_keyword(Keyword::UNSET) { + AlterTableOperation::SetOptionsParens { + options: parse_alter_materialized_view_properties(parser, true)?, + } + } else { + return parser.expected_ref( + "RENAME, CLUSTER BY, DROP CLUSTERING KEY, SUSPEND, RESUME, SET, \ + or UNSET after ALTER MATERIALIZED VIEW", + parser.peek_token_ref(), + ); + }; + + let end_token = if parser.peek_token_ref().token == Token::SemiColon { + parser.peek_token_ref().clone() + } else { + parser.get_current_token().clone() + }; + + Ok(Statement::AlterTable(AlterTable { + name, + if_exists, + only: false, + operations: vec![operation], + location: None, + on_cluster: None, + table_type: Some(AlterTableType::MaterializedView), + end_token: AttachedToken(end_token), + })) +} + +/// Parse the comma-separated property list of `ALTER MATERIALIZED VIEW … +/// SET/UNSET { SECURE | COMMENT | CONTACT | DATA_METRIC_SCHEDULE }`. The list is +/// only preserved for round-tripping; the emulator treats every property as a +/// no-op. +fn parse_alter_materialized_view_properties( + parser: &mut Parser, + unset: bool, +) -> Result, ParserError> { + let mut options = vec![parse_alter_materialized_view_property(parser, unset)?]; + while parser.consume_token(&Token::Comma) { + options.push(parse_alter_materialized_view_property(parser, unset)?); + } + Ok(options) +} + +/// Parse one `SET`/`UNSET` property. `SECURE` is a bare flag; `CONTACT` takes a +/// `purpose[= contact]` pair; everything else takes `= ` on SET. +fn parse_alter_materialized_view_property( + parser: &mut Parser, + unset: bool, +) -> Result { + let key_token = parser.next_token(); + let key = match &key_token.token { + Token::Word(w) => w.value.to_uppercase(), + _ => return parser.expected("a materialized view property name", key_token), + }; + + let value = if key == "SECURE" { + Expr::Value(Value::Boolean(!unset).into()) + } else if key == "CONTACT" { + let purpose = parser.parse_identifier()?; + if unset { + Expr::Value(Value::SingleQuotedString(purpose.value).into()) + } else { + parser.expect_token(&Token::Eq)?; + let contact = parser.parse_object_name(false)?; + Expr::Value(Value::SingleQuotedString(format!("{}={contact}", purpose.value)).into()) + } + } else if unset { + Expr::Value(Value::Null.into()) + } else { + parser.expect_token(&Token::Eq)?; + parser.parse_expr()? + }; + + Ok(SqlOption::KeyValue { + key: Ident::new(key), + value, + }) +} + /// Parse snowflake alter external table. /// fn parse_alter_external_table(parser: &mut Parser) -> Result { diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9254bc3c69..9d1fa07241 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1395,7 +1395,8 @@ impl<'a> Parser<'a> { /// Parse `TRUNCATE` statement. pub fn parse_truncate(&mut self) -> Result { - let table = self.parse_keyword(Keyword::TABLE); + let materialized_view = self.parse_keywords(&[Keyword::MATERIALIZED, Keyword::VIEW]); + let table = !materialized_view && self.parse_keyword(Keyword::TABLE); let if_exists = self.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); let table_names = self.parse_comma_separated(|p| { @@ -1437,6 +1438,7 @@ impl<'a> Parser<'a> { table_names, partitions, table, + materialized_view, if_exists, identity, cascade, @@ -14918,6 +14920,14 @@ impl<'a> Parser<'a> { object_name, }); } + if self.parse_keywords(&[Keyword::MATERIALIZED, Keyword::VIEW]) { + let object_name = self.parse_object_name(false)?; + return Ok(Statement::DescribeObject { + describe_alias, + object_type: DescribeObjectType::MaterializedView, + object_name, + }); + } // `DESCRIBE TABLE STREAM ` is not valid Snowflake // syntax (a client such as sqlglot may emit it). Real // Snowflake consumes the whole statement and then reports an diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index d2b1c1e513..4980ac4908 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -17345,6 +17345,7 @@ fn parse_truncate_only() { table_names, partitions: None, table: true, + materialized_view: false, if_exists: false, identity: None, cascade: None, diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 8696959068..d409eabba5 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -5457,6 +5457,7 @@ fn parse_truncate() { table_names, partitions: None, table: false, + materialized_view: false, if_exists: false, identity: None, cascade: None, @@ -5483,6 +5484,7 @@ fn parse_truncate_with_options() { table_names, partitions: None, table: true, + materialized_view: false, if_exists: false, identity: Some(TruncateIdentityOption::Restart), cascade: Some(CascadeOption::Cascade), @@ -5519,6 +5521,7 @@ fn parse_truncate_with_table_list() { table_names, partitions: None, table: true, + materialized_view: false, if_exists: false, identity: Some(TruncateIdentityOption::Restart), cascade: Some(CascadeOption::Cascade), @@ -5543,6 +5546,7 @@ fn parse_truncate_with_descendant() { table_names, partitions: None, table: true, + materialized_view: false, if_exists: false, identity: None, cascade: None, @@ -5577,6 +5581,7 @@ fn parse_truncate_with_descendant() { table_names, partitions: None, table: true, + materialized_view: false, if_exists: false, identity: Some(TruncateIdentityOption::Restart), cascade: None, From 2c5d82f3016090cbd9eed2b204b2053776762576 Mon Sep 17 00:00:00 2001 From: Wojciech Padlo Date: Wed, 22 Jul 2026 11:22:34 +0200 Subject: [PATCH 48/59] Snowflake: parse CREATE/ALTER/DROP/DESCRIBE/SHOW STORAGE INTEGRATION Co-Authored-By: Claude Opus 4.8 --- src/ast/mod.rs | 97 ++++++++++++++++++++++++++++++++++++++++ src/ast/spans.rs | 5 +++ src/dialect/snowflake.rs | 78 ++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index e214a1a776..e2263f0c5d 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -5357,6 +5357,54 @@ pub enum Statement { filter: Option, }, /// ```sql + /// CREATE [OR REPLACE] STORAGE INTEGRATION [IF NOT EXISTS] ... + /// ``` + /// See + CreateStorageIntegration { + /// `OR REPLACE` flag. + or_replace: bool, + /// `IF NOT EXISTS` flag. + if_not_exists: bool, + /// Storage integration name. + name: ObjectName, + /// Configuration parameters (`TYPE`, `ENABLED`, `STORAGE_PROVIDER`, …). + params: KeyValueOptions, + }, + /// ```sql + /// ALTER STORAGE INTEGRATION [IF EXISTS] SET ... + /// ``` + AlterStorageIntegration { + /// Storage integration name. + name: ObjectName, + /// `IF EXISTS` flag. + if_exists: bool, + /// The `SET` options. + set_options: KeyValueOptions, + }, + /// ```sql + /// DROP STORAGE INTEGRATION [IF EXISTS] + /// ``` + DropStorageIntegration { + /// Storage integration name. + name: ObjectName, + /// `IF EXISTS` flag. + if_exists: bool, + }, + /// ```sql + /// DESC[RIBE] STORAGE INTEGRATION + /// ``` + DescribeStorageIntegration { + /// Storage integration name. + name: ObjectName, + }, + /// ```sql + /// SHOW STORAGE INTEGRATIONS [LIKE ''] + /// ``` + ShowStorageIntegrations { + /// Optional filter (e.g. `LIKE`). + filter: Option, + }, + /// ```sql /// ASSERT [AS ] /// ``` Assert { @@ -7736,6 +7784,55 @@ impl fmt::Display for Statement { } Ok(()) } + Statement::CreateStorageIntegration { + or_replace, + if_not_exists, + name, + params, + } => { + write!( + f, + "CREATE {or_replace}STORAGE INTEGRATION {if_not_exists}{name}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, + )?; + if !params.options.is_empty() { + write!(f, " {params}")?; + } + Ok(()) + } + Statement::AlterStorageIntegration { + name, + if_exists, + set_options, + } => { + write!( + f, + "ALTER STORAGE INTEGRATION {if_exists}{name} SET", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + )?; + if !set_options.options.is_empty() { + write!(f, " {set_options}")?; + } + Ok(()) + } + Statement::DropStorageIntegration { name, if_exists } => { + write!( + f, + "DROP STORAGE INTEGRATION {if_exists}{name}", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + ) + } + Statement::DescribeStorageIntegration { name } => { + write!(f, "DESCRIBE STORAGE INTEGRATION {name}") + } + Statement::ShowStorageIntegrations { filter } => { + write!(f, "SHOW STORAGE INTEGRATIONS")?; + if let Some(ref filter) = filter { + write!(f, " {filter}")?; + } + Ok(()) + } Statement::CopyIntoSnowflake { kind, into, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 6a5ec4b8a6..3c9fe7211c 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -575,6 +575,11 @@ impl Spanned for Statement { Statement::CreateCatalogIntegration { .. } => Span::empty(), Statement::DropCatalogIntegration { .. } => Span::empty(), Statement::ShowCatalogIntegrations { .. } => Span::empty(), + Statement::CreateStorageIntegration { .. } => Span::empty(), + Statement::AlterStorageIntegration { .. } => Span::empty(), + Statement::DropStorageIntegration { .. } => Span::empty(), + Statement::DescribeStorageIntegration { .. } => Span::empty(), + Statement::ShowStorageIntegrations { .. } => Span::empty(), Statement::Assignment { .. } => Span::empty(), Statement::Let { .. } => Span::empty(), Statement::Null => Span::empty(), diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index fa4e79d91c..4ca10d8ae2 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -310,6 +310,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_alter_external_table(parser)); } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::STORAGE, Keyword::INTEGRATION]) { + // ALTER STORAGE INTEGRATION + return Some(parse_alter_storage_integration(parser)); + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::FILE, Keyword::FORMAT]) { // ALTER FILE FORMAT return Some(parse_alter_file_format(parser)); @@ -371,6 +376,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_drop_catalog_integration(parser)); } + if parser.parse_keywords(&[Keyword::DROP, Keyword::STORAGE, Keyword::INTEGRATION]) { + // DROP STORAGE INTEGRATION + return Some(parse_drop_storage_integration(parser)); + } + if parser.parse_keywords(&[Keyword::DROP, Keyword::FILE, Keyword::FORMAT]) { // DROP FILE FORMAT return Some(parse_drop_file_format(parser)); @@ -399,6 +409,10 @@ impl Dialect for SnowflakeDialect { // DESC[RIBE] EXTERNAL VOLUME return Some(parse_describe_external_volume(parser)); } + if parser.parse_keywords(&[Keyword::STORAGE, Keyword::INTEGRATION]) { + // DESC[RIBE] STORAGE INTEGRATION + return Some(parse_describe_storage_integration(parser)); + } if parser.parse_keywords(&[Keyword::FILE, Keyword::FORMAT]) { // DESC[RIBE] FILE FORMAT return Some(parse_describe_file_format(parser)); @@ -430,6 +444,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_create_catalog_integration(or_replace, parser)); } + // CREATE [OR REPLACE] STORAGE INTEGRATION + if parser.parse_keywords(&[Keyword::STORAGE, Keyword::INTEGRATION]) { + return Some(parse_create_storage_integration(or_replace, parser)); + } + // CREATE [OR REPLACE] ROW ACCESS POLICY if parser.parse_keywords(&[Keyword::ROW, Keyword::ACCESS, Keyword::POLICY]) { return Some(parse_create_row_access_policy(or_replace, parser)); @@ -554,6 +573,9 @@ impl Dialect for SnowflakeDialect { if parser.parse_keywords(&[Keyword::CATALOG, Keyword::INTEGRATIONS]) { return Some(parse_show_catalog_integrations(parser)); } + if parser.parse_keywords(&[Keyword::STORAGE, Keyword::INTEGRATIONS]) { + return Some(parse_show_storage_integrations(parser)); + } if parser.parse_keyword(Keyword::WAREHOUSES) { return Some(parse_show_warehouses(parser)); } @@ -3505,3 +3527,59 @@ fn parse_show_catalog_integrations(parser: &mut Parser) -> Result `. +/// +/// Params (`TYPE`, `ENABLED`, `STORAGE_PROVIDER`, `STORAGE_AWS_ROLE_ARN`, +/// `STORAGE_ALLOWED_LOCATIONS`, `STORAGE_BLOCKED_LOCATIONS`, `COMMENT`, plus +/// GCS/Azure provider variants) are captured generically as key-value options, +/// so the parser stays agnostic to the provider-specific property set. +fn parse_create_storage_integration( + or_replace: bool, + parser: &mut Parser, +) -> Result { + let if_not_exists = parser.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + let params = parser.parse_key_value_options(false, &[], false)?; + Ok(Statement::CreateStorageIntegration { + or_replace, + if_not_exists, + name, + params, + }) +} + +/// Parse `ALTER STORAGE INTEGRATION [IF EXISTS] SET `. +/// +/// Only the `SET` form is modeled; the emulator consumes it in a follow-up +/// task. The `SET` options are captured generically as key-value options. +fn parse_alter_storage_integration(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + parser.expect_keyword(Keyword::SET)?; + let set_options = parser.parse_key_value_options(false, &[], false)?; + Ok(Statement::AlterStorageIntegration { + name, + if_exists, + set_options, + }) +} + +/// Parse `DROP STORAGE INTEGRATION [IF EXISTS] `. +fn parse_drop_storage_integration(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + Ok(Statement::DropStorageIntegration { name, if_exists }) +} + +/// Parse `DESC[RIBE] STORAGE INTEGRATION `. +fn parse_describe_storage_integration(parser: &mut Parser) -> Result { + let name = parser.parse_object_name(false)?; + Ok(Statement::DescribeStorageIntegration { name }) +} + +/// Parse `SHOW STORAGE INTEGRATIONS [LIKE '']`. +fn parse_show_storage_integrations(parser: &mut Parser) -> Result { + let filter = parser.parse_show_statement_filter()?; + Ok(Statement::ShowStorageIntegrations { filter }) +} From 2b9fdc445abb8f655ca4804846edbfca3657ca66 Mon Sep 17 00:00:00 2001 From: sabir-akhadov-localstack Date: Wed, 22 Jul 2026 16:11:39 +0200 Subject: [PATCH 49/59] Snowflake: parse FETCH INTO, CALL INTO, ALTER PROCEDURE, WITH AS PROCEDURE --- src/ast/ddl.rs | 74 +++++++++++++++++ src/ast/mod.rs | 80 +++++++++++++++++- src/ast/spans.rs | 4 + src/dialect/mod.rs | 6 ++ src/dialect/snowflake.rs | 155 ++++++++++++++++++++++++++++++++++- src/parser/mod.rs | 98 +++++++++++++++------- tests/sqlparser_snowflake.rs | 128 +++++++++++++++++++++++++++++ 7 files changed, 512 insertions(+), 33 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index cb973494ca..27ff5b38c8 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -5723,6 +5723,80 @@ impl Spanned for AlterFunction { } } +/// Snowflake `ALTER PROCEDURE [IF EXISTS] ( [ [, ...]] ) `. +/// +/// Kept distinct from [`AlterFunction`] because the procedure grammar carries +/// the `EXECUTE AS { CALLER | OWNER }` rights operation, which has no function +/// analog. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct AlterProcedure { + /// `IF EXISTS` flag. + pub if_exists: bool, + /// Procedure name. + pub name: ObjectName, + /// Argument-type signature (`(NUMBER, VARCHAR)`), used to disambiguate + /// overloads. Empty when the parentheses hold no arguments. + pub args: Vec, + /// Operation applied to the procedure. + pub operation: AlterProcedureOperation, +} + +/// Operation for [`AlterProcedure`]. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AlterProcedureOperation { + /// `RENAME TO ` + RenameTo { + /// New procedure name. + new_name: ObjectName, + }, + /// `SET COMMENT = ` + SetComment { + /// The comment value expression. + comment: Expr, + }, + /// `UNSET COMMENT` + UnsetComment, + /// `EXECUTE AS { CALLER | OWNER }` + ExecuteAs(ProcedureExecuteAs), +} + +impl fmt::Display for AlterProcedure { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "ALTER PROCEDURE ")?; + if self.if_exists { + write!(f, "IF EXISTS ")?; + } + write!( + f, + "{}({}) {}", + self.name, + display_comma_separated(&self.args), + self.operation + ) + } +} + +impl fmt::Display for AlterProcedureOperation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AlterProcedureOperation::RenameTo { new_name } => write!(f, "RENAME TO {new_name}"), + AlterProcedureOperation::SetComment { comment } => write!(f, "SET COMMENT = {comment}"), + AlterProcedureOperation::UnsetComment => write!(f, "UNSET COMMENT"), + AlterProcedureOperation::ExecuteAs(execute_as) => write!(f, "EXECUTE AS {execute_as}"), + } + } +} + +impl Spanned for AlterProcedure { + fn span(&self) -> Span { + Span::empty() + } +} + /// CREATE POLICY statement. /// /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createpolicy.html) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index e2263f0c5d..d2a69346e1 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -64,7 +64,8 @@ pub use self::dcl::{ pub use self::ddl::{ Alignment, AlterCollation, AlterCollationOperation, AlterColumnOperation, AlterConnectorOwner, AlterFunction, AlterFunctionAction, AlterFunctionKind, AlterFunctionOperation, - AlterIndexOperation, AlterOperator, AlterOperatorClass, AlterOperatorClassOperation, + AlterIndexOperation, AlterProcedure, AlterProcedureOperation, + AlterOperator, AlterOperatorClass, AlterOperatorClassOperation, AlterOperatorFamily, AlterOperatorFamilyOperation, AlterOperatorOperation, AlterPolicy, AlterPolicyOperation, AlterSchema, AlterSchemaOperation, AlterTable, AlterTableAlgorithm, AlterTableLock, AlterTableOperation, AlterTableType, AlterType, AlterTypeAddValue, @@ -4363,6 +4364,48 @@ pub enum Statement { /// Optional target table to fetch rows into. into: Option, }, + /// Snowflake scripting `FETCH INTO [, ...]`. + /// + /// Unlike the ISO/PostgreSQL [`Statement::Fetch`], the scripting form has + /// no direction and no `FROM`/`IN`; it binds the current cursor row into + /// one or more local variables. + FetchInto { + /// Cursor name. + cursor: Ident, + /// One or more variable targets. + into: Vec, + }, + /// Snowflake `CALL () INTO [, ...]`. + /// + /// Like [`Statement::Call`] but captures the procedure result into one or + /// more local variables. + CallInto { + /// The procedure call. + function: Function, + /// One or more variable targets. + into: Vec, + }, + /// Snowflake `ALTER PROCEDURE`. + AlterProcedure(AlterProcedure), + /// Snowflake anonymous procedure: + /// `WITH AS PROCEDURE () RETURNS LANGUAGE + /// [EXECUTE AS ...] AS CALL ()`. + WithProcedure { + /// Procedure name introduced by the `WITH` clause. + name: Ident, + /// Optional procedure parameters. + params: Option>, + /// Optional return type. + returns: Option, + /// Optional language identifier. + language: Option, + /// Optional `EXECUTE AS { CALLER | OWNER }` rights clause. + execute_as: Option, + /// Procedure body statements. + body: ConditionalStatements, + /// The trailing `CALL ()` statement. + call: Box, + }, /// ```sql /// FLUSH [NO_WRITE_TO_BINLOG | LOCAL] flush_option [, flush_option] ... | tables_option /// ``` @@ -6153,6 +6196,41 @@ impl fmt::Display for Statement { Ok(()) } + Statement::FetchInto { cursor, into } => { + write!(f, "FETCH {cursor} INTO {}", display_comma_separated(into)) + } + Statement::CallInto { function, into } => { + write!( + f, + "CALL {function} INTO {}", + display_comma_separated(into) + ) + } + Statement::AlterProcedure(alter_procedure) => write!(f, "{alter_procedure}"), + Statement::WithProcedure { + name, + params, + returns, + language, + execute_as, + body, + call, + } => { + write!(f, "WITH {name} AS PROCEDURE")?; + if let Some(p) = params { + write!(f, " ({})", display_comma_separated(p))?; + } + if let Some(ret) = returns { + write!(f, " RETURNS {ret}")?; + } + if let Some(language) = language { + write!(f, " LANGUAGE {language}")?; + } + if let Some(execute_as) = execute_as { + write!(f, " EXECUTE AS {execute_as}")?; + } + write!(f, " AS {body} {call}") + } Statement::Directory { overwrite, local, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 3c9fe7211c..f418d6aa96 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -584,6 +584,10 @@ impl Spanned for Statement { Statement::Let { .. } => Span::empty(), Statement::Null => Span::empty(), Statement::PutGetFiles { .. } => Span::empty(), + Statement::FetchInto { .. } => Span::empty(), + Statement::CallInto { .. } => Span::empty(), + Statement::AlterProcedure { .. } => Span::empty(), + Statement::WithProcedure { .. } => Span::empty(), } } } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 390ec361cf..bd3ade0d30 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1122,6 +1122,12 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if this dialect accepts an `INTO [, ...]` tail after + /// a procedure call, e.g. `CALL p(1) INTO :ret` in Snowflake scripting. + fn supports_call_into(&self) -> bool { + false + } + /// Returns true if this dialect supports `$` as a prefix for money literals /// e.g. `SELECT $123.45` (SQL Server) fn supports_dollar_as_money_prefix(&self) -> bool { diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 4ca10d8ae2..c10dbc8632 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -27,11 +27,13 @@ use crate::ast::helpers::stmt_data_loading::{ FileStagingCommand, StageLoadSelectItem, StageLoadSelectItemKind, StageParamsObject, }; use crate::ast::{ - AlterExternalVolumeOperation, AlterFileFormatOperation, AlterStageOperation, AlterTable, + AlterExternalVolumeOperation, AlterFileFormatOperation, AlterProcedure, + AlterProcedureOperation, AlterStageOperation, AlterTable, AlterTableOperation, AlterTableType, CatalogRestAuthentication, CatalogRestConfig, CatalogSource, CatalogSyncNamespaceMode, CatalogTableFormat, ColumnOption, ColumnPolicy, ColumnPolicyProperty, ContactEntry, CopyIntoSnowflakeKind, CreateTable, CreateTableLikeKind, - DollarQuotedString, Expr, ExternalVolumeEncryption, ExternalVolumeStorageLocation, Ident, + DollarQuotedString, Expr, ExternalVolumeEncryption, ExternalVolumeStorageLocation, + Ident, ProcedureExecuteAs, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, InitializeKind, Insert, MultiTableInsertIntoClause, MultiTableInsertType, MultiTableInsertValue, MultiTableInsertValues, @@ -201,6 +203,12 @@ impl Dialect for SnowflakeDialect { true } + /// Snowflake scripting accepts `CALL p(...) INTO :var` to capture a + /// procedure result into local variables. + fn supports_call_into(&self) -> bool { + true + } + /// See fn supports_for_loop_over_cursor(&self) -> bool { true @@ -290,6 +298,24 @@ impl Dialect for SnowflakeDialect { return Some(parser.parse_begin_exception_end()); } + // Snowflake scripting `FETCH INTO [, ...]` has no + // direction and no FROM/IN, so it can't go through the ISO parser. + // Intercept only that shape; anything else falls through untouched. + if parser.peek_keyword(Keyword::FETCH) { + if let Ok(Some(stmt)) = parser.maybe_parse(parse_fetch_into) { + return Some(Ok(stmt)); + } + } + + // Snowflake anonymous procedure: `WITH AS PROCEDURE ...`. Every + // other `WITH` (ordinary CTE) fails the `AS PROCEDURE` probe and falls + // through to the standard query parser. + if parser.peek_keyword(Keyword::WITH) { + if let Ok(Some(stmt)) = parser.maybe_parse(parse_with_procedure) { + return Some(Ok(stmt)); + } + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::DYNAMIC, Keyword::TABLE]) { // ALTER DYNAMIC TABLE return Some(parse_alter_dynamic_table(parser)); @@ -315,6 +341,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_alter_storage_integration(parser)); } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::PROCEDURE]) { + // ALTER PROCEDURE + return Some(parse_alter_procedure(parser)); + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::FILE, Keyword::FORMAT]) { // ALTER FILE FORMAT return Some(parse_alter_file_format(parser)); @@ -1140,6 +1171,126 @@ fn parse_alter_dynamic_table_property( }) } +/// Parse Snowflake scripting `FETCH INTO [, ...]`. +/// +/// The caller has verified the next keyword is `FETCH` and runs this via +/// `maybe_parse`, so a non-scripting `FETCH` simply errors out and rewinds. +fn parse_fetch_into(parser: &mut Parser) -> Result { + parser.expect_keyword(Keyword::FETCH)?; + let cursor = parser.parse_identifier()?; + parser.expect_keyword(Keyword::INTO)?; + let into = parser.parse_scripting_into_targets()?; + Ok(Statement::FetchInto { cursor, into }) +} + +/// Parse `ALTER PROCEDURE [IF EXISTS] ( [ [, ...]] ) +/// { RENAME TO ... | SET COMMENT = ... | UNSET COMMENT | EXECUTE AS CALLER|OWNER }`. +/// +/// The `ALTER PROCEDURE` keywords are already consumed by the caller. +fn parse_alter_procedure(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + + parser.expect_token(&Token::LParen)?; + let args = if parser.peek_token_ref().token == Token::RParen { + vec![] + } else { + parser.parse_comma_separated(Parser::parse_data_type)? + }; + parser.expect_token(&Token::RParen)?; + + let operation = if parser.parse_keywords(&[Keyword::RENAME, Keyword::TO]) { + AlterProcedureOperation::RenameTo { + new_name: parser.parse_object_name(false)?, + } + } else if parser.parse_keywords(&[Keyword::EXECUTE, Keyword::AS]) { + let execute_as = if parser.parse_keyword(Keyword::CALLER) { + ProcedureExecuteAs::Caller + } else { + parser.expect_keyword_is(Keyword::OWNER)?; + ProcedureExecuteAs::Owner + }; + AlterProcedureOperation::ExecuteAs(execute_as) + } else if parser.parse_keyword(Keyword::SET) { + parser.expect_keyword_is(Keyword::COMMENT)?; + parser.expect_token(&Token::Eq)?; + AlterProcedureOperation::SetComment { + comment: parser.parse_expr()?, + } + } else if parser.parse_keyword(Keyword::UNSET) { + parser.expect_keyword_is(Keyword::COMMENT)?; + AlterProcedureOperation::UnsetComment + } else { + return parser.expected_ref( + "RENAME TO, SET COMMENT, UNSET COMMENT, or EXECUTE AS after ALTER PROCEDURE", + parser.peek_token_ref(), + ); + }; + + Ok(Statement::AlterProcedure(AlterProcedure { + if_exists, + name, + args, + operation, + })) +} + +/// Parse Snowflake anonymous procedure: +/// `WITH AS PROCEDURE () RETURNS LANGUAGE +/// [EXECUTE AS ...] AS CALL ()`. +/// +/// The caller runs this via `maybe_parse`, so an ordinary CTE fails the +/// `AS PROCEDURE` probe and rewinds. +fn parse_with_procedure(parser: &mut Parser) -> Result { + parser.expect_keyword(Keyword::WITH)?; + let name = parser.parse_identifier()?; + parser.expect_keyword_is(Keyword::AS)?; + parser.expect_keyword_is(Keyword::PROCEDURE)?; + + let params = parser.parse_optional_procedure_parameters()?; + + let returns = if parser.parse_keyword(Keyword::RETURNS) { + Some(parser.parse_data_type()?) + } else { + None + }; + // Snowflake allows a `NOT NULL` return-type annotation; drop it. + let _ = parser.parse_keywords(&[Keyword::NOT, Keyword::NULL]); + + let language = if parser.parse_keyword(Keyword::LANGUAGE) { + Some(parser.parse_identifier()?) + } else { + None + }; + + let execute_as = if parser.parse_keywords(&[Keyword::EXECUTE, Keyword::AS]) { + if parser.parse_keyword(Keyword::CALLER) { + Some(ProcedureExecuteAs::Caller) + } else { + parser.expect_keyword_is(Keyword::OWNER)?; + Some(ProcedureExecuteAs::Owner) + } + } else { + None + }; + + parser.expect_keyword_is(Keyword::AS)?; + let body = parser.parse_procedure_body()?; + + parser.expect_keyword(Keyword::CALL)?; + let call = Box::new(parser.parse_call()?); + + Ok(Statement::WithProcedure { + name, + params, + returns, + language, + execute_as, + body, + call, + }) +} + /// Parse snowflake alter materialized view. /// /// diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9d1fa07241..4442dbe9a3 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -12378,16 +12378,18 @@ impl<'a> Parser<'a> { /// or `CALL procedure_name` statement pub fn parse_call(&mut self) -> Result { let object_name = self.parse_object_name(false)?; - if self.peek_token_ref().token == Token::LParen { + let function = if self.peek_token_ref().token == Token::LParen { match self.parse_function(object_name)? { - Expr::Function(f) => Ok(Statement::Call(f)), - other => parser_err!( - format!("Expected a simple procedure call but found: {other}"), - self.peek_token_ref().span.start - ), + Expr::Function(f) => f, + other => { + return parser_err!( + format!("Expected a simple procedure call but found: {other}"), + self.peek_token_ref().span.start + ) + } } } else { - Ok(Statement::Call(Function { + Function { name: object_name, uses_odbc_syntax: false, parameters: FunctionArguments::None, @@ -12396,7 +12398,38 @@ impl<'a> Parser<'a> { filter: None, null_treatment: None, within_group: vec![], - })) + } + }; + + // Snowflake scripting: `CALL p(...) INTO :var [, ...]` captures the + // procedure result into local variables. + if self.dialect.supports_call_into() && self.parse_keyword(Keyword::INTO) { + let into = self.parse_scripting_into_targets()?; + return Ok(Statement::CallInto { function, into }); + } + + Ok(Statement::Call(function)) + } + + /// Parse a comma-separated list of Snowflake scripting `INTO` targets. + /// + /// Each target is either a `:placeholder` bind variable — kept verbatim as + /// its `:name` text so the round-trip is stable — or a bare local-variable + /// name. + pub(crate) fn parse_scripting_into_targets( + &mut self, + ) -> Result, ParserError> { + self.parse_comma_separated(Parser::parse_scripting_into_target) + } + + fn parse_scripting_into_target(&mut self) -> Result { + if self.peek_token_ref().token == Token::Colon { + let placeholder = self.parse_value()?; + Ok(ObjectName(vec![ObjectNamePart::Identifier(Ident::new( + placeholder.to_string(), + ))])) + } else { + self.parse_object_name(false) } } @@ -20981,28 +21014,7 @@ impl<'a> Parser<'a> { self.expect_keyword_is(Keyword::AS)?; - // Snowflake encloses the procedure body in a dollar-quoted string - // (e.g. `AS $$ BEGIN … END $$`) or, equivalently, a single-quoted - // string with interior quotes doubled (`AS ' … '`). In both cases - // re-parse the string content as a conditional-statements block so - // the body is always typed as `ConditionalStatements` regardless of - // how it was written. The tokenizer already unescapes doubled `''` - // quotes, so the string value is the plain scripting text. - let body = match self.peek_token().token.clone() { - Token::DollarQuotedString(dqs) => { - self.next_token(); // consume the dollar-quoted string token - Parser::new(self.dialect) - .try_with_sql(&dqs.value)? - .parse_conditional_statements(&[Keyword::END])? - } - Token::SingleQuotedString(body) => { - self.next_token(); // consume the single-quoted string token - Parser::new(self.dialect) - .try_with_sql(&body)? - .parse_conditional_statements(&[Keyword::END])? - } - _ => self.parse_conditional_statements(&[Keyword::END])?, - }; + let body = self.parse_procedure_body()?; Ok(Statement::CreateProcedure { name, @@ -21016,6 +21028,32 @@ impl<'a> Parser<'a> { }) } + /// Parse a stored-procedure body following the `AS` keyword. + /// + /// Snowflake encloses the body in a dollar-quoted string + /// (e.g. `AS $$ BEGIN … END $$`) or, equivalently, a single-quoted string + /// with interior quotes doubled (`AS ' … '`); it may also be written bare + /// (`AS BEGIN … END`). In every case the body is typed as + /// `ConditionalStatements`. The tokenizer already unescapes doubled `''` + /// quotes, so the string value is the plain scripting text. + pub(crate) fn parse_procedure_body(&mut self) -> Result { + match self.peek_token().token.clone() { + Token::DollarQuotedString(dqs) => { + self.next_token(); // consume the dollar-quoted string token + Parser::new(self.dialect) + .try_with_sql(&dqs.value)? + .parse_conditional_statements(&[Keyword::END]) + } + Token::SingleQuotedString(body) => { + self.next_token(); // consume the single-quoted string token + Parser::new(self.dialect) + .try_with_sql(&body)? + .parse_conditional_statements(&[Keyword::END]) + } + _ => self.parse_conditional_statements(&[Keyword::END]), + } + } + /// Parse a window specification. pub fn parse_window_spec(&mut self) -> Result { let window_name = match &self.peek_token_ref().token { diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 861da52e67..861af46398 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -8651,3 +8651,131 @@ fn test_select_into_placeholder_target() { }; assert_eq!(select.into.unwrap().name.to_string(), "res"); } + +#[test] +fn test_fetch_cursor_into_variables() { + // Snowflake scripting FETCH has no direction and no FROM/IN; it binds the + // current cursor row into one or more local variables. + let stmt = snowflake().verified_stmt("FETCH c INTO x, y"); + match stmt { + Statement::FetchInto { cursor, into } => { + assert_eq!(cursor.value, "c"); + let names: Vec = into.iter().map(ToString::to_string).collect(); + assert_eq!(names, vec!["x".to_string(), "y".to_string()]); + } + other => panic!("expected FetchInto, got {other:?}"), + } + + // Single variable and colon-placeholder targets both round-trip. + let stmt = snowflake().verified_stmt("FETCH c INTO :res"); + match stmt { + Statement::FetchInto { cursor, into } => { + assert_eq!(cursor.value, "c"); + assert_eq!(into.len(), 1); + assert_eq!(into[0].to_string(), ":res"); + } + other => panic!("expected FetchInto, got {other:?}"), + } +} + +#[test] +fn test_call_into_variable() { + let stmt = snowflake().verified_stmt("CALL myproc(1, 'a') INTO :ret"); + match stmt { + Statement::CallInto { function, into } => { + assert_eq!(function.name.to_string(), "myproc"); + assert_eq!(into.len(), 1); + assert_eq!(into[0].to_string(), ":ret"); + } + other => panic!("expected CallInto, got {other:?}"), + } + + // Multiple targets. + let stmt = snowflake().verified_stmt("CALL p() INTO a, b"); + match stmt { + Statement::CallInto { into, .. } => { + let names: Vec = into.iter().map(ToString::to_string).collect(); + assert_eq!(names, vec!["a".to_string(), "b".to_string()]); + } + other => panic!("expected CallInto, got {other:?}"), + } + + // A plain CALL is unaffected. + let stmt = snowflake().verified_stmt("CALL myproc(1)"); + assert!(matches!(stmt, Statement::Call(_))); +} + +#[test] +fn test_alter_procedure() { + let stmt = snowflake().verified_stmt("ALTER PROCEDURE myproc(NUMBER, VARCHAR) RENAME TO newproc"); + match stmt { + Statement::AlterProcedure(AlterProcedure { + if_exists, + name, + args, + operation, + }) => { + assert!(!if_exists); + assert_eq!(name.to_string(), "myproc"); + assert_eq!(args.len(), 2); + assert_eq!( + operation, + AlterProcedureOperation::RenameTo { + new_name: ObjectName::from(vec![Ident::new("newproc")]) + } + ); + } + other => panic!("expected AlterProcedure, got {other:?}"), + } + + // IF EXISTS + EXECUTE AS CALLER. + let stmt = snowflake().verified_stmt("ALTER PROCEDURE IF EXISTS p() EXECUTE AS CALLER"); + match stmt { + Statement::AlterProcedure(AlterProcedure { + if_exists, + args, + operation, + .. + }) => { + assert!(if_exists); + assert!(args.is_empty()); + assert_eq!( + operation, + AlterProcedureOperation::ExecuteAs(ProcedureExecuteAs::Caller) + ); + } + other => panic!("expected AlterProcedure, got {other:?}"), + } + + // SET / UNSET COMMENT. + snowflake().verified_stmt("ALTER PROCEDURE p(NUMBER) SET COMMENT = 'hi'"); + snowflake().verified_stmt("ALTER PROCEDURE p(NUMBER) UNSET COMMENT"); + snowflake().verified_stmt("ALTER PROCEDURE p(NUMBER) EXECUTE AS OWNER"); +} + +#[test] +fn test_with_as_procedure() { + let sql = "WITH myproc AS PROCEDURE (arg1 INT) RETURNS INT LANGUAGE SQL AS BEGIN RETURN arg1; END CALL myproc(10)"; + let stmt = snowflake().verified_stmt(sql); + match stmt { + Statement::WithProcedure { + name, + params, + returns, + language, + call, + .. + } => { + assert_eq!(name.value, "myproc"); + assert_eq!(params.as_ref().map(Vec::len), Some(1)); + assert!(returns.is_some()); + assert_eq!(language.map(|l| l.value), Some("SQL".to_string())); + assert!(matches!(*call, Statement::Call(_))); + } + other => panic!("expected WithProcedure, got {other:?}"), + } + + // An ordinary CTE query is unaffected. + let stmt = snowflake().verified_stmt("WITH t AS (SELECT 1) SELECT * FROM t"); + assert!(matches!(stmt, Statement::Query(_))); +} From f611954ff6ffdb318f2035894deca96eee58486b Mon Sep 17 00:00:00 2001 From: Przemyslaw Denkiewicz Date: Wed, 22 Jul 2026 20:46:51 +0200 Subject: [PATCH 50/59] Snowflake: parse LIKE/ILIKE ANY/ALL multi-pattern lists Add an additive Expr::LikeAnyAll variant modelling [NOT] {LIKE|ILIKE} {ANY|ALL} (p1, ..., pN) [ESCAPE e], leaving the single-pattern Expr::Like / Expr::ILike shape untouched. The LIKE/ILIKE infix parser now routes a parenthesized {ANY|ALL} (...) list into the new variant. --- src/ast/mod.rs | 39 +++++++++++++++++++++++++++ src/ast/spans.rs | 10 +++++++ src/parser/mod.rs | 68 ++++++++++++++++++++++++++++++++++------------- 3 files changed, 99 insertions(+), 18 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index d2a69346e1..77305bc7df 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -1031,6 +1031,23 @@ pub enum Expr { /// Optional escape character. escape_char: Option, }, + /// Snowflake ` [NOT] {LIKE|ILIKE} {ANY|ALL} (, ..., ) [ESCAPE ]` + /// matching a subject against a parenthesized list of patterns. + /// + LikeAnyAll { + /// `true` when `NOT` is present. + negated: bool, + /// `true` for `ILIKE`, `false` for `LIKE`. + ilike: bool, + /// `true` for the `ALL` quantifier, `false` for `ANY`. + all: bool, + /// Subject expression to match. + expr: Box, + /// List of pattern expressions. + patterns: Vec, + /// Optional escape character applied to every pattern. + escape_char: Option, + }, /// `SIMILAR TO` regex SimilarTo { /// `true` when `NOT` is present. @@ -1853,6 +1870,28 @@ impl fmt::Display for Expr { pattern ), }, + Expr::LikeAnyAll { + negated, + ilike, + all, + expr, + patterns, + escape_char, + } => { + write!( + f, + "{} {}{} {} ({})", + expr, + if *negated { "NOT " } else { "" }, + if *ilike { "ILIKE" } else { "LIKE" }, + if *all { "ALL" } else { "ANY" }, + display_comma_separated(patterns), + )?; + if let Some(ch) = escape_char { + write!(f, " ESCAPE {ch}")?; + } + Ok(()) + } Expr::RLike { negated, expr, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index f418d6aa96..71bdeb1d72 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -1645,6 +1645,16 @@ impl Spanned for Expr { escape_char: _, any: _, } => expr.span().union(&pattern.span()), + Expr::LikeAnyAll { + negated: _, + ilike: _, + all: _, + expr, + patterns, + escape_char: _, + } => union_spans( + core::iter::once(expr.span()).chain(patterns.iter().map(|p| p.span())), + ), Expr::RLike { .. } => Span::empty(), Expr::IsNormalized { expr, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 4442dbe9a3..5d907c4a04 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4365,25 +4365,9 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::BETWEEN) { self.parse_between(expr, negated) } else if self.parse_keyword(Keyword::LIKE) { - Ok(Expr::Like { - negated, - any: self.parse_keyword(Keyword::ANY), - expr: Box::new(expr), - pattern: Box::new( - self.parse_subexpr(self.dialect.prec_value(Precedence::Like))?, - ), - escape_char: self.parse_escape_char()?, - }) + self.parse_like_expr(negated, false, expr) } else if self.parse_keyword(Keyword::ILIKE) { - Ok(Expr::ILike { - negated, - any: self.parse_keyword(Keyword::ANY), - expr: Box::new(expr), - pattern: Box::new( - self.parse_subexpr(self.dialect.prec_value(Precedence::Like))?, - ), - escape_char: self.parse_escape_char()?, - }) + self.parse_like_expr(negated, true, expr) } else if self.parse_keywords(&[Keyword::SIMILAR, Keyword::TO]) { Ok(Expr::SimilarTo { negated, @@ -4446,6 +4430,54 @@ impl<'a> Parser<'a> { } } + /// Parse the tail of a `LIKE` / `ILIKE` predicate after the keyword has + /// been consumed. Handles Snowflake's `{ANY|ALL} (, ..., )` + /// multi-pattern list form as well as the ordinary single-pattern form. + fn parse_like_expr( + &mut self, + negated: bool, + ilike: bool, + expr: Expr, + ) -> Result { + let quantifier = self.parse_one_of_keywords(&[Keyword::ANY, Keyword::ALL]); + if let Some(kw) = quantifier { + if self.consume_token(&Token::LParen) { + let patterns = self.parse_comma_separated0(Parser::parse_expr, Token::RParen)?; + self.expect_token(&Token::RParen)?; + return Ok(Expr::LikeAnyAll { + negated, + ilike, + all: kw == Keyword::ALL, + expr: Box::new(expr), + patterns, + escape_char: self.parse_escape_char()?, + }); + } + // `ANY` without a parenthesized list falls back to the legacy + // single-pattern form (`ALL` has no single-pattern meaning here). + } + let any = quantifier == Some(Keyword::ANY); + let pattern = Box::new(self.parse_subexpr(self.dialect.prec_value(Precedence::Like))?); + let escape_char = self.parse_escape_char()?; + if ilike { + Ok(Expr::ILike { + negated, + any, + expr: Box::new(expr), + pattern, + escape_char, + }) + } else { + Ok(Expr::Like { + negated, + any, + expr: Box::new(expr), + pattern, + escape_char, + }) + } + } + /// Parse the `ESCAPE CHAR` portion of `LIKE`, `ILIKE`, and `SIMILAR TO` pub fn parse_escape_char(&mut self) -> Result, ParserError> { if self.parse_keyword(Keyword::ESCAPE) { From 592894c28fd6d0b31e9488bab8d38046be445cf6 Mon Sep 17 00:00:00 2001 From: Wojciech Padlo Date: Thu, 23 Jul 2026 13:45:19 +0200 Subject: [PATCH 51/59] Snowflake: parse inline stage args in COPY INTO transformation subquery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept `@stage (FILE_FORMAT => …, PATTERN => …)` table-function args on the FROM stage of a `COPY INTO
FROM (SELECT … FROM @stage (…))` load, and carry them on a new `CopyIntoSnowflake::from_obj_args` field. --- src/ast/mod.rs | 11 +++++++++++ src/ast/spans.rs | 1 + src/dialect/snowflake.rs | 7 +++++++ src/parser/mod.rs | 5 ++++- 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 77305bc7df..67d61d4d5d 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -3977,6 +3977,10 @@ pub enum Statement { from_obj: Option, /// Optional alias for the source object. from_obj_alias: Option, + /// Optional inline stage table-function args on the FROM stage + /// (`@stage (FILE_FORMAT => …, PATTERN => …)`), as used in a + /// `COPY INTO
FROM (SELECT … FROM @stage (…))` load. + from_obj_args: Option, /// Stage-specific parameters (e.g., credentials, path). stage_params: StageParamsObject, /// Optional list of transformations applied when loading. @@ -7956,6 +7960,7 @@ impl fmt::Display for Statement { into_columns, from_obj, from_obj_alias, + from_obj_args, stage_params, from_transformations, from_query, @@ -7980,6 +7985,9 @@ impl fmt::Display for Statement { from_stage, stage_params )?; + if let Some(args) = from_obj_args { + write!(f, " ({})", display_comma_separated(&args.args))?; + } } if let Some(from_obj_alias) = from_obj_alias { write!(f, " AS {from_obj_alias}")?; @@ -7988,6 +7996,9 @@ impl fmt::Display for Statement { } else if let Some(from_obj) = from_obj { // Standard data load write!(f, " FROM {from_obj}{stage_params}")?; + if let Some(args) = from_obj_args { + write!(f, " ({})", display_comma_separated(&args.args))?; + } if let Some(from_obj_alias) = from_obj_alias { write!(f, " AS {from_obj_alias}")?; } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 71bdeb1d72..3a19d28eb3 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -361,6 +361,7 @@ impl Spanned for Statement { into_columns: _, from_obj: _, from_obj_alias: _, + from_obj_args: _, stage_params: _, from_transformations: _, files: _, diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index c10dbc8632..9ead98849d 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -2153,6 +2153,7 @@ pub fn parse_copy_into(parser: &mut Parser) -> Result { let mut from_transformations: Option> = None; let mut from_stage_alias = None; let mut from_stage = None; + let mut from_stage_args = None; let mut stage_params = StageParamsObject { url: None, encryption: KeyValueOptions { @@ -2192,6 +2193,11 @@ pub fn parse_copy_into(parser: &mut Parser) -> Result { parser.expect_keyword_is(Keyword::FROM)?; from_stage = Some(parse_snowflake_stage_name(parser)?); + // Inline stage table-function args (querying-stage syntax): + // `@stage (FILE_FORMAT => …, PATTERN => …)`. + if parser.consume_token(&Token::LParen) { + from_stage_args = Some(parser.parse_table_function_args()?); + } stage_params = parse_stage_params(parser)?; // Parse an optional alias @@ -2326,6 +2332,7 @@ pub fn parse_copy_into(parser: &mut Parser) -> Result { into_columns, from_obj: from_stage, from_obj_alias: from_stage_alias, + from_obj_args: from_stage_args, stage_params, from_transformations, from_query, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 5d907c4a04..43aea123c1 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -19525,7 +19525,10 @@ impl<'a> Parser<'a> { } } - fn parse_table_function_args(&mut self) -> Result { + /// Parse the parenthesized argument list of a table-valued function, + /// assuming the opening `(` has already been consumed, up to and + /// including the closing `)`. + pub fn parse_table_function_args(&mut self) -> Result { if self.consume_token(&Token::RParen) { return Ok(TableFunctionArgs { args: vec![], From 0cb68201575c8f9acb14b68bef5a74e9a9bd43df Mon Sep 17 00:00:00 2001 From: Przemyslaw Denkiewicz Date: Fri, 24 Jul 2026 15:57:59 +0200 Subject: [PATCH 52/59] Snowflake: parse CREATE STREAM ON VIEW; add StreamSourceKind discriminator Accept CREATE [OR REPLACE] STREAM [IF NOT EXISTS] ON VIEW in addition to ON TABLE , and carry the source kind (Table vs View) on the CreateStream AST node via a new StreamSourceKind enum. Display round-trips both forms. --- src/ast/mod.rs | 32 +++++++++++++++++++++++++++++--- src/parser/mod.rs | 11 +++++++++-- tests/sqlparser_snowflake.rs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 67d61d4d5d..66b3a3631f 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -4945,7 +4945,7 @@ pub enum Statement { name: ObjectName, }, /// ```sql - /// CREATE [OR REPLACE] STREAM [IF NOT EXISTS] ON TABLE
+ /// CREATE [OR REPLACE] STREAM [IF NOT EXISTS] ON { TABLE | VIEW } /// ``` CreateStream { /// `OR REPLACE` flag. @@ -4954,7 +4954,10 @@ pub enum Statement { if_not_exists: bool, /// Stream name. name: ObjectName, - /// The source table the stream tracks (`ON TABLE
`). + /// Whether the source is a table (`ON TABLE`) or a view (`ON VIEW`). + source_kind: StreamSourceKind, + /// The source object the stream tracks (the `` after + /// `ON TABLE`/`ON VIEW`). source_table: ObjectName, }, /// ```sql @@ -7408,11 +7411,12 @@ impl fmt::Display for Statement { or_replace, if_not_exists, name, + source_kind, source_table, } => { write!( f, - "CREATE {or_replace}STREAM {if_not_exists}{name} ON TABLE {source_table}", + "CREATE {or_replace}STREAM {if_not_exists}{name} ON {source_kind} {source_table}", or_replace = if *or_replace { "OR REPLACE " } else { "" }, if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, ) @@ -10439,6 +10443,28 @@ impl fmt::Display for ObjectType { } } +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +/// The kind of object a Snowflake stream tracks, i.e. whether it was created +/// with `ON TABLE ` or `ON VIEW `. +/// +pub enum StreamSourceKind { + /// `ON TABLE `. + Table, + /// `ON VIEW `. + View, +} + +impl fmt::Display for StreamSourceKind { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(match self { + StreamSourceKind::Table => "TABLE", + StreamSourceKind::View => "VIEW", + }) + } +} + #[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 43aea123c1..2a425e74fe 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5628,16 +5628,23 @@ impl<'a> Parser<'a> { }) } - /// `CREATE [OR REPLACE] STREAM [IF NOT EXISTS] ON TABLE
` + /// `CREATE [OR REPLACE] STREAM [IF NOT EXISTS] ON { TABLE | VIEW } ` fn parse_create_stream(&mut self, or_replace: bool) -> Result { let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); let name = self.parse_object_name(false)?; - self.expect_keywords(&[Keyword::ON, Keyword::TABLE])?; + self.expect_keyword(Keyword::ON)?; + let source_kind = if self.parse_keyword(Keyword::VIEW) { + StreamSourceKind::View + } else { + self.expect_keyword(Keyword::TABLE)?; + StreamSourceKind::Table + }; let source_table = self.parse_object_name(false)?; Ok(Statement::CreateStream { or_replace, if_not_exists, name, + source_kind, source_table, }) } diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 861af46398..df72fe771f 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -71,6 +71,38 @@ fn parse_sf_create_secure_view_and_materialized_view() { } } +#[test] +fn parse_sf_create_stream_on_table_and_view() { + for (sql, expected_kind) in [ + ("CREATE STREAM s ON TABLE t", StreamSourceKind::Table), + ("CREATE STREAM s ON VIEW v", StreamSourceKind::View), + ( + "CREATE OR REPLACE STREAM s ON TABLE t", + StreamSourceKind::Table, + ), + ( + "CREATE OR REPLACE STREAM s ON VIEW v", + StreamSourceKind::View, + ), + ( + "CREATE STREAM IF NOT EXISTS s ON TABLE t", + StreamSourceKind::Table, + ), + ( + "CREATE STREAM IF NOT EXISTS s ON VIEW v", + StreamSourceKind::View, + ), + ] { + match snowflake().verified_stmt(sql) { + Statement::CreateStream { source_kind, .. } => { + assert_eq!(source_kind, expected_kind); + } + _ => unreachable!(), + } + assert_eq!(snowflake().verified_stmt(sql).to_string(), sql); + } +} + #[test] fn test_snowflake_create_or_replace_table() { let sql = "CREATE OR REPLACE TABLE my_table (a number)"; From 0e5e26e2d4e1e3256a8bd05c3221ad718ffd04f9 Mon Sep 17 00:00:00 2001 From: Przemyslaw Denkiewicz Date: Mon, 27 Jul 2026 10:18:44 +0200 Subject: [PATCH 53/59] Snowflake: parse informational constraint properties (ENABLE/VALIDATE/RELY) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend ConstraintCharacteristics with the remaining three Snowflake constraint properties — { ENABLE | DISABLE }, { VALIDATE | NOVALIDATE } and { RELY | NORELY } — so they can be given in any order alongside DEFERRABLE / INITIALLY / ENFORCED on inline column constraints, out-of-line table constraints and ALTER TABLE ADD CONSTRAINT. Parsing is gated on a new Dialect::supports_informational_constraint_properties hook (Snowflake only), since ENABLE, DISABLE and VALIDATE are keywords used elsewhere. Display round-trips the full set. Also add TableConstraint::clear_characteristics(), for consumers whose target dialect has no grammar for the characteristics Snowflake accepts. --- src/ast/ddl.rs | 53 ++++++++++++++++++++---------- src/ast/spans.rs | 3 ++ src/ast/table_constraints.rs | 18 +++++++++++ src/dialect/mod.rs | 15 +++++++++ src/dialect/snowflake.rs | 5 +++ src/keywords.rs | 3 ++ src/parser/mod.rs | 54 ++++++++++++++++++++++++++++--- tests/sqlparser_common.rs | 7 +++- tests/sqlparser_postgres.rs | 1 + tests/sqlparser_snowflake.rs | 63 +++++++++++++++++++++++++++++++++++- 10 files changed, 199 insertions(+), 23 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 27ff5b38c8..acb4e5e6ee 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -2310,9 +2310,13 @@ pub(crate) fn display_option_spaced(option: &Option) -> impl display_option(" ", "", option) } -/// ` = [ DEFERRABLE | NOT DEFERRABLE ] [ INITIALLY DEFERRED | INITIALLY IMMEDIATE ] [ ENFORCED | NOT ENFORCED ]` +/// ` = [ DEFERRABLE | NOT DEFERRABLE ] [ INITIALLY DEFERRED | INITIALLY IMMEDIATE ] [ ENFORCED | NOT ENFORCED ] [ ENABLE | DISABLE ] [ VALIDATE | NOVALIDATE ] [ RELY | NORELY ]` /// /// Used in UNIQUE and foreign key constraints. The individual settings may occur in any order. +/// +/// `ENABLE`/`DISABLE`, `VALIDATE`/`NOVALIDATE` and `RELY`/`NORELY` are only +/// parsed for dialects returning true from +/// [`Dialect::supports_informational_constraint_properties`]. #[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Default, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] @@ -2323,6 +2327,12 @@ pub struct ConstraintCharacteristics { pub initially: Option, /// `[ ENFORCED | NOT ENFORCED ]` pub enforced: Option, + /// `[ ENABLE | DISABLE ]` + pub enabled: Option, + /// `[ VALIDATE | NOVALIDATE ]` + pub validated: Option, + /// `[ RELY | NORELY ]` + pub rely: Option, } /// Initial setting for deferrable constraints (`INITIALLY IMMEDIATE` or `INITIALLY DEFERRED`). @@ -2366,26 +2376,35 @@ impl ConstraintCharacteristics { }, ) } + + fn enabled_text(&self) -> Option<&'static str> { + self.enabled + .map(|enabled| if enabled { "ENABLE" } else { "DISABLE" }) + } + + fn validated_text(&self) -> Option<&'static str> { + self.validated + .map(|validated| if validated { "VALIDATE" } else { "NOVALIDATE" }) + } + + fn rely_text(&self) -> Option<&'static str> { + self.rely.map(|rely| if rely { "RELY" } else { "NORELY" }) + } } impl fmt::Display for ConstraintCharacteristics { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let deferrable = self.deferrable_text(); - let initially_immediate = self.initially_immediate_text(); - let enforced = self.enforced_text(); - - match (deferrable, initially_immediate, enforced) { - (None, None, None) => Ok(()), - (None, None, Some(enforced)) => write!(f, "{enforced}"), - (None, Some(initial), None) => write!(f, "{initial}"), - (None, Some(initial), Some(enforced)) => write!(f, "{initial} {enforced}"), - (Some(deferrable), None, None) => write!(f, "{deferrable}"), - (Some(deferrable), None, Some(enforced)) => write!(f, "{deferrable} {enforced}"), - (Some(deferrable), Some(initial), None) => write!(f, "{deferrable} {initial}"), - (Some(deferrable), Some(initial), Some(enforced)) => { - write!(f, "{deferrable} {initial} {enforced}") - } - } + let properties = [ + self.deferrable_text(), + self.initially_immediate_text(), + self.enforced_text(), + self.enabled_text(), + self.validated_text(), + self.rely_text(), + ]; + + let set: Vec<&str> = properties.into_iter().flatten().collect(); + write!(f, "{}", display_separated(&set, " ")) } } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 3a19d28eb3..bb59c18eb4 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -978,6 +978,9 @@ impl Spanned for ConstraintCharacteristics { deferrable: _, // bool initially: _, // enum enforced: _, // bool + enabled: _, // bool + validated: _, // bool + rely: _, // bool } = self; Span::empty() diff --git a/src/ast/table_constraints.rs b/src/ast/table_constraints.rs index 9ba196a81e..25c70f2dd7 100644 --- a/src/ast/table_constraints.rs +++ b/src/ast/table_constraints.rs @@ -155,6 +155,24 @@ impl From for TableConstraint { } } +impl TableConstraint { + /// Drop any [`ConstraintCharacteristics`] the constraint carries, so it + /// renders as the bare constraint. Useful for dialects whose grammar has no + /// counterpart for the characteristics the source dialect accepts. + pub fn clear_characteristics(&mut self) { + match self { + TableConstraint::Unique(constraint) => constraint.characteristics = None, + TableConstraint::PrimaryKey(constraint) => constraint.characteristics = None, + TableConstraint::ForeignKey(constraint) => constraint.characteristics = None, + TableConstraint::PrimaryKeyUsingIndex(constraint) + | TableConstraint::UniqueUsingIndex(constraint) => constraint.characteristics = None, + TableConstraint::Check(_) + | TableConstraint::Index(_) + | TableConstraint::FulltextOrSpatial(_) => {} + } + } +} + impl fmt::Display for TableConstraint { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index bd3ade0d30..a958d33caa 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1265,6 +1265,21 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect accepts the informational constraint properties + /// `{ ENABLE | DISABLE }`, `{ VALIDATE | NOVALIDATE }` and `{ RELY | NORELY }` + /// alongside `DEFERRABLE` / `INITIALLY` / `ENFORCED` in constraint + /// characteristics. + /// + /// Example: + /// ```sql + /// CREATE TABLE t (a INT, CONSTRAINT pk PRIMARY KEY (a) NOT ENFORCED RELY) + /// ``` + /// + /// + fn supports_informational_constraint_properties(&self) -> bool { + false + } + /// Returns true if the dialect supports the `CONSTRAINT` keyword without a name /// in table constraint definitions. /// diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 9ead98849d..a95dffa1b7 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -915,6 +915,11 @@ impl Dialect for SnowflakeDialect { true } + /// See: + fn supports_informational_constraint_properties(&self) -> bool { + true + } + /// See: fn get_reserved_keywords_for_select_item_operator(&self) -> &[Keyword] { &RESERVED_KEYWORDS_FOR_SELECT_ITEM_OPERATOR diff --git a/src/keywords.rs b/src/keywords.rs index 6177b30eb7..9b97eedcd0 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -733,6 +733,7 @@ define_keywords!( NOLOGIN, NONE, NOORDER, + NORELY, NOREPLICATION, NORMALIZE, NORMALIZED, @@ -742,6 +743,7 @@ define_keywords!( NOTHING, NOTIFY, NOTNULL, + NOVALIDATE, NOWAIT, NO_WRITE_TO_BINLOG, NTH_VALUE, @@ -909,6 +911,7 @@ define_keywords!( RELAY, RELEASE, RELEASES, + RELY, REMAINDER, REMOTE, REMOVE, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 2a425e74fe..50d8f76155 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -10403,16 +10403,62 @@ impl<'a> Parser<'a> { && self.parse_keywords(&[Keyword::NOT, Keyword::ENFORCED]) { cc.enforced = Some(false); - } else { + } else if !self.parse_informational_constraint_property(&mut cc) { break; } } - if cc.deferrable.is_some() || cc.initially.is_some() || cc.enforced.is_some() { - Ok(Some(cc)) - } else { + if cc == ConstraintCharacteristics::default() { Ok(None) + } else { + Ok(Some(cc)) + } + } + + /// Parse one of the informational constraint properties `{ ENABLE | DISABLE }`, + /// `{ VALIDATE | NOVALIDATE }` or `{ RELY | NORELY }`, returning whether one was + /// consumed. Only dialects opting in via + /// [`Dialect::supports_informational_constraint_properties`] accept them, as + /// `ENABLE`, `DISABLE` and `VALIDATE` are keywords used elsewhere. + fn parse_informational_constraint_property( + &mut self, + cc: &mut ConstraintCharacteristics, + ) -> bool { + if !self.dialect.supports_informational_constraint_properties() { + return false; + } + + if cc.enabled.is_none() { + if self.parse_keyword(Keyword::ENABLE) { + cc.enabled = Some(true); + return true; + } + if self.parse_keyword(Keyword::DISABLE) { + cc.enabled = Some(false); + return true; + } + } + if cc.validated.is_none() { + if self.parse_keyword(Keyword::VALIDATE) { + cc.validated = Some(true); + return true; + } + if self.parse_keyword(Keyword::NOVALIDATE) { + cc.validated = Some(false); + return true; + } + } + if cc.rely.is_none() { + if self.parse_keyword(Keyword::RELY) { + cc.rely = Some(true); + return true; + } + if self.parse_keyword(Keyword::NORELY) { + cc.rely = Some(false); + return true; + } } + false } /// Parse an optional table constraint (e.g. `PRIMARY KEY`, `UNIQUE`, `FOREIGN KEY`, `CHECK`). diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 4980ac4908..0b8fbe84a7 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -4111,7 +4111,8 @@ fn parse_create_table_with_constraint_characteristics() { characteristics: Some(ConstraintCharacteristics { deferrable: Some(true), initially: Some(DeferrableInitial::Deferred), - enforced: None + enforced: None, + ..Default::default() }), } .into(), @@ -4128,6 +4129,7 @@ fn parse_create_table_with_constraint_characteristics() { deferrable: Some(true), initially: Some(DeferrableInitial::Immediate), enforced: None, + ..Default::default() }), } .into(), @@ -4144,6 +4146,7 @@ fn parse_create_table_with_constraint_characteristics() { deferrable: Some(false), initially: Some(DeferrableInitial::Deferred), enforced: Some(false), + ..Default::default() }), } .into(), @@ -4160,6 +4163,7 @@ fn parse_create_table_with_constraint_characteristics() { deferrable: Some(false), initially: Some(DeferrableInitial::Immediate), enforced: Some(true), + ..Default::default() }), } .into(), @@ -4226,6 +4230,7 @@ fn parse_create_table_column_constraint_characteristics() { deferrable, initially, enforced, + ..Default::default() }) } else { None diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index d409eabba5..c8ac31066c 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -6444,6 +6444,7 @@ fn parse_create_trigger_with_multiple_events_and_deferrable() { deferrable: Some(true), initially: Some(DeferrableInitial::Deferred), enforced: None, + ..Default::default() }), }); diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index df72fe771f..1128c0454d 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -23,7 +23,7 @@ use sqlparser::ast::helpers::key_value_options::{KeyValueOption, KeyValueOptionK use sqlparser::ast::helpers::stmt_data_loading::{StageLoadSelectItem, StageLoadSelectItemKind}; use sqlparser::ast::*; use sqlparser::dialect::{Dialect, GenericDialect, SnowflakeDialect}; -use sqlparser::parser::{ParserError, ParserOptions}; +use sqlparser::parser::{Parser, ParserError, ParserOptions}; use sqlparser::tokenizer::*; use test_utils::*; @@ -103,6 +103,67 @@ fn parse_sf_create_stream_on_table_and_view() { } } +#[test] +fn parse_sf_informational_constraint_properties() { + let canonical = "CREATE TABLE t (id INT, CONSTRAINT pk PRIMARY KEY (id) NOT ENFORCED DISABLE NOVALIDATE RELY)"; + match snowflake().verified_stmt(canonical) { + Statement::CreateTable(CreateTable { constraints, .. }) => match &constraints[0] { + TableConstraint::PrimaryKey(pk) => assert_eq!( + pk.characteristics, + Some(ConstraintCharacteristics { + deferrable: None, + initially: None, + enforced: Some(false), + enabled: Some(false), + validated: Some(false), + rely: Some(true), + }) + ), + other => panic!("unexpected constraint: {other}"), + }, + other => panic!("unexpected statement: {other}"), + } + + // The properties are interchangeable in order. + snowflake().one_statement_parses_to( + "CREATE TABLE t (id INT, CONSTRAINT pk PRIMARY KEY (id) RELY DISABLE NOVALIDATE NOT ENFORCED)", + canonical, + ); + + snowflake().verified_stmt("CREATE TABLE t (id INT PRIMARY KEY NOT ENFORCED RELY)"); + snowflake().verified_stmt("CREATE TABLE t (id INT UNIQUE ENABLE VALIDATE NORELY)"); + snowflake() + .verified_stmt("CREATE TABLE t (id INT, CONSTRAINT u UNIQUE (id) NOT ENFORCED NORELY)"); + snowflake().verified_stmt( + "CREATE TABLE t (id INT, CONSTRAINT fk FOREIGN KEY (id) REFERENCES p(id) NOT ENFORCED RELY)", + ); + snowflake().verified_stmt("ALTER TABLE t ADD CONSTRAINT u UNIQUE (id) NOT ENFORCED RELY"); +} + +#[test] +fn parse_sf_clear_constraint_characteristics() { + let sql = "CREATE TABLE t (id INT, CONSTRAINT u UNIQUE (id) NOT ENFORCED RELY)"; + match snowflake().verified_stmt(sql) { + Statement::CreateTable(CreateTable { + mut constraints, .. + }) => { + constraints[0].clear_characteristics(); + assert_eq!(constraints[0].to_string(), "CONSTRAINT u UNIQUE (id)"); + } + other => panic!("unexpected statement: {other}"), + } +} + +#[test] +fn parse_informational_constraint_properties_are_dialect_gated() { + let sql = "CREATE TABLE t (id INT, CONSTRAINT pk PRIMARY KEY (id) RELY)"; + let err = Parser::parse_sql(&GenericDialect {}, sql).unwrap_err(); + assert_eq!( + err.to_string(), + "sql parser error: Expected: \',\' or \')\' after column definition, found: RELY at Line: 1, Column: 56" + ); +} + #[test] fn test_snowflake_create_or_replace_table() { let sql = "CREATE OR REPLACE TABLE my_table (a number)"; From ae2fd4ab2dec7e995f76166379254c7b05f047d2 Mon Sep 17 00:00:00 2001 From: Przemyslaw Denkiewicz Date: Mon, 27 Jul 2026 12:58:12 +0200 Subject: [PATCH 54/59] Add ColumnDef::clear_constraint_characteristics Counterpart to TableConstraint::clear_characteristics for inline column constraints, so a consumer targeting a dialect without the constraint characteristics grammar can drop them from a column list in one call instead of hand-matching the constraint-bearing ColumnOption variants. --- src/ast/ddl.rs | 36 ++++++++++++++++++++++++++++++++++++ tests/sqlparser_snowflake.rs | 8 ++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index acb4e5e6ee..c87646f85d 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -1625,6 +1625,42 @@ pub struct ColumnDef { pub options: Vec, } +impl ColumnDef { + /// Drop any [`ConstraintCharacteristics`] the column's inline constraints + /// carry, so they render as bare constraints. Counterpart to + /// [`TableConstraint::clear_characteristics`] for dialects whose grammar has + /// no equivalent of the characteristics the source dialect accepts. + pub fn clear_constraint_characteristics(&mut self) { + for option in &mut self.options { + match &mut option.option { + ColumnOption::PrimaryKey(constraint) => constraint.characteristics = None, + ColumnOption::Unique(constraint) => constraint.characteristics = None, + ColumnOption::ForeignKey(constraint) => constraint.characteristics = None, + ColumnOption::Null + | ColumnOption::NotNull + | ColumnOption::Default(_) + | ColumnOption::Materialized(_) + | ColumnOption::Ephemeral(_) + | ColumnOption::Alias(_) + | ColumnOption::Check(_) + | ColumnOption::DialectSpecific(_) + | ColumnOption::CharacterSet(_) + | ColumnOption::Collation(_) + | ColumnOption::Comment(_) + | ColumnOption::OnUpdate(_) + | ColumnOption::Generated { .. } + | ColumnOption::Options(_) + | ColumnOption::Identity(_) + | ColumnOption::OnConflict(_) + | ColumnOption::Policy(_) + | ColumnOption::Tags(_) + | ColumnOption::Srid(_) + | ColumnOption::Invisible => {} + } + } + } +} + impl fmt::Display for ColumnDef { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { if self.data_type == DataType::Unspecified { diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 1128c0454d..c79cfb0bb7 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -142,13 +142,17 @@ fn parse_sf_informational_constraint_properties() { #[test] fn parse_sf_clear_constraint_characteristics() { - let sql = "CREATE TABLE t (id INT, CONSTRAINT u UNIQUE (id) NOT ENFORCED RELY)"; + let sql = "CREATE TABLE t (id INT UNIQUE ENABLE VALIDATE RELY, CONSTRAINT u UNIQUE (id) NOT ENFORCED RELY)"; match snowflake().verified_stmt(sql) { Statement::CreateTable(CreateTable { - mut constraints, .. + mut columns, + mut constraints, + .. }) => { constraints[0].clear_characteristics(); assert_eq!(constraints[0].to_string(), "CONSTRAINT u UNIQUE (id)"); + columns[0].clear_constraint_characteristics(); + assert_eq!(columns[0].to_string(), "id INT UNIQUE"); } other => panic!("unexpected statement: {other}"), } From b5e11f27d9b96dc869ed3682230658d5e7f64990 Mon Sep 17 00:00:00 2001 From: sabir-akhadov-localstack Date: Wed, 29 Jul 2026 13:22:26 +0200 Subject: [PATCH 55/59] Snowflake: parse masking policy statement family --- src/ast/ddl.rs | 70 +++++++++ src/ast/mod.rs | 128 ++++++++++++++++ src/ast/spans.rs | 14 ++ src/dialect/snowflake.rs | 121 ++++++++++++++- src/keywords.rs | 1 + src/parser/mod.rs | 82 ++++++++-- tests/sqlparser_snowflake.rs | 288 +++++++++++++++++++++++++++++++++++ 7 files changed, 691 insertions(+), 13 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index c87646f85d..29dd293f4b 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -1356,6 +1356,61 @@ pub enum AlterColumnOperation { /// Optional sequence options for identity generation. sequence_options: Option>, }, + + /// `SET MASKING POLICY [USING (, ...)] [FORCE]` + /// + /// Snowflake: attach a masking policy to the column + /// (`ALTER TABLE t MODIFY COLUMN c SET MASKING POLICY p`). + SetMaskingPolicy { + /// The policy to attach. + policy_name: ObjectName, + /// Optional `USING (, ...)` conditional-masking column list. + using_columns: Option>, + /// Whether the `FORCE` keyword was present. + force: bool, + }, + + /// `UNSET MASKING POLICY` + /// + /// Snowflake: detach the masking policy from the column. + UnsetMaskingPolicy, +} + +/// An operation on a masking policy in an `ALTER MASKING POLICY` statement. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AlterMaskingPolicyOperation { + /// `SET BODY -> ` + SetBody { + /// The replacement body expression. + body: Expr, + }, + /// `RENAME TO ` + RenameTo { + /// The new policy name. + new_name: ObjectName, + }, + /// `SET COMMENT = ''` + SetComment { + /// The replacement comment text. + comment: String, + }, + /// `UNSET COMMENT` + UnsetComment, +} + +impl fmt::Display for AlterMaskingPolicyOperation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AlterMaskingPolicyOperation::SetBody { body } => write!(f, "SET BODY -> {body}"), + AlterMaskingPolicyOperation::RenameTo { new_name } => write!(f, "RENAME TO {new_name}"), + AlterMaskingPolicyOperation::SetComment { comment } => { + write!(f, "SET COMMENT = '{}'", escape_single_quote_string(comment)) + } + AlterMaskingPolicyOperation::UnsetComment => write!(f, "UNSET COMMENT"), + } + } } impl fmt::Display for AlterColumnOperation { @@ -1408,6 +1463,21 @@ impl fmt::Display for AlterColumnOperation { } Ok(()) } + AlterColumnOperation::SetMaskingPolicy { + policy_name, + using_columns, + force, + } => { + write!(f, "SET MASKING POLICY {policy_name}")?; + if let Some(columns) = using_columns { + write!(f, " USING ({})", display_comma_separated(columns))?; + } + if *force { + write!(f, " FORCE")?; + } + Ok(()) + } + AlterColumnOperation::UnsetMaskingPolicy => write!(f, "UNSET MASKING POLICY"), } } } diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 66b3a3631f..0523deb69d 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -64,6 +64,7 @@ pub use self::dcl::{ pub use self::ddl::{ Alignment, AlterCollation, AlterCollationOperation, AlterColumnOperation, AlterConnectorOwner, AlterFunction, AlterFunctionAction, AlterFunctionKind, AlterFunctionOperation, + AlterMaskingPolicyOperation, AlterIndexOperation, AlterProcedure, AlterProcedureOperation, AlterOperator, AlterOperatorClass, AlterOperatorClassOperation, AlterOperatorFamily, AlterOperatorFamilyOperation, AlterOperatorOperation, AlterPolicy, @@ -4148,6 +4149,20 @@ pub enum Statement { with_options: Vec, }, /// ```sql + /// ALTER VIEW { MODIFY | ALTER } COLUMN + /// { SET MASKING POLICY

[USING (, ...)] [FORCE] | UNSET MASKING POLICY } + /// ``` + /// Snowflake column-level masking-policy operation on a view. + AlterViewColumn { + /// View name. + #[cfg_attr(feature = "visitor", visit(with = "visit_relation"))] + name: ObjectName, + /// Target column. + column_name: Ident, + /// The column operation to apply. + op: AlterColumnOperation, + }, + /// ```sql /// ALTER FUNCTION /// ALTER AGGREGATE /// ``` @@ -5381,6 +5396,63 @@ pub enum Statement { filter: Option, }, /// ```sql + /// CREATE [OR REPLACE] MASKING POLICY [IF NOT EXISTS] + /// AS ( [, ...]) RETURNS -> [COMMENT = ''] + /// ``` + /// See + CreateMaskingPolicy { + /// `OR REPLACE` flag. + or_replace: bool, + /// `IF NOT EXISTS` flag. + if_not_exists: bool, + /// Policy name. + name: ObjectName, + /// Signature arguments (name + type), in declaration order. + args: Vec, + /// The declared return type. + return_type: DataType, + /// The policy body expression after `->`. + policy_expr: Expr, + /// Optional `COMMENT = ''`. + comment: Option, + }, + /// ```sql + /// ALTER MASKING POLICY [IF EXISTS] + /// { SET BODY -> | RENAME TO | SET COMMENT = '' | UNSET COMMENT } + /// ``` + /// See + AlterMaskingPolicy { + /// `IF EXISTS` flag. + if_exists: bool, + /// Policy name. + name: ObjectName, + /// The operation to apply. + operation: AlterMaskingPolicyOperation, + }, + /// ```sql + /// DROP MASKING POLICY [IF EXISTS] + /// ``` + DropMaskingPolicy { + /// `IF EXISTS` flag. + if_exists: bool, + /// Policy name. + name: ObjectName, + }, + /// ```sql + /// DESC[RIBE] MASKING POLICY + /// ``` + DescribeMaskingPolicy { + /// Policy name. + name: ObjectName, + }, + /// ```sql + /// SHOW MASKING POLICIES [ LIKE '' ] [ IN ] + /// ``` + ShowMaskingPolicies { + /// Options controlling the SHOW output (filter, `IN `, etc.). + show_options: ShowStatementOptions, + }, + /// ```sql /// SHOW PROCEDURES [ LIKE '' ] [ IN ] /// ``` ShowProcedures { @@ -6746,6 +6818,13 @@ impl fmt::Display for Statement { } write!(f, " AS {query}") } + Statement::AlterViewColumn { + name, + column_name, + op, + } => { + write!(f, "ALTER VIEW {name} ALTER COLUMN {column_name} {op}") + } Statement::AlterFunction(alter_function) => write!(f, "{alter_function}"), Statement::AlterType(AlterType { name, operation }) => { write!(f, "ALTER TYPE {name} {operation}") @@ -7835,6 +7914,55 @@ impl fmt::Display for Statement { } Ok(()) } + Statement::CreateMaskingPolicy { + or_replace, + if_not_exists, + name, + args, + return_type, + policy_expr, + comment, + } => { + write!( + f, + "CREATE {or_replace}MASKING POLICY {if_not_exists}{name} AS ({args}) RETURNS {return_type} -> {policy_expr}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, + args = display_comma_separated(args), + )?; + if let Some(comment) = comment { + write!( + f, + " COMMENT = '{}'", + value::escape_single_quote_string(comment) + )?; + } + Ok(()) + } + Statement::AlterMaskingPolicy { + if_exists, + name, + operation, + } => { + write!( + f, + "ALTER MASKING POLICY {if_exists}{name} {operation}", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + ) + } + Statement::DropMaskingPolicy { if_exists, name } => { + write!( + f, + "DROP MASKING POLICY {if_exists}{name}", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + ) + } + Statement::DescribeMaskingPolicy { name } => { + write!(f, "DESCRIBE MASKING POLICY {name}") + } + Statement::ShowMaskingPolicies { show_options } => { + write!(f, "SHOW MASKING POLICIES{show_options}") + } Statement::ShowProcedures { show_options } => { write!(f, "SHOW PROCEDURES{show_options}")?; Ok(()) diff --git a/src/ast/spans.rs b/src/ast/spans.rs index bb59c18eb4..a25c784f07 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -422,6 +422,13 @@ impl Spanned for Statement { .chain(core::iter::once(query.span())) .chain(with_options.iter().map(|i| i.span())), ), + Statement::AlterViewColumn { + name, + column_name, + op, + } => union_spans( + [name.span(), column_name.span, op.span()].into_iter(), + ), // These statements need to be implemented Statement::AlterFunction { .. } => Span::empty(), Statement::AlterType { .. } => Span::empty(), @@ -570,6 +577,11 @@ impl Spanned for Statement { Statement::DropRowAccessPolicy { .. } => Span::empty(), Statement::DescribeRowAccessPolicy { .. } => Span::empty(), Statement::ShowRowAccessPolicies { .. } => Span::empty(), + Statement::CreateMaskingPolicy { .. } => Span::empty(), + Statement::AlterMaskingPolicy { .. } => Span::empty(), + Statement::DropMaskingPolicy { .. } => Span::empty(), + Statement::DescribeMaskingPolicy { .. } => Span::empty(), + Statement::ShowMaskingPolicies { .. } => Span::empty(), Statement::ShowProcedures { .. } => Span::empty(), Statement::ShowConnections { .. } => Span::empty(), Statement::ShowShares { .. } => Span::empty(), @@ -1024,6 +1036,8 @@ impl Spanned for AlterColumnOperation { } => using.as_ref().map_or(Span::empty(), |u| u.span()), AlterColumnOperation::Comment { .. } => Span::empty(), AlterColumnOperation::AddGenerated { .. } => Span::empty(), + AlterColumnOperation::SetMaskingPolicy { .. } => Span::empty(), + AlterColumnOperation::UnsetMaskingPolicy => Span::empty(), } } } diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index a95dffa1b7..9304750890 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -27,8 +27,8 @@ use crate::ast::helpers::stmt_data_loading::{ FileStagingCommand, StageLoadSelectItem, StageLoadSelectItemKind, StageParamsObject, }; use crate::ast::{ - AlterExternalVolumeOperation, AlterFileFormatOperation, AlterProcedure, - AlterProcedureOperation, AlterStageOperation, AlterTable, + AlterExternalVolumeOperation, AlterFileFormatOperation, AlterMaskingPolicyOperation, + AlterProcedure, AlterProcedureOperation, AlterStageOperation, AlterTable, AlterTableOperation, AlterTableType, CatalogRestAuthentication, CatalogRestConfig, CatalogSource, CatalogSyncNamespaceMode, CatalogTableFormat, ColumnOption, ColumnPolicy, ColumnPolicyProperty, ContactEntry, CopyIntoSnowflakeKind, CreateTable, CreateTableLikeKind, @@ -387,6 +387,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_alter_row_access_policy(parser)); } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::MASKING, Keyword::POLICY]) { + // ALTER MASKING POLICY + return Some(parse_alter_masking_policy(parser)); + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::SESSION]) { // ALTER SESSION let set = match parser.parse_one_of_keywords(&[Keyword::SET, Keyword::UNSET]) { @@ -432,6 +437,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_drop_row_access_policy(parser)); } + if parser.parse_keywords(&[Keyword::DROP, Keyword::MASKING, Keyword::POLICY]) { + // DROP MASKING POLICY + return Some(parse_drop_masking_policy(parser)); + } + if parser .parse_one_of_keywords(&[Keyword::DESC, Keyword::DESCRIBE]) .is_some() @@ -456,6 +466,10 @@ impl Dialect for SnowflakeDialect { // DESC[RIBE] ROW ACCESS POLICY return Some(parse_describe_row_access_policy(parser)); } + if parser.parse_keywords(&[Keyword::MASKING, Keyword::POLICY]) { + // DESC[RIBE] MASKING POLICY + return Some(parse_describe_masking_policy(parser)); + } // not handled — put back DESC/DESCRIBE parser.prev_token(); } @@ -485,6 +499,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_create_row_access_policy(or_replace, parser)); } + // CREATE [OR REPLACE] MASKING POLICY + if parser.parse_keywords(&[Keyword::MASKING, Keyword::POLICY]) { + return Some(parse_create_masking_policy(or_replace, parser)); + } + // LOCAL | GLOBAL let global = match parser.parse_one_of_keywords(&[Keyword::LOCAL, Keyword::GLOBAL]) { Some(Keyword::LOCAL) => Some(false), @@ -644,6 +663,9 @@ impl Dialect for SnowflakeDialect { if parser.parse_keywords(&[Keyword::ROW, Keyword::ACCESS, Keyword::POLICIES]) { return Some(parse_show_row_access_policies(parser)); } + if parser.parse_keywords(&[Keyword::MASKING, Keyword::POLICIES]) { + return Some(parse_show_masking_policies(parser)); + } if parser.parse_keyword(Keyword::PROCEDURES) { return Some(parse_show_procedures(parser)); } @@ -3399,6 +3421,101 @@ fn parse_show_row_access_policies(parser: &mut Parser) -> Result +/// AS ( [, ...]) RETURNS -> [COMMENT = '']` +fn parse_create_masking_policy( + or_replace: bool, + parser: &mut Parser, +) -> Result { + let if_not_exists = parser.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + parser.expect_keyword_is(Keyword::AS)?; + parser.expect_token(&Token::LParen)?; + let args = parser.parse_comma_separated(|p| { + let arg_name = p.parse_identifier()?; + let data_type = p.parse_data_type()?; + Ok(OperateFunctionArg { + mode: None, + name: Some(arg_name), + data_type, + default_expr: None, + }) + })?; + parser.expect_token(&Token::RParen)?; + parser.expect_keyword_is(Keyword::RETURNS)?; + let return_type = parser.parse_data_type()?; + parser.expect_token(&Token::Arrow)?; + let policy_expr = parser.parse_expr()?; + let comment = if parser.parse_keyword(Keyword::COMMENT) { + parser.expect_token(&Token::Eq)?; + Some(parser.parse_comment_value()?) + } else { + None + }; + Ok(Statement::CreateMaskingPolicy { + or_replace, + if_not_exists, + name, + args, + return_type, + policy_expr, + comment, + }) +} + +/// Parse `ALTER MASKING POLICY [IF EXISTS] +/// { SET BODY -> | RENAME TO | SET COMMENT = '' | UNSET COMMENT }` +fn parse_alter_masking_policy(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + let operation = if parser.parse_keywords(&[Keyword::SET, Keyword::BODY]) { + parser.expect_token(&Token::Arrow)?; + AlterMaskingPolicyOperation::SetBody { + body: parser.parse_expr()?, + } + } else if parser.parse_keywords(&[Keyword::RENAME, Keyword::TO]) { + AlterMaskingPolicyOperation::RenameTo { + new_name: parser.parse_object_name(false)?, + } + } else if parser.parse_keywords(&[Keyword::SET, Keyword::COMMENT]) { + parser.expect_token(&Token::Eq)?; + AlterMaskingPolicyOperation::SetComment { + comment: parser.parse_comment_value()?, + } + } else if parser.parse_keywords(&[Keyword::UNSET, Keyword::COMMENT]) { + AlterMaskingPolicyOperation::UnsetComment + } else { + return parser.expected_ref( + "SET BODY, RENAME TO, SET COMMENT, or UNSET COMMENT", + parser.peek_token_ref(), + ); + }; + Ok(Statement::AlterMaskingPolicy { + if_exists, + name, + operation, + }) +} + +/// Parse `DROP MASKING POLICY [IF EXISTS] ` +fn parse_drop_masking_policy(parser: &mut Parser) -> Result { + let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = parser.parse_object_name(false)?; + Ok(Statement::DropMaskingPolicy { if_exists, name }) +} + +/// Parse `DESC[RIBE] MASKING POLICY ` +fn parse_describe_masking_policy(parser: &mut Parser) -> Result { + let name = parser.parse_object_name(false)?; + Ok(Statement::DescribeMaskingPolicy { name }) +} + +/// Parse `SHOW MASKING POLICIES [LIKE ''] [IN ]` +fn parse_show_masking_policies(parser: &mut Parser) -> Result { + let show_options = parser.parse_show_stmt_options()?; + Ok(Statement::ShowMaskingPolicies { show_options }) +} + /// Parse `SHOW PROCEDURES [LIKE ''] [IN ]` fn parse_show_procedures(parser: &mut Parser) -> Result { let show_options = parser.parse_show_stmt_options()?; diff --git a/src/keywords.rs b/src/keywords.rs index 9b97eedcd0..a8c51e223b 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -179,6 +179,7 @@ define_keywords!( BLOCK, BLOOM, BLOOMFILTER, + BODY, BOOL, BOOLEAN, BOOST, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 50d8f76155..7ff5ac2903 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -11275,19 +11275,26 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::MODIFY) { let _ = self.parse_keyword(Keyword::COLUMN); // [ COLUMN ] let col_name = self.parse_identifier()?; - let data_type = self.parse_data_type()?; - let mut options = vec![]; - while let Some(option) = self.parse_optional_column_option()? { - options.push(option); - } + if let Some(op) = self.maybe_parse_column_masking_policy()? { + AlterTableOperation::AlterColumn { + column_name: col_name, + op, + } + } else { + let data_type = self.parse_data_type()?; + let mut options = vec![]; + while let Some(option) = self.parse_optional_column_option()? { + options.push(option); + } - let column_position = self.parse_column_position()?; + let column_position = self.parse_column_position()?; - AlterTableOperation::ModifyColumn { - col_name, - data_type, - options, - column_position, + AlterTableOperation::ModifyColumn { + col_name, + data_type, + options, + column_position, + } } } else if self.dialect.supports_alter_column_comment() && (self.parse_keyword(Keyword::COLUMN) @@ -11363,6 +11370,8 @@ impl<'a> Parser<'a> { generated_as, sequence_options, } + } else if let Some(op) = self.maybe_parse_column_masking_policy()? { + op } else { let message = if is_postgresql { "SET/DROP NOT NULL, SET DEFAULT, SET DATA TYPE, or ADD GENERATED after ALTER COLUMN" @@ -11513,6 +11522,35 @@ impl<'a> Parser<'a> { Ok(operation) } + /// Try to parse a Snowflake column masking-policy operation + /// (`SET MASKING POLICY

[USING (, ...)] [FORCE]` or + /// `UNSET MASKING POLICY`) following the column name in an `ALTER TABLE` + /// / `ALTER VIEW` `{MODIFY|ALTER} COLUMN` clause. Returns `None` if the + /// upcoming tokens are not a masking-policy operation, leaving the parser + /// position unchanged. + fn maybe_parse_column_masking_policy( + &mut self, + ) -> Result, ParserError> { + if self.parse_keywords(&[Keyword::SET, Keyword::MASKING, Keyword::POLICY]) { + let policy_name = self.parse_object_name(false)?; + let using_columns = if self.parse_keyword(Keyword::USING) { + Some(self.parse_parenthesized_column_list(Mandatory, false)?) + } else { + None + }; + let force = self.parse_keyword(Keyword::FORCE); + Ok(Some(AlterColumnOperation::SetMaskingPolicy { + policy_name, + using_columns, + force, + })) + } else if self.parse_keywords(&[Keyword::UNSET, Keyword::MASKING, Keyword::POLICY]) { + Ok(Some(AlterColumnOperation::UnsetMaskingPolicy)) + } else { + Ok(None) + } + } + fn parse_set_data_type(&mut self, had_set: bool) -> Result { let data_type = self.parse_data_type()?; let using = if self.dialect.supports_alter_column_type_using() @@ -12021,6 +12059,28 @@ impl<'a> Parser<'a> { /// Parse an `ALTER VIEW` statement. pub fn parse_alter_view(&mut self) -> Result { let name = self.parse_object_name(false)?; + + // Snowflake: `ALTER VIEW v { MODIFY | ALTER } COLUMN c + // { SET MASKING POLICY p [USING (...)] [FORCE] | UNSET MASKING POLICY }` + if self.parse_one_of_keywords(&[Keyword::MODIFY, Keyword::ALTER]).is_some() { + let _ = self.parse_keyword(Keyword::COLUMN); // [ COLUMN ] + let column_name = self.parse_identifier()?; + let op = match self.maybe_parse_column_masking_policy()? { + Some(op) => op, + None => { + return self.expected_ref( + "SET MASKING POLICY or UNSET MASKING POLICY", + self.peek_token_ref(), + ) + } + }; + return Ok(Statement::AlterViewColumn { + name, + column_name, + op, + }); + } + let columns = self.parse_parenthesized_column_list(Optional, false)?; let with_options = self.parse_options(Keyword::WITH)?; diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index c79cfb0bb7..ad051b498b 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -8876,3 +8876,291 @@ fn test_with_as_procedure() { let stmt = snowflake().verified_stmt("WITH t AS (SELECT 1) SELECT * FROM t"); assert!(matches!(stmt, Statement::Query(_))); } + +#[test] +fn parse_snowflake_create_masking_policy() { + match snowflake().verified_stmt( + "CREATE MASKING POLICY p AS (a VARCHAR, b VARCHAR) RETURNS VARCHAR -> a", + ) { + Statement::CreateMaskingPolicy { + or_replace, + if_not_exists, + name, + args, + return_type, + policy_expr, + comment, + } => { + assert!(!or_replace); + assert!(!if_not_exists); + assert_eq!("p", name.to_string()); + assert_eq!(2, args.len()); + assert_eq!(Some(Ident::new("a")), args[0].name); + assert_eq!(DataType::Varchar(None), args[0].data_type); + assert_eq!(Some(Ident::new("b")), args[1].name); + assert_eq!(DataType::Varchar(None), return_type); + assert_eq!(Expr::Identifier(Ident::new("a")), policy_expr); + assert_eq!(None, comment); + } + other => panic!("expected CreateMaskingPolicy, got {other:?}"), + } + + // OR REPLACE, IF NOT EXISTS, and a COMMENT. + match snowflake().verified_stmt( + "CREATE OR REPLACE MASKING POLICY p AS (a VARCHAR) RETURNS VARCHAR -> a COMMENT = 'hi'", + ) { + Statement::CreateMaskingPolicy { + or_replace, + if_not_exists, + comment, + .. + } => { + assert!(or_replace); + assert!(!if_not_exists); + assert_eq!(Some("hi".to_string()), comment); + } + other => panic!("expected CreateMaskingPolicy, got {other:?}"), + } + + match snowflake().verified_stmt( + "CREATE MASKING POLICY IF NOT EXISTS p AS (a VARCHAR) RETURNS VARCHAR -> a", + ) { + Statement::CreateMaskingPolicy { if_not_exists, .. } => assert!(if_not_exists), + other => panic!("expected CreateMaskingPolicy, got {other:?}"), + } + + // A conditional-masking body with multiple arguments round-trips. + snowflake().verified_stmt( + "CREATE MASKING POLICY p AS (a VARCHAR, b VARCHAR) RETURNS VARCHAR -> \ + CASE WHEN b = 'x' THEN '***' ELSE a END", + ); +} + +#[test] +fn parse_snowflake_alter_masking_policy() { + match snowflake().verified_stmt("ALTER MASKING POLICY p SET BODY -> 'true'") { + Statement::AlterMaskingPolicy { + if_exists, + name, + operation, + } => { + assert!(!if_exists); + assert_eq!("p", name.to_string()); + assert_eq!( + AlterMaskingPolicyOperation::SetBody { + body: Expr::Value( + Value::SingleQuotedString("true".to_string()).with_empty_span() + ), + }, + operation + ); + } + other => panic!("expected AlterMaskingPolicy, got {other:?}"), + } + + match snowflake().verified_stmt("ALTER MASKING POLICY IF EXISTS p RENAME TO q") { + Statement::AlterMaskingPolicy { + if_exists, + operation, + .. + } => { + assert!(if_exists); + assert_eq!( + AlterMaskingPolicyOperation::RenameTo { + new_name: ObjectName::from(vec![Ident::new("q")]), + }, + operation + ); + } + other => panic!("expected AlterMaskingPolicy, got {other:?}"), + } + + match snowflake().verified_stmt("ALTER MASKING POLICY p SET COMMENT = 'c'") { + Statement::AlterMaskingPolicy { operation, .. } => assert_eq!( + AlterMaskingPolicyOperation::SetComment { + comment: "c".to_string(), + }, + operation + ), + other => panic!("expected AlterMaskingPolicy, got {other:?}"), + } + + match snowflake().verified_stmt("ALTER MASKING POLICY p UNSET COMMENT") { + Statement::AlterMaskingPolicy { operation, .. } => { + assert_eq!(AlterMaskingPolicyOperation::UnsetComment, operation) + } + other => panic!("expected AlterMaskingPolicy, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_drop_masking_policy() { + match snowflake().verified_stmt("DROP MASKING POLICY p") { + Statement::DropMaskingPolicy { if_exists, name } => { + assert!(!if_exists); + assert_eq!("p", name.to_string()); + } + other => panic!("expected DropMaskingPolicy, got {other:?}"), + } + + match snowflake().verified_stmt("DROP MASKING POLICY IF EXISTS p") { + Statement::DropMaskingPolicy { if_exists, .. } => assert!(if_exists), + other => panic!("expected DropMaskingPolicy, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_describe_masking_policy() { + for sql in [ + "DESCRIBE MASKING POLICY p", + "DESC MASKING POLICY p", + ] { + match snowflake().one_statement_parses_to(sql, "DESCRIBE MASKING POLICY p") { + Statement::DescribeMaskingPolicy { name } => assert_eq!("p", name.to_string()), + other => panic!("expected DescribeMaskingPolicy, got {other:?}"), + } + } +} + +#[test] +fn parse_snowflake_show_masking_policies() { + match snowflake().verified_stmt("SHOW MASKING POLICIES") { + Statement::ShowMaskingPolicies { show_options } => { + assert!(show_options.filter_position.is_none()); + } + other => panic!("expected ShowMaskingPolicies, got {other:?}"), + } + + match snowflake().verified_stmt("SHOW MASKING POLICIES LIKE '%p%'") { + Statement::ShowMaskingPolicies { .. } => {} + other => panic!("expected ShowMaskingPolicies, got {other:?}"), + } + + snowflake().verified_stmt("SHOW MASKING POLICIES IN SCHEMA s"); +} + +#[test] +fn parse_snowflake_alter_table_column_masking_policy() { + for keyword in ["MODIFY", "ALTER"] { + let sql = format!("ALTER TABLE t {keyword} COLUMN c SET MASKING POLICY p"); + match snowflake().one_statement_parses_to( + &sql, + "ALTER TABLE t ALTER COLUMN c SET MASKING POLICY p", + ) { + Statement::AlterTable(AlterTable { operations, .. }) => match &operations[0] { + AlterTableOperation::AlterColumn { column_name, op } => { + assert_eq!("c", column_name.to_string()); + assert_eq!( + &AlterColumnOperation::SetMaskingPolicy { + policy_name: ObjectName::from(vec![Ident::new("p")]), + using_columns: None, + force: false, + }, + op + ); + } + other => panic!("expected AlterColumn, got {other:?}"), + }, + other => panic!("expected AlterTable, got {other:?}"), + } + } + + // USING and FORCE. + match snowflake() + .verified_stmt("ALTER TABLE t ALTER COLUMN c SET MASKING POLICY p USING (c, d) FORCE") + { + Statement::AlterTable(AlterTable { operations, .. }) => match &operations[0] { + AlterTableOperation::AlterColumn { + op: + AlterColumnOperation::SetMaskingPolicy { + using_columns, + force, + .. + }, + .. + } => { + assert_eq!( + &Some(vec![Ident::new("c"), Ident::new("d")]), + using_columns + ); + assert!(force); + } + other => panic!("expected SetMaskingPolicy, got {other:?}"), + }, + other => panic!("expected AlterTable, got {other:?}"), + } + + // UNSET. + match snowflake().verified_stmt("ALTER TABLE t ALTER COLUMN c UNSET MASKING POLICY") { + Statement::AlterTable(AlterTable { operations, .. }) => assert_eq!( + AlterTableOperation::AlterColumn { + column_name: Ident::new("c"), + op: AlterColumnOperation::UnsetMaskingPolicy, + }, + operations[0] + ), + other => panic!("expected AlterTable, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_alter_view_column_masking_policy() { + for keyword in ["MODIFY", "ALTER"] { + let sql = format!("ALTER VIEW v {keyword} COLUMN c SET MASKING POLICY p"); + match snowflake() + .one_statement_parses_to(&sql, "ALTER VIEW v ALTER COLUMN c SET MASKING POLICY p") + { + Statement::AlterViewColumn { + name, + column_name, + op, + } => { + assert_eq!("v", name.to_string()); + assert_eq!("c", column_name.to_string()); + assert_eq!( + AlterColumnOperation::SetMaskingPolicy { + policy_name: ObjectName::from(vec![Ident::new("p")]), + using_columns: None, + force: false, + }, + op + ); + } + other => panic!("expected AlterViewColumn, got {other:?}"), + } + } + + match snowflake().verified_stmt("ALTER VIEW v ALTER COLUMN c UNSET MASKING POLICY") { + Statement::AlterViewColumn { op, .. } => { + assert_eq!(AlterColumnOperation::UnsetMaskingPolicy, op) + } + other => panic!("expected AlterViewColumn, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_create_view_column_masking_policy() { + match snowflake().verified_stmt( + "CREATE VIEW v (c WITH MASKING POLICY p USING (c, d)) AS SELECT c, d FROM t", + ) { + Statement::CreateView(CreateView { columns, .. }) => { + assert_eq!(1, columns.len()); + assert_eq!("c", columns[0].name.to_string()); + match &columns[0].options { + Some(ColumnOptions::SpaceSeparated(options)) => match &options[0] { + ColumnOption::Policy(ColumnPolicy::MaskingPolicy(property)) => { + assert!(property.with); + assert_eq!("p", property.policy_name.to_string()); + assert_eq!( + Some(vec![Ident::new("c"), Ident::new("d")]), + property.using_columns + ); + } + other => panic!("expected MaskingPolicy, got {other:?}"), + }, + other => panic!("expected column options, got {other:?}"), + } + } + other => panic!("expected CreateView, got {other:?}"), + } +} From 4238599896a0b6c4f40e5f2454ce370ab426e548 Mon Sep 17 00:00:00 2001 From: wojpadlo Date: Wed, 29 Jul 2026 15:44:20 +0200 Subject: [PATCH 56/59] Snowflake: decode octal/hex/unicode string-literal escapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decode \ooo, \xhh and \uhhhh as Unicode code points, keep \0 as NUL, collapse unrecognized letters (\a, \Z) to the bare letter, and raise a tokenizer error on malformed \x / \u — gated behind a new supports_snowflake_string_literal_escapes dialect capability so other backslash-escaping dialects keep MySQL/BigQuery semantics. Co-Authored-By: Claude Opus 4.8 --- src/dialect/mod.rs | 16 ++++ src/dialect/snowflake.rs | 5 + src/tokenizer.rs | 197 +++++++++++++++++++++++++++++++++++++- tests/sqlparser_common.rs | 6 +- 4 files changed, 218 insertions(+), 6 deletions(-) diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index a958d33caa..2be89f4215 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -283,6 +283,18 @@ pub trait Dialect: Debug + Any { false } + /// Determine whether the dialect decodes Snowflake's extended backslash + /// escape set inside single-quoted string literals: octal `\ooo`, hex + /// `\xhh` and unicode `\uhhhh` (each yielding a Unicode code point), with + /// unrecognized letters such as `\a` / `\Z` collapsing to the bare letter + /// and malformed `\x` / `\u` raising a tokenizer error. This differs from + /// the MySQL/BigQuery-style mapping used by + /// [`Self::supports_string_literal_backslash_escape`], so a dialect that + /// enables this must also enable that one. + fn supports_snowflake_string_literal_escapes(&self) -> bool { + false + } + /// Determine whether the dialect strips the backslash when escaping LIKE wildcards (%, _). /// /// [MySQL] has a special case when escaping single quoted strings which leaves these unescaped @@ -2066,6 +2078,10 @@ mod tests { self.0.supports_string_literal_backslash_escape() } + fn supports_snowflake_string_literal_escapes(&self) -> bool { + self.0.supports_snowflake_string_literal_escapes() + } + fn supports_filter_during_aggregation(&self) -> bool { self.0.supports_filter_during_aggregation() } diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 9304750890..fc4a541b69 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -179,6 +179,11 @@ impl Dialect for SnowflakeDialect { true } + // See https://docs.snowflake.com/en/sql-reference/data-types-text#escape-sequences-in-single-quoted-string-constants + fn supports_snowflake_string_literal_escapes(&self) -> bool { + true + } + fn supports_within_after_array_aggregation(&self) -> bool { true } diff --git a/src/tokenizer.rs b/src/tokenizer.rs index d9f131f8fc..4b6fda3c29 100644 --- a/src/tokenizer.rs +++ b/src/tokenizer.rs @@ -2287,17 +2287,21 @@ impl<'a> Tokenizer<'a> { num_consecutive_quotes = 0; - if let Some(next) = chars.peek() { + if let Some(&next) = chars.peek() { if !self.unescape || (self.dialect.ignores_wildcard_escapes() - && (*next == '%' || *next == '_')) + && (next == '%' || next == '_')) { // In no-escape mode, the given query has to be saved completely // including backslashes. Similarly, with ignore_like_wildcard_escapes, // the backslash is not stripped. s.push(ch); - s.push(*next); + s.push(next); chars.next(); // consume next + } else if self.dialect.supports_snowflake_string_literal_escapes() { + let escape_loc = chars.location(); + chars.next(); // consume the escape indicator + s.push(self.unescape_snowflake_escape(next, chars, escape_loc)?); } else { let n = match next { '0' => '\0', @@ -2308,7 +2312,7 @@ impl<'a> Tokenizer<'a> { 'r' => '\r', 't' => '\t', 'Z' => '\u{1a}', - _ => *next, + _ => next, }; s.push(n); chars.next(); // consume next @@ -2331,6 +2335,172 @@ impl<'a> Tokenizer<'a> { self.tokenizer_error(error_loc, "Unterminated string literal") } + /// Decode a single Snowflake backslash escape (the backslash and the + /// `indicator` char have already been consumed). Octal `\ooo`, hex `\xhh` + /// and unicode `\uhhhh` each yield a Unicode code point; recognized control + /// letters map to their control character; every other letter collapses to + /// itself. Malformed `\x` / `\u` raise a tokenizer error, matching real + /// Snowflake. + fn unescape_snowflake_escape( + &self, + indicator: char, + chars: &mut State, + loc: Location, + ) -> Result { + match indicator { + 'x' => { + let mut digits = String::new(); + while digits.len() < 2 { + match chars.peek() { + Some(c) if c.is_ascii_hexdigit() => { + if let Some(c) = chars.next() { + digits.push(c); + } + } + _ => break, + } + } + if digits.len() < 2 { + return self.tokenizer_error( + loc, + format!( + "Invalid hex escape sequence '\\x{digits}'; should be exactly 2 digits." + ), + ); + } + self.code_point(&digits, 16, loc) + } + 'u' => self.unescape_unicode(chars, loc), + // Octal `\ooo`: up to three octal digits, but a third digit is only + // consumed when the running value stays within a single byte + // (<= 0xFF); otherwise it is left as a literal character. + '0'..='7' => { + let mut value = indicator.to_digit(8).unwrap_or(0); + // Second octal digit always fits in a byte (max 0o77 = 63). + if let Some(d) = chars.peek().and_then(|c| c.to_digit(8)) { + value = value * 8 + d; + chars.next(); + // Third digit only when the byte does not overflow. + if let Some(d) = chars.peek().and_then(|c| c.to_digit(8)) { + if value * 8 + d <= 0xFF { + value = value * 8 + d; + chars.next(); + } + } + } + char::from_u32(value).map_or_else( + || self.tokenizer_error(loc, "Invalid octal escape sequence."), + Ok, + ) + } + 'b' => Ok('\u{8}'), + 'f' => Ok('\u{c}'), + 'n' => Ok('\n'), + 'r' => Ok('\r'), + 't' => Ok('\t'), + other => Ok(other), + } + } + + fn code_point(&self, digits: &str, radix: u32, loc: Location) -> Result { + u32::from_str_radix(digits, radix) + .ok() + .and_then(char::from_u32) + .map_or_else(|| self.tokenizer_error(loc, "Invalid escape sequence."), Ok) + } + + /// Decode a `\uhhhh` escape (the `\u` has already been consumed). A high + /// surrogate must be followed by a `\uhhhh` low surrogate and combines with + /// it into an astral code point; an unpaired high or low surrogate is an + /// error, matching real Snowflake. + fn unescape_unicode(&self, chars: &mut State, loc: Location) -> Result { + let value = self.read_hex4(chars, loc)?; + if (0xD800..=0xDBFF).contains(&value) { + return match self.read_low_surrogate(chars) { + Some(low) => { + let cp = 0x1_0000 + ((value - 0xD800) << 10) + (low - 0xDC00); + char::from_u32(cp).map_or_else( + || self.tokenizer_error(loc, "Invalid escape sequence."), + Ok, + ) + } + None => self.tokenizer_error( + loc, + format!( + "Invalid Unicode string literal; high surrogate '\\u{value:04X}' \ + must be followed by a low surrogate ('\\uDC00'-'\\uDFFF')." + ), + ), + }; + } + if (0xDC00..=0xDFFF).contains(&value) { + return self.tokenizer_error( + loc, + format!( + "Invalid Unicode string literal; low surrogate '\\u{value:04X}' \ + must be preceded by a high surrogate ('\\uD800'-'\\uDBFF')." + ), + ); + } + char::from_u32(value) + .map_or_else(|| self.tokenizer_error(loc, "Invalid escape sequence."), Ok) + } + + /// Read exactly four hex digits as a `u32`, erroring like Snowflake when + /// fewer than four are present. + fn read_hex4(&self, chars: &mut State, loc: Location) -> Result { + let mut digits = String::new(); + while digits.len() < 4 { + match chars.peek() { + Some(c) if c.is_ascii_hexdigit() => { + if let Some(c) = chars.next() { + digits.push(c); + } + } + _ => break, + } + } + if digits.len() < 4 { + return self.tokenizer_error( + loc, + format!("Invalid unicode escape sequence '\\u{digits}'; should be exactly 4 digits."), + ); + } + u32::from_str_radix(&digits, 16) + .map_or_else(|_| self.tokenizer_error(loc, "Invalid unicode escape sequence."), Ok) + } + + /// Consume a trailing `\uhhhh` low surrogate, returning its value when + /// present and in range. Consumes greedily on the error path — the caller + /// aborts the whole token, so partial consumption is irrelevant. + fn read_low_surrogate(&self, chars: &mut State) -> Option { + if chars.peek() != Some(&'\\') { + return None; + } + chars.next(); + if chars.peek() != Some(&'u') { + return None; + } + chars.next(); + let mut digits = String::new(); + while digits.len() < 4 { + match chars.peek() { + Some(c) if c.is_ascii_hexdigit() => { + if let Some(c) = chars.next() { + digits.push(c); + } + } + _ => break, + } + } + if digits.len() < 4 { + return None; + } + u32::from_str_radix(&digits, 16) + .ok() + .filter(|low| (0xDC00..=0xDFFF).contains(low)) + } + fn tokenize_multiline_comment( &self, chars: &mut State, @@ -3878,10 +4048,13 @@ mod tests { (r#"'%a\'%b'"#, r#"%a\'%b"#, r#"%a'%b"#), (r#"'a\'\'b\'c\'d'"#, r#"a\'\'b\'c\'d"#, r#"a''b'c'd"#), (r#"'\\'"#, r#"\\"#, r#"\"#), + // Snowflake keeps `\0` as NUL, maps the control-letter escapes, and + // collapses unrecognized letters (`\a` -> a, `\Z` -> Z) to the bare + // letter rather than a control character. ( r#"'\0\a\b\f\n\r\t\Z'"#, r#"\0\a\b\f\n\r\t\Z"#, - "\0\u{7}\u{8}\u{c}\n\r\t\u{1a}", + "\0a\u{8}\u{c}\n\r\tZ", ), (r#"'\"'"#, r#"\""#, "\""), (r#"'\\a\\b\'c'"#, r#"\\a\\b\'c"#, r#"\a\b'c"#), @@ -3890,6 +4063,20 @@ mod tests { (r#"'\q'"#, r#"\q"#, r#"q"#), (r#"'\%\_'"#, r#"\%\_"#, r#"%_"#), (r#"'\\%\\_'"#, r#"\\%\\_"#, r#"\%\_"#), + // Hex `\xhh` and unicode `\uhhhh` yield a code point; octal `\ooo` + // takes a third digit only when the byte does not overflow. + (r#"'\x41'"#, r#"\x41"#, "A"), + (r#"'\u0041'"#, r#"\u0041"#, "A"), + (r#"'\u00e9'"#, r#"\u00e9"#, "\u{e9}"), + (r#"'\ud83d\ude00'"#, r#"\ud83d\ude00"#, "\u{1f600}"), + (r#"'\xff'"#, r#"\xff"#, "\u{ff}"), + (r#"'A'"#, r#"A"#, "A"), + (r#"'é'"#, r#"é"#, "\u{e9}"), + (r#"'ꯍ'"#, r#"ꯍ"#, "\u{abcd}"), + (r#"'\101'"#, r#"\101"#, "A"), + (r#"'\1012'"#, r#"\1012"#, "A2"), + (r#"'\777'"#, r#"\777"#, "\u{3f}7"), + (r#"'\400'"#, r#"\400"#, "\u{20}0"), ] { let tokens = Tokenizer::new(&dialect, sql) .with_unescape(false) diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 0b8fbe84a7..7761701859 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -11283,7 +11283,11 @@ fn parse_escaped_string_with_unescape() { let escaping_dialects = &all_dialects_where(|dialect| dialect.supports_string_literal_backslash_escape()); let no_wildcard_exception = &all_dialects_where(|dialect| { - dialect.supports_string_literal_backslash_escape() && !dialect.ignores_wildcard_escapes() + dialect.supports_string_literal_backslash_escape() + && !dialect.ignores_wildcard_escapes() + // Snowflake decodes `\Z` / `\a` as the bare letter, not a control + // character, so it is exercised separately. + && !dialect.supports_snowflake_string_literal_escapes() }); let with_wildcard_exception = &all_dialects_where(|dialect| { dialect.supports_string_literal_backslash_escape() && dialect.ignores_wildcard_escapes() From acb6baa3abcf20e899c1c4c4ed0a12894111d949 Mon Sep 17 00:00:00 2001 From: sabir-akhadov-localstack Date: Thu, 30 Jul 2026 10:59:28 +0200 Subject: [PATCH 57/59] Snowflake: parse ALTER TAG SET/UNSET MASKING POLICY --- src/ast/ddl.rs | 55 ++++++++++++++++++++++++ src/ast/mod.rs | 12 +++--- src/dialect/snowflake.rs | 36 +++++++++++++--- tests/sqlparser_snowflake.rs | 81 ++++++++++++++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 10 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 29dd293f4b..862ba89710 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -1413,6 +1413,61 @@ impl fmt::Display for AlterMaskingPolicyOperation { } } +/// An operation in an `ALTER TAG` statement. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AlterTagOperation { + /// `RENAME TO ` + RenameTo { + /// The new tag name. + new_name: ObjectName, + }, + /// `SET MASKING POLICY

[, MASKING POLICY

...] [FORCE]` + SetMaskingPolicy { + /// The masking policies to attach. + policies: Vec, + /// `FORCE` flag. + force: bool, + }, + /// `UNSET MASKING POLICY

[, MASKING POLICY

...]` + UnsetMaskingPolicy { + /// The masking policies to detach. + policies: Vec, + }, +} + +impl fmt::Display for AlterTagOperation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AlterTagOperation::RenameTo { new_name } => write!(f, "RENAME TO {new_name}"), + AlterTagOperation::SetMaskingPolicy { policies, force } => { + write!(f, "SET ")?; + for (i, p) in policies.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "MASKING POLICY {p}")?; + } + if *force { + write!(f, " FORCE")?; + } + Ok(()) + } + AlterTagOperation::UnsetMaskingPolicy { policies } => { + write!(f, "UNSET ")?; + for (i, p) in policies.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "MASKING POLICY {p}")?; + } + Ok(()) + } + } + } +} + impl fmt::Display for AlterColumnOperation { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 0523deb69d..e9735b4b7d 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -64,7 +64,7 @@ pub use self::dcl::{ pub use self::ddl::{ Alignment, AlterCollation, AlterCollationOperation, AlterColumnOperation, AlterConnectorOwner, AlterFunction, AlterFunctionAction, AlterFunctionKind, AlterFunctionOperation, - AlterMaskingPolicyOperation, + AlterMaskingPolicyOperation, AlterTagOperation, AlterIndexOperation, AlterProcedure, AlterProcedureOperation, AlterOperator, AlterOperatorClass, AlterOperatorClassOperation, AlterOperatorFamily, AlterOperatorFamilyOperation, AlterOperatorOperation, AlterPolicy, @@ -5276,14 +5276,16 @@ pub enum Statement { }, /// ```sql /// ALTER TAG [IF EXISTS] RENAME TO + /// ALTER TAG [IF EXISTS] SET MASKING POLICY

[, MASKING POLICY

...] [FORCE] + /// ALTER TAG [IF EXISTS] UNSET MASKING POLICY

[, MASKING POLICY

...] /// ``` AlterTag { /// `IF EXISTS` flag. if_exists: bool, /// Tag name. name: ObjectName, - /// New tag name. - new_name: ObjectName, + /// The operation to apply to the tag. + operation: AlterTagOperation, }, /// ```sql /// DROP TAG [IF EXISTS] @@ -7810,11 +7812,11 @@ impl fmt::Display for Statement { Statement::AlterTag { if_exists, name, - new_name, + operation, } => { write!( f, - "ALTER TAG {if_exists}{name} RENAME TO {new_name}", + "ALTER TAG {if_exists}{name} {operation}", if_exists = if *if_exists { "IF EXISTS " } else { "" }, ) } diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index fc4a541b69..fbf08fcafe 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -28,7 +28,7 @@ use crate::ast::helpers::stmt_data_loading::{ }; use crate::ast::{ AlterExternalVolumeOperation, AlterFileFormatOperation, AlterMaskingPolicyOperation, - AlterProcedure, AlterProcedureOperation, AlterStageOperation, AlterTable, + AlterProcedure, AlterProcedureOperation, AlterStageOperation, AlterTable, AlterTagOperation, AlterTableOperation, AlterTableType, CatalogRestAuthentication, CatalogRestConfig, CatalogSource, CatalogSyncNamespaceMode, CatalogTableFormat, ColumnOption, ColumnPolicy, ColumnPolicyProperty, ContactEntry, CopyIntoSnowflakeKind, CreateTable, CreateTableLikeKind, @@ -3257,16 +3257,42 @@ fn parse_create_tag(or_replace: bool, parser: &mut Parser) -> Result RENAME TO ` +/// Parse a comma-separated list of `MASKING POLICY ` items, where the +/// `MASKING POLICY` keywords are repeated before each policy name. +fn parse_masking_policy_list(parser: &mut Parser) -> Result, ParserError> { + let mut policies = Vec::new(); + loop { + parser.expect_keywords(&[Keyword::MASKING, Keyword::POLICY])?; + policies.push(parser.parse_object_name(false)?); + if !parser.consume_token(&Token::Comma) { + break; + } + } + Ok(policies) +} + +/// Parse `ALTER TAG [IF EXISTS] { RENAME TO +/// | SET MASKING POLICY

[, MASKING POLICY

...] [FORCE] +/// | UNSET MASKING POLICY

[, MASKING POLICY

...] }` fn parse_alter_tag(parser: &mut Parser) -> Result { let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); let name = parser.parse_object_name(false)?; - parser.expect_keywords(&[Keyword::RENAME, Keyword::TO])?; - let new_name = parser.parse_object_name(false)?; + let operation = if parser.parse_keyword(Keyword::SET) { + let policies = parse_masking_policy_list(parser)?; + let force = parser.parse_keyword(Keyword::FORCE); + AlterTagOperation::SetMaskingPolicy { policies, force } + } else if parser.parse_keyword(Keyword::UNSET) { + let policies = parse_masking_policy_list(parser)?; + AlterTagOperation::UnsetMaskingPolicy { policies } + } else { + parser.expect_keywords(&[Keyword::RENAME, Keyword::TO])?; + let new_name = parser.parse_object_name(false)?; + AlterTagOperation::RenameTo { new_name } + }; Ok(Statement::AlterTag { if_exists, name, - new_name, + operation, }) } diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index ad051b498b..7b476b7fe3 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -9164,3 +9164,84 @@ fn parse_snowflake_create_view_column_masking_policy() { other => panic!("expected CreateView, got {other:?}"), } } + +#[test] +fn parse_snowflake_alter_tag_rename() { + match snowflake().verified_stmt("ALTER TAG IF EXISTS t RENAME TO u") { + Statement::AlterTag { + if_exists, + name, + operation, + } => { + assert!(if_exists); + assert_eq!("t", name.to_string()); + assert_eq!( + AlterTagOperation::RenameTo { + new_name: ObjectName::from(vec![Ident::new("u")]), + }, + operation + ); + } + other => panic!("expected AlterTag, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_alter_tag_set_masking_policy() { + match snowflake().verified_stmt("ALTER TAG t SET MASKING POLICY p") { + Statement::AlterTag { + if_exists, + operation, + .. + } => { + assert!(!if_exists); + assert_eq!( + AlterTagOperation::SetMaskingPolicy { + policies: vec![ObjectName::from(vec![Ident::new("p")])], + force: false, + }, + operation + ); + } + other => panic!("expected AlterTag, got {other:?}"), + } + + match snowflake() + .verified_stmt("ALTER TAG IF EXISTS t SET MASKING POLICY p, MASKING POLICY q FORCE") + { + Statement::AlterTag { + if_exists, + operation, + .. + } => { + assert!(if_exists); + assert_eq!( + AlterTagOperation::SetMaskingPolicy { + policies: vec![ + ObjectName::from(vec![Ident::new("p")]), + ObjectName::from(vec![Ident::new("q")]), + ], + force: true, + }, + operation + ); + } + other => panic!("expected AlterTag, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_alter_tag_unset_masking_policy() { + match snowflake().verified_stmt("ALTER TAG t UNSET MASKING POLICY p, MASKING POLICY q") { + Statement::AlterTag { operation, .. } => assert_eq!( + AlterTagOperation::UnsetMaskingPolicy { + policies: vec![ + ObjectName::from(vec![Ident::new("p")]), + ObjectName::from(vec![Ident::new("q")]), + ], + }, + operation + ), + other => panic!("expected AlterTag, got {other:?}"), + } +} From cbd33aa11255535423d4f300e56335f2c565cf53 Mon Sep 17 00:00:00 2001 From: Przemyslaw Denkiewicz Date: Thu, 30 Jul 2026 19:22:10 +0200 Subject: [PATCH 58/59] Snowflake: parse DESC TABLE TYPE = { COLUMNS | STAGE } Co-Authored-By: Claude Opus 4.8 --- src/ast/mod.rs | 30 ++++++++++++++++++++++- src/parser/mod.rs | 19 +++++++++++++++ tests/sqlparser_snowflake.rs | 46 ++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index e9735b4b7d..499cb96ab1 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -5683,6 +5683,8 @@ pub enum Statement { /// The object name (may be qualified: db.schema.table) #[cfg_attr(feature = "visitor", visit(with = "visit_relation"))] object_name: ObjectName, + /// Optional `TYPE = { COLUMNS | STAGE }` modifier (Snowflake `DESC TABLE`). + table_type: Option, }, /// ```sql /// DESC | DESCRIBE RESULT @@ -6252,8 +6254,13 @@ impl fmt::Display for Statement { describe_alias, object_type, object_name, + table_type, } => { - write!(f, "{describe_alias} {object_type} {object_name}") + write!(f, "{describe_alias} {object_type} {object_name}")?; + if let Some(table_type) = table_type { + write!(f, " TYPE = {table_type}")?; + } + Ok(()) } Statement::DescribeResult { describe_alias, @@ -10786,6 +10793,27 @@ impl fmt::Display for DescribeObjectType { } } +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +/// `TYPE = { COLUMNS | STAGE }` modifier of a Snowflake `DESC TABLE` statement. +/// +pub enum DescribeTableType { + /// `TYPE = COLUMNS` (the default): describe the table's columns. + Columns, + /// `TYPE = STAGE`: describe the table's implicit internal stage properties. + Stage, +} + +impl fmt::Display for DescribeTableType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + DescribeTableType::Columns => "COLUMNS", + DescribeTableType::Stage => "STAGE", + }) + } +} + #[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 7ff5ac2903..469eeca419 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -15096,6 +15096,7 @@ impl<'a> Parser<'a> { describe_alias, object_type: DescribeObjectType::DynamicTable, object_name, + table_type: None, }); } if self.parse_keywords(&[Keyword::MATERIALIZED, Keyword::VIEW]) { @@ -15104,6 +15105,7 @@ impl<'a> Parser<'a> { describe_alias, object_type: DescribeObjectType::MaterializedView, object_name, + table_type: None, }); } // `DESCRIBE TABLE STREAM ` is not valid Snowflake @@ -15135,10 +15137,27 @@ impl<'a> Parser<'a> { _ => return self.expected("a describe object type", self.peek_token()), }; let object_name = self.parse_object_name(false)?; + // Snowflake `DESC TABLE [ TYPE = { COLUMNS | STAGE } ]`. + // The modifier is only valid for TABLE; COLUMNS is the default. + let table_type = if object_type == DescribeObjectType::Table + && self.parse_keyword(Keyword::TYPE) + { + self.expect_token(&Token::Eq)?; + match self.parse_one_of_keywords(&[Keyword::COLUMNS, Keyword::STAGE]) { + Some(Keyword::COLUMNS) => Some(DescribeTableType::Columns), + Some(Keyword::STAGE) => Some(DescribeTableType::Stage), + _ => { + return self.expected("COLUMNS or STAGE", self.peek_token()) + } + } + } else { + None + }; return Ok(Statement::DescribeObject { describe_alias, object_type, object_name, + table_type, }); } } diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 7b476b7fe3..930fb29597 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -7684,6 +7684,52 @@ fn test_desc_task() { } } +#[test] +fn test_desc_table_type_columns() { + for sql in ["DESC TABLE foo TYPE = COLUMNS", "DESCRIBE TABLE foo TYPE = COLUMNS"] { + match snowflake().verified_stmt(sql) { + Statement::DescribeObject { + object_type, + object_name, + table_type, + .. + } => { + assert_eq!(DescribeObjectType::Table, object_type); + assert_eq!("foo", object_name.to_string()); + assert_eq!(Some(DescribeTableType::Columns), table_type); + } + _ => unreachable!(), + } + } +} + +#[test] +fn test_desc_table_type_stage() { + match snowflake().verified_stmt("DESC TABLE foo TYPE = STAGE") { + Statement::DescribeObject { + object_type, + object_name, + table_type, + .. + } => { + assert_eq!(DescribeObjectType::Table, object_type); + assert_eq!("foo", object_name.to_string()); + assert_eq!(Some(DescribeTableType::Stage), table_type); + } + _ => unreachable!(), + } +} + +#[test] +fn test_desc_table_no_type_clause() { + match snowflake().verified_stmt("DESC TABLE foo") { + Statement::DescribeObject { table_type, .. } => { + assert_eq!(None, table_type); + } + _ => unreachable!(), + } +} + #[test] fn test_show_tasks() { let sql = "SHOW TASKS"; From b40e5844b59587eadd664edc2076717f2067890f Mon Sep 17 00:00:00 2001 From: sabir-akhadov-localstack Date: Thu, 30 Jul 2026 21:39:26 +0200 Subject: [PATCH 59/59] Snowflake: parse UNDROP Add first-class UNDROP grammar for the statement family. VIEW parses so the consumer can reject it with Snowflake's unsupported-feature error rather than a parse error. Co-Authored-By: Claude Opus 4.8 --- src/ast/mod.rs | 15 ++++++++++++++ src/ast/spans.rs | 1 + src/keywords.rs | 1 + src/parser/mod.rs | 28 ++++++++++++++++++++++++++ tests/sqlparser_snowflake.rs | 39 ++++++++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 499cb96ab1..5d5a9d38c3 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -4305,6 +4305,18 @@ pub enum Statement { table: Option, }, /// ```sql + /// UNDROP

+ /// ``` + /// Snowflake: restore the most recently dropped object of the given name + /// within the Time Travel retention window. + /// + Undrop { + /// The type of object to restore. + object_type: ObjectType, + /// The name of the object to restore. + name: ObjectName, + }, + /// ```sql /// DROP FUNCTION /// ``` DropFunction(DropFunction), @@ -6921,6 +6933,9 @@ impl fmt::Display for Statement { }; Ok(()) } + Statement::Undrop { object_type, name } => { + write!(f, "UNDROP {object_type} {name}") + } Statement::DropFunction(drop_function) => write!(f, "{drop_function}"), Statement::DropDomain(DropDomain { if_exists, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index a25c784f07..ca1bd2484a 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -442,6 +442,7 @@ impl Spanned for Statement { Statement::AttachDuckDBDatabase { .. } => Span::empty(), Statement::DetachDuckDBDatabase { .. } => Span::empty(), Statement::Drop { .. } => Span::empty(), + Statement::Undrop { .. } => Span::empty(), Statement::DropFunction(drop_function) => drop_function.span(), Statement::DropDomain { .. } => Span::empty(), Statement::DropProcedure { .. } => Span::empty(), diff --git a/src/keywords.rs b/src/keywords.rs index a8c51e223b..74fc5d4b6d 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -1174,6 +1174,7 @@ define_keywords!( UNCACHE, UNCOMMITTED, UNDEFINED, + UNDROP, UNFREEZE, UNION, UNIQUE, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 469eeca419..3e736d4df4 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -650,6 +650,7 @@ impl<'a> Parser<'a> { Keyword::CREATE => self.parse_create(), Keyword::CACHE => self.parse_cache_table(), Keyword::DROP => self.parse_drop(), + Keyword::UNDROP => self.parse_undrop(), Keyword::DISCARD => self.parse_discard(), Keyword::DECLARE => self.parse_declare(), Keyword::FETCH => self.parse_fetch_statement(), @@ -8056,6 +8057,33 @@ impl<'a> Parser<'a> { }) } + /// Parse a Snowflake `UNDROP ` statement. + /// + /// Grammar for the whole UNDROP family (`TABLE`, `DYNAMIC TABLE`, + /// `SCHEMA`, `DATABASE`) plus `VIEW`, which parses so it can be rejected + /// downstream with Snowflake's unsupported-feature error rather than a + /// parse error. + pub fn parse_undrop(&mut self) -> Result { + let object_type = if self.parse_keywords(&[Keyword::DYNAMIC, Keyword::TABLE]) { + ObjectType::DynamicTable + } else if self.parse_keyword(Keyword::TABLE) { + ObjectType::Table + } else if self.parse_keyword(Keyword::VIEW) { + ObjectType::View + } else if self.parse_keyword(Keyword::SCHEMA) { + ObjectType::Schema + } else if self.parse_keyword(Keyword::DATABASE) { + ObjectType::Database + } else { + return self.expected_ref( + "DATABASE, DYNAMIC TABLE, SCHEMA, TABLE or VIEW after UNDROP", + self.peek_token_ref(), + ); + }; + let name = self.parse_object_name(false)?; + Ok(Statement::Undrop { object_type, name }) + } + fn parse_optional_drop_behavior(&mut self) -> Option { match self.parse_one_of_keywords(&[Keyword::CASCADE, Keyword::RESTRICT]) { Some(Keyword::CASCADE) => Some(DropBehavior::Cascade), diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 930fb29597..4cea8d2b75 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -9291,3 +9291,42 @@ fn parse_snowflake_alter_tag_unset_masking_policy() { other => panic!("expected AlterTag, got {other:?}"), } } + +#[test] +fn parse_snowflake_undrop() { + for (sql, expected_type) in [ + ("UNDROP TABLE t", ObjectType::Table), + ("UNDROP DYNAMIC TABLE t", ObjectType::DynamicTable), + ("UNDROP SCHEMA s", ObjectType::Schema), + ("UNDROP DATABASE d", ObjectType::Database), + ("UNDROP VIEW v", ObjectType::View), + ] { + match snowflake().verified_stmt(sql) { + Statement::Undrop { object_type, name } => { + assert_eq!(object_type, expected_type); + assert_eq!(name, ObjectName::from(vec![Ident::new(sql.rsplit(' ').next().unwrap())])); + } + other => panic!("expected Undrop, got {other:?}"), + } + } +} + +#[test] +fn parse_snowflake_undrop_qualified_name() { + match snowflake().verified_stmt("UNDROP TABLE db.sch.t") { + Statement::Undrop { object_type, name } => { + assert_eq!(object_type, ObjectType::Table); + assert_eq!( + name, + ObjectName::from(vec![Ident::new("db"), Ident::new("sch"), Ident::new("t")]) + ); + } + other => panic!("expected Undrop, got {other:?}"), + } +} + +#[test] +fn parse_snowflake_undrop_missing_object_type() { + let res = snowflake().parse_sql_statements("UNDROP t"); + assert!(res.is_err(), "expected parse error, got {res:?}"); +}