From f770b813654c0bd9e9a40e8e0ec03ed3fb24d9ee Mon Sep 17 00:00:00 2001 From: sabir-akhadov-localstack Date: Tue, 1 Sep 2026 10:50:36 +0200 Subject: [PATCH 1/2] Snowflake: Add CREATE and DROP EXTERNAL VOLUME Co-Authored-By: Claude Fable 5 --- src/ast/mod.rs | 68 +++++++ src/ast/spans.rs | 1 + src/dialect/snowflake.rs | 81 ++++++++- src/keywords.rs | 2 + src/parser/mod.rs | 4 +- tests/sqlparser_snowflake.rs | 337 +++++++++++++++++++++++++++++++++++ 6 files changed, 485 insertions(+), 8 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 4d86dd6a6..76909ec28 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -4558,6 +4558,11 @@ pub enum Statement { comment: Option, }, /// ```sql + /// CREATE [OR REPLACE] EXTERNAL VOLUME [IF NOT EXISTS] + /// ``` + /// See + CreateExternalVolume(CreateExternalVolume), + /// ```sql /// CREATE [ OR REPLACE ] WAREHOUSE [ IF NOT EXISTS ] /// [ [ WITH ] = [ ... ] ] /// ``` @@ -6293,6 +6298,7 @@ impl fmt::Display for Statement { } Ok(()) } + Statement::CreateExternalVolume(s) => write!(f, "{s}"), Statement::CreateWarehouse(s) => write!(f, "{s}"), Statement::CopyIntoSnowflake { kind, @@ -8686,6 +8692,8 @@ pub enum ObjectType { User, /// A stream. Stream, + /// A Snowflake external volume. + ExternalVolume, /// A warehouse. Warehouse, } @@ -8706,6 +8714,7 @@ impl fmt::Display for ObjectType { ObjectType::Type => "TYPE", ObjectType::User => "USER", ObjectType::Stream => "STREAM", + ObjectType::ExternalVolume => "EXTERNAL VOLUME", ObjectType::Warehouse => "WAREHOUSE", }) } @@ -11131,6 +11140,59 @@ pub struct ShowObjects { pub show_options: ShowStatementOptions, } +/// ```sql +/// CREATE [OR REPLACE] EXTERNAL VOLUME [IF NOT EXISTS] +/// ``` +/// See +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreateExternalVolume { + /// `OR REPLACE` flag. + pub or_replace: bool, + /// `IF NOT EXISTS` flag. + pub if_not_exists: bool, + /// External volume name. + pub name: ObjectName, + /// Storage locations, each a parenthesized list of key-value options + /// (e.g. `(NAME='loc1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket/')`). + pub storage_locations: Vec, + /// Optional `ALLOW_WRITES` setting. + pub allow_writes: Option, + /// Optional comment. + pub comment: Option, +} + +impl fmt::Display for CreateExternalVolume { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "CREATE {or_replace}EXTERNAL VOLUME {if_not_exists}{name} STORAGE_LOCATIONS = (", + or_replace = if self.or_replace { "OR REPLACE " } else { "" }, + if_not_exists = if self.if_not_exists { + "IF NOT EXISTS " + } else { + "" + }, + name = self.name, + )?; + for (i, loc) in self.storage_locations.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "({loc})")?; + } + write!(f, ")")?; + if let Some(val) = self.allow_writes { + write!(f, " ALLOW_WRITES = {}", if val { "TRUE" } else { "FALSE" })?; + } + if let Some(ref c) = self.comment { + write!(f, " COMMENT = '{}'", value::escape_single_quote_string(c))?; + } + Ok(()) + } +} + /// MSSQL's json null clause /// /// ```plaintext @@ -12626,6 +12688,12 @@ impl From for Statement { } } +impl From for Statement { + fn from(c: CreateExternalVolume) -> Self { + Self::CreateExternalVolume(c) + } +} + impl From for Statement { fn from(c: CreateWarehouse) -> Self { Self::CreateWarehouse(c) diff --git a/src/ast/spans.rs b/src/ast/spans.rs index a34fe66d9..ea813bf75 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -523,6 +523,7 @@ impl Spanned for Statement { Statement::Vacuum(..) => Span::empty(), Statement::AlterUser(..) => Span::empty(), Statement::Reset(..) => Span::empty(), + Statement::CreateExternalVolume(..) => Span::empty(), } } } diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 0bedb12a5..cca06c8bc 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -28,13 +28,14 @@ use crate::ast::helpers::stmt_data_loading::{ }; use crate::ast::{ AlterTable, AlterTableOperation, AlterTableType, CatalogSyncNamespaceMode, ColumnOption, - ColumnPolicy, ColumnPolicyProperty, ContactEntry, CopyIntoSnowflakeKind, CreateTable, - CreateTableLikeKind, DollarQuotedString, Ident, IdentityParameters, IdentityProperty, - IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, InitializeKind, - Insert, MultiTableInsertIntoClause, MultiTableInsertType, MultiTableInsertValue, - MultiTableInsertValues, MultiTableInsertWhenClause, ObjectName, ObjectNamePart, - RefreshModeKind, RowAccessPolicy, ShowObjects, SqlOption, Statement, StorageLifecyclePolicy, - StorageSerializationPolicy, TableObject, TagsColumnOption, Value, WrappedCollection, + ColumnPolicy, ColumnPolicyProperty, ContactEntry, CopyIntoSnowflakeKind, CreateExternalVolume, + CreateTable, CreateTableLikeKind, DollarQuotedString, Ident, IdentityParameters, + IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, + InitializeKind, Insert, MultiTableInsertIntoClause, MultiTableInsertType, + MultiTableInsertValue, MultiTableInsertValues, MultiTableInsertWhenClause, ObjectName, + ObjectNamePart, RefreshModeKind, RowAccessPolicy, ShowObjects, SqlOption, Statement, + StorageLifecyclePolicy, StorageSerializationPolicy, TableObject, TagsColumnOption, Value, + WrappedCollection, }; use crate::dialect::{Dialect, Precedence}; use crate::keywords::Keyword; @@ -290,6 +291,12 @@ impl Dialect for SnowflakeDialect { // possibly CREATE STAGE //[ OR REPLACE ] let or_replace = parser.parse_keywords(&[Keyword::OR, Keyword::REPLACE]); + + // CREATE [OR REPLACE] EXTERNAL VOLUME + if parser.parse_keywords(&[Keyword::EXTERNAL, Keyword::VOLUME]) { + return Some(parse_create_external_volume(or_replace, parser)); + } + // LOCAL | GLOBAL let global = match parser.parse_one_of_keywords(&[Keyword::LOCAL, Keyword::GLOBAL]) { Some(Keyword::LOCAL) => Some(false), @@ -1988,3 +1995,63 @@ fn parse_multi_table_insert_when_clauses( Ok((when_clauses, else_clause)) } + +/// Parse `CREATE [OR REPLACE] EXTERNAL VOLUME [IF NOT EXISTS] ...` +/// +/// Each storage location is parsed by [`parse_external_volume_storage_location`]; +/// the trailing `ALLOW_WRITES` and `COMMENT` properties are accepted in any +/// order. +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)?; + + parser.expect_keyword_is(Keyword::STORAGE_LOCATIONS)?; + parser.expect_token(&Token::Eq)?; + parser.expect_token(&Token::LParen)?; + + let storage_locations = parser.parse_comma_separated(parse_external_volume_storage_location)?; + parser.expect_token(&Token::RParen)?; + + let mut allow_writes = None; + let mut comment = None; + + loop { + if parser.parse_keyword(Keyword::ALLOW_WRITES) { + parser.expect_token(&Token::Eq)?; + allow_writes = Some(parser.parse_boolean_string()?); + } else if parser.parse_keyword(Keyword::COMMENT) { + parser.expect_token(&Token::Eq)?; + comment = Some(parser.parse_comment_value()?); + } else { + break; + } + } + + Ok(CreateExternalVolume { + or_replace, + if_not_exists, + name, + storage_locations, + allow_writes, + comment, + } + .into()) +} + +/// Parse one parenthesized storage-location option list, e.g. +/// `(NAME='loc1' STORAGE_PROVIDER='S3' ...)`. The options (and the +/// `ENCRYPTION = (...)` sub-list) are parsed generically via +/// [`Parser::parse_key_value_options`]; only an empty list is rejected, +/// field order and the exact option set are left to the consumer. +fn parse_external_volume_storage_location( + parser: &mut Parser, +) -> Result { + let location = parser.parse_key_value_options(true, &[])?; + if location.options.is_empty() { + return parser.expected("storage location options", parser.peek_token()); + } + Ok(location) +} diff --git a/src/keywords.rs b/src/keywords.rs index 0c50703c3..29a5e19e9 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -112,6 +112,7 @@ define_keywords!( ALL, ALLOCATE, ALLOWOVERWRITE, + ALLOW_WRITES, ALTER, ALWAYS, ANALYZE, @@ -1006,6 +1007,7 @@ define_keywords!( STEP, STORAGE, STORAGE_INTEGRATION, + STORAGE_LOCATIONS, STORAGE_SERIALIZATION_POLICY, STORED, STRAIGHT_JOIN, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 6af0fb776..f1b5de38c 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7627,6 +7627,8 @@ impl<'a> Parser<'a> { ObjectType::User } else if self.parse_keyword(Keyword::STREAM) { ObjectType::Stream + } else if self.parse_keywords(&[Keyword::EXTERNAL, Keyword::VOLUME]) { + ObjectType::ExternalVolume } else if self.parse_keyword(Keyword::WAREHOUSE) { ObjectType::Warehouse } else if self.parse_keyword(Keyword::FUNCTION) { @@ -7656,7 +7658,7 @@ impl<'a> Parser<'a> { }; } else { return self.expected_ref( - "COLLATION, CONNECTOR, DATABASE, EXTENSION, FUNCTION, INDEX, OPERATOR, POLICY, PROCEDURE, ROLE, SCHEMA, SECRET, SEQUENCE, STAGE, TABLE, TRIGGER, TYPE, VIEW, MATERIALIZED VIEW, USER or WAREHOUSE after DROP", + "COLLATION, CONNECTOR, DATABASE, EXTENSION, EXTERNAL VOLUME, FUNCTION, INDEX, OPERATOR, POLICY, PROCEDURE, ROLE, SCHEMA, SECRET, SEQUENCE, STAGE, TABLE, TRIGGER, TYPE, VIEW, MATERIALIZED VIEW, USER or WAREHOUSE after DROP", self.peek_token_ref(), ); }; diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 059560dcc..1aa0f9a25 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -4912,3 +4912,340 @@ fn test_select_dollar_column_from_stage() { // With table function args, without alias snowflake().verified_stmt("SELECT $1, $2 FROM @mystage1(file_format => 'myformat')"); } + +/// Return the string value of a single-valued option by name, if present. +fn ext_vol_option<'a>(options: &'a [KeyValueOption], name: &str) -> Option<&'a str> { + options + .iter() + .find(|o| o.option_name == name) + .and_then(|o| match &o.option_value { + KeyValueOptionKind::Single(v) => match &v.value { + Value::SingleQuotedString(s) => Some(s.as_str()), + _ => None, + }, + _ => None, + }) +} + +#[test] +fn test_create_external_volume_basic() { + let sql = "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket/path/'))"; + match snowflake().verified_stmt(sql) { + Statement::CreateExternalVolume(CreateExternalVolume { + or_replace, + if_not_exists, + name, + storage_locations, + allow_writes, + comment, + }) => { + assert!(!or_replace); + assert!(!if_not_exists); + assert_eq!("my_vol", name.to_string()); + assert_eq!(1, storage_locations.len()); + let loc = &storage_locations[0].options; + assert_eq!(Some("loc1"), ext_vol_option(loc, "NAME")); + assert_eq!(Some("S3"), ext_vol_option(loc, "STORAGE_PROVIDER")); + assert_eq!( + Some("s3://bucket/path/"), + ext_vol_option(loc, "STORAGE_BASE_URL") + ); + assert!(allow_writes.is_none()); + assert!(comment.is_none()); + } + _ => unreachable!(), + } +} + +#[test] +fn test_create_external_volume_or_replace() { + let sql = "CREATE OR REPLACE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket/'))"; + match snowflake().verified_stmt(sql) { + Statement::CreateExternalVolume(CreateExternalVolume { or_replace, .. }) => { + assert!(or_replace); + } + _ => unreachable!(), + } +} + +#[test] +fn test_create_external_volume_if_not_exists() { + let sql = "CREATE EXTERNAL VOLUME IF NOT EXISTS my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket/'))"; + match snowflake().verified_stmt(sql) { + Statement::CreateExternalVolume(CreateExternalVolume { if_not_exists, .. }) => { + assert!(if_not_exists); + } + _ => unreachable!(), + } +} + +#[test] +fn test_create_external_volume_multi_location() { + let sql = "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket1/'), \ + (NAME='loc2' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket2/' \ + STORAGE_AWS_ROLE_ARN='arn:aws:iam::role/myrole'))"; + match snowflake().verified_stmt(sql) { + Statement::CreateExternalVolume(CreateExternalVolume { + storage_locations, .. + }) => { + assert_eq!(2, storage_locations.len()); + assert_eq!( + Some("loc1"), + ext_vol_option(&storage_locations[0].options, "NAME") + ); + assert_eq!( + Some("loc2"), + ext_vol_option(&storage_locations[1].options, "NAME") + ); + assert_eq!( + Some("arn:aws:iam::role/myrole"), + ext_vol_option(&storage_locations[1].options, "STORAGE_AWS_ROLE_ARN") + ); + } + _ => unreachable!(), + } +} + +#[test] +fn test_create_external_volume_with_encryption_sse_s3() { + snowflake().verified_stmt( + "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket/' \ + ENCRYPTION=(TYPE='AWS_SSE_S3')))", + ); +} + +#[test] +fn test_create_external_volume_with_encryption_kms() { + let sql = "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket/' \ + ENCRYPTION=(TYPE='AWS_SSE_KMS' KMS_KEY_ID='my-key-id')))"; + match snowflake().verified_stmt(sql) { + Statement::CreateExternalVolume(CreateExternalVolume { + storage_locations, .. + }) => { + // ENCRYPTION is parsed as a nested key-value option list. + let enc = storage_locations[0] + .options + .iter() + .find(|o| o.option_name == "ENCRYPTION") + .expect("ENCRYPTION option present"); + match &enc.option_value { + KeyValueOptionKind::KeyValueOptions(inner) => { + assert_eq!(Some("AWS_SSE_KMS"), ext_vol_option(&inner.options, "TYPE")); + assert_eq!( + Some("my-key-id"), + ext_vol_option(&inner.options, "KMS_KEY_ID") + ); + } + _ => unreachable!("ENCRYPTION should be a nested option list"), + } + } + _ => unreachable!(), + } +} + +#[test] +fn test_create_external_volume_with_encryption_none() { + snowflake().verified_stmt( + "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket/' \ + ENCRYPTION=(TYPE='NONE')))", + ); +} + +#[test] +fn test_create_external_volume_full() { + let sql = "CREATE OR REPLACE EXTERNAL VOLUME IF NOT EXISTS my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket/' \ + STORAGE_AWS_ROLE_ARN='arn:aws:iam::role/r' \ + STORAGE_AWS_EXTERNAL_ID='ext-id' \ + ENCRYPTION=(TYPE='AWS_SSE_KMS' KMS_KEY_ID='key'))) \ + ALLOW_WRITES = TRUE COMMENT = 'my comment'"; + match snowflake().verified_stmt(sql) { + Statement::CreateExternalVolume(CreateExternalVolume { + or_replace, + if_not_exists, + storage_locations, + allow_writes, + comment, + .. + }) => { + assert!(or_replace); + assert!(if_not_exists); + assert_eq!(1, storage_locations.len()); + assert_eq!( + Some("ext-id"), + ext_vol_option(&storage_locations[0].options, "STORAGE_AWS_EXTERNAL_ID") + ); + assert_eq!(Some(true), allow_writes); + assert_eq!(Some("my comment".to_string()), comment); + } + _ => unreachable!(), + } +} + +#[test] +fn test_create_external_volume_allow_writes_false() { + let sql = "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket/')) \ + ALLOW_WRITES = FALSE"; + match snowflake().verified_stmt(sql) { + Statement::CreateExternalVolume(CreateExternalVolume { allow_writes, .. }) => { + assert_eq!(Some(false), allow_writes); + } + _ => unreachable!(), + } +} + +#[test] +fn test_create_external_volume_comma_separated_fields() { + // Options within a location may be comma-separated; the comma delimiter + // is preserved on round-trip. + snowflake().verified_stmt( + "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1', STORAGE_PROVIDER='S3', STORAGE_BASE_URL='s3://bucket/', \ + STORAGE_AWS_ROLE_ARN='arn:aws:iam::role/r'))", + ); +} + +#[test] +fn test_create_external_volume_flexible_field_ordering() { + // Field order within a location is preserved as written (not normalized). + snowflake().verified_stmt( + "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1' STORAGE_PROVIDER='S3' \ + STORAGE_AWS_ROLE_ARN='arn:aws:iam::role/r' STORAGE_BASE_URL='s3://bucket/'))", + ); +} + +#[test] +fn test_create_external_volume_spaces_around_equals_normalized() { + // Snowflake accepts spaces around `=`; they are removed on round-trip, + // matching the rest of the dialect's key-value option rendering. + let sql = "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME = 'loc1' STORAGE_PROVIDER = 'S3' STORAGE_BASE_URL = 's3://bucket/'))"; + let canonical = "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket/'))"; + snowflake().one_statement_parses_to(sql, canonical); +} + +#[test] +fn test_create_external_volume_minimal_location_accepted() { + // Parsing is syntax-only: a location with no STORAGE_BASE_URL is accepted + // (semantic validation is left to the consumer). + snowflake().verified_stmt( + "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1' STORAGE_PROVIDER='S3'))", + ); +} + +#[test] +fn test_create_external_volume_escaped_single_quotes() { + // Single quotes inside string values round-trip through escaping. + let sql = "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME='lo''c1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket/')) \ + COMMENT = 'it''s mine'"; + match snowflake().verified_stmt(sql) { + Statement::CreateExternalVolume(CreateExternalVolume { + storage_locations, + comment, + .. + }) => { + assert_eq!( + Some("lo'c1"), + ext_vol_option(&storage_locations[0].options, "NAME") + ); + assert_eq!(Some("it's mine".to_string()), comment); + } + _ => unreachable!(), + } +} + +#[test] +fn test_create_external_volume_empty_storage_locations() { + let sql = "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = ()"; + snowflake() + .parse_sql_statements(sql) + .expect_err("parser must reject empty STORAGE_LOCATIONS"); +} + +#[test] +fn test_create_external_volume_empty_storage_location() { + let sql = "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = (())"; + snowflake() + .parse_sql_statements(sql) + .expect_err("parser must reject an empty storage location"); +} + +#[test] +fn test_create_external_volume_comment_before_allow_writes() { + // ALLOW_WRITES and COMMENT parse in either order; display order is canonical. + let sql = "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket/')) \ + COMMENT = 'my comment' ALLOW_WRITES = TRUE"; + let canonical = "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket/')) \ + ALLOW_WRITES = TRUE COMMENT = 'my comment'"; + match snowflake().one_statement_parses_to(sql, canonical) { + Statement::CreateExternalVolume(CreateExternalVolume { + allow_writes, + comment, + .. + }) => { + assert_eq!(Some(true), allow_writes); + assert_eq!(Some("my comment".to_string()), comment); + } + _ => unreachable!(), + } +} + +#[test] +fn test_create_external_volume_allow_writes_non_boolean() { + let sql = "CREATE EXTERNAL VOLUME my_vol STORAGE_LOCATIONS = \ + ((NAME='loc1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket/')) \ + ALLOW_WRITES = 1"; + let err = snowflake() + .parse_sql_statements(sql) + .expect_err("parser must reject non-boolean ALLOW_WRITES"); + assert!( + err.to_string().contains("TRUE or FALSE"), + "unexpected error: {err}" + ); +} + +#[test] +fn test_drop_external_volume() { + match snowflake().verified_stmt("DROP EXTERNAL VOLUME my_vol") { + Statement::Drop { + object_type, + if_exists, + names, + .. + } => { + assert_eq!(ObjectType::ExternalVolume, object_type); + assert!(!if_exists); + assert_eq!("my_vol", names[0].to_string()); + } + _ => unreachable!(), + } +} + +#[test] +fn test_drop_external_volume_if_exists() { + match snowflake().verified_stmt("DROP EXTERNAL VOLUME IF EXISTS my_vol") { + Statement::Drop { + object_type, + if_exists, + .. + } => { + assert_eq!(ObjectType::ExternalVolume, object_type); + assert!(if_exists); + } + _ => unreachable!(), + } +} From ec60ac7b559c50ef2efa680814dfbba44cd5ddb1 Mon Sep 17 00:00:00 2001 From: sabir-akhadov-localstack Date: Tue, 1 Sep 2026 10:53:00 +0200 Subject: [PATCH 2/2] Snowflake: Add ALTER EXTERNAL VOLUME Co-Authored-By: Claude Fable 5 --- src/ast/mod.rs | 77 +++++++++++++++++++++++ src/ast/spans.rs | 1 + src/dialect/snowflake.rs | 57 ++++++++++++++--- src/keywords.rs | 1 + tests/sqlparser_snowflake.rs | 118 +++++++++++++++++++++++++++++++++++ 5 files changed, 245 insertions(+), 9 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 76909ec28..0c2cbaec7 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -4563,6 +4563,11 @@ pub enum Statement { /// See CreateExternalVolume(CreateExternalVolume), /// ```sql + /// ALTER EXTERNAL VOLUME [IF EXISTS] ... + /// ``` + /// See + AlterExternalVolume(AlterExternalVolume), + /// ```sql /// CREATE [ OR REPLACE ] WAREHOUSE [ IF NOT EXISTS ] /// [ [ WITH ] = [ ... ] ] /// ``` @@ -6299,6 +6304,7 @@ impl fmt::Display for Statement { Ok(()) } Statement::CreateExternalVolume(s) => write!(f, "{s}"), + Statement::AlterExternalVolume(s) => write!(f, "{s}"), Statement::CreateWarehouse(s) => write!(f, "{s}"), Statement::CopyIntoSnowflake { kind, @@ -11193,6 +11199,71 @@ impl fmt::Display for CreateExternalVolume { } } +/// ```sql +/// ALTER EXTERNAL VOLUME [IF EXISTS] ... +/// ``` +/// See +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct AlterExternalVolume { + /// External volume name. + pub name: ObjectName, + /// `IF EXISTS` flag. + pub if_exists: bool, + /// The alter operation. + pub operation: AlterExternalVolumeOperation, +} + +impl fmt::Display for AlterExternalVolume { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "ALTER EXTERNAL VOLUME {if_exists}{name} {operation}", + if_exists = if self.if_exists { "IF EXISTS " } else { "" }, + name = self.name, + operation = self.operation, + ) + } +} + +/// Operations for `ALTER EXTERNAL VOLUME`. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AlterExternalVolumeOperation { + /// `ADD STORAGE_LOCATION = ( ... )` + AddStorageLocation(KeyValueOptions), + /// `SET ALLOW_WRITES = TRUE|FALSE` + SetAllowWrites(bool), + /// `REMOVE STORAGE_LOCATION ''` + RemoveStorageLocation(String), +} + +impl fmt::Display for AlterExternalVolumeOperation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AlterExternalVolumeOperation::AddStorageLocation(loc) => { + write!(f, "ADD STORAGE_LOCATION = ({loc})") + } + AlterExternalVolumeOperation::SetAllowWrites(val) => { + write!( + f, + "SET ALLOW_WRITES = {}", + if *val { "TRUE" } else { "FALSE" } + ) + } + AlterExternalVolumeOperation::RemoveStorageLocation(name) => { + write!( + f, + "REMOVE STORAGE_LOCATION '{}'", + value::escape_single_quote_string(name) + ) + } + } + } +} + /// MSSQL's json null clause /// /// ```plaintext @@ -12694,6 +12765,12 @@ impl From for Statement { } } +impl From for Statement { + fn from(a: AlterExternalVolume) -> Self { + Self::AlterExternalVolume(a) + } +} + impl From for Statement { fn from(c: CreateWarehouse) -> Self { Self::CreateWarehouse(c) diff --git a/src/ast/spans.rs b/src/ast/spans.rs index ea813bf75..b6f437bdc 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -524,6 +524,7 @@ impl Spanned for Statement { Statement::AlterUser(..) => Span::empty(), Statement::Reset(..) => Span::empty(), Statement::CreateExternalVolume(..) => Span::empty(), + Statement::AlterExternalVolume(..) => Span::empty(), } } } diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index cca06c8bc..55a5e4909 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -27,15 +27,15 @@ use crate::ast::helpers::stmt_data_loading::{ FileStagingCommand, StageLoadSelectItem, StageLoadSelectItemKind, StageParamsObject, }; use crate::ast::{ - AlterTable, AlterTableOperation, AlterTableType, CatalogSyncNamespaceMode, ColumnOption, - ColumnPolicy, ColumnPolicyProperty, ContactEntry, CopyIntoSnowflakeKind, CreateExternalVolume, - CreateTable, CreateTableLikeKind, DollarQuotedString, Ident, IdentityParameters, - IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, - InitializeKind, Insert, MultiTableInsertIntoClause, MultiTableInsertType, - MultiTableInsertValue, MultiTableInsertValues, MultiTableInsertWhenClause, ObjectName, - ObjectNamePart, RefreshModeKind, RowAccessPolicy, ShowObjects, SqlOption, Statement, - StorageLifecyclePolicy, StorageSerializationPolicy, TableObject, TagsColumnOption, Value, - WrappedCollection, + AlterExternalVolume, AlterExternalVolumeOperation, AlterTable, AlterTableOperation, + AlterTableType, CatalogSyncNamespaceMode, ColumnOption, ColumnPolicy, ColumnPolicyProperty, + ContactEntry, CopyIntoSnowflakeKind, CreateExternalVolume, CreateTable, CreateTableLikeKind, + DollarQuotedString, Ident, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, + IdentityPropertyKind, IdentityPropertyOrder, InitializeKind, Insert, + MultiTableInsertIntoClause, MultiTableInsertType, MultiTableInsertValue, + MultiTableInsertValues, MultiTableInsertWhenClause, ObjectName, ObjectNamePart, + RefreshModeKind, RowAccessPolicy, ShowObjects, SqlOption, Statement, StorageLifecyclePolicy, + StorageSerializationPolicy, TableObject, TagsColumnOption, Value, WrappedCollection, }; use crate::dialect::{Dialect, Precedence}; use crate::keywords::Keyword; @@ -272,6 +272,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_alter_dynamic_table(parser)); } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::EXTERNAL, Keyword::VOLUME]) { + // ALTER EXTERNAL VOLUME + return Some(parse_alter_external_volume(parser)); + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::EXTERNAL, Keyword::TABLE]) { // ALTER EXTERNAL TABLE return Some(parse_alter_external_table(parser)); @@ -2041,6 +2046,40 @@ fn parse_create_external_volume( .into()) } +/// Parse `ALTER EXTERNAL VOLUME [IF EXISTS] ...` +fn parse_alter_external_volume(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_keyword(Keyword::ADD) { + parser.expect_keyword_is(Keyword::STORAGE_LOCATION)?; + parser.expect_token(&Token::Eq)?; + AlterExternalVolumeOperation::AddStorageLocation(parse_external_volume_storage_location( + parser, + )?) + } else if parser.parse_keyword(Keyword::SET) { + parser.expect_keyword_is(Keyword::ALLOW_WRITES)?; + parser.expect_token(&Token::Eq)?; + AlterExternalVolumeOperation::SetAllowWrites(parser.parse_boolean_string()?) + } else if parser.parse_keyword(Keyword::REMOVE) { + parser.expect_keyword_is(Keyword::STORAGE_LOCATION)?; + let loc_name = parser.parse_literal_string()?; + AlterExternalVolumeOperation::RemoveStorageLocation(loc_name) + } else { + return parser.expected( + "ADD, SET, or REMOVE after ALTER EXTERNAL VOLUME ", + parser.peek_token(), + ); + }; + + Ok(AlterExternalVolume { + name, + if_exists, + operation, + } + .into()) +} + /// Parse one parenthesized storage-location option list, e.g. /// `(NAME='loc1' STORAGE_PROVIDER='S3' ...)`. The options (and the /// `ENCRYPTION = (...)` sub-list) are parsed generically via diff --git a/src/keywords.rs b/src/keywords.rs index 29a5e19e9..57ee7aa3e 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -1007,6 +1007,7 @@ define_keywords!( STEP, STORAGE, STORAGE_INTEGRATION, + STORAGE_LOCATION, STORAGE_LOCATIONS, STORAGE_SERIALIZATION_POLICY, STORED, diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 1aa0f9a25..7a7f98455 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -5249,3 +5249,121 @@ fn test_drop_external_volume_if_exists() { _ => unreachable!(), } } + +#[test] +fn test_alter_external_volume_add_storage_location() { + let sql = "ALTER EXTERNAL VOLUME my_vol ADD STORAGE_LOCATION = \ + (NAME='loc2' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket2/')"; + match snowflake().verified_stmt(sql) { + Statement::AlterExternalVolume(AlterExternalVolume { + name, + if_exists, + operation, + }) => { + assert_eq!("my_vol", name.to_string()); + assert!(!if_exists); + match operation { + AlterExternalVolumeOperation::AddStorageLocation(loc) => { + assert_eq!(Some("loc2"), ext_vol_option(&loc.options, "NAME")); + assert_eq!(Some("S3"), ext_vol_option(&loc.options, "STORAGE_PROVIDER")); + } + _ => unreachable!(), + } + } + _ => unreachable!(), + } +} + +#[test] +fn test_alter_external_volume_add_storage_location_full() { + // The ADD path reuses the same option parser, so exercise the optional + // fields (external id + encryption) through it too. + snowflake().verified_stmt( + "ALTER EXTERNAL VOLUME my_vol ADD STORAGE_LOCATION = \ + (NAME='loc2' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket2/' \ + STORAGE_AWS_ROLE_ARN='arn:aws:iam::role/r' \ + STORAGE_AWS_EXTERNAL_ID='ext-id' \ + ENCRYPTION=(TYPE='AWS_SSE_KMS' KMS_KEY_ID='key'))", + ); +} + +#[test] +fn test_alter_external_volume_set_allow_writes() { + match snowflake().verified_stmt("ALTER EXTERNAL VOLUME my_vol SET ALLOW_WRITES = TRUE") { + Statement::AlterExternalVolume(AlterExternalVolume { operation, .. }) => { + assert_eq!( + AlterExternalVolumeOperation::SetAllowWrites(true), + operation + ); + } + _ => unreachable!(), + } + + match snowflake().verified_stmt("ALTER EXTERNAL VOLUME my_vol SET ALLOW_WRITES = FALSE") { + Statement::AlterExternalVolume(AlterExternalVolume { operation, .. }) => { + assert_eq!( + AlterExternalVolumeOperation::SetAllowWrites(false), + operation + ); + } + _ => unreachable!(), + } +} + +#[test] +fn test_alter_external_volume_if_exists() { + match snowflake() + .verified_stmt("ALTER EXTERNAL VOLUME IF EXISTS my_vol SET ALLOW_WRITES = TRUE") + { + Statement::AlterExternalVolume(AlterExternalVolume { if_exists, .. }) => { + assert!(if_exists); + } + _ => unreachable!(), + } +} + +#[test] +fn test_alter_external_volume_remove_storage_location() { + match snowflake().verified_stmt("ALTER EXTERNAL VOLUME my_vol REMOVE STORAGE_LOCATION 'loc1'") { + Statement::AlterExternalVolume(AlterExternalVolume { operation, .. }) => { + assert_eq!( + AlterExternalVolumeOperation::RemoveStorageLocation("loc1".to_string()), + operation + ); + } + _ => unreachable!(), + } +} + +#[test] +fn test_alter_external_volume_add_empty_storage_location() { + let err = snowflake() + .parse_sql_statements("ALTER EXTERNAL VOLUME my_vol ADD STORAGE_LOCATION = ()") + .expect_err("parser must reject an empty storage location"); + assert!( + err.to_string().contains("storage location options"), + "unexpected error: {err}" + ); +} + +#[test] +fn test_alter_external_volume_allow_writes_non_boolean() { + let err = snowflake() + .parse_sql_statements("ALTER EXTERNAL VOLUME my_vol SET ALLOW_WRITES = 1") + .expect_err("parser must reject non-boolean ALLOW_WRITES"); + assert!( + err.to_string().contains("TRUE or FALSE"), + "unexpected error: {err}" + ); +} + +#[test] +fn test_alter_external_volume_missing_operation() { + let err = snowflake() + .parse_sql_statements("ALTER EXTERNAL VOLUME my_vol") + .expect_err("parser must reject ALTER EXTERNAL VOLUME without an operation"); + assert!( + err.to_string().contains("ADD, SET, or REMOVE"), + "unexpected error: {err}" + ); +}