From cd9c2b12993f42e891b8bf02a43db7557cdeffe7 Mon Sep 17 00:00:00 2001 From: Yoav Cohen <59807311+yoavcloud@users.noreply.github.com> Date: Mon, 21 Jul 2025 14:41:20 +0300 Subject: [PATCH 1/8] Snowflake: Support `CLONE` option in `CREATE DATABASE/SCHEMA` statements (#1958) --- src/ast/mod.rs | 24 ++++++++++++++++++++++++ src/parser/mod.rs | 14 ++++++++++++++ tests/sqlparser_common.rs | 25 +++++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index c2ec8c686..5820bd457 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -3846,6 +3846,14 @@ pub enum Statement { /// /// [BigQuery](https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#create_schema_statement) default_collate_spec: Option, + /// Clones a schema + /// + /// ```sql + /// CREATE SCHEMA myschema CLONE otherschema + /// ``` + /// + /// [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/create-clone#databases-schemas) + clone: Option, }, /// ```sql /// CREATE DATABASE @@ -3855,6 +3863,14 @@ pub enum Statement { if_not_exists: bool, location: Option, managed_location: Option, + /// Clones a database + /// + /// ```sql + /// CREATE DATABASE mydb CLONE otherdb + /// ``` + /// + /// [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/create-clone#databases-schemas) + clone: Option, }, /// ```sql /// CREATE FUNCTION @@ -4797,6 +4813,7 @@ impl fmt::Display for Statement { if_not_exists, location, managed_location, + clone, } => { write!(f, "CREATE DATABASE")?; if *if_not_exists { @@ -4809,6 +4826,9 @@ impl fmt::Display for Statement { if let Some(ml) = managed_location { write!(f, " MANAGEDLOCATION '{ml}'")?; } + if let Some(clone) = clone { + write!(f, " CLONE {clone}")?; + } Ok(()) } Statement::CreateFunction(create_function) => create_function.fmt(f), @@ -5730,6 +5750,7 @@ impl fmt::Display for Statement { with, options, default_collate_spec, + clone, } => { write!( f, @@ -5750,6 +5771,9 @@ impl fmt::Display for Statement { write!(f, " OPTIONS({})", display_comma_separated(options))?; } + if let Some(clone) = clone { + write!(f, " CLONE {clone}")?; + } Ok(()) } Statement::Assert { condition, message } => { diff --git a/src/parser/mod.rs b/src/parser/mod.rs index d96a3002e..9694b237e 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4900,12 +4900,19 @@ impl<'a> Parser<'a> { None }; + let clone = if self.parse_keyword(Keyword::CLONE) { + Some(self.parse_object_name(false)?) + } else { + None + }; + Ok(Statement::CreateSchema { schema_name, if_not_exists, with, options, default_collate_spec, + clone, }) } @@ -4940,11 +4947,18 @@ impl<'a> Parser<'a> { _ => break, } } + let clone = if self.parse_keyword(Keyword::CLONE) { + Some(self.parse_object_name(false)?) + } else { + None + }; + Ok(Statement::CreateDatabase { db_name, if_not_exists: ine, location, managed_location, + clone, }) } diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 32bc70e65..7d72d551e 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -4296,6 +4296,7 @@ fn parse_create_schema() { verified_stmt(r#"CREATE SCHEMA a.b.c WITH (key1 = 'value1', key2 = 'value2')"#); verified_stmt(r#"CREATE SCHEMA IF NOT EXISTS a WITH (key1 = 'value1')"#); verified_stmt(r#"CREATE SCHEMA IF NOT EXISTS a WITH ()"#); + verified_stmt(r#"CREATE SCHEMA a CLONE b"#); } #[test] @@ -7891,11 +7892,33 @@ fn parse_create_database() { if_not_exists, location, managed_location, + clone, } => { assert_eq!("mydb", db_name.to_string()); assert!(!if_not_exists); assert_eq!(None, location); assert_eq!(None, managed_location); + assert_eq!(None, clone); + } + _ => unreachable!(), + } + let sql = "CREATE DATABASE mydb CLONE otherdb"; + match verified_stmt(sql) { + Statement::CreateDatabase { + db_name, + if_not_exists, + location, + managed_location, + clone, + } => { + assert_eq!("mydb", db_name.to_string()); + assert!(!if_not_exists); + assert_eq!(None, location); + assert_eq!(None, managed_location); + assert_eq!( + Some(ObjectName::from(vec![Ident::new("otherdb".to_string())])), + clone + ); } _ => unreachable!(), } @@ -7910,11 +7933,13 @@ fn parse_create_database_ine() { if_not_exists, location, managed_location, + clone, } => { assert_eq!("mydb", db_name.to_string()); assert!(if_not_exists); assert_eq!(None, location); assert_eq!(None, managed_location); + assert_eq!(None, clone); } _ => unreachable!(), } From 292df6ded232d63cc8da78d75262fd5abdc0bec8 Mon Sep 17 00:00:00 2001 From: Artem Osipov <59066880+osipovartem@users.noreply.github.com> Date: Fri, 1 Aug 2025 14:56:21 +0300 Subject: [PATCH 2/8] Snowflake create database (#1939) --- src/ast/helpers/mod.rs | 1 + src/ast/helpers/stmt_create_database.rs | 324 ++++++++++++++++++++++++ src/ast/mod.rs | 147 ++++++++++- src/dialect/snowflake.rs | 148 +++++++++-- src/keywords.rs | 6 + src/parser/mod.rs | 24 ++ tests/sqlparser_common.rs | 3 + tests/sqlparser_snowflake.rs | 51 ++++ 8 files changed, 666 insertions(+), 38 deletions(-) create mode 100644 src/ast/helpers/stmt_create_database.rs diff --git a/src/ast/helpers/mod.rs b/src/ast/helpers/mod.rs index 55831220d..3efbcf7b0 100644 --- a/src/ast/helpers/mod.rs +++ b/src/ast/helpers/mod.rs @@ -16,5 +16,6 @@ // under the License. pub mod attached_token; pub mod key_value_options; +pub mod stmt_create_database; pub mod stmt_create_table; pub mod stmt_data_loading; diff --git a/src/ast/helpers/stmt_create_database.rs b/src/ast/helpers/stmt_create_database.rs new file mode 100644 index 000000000..94997bfa5 --- /dev/null +++ b/src/ast/helpers/stmt_create_database.rs @@ -0,0 +1,324 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +#[cfg(not(feature = "std"))] +use alloc::{boxed::Box, format, string::String, vec, vec::Vec}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "visitor")] +use sqlparser_derive::{Visit, VisitMut}; + +use crate::ast::{ + CatalogSyncNamespaceMode, ContactEntry, ObjectName, Statement, StorageSerializationPolicy, Tag, +}; +use crate::parser::ParserError; + +/// Builder for create database statement variant ([1]). +/// +/// This structure helps building and accessing a create database with more ease, without needing to: +/// - Match the enum itself a lot of times; or +/// - Moving a lot of variables around the code. +/// +/// # Example +/// ```rust +/// use sqlparser::ast::helpers::stmt_create_database::CreateDatabaseBuilder; +/// use sqlparser::ast::{ColumnDef, Ident, ObjectName}; +/// let builder = CreateDatabaseBuilder::new(ObjectName::from(vec![Ident::new("database_name")])) +/// .if_not_exists(true); +/// // You can access internal elements with ease +/// assert!(builder.if_not_exists); +/// // Convert to a statement +/// assert_eq!( +/// builder.build().to_string(), +/// "CREATE DATABASE IF NOT EXISTS database_name" +/// ) +/// ``` +/// +/// [1]: Statement::CreateDatabase +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreateDatabaseBuilder { + pub db_name: ObjectName, + pub if_not_exists: bool, + pub location: Option, + pub managed_location: Option, + pub or_replace: bool, + pub transient: bool, + pub clone: Option, + pub data_retention_time_in_days: Option, + pub max_data_extension_time_in_days: Option, + pub external_volume: Option, + pub catalog: Option, + pub replace_invalid_characters: Option, + pub default_ddl_collation: Option, + pub storage_serialization_policy: Option, + pub comment: Option, + pub catalog_sync: Option, + pub catalog_sync_namespace_mode: Option, + pub catalog_sync_namespace_flatten_delimiter: Option, + pub with_tags: Option>, + pub with_contacts: Option>, +} + +impl CreateDatabaseBuilder { + pub fn new(name: ObjectName) -> Self { + Self { + db_name: name, + if_not_exists: false, + location: None, + managed_location: None, + or_replace: false, + transient: false, + clone: None, + data_retention_time_in_days: None, + max_data_extension_time_in_days: None, + external_volume: None, + catalog: None, + replace_invalid_characters: None, + default_ddl_collation: None, + storage_serialization_policy: None, + comment: None, + catalog_sync: None, + catalog_sync_namespace_mode: None, + catalog_sync_namespace_flatten_delimiter: None, + with_tags: None, + with_contacts: None, + } + } + + pub fn location(mut self, location: Option) -> Self { + self.location = location; + self + } + + pub fn managed_location(mut self, managed_location: Option) -> Self { + self.managed_location = managed_location; + self + } + + pub fn or_replace(mut self, or_replace: bool) -> Self { + self.or_replace = or_replace; + self + } + + pub fn transient(mut self, transient: bool) -> Self { + self.transient = transient; + self + } + + pub fn if_not_exists(mut self, if_not_exists: bool) -> Self { + self.if_not_exists = if_not_exists; + self + } + + pub fn clone_clause(mut self, clone: Option) -> Self { + self.clone = clone; + self + } + + 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; + self + } + + pub fn max_data_extension_time_in_days( + mut self, + max_data_extension_time_in_days: Option, + ) -> Self { + self.max_data_extension_time_in_days = max_data_extension_time_in_days; + self + } + + pub fn external_volume(mut self, external_volume: Option) -> Self { + self.external_volume = external_volume; + self + } + + pub fn catalog(mut self, catalog: Option) -> Self { + self.catalog = catalog; + self + } + + pub fn replace_invalid_characters(mut self, replace_invalid_characters: Option) -> Self { + self.replace_invalid_characters = replace_invalid_characters; + self + } + + pub fn default_ddl_collation(mut self, default_ddl_collation: Option) -> Self { + self.default_ddl_collation = default_ddl_collation; + self + } + + pub fn storage_serialization_policy( + mut self, + storage_serialization_policy: Option, + ) -> Self { + self.storage_serialization_policy = storage_serialization_policy; + self + } + + pub fn comment(mut self, comment: Option) -> Self { + self.comment = comment; + self + } + + pub fn catalog_sync(mut self, catalog_sync: Option) -> Self { + self.catalog_sync = catalog_sync; + self + } + + pub fn catalog_sync_namespace_mode( + mut self, + catalog_sync_namespace_mode: Option, + ) -> Self { + self.catalog_sync_namespace_mode = catalog_sync_namespace_mode; + self + } + + pub fn catalog_sync_namespace_flatten_delimiter( + mut self, + catalog_sync_namespace_flatten_delimiter: Option, + ) -> Self { + self.catalog_sync_namespace_flatten_delimiter = catalog_sync_namespace_flatten_delimiter; + self + } + + pub fn with_tags(mut self, with_tags: Option>) -> Self { + self.with_tags = with_tags; + self + } + + pub fn with_contacts(mut self, with_contacts: Option>) -> Self { + self.with_contacts = with_contacts; + self + } + + pub fn build(self) -> Statement { + Statement::CreateDatabase { + db_name: self.db_name, + if_not_exists: self.if_not_exists, + managed_location: self.managed_location, + location: self.location, + or_replace: self.or_replace, + transient: self.transient, + clone: self.clone, + data_retention_time_in_days: self.data_retention_time_in_days, + max_data_extension_time_in_days: self.max_data_extension_time_in_days, + external_volume: self.external_volume, + catalog: self.catalog, + replace_invalid_characters: self.replace_invalid_characters, + default_ddl_collation: self.default_ddl_collation, + storage_serialization_policy: self.storage_serialization_policy, + comment: self.comment, + catalog_sync: self.catalog_sync, + catalog_sync_namespace_mode: self.catalog_sync_namespace_mode, + catalog_sync_namespace_flatten_delimiter: self.catalog_sync_namespace_flatten_delimiter, + with_tags: self.with_tags, + with_contacts: self.with_contacts, + } + } +} + +impl TryFrom for CreateDatabaseBuilder { + type Error = ParserError; + + fn try_from(stmt: Statement) -> Result { + match stmt { + Statement::CreateDatabase { + db_name, + if_not_exists, + location, + managed_location, + or_replace, + transient, + clone, + data_retention_time_in_days, + max_data_extension_time_in_days, + external_volume, + catalog, + replace_invalid_characters, + default_ddl_collation, + storage_serialization_policy, + comment, + catalog_sync, + catalog_sync_namespace_mode, + catalog_sync_namespace_flatten_delimiter, + with_tags, + with_contacts, + } => Ok(Self { + db_name, + if_not_exists, + location, + managed_location, + or_replace, + transient, + clone, + data_retention_time_in_days, + max_data_extension_time_in_days, + external_volume, + catalog, + replace_invalid_characters, + default_ddl_collation, + storage_serialization_policy, + comment, + catalog_sync, + catalog_sync_namespace_mode, + catalog_sync_namespace_flatten_delimiter, + with_tags, + with_contacts, + }), + _ => Err(ParserError::ParserError(format!( + "Expected create database statement, but received: {stmt}" + ))), + } + } +} + +#[cfg(test)] +mod tests { + use crate::ast::helpers::stmt_create_database::CreateDatabaseBuilder; + use crate::ast::{Ident, ObjectName, Statement}; + use crate::parser::ParserError; + + #[test] + pub fn test_from_valid_statement() { + let builder = CreateDatabaseBuilder::new(ObjectName::from(vec![Ident::new("db_name")])); + + let stmt = builder.clone().build(); + + assert_eq!(builder, CreateDatabaseBuilder::try_from(stmt).unwrap()); + } + + #[test] + pub fn test_from_invalid_statement() { + let stmt = Statement::Commit { + chain: false, + end: false, + modifier: None, + }; + + assert_eq!( + CreateDatabaseBuilder::try_from(stmt).unwrap_err(), + ParserError::ParserError( + "Expected create database statement, but received: COMMIT".to_owned() + ) + ); + } +} diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 5820bd457..2ef88ace8 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -3858,19 +3858,29 @@ pub enum Statement { /// ```sql /// CREATE DATABASE /// ``` + /// See: + /// CreateDatabase { db_name: ObjectName, if_not_exists: bool, location: Option, managed_location: Option, - /// Clones a database - /// - /// ```sql - /// CREATE DATABASE mydb CLONE otherdb - /// ``` - /// - /// [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/create-clone#databases-schemas) + or_replace: bool, + transient: bool, clone: Option, + data_retention_time_in_days: Option, + max_data_extension_time_in_days: Option, + external_volume: Option, + catalog: Option, + replace_invalid_characters: Option, + default_ddl_collation: Option, + storage_serialization_policy: Option, + comment: Option, + catalog_sync: Option, + catalog_sync_namespace_mode: Option, + catalog_sync_namespace_flatten_delimiter: Option, + with_tags: Option>, + with_contacts: Option>, }, /// ```sql /// CREATE FUNCTION @@ -4813,13 +4823,32 @@ impl fmt::Display for Statement { if_not_exists, location, managed_location, + or_replace, + transient, clone, + data_retention_time_in_days, + max_data_extension_time_in_days, + external_volume, + catalog, + replace_invalid_characters, + default_ddl_collation, + storage_serialization_policy, + comment, + catalog_sync, + catalog_sync_namespace_mode, + catalog_sync_namespace_flatten_delimiter, + with_tags, + with_contacts, } => { - write!(f, "CREATE DATABASE")?; - if *if_not_exists { - write!(f, " IF NOT EXISTS")?; - } - write!(f, " {db_name}")?; + write!( + f, + "CREATE {or_replace}{transient}DATABASE {if_not_exists}{name}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + transient = if *transient { "TRANSIENT " } else { "" }, + if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, + name = db_name, + )?; + if let Some(l) = location { write!(f, " LOCATION '{l}'")?; } @@ -4829,6 +4858,60 @@ impl fmt::Display for Statement { if let Some(clone) = clone { write!(f, " CLONE {clone}")?; } + + if let Some(value) = data_retention_time_in_days { + write!(f, " DATA_RETENTION_TIME_IN_DAYS = {value}")?; + } + + if let Some(value) = max_data_extension_time_in_days { + write!(f, " MAX_DATA_EXTENSION_TIME_IN_DAYS = {value}")?; + } + + if let Some(vol) = external_volume { + write!(f, " EXTERNAL_VOLUME = '{vol}'")?; + } + + if let Some(cat) = catalog { + write!(f, " CATALOG = '{cat}'")?; + } + + if let Some(true) = replace_invalid_characters { + write!(f, " REPLACE_INVALID_CHARACTERS = TRUE")?; + } else if let Some(false) = replace_invalid_characters { + write!(f, " REPLACE_INVALID_CHARACTERS = FALSE")?; + } + + if let Some(collation) = default_ddl_collation { + write!(f, " DEFAULT_DDL_COLLATION = '{collation}'")?; + } + + if let Some(policy) = storage_serialization_policy { + write!(f, " STORAGE_SERIALIZATION_POLICY = {policy}")?; + } + + if let Some(comment) = comment { + write!(f, " COMMENT = '{comment}'")?; + } + + if let Some(sync) = catalog_sync { + write!(f, " CATALOG_SYNC = '{sync}'")?; + } + + if let Some(mode) = catalog_sync_namespace_mode { + write!(f, " CATALOG_SYNC_NAMESPACE_MODE = {mode}")?; + } + + if let Some(delim) = catalog_sync_namespace_flatten_delimiter { + write!(f, " CATALOG_SYNC_NAMESPACE_FLATTEN_DELIMITER = '{delim}'")?; + } + + if let Some(tags) = with_tags { + write!(f, " WITH TAG ({})", display_comma_separated(tags))?; + } + + if let Some(contacts) = with_contacts { + write!(f, " WITH CONTACT ({})", display_comma_separated(contacts))?; + } Ok(()) } Statement::CreateFunction(create_function) => create_function.fmt(f), @@ -9564,6 +9647,23 @@ impl Display for Tag { } } +/// Snowflake `WITH CONTACT ( purpose = contact [ , purpose = contact ...] )` +/// +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct ContactEntry { + pub purpose: String, + pub contact: String, +} + +impl Display for ContactEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} = {}", self.purpose, self.contact) + } +} + /// Helper to indicate if a comment includes the `=` in the display form #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -9990,6 +10090,29 @@ impl Display for StorageSerializationPolicy { } } +/// Snowflake CatalogSyncNamespaceMode +/// ```sql +/// [ CATALOG_SYNC_NAMESPACE_MODE = { NEST | FLATTEN } ] +/// ``` +/// +/// +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum CatalogSyncNamespaceMode { + Nest, + Flatten, +} + +impl Display for CatalogSyncNamespaceMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CatalogSyncNamespaceMode::Nest => write!(f, "NEST"), + CatalogSyncNamespaceMode::Flatten => write!(f, "FLATTEN"), + } + } +} + /// Variants of the Snowflake `COPY INTO` statement #[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index baf99b84b..7269a7b4c 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -17,16 +17,20 @@ #[cfg(not(feature = "std"))] use crate::alloc::string::ToString; -use crate::ast::helpers::key_value_options::{KeyValueOption, KeyValueOptionType, KeyValueOptions}; +use crate::ast::helpers::key_value_options::{ + KeyValueOption, KeyValueOptionType, KeyValueOptions, +}; +use crate::ast::helpers::stmt_create_database::CreateDatabaseBuilder; use crate::ast::helpers::stmt_create_table::CreateTableBuilder; use crate::ast::helpers::stmt_data_loading::{ FileStagingCommand, StageLoadSelectItem, StageLoadSelectItemKind, StageParamsObject, }; use crate::ast::{ - ColumnOption, ColumnPolicy, ColumnPolicyProperty, CopyIntoSnowflakeKind, DollarQuotedString, - Ident, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, - IdentityPropertyOrder, ObjectName, ObjectNamePart, RowAccessPolicy, ShowObjects, SqlOption, - Statement, TagsColumnOption, WrappedCollection, + CatalogSyncNamespaceMode, ColumnOption, ColumnPolicy, ColumnPolicyProperty, ContactEntry, + CopyIntoSnowflakeKind, DollarQuotedString, Ident, IdentityParameters, IdentityProperty, + IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, ObjectName, + ObjectNamePart, RowAccessPolicy, ShowObjects, SqlOption, Statement, StorageSerializationPolicy, + TagsColumnOption, WrappedCollection, }; use crate::dialect::{Dialect, Precedence}; use crate::keywords::Keyword; @@ -42,7 +46,6 @@ use alloc::vec::Vec; use alloc::{format, vec}; use super::keywords::RESERVED_FOR_IDENTIFIER; -use sqlparser::ast::StorageSerializationPolicy; const RESERVED_KEYWORDS_FOR_SELECT_ITEM_OPERATOR: [Keyword; 1] = [Keyword::CONNECT_BY_ROOT]; /// A [`Dialect`] for [Snowflake](https://www.snowflake.com/) @@ -182,6 +185,8 @@ impl Dialect for SnowflakeDialect { return Some(parse_create_table( or_replace, global, temporary, volatile, transient, iceberg, parser, )); + } else if parser.parse_keyword(Keyword::DATABASE) { + return Some(parse_create_database(or_replace, transient, parser)); } else { // need to go back with the cursor let mut back = 1; @@ -592,29 +597,11 @@ pub fn parse_create_table( } Keyword::ENABLE_SCHEMA_EVOLUTION => { parser.expect_token(&Token::Eq)?; - let enable_schema_evolution = - match parser.parse_one_of_keywords(&[Keyword::TRUE, Keyword::FALSE]) { - Some(Keyword::TRUE) => true, - Some(Keyword::FALSE) => false, - _ => { - return parser.expected("TRUE or FALSE", next_token); - } - }; - - builder = builder.enable_schema_evolution(Some(enable_schema_evolution)); + builder = builder.enable_schema_evolution(Some(parser.parse_boolean_string()?)); } Keyword::CHANGE_TRACKING => { parser.expect_token(&Token::Eq)?; - let change_tracking = - match parser.parse_one_of_keywords(&[Keyword::TRUE, Keyword::FALSE]) { - Some(Keyword::TRUE) => true, - Some(Keyword::FALSE) => false, - _ => { - return parser.expected("TRUE or FALSE", next_token); - } - }; - - builder = builder.change_tracking(Some(change_tracking)); + builder = builder.change_tracking(Some(parser.parse_boolean_string()?)); } Keyword::DATA_RETENTION_TIME_IN_DAYS => { parser.expect_token(&Token::Eq)?; @@ -744,6 +731,115 @@ pub fn parse_create_table( Ok(builder.build()) } +/// Parse snowflake create database statement. +/// +pub fn parse_create_database( + or_replace: bool, + transient: 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 builder = CreateDatabaseBuilder::new(name) + .or_replace(or_replace) + .transient(transient) + .if_not_exists(if_not_exists); + + loop { + let next_token = parser.next_token(); + match &next_token.token { + Token::Word(word) => match word.keyword { + Keyword::CLONE => { + builder = builder.clone_clause(Some(parser.parse_object_name(false)?)); + } + Keyword::DATA_RETENTION_TIME_IN_DAYS => { + parser.expect_token(&Token::Eq)?; + builder = + builder.data_retention_time_in_days(Some(parser.parse_literal_uint()?)); + } + Keyword::MAX_DATA_EXTENSION_TIME_IN_DAYS => { + parser.expect_token(&Token::Eq)?; + builder = + builder.max_data_extension_time_in_days(Some(parser.parse_literal_uint()?)); + } + Keyword::EXTERNAL_VOLUME => { + parser.expect_token(&Token::Eq)?; + builder = builder.external_volume(Some(parser.parse_literal_string()?)); + } + Keyword::CATALOG => { + parser.expect_token(&Token::Eq)?; + builder = builder.catalog(Some(parser.parse_literal_string()?)); + } + Keyword::REPLACE_INVALID_CHARACTERS => { + parser.expect_token(&Token::Eq)?; + builder = + builder.replace_invalid_characters(Some(parser.parse_boolean_string()?)); + } + Keyword::DEFAULT_DDL_COLLATION => { + parser.expect_token(&Token::Eq)?; + builder = builder.default_ddl_collation(Some(parser.parse_literal_string()?)); + } + Keyword::STORAGE_SERIALIZATION_POLICY => { + parser.expect_token(&Token::Eq)?; + let policy = parse_storage_serialization_policy(parser)?; + builder = builder.storage_serialization_policy(Some(policy)); + } + Keyword::COMMENT => { + parser.expect_token(&Token::Eq)?; + builder = builder.comment(Some(parser.parse_literal_string()?)); + } + Keyword::CATALOG_SYNC => { + parser.expect_token(&Token::Eq)?; + builder = builder.catalog_sync(Some(parser.parse_literal_string()?)); + } + Keyword::CATALOG_SYNC_NAMESPACE_FLATTEN_DELIMITER => { + parser.expect_token(&Token::Eq)?; + builder = builder.catalog_sync_namespace_flatten_delimiter(Some( + parser.parse_literal_string()?, + )); + } + Keyword::CATALOG_SYNC_NAMESPACE_MODE => { + parser.expect_token(&Token::Eq)?; + let mode = + match parser.parse_one_of_keywords(&[Keyword::NEST, Keyword::FLATTEN]) { + Some(Keyword::NEST) => CatalogSyncNamespaceMode::Nest, + Some(Keyword::FLATTEN) => CatalogSyncNamespaceMode::Flatten, + _ => { + return parser.expected("NEST or FLATTEN", next_token); + } + }; + builder = builder.catalog_sync_namespace_mode(Some(mode)); + } + Keyword::WITH => { + if parser.parse_keyword(Keyword::TAG) { + parser.expect_token(&Token::LParen)?; + let tags = parser.parse_comma_separated(Parser::parse_tag)?; + parser.expect_token(&Token::RParen)?; + builder = builder.with_tags(Some(tags)); + } else if parser.parse_keyword(Keyword::CONTACT) { + parser.expect_token(&Token::LParen)?; + let contacts = parser.parse_comma_separated(|p| { + let purpose = p.parse_identifier()?.value; + p.expect_token(&Token::Eq)?; + let contact = p.parse_identifier()?.value; + Ok(ContactEntry { purpose, contact }) + })?; + parser.expect_token(&Token::RParen)?; + builder = builder.with_contacts(Some(contacts)); + } else { + return parser.expected("TAG or CONTACT", next_token); + } + } + _ => return parser.expected("end of statement", next_token), + }, + Token::SemiColon | Token::EOF => break, + _ => return parser.expected("end of statement", next_token), + } + } + Ok(builder.build()) +} + pub fn parse_storage_serialization_policy( parser: &mut Parser, ) -> Result { diff --git a/src/keywords.rs b/src/keywords.rs index 9e689a6df..98a75625f 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -166,6 +166,8 @@ define_keywords!( CAST, CATALOG, CATALOG_SYNC, + CATALOG_SYNC_NAMESPACE_FLATTEN_DELIMITER, + CATALOG_SYNC_NAMESPACE_MODE, CATCH, CEIL, CEILING, @@ -213,6 +215,7 @@ define_keywords!( CONNECTOR, CONNECT_BY_ROOT, CONSTRAINT, + CONTACT, CONTAINS, CONTINUE, CONVERT, @@ -366,6 +369,7 @@ define_keywords!( FIRST, FIRST_VALUE, FIXEDSTRING, + FLATTEN, FLOAT, FLOAT32, FLOAT4, @@ -584,6 +588,7 @@ define_keywords!( NATURAL, NCHAR, NCLOB, + NEST, NESTED, NETWORK, NEW, @@ -755,6 +760,7 @@ define_keywords!( REPAIR, REPEATABLE, REPLACE, + REPLACE_INVALID_CHARACTERS, REPLICA, REPLICATE, REPLICATION, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9694b237e..4ace2d666 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4958,7 +4958,22 @@ impl<'a> Parser<'a> { if_not_exists: ine, location, managed_location, + or_replace: false, + transient: false, clone, + data_retention_time_in_days: None, + max_data_extension_time_in_days: None, + external_volume: None, + catalog: None, + replace_invalid_characters: None, + default_ddl_collation: None, + storage_serialization_policy: None, + comment: None, + catalog_sync: None, + catalog_sync_namespace_mode: None, + catalog_sync_namespace_flatten_delimiter: None, + with_tags: None, + with_contacts: None, }) } @@ -9598,6 +9613,15 @@ impl<'a> Parser<'a> { } } + /// Parse a boolean string + pub(crate) fn parse_boolean_string(&mut self) -> Result { + match self.parse_one_of_keywords(&[Keyword::TRUE, Keyword::FALSE]) { + Some(Keyword::TRUE) => Ok(true), + Some(Keyword::FALSE) => Ok(false), + _ => self.expected("TRUE or FALSE", self.peek_token()), + } + } + /// Parse a literal unicode normalization clause pub fn parse_unicode_is_normalized(&mut self, expr: Expr) -> Result { let neg = self.parse_keyword(Keyword::NOT); diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 7d72d551e..99ad3e8ed 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -7893,6 +7893,7 @@ fn parse_create_database() { location, managed_location, clone, + .. } => { assert_eq!("mydb", db_name.to_string()); assert!(!if_not_exists); @@ -7910,6 +7911,7 @@ fn parse_create_database() { location, managed_location, clone, + .. } => { assert_eq!("mydb", db_name.to_string()); assert!(!if_not_exists); @@ -7934,6 +7936,7 @@ fn parse_create_database_ine() { location, managed_location, clone, + .. } => { assert_eq!("mydb", db_name.to_string()); assert!(if_not_exists); diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index a7a633152..8991527a8 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -4501,3 +4501,54 @@ fn test_snowflake_identifier_function() { true ); } + +#[test] +fn test_create_database() { + snowflake().verified_stmt("CREATE DATABASE my_db"); + snowflake().verified_stmt("CREATE OR REPLACE DATABASE my_db"); + snowflake().verified_stmt("CREATE TRANSIENT DATABASE IF NOT EXISTS my_db"); + snowflake().verified_stmt("CREATE DATABASE my_db CLONE src_db"); + snowflake().verified_stmt( + "CREATE OR REPLACE DATABASE my_db CLONE src_db DATA_RETENTION_TIME_IN_DAYS = 1", + ); + snowflake().one_statement_parses_to( + r#" + CREATE OR REPLACE TRANSIENT DATABASE IF NOT EXISTS my_db + CLONE src_db + DATA_RETENTION_TIME_IN_DAYS = 1 + MAX_DATA_EXTENSION_TIME_IN_DAYS = 5 + EXTERNAL_VOLUME = 'volume1' + CATALOG = 'my_catalog' + REPLACE_INVALID_CHARACTERS = TRUE + DEFAULT_DDL_COLLATION = 'en-ci' + STORAGE_SERIALIZATION_POLICY = COMPATIBLE + COMMENT = 'This is my database' + CATALOG_SYNC = 'sync_integration' + CATALOG_SYNC_NAMESPACE_MODE = NEST + CATALOG_SYNC_NAMESPACE_FLATTEN_DELIMITER = '/' + WITH TAG (env = 'prod', team = 'data') + WITH CONTACT (owner = 'admin', dpo = 'compliance') + "#, + "CREATE OR REPLACE TRANSIENT DATABASE IF NOT EXISTS \ + my_db CLONE src_db DATA_RETENTION_TIME_IN_DAYS = 1 MAX_DATA_EXTENSION_TIME_IN_DAYS = 5 \ + EXTERNAL_VOLUME = 'volume1' CATALOG = 'my_catalog' \ + REPLACE_INVALID_CHARACTERS = TRUE DEFAULT_DDL_COLLATION = 'en-ci' \ + STORAGE_SERIALIZATION_POLICY = COMPATIBLE COMMENT = 'This is my database' \ + CATALOG_SYNC = 'sync_integration' CATALOG_SYNC_NAMESPACE_MODE = NEST \ + CATALOG_SYNC_NAMESPACE_FLATTEN_DELIMITER = '/' \ + WITH TAG (env='prod', team='data') \ + WITH CONTACT (owner = admin, dpo = compliance)", + ); + + let err = snowflake() + .parse_sql_statements("CREATE DATABASE") + .unwrap_err() + .to_string(); + assert!(err.contains("Expected"), "Unexpected error: {err}"); + + let err = snowflake() + .parse_sql_statements("CREATE DATABASE my_db CLONE") + .unwrap_err() + .to_string(); + assert!(err.contains("Expected"), "Unexpected error: {err}"); +} From 890fe6b4b452e983df38aaab16259725786874c0 Mon Sep 17 00:00:00 2001 From: Artem Osipov <59066880+osipovartem@users.noreply.github.com> Date: Wed, 30 Jul 2025 21:32:56 +0300 Subject: [PATCH 3/8] Merge pull request #15 from Embucket/issues/1418_external_volume CREATE EXTERNAL VOLUME sql --- .github/workflows/rust.yml | 4 + src/ast/mod.rs | 113 ++++++++++++++++++++++++ src/ast/spans.rs | 2 + src/dialect/snowflake.rs | 161 ++++++++++++++++++++++++++++++++--- src/keywords.rs | 10 +++ src/parser/mod.rs | 3 +- tests/sqlparser_snowflake.rs | 19 +++++ 7 files changed, 299 insertions(+), 13 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 3abf9d387..14dac8b0b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -38,6 +38,8 @@ jobs: - uses: actions/checkout@v4 - name: Setup Rust Toolchain uses: ./.github/actions/setup-builder + with: + rust-version: "1.86.0" - run: cargo clippy --all-targets --all-features -- -D warnings benchmark-lint: @@ -46,6 +48,8 @@ jobs: - uses: actions/checkout@v4 - name: Setup Rust Toolchain uses: ./.github/actions/setup-builder + with: + rust-version: "1.86.0" - run: cd sqlparser_bench && cargo clippy --all-targets --all-features -- -D warnings compile: diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 2ef88ace8..f65813dd2 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -3990,6 +3990,18 @@ pub enum Statement { option: Option, }, /// ```sql + /// CREATE EXTERNAL VOLUME + /// ``` + /// See + CreateExternalVolume { + or_replace: bool, + if_not_exists: bool, + name: ObjectName, + storage_locations: Vec, + allow_writes: Option, + comment: Option, + }, + /// ```sql /// CREATE PROCEDURE /// ``` CreateProcedure { @@ -5002,6 +5014,39 @@ impl fmt::Display for Statement { } Ok(()) } + Statement::CreateExternalVolume { + or_replace, + if_not_exists, + name, + storage_locations, + allow_writes, + comment, + } => { + write!( + f, + "CREATE {or_replace}EXTERNAL VOLUME {if_not_exists}{name}", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + if_not_exists = if *if_not_exists { " IF NOT EXISTS" } else { "" }, + )?; + if !storage_locations.is_empty() { + write!( + f, + " STORAGE_LOCATIONS = ({})", + storage_locations + .iter() + .map(|loc| format!("({})", loc)) + .collect::>() + .join(", ") + )?; + } + if let Some(true) = allow_writes { + write!(f, " ALLOW_WRITES = TRUE")?; + } + if let Some(c) = comment { + write!(f, " COMMENT = '{c}'")?; + } + Ok(()) + } Statement::CreateProcedure { name, or_alter, @@ -10223,6 +10268,74 @@ impl fmt::Display for MemberOf { } } +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CloudProviderParams { + pub name: String, + pub provider: String, + pub base_url: Option, + pub aws_role_arn: Option, + pub aws_access_point_arn: Option, + pub aws_external_id: Option, + pub azure_tenant_id: Option, + pub storage_endpoint: Option, + pub use_private_link_endpoint: Option, + pub encryption: KeyValueOptions, + pub credentials: KeyValueOptions, +} + +impl fmt::Display for CloudProviderParams { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "NAME = '{}' STORAGE_PROVIDER = '{}'", + self.name, self.provider + )?; + + if let Some(base_url) = &self.base_url { + write!(f, " STORAGE_BASE_URL = '{base_url}'")?; + } + + if let Some(arn) = &self.aws_role_arn { + write!(f, " STORAGE_AWS_ROLE_ARN = '{arn}'")?; + } + + if let Some(ap_arn) = &self.aws_access_point_arn { + write!(f, " STORAGE_AWS_ACCESS_POINT_ARN = '{ap_arn}'")?; + } + + if let Some(ext_id) = &self.aws_external_id { + write!(f, " STORAGE_AWS_EXTERNAL_ID = '{ext_id}'")?; + } + + if let Some(tenant_id) = &self.azure_tenant_id { + write!(f, " AZURE_TENANT_ID = '{tenant_id}'")?; + } + + if let Some(endpoint) = &self.storage_endpoint { + write!(f, " STORAGE_ENDPOINT = '{endpoint}'")?; + } + + if let Some(use_pl) = self.use_private_link_endpoint { + write!( + f, + " USE_PRIVATELINK_ENDPOINT = {}", + if use_pl { "TRUE" } else { "FALSE" } + )?; + } + + if !self.encryption.options.is_empty() { + write!(f, " ENCRYPTION=({})", self.encryption)?; + } + + if !self.credentials.options.is_empty() { + write!(f, " CREDENTIALS=({})", self.credentials)?; + } + Ok(()) + } +} + #[cfg(test)] mod tests { use crate::tokenizer::Location; diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 3e82905e1..6df4cfd57 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -267,6 +267,7 @@ impl Spanned for Values { /// - [Statement::CreateFunction] /// - [Statement::CreateTrigger] /// - [Statement::DropTrigger] +/// - [Statement::CreateExternalVolume] /// - [Statement::CreateProcedure] /// - [Statement::CreateMacro] /// - [Statement::CreateStage] @@ -488,6 +489,7 @@ impl Spanned for Statement { Statement::CreateDomain { .. } => Span::empty(), Statement::CreateTrigger { .. } => Span::empty(), Statement::DropTrigger { .. } => Span::empty(), + Statement::CreateExternalVolume { .. } => Span::empty(), Statement::CreateProcedure { .. } => Span::empty(), Statement::CreateMacro { .. } => Span::empty(), Statement::CreateStage { .. } => Span::empty(), diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 7269a7b4c..ae4b0a008 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -15,22 +15,21 @@ // specific language governing permissions and limitations // under the License. +use super::keywords::RESERVED_FOR_IDENTIFIER; #[cfg(not(feature = "std"))] use crate::alloc::string::ToString; -use crate::ast::helpers::key_value_options::{ - KeyValueOption, KeyValueOptionType, KeyValueOptions, -}; +use crate::ast::helpers::key_value_options::{KeyValueOption, KeyValueOptionType, KeyValueOptions}; use crate::ast::helpers::stmt_create_database::CreateDatabaseBuilder; use crate::ast::helpers::stmt_create_table::CreateTableBuilder; use crate::ast::helpers::stmt_data_loading::{ FileStagingCommand, StageLoadSelectItem, StageLoadSelectItemKind, StageParamsObject, }; use crate::ast::{ - CatalogSyncNamespaceMode, ColumnOption, ColumnPolicy, ColumnPolicyProperty, ContactEntry, - CopyIntoSnowflakeKind, DollarQuotedString, Ident, IdentityParameters, IdentityProperty, - IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, ObjectName, - ObjectNamePart, RowAccessPolicy, ShowObjects, SqlOption, Statement, StorageSerializationPolicy, - TagsColumnOption, WrappedCollection, + CatalogSyncNamespaceMode, CloudProviderParams, ColumnOption, ColumnPolicy, + ColumnPolicyProperty, ContactEntry, CopyIntoSnowflakeKind, DollarQuotedString, Ident, + IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, + IdentityPropertyOrder, ObjectName, ObjectNamePart, RowAccessPolicy, ShowObjects, SqlOption, + Statement, StorageSerializationPolicy, TagsColumnOption, WrappedCollection, }; use crate::dialect::{Dialect, Precedence}; use crate::keywords::Keyword; @@ -45,8 +44,6 @@ use alloc::vec::Vec; #[cfg(not(feature = "std"))] use alloc::{format, vec}; -use super::keywords::RESERVED_FOR_IDENTIFIER; - const RESERVED_KEYWORDS_FOR_SELECT_ITEM_OPERATOR: [Keyword; 1] = [Keyword::CONNECT_BY_ROOT]; /// A [`Dialect`] for [Snowflake](https://www.snowflake.com/) #[derive(Debug, Default)] @@ -187,6 +184,8 @@ impl Dialect for SnowflakeDialect { )); } else if parser.parse_keyword(Keyword::DATABASE) { return Some(parse_create_database(or_replace, transient, parser)); + } else if parser.parse_keywords(&[Keyword::EXTERNAL, Keyword::VOLUME]) { + return Some(parse_create_external_volume(or_replace, parser)); } else { // need to go back with the cursor let mut back = 1; @@ -323,7 +322,7 @@ impl Dialect for SnowflakeDialect { Keyword::LIMIT | Keyword::OFFSET if peek_for_limit_options(parser) => false, // `FETCH` can be considered an alias as long as it's not followed by `FIRST`` or `NEXT` - // which would give it a different meanings, for example: + // which would give it a different meanings, for example: // `SELECT 1 FETCH FIRST 10 ROWS` - not an alias // `SELECT 1 FETCH 10` - not an alias Keyword::FETCH if parser.peek_one_of_keywords(&[Keyword::FIRST, Keyword::NEXT]).is_some() @@ -840,6 +839,146 @@ pub fn parse_create_database( Ok(builder.build()) } +fn parse_create_external_volume( + 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 comment = None; + let mut allow_writes = None; + let mut storage_locations = Vec::new(); + + // STORAGE_LOCATIONS (...) + if parser.parse_keywords(&[Keyword::STORAGE_LOCATIONS]) { + parser.expect_token(&Token::Eq)?; + storage_locations = parse_storage_locations(parser)?; + }; + + // ALLOW_WRITES [ = true | false ] + if parser.parse_keyword(Keyword::ALLOW_WRITES) { + parser.expect_token(&Token::Eq)?; + allow_writes = Some(parser.parse_boolean_string()?); + } + + // COMMENT = '...' + if parser.parse_keyword(Keyword::COMMENT) { + parser.expect_token(&Token::Eq)?; + comment = Some(parser.parse_literal_string()?); + } + + if storage_locations.is_empty() { + return Err(ParserError::ParserError( + "STORAGE_LOCATIONS is required for CREATE EXTERNAL VOLUME".to_string(), + )); + } + + Ok(Statement::CreateExternalVolume { + or_replace, + if_not_exists, + name, + allow_writes, + comment, + storage_locations, + }) +} + +fn parse_storage_locations(parser: &mut Parser) -> Result, ParserError> { + let mut locations = Vec::new(); + parser.expect_token(&Token::LParen)?; + + loop { + parser.expect_token(&Token::LParen)?; + + // START OF ONE CloudProviderParams BLOCK + let mut name = None; + let mut provider = None; + let mut base_url = None; + let mut aws_role_arn = None; + let mut aws_access_point_arn = None; + let mut aws_external_id = None; + let mut azure_tenant_id = None; + let mut storage_endpoint = None; + let mut use_private_link_endpoint = None; + let mut encryption: KeyValueOptions = KeyValueOptions { options: vec![] }; + let mut credentials: KeyValueOptions = KeyValueOptions { options: vec![] }; + + loop { + if parser.parse_keyword(Keyword::NAME) { + parser.expect_token(&Token::Eq)?; + name = Some(parser.parse_literal_string()?); + } else if parser.parse_keyword(Keyword::STORAGE_PROVIDER) { + parser.expect_token(&Token::Eq)?; + provider = Some(parser.parse_literal_string()?); + } else if parser.parse_keyword(Keyword::STORAGE_BASE_URL) { + parser.expect_token(&Token::Eq)?; + base_url = Some(parser.parse_literal_string()?); + } else if parser.parse_keyword(Keyword::STORAGE_AWS_ROLE_ARN) { + parser.expect_token(&Token::Eq)?; + aws_role_arn = Some(parser.parse_literal_string()?); + } else if parser.parse_keyword(Keyword::STORAGE_AWS_ACCESS_POINT_ARN) { + parser.expect_token(&Token::Eq)?; + aws_access_point_arn = Some(parser.parse_literal_string()?); + } else if parser.parse_keyword(Keyword::STORAGE_AWS_EXTERNAL_ID) { + parser.expect_token(&Token::Eq)?; + aws_external_id = Some(parser.parse_literal_string()?); + } else if parser.parse_keyword(Keyword::AZURE_TENANT_ID) { + parser.expect_token(&Token::Eq)?; + azure_tenant_id = Some(parser.parse_literal_string()?); + } else if parser.parse_keyword(Keyword::STORAGE_ENDPOINT) { + parser.expect_token(&Token::Eq)?; + storage_endpoint = Some(parser.parse_literal_string()?); + } else if parser.parse_keyword(Keyword::USE_PRIVATELINK_ENDPOINT) { + parser.expect_token(&Token::Eq)?; + use_private_link_endpoint = Some(parser.parse_boolean_string()?); + } else if parser.parse_keyword(Keyword::ENCRYPTION) { + parser.expect_token(&Token::Eq)?; + encryption = KeyValueOptions { + options: parse_parentheses_options(parser)?, + }; + } else if parser.parse_keyword(Keyword::CREDENTIALS) { + parser.expect_token(&Token::Eq)?; + credentials = KeyValueOptions { + options: parse_parentheses_options(parser)?, + }; + } else if parser.consume_token(&Token::RParen) { + break; + } else { + return parser.expected("a valid key or closing paren", parser.peek_token()); + } + } + + let Some(name) = name else { + return parser.expected("NAME = '...'", parser.peek_token()); + }; + + let Some(provider) = provider else { + return parser.expected("STORAGE_PROVIDER = '...'", parser.peek_token()); + }; + + locations.push(CloudProviderParams { + name, + provider, + base_url, + aws_role_arn, + aws_access_point_arn, + aws_external_id, + azure_tenant_id, + storage_endpoint, + use_private_link_endpoint, + encryption, + credentials, + }); + // EXIT if next token is RParen + if parser.consume_token(&Token::RParen) { + break; + } + // Otherwise expect a comma before next object + parser.expect_token(&Token::Comma)?; + } + Ok(locations) +} + pub fn parse_storage_serialization_policy( parser: &mut Parser, ) -> Result { diff --git a/src/keywords.rs b/src/keywords.rs index 98a75625f..806d8491e 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -90,6 +90,7 @@ define_keywords!( ALIAS, ALL, ALLOCATE, + ALLOW_WRITES, ALTER, ALWAYS, ANALYZE, @@ -122,6 +123,7 @@ define_keywords!( AVG, AVG_ROW_LENGTH, AVRO, + AZURE_TENANT_ID, BACKWARD, BASE64, BASE_LOCATION, @@ -871,7 +873,14 @@ define_keywords!( STDOUT, STEP, STORAGE, + STORAGE_AWS_ACCESS_POINT_ARN, + STORAGE_AWS_EXTERNAL_ID, + STORAGE_AWS_ROLE_ARN, + STORAGE_BASE_URL, + STORAGE_ENDPOINT, STORAGE_INTEGRATION, + STORAGE_LOCATIONS, + STORAGE_PROVIDER, STORAGE_SERIALIZATION_POLICY, STORED, STRAIGHT_JOIN, @@ -982,6 +991,7 @@ define_keywords!( USE, USER, USER_RESOURCES, + USE_PRIVATELINK_ENDPOINT, USING, USMALLINT, UTINYINT, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 4ace2d666..bc7067bd9 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4649,7 +4649,7 @@ impl<'a> Parser<'a> { self.parse_create_view(or_alter, or_replace, temporary, create_view_params) } else if self.parse_keyword(Keyword::POLICY) { self.parse_create_policy() - } else if self.parse_keyword(Keyword::EXTERNAL) { + } else if self.parse_keywords(&[Keyword::EXTERNAL, Keyword::TABLE]) { self.parse_create_external_table(or_replace) } else if self.parse_keyword(Keyword::FUNCTION) { self.parse_create_function(or_alter, or_replace, temporary) @@ -5649,7 +5649,6 @@ impl<'a> Parser<'a> { &mut self, or_replace: bool, ) -> Result { - self.expect_keyword_is(Keyword::TABLE)?; let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); let table_name = self.parse_object_name(false)?; let (columns, constraints) = self.parse_columns()?; diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 8991527a8..ee58251e3 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -4552,3 +4552,22 @@ fn test_create_database() { .to_string(); assert!(err.contains("Expected"), "Unexpected error: {err}"); } + +#[test] +fn test_external_volume() { + snowflake().verified_stmt("CREATE OR REPLACE EXTERNAL VOLUME exvol STORAGE_LOCATIONS = ((NAME = 'my-s3-us-west-2' STORAGE_PROVIDER = 'S3' STORAGE_BASE_URL = 's3://my-example-bucket/' STORAGE_AWS_ROLE_ARN = 'arn:aws:iam::123456789012:role/myrole' ENCRYPTION=(TYPE='AWS_SSE_KMS' KMS_KEY_ID='1234abcd-12ab-34cd-56ef-1234567890ab'))) ALLOW_WRITES = TRUE"); + snowflake().verified_stmt("CREATE EXTERNAL VOLUME exvol STORAGE_LOCATIONS = ((NAME = 'my-us-east-1' STORAGE_PROVIDER = 'GCS' STORAGE_BASE_URL = 'gcs://mybucket1/path1/' ENCRYPTION=(TYPE='GCS_SSE_KMS' KMS_KEY_ID='1234abcd-12ab-34cd-56ef-1234567890ab'))) ALLOW_WRITES = TRUE"); + snowflake().verified_stmt("CREATE EXTERNAL VOLUME exvol STORAGE_LOCATIONS = ((NAME = 'my-azure-northeurope' STORAGE_PROVIDER = 'AZURE' STORAGE_BASE_URL = 'azure://exampleacct.blob.core.windows.net/my_container_northeurope/' AZURE_TENANT_ID = 'a123b4c5-1234-123a-a12b-1a23b45678c9')) ALLOW_WRITES = TRUE"); + snowflake().verified_stmt("CREATE OR REPLACE EXTERNAL VOLUME ext_vol_s3_compat STORAGE_LOCATIONS = ((NAME = 'my_s3_compat_storage_location' STORAGE_PROVIDER = 'S3COMPAT' STORAGE_BASE_URL = 's3compat://mybucket/unload/mys3compatdata' STORAGE_ENDPOINT = 'example.com' CREDENTIALS=(AWS_KEY_ID='1a2b3c...' AWS_SECRET_KEY='4x5y6z...')))", ); + snowflake().verified_stmt("CREATE OR REPLACE EXTERNAL VOLUME mem STORAGE_LOCATIONS = ((NAME = 'mem' STORAGE_PROVIDER = 'MEMORY'))"); + snowflake().verified_stmt("CREATE OR REPLACE EXTERNAL VOLUME file STORAGE_LOCATIONS = ((NAME = 'file' STORAGE_PROVIDER = 'FILE' STORAGE_BASE_URL = '/home/user/'))"); + + let err = snowflake() + .parse_sql_statements("CREATE EXTERNAL VOLUME name NAME") + .unwrap_err() + .to_string(); + assert!( + err.contains("STORAGE_LOCATIONS is required for CREATE EXTERNAL VOLUME"), + "Unexpected error: {err}" + ); +} From 75ce913994c7bf8e1e9122f836da882302673bde Mon Sep 17 00:00:00 2001 From: Denys Tsomenko Date: Thu, 28 Aug 2025 22:17:44 +0300 Subject: [PATCH 4/8] Add SECURE keyword for views in Snowflake (#2004) (#16) --- src/ast/mod.rs | 14 +++++++++++--- src/ast/spans.rs | 1 + src/keywords.rs | 1 + src/parser/mod.rs | 9 +++++++-- tests/sqlparser_common.rs | 7 +++++++ tests/sqlparser_snowflake.rs | 27 +++++++++++++++++++++++++++ 6 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index f65813dd2..bf128b4cf 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -3261,6 +3261,9 @@ pub enum Statement { or_alter: bool, or_replace: bool, materialized: bool, + /// Snowflake: SECURE view modifier + /// + secure: bool, /// View name name: ObjectName, columns: Vec, @@ -5102,6 +5105,7 @@ impl fmt::Display for Statement { columns, query, materialized, + secure, options, cluster_by, comment, @@ -5122,11 +5126,15 @@ impl fmt::Display for Statement { } write!( f, - "{materialized}{temporary}VIEW {if_not_exists}{name}{to}", + "{secure}{materialized}{temporary}VIEW {if_not_and_name}{to}", + if_not_and_name = if *if_not_exists { + format!("IF NOT EXISTS {name}") + } else { + format!("{name}") + }, + secure = if *secure { "SECURE " } else { "" }, materialized = if *materialized { "MATERIALIZED " } else { "" }, - name = name, temporary = if *temporary { "TEMPORARY " } else { "" }, - if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, to = to .as_ref() .map(|to| format!(" TO {to}")) diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 6df4cfd57..c8b278ade 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -391,6 +391,7 @@ impl Spanned for Statement { or_alter: _, or_replace: _, materialized: _, + secure: _, name, columns, query, diff --git a/src/keywords.rs b/src/keywords.rs index 806d8491e..eb150b356 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -812,6 +812,7 @@ define_keywords!( SECONDARY_ENGINE_ATTRIBUTE, SECONDS, SECRET, + SECURE, SECURITY, SEED, SELECT, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index bc7067bd9..77957f095 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4644,8 +4644,11 @@ impl<'a> Parser<'a> { let create_view_params = self.parse_create_view_params()?; if self.parse_keyword(Keyword::TABLE) { self.parse_create_table(or_replace, temporary, global, transient) - } else if self.parse_keyword(Keyword::MATERIALIZED) || self.parse_keyword(Keyword::VIEW) { - self.prev_token(); + } else if self.peek_keyword(Keyword::MATERIALIZED) + || self.peek_keyword(Keyword::VIEW) + || self.peek_keywords(&[Keyword::SECURE, Keyword::MATERIALIZED, Keyword::VIEW]) + || self.peek_keywords(&[Keyword::SECURE, Keyword::VIEW]) + { self.parse_create_view(or_alter, or_replace, temporary, create_view_params) } else if self.parse_keyword(Keyword::POLICY) { self.parse_create_policy() @@ -5722,6 +5725,7 @@ impl<'a> Parser<'a> { temporary: bool, create_view_params: Option, ) -> Result { + let secure = self.parse_keyword(Keyword::SECURE); let materialized = self.parse_keyword(Keyword::MATERIALIZED); self.expect_keyword_is(Keyword::VIEW)?; let if_not_exists = dialect_of!(self is BigQueryDialect|SQLiteDialect|GenericDialect) @@ -5787,6 +5791,7 @@ impl<'a> Parser<'a> { columns, query, materialized, + secure, or_replace, options, cluster_by, diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 99ad3e8ed..1a5565cfe 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -8004,6 +8004,7 @@ fn parse_create_view() { temporary, to, params, + secure: _, } => { assert_eq!(or_alter, false); assert_eq!("myschema.myview", name.to_string()); @@ -8072,6 +8073,7 @@ fn parse_create_view_with_columns() { temporary, to, params, + secure: _, } => { assert_eq!(or_alter, false); assert_eq!("v", name.to_string()); @@ -8121,6 +8123,7 @@ fn parse_create_view_temporary() { temporary, to, params, + secure: _, } => { assert_eq!(or_alter, false); assert_eq!("myschema.myview", name.to_string()); @@ -8160,6 +8163,7 @@ fn parse_create_or_replace_view() { temporary, to, params, + secure: _, } => { assert_eq!(or_alter, false); assert_eq!("v", name.to_string()); @@ -8203,6 +8207,7 @@ fn parse_create_or_replace_materialized_view() { temporary, to, params, + secure: _, } => { assert_eq!(or_alter, false); assert_eq!("v", name.to_string()); @@ -8242,6 +8247,7 @@ fn parse_create_materialized_view() { temporary, to, params, + secure: _, } => { assert_eq!(or_alter, false); assert_eq!("myschema.myview", name.to_string()); @@ -8281,6 +8287,7 @@ fn parse_create_materialized_view_with_cluster_by() { temporary, to, params, + secure: _, } => { assert_eq!(or_alter, false); assert_eq!("myschema.myview", name.to_string()); diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index ee58251e3..be3738f71 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -44,6 +44,33 @@ fn test_snowflake_create_table() { } } +#[test] +fn parse_sf_create_secure_view_and_materialized_view() { + for sql in [ + "CREATE SECURE VIEW v AS SELECT 1", + "CREATE SECURE MATERIALIZED VIEW v AS SELECT 1", + "CREATE OR REPLACE SECURE VIEW v AS SELECT 1", + "CREATE OR REPLACE SECURE MATERIALIZED VIEW v AS SELECT 1", + ] { + match snowflake().verified_stmt(sql) { + Statement::CreateView { + secure, + materialized, + .. + } => { + assert!(secure); + if sql.contains("MATERIALIZED") { + assert!(materialized); + } else { + assert!(!materialized); + } + } + _ => 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 9d78029628da4777ad0ca8fc0b248da53dff42cd Mon Sep 17 00:00:00 2001 From: Denys Tsomenko Date: Fri, 19 Sep 2025 17:34:02 +0300 Subject: [PATCH 5/8] Delete wrong validate_schema_info --- src/ast/helpers/stmt_create_table.rs | 20 --------------- src/dialect/snowflake.rs | 12 --------- tests/sqlparser_snowflake.rs | 38 ---------------------------- 3 files changed, 70 deletions(-) diff --git a/src/ast/helpers/stmt_create_table.rs b/src/ast/helpers/stmt_create_table.rs index 60b8fb2a0..d66a869bf 100644 --- a/src/ast/helpers/stmt_create_table.rs +++ b/src/ast/helpers/stmt_create_table.rs @@ -383,26 +383,6 @@ impl CreateTableBuilder { self } - /// Returns true if the statement has exactly one source of info on the schema of the new table. - /// This is Snowflake-specific, some dialects allow more than one source. - pub(crate) fn validate_schema_info(&self) -> bool { - let mut sources = 0; - if !self.columns.is_empty() { - sources += 1; - } - if self.query.is_some() { - sources += 1; - } - if self.like.is_some() { - sources += 1; - } - if self.clone.is_some() { - sources += 1; - } - - sources == 1 - } - pub fn build(self) -> Statement { Statement::CreateTable(CreateTable { or_replace: self.or_replace, diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index ae4b0a008..116255f8e 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -690,21 +690,9 @@ pub fn parse_create_table( builder = builder.columns(columns).constraints(constraints); } Token::EOF => { - if !builder.validate_schema_info() { - return Err(ParserError::ParserError( - "unexpected end of input".to_string(), - )); - } - break; } Token::SemiColon => { - if !builder.validate_schema_info() { - return Err(ParserError::ParserError( - "unexpected end of input".to_string(), - )); - } - parser.prev_token(); break; } diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index be3738f71..b724bebe9 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -555,23 +555,6 @@ fn test_snowflake_create_table_comment() { } } -#[test] -fn test_snowflake_create_table_incomplete_statement() { - assert_eq!( - snowflake().parse_sql_statements("CREATE TABLE my_table"), - Err(ParserError::ParserError( - "unexpected end of input".to_string() - )) - ); - - assert_eq!( - snowflake().parse_sql_statements("CREATE TABLE my_table; (c int)"), - Err(ParserError::ParserError( - "unexpected end of input".to_string() - )) - ); -} - #[test] fn test_snowflake_single_line_tokenize() { let sql = "CREATE TABLE# this is a comment \ntable_1"; @@ -1046,27 +1029,6 @@ fn test_snowflake_create_table_trailing_options() { .unwrap(); } -#[test] -fn test_snowflake_create_table_valid_schema_info() { - // Validate there's exactly one source of information on the schema of the new table - assert_eq!( - snowflake() - .parse_sql_statements("CREATE TABLE dst") - .is_err(), - true - ); - assert_eq!( - snowflake().parse_sql_statements("CREATE OR REPLACE TEMP TABLE dst LIKE src AS (SELECT * FROM CUSTOMERS) ON COMMIT PRESERVE ROWS").is_err(), - true - ); - assert_eq!( - snowflake() - .parse_sql_statements("CREATE OR REPLACE TEMP TABLE dst CLONE customers LIKE customer2") - .is_err(), - true - ); -} - #[test] fn parse_sf_create_or_replace_view_with_comment_missing_equal() { assert!(snowflake_and_generic() From aebc60e69e7b09cc1e1b43c4777be00087c9ea51 Mon Sep 17 00:00:00 2001 From: Denys Tsomenko Date: Sat, 20 Sep 2025 20:55:50 +0300 Subject: [PATCH 6/8] Add support for optional precission in TIMESTAMP_NTZ --- src/parser/mod.rs | 7 ++++++- tests/sqlparser_snowflake.rs | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 77957f095..f7f20b0ac 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -9869,7 +9869,12 @@ impl<'a> Parser<'a> { self.parse_optional_precision()?, TimezoneInfo::Tz, )), - Keyword::TIMESTAMP_NTZ => Ok(DataType::TimestampNtz), + // Consume optional precision for Snowflake/Databricks TIMESTAMP_NTZ, e.g. TIMESTAMP_NTZ(3) + // Precision is currently not represented in the AST variant, but we must not error on it. + Keyword::TIMESTAMP_NTZ => { + let _ = self.parse_optional_precision()?; + Ok(DataType::TimestampNtz) + } Keyword::TIME => { let precision = self.parse_optional_precision()?; let tz = if self.parse_keyword(Keyword::WITH) { diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index b724bebe9..410744f4e 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -44,6 +44,13 @@ fn test_snowflake_create_table() { } } +#[test] +fn test_snowflake_create_table_timestamp_ntz_precision_ctas_values() { + let sql = "CREATE TABLE t (x TIMESTAMP_NTZ(3)) AS SELECT * FROM VALUES ('2025-04-09T21:11:23')"; + let canonical = "CREATE TABLE t (x TIMESTAMP_NTZ) AS SELECT * FROM (VALUES ('2025-04-09T21:11:23'))"; + snowflake().one_statement_parses_to(sql, canonical); +} + #[test] fn parse_sf_create_secure_view_and_materialized_view() { for sql in [ From 7dbb724b8173db35363fa07a2bb2b8bef35ac098 Mon Sep 17 00:00:00 2001 From: Denys Tsomenko Date: Sat, 20 Sep 2025 22:17:07 +0300 Subject: [PATCH 7/8] Allow SHOW VARIABLES for Snowflake dialect --- src/parser/mod.rs | 2 +- tests/sqlparser_snowflake.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index f7f20b0ac..4af293976 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -12451,7 +12451,7 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::COLLATION) { Ok(self.parse_show_collation()?) } else if self.parse_keyword(Keyword::VARIABLES) - && dialect_of!(self is MySqlDialect | GenericDialect) + && dialect_of!(self is MySqlDialect | GenericDialect | SnowflakeDialect) { Ok(Statement::ShowVariables { filter: self.parse_show_statement_filter()?, diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 410744f4e..5c6fb6231 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -47,7 +47,8 @@ fn test_snowflake_create_table() { #[test] fn test_snowflake_create_table_timestamp_ntz_precision_ctas_values() { let sql = "CREATE TABLE t (x TIMESTAMP_NTZ(3)) AS SELECT * FROM VALUES ('2025-04-09T21:11:23')"; - let canonical = "CREATE TABLE t (x TIMESTAMP_NTZ) AS SELECT * FROM (VALUES ('2025-04-09T21:11:23'))"; + let canonical = + "CREATE TABLE t (x TIMESTAMP_NTZ) AS SELECT * FROM (VALUES ('2025-04-09T21:11:23'))"; snowflake().one_statement_parses_to(sql, canonical); } From 28012078c09714542c95ddbad8203a282fa37cb2 Mon Sep 17 00:00:00 2001 From: Denys Tsomenko Date: Wed, 24 Sep 2025 15:11:11 +0300 Subject: [PATCH 8/8] Support BEGIN as standalon clause (#17) * Support BEGIN as standalon clause * Only affect snowflake logic --- src/dialect/snowflake.rs | 19 ++++++++++++++++++- tests/sqlparser_snowflake.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 116255f8e..59cafe653 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -132,7 +132,24 @@ impl Dialect for SnowflakeDialect { fn parse_statement(&self, parser: &mut Parser) -> Option> { if parser.parse_keyword(Keyword::BEGIN) { - return Some(parser.parse_begin_exception_end()); + // Allow standalone BEGIN; for Snowflake + match &parser.peek_token_ref().token { + Token::SemiColon | Token::EOF => { + return Some(Ok(Statement::StartTransaction { + modes: Default::default(), + begin: true, + transaction: None, + modifier: None, + statements: vec![], + exception: None, + has_end_keyword: false, + })) + } + _ => { + // BEGIN ... [EXCEPTION] ... END block + return Some(parser.parse_begin_exception_end()); + } + } } if parser.parse_keywords(&[Keyword::ALTER, Keyword::SESSION]) { diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 5c6fb6231..d1719dd43 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -4343,6 +4343,40 @@ fn test_snowflake_fetch_clause_syntax() { ); } +#[test] +fn test_snowflake_begin_standalone() { + // BEGIN; (no END) should be allowed for Snowflake + let mut stmts = snowflake().parse_sql_statements("BEGIN;").unwrap(); + assert_eq!(1, stmts.len()); + match stmts.remove(0) { + Statement::StartTransaction { + begin, + has_end_keyword, + statements, + .. + } => { + assert!(begin); + assert!(!has_end_keyword); + assert!(statements.is_empty()); + } + other => panic!("unexpected stmt: {other:?}"), + } +} + +#[test] +fn test_snowflake_begin_commit_sequence() { + let mut stmts = snowflake().parse_sql_statements("BEGIN; COMMIT;").unwrap(); + assert_eq!(2, stmts.len()); + match stmts.remove(0) { + Statement::StartTransaction { begin, .. } => assert!(begin), + other => panic!("unexpected first stmt: {other:?}"), + } + match stmts.remove(0) { + Statement::Commit { end, .. } => assert!(!end), + other => panic!("unexpected second stmt: {other:?}"), + } +} + #[test] fn test_snowflake_create_view_with_multiple_column_options() { let create_view_with_tag =