diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index ad3191ebc..eda182cff 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -371,10 +371,50 @@ pub enum AlterTableOperation { DropClusteringKey, SuspendRecluster, ResumeRecluster, - /// `REFRESH` + /// `REFRESH [ '' ]` /// - /// Note: this is Snowflake specific for dynamic tables - Refresh, + /// Note: this is Snowflake specific for dynamic/external tables + /// + /// + Refresh { + /// Optional subpath for external table refresh + subpath: Option, + }, + /// `ADD PARTITION COLUMN ` + /// + /// Note: this is Snowflake specific for external tables + AddPartitionColumn { + 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 @@ -425,6 +465,16 @@ pub enum AlterTableOperation { SetOptionsParens { options: Vec, }, + /// `SET key = value` options without parentheses. + /// + /// Example: + /// ```sql + /// SET AUTO_REFRESH = TRUE + /// ``` + /// [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/alter-external-table) + SetOptions { + options: Vec, + }, } /// An `ALTER Policy` (`Statement::AlterPolicy`) operation @@ -863,8 +913,58 @@ impl fmt::Display for AlterTableOperation { write!(f, "RESUME RECLUSTER")?; Ok(()) } - AlterTableOperation::Refresh => { - write!(f, "REFRESH") + AlterTableOperation::Refresh { subpath } => { + write!(f, "REFRESH")?; + if let Some(path) = subpath { + write!(f, " '{path}'")?; + } + Ok(()) + } + AlterTableOperation::AddPartitionColumn { + column_name, + data_type, + } => { + 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") @@ -892,6 +992,9 @@ impl fmt::Display for AlterTableOperation { AlterTableOperation::SetOptionsParens { options } => { write!(f, "SET ({})", display_comma_separated(options)) } + AlterTableOperation::SetOptions { options } => { + write!(f, "SET {}", display_comma_separated(options)) + } } } } @@ -3880,8 +3983,11 @@ pub enum AlterTableType { /// Iceberg, /// Dynamic table type - /// + /// Dynamic, + /// External table type + /// + External, } /// ALTER TABLE statement @@ -3911,16 +4017,32 @@ impl fmt::Display for AlterTable { match &self.table_type { Some(AlterTableType::Iceberg) => write!(f, "ALTER ICEBERG TABLE ")?, Some(AlterTableType::Dynamic) => write!(f, "ALTER DYNAMIC TABLE ")?, + Some(AlterTableType::External) => write!(f, "ALTER EXTERNAL TABLE ")?, 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 684cc5b02..96feab899 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -1145,7 +1145,14 @@ impl Spanned for AlterTableOperation { AlterTableOperation::DropClusteringKey => Span::empty(), AlterTableOperation::SuspendRecluster => Span::empty(), AlterTableOperation::ResumeRecluster => Span::empty(), - AlterTableOperation::Refresh => 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(), @@ -1156,6 +1163,9 @@ impl Spanned for AlterTableOperation { AlterTableOperation::SetOptionsParens { options } => { union_spans(options.iter().map(|i| i.span())) } + AlterTableOperation::SetOptions { options } => { + union_spans(options.iter().map(|i| i.span())) + } } } } diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 4cfaddceb..0665ddb80 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -31,7 +31,7 @@ use crate::ast::{ ColumnPolicy, ColumnPolicyProperty, ContactEntry, CopyIntoSnowflakeKind, CreateTableLikeKind, DollarQuotedString, Ident, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, InitializeKind, ObjectName, ObjectNamePart, - RefreshModeKind, RowAccessPolicy, ShowObjects, SqlOption, Statement, + RefreshModeKind, RenameTableNameKind, RowAccessPolicy, ShowObjects, SqlOption, Statement, StorageSerializationPolicy, TagsColumnOption, Value, WrappedCollection, }; use crate::dialect::{Dialect, Precedence}; @@ -221,6 +221,11 @@ impl Dialect for SnowflakeDialect { return Some(parse_alter_dynamic_table(parser)); } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::EXTERNAL, Keyword::TABLE]) { + // ALTER EXTERNAL TABLE + return Some(parse_alter_external_table(parser)); + } + if parser.parse_keywords(&[Keyword::ALTER, Keyword::SESSION]) { // ALTER SESSION let set = match parser.parse_one_of_keywords(&[Keyword::SET, Keyword::UNSET]) { @@ -619,7 +624,7 @@ fn parse_alter_dynamic_table(parser: &mut Parser) -> Result Result +fn parse_alter_external_table(parser: &mut Parser) -> Result { + // 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 + let subpath = match parser.peek_token().token { + Token::SingleQuotedString(s) => { + parser.next_token(); + Some(s) + } + _ => None, + }; + AlterTableOperation::Refresh { subpath } + } else if parser.parse_keywords(&[Keyword::RENAME, Keyword::TO]) { + let new_table_name = parser.parse_object_name(false)?; + AlterTableOperation::RenameTable { + table_name: RenameTableNameKind::To(new_table_name), + } + } else if parser.parse_keywords(&[Keyword::ADD, Keyword::PARTITION, Keyword::COLUMN]) { + let column_name = parser.parse_identifier()?; + let data_type = parser.parse_data_type()?; + AlterTableOperation::AddPartitionColumn { + column_name, + data_type, + } + } else if parser.parse_keywords(&[Keyword::ADD, Keyword::PARTITION]) { + // ADD PARTITION ( = '' [, ...] ) 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![]; + loop { + let key = parser.parse_identifier()?; + parser.expect_token(&Token::Eq)?; + let value = parser.parse_expr()?; + options.push(SqlOption::KeyValue { key, value }); + if !parser.consume_token(&Token::Comma) { + break; + } + } + AlterTableOperation::SetOptions { options } + } else { + return parser.expected( + "REFRESH, RENAME TO, ADD, DROP, or SET after ALTER EXTERNAL TABLE", + parser.peek_token(), + ); + }; + + let end_token = if parser.peek_token_ref().token == Token::SemiColon { + parser.peek_token_ref().clone() + } else { + parser.get_current_token().clone() + }; + + Ok(Statement::AlterTable(AlterTable { + name: table_name, + if_exists, + only: false, + operations: vec![operation], + location: None, + on_cluster: None, + table_type: Some(AlterTableType::External), + end_token: AttachedToken(end_token), + })) +} + +/// Parse a parenthesized list of single-quoted file paths. +fn parse_parenthesized_file_list(parser: &mut Parser) -> 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/src/parser/mod.rs b/src/parser/mod.rs index b2fa3b169..fc104a5fa 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -9778,6 +9778,7 @@ impl<'a> Parser<'a> { Keyword::POLICY, Keyword::CONNECTOR, Keyword::ICEBERG, + Keyword::EXTERNAL, Keyword::SCHEMA, Keyword::USER, ])?; @@ -9789,10 +9790,14 @@ impl<'a> Parser<'a> { } Keyword::VIEW => self.parse_alter_view(), Keyword::TYPE => self.parse_alter_type(), - Keyword::TABLE => self.parse_alter_table(false), + Keyword::TABLE => self.parse_alter_table(None), Keyword::ICEBERG => { self.expect_keyword(Keyword::TABLE)?; - self.parse_alter_table(true) + self.parse_alter_table(Some(AlterTableType::Iceberg)) + } + Keyword::EXTERNAL => { + self.expect_keyword(Keyword::TABLE)?; + self.parse_alter_table(Some(AlterTableType::External)) } Keyword::INDEX => { let index_name = self.parse_object_name(false)?; @@ -9822,7 +9827,10 @@ impl<'a> Parser<'a> { } /// Parse a [Statement::AlterTable] - pub fn parse_alter_table(&mut self, iceberg: bool) -> Result { + pub fn parse_alter_table( + &mut self, + table_type: Option, + ) -> Result { let if_exists = self.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); let only = self.parse_keyword(Keyword::ONLY); // [ ONLY ] let table_name = self.parse_object_name(false)?; @@ -9855,11 +9863,7 @@ impl<'a> Parser<'a> { operations, location, on_cluster, - table_type: if iceberg { - Some(AlterTableType::Iceberg) - } else { - None - }, + table_type, end_token: AttachedToken(end_token), } .into()) diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 22a632666..e64fc133c 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs @@ -4635,3 +4635,39 @@ fn test_alter_dynamic_table() { snowflake().verified_stmt("ALTER DYNAMIC TABLE my_dyn_table SUSPEND"); snowflake().verified_stmt("ALTER DYNAMIC TABLE my_dyn_table RESUME"); } + +#[test] +fn test_alter_external_table() { + snowflake().verified_stmt("ALTER EXTERNAL TABLE some_table REFRESH"); + snowflake().verified_stmt("ALTER EXTERNAL TABLE some_table REFRESH 'year=2025/month=12/'"); + snowflake().verified_stmt("ALTER EXTERNAL TABLE my_database.my_schema.my_external_table REFRESH"); + 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/'", + ); +}