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!(), + } +}