diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index eff328d5..eda182cf 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -387,6 +387,34 @@ pub enum AlterTableOperation { column_name: Ident, data_type: DataType, }, + /// `ADD FILES ( '' [, '', ...] )` + /// + /// Note: this is Snowflake specific for external tables + AddFiles { + files: Vec, + }, + /// `REMOVE FILES ( '' [, '', ...] )` + /// + /// Note: this is Snowflake specific for external tables + RemoveFiles { + files: Vec, + }, + /// `ADD PARTITION ( = '' [, ...] ) LOCATION ''` + /// + /// Note: this is Snowflake specific for external tables + AddPartition { + /// Partition column values as key-value pairs + partition: Vec<(Ident, String)>, + /// Location path + location: String, + }, + /// `DROP PARTITION LOCATION ''` + /// + /// Note: this is Snowflake specific for external tables + DropPartitionLocation { + /// Location path + location: String, + }, /// `SUSPEND` /// /// Note: this is Snowflake specific for dynamic tables @@ -898,6 +926,46 @@ impl fmt::Display for AlterTableOperation { } => { write!(f, "ADD PARTITION COLUMN {column_name} {data_type}") } + AlterTableOperation::AddFiles { files } => { + write!( + f, + "ADD FILES ({})", + files + .iter() + .map(|f| format!("'{f}'")) + .collect::>() + .join(", ") + ) + } + AlterTableOperation::RemoveFiles { files } => { + write!( + f, + "REMOVE FILES ({})", + files + .iter() + .map(|f| format!("'{f}'")) + .collect::>() + .join(", ") + ) + } + AlterTableOperation::AddPartition { + partition, + location, + } => { + write!( + f, + "ADD PARTITION ({}) LOCATION '{}'", + partition + .iter() + .map(|(k, v)| format!("{k} = '{v}'")) + .collect::>() + .join(", "), + location + ) + } + AlterTableOperation::DropPartitionLocation { location } => { + write!(f, "DROP PARTITION LOCATION '{location}'") + } AlterTableOperation::Suspend => { write!(f, "SUSPEND") } @@ -3953,13 +4021,28 @@ impl fmt::Display for AlterTable { None => write!(f, "ALTER TABLE ")?, } - if self.if_exists { + // For external table ADD PARTITION / DROP PARTITION operations, + // IF EXISTS comes after the table name per Snowflake syntax + let if_exists_after_table_name = self.table_type == Some(AlterTableType::External) + && self.operations.iter().any(|op| { + matches!( + op, + AlterTableOperation::AddPartition { .. } + | AlterTableOperation::DropPartitionLocation { .. } + ) + }); + + if self.if_exists && !if_exists_after_table_name { write!(f, "IF EXISTS ")?; } if self.only { write!(f, "ONLY ")?; } - write!(f, "{} ", &self.name)?; + write!(f, "{}", &self.name)?; + if self.if_exists && if_exists_after_table_name { + write!(f, " IF EXISTS")?; + } + write!(f, " ")?; if let Some(cluster) = &self.on_cluster { write!(f, "ON CLUSTER {cluster} ")?; } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 0f971a54..96feab89 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -1147,6 +1147,12 @@ impl Spanned for AlterTableOperation { AlterTableOperation::ResumeRecluster => Span::empty(), AlterTableOperation::Refresh { .. } => Span::empty(), AlterTableOperation::AddPartitionColumn { column_name, .. } => column_name.span, + AlterTableOperation::AddFiles { .. } => Span::empty(), + AlterTableOperation::RemoveFiles { .. } => Span::empty(), + AlterTableOperation::AddPartition { partition, .. } => { + union_spans(partition.iter().map(|(k, _)| k.span)) + } + AlterTableOperation::DropPartitionLocation { .. } => Span::empty(), AlterTableOperation::Suspend => Span::empty(), AlterTableOperation::Resume => Span::empty(), AlterTableOperation::Algorithm { .. } => Span::empty(), diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index a76d1399..0665ddb8 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -657,9 +657,15 @@ fn parse_alter_dynamic_table(parser: &mut Parser) -> Result fn parse_alter_external_table(parser: &mut Parser) -> Result { - let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + // IF EXISTS can appear before the table name for most operations + let mut if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); let table_name = parser.parse_object_name(true)?; + // IF EXISTS can also appear after the table name for ADD/DROP PARTITION operations + if !if_exists { + if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + } + // Parse the operation let operation = if parser.parse_keyword(Keyword::REFRESH) { // Optional subpath for refreshing specific partitions @@ -683,6 +689,27 @@ fn parse_alter_external_table(parser: &mut Parser) -> Result = '' [, ...] ) LOCATION '' + let partition = parse_partition_key_values(parser)?; + parser.expect_keyword(Keyword::LOCATION)?; + let location = parse_single_quoted_string(parser)?; + AlterTableOperation::AddPartition { + partition, + location, + } + } else if parser.parse_keywords(&[Keyword::DROP, Keyword::PARTITION, Keyword::LOCATION]) { + // DROP PARTITION LOCATION '' + let location = parse_single_quoted_string(parser)?; + AlterTableOperation::DropPartitionLocation { location } + } else if parser.parse_keywords(&[Keyword::ADD, Keyword::FILES]) { + // Parse ADD FILES ( '' [, '', ...] ) + let files = parse_parenthesized_file_list(parser)?; + AlterTableOperation::AddFiles { files } + } else if parser.parse_keywords(&[Keyword::REMOVE, Keyword::FILES]) { + // Parse REMOVE FILES ( '' [, '', ...] ) + let files = parse_parenthesized_file_list(parser)?; + AlterTableOperation::RemoveFiles { files } } else if parser.parse_keyword(Keyword::SET) { // Parse SET key = value options (e.g., SET AUTO_REFRESH = TRUE) let mut options = vec![]; @@ -698,7 +725,7 @@ fn parse_alter_external_table(parser: &mut Parser) -> Result Result Result, ParserError> { + parser.expect_token(&Token::LParen)?; + let mut files = vec![]; + loop { + match parser.next_token().token { + Token::SingleQuotedString(s) => files.push(s), + _ => { + return parser.expected("a single-quoted string", parser.peek_token()); + } + } + if !parser.consume_token(&Token::Comma) { + break; + } + } + parser.expect_token(&Token::RParen)?; + Ok(files) +} + +/// Parse partition key-value pairs: ( = '' [, = '', ...] ) +fn parse_partition_key_values(parser: &mut Parser) -> Result, ParserError> { + parser.expect_token(&Token::LParen)?; + let mut pairs = vec![]; + loop { + let key = parser.parse_identifier()?; + parser.expect_token(&Token::Eq)?; + let value = parse_single_quoted_string(parser)?; + pairs.push((key, value)); + if !parser.consume_token(&Token::Comma) { + break; + } + } + parser.expect_token(&Token::RParen)?; + Ok(pairs) +} + +/// Parse a single-quoted string and return its content. +fn parse_single_quoted_string(parser: &mut Parser) -> Result { + match parser.next_token().token { + Token::SingleQuotedString(s) => Ok(s), + _ => parser.expected("a single-quoted string", parser.peek_token()), + } +} + /// Parse snowflake alter session. /// fn parse_alter_session(parser: &mut Parser, set: bool) -> Result { diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 005bd7c3..e64fc133 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -4644,4 +4644,30 @@ fn test_alter_external_table() { snowflake().verified_stmt("ALTER EXTERNAL TABLE some_table RENAME TO new_table_name"); snowflake().verified_stmt("ALTER EXTERNAL TABLE some_table ADD PARTITION COLUMN column_name VARCHAR"); snowflake().verified_stmt("ALTER EXTERNAL TABLE some_table SET AUTO_REFRESH = true"); + snowflake().verified_stmt("ALTER EXTERNAL TABLE some_table ADD FILES ('file1.parquet')"); + snowflake().verified_stmt( + "ALTER EXTERNAL TABLE some_table ADD FILES ('path/file1.parquet', 'path/file2.parquet')", + ); + snowflake().verified_stmt("ALTER EXTERNAL TABLE some_table REMOVE FILES ('file1.parquet')"); + snowflake().verified_stmt( + "ALTER EXTERNAL TABLE some_table REMOVE FILES ('path/file1.parquet', 'path/file2.parquet')", + ); + // ADD PARTITION with location + snowflake() + .verified_stmt("ALTER EXTERNAL TABLE some_table ADD PARTITION (year = '2024') LOCATION 's3://bucket/path/'"); + snowflake().verified_stmt( + "ALTER EXTERNAL TABLE some_table ADD PARTITION (year = '2024', month = '12') LOCATION 's3://bucket/path/'", + ); + // DROP PARTITION location + snowflake() + .verified_stmt("ALTER EXTERNAL TABLE some_table DROP PARTITION LOCATION 's3://bucket/path/'"); + // Test IF EXISTS (before table name for most operations) + snowflake().verified_stmt("ALTER EXTERNAL TABLE IF EXISTS some_table REFRESH"); + // Test IF EXISTS (after table name for ADD/DROP PARTITION per Snowflake syntax) + snowflake().verified_stmt( + "ALTER EXTERNAL TABLE some_table IF EXISTS ADD PARTITION (year = '2024') LOCATION 's3://bucket/path/'", + ); + snowflake().verified_stmt( + "ALTER EXTERNAL TABLE some_table IF EXISTS DROP PARTITION LOCATION 's3://bucket/path/'", + ); }