From 53fca6d904660d455d4c62a84fb55e78f3bb97bd Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:56:17 +0000 Subject: [PATCH 1/2] Parse Snowflake CLONE kinds and security AS REPLICA forms CREATE ... CLONE now parses for ALERT, STAGE, STREAM, SEQUENCE, FILE FORMAT, DATABASE ROLE, EVENT TABLE and the dynamic, Iceberg, temporary and volatile tables into CreateObject with source CLONE, with the documented time travel clause and trailing options. Database, schema and table clones take their typed time travel and documented options. CREATE CONNECTION, FAILOVER GROUP and REPLICATION GROUP ... AS REPLICA OF org.account.name parse into CreateObject with source REPLICA. The polyglot exact check lists the typed time travel clause as a kept field, since polyglot keeps it as raw text. --- .../polyglot-check/expected_differences.toml | 12 + .../src/expressions/schema_objects/models.rs | 4 + .../src/parser/_helpers/ddl/commands.rs | 12 +- .../parser/_helpers/ddl/create_table_forms.rs | 27 +- .../src/parser/_helpers/ddl/mod.rs | 1 + .../_helpers/ddl/snowflake_create_clone.rs | 366 ++++++++++++++++++ .../src/parser/_helpers/statements/dml.rs | 7 + crates/sqlquack-syntax/tests/parser.rs | 2 + .../parser/test_snowflake_create_clone.rs | 195 ++++++++++ 9 files changed, 620 insertions(+), 6 deletions(-) create mode 100644 crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_clone.rs create mode 100644 crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs diff --git a/corpora/polyglot-check/expected_differences.toml b/corpora/polyglot-check/expected_differences.toml index bce16288f..b63516834 100644 --- a/corpora/polyglot-check/expected_differences.toml +++ b/corpora/polyglot-check/expected_differences.toml @@ -1086,6 +1086,18 @@ marker = "WITHOUTTIMEZONE" field = "to" note = "Snowflake documents TIMESTAMP WITHOUT TIME ZONE as a synonym of TIMESTAMP_NTZ, so SQLQuack's cast target is TIMESTAMPNTZ, as for TIMESTAMP_NTZ; polyglot reads it as bare TIMESTAMP, whose meaning follows TIMESTAMP_TYPE_MAPPING; crates/sqlquack-syntax/tests/parser/test_fixes.rs pins the tree" +[[kept_field]] +id = "snowflake_clone_time_travel" +marker = "CLONE" +field = "at_clause" +note = "a database or schema clone's AT | BEFORE ( TIMESTAMP | OFFSET | STATEMENT => ) parses into its keyword, kind and expression; polyglot keeps the clause as raw text; crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs pins the tree" + +[[kept_field]] +id = "snowflake_table_clone_time_travel" +marker = "CLONE" +field = "clone_at_clause" +note = "a table clone's AT | BEFORE ( TIMESTAMP | OFFSET | STATEMENT => ) parses into its keyword, kind and expression; polyglot keeps the clause as raw text; crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs pins the tree" + # Statements polyglot keeps as one opaque command where SQLQuack parses them into structured nodes. # The check counts an entry only when polyglot's tree is a single command, SQLQuack's tree starts # with `sqlquack_node`, and the statement, without whitespace and ignoring case, contains `marker`. diff --git a/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs b/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs index 538f748c0..4901d42b6 100644 --- a/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs +++ b/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs @@ -364,6 +364,9 @@ pub struct CreateDatabase { /// AT/BEFORE clause for time travel (Snowflake) #[serde(default)] pub at_clause: Option, + /// Snowflake clone options, e.g. IGNORE HYBRID TABLES + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub properties: Vec, } /// Database option @@ -385,6 +388,7 @@ impl CreateDatabase { options: Vec::new(), clone_from: None, at_clause: None, + properties: Vec::new(), } } } diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs index 052e245c3..40a04e858 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs @@ -1222,7 +1222,9 @@ impl Parser { None }; - let at_clause = if self.match_identifier("AT") || self.match_token(TokenType::Before) { + let at_clause = if self.is_snowflake() { + self.parse_clone_time_travel()? + } else if self.match_identifier("AT") || self.match_token(TokenType::Before) { let keyword = self.previous_text().to_ascii_uppercase(); self.expect_token(TokenType::LParen)?; let mut result = format!("{} (", keyword); @@ -1273,7 +1275,7 @@ impl Parser { None }; - let mut properties: Vec = Vec::new(); + let mut properties = self.parse_clone_container_properties(clone_from.is_some())?; if self.match_token(TokenType::With) { self.expect_token(TokenType::LParen)?; @@ -1351,7 +1353,9 @@ impl Parser { None }; - let at_clause = if self.match_identifier("AT") || self.match_token(TokenType::Before) { + let at_clause = if self.is_snowflake() { + self.parse_clone_time_travel()? + } else if self.match_identifier("AT") || self.match_token(TokenType::Before) { let keyword = self.previous_text().to_ascii_uppercase(); self.expect_token(TokenType::LParen)?; let mut result = format!("{} (", keyword); @@ -1398,6 +1402,7 @@ impl Parser { let _on_cluster = self.parse_on_cluster_clause()?; + let properties = self.parse_clone_container_properties(clone_from.is_some())?; let mut options: Vec = Vec::new(); while !self.is_at_end() && !self.check(TokenType::Semicolon) { @@ -1459,6 +1464,7 @@ impl Parser { options, clone_from, at_clause, + properties, }))) } diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/create_table_forms.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/create_table_forms.rs index cde6b935a..8c27c7c28 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/create_table_forms.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/create_table_forms.rs @@ -8,6 +8,7 @@ use crate::expressions::statements::models::Version; use crate::expressions::tables::models::CreateTable; use crate::expressions::tables::models::PartitionedOfProperty; use crate::expressions::tree::models::Expression; +use crate::parser::_helpers::ddl::snowflake_create_clone::CloneOptions; use crate::parser::models::CreateTableCloneKind; use crate::parser::models::CreateTableHead; use crate::parser::models::CreateTableLeadingOptions; @@ -180,8 +181,23 @@ impl Parser { self.skip(); - let source = self.parse_table_ref()?; - let at_clause = if self.match_identifier("AT") || self.match_token(TokenType::Before) { + let snowflake = self.is_snowflake(); + if snowflake { + for part in [&name.catalog, &name.schema, &Some(name.name.clone())] + .into_iter() + .flatten() + { + self.reject_reserved_name_part(part)?; + } + } + let source = if snowflake { + self.parse_object_name()? + } else { + self.parse_table_ref()? + }; + let at_clause = if self.is_snowflake() { + self.parse_clone_time_travel()? + } else if self.match_identifier("AT") || self.match_token(TokenType::Before) { let keyword = self.previous_text().to_ascii_uppercase(); self.expect_token(TokenType::LParen)?; let mut result = format!("{} (", keyword); @@ -259,6 +275,11 @@ impl Parser { } else { None }; + let properties = if snowflake { + Self::clone_options_as_properties(self.parse_clone_options(CloneOptions::Copy)?) + } else { + Vec::new() + }; Ok(Expression::CreateTable(Box::new(CreateTable { name, on_cluster: on_cluster.clone(), @@ -284,7 +305,7 @@ impl Parser { with_statistics: None, teradata_indexes: Vec::new(), with_cte: None, - properties: Vec::new(), + properties, partition_of: None, post_table_properties: Vec::new(), mysql_table_options: Vec::new(), diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs index 27aedc79f..382133520 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs @@ -13,6 +13,7 @@ pub(crate) mod snowflake_alter_objects; pub(crate) mod snowflake_alter_resources; pub(crate) mod snowflake_alter_tables; pub(crate) mod snowflake_commands; +pub(crate) mod snowflake_create_clone; pub(crate) mod snowflake_create_from; pub(crate) mod snowflake_show; pub(crate) mod snowflake_statements; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_clone.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_clone.rs new file mode 100644 index 000000000..07150ef25 --- /dev/null +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_clone.rs @@ -0,0 +1,366 @@ +//! Snowflake CREATE ... CLONE and CREATE ... AS REPLICA OF for kinds without a CREATE node of their own. + +use crate::expressions::names::models::Identifier; +use crate::expressions::schema_objects::models::CreateObject; +use crate::expressions::schema_objects::models::ObjectProperty; +use crate::expressions::statements::models::HistoricalData; +use crate::expressions::tables::models::Property; +use crate::expressions::tree::models::Expression; +use crate::expressions::types::models::Tuple; +use crate::parser::_helpers::ddl::snowflake_alter::property; +use crate::parser::models::Parser; +use crate::tokens::models::TokenType; +use crate::tokens::types::Result; + +/// The options a clone documents after its source. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum CloneOptions { + /// None: ALERT, STAGE, FILE FORMAT, SEQUENCE, DATABASE ROLE, EVENT TABLE. + Bare, + /// COPY GRANTS, COPY TAGS and COPY = ( ... ): tables and streams. + Copy, + /// The copy options, TARGET_LAG, WAREHOUSE, EXECUTE AS USER and USE SECONDARY ROLES: dynamic tables. + Dynamic, + /// IGNORE TABLES WITH INSUFFICIENT DATA RETENTION, IGNORE HYBRID TABLES, INCLUDE INTERNAL STAGES. + Container, +} + +impl Parser { + /// A clone's `{ AT | BEFORE } ( { TIMESTAMP | OFFSET | STATEMENT } => )`, if present. + pub(crate) fn parse_clone_time_travel(&mut self) -> Result> { + if !self.check_identifier("AT") && !self.check(TokenType::Before) { + return Ok(None); + } + let this = self.advance_text()?.to_ascii_uppercase(); + self.expect_token(TokenType::LParen)?; + let kind = self.expect_alter_words(&[&["TIMESTAMP"], &["OFFSET"], &["STATEMENT"]])?; + self.expect_token(TokenType::FArrow)?; + let expression = self.parse_expression()?; + self.expect_token(TokenType::RParen)?; + Ok(Some(Expression::HistoricalData(Box::new(HistoricalData { + this: Box::new(Expression::Identifier(Identifier::new(this))), + kind, + expression: Box::new(expression), + })))) + } + + /// The options after a clone's source and time travel, to the end; any other word is an error. + pub(crate) fn parse_clone_options( + &mut self, + options: CloneOptions, + ) -> Result> { + let copy = matches!(options, CloneOptions::Copy | CloneOptions::Dynamic); + let dynamic = options == CloneOptions::Dynamic; + let mut properties: Vec = Vec::new(); + while !self.is_at_end() && !self.check(TokenType::Semicolon) { + if copy && self.match_token(TokenType::Copy) { + if self.match_token(TokenType::Eq) { + self.expect_token(TokenType::LParen)?; + let mut kinds: Vec = Vec::new(); + loop { + let kind = + self.expect_alter_words(&[&["COMMENTS"], &["TAGS"], &["GRANTS"]])?; + kinds.push(Expression::Identifier(Identifier::new(kind))); + if !self.match_token(TokenType::Comma) { + break; + } + } + self.expect_token(TokenType::RParen)?; + properties.push(property( + "COPY", + None, + Some(Expression::Tuple(Box::new(Tuple { expressions: kinds }))), + )); + } else { + let kind = self.expect_alter_words(&[&["GRANTS"], &["TAGS"]])?; + properties.push(property(&format!("COPY {kind}"), None, None)); + } + } else if dynamic && self.match_text_seq(&["TARGET_LAG"]) { + self.expect_token(TokenType::Eq)?; + let value = if self.match_text_seq(&["DOWNSTREAM"]) { + Expression::Identifier(Identifier::new("DOWNSTREAM")) + } else { + self.parse_string_value()? + }; + properties.push(property("TARGET_LAG", None, Some(value))); + } else if dynamic && self.match_text_seq(&["WAREHOUSE"]) { + self.expect_token(TokenType::Eq)?; + let warehouse = self.parse_object_name()?; + properties.push(property( + "WAREHOUSE", + None, + Some(Expression::Table(Box::new(warehouse))), + )); + } else if dynamic && self.match_text_seq(&["EXECUTE"]) { + self.expect_alter_words(&[&["AS", "USER"]])?; + let user = self.parse_object_name()?; + properties.push(property( + "EXECUTE AS USER", + None, + Some(Expression::Table(Box::new(user))), + )); + } else if dynamic && self.match_text_seq(&["USE", "SECONDARY", "ROLES"]) { + let value = if self.match_text_seq(&["ALL"]) || self.match_text_seq(&["NONE"]) { + Expression::Identifier(Identifier::new( + self.previous_text().to_ascii_uppercase(), + )) + } else { + let mut roles: Vec = Vec::new(); + loop { + roles.push(Expression::Table(Box::new(self.parse_object_name()?))); + if !self.match_token(TokenType::Comma) { + break; + } + } + Expression::Tuple(Box::new(Tuple { expressions: roles })) + }; + properties.push(property("USE SECONDARY ROLES", None, Some(value))); + } else if options == CloneOptions::Container + && (self.check_text_seq(&["IGNORE"]) || self.check_text_seq(&["INCLUDE"])) + { + let words = self.expect_alter_words(&[ + &[ + "IGNORE", + "TABLES", + "WITH", + "INSUFFICIENT", + "DATA", + "RETENTION", + ], + &["IGNORE", "HYBRID", "TABLES"], + &["INCLUDE", "INTERNAL", "STAGES"], + ])?; + properties.push(property(&words, None, None)); + } else { + return Err(self.parse_error("Unexpected token after the CLONE source")); + } + } + Ok(properties) + } + + /// The clone options as table or schema properties: `COPY TAGS` or `COPY = (COMMENTS, TAGS)`. + pub(crate) fn clone_options_as_properties(options: Vec) -> Vec { + options + .into_iter() + .map(|option| { + Expression::Property(Box::new(Property { + this: Box::new(Expression::Identifier(Identifier::new(option.kind))), + value: option.value.map(Box::new), + })) + }) + .collect() + } + + /// A Snowflake database or schema clone's IGNORE and INCLUDE options, when `cloned`. + pub(crate) fn parse_clone_container_properties( + &mut self, + cloned: bool, + ) -> Result> { + if !(cloned && self.is_snowflake()) { + return Ok(Vec::new()); + } + Ok(Self::clone_options_as_properties( + self.parse_clone_options(CloneOptions::Container)?, + )) + } + + /// `CREATE [ IF NOT EXISTS ] CLONE ...` for kinds without their own node, or None. + pub(crate) fn parse_snowflake_create_clone( + &mut self, + or_replace: bool, + temporary: bool, + table_modifier: Option<&str>, + ) -> Result> { + let start = self.current; + if table_modifier.is_none() && !temporary && !or_replace && self.statement_names_a_replica() + { + if let Some(create) = self.parse_snowflake_create_replica()? { + return Ok(Some(create)); + } + } + if table_modifier.is_none() && !temporary && self.check_text_seq(&["ROLE"]) { + if self + .tokens + .get(self.current + 2) + .is_some_and(|t| t.text.eq_ignore_ascii_case("CLONE")) + { + return Err( + self.parse_error("A role has no CLONE; Snowflake clones a DATABASE ROLE") + ); + } + return Ok(None); + } + let mut modifier: Option = table_modifier.map(str::to_owned); + if table_modifier.is_none() && !temporary { + if self.match_text_seq(&["LOCAL"]) || self.match_text_seq(&["GLOBAL"]) { + let scope = self.previous_text().to_ascii_uppercase(); + if !(self.match_text_seq(&["TEMPORARY"]) || self.match_text_seq(&["TEMP"])) { + self.current = start; + return Ok(None); + } + modifier = Some(format!("{scope} TEMPORARY")); + } else if self.match_text_seq(&["VOLATILE"]) { + modifier = Some("VOLATILE".to_owned()); + } + } + let (kind, options) = match modifier.as_deref() { + Some("DYNAMIC") if self.match_text_seq(&["ICEBERG", "TABLE"]) => { + modifier = Some("DYNAMIC ICEBERG".to_owned()); + ("TABLE", CloneOptions::Dynamic) + } + Some("TRANSIENT") if self.match_text_seq(&["DYNAMIC", "TABLE"]) => { + modifier = Some("TRANSIENT DYNAMIC".to_owned()); + ("TABLE", CloneOptions::Dynamic) + } + Some("DYNAMIC") if self.match_token(TokenType::Table) => { + ("TABLE", CloneOptions::Dynamic) + } + Some("ICEBERG" | "LOCAL TEMPORARY" | "GLOBAL TEMPORARY" | "VOLATILE") + if self.match_token(TokenType::Table) => + { + ("TABLE", CloneOptions::Copy) + } + None if !temporary && self.match_text_seq(&["EVENT", "TABLE"]) => { + ("EVENT TABLE", CloneOptions::Bare) + } + None if !temporary && self.match_text_seq(&["ALERT"]) => ("ALERT", CloneOptions::Bare), + None if !temporary && self.match_text_seq(&["STAGE"]) => ("STAGE", CloneOptions::Bare), + None if !temporary && self.match_text_seq(&["STREAM"]) => { + ("STREAM", CloneOptions::Copy) + } + None if !temporary && self.match_text_seq(&["SEQUENCE"]) => { + ("SEQUENCE", CloneOptions::Bare) + } + None if !temporary && self.match_text_seq(&["FILE", "FORMAT"]) => { + ("FILE FORMAT", CloneOptions::Bare) + } + None if !temporary && self.match_text_seq(&["DATABASE", "ROLE"]) => { + ("DATABASE ROLE", CloneOptions::Bare) + } + _ => { + let partial = (modifier.is_none() && self.check_text_seq(&["EVENT"])) + || (modifier.as_deref() == Some("DYNAMIC") + && self.check_text_seq(&["ICEBERG"])); + if partial + && self + .tokens + .get(self.current + 2) + .is_some_and(|t| t.text.eq_ignore_ascii_case("CLONE")) + { + return Err(self.parse_error("Expected TABLE before the name")); + } + self.current = start; + return Ok(None); + } + }; + let if_not_exists = + self.match_keywords(&[TokenType::If, TokenType::Not, TokenType::Exists]); + if self.check(TokenType::If) { + return Err(self.parse_error("Expected IF NOT EXISTS")); + } + let name = self.parse_object_name()?; + if !self.match_identifier("CLONE") { + let lone_word = self.is_identifier_token() + && !self.check_keyword() + && self + .tokens + .get(self.current + 1) + .is_none_or(|t| t.token_type == TokenType::Semicolon); + if lone_word && !matches!(kind, "SEQUENCE" | "FILE FORMAT") { + return Err(self.parse_error(format!( + "Expected CLONE or the definition after CREATE {kind} " + ))); + } + self.current = start; + return Ok(None); + } + let mut operands = vec![Expression::Table(Box::new(self.parse_object_name()?))]; + if matches!(kind, "TABLE" | "EVENT TABLE") { + if let Some(time_travel) = self.parse_clone_time_travel()? { + operands.push(time_travel); + } + } + let properties = self.parse_clone_options(options)?; + Ok(Some(Expression::CreateObject(Box::new(CreateObject { + kind: kind.to_owned(), + modifier, + or_replace, + if_not_exists, + name, + source: "CLONE".to_owned(), + operands, + properties, + })))) + } + + /// Whether the rest of the statement contains REPLICA or AS OF, which make it a replica. + fn statement_names_a_replica(&self) -> bool { + self.tokens[self.current..] + .iter() + .take_while(|t| t.token_type != TokenType::Semicolon) + .collect::>() + .windows(2) + .any(|pair| { + pair[1].text.eq_ignore_ascii_case("REPLICA") + || (pair[0].token_type == TokenType::As + && pair[1].text.eq_ignore_ascii_case("OF")) + }) + } + + /// `CREATE { CONNECTION | FAILOVER GROUP | REPLICATION GROUP } ... AS REPLICA OF o.a.n`, or None. + fn parse_snowflake_create_replica(&mut self) -> Result> { + let (kind, takes_comment) = if self.match_text_seq(&["CONNECTION"]) { + ("CONNECTION", true) + } else if self.match_text_seq(&["FAILOVER", "GROUP"]) { + ("FAILOVER GROUP", false) + } else if self.match_text_seq(&["REPLICATION", "GROUP"]) { + ("REPLICATION GROUP", false) + } else if self.check_text_seq(&["FAILOVER"]) + || self.check_text_seq(&["REPLICATION"]) + || self.check_text_seq(&["GROUP"]) + { + return Err( + self.parse_error("Expected CONNECTION, FAILOVER GROUP or REPLICATION GROUP") + ); + } else { + return Ok(None); + }; + let if_not_exists = + self.match_keywords(&[TokenType::If, TokenType::Not, TokenType::Exists]); + if self.check(TokenType::If) { + return Err(self.parse_error("Expected IF NOT EXISTS")); + } + let name = self.parse_object_name()?; + self.expect_token(TokenType::As)?; + self.expect_alter_words(&[&["REPLICA", "OF"]])?; + let primary = self.parse_object_name()?; + if primary.catalog.is_none() + || primary + .schema + .as_ref() + .is_none_or(|part| part.name.is_empty()) + { + return Err(self.parse_error("Expected ..")); + } + let mut properties: Vec = Vec::new(); + if takes_comment && self.match_token(TokenType::Comment) { + self.expect_token(TokenType::Eq)?; + properties.push(property("COMMENT", None, Some(self.parse_string_value()?))); + } + if !self.is_at_end() && !self.check(TokenType::Semicolon) { + return Err(self.parse_error(format!( + "Unexpected token after CREATE {kind} AS REPLICA OF" + ))); + } + Ok(Some(Expression::CreateObject(Box::new(CreateObject { + kind: kind.to_owned(), + modifier: None, + or_replace: false, + if_not_exists, + name, + source: "REPLICA".to_owned(), + operands: vec![Expression::Table(Box::new(primary))], + properties, + })))) + } +} diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs index acad21d11..3fea36ee5 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs @@ -299,6 +299,13 @@ impl Parser { return Ok(create); } } + if self.is_snowflake() && !or_alter && !materialized && !secure { + if let Some(create) = + self.parse_snowflake_create_clone(or_replace, temporary, table_modifier.as_deref())? + { + return Ok(create); + } + } match self.peek().token_type { TokenType::Table => { diff --git a/crates/sqlquack-syntax/tests/parser.rs b/crates/sqlquack-syntax/tests/parser.rs index 4c2829a67..41d215b97 100644 --- a/crates/sqlquack-syntax/tests/parser.rs +++ b/crates/sqlquack-syntax/tests/parser.rs @@ -12,5 +12,7 @@ mod test_scripting; mod test_snowflake_alter; #[path = "parser/test_snowflake_alter_gaps.rs"] mod test_snowflake_alter_gaps; +#[path = "parser/test_snowflake_create_clone.rs"] +mod test_snowflake_create_clone; #[path = "parser/test_types.rs"] mod test_types; diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs new file mode 100644 index 000000000..2c764cefc --- /dev/null +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs @@ -0,0 +1,195 @@ +use serde_json::json; + +use sqlquack_syntax::{CallMode, Dialect}; + +use crate::helpers::{accepted, statements}; +use crate::test_types::{AcceptanceTestCase, ProjectionTestCase}; + +#[test] +fn given_a_snowflake_clone_or_replica_when_parsing_then_only_documented_forms_are_accepted() { + let test_cases = [ + AcceptanceTestCase { + description: "an alert clones (recorded success)", + sql: "CREATE OR REPLACE ALERT v1 CLONE v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a database role clones within its database (recorded success)", + sql: "CREATE DATABASE ROLE IF NOT EXISTS v1 . v1 CLONE v1 . v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a dynamic table clone takes its lag and warehouse (recorded success)", + sql: "CREATE OR REPLACE DYNAMIC TABLE v1 CLONE v1 AT ( OFFSET => -60 ) COPY GRANTS TARGET_LAG = DOWNSTREAM WAREHOUSE = v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a database clone takes its IGNORE options (recorded success)", + sql: "CREATE DATABASE v1 CLONE v1 AT ( TIMESTAMP => CURRENT_TIMESTAMP ( ) ) IGNORE TABLES WITH INSUFFICIENT DATA RETENTION IGNORE HYBRID TABLES ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a role has no CLONE (recorded errno 1003)", + sql: "CREATE ROLE v1 CLONE v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a clone needs its source (recorded errno 1003)", + sql: "CREATE DYNAMIC TABLE v1 CLONE ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a time travel clause needs its kind's value (recorded errno 1003)", + sql: "CREATE EVENT TABLE v1 CLONE v1 AT ( TIMESTAMP => ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a time travel clause is parenthesized (recorded errno 1003)", + sql: "CREATE EVENT TABLE v1 CLONE v1 AT TIMESTAMP => CURRENT_TIMESTAMP ( ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a database clone's IGNORE TABLES needs WITH (recorded errno 1003)", + sql: "CREATE DATABASE v1 CLONE v1 IGNORE TABLES INSUFFICIENT DATA RETENTION ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "COPY needs GRANTS or TAGS (recorded errno 1003)", + sql: "CREATE STREAM v1 CLONE v1 COPY ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a clone takes no IF EXISTS (recorded errno 1003)", + sql: "CREATE TABLE IF EXISTS v1 CLONE v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a clone's name is no reserved keyword (recorded errno 1003)", + sql: "CREATE STAGE SELECT CLONE v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a connection replica names org, account and connection", + sql: "CREATE CONNECTION IF NOT EXISTS v1 AS REPLICA OF v1 . v1 . v1 COMMENT = 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a replication group replica names org, account and group", + sql: "CREATE REPLICATION GROUP v1 AS REPLICA OF v1 . \"v1 Q\" . v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a replica's source has all three parts", + sql: "CREATE FAILOVER GROUP v1 AS REPLICA OF v1 . . v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a replica needs AS", + sql: "CREATE FAILOVER GROUP v1 REPLICA OF v1 . v1 . v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a replica needs REPLICA", + sql: "CREATE CONNECTION v1 AS OF v1 . v1 . v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a group replica takes no COMMENT", + sql: "CREATE REPLICATION GROUP v1 AS REPLICA OF v1 . v1 . v1 COMMENT = 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + ]; + + for test_case in test_cases { + assert_eq!( + accepted(test_case.sql, test_case.dialect), + test_case.expected_accepted, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_a_snowflake_clone_or_replica_when_parsing_then_the_tree_keeps_its_parts() { + let test_cases = [ + ProjectionTestCase { + description: "a database role clone names its kind", + sql: "CREATE DATABASE ROLE d1.r1 CLONE d1.r2", + pointer: "/0/create_object/kind", + expected_value: json!("DATABASE ROLE"), + }, + ProjectionTestCase { + description: "a dynamic table clone keeps its lag", + sql: "CREATE DYNAMIC TABLE t1 CLONE t2 TARGET_LAG = DOWNSTREAM", + pointer: "/0/create_object/properties/0/kind", + expected_value: json!("TARGET_LAG"), + }, + ProjectionTestCase { + description: "an event table clone keeps its time travel kind", + sql: "CREATE EVENT TABLE e1 CLONE e2 AT ( OFFSET => -60 )", + pointer: "/0/create_object/operands/1/historical_data/kind", + expected_value: json!("OFFSET"), + }, + ProjectionTestCase { + description: "a table clone's time travel is typed, not raw", + sql: "CREATE TABLE t1 CLONE t2 BEFORE ( STATEMENT => 'q1' )", + pointer: "/0/create_table/clone_at_clause/historical_data/expression/literal/value", + expected_value: json!("q1"), + }, + ProjectionTestCase { + description: "a database clone's time travel is typed, not raw", + sql: "CREATE DATABASE d1 CLONE d2 AT ( OFFSET => -60 )", + pointer: "/0/create_database/at_clause/historical_data/this/identifier/name", + expected_value: json!("AT"), + }, + ProjectionTestCase { + description: "a schema clone keeps its INCLUDE option", + sql: "CREATE SCHEMA s1 CLONE s2 INCLUDE INTERNAL STAGES", + pointer: "/0/create_schema/properties/0/property/this/identifier/name", + expected_value: json!("INCLUDE INTERNAL STAGES"), + }, + ProjectionTestCase { + description: "a connection replica keeps its organization", + sql: "CREATE CONNECTION c1 AS REPLICA OF o1.a1.c2", + pointer: "/0/create_object/operands/0/table/catalog/name", + expected_value: json!("o1"), + }, + ProjectionTestCase { + description: "a failover group replica names its source", + sql: "CREATE FAILOVER GROUP g1 AS REPLICA OF o1.a1.g2", + pointer: "/0/create_object/source", + expected_value: json!("REPLICA"), + }, + ]; + + for test_case in test_cases { + assert_eq!( + statements(test_case.sql, Dialect::Snowflake, CallMode::Faithful) + .pointer(test_case.pointer) + .cloned() + .unwrap_or_default(), + test_case.expected_value, + "{}", + test_case.description + ); + } +} From 77e10eeb25a7686b3ca953d474a9a1a8981edc5e Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:35:54 +0000 Subject: [PATCH 2/2] Share the raw AT/BEFORE capture of schema and database clones Outside Snowflake, CREATE SCHEMA and CREATE DATABASE kept their clone time travel clause with the same loop; it is now one helper, which keeps commands.rs within the file length limit after the merge. --- .../src/parser/_helpers/ddl/commands.rs | 133 +++++++----------- 1 file changed, 49 insertions(+), 84 deletions(-) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs index 5b9df5165..e20765603 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs @@ -1224,49 +1224,8 @@ impl Parser { let at_clause = if self.is_snowflake() { self.parse_clone_time_travel()? - } else if self.match_identifier("AT") || self.match_token(TokenType::Before) { - let keyword = self.previous_text().to_ascii_uppercase(); - self.expect_token(TokenType::LParen)?; - let mut result = format!("{} (", keyword); - let mut prev_token_type: Option = None; - let mut paren_depth = 1; - while !self.is_at_end() && paren_depth > 0 { - let token = self.advance()?; - if token.token_type == TokenType::LParen { - paren_depth += 1; - } else if token.token_type == TokenType::RParen { - paren_depth -= 1; - if paren_depth == 0 { - break; - } - } - let needs_space = !result.ends_with('(') - && prev_token_type != Some(TokenType::Arrow) - && prev_token_type != Some(TokenType::Dash) - && prev_token_type != Some(TokenType::LParen) - && token.token_type != TokenType::LParen; - if needs_space - && token.token_type != TokenType::RParen - && token.token_type != TokenType::Comma - { - result.push(' '); - } - if token.token_type == TokenType::String { - result.push('\''); - result.push_str(&token.text.replace('\'', "''")); - result.push('\''); - } else { - result.push_str(&token.text); - } - if token.token_type == TokenType::Arrow || token.token_type == TokenType::Comma { - result.push(' '); - } - prev_token_type = Some(token.token_type); - } - result.push(')'); - Some(Expression::Raw(Raw { sql: result })) } else { - None + self.parse_raw_at_clause()? }; let authorization = if self.match_token(TokenType::Authorization) { @@ -1355,49 +1314,8 @@ impl Parser { let at_clause = if self.is_snowflake() { self.parse_clone_time_travel()? - } else if self.match_identifier("AT") || self.match_token(TokenType::Before) { - let keyword = self.previous_text().to_ascii_uppercase(); - self.expect_token(TokenType::LParen)?; - let mut result = format!("{} (", keyword); - let mut prev_token_type: Option = None; - let mut paren_depth = 1; - while !self.is_at_end() && paren_depth > 0 { - let token = self.advance()?; - if token.token_type == TokenType::LParen { - paren_depth += 1; - } else if token.token_type == TokenType::RParen { - paren_depth -= 1; - if paren_depth == 0 { - break; - } - } - let needs_space = !result.ends_with('(') - && prev_token_type != Some(TokenType::Arrow) - && prev_token_type != Some(TokenType::Dash) - && prev_token_type != Some(TokenType::LParen) - && token.token_type != TokenType::LParen; - if needs_space - && token.token_type != TokenType::RParen - && token.token_type != TokenType::Comma - { - result.push(' '); - } - if token.token_type == TokenType::String { - result.push('\''); - result.push_str(&token.text.replace('\'', "''")); - result.push('\''); - } else { - result.push_str(&token.text); - } - if token.token_type == TokenType::Arrow || token.token_type == TokenType::Comma { - result.push(' '); - } - prev_token_type = Some(token.token_type); - } - result.push(')'); - Some(Expression::Raw(Raw { sql: result })) } else { - None + self.parse_raw_at_clause()? }; let _on_cluster = self.parse_on_cluster_clause()?; @@ -1468,6 +1386,53 @@ impl Parser { }))) } + /// A schema or database clone's `AT | BEFORE ( ... )` kept as written, outside Snowflake. + fn parse_raw_at_clause(&mut self) -> Result> { + if !(self.match_identifier("AT") || self.match_token(TokenType::Before)) { + return Ok(None); + } + let keyword = self.previous_text().to_ascii_uppercase(); + self.expect_token(TokenType::LParen)?; + let mut result = format!("{} (", keyword); + let mut prev_token_type: Option = None; + let mut paren_depth = 1; + while !self.is_at_end() && paren_depth > 0 { + let token = self.advance()?; + if token.token_type == TokenType::LParen { + paren_depth += 1; + } else if token.token_type == TokenType::RParen { + paren_depth -= 1; + if paren_depth == 0 { + break; + } + } + let needs_space = !result.ends_with('(') + && prev_token_type != Some(TokenType::Arrow) + && prev_token_type != Some(TokenType::Dash) + && prev_token_type != Some(TokenType::LParen) + && token.token_type != TokenType::LParen; + if needs_space + && token.token_type != TokenType::RParen + && token.token_type != TokenType::Comma + { + result.push(' '); + } + if token.token_type == TokenType::String { + result.push('\''); + result.push_str(&token.text.replace('\'', "''")); + result.push('\''); + } else { + result.push_str(&token.text); + } + if token.token_type == TokenType::Arrow || token.token_type == TokenType::Comma { + result.push(' '); + } + prev_token_type = Some(token.token_type); + } + result.push(')'); + Ok(Some(Expression::Raw(Raw { sql: result }))) + } + /// Parse DROP DATABASE statement pub(crate) fn parse_drop_database(&mut self) -> Result { self.expect_token(TokenType::Database)?;