diff --git a/corpora/polyglot-check/expected_differences.toml b/corpora/polyglot-check/expected_differences.toml index f0cbc4f69..b08e1b036 100644 --- a/corpora/polyglot-check/expected_differences.toml +++ b/corpora/polyglot-check/expected_differences.toml @@ -1121,6 +1121,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 0322aafa7..9b0424ec6 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 d21558a34..e20765603 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs @@ -1222,49 +1222,10 @@ impl Parser { None }; - let at_clause = 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 })) + let at_clause = if self.is_snowflake() { + self.parse_clone_time_travel()? } else { - None + self.parse_raw_at_clause()? }; let authorization = if self.match_token(TokenType::Authorization) { @@ -1273,7 +1234,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,53 +1312,15 @@ impl Parser { None }; - let at_clause = 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 })) + let at_clause = if self.is_snowflake() { + self.parse_clone_time_travel()? } else { - None + self.parse_raw_at_clause()? }; 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,9 +1382,57 @@ impl Parser { options, clone_from, at_clause, + properties, }))) } + /// 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)?; 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 e07d1e565..92cc1809b 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 88db79448..59207c81c 100644 --- a/crates/sqlquack-syntax/tests/parser.rs +++ b/crates/sqlquack-syntax/tests/parser.rs @@ -12,6 +12,8 @@ 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_snowflake_recorded.rs"] mod test_snowflake_recorded; #[path = "parser/test_types.rs"] 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 + ); + } +}