Skip to content

Commit dde3a2f

Browse files
committed
Snowflake: parse CREATE / DROP ICEBERG TABLE
Add CATALOG_TABLE_NAME and AUTO_REFRESH to CreateTable for externally-managed Iceberg tables, accept comma- or space-separated Iceberg options in free order, relax the BASE_LOCATION requirement for externally-managed tables, and parse DROP ICEBERG TABLE [IF EXISTS] <name> [PURGE].
1 parent 49b5093 commit dde3a2f

10 files changed

Lines changed: 173 additions & 3 deletions

File tree

src/ast/ddl.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3073,6 +3073,12 @@ pub struct CreateTable {
30733073
/// Snowflake "CATALOG" clause for Iceberg tables
30743074
/// <https://docs.snowflake.com/en/sql-reference/sql/create-iceberg-table>
30753075
pub catalog: Option<String>,
3076+
/// Snowflake "CATALOG_TABLE_NAME" clause for externally-managed Iceberg tables
3077+
/// <https://docs.snowflake.com/en/sql-reference/sql/create-iceberg-table>
3078+
pub catalog_table_name: Option<String>,
3079+
/// Snowflake "AUTO_REFRESH" clause for externally-managed Iceberg tables
3080+
/// <https://docs.snowflake.com/en/sql-reference/sql/create-iceberg-table>
3081+
pub auto_refresh: Option<bool>,
30763082
/// Snowflake "CATALOG_SYNC" clause for Iceberg tables
30773083
/// <https://docs.snowflake.com/en/sql-reference/sql/create-iceberg-table>
30783084
pub catalog_sync: Option<String>,
@@ -3162,8 +3168,10 @@ impl fmt::Display for CreateTable {
31623168
&& self.like.is_none()
31633169
&& self.clone.is_none()
31643170
&& self.partition_of.is_none()
3171+
&& !self.iceberg
31653172
{
3166-
// PostgreSQL allows `CREATE TABLE t ();`, but requires empty parens
3173+
// PostgreSQL allows `CREATE TABLE t ();`, but requires empty parens.
3174+
// Externally-managed Iceberg tables legitimately have no column list.
31673175
f.write_str(" ()")?;
31683176
} else if let Some(CreateTableLikeKind::Parenthesized(like_in_columns_list)) = &self.like {
31693177
write!(f, " ({like_in_columns_list})")?;
@@ -3307,6 +3315,18 @@ impl fmt::Display for CreateTable {
33073315
write!(f, " CATALOG='{catalog}'")?;
33083316
}
33093317

3318+
if let Some(catalog_table_name) = self.catalog_table_name.as_ref() {
3319+
write!(f, " CATALOG_TABLE_NAME='{catalog_table_name}'")?;
3320+
}
3321+
3322+
if let Some(auto_refresh) = self.auto_refresh {
3323+
write!(
3324+
f,
3325+
" AUTO_REFRESH={}",
3326+
if auto_refresh { "TRUE" } else { "FALSE" }
3327+
)?;
3328+
}
3329+
33103330
if self.iceberg {
33113331
if let Some(base_location) = self.base_location.as_ref() {
33123332
write!(f, " BASE_LOCATION='{base_location}'")?;

src/ast/helpers/stmt_create_table.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ pub struct CreateTableBuilder {
159159
pub external_volume: Option<String>,
160160
/// Optional catalog name.
161161
pub catalog: Option<String>,
162+
/// Optional externally-managed catalog table name.
163+
pub catalog_table_name: Option<String>,
164+
/// Optional auto-refresh flag for externally-managed Iceberg tables.
165+
pub auto_refresh: Option<bool>,
162166
/// Optional catalog synchronization option.
163167
pub catalog_sync: Option<String>,
164168
/// Optional storage serialization policy.
@@ -236,6 +240,8 @@ impl CreateTableBuilder {
236240
base_location: None,
237241
external_volume: None,
238242
catalog: None,
243+
catalog_table_name: None,
244+
auto_refresh: None,
239245
catalog_sync: None,
240246
storage_serialization_policy: None,
241247
table_options: CreateTableOptions::None,
@@ -606,6 +612,8 @@ impl CreateTableBuilder {
606612
base_location: self.base_location,
607613
external_volume: self.external_volume,
608614
catalog: self.catalog,
615+
catalog_table_name: self.catalog_table_name,
616+
auto_refresh: self.auto_refresh,
609617
catalog_sync: self.catalog_sync,
610618
storage_serialization_policy: self.storage_serialization_policy,
611619
table_options: self.table_options,
@@ -687,6 +695,8 @@ impl From<CreateTable> for CreateTableBuilder {
687695
base_location: table.base_location,
688696
external_volume: table.external_volume,
689697
catalog: table.catalog,
698+
catalog_table_name: table.catalog_table_name,
699+
auto_refresh: table.auto_refresh,
690700
catalog_sync: table.catalog_sync,
691701
storage_serialization_policy: table.storage_serialization_policy,
692702
table_options: table.table_options,

src/ast/spans.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,8 @@ impl Spanned for CreateTable {
638638
external_volume: _, // todo, Snowflake specific
639639
base_location: _, // todo, Snowflake specific
640640
catalog: _, // todo, Snowflake specific
641+
catalog_table_name: _, // todo, Snowflake specific
642+
auto_refresh: _, // todo, Snowflake specific
641643
catalog_sync: _, // todo, Snowflake specific
642644
storage_serialization_policy: _,
643645
table_options,

src/dialect/snowflake.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1109,6 +1109,14 @@ pub fn parse_create_table(
11091109
parser.expect_token(&Token::Eq)?;
11101110
builder.catalog = Some(parser.parse_literal_string()?);
11111111
}
1112+
Keyword::CATALOG_TABLE_NAME => {
1113+
parser.expect_token(&Token::Eq)?;
1114+
builder.catalog_table_name = Some(parser.parse_literal_string()?);
1115+
}
1116+
Keyword::AUTO_REFRESH => {
1117+
parser.expect_token(&Token::Eq)?;
1118+
builder.auto_refresh = Some(parser.parse_boolean_string()?);
1119+
}
11121120
Keyword::BASE_LOCATION => {
11131121
parser.expect_token(&Token::Eq)?;
11141122
builder.base_location = Some(parser.parse_literal_string()?);
@@ -1178,6 +1186,8 @@ pub fn parse_create_table(
11781186
let (columns, constraints) = parser.parse_columns()?;
11791187
builder = builder.columns(columns).constraints(constraints);
11801188
}
1189+
// Snowflake accepts Iceberg table options either space- or comma-separated.
1190+
Token::Comma => {}
11811191
Token::EOF => {
11821192
break;
11831193
}
@@ -1198,7 +1208,9 @@ pub fn parse_create_table(
11981208

11991209
builder = builder.table_options(table_options);
12001210

1201-
if iceberg && builder.base_location.is_none() {
1211+
// Managed Iceberg tables require BASE_LOCATION; externally-managed ones
1212+
// (identified by CATALOG_TABLE_NAME) do not.
1213+
if iceberg && builder.base_location.is_none() && builder.catalog_table_name.is_none() {
12021214
return Err(ParserError::ParserError(
12031215
"BASE_LOCATION is required for ICEBERG tables".to_string(),
12041216
));

src/keywords.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ define_keywords!(
147147
AUTOEXTEND_SIZE,
148148
AUTOINCREMENT,
149149
AUTO_INCREMENT,
150+
AUTO_REFRESH,
150151
AVG,
151152
AVG_ROW_LENGTH,
152153
AVRO,
@@ -213,6 +214,7 @@ define_keywords!(
213214
CATALOG_SYNC,
214215
CATALOG_SYNC_NAMESPACE_FLATTEN_DELIMITER,
215216
CATALOG_SYNC_NAMESPACE_MODE,
217+
CATALOG_TABLE_NAME,
216218
CATALOG_URI,
217219
CATCH,
218220
CATEGORY,

src/parser/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7788,7 +7788,9 @@ impl<'a> Parser<'a> {
77887788
let persistent = dialect_of!(self is DuckDbDialect)
77897789
&& self.parse_one_of_keywords(&[Keyword::PERSISTENT]).is_some();
77907790

7791-
let object_type = if self.parse_keyword(Keyword::TABLE) {
7791+
let object_type = if self.parse_keyword(Keyword::TABLE)
7792+
|| self.parse_keywords(&[Keyword::ICEBERG, Keyword::TABLE])
7793+
{
77927794
ObjectType::Table
77937795
} else if self.parse_keyword(Keyword::COLLATION) {
77947796
ObjectType::Collation

tests/sqlparser_duckdb.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,8 @@ fn test_duckdb_union_datatype() {
781781
base_location: Default::default(),
782782
external_volume: Default::default(),
783783
catalog: Default::default(),
784+
catalog_table_name: Default::default(),
785+
auto_refresh: Default::default(),
784786
catalog_sync: Default::default(),
785787
storage_serialization_policy: Default::default(),
786788
table_options: CreateTableOptions::None,

tests/sqlparser_mssql.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2006,6 +2006,8 @@ fn parse_create_table_with_valid_options() {
20062006
base_location: None,
20072007
external_volume: None,
20082008
catalog: None,
2009+
catalog_table_name: None,
2010+
auto_refresh: None,
20092011
catalog_sync: None,
20102012
storage_serialization_policy: None,
20112013
table_options: CreateTableOptions::With(with_options),
@@ -2180,6 +2182,8 @@ fn parse_create_table_with_identity_column() {
21802182
base_location: None,
21812183
external_volume: None,
21822184
catalog: None,
2185+
catalog_table_name: None,
2186+
auto_refresh: None,
21832187
catalog_sync: None,
21842188
storage_serialization_policy: None,
21852189
table_options: CreateTableOptions::None,

tests/sqlparser_postgres.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6710,6 +6710,8 @@ fn parse_trigger_related_functions() {
67106710
base_location: None,
67116711
external_volume: None,
67126712
catalog: None,
6713+
catalog_table_name: None,
6714+
auto_refresh: None,
67136715
catalog_sync: None,
67146716
storage_serialization_policy: None,
67156717
table_options: CreateTableOptions::None,

tests/sqlparser_snowflake.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,120 @@ fn test_snowflake_create_iceberg_table_without_location() {
10511051
);
10521052
}
10531053

1054+
#[test]
1055+
fn test_snowflake_create_iceberg_table_comma_separated_options() {
1056+
let canonical = "CREATE ICEBERG TABLE my_table (a INT) \
1057+
EXTERNAL_VOLUME='volume' CATALOG='SNOWFLAKE' BASE_LOCATION='relative/path'";
1058+
let with_commas = "CREATE ICEBERG TABLE my_table (a INT) \
1059+
CATALOG='SNOWFLAKE', EXTERNAL_VOLUME='volume', BASE_LOCATION='relative/path'";
1060+
match snowflake().one_statement_parses_to(with_commas, canonical) {
1061+
Statement::CreateTable(CreateTable {
1062+
iceberg,
1063+
columns,
1064+
external_volume,
1065+
catalog,
1066+
base_location,
1067+
..
1068+
}) => {
1069+
assert!(iceberg);
1070+
assert_eq!(1, columns.len());
1071+
assert_eq!("volume", external_volume.unwrap());
1072+
assert_eq!("SNOWFLAKE", catalog.unwrap());
1073+
assert_eq!("relative/path", base_location.unwrap());
1074+
}
1075+
_ => unreachable!(),
1076+
}
1077+
}
1078+
1079+
#[test]
1080+
fn test_snowflake_create_iceberg_table_externally_managed() {
1081+
match snowflake().verified_stmt(
1082+
"CREATE OR REPLACE ICEBERG TABLE my_table \
1083+
CATALOG='my_catalog_integration' CATALOG_TABLE_NAME='db.tbl' AUTO_REFRESH=TRUE",
1084+
) {
1085+
Statement::CreateTable(CreateTable {
1086+
or_replace,
1087+
iceberg,
1088+
columns,
1089+
catalog,
1090+
catalog_table_name,
1091+
auto_refresh,
1092+
base_location,
1093+
query,
1094+
..
1095+
}) => {
1096+
assert!(or_replace);
1097+
assert!(iceberg);
1098+
assert!(columns.is_empty());
1099+
assert_eq!("my_catalog_integration", catalog.unwrap());
1100+
assert_eq!("db.tbl", catalog_table_name.unwrap());
1101+
assert_eq!(Some(true), auto_refresh);
1102+
assert!(base_location.is_none());
1103+
assert!(query.is_none());
1104+
}
1105+
_ => unreachable!(),
1106+
}
1107+
}
1108+
1109+
#[test]
1110+
fn test_snowflake_create_iceberg_table_ctas() {
1111+
match snowflake().verified_stmt(
1112+
"CREATE ICEBERG TABLE my_table EXTERNAL_VOLUME='volume' CATALOG='SNOWFLAKE' \
1113+
BASE_LOCATION='relative/path' AS SELECT 1",
1114+
) {
1115+
Statement::CreateTable(CreateTable {
1116+
iceberg,
1117+
columns,
1118+
query,
1119+
base_location,
1120+
..
1121+
}) => {
1122+
assert!(iceberg);
1123+
assert!(columns.is_empty());
1124+
assert!(query.is_some());
1125+
assert_eq!("relative/path", base_location.unwrap());
1126+
}
1127+
_ => unreachable!(),
1128+
}
1129+
}
1130+
1131+
#[test]
1132+
fn test_snowflake_drop_iceberg_table() {
1133+
match snowflake().one_statement_parses_to(
1134+
"DROP ICEBERG TABLE IF EXISTS my_table PURGE",
1135+
"DROP TABLE IF EXISTS my_table PURGE",
1136+
) {
1137+
Statement::Drop {
1138+
object_type,
1139+
if_exists,
1140+
names,
1141+
purge,
1142+
..
1143+
} => {
1144+
assert_eq!(ObjectType::Table, object_type);
1145+
assert!(if_exists);
1146+
assert!(purge);
1147+
assert_eq!("my_table", names[0].to_string());
1148+
}
1149+
_ => unreachable!(),
1150+
}
1151+
1152+
match snowflake().one_statement_parses_to("DROP ICEBERG TABLE my_table", "DROP TABLE my_table")
1153+
{
1154+
Statement::Drop {
1155+
object_type,
1156+
if_exists,
1157+
purge,
1158+
..
1159+
} => {
1160+
assert_eq!(ObjectType::Table, object_type);
1161+
assert!(!if_exists);
1162+
assert!(!purge);
1163+
}
1164+
_ => unreachable!(),
1165+
}
1166+
}
1167+
10541168
#[test]
10551169
fn test_snowflake_create_table_trailing_options() {
10561170
// Serialization to SQL assume that in `CREATE TABLE AS` the options come before the `AS (<query>)`

0 commit comments

Comments
 (0)