diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 88883cfbb..2b7a4319c 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -4536,6 +4536,52 @@ pub enum Statement { comment: Option, }, /// ```sql + /// CREATE [OR REPLACE] EXTERNAL VOLUME [IF NOT EXISTS] + /// ``` + /// See + CreateExternalVolume { + /// `OR REPLACE` flag. + or_replace: bool, + /// `IF NOT EXISTS` flag. + if_not_exists: bool, + /// External volume name. + name: ObjectName, + /// Storage locations, each a parenthesized list of key-value options + /// (e.g. `(NAME='loc1' STORAGE_PROVIDER='S3' STORAGE_BASE_URL='s3://bucket/')`). + storage_locations: Vec, + /// Optional `ALLOW_WRITES` setting. + allow_writes: Option, + /// Optional comment. + comment: Option, + }, + /// ```sql + /// ALTER EXTERNAL VOLUME [IF EXISTS] ... + /// ``` + AlterExternalVolume { + /// External volume name. + name: ObjectName, + /// `IF EXISTS` flag. + if_exists: bool, + /// The alter operation. + operation: AlterExternalVolumeOperation, + }, + /// ```sql + /// DESC[RIBE] EXTERNAL VOLUME + /// ``` + DescribeExternalVolume { + /// The keyword used, `DESC` or `DESCRIBE`. + describe_alias: DescribeAlias, + /// External volume name. + name: ObjectName, + }, + /// ```sql + /// SHOW EXTERNAL VOLUMES [LIKE ''] + /// ``` + ShowExternalVolumes { + /// Optional filter (e.g. `LIKE`). + filter: Option, + }, + /// ```sql /// ASSERT [AS ] /// ``` Assert { @@ -6261,6 +6307,59 @@ 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} STORAGE_LOCATIONS = (", + or_replace = if *or_replace { "OR REPLACE " } else { "" }, + if_not_exists = if *if_not_exists { "IF NOT EXISTS " } else { "" }, + )?; + for (i, loc) in storage_locations.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "({loc})")?; + } + write!(f, ")")?; + if let Some(val) = allow_writes { + write!(f, " ALLOW_WRITES = {}", if *val { "TRUE" } else { "FALSE" })?; + } + if let Some(ref c) = comment { + write!(f, " COMMENT = '{}'", value::escape_single_quote_string(c))?; + } + Ok(()) + } + Statement::AlterExternalVolume { + name, + if_exists, + operation, + } => { + write!( + f, + "ALTER EXTERNAL VOLUME {if_exists}{name} {operation}", + if_exists = if *if_exists { "IF EXISTS " } else { "" }, + ) + } + Statement::DescribeExternalVolume { + describe_alias, + name, + } => { + write!(f, "{describe_alias} EXTERNAL VOLUME {name}") + } + Statement::ShowExternalVolumes { filter } => { + write!(f, "SHOW EXTERNAL VOLUMES")?; + if let Some(ref filter) = filter { + write!(f, " {filter}")?; + } + Ok(()) + } Statement::CopyIntoSnowflake { kind, into, @@ -8579,6 +8678,8 @@ pub enum ObjectType { User, /// A stream. Stream, + /// A Snowflake external volume. + ExternalVolume, } impl fmt::Display for ObjectType { @@ -8597,6 +8698,7 @@ impl fmt::Display for ObjectType { ObjectType::Type => "TYPE", ObjectType::User => "USER", ObjectType::Stream => "STREAM", + ObjectType::ExternalVolume => "EXTERNAL VOLUME", }) } } @@ -11021,6 +11123,43 @@ pub struct ShowObjects { pub show_options: ShowStatementOptions, } +/// 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 diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 6505b209e..f40a93027 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -522,6 +522,10 @@ impl Spanned for Statement { Statement::Vacuum(..) => Span::empty(), Statement::AlterUser(..) => Span::empty(), Statement::Reset(..) => Span::empty(), + Statement::CreateExternalVolume { .. } => Span::empty(), + Statement::AlterExternalVolume { .. } => Span::empty(), + Statement::DescribeExternalVolume { .. } => Span::empty(), + Statement::ShowExternalVolumes { .. } => Span::empty(), } } } diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index b76cbc55e..9e63ecbc0 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -27,14 +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, 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, + AlterExternalVolumeOperation, AlterTable, AlterTableOperation, AlterTableType, + CatalogSyncNamespaceMode, ColumnOption, ColumnPolicy, ColumnPolicyProperty, ContactEntry, + CopyIntoSnowflakeKind, CreateTable, CreateTableLikeKind, DescribeAlias, 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; @@ -266,6 +267,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)); @@ -281,10 +287,30 @@ impl Dialect for SnowflakeDialect { return Some(parse_alter_session(parser, set)); } + if let Some(kw) = parser.parse_one_of_keywords(&[Keyword::DESC, Keyword::DESCRIBE]) { + if parser.parse_keywords(&[Keyword::EXTERNAL, Keyword::VOLUME]) { + // DESC[RIBE] EXTERNAL VOLUME + let describe_alias = if kw == Keyword::DESC { + DescribeAlias::Desc + } else { + DescribeAlias::Describe + }; + return Some(parse_describe_external_volume(describe_alias, parser)); + } + // not EXTERNAL VOLUME — put back DESC/DESCRIBE + parser.prev_token(); + } + if parser.parse_keyword(Keyword::CREATE) { // 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), @@ -363,6 +389,9 @@ impl Dialect for SnowflakeDialect { } if parser.parse_keyword(Keyword::SHOW) { + if parser.parse_keywords(&[Keyword::EXTERNAL, Keyword::VOLUMES]) { + return Some(parse_show_external_volumes(parser)); + } let terse = parser.parse_keyword(Keyword::TERSE); if parser.parse_keyword(Keyword::OBJECTS) { return Some(parse_show_objects(terse, parser)); @@ -1983,3 +2012,113 @@ 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(Statement::CreateExternalVolume { + or_replace, + if_not_exists, + name, + storage_locations, + allow_writes, + comment, + }) +} + +/// 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(Statement::AlterExternalVolume { + name, + if_exists, + operation, + }) +} + +/// 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) +} + +/// Parse `DESC[RIBE] EXTERNAL VOLUME ` +fn parse_describe_external_volume( + describe_alias: DescribeAlias, + parser: &mut Parser, +) -> Result { + let name = parser.parse_object_name(false)?; + Ok(Statement::DescribeExternalVolume { + describe_alias, + name, + }) +} + +/// Parse `SHOW EXTERNAL VOLUMES [LIKE '']` +fn parse_show_external_volumes(parser: &mut Parser) -> Result { + let filter = parser.parse_show_statement_filter()?; + Ok(Statement::ShowExternalVolumes { filter }) +} diff --git a/src/keywords.rs b/src/keywords.rs index c2d0a47ec..d3904a4cb 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -112,6 +112,7 @@ define_keywords!( ALL, ALLOCATE, ALLOWOVERWRITE, + ALLOW_WRITES, ALTER, ALWAYS, ANALYZE, @@ -1005,6 +1006,8 @@ define_keywords!( STEP, STORAGE, STORAGE_INTEGRATION, + STORAGE_LOCATION, + STORAGE_LOCATIONS, STORAGE_SERIALIZATION_POLICY, STORED, STRAIGHT_JOIN, @@ -1161,6 +1164,7 @@ define_keywords!( VIRTUAL, VOLATILE, VOLUME, + VOLUMES, WAITFOR, WAREHOUSE, WAREHOUSES, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index ac1b5e376..a4c23b2a0 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7581,6 +7581,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::FUNCTION) { return self.parse_drop_function().map(Into::into); } else if self.parse_keyword(Keyword::POLICY) { @@ -7608,7 +7610,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 or USER after DROP", + "COLLATION, CONNECTOR, DATABASE, EXTENSION, EXTERNAL VOLUME, FUNCTION, INDEX, OPERATOR, POLICY, PROCEDURE, ROLE, SCHEMA, SECRET, SEQUENCE, STAGE, TABLE, TRIGGER, TYPE, VIEW, MATERIALIZED VIEW or USER after DROP", self.peek_token_ref(), ); }; diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 6bebd5863..ee1ca862c 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -4914,3 +4914,509 @@ 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 { + 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 { 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 { 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 { + 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 { + 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 { + 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 { 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 { + 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 { + 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_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 { + 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 { operation, .. } => { + assert_eq!( + AlterExternalVolumeOperation::SetAllowWrites(true), + operation + ); + } + _ => unreachable!(), + } + + match snowflake().verified_stmt("ALTER EXTERNAL VOLUME my_vol SET ALLOW_WRITES = FALSE") { + Statement::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 { 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 { 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}" + ); +} + +#[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!(), + } +} + +#[test] +fn test_describe_external_volume() { + match snowflake().verified_stmt("DESCRIBE EXTERNAL VOLUME my_vol") { + Statement::DescribeExternalVolume { + describe_alias, + name, + } => { + assert_eq!(DescribeAlias::Describe, describe_alias); + assert_eq!("my_vol", name.to_string()); + } + _ => unreachable!(), + } +} + +#[test] +fn test_desc_external_volume() { + // The DESC spelling is preserved on round-trip. + match snowflake().verified_stmt("DESC EXTERNAL VOLUME my_vol") { + Statement::DescribeExternalVolume { + describe_alias, + name, + } => { + assert_eq!(DescribeAlias::Desc, describe_alias); + assert_eq!("my_vol", name.to_string()); + } + _ => unreachable!(), + } +} + +#[test] +fn test_show_external_volumes() { + match snowflake().verified_stmt("SHOW EXTERNAL VOLUMES") { + Statement::ShowExternalVolumes { filter } => { + assert!(filter.is_none()); + } + _ => unreachable!(), + } +} + +#[test] +fn test_show_external_volumes_like() { + match snowflake().verified_stmt("SHOW EXTERNAL VOLUMES LIKE 'my_%'") { + Statement::ShowExternalVolumes { + filter: Some(ShowStatementFilter::Like(pattern)), + } => { + assert_eq!("my_%", pattern); + } + _ => unreachable!(), + } +}