From 0faa5be73be3417eb831c11114967fa71fed8787 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:42:12 +0000 Subject: [PATCH 1/3] Keep OR REPLACE and the quoted name of Snowflake database and schema clones CreateDatabase and CreateSchema now record OR REPLACE, which was parsed and dropped, and a Snowflake database name keeps its quoting. --- .../src/expressions/schema_objects/models.rs | 8 ++++++++ .../src/parser/_helpers/ddl/commands.rs | 11 +++++++++-- .../src/parser/_helpers/statements/dml.rs | 4 ++-- .../parser/test_snowflake_create_clone.rs | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs b/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs index 9b0424ec6..29520d987 100644 --- a/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs +++ b/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs @@ -282,6 +282,9 @@ impl AlterIndex { pub struct CreateSchema { /// Schema name parts, possibly dot-qualified (e.g. [mydb, hr] for "mydb.hr") pub name: Vec, + /// OR REPLACE (Snowflake) + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub or_replace: bool, pub if_not_exists: bool, pub authorization: Option, /// CLONE source parts, possibly dot-qualified @@ -302,6 +305,7 @@ impl CreateSchema { pub fn new(name: impl Into) -> Self { Self { name: vec![Identifier::new(name)], + or_replace: false, if_not_exists: false, authorization: None, clone_from: None, @@ -356,6 +360,9 @@ impl DropNamespace { #[derive(sqlquack_syntax_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CreateDatabase { pub name: Identifier, + /// OR REPLACE (Snowflake) + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub or_replace: bool, pub if_not_exists: bool, pub options: Vec, /// Snowflake CLONE source @@ -384,6 +391,7 @@ impl CreateDatabase { pub fn new(name: impl Into) -> Self { Self { name: Identifier::new(name), + or_replace: false, if_not_exists: false, options: Vec::new(), clone_from: None, diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs index e20765603..9c5b288da 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs @@ -1209,6 +1209,7 @@ impl Parser { pub(crate) fn parse_create_schema( &mut self, leading_comments: Vec, + or_replace: bool, ) -> Result { self.expect_token(TokenType::Schema)?; @@ -1271,6 +1272,7 @@ impl Parser { Ok(Expression::CreateSchema(Box::new(CreateSchema { name, + or_replace, if_not_exists, authorization, clone_from, @@ -1299,12 +1301,16 @@ impl Parser { } /// Parse CREATE DATABASE statement - pub(crate) fn parse_create_database(&mut self) -> Result { + pub(crate) fn parse_create_database(&mut self, or_replace: bool) -> Result { self.expect_token(TokenType::Database)?; let if_not_exists = self.match_keywords(&[TokenType::If, TokenType::Not, TokenType::Exists]); - let name = self.parse_unquoted_identifier(Self::expect_identifier)?; + let name = if self.is_snowflake() { + self.parse_quoting_identifier(Self::expect_identifier)? + } else { + self.parse_unquoted_identifier(Self::expect_identifier)? + }; let clone_from = if self.match_identifier("CLONE") { Some(self.expect_identifier_or_safe_keyword_with_quoted()?) @@ -1378,6 +1384,7 @@ impl Parser { Ok(Expression::CreateDatabase(Box::new(CreateDatabase { name, + or_replace, if_not_exists, options, clone_from, diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs index 92cc1809b..35b7f0aec 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs @@ -357,8 +357,8 @@ impl Parser { } } TokenType::Index => self.parse_create_index_with_clustered(false, None), - TokenType::Schema => self.parse_create_schema(leading_comments), - TokenType::Database => self.parse_create_database(), + TokenType::Schema => self.parse_create_schema(leading_comments, or_replace), + TokenType::Database => self.parse_create_database(or_replace), TokenType::Function => { self.parse_create_function(or_replace, or_alter, temporary, false) } diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs index 2c764cefc..84d3520e6 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs @@ -179,6 +179,24 @@ fn given_a_snowflake_clone_or_replica_when_parsing_then_the_tree_keeps_its_parts pointer: "/0/create_object/source", expected_value: json!("REPLICA"), }, + ProjectionTestCase { + description: "a database clone keeps OR REPLACE", + sql: "CREATE OR REPLACE DATABASE d1 CLONE d2", + pointer: "/0/create_database/or_replace", + expected_value: json!(true), + }, + ProjectionTestCase { + description: "a database clone keeps its name's quoting", + sql: "CREATE DATABASE \"v1 Q\" CLONE d2", + pointer: "/0/create_database/name/quoted", + expected_value: json!(true), + }, + ProjectionTestCase { + description: "a schema clone keeps OR REPLACE", + sql: "CREATE OR REPLACE SCHEMA s1 CLONE s2", + pointer: "/0/create_schema/or_replace", + expected_value: json!(true), + }, ]; for test_case in test_cases { From c15ba204a6eb2c2e0757cd538c319b9f8fdf528e Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:06:56 +0000 Subject: [PATCH 2/3] Leave CREATE DATABASE ROLE alone to CREATE DATABASE Snowflake reads CREATE DATABASE ROLE ; and CREATE DATABASE ROLE CLONE d2 as a database named ROLE (recorded success); #55 took them as a database role without a name. --- .../parser/_helpers/ddl/snowflake_create_clone.rs | 12 +++++++++++- .../tests/parser/test_snowflake_create_clone.rs | 12 ++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) 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 index 07150ef25..b4480e13a 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_clone.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_clone.rs @@ -234,7 +234,8 @@ impl Parser { None if !temporary && self.match_text_seq(&["FILE", "FORMAT"]) => { ("FILE FORMAT", CloneOptions::Bare) } - None if !temporary && self.match_text_seq(&["DATABASE", "ROLE"]) => { + None if !temporary && self.names_a_database_role() => { + self.match_text_seq(&["DATABASE", "ROLE"]); ("DATABASE ROLE", CloneOptions::Bare) } _ => { @@ -293,6 +294,15 @@ impl Parser { })))) } + /// Whether DATABASE ROLE starts a database role: `CREATE DATABASE ROLE ;` and + /// `CREATE DATABASE ROLE CLONE d2` create a database named ROLE. + fn names_a_database_role(&self) -> bool { + self.check_text_seq(&["DATABASE", "ROLE"]) + && self.tokens.get(self.current + 2).is_some_and(|t| { + t.token_type != TokenType::Semicolon && !t.text.eq_ignore_ascii_case("CLONE") + }) + } + /// 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..] diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs index 84d3520e6..46e1846ee 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs @@ -32,6 +32,12 @@ fn given_a_snowflake_clone_or_replica_when_parsing_then_only_documented_forms_ar dialect: Dialect::Snowflake, expected_accepted: true, }, + AcceptanceTestCase { + description: "CREATE DATABASE ROLE alone creates a database named ROLE (recorded success)", + sql: "CREATE DATABASE ROLE ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, AcceptanceTestCase { description: "a role has no CLONE (recorded errno 1003)", sql: "CREATE ROLE v1 CLONE v1 ;", @@ -179,6 +185,12 @@ fn given_a_snowflake_clone_or_replica_when_parsing_then_the_tree_keeps_its_parts pointer: "/0/create_object/source", expected_value: json!("REPLICA"), }, + ProjectionTestCase { + description: "a database named ROLE clones as a database", + sql: "CREATE DATABASE ROLE CLONE d2", + pointer: "/0/create_database/name/name", + expected_value: json!("ROLE"), + }, ProjectionTestCase { description: "a database clone keeps OR REPLACE", sql: "CREATE OR REPLACE DATABASE d1 CLONE d2", From c793813c8b91662b412d042b5449f2360c539fb5 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:09:29 +0000 Subject: [PATCH 3/3] Accept an empty Snowflake COPY = ( ) clone list Snowflake parses CREATE STREAM v1 CLONE v1 COPY = ( ) and fails later (errno 1422), so the clone options now accept an empty kind list. Adds regression cases for it and for CREATE DATABASE ROLE CLONE v1, and keeps the database-role doc comment on one line. --- .../_helpers/ddl/snowflake_create_clone.rs | 5 ++--- .../parser/test_snowflake_create_clone.rs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) 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 index b4480e13a..623ec47ee 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_clone.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_clone.rs @@ -57,7 +57,7 @@ impl Parser { if self.match_token(TokenType::Eq) { self.expect_token(TokenType::LParen)?; let mut kinds: Vec = Vec::new(); - loop { + while !self.check(TokenType::RParen) || !kinds.is_empty() { let kind = self.expect_alter_words(&[&["COMMENTS"], &["TAGS"], &["GRANTS"]])?; kinds.push(Expression::Identifier(Identifier::new(kind))); @@ -294,8 +294,7 @@ impl Parser { })))) } - /// Whether DATABASE ROLE starts a database role: `CREATE DATABASE ROLE ;` and - /// `CREATE DATABASE ROLE CLONE d2` create a database named ROLE. + /// Whether DATABASE ROLE starts a role; alone or before CLONE, ROLE names a database. fn names_a_database_role(&self) -> bool { self.check_text_seq(&["DATABASE", "ROLE"]) && self.tokens.get(self.current + 2).is_some_and(|t| { diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs index 46e1846ee..c20dc1ae0 100644 --- a/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_create_clone.rs @@ -38,6 +38,24 @@ fn given_a_snowflake_clone_or_replica_when_parsing_then_only_documented_forms_ar dialect: Dialect::Snowflake, expected_accepted: true, }, + AcceptanceTestCase { + description: "a database named ROLE clones (recorded errno 2003, after parsing)", + sql: "CREATE DATABASE ROLE CLONE v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "COPY = ( ) names no kind (recorded errno 1422, after parsing)", + sql: "CREATE STREAM v1 CLONE v1 COPY = ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a COPY list does not end in a comma", + sql: "CREATE TABLE v1 CLONE v1 COPY = ( TAGS , ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, AcceptanceTestCase { description: "a role has no CLONE (recorded errno 1003)", sql: "CREATE ROLE v1 CLONE v1 ;",