diff --git a/corpora/polyglot-check/expected_differences.toml b/corpora/polyglot-check/expected_differences.toml index 1d9e8a947..f0cbc4f69 100644 --- a/corpora/polyglot-check/expected_differences.toml +++ b/corpora/polyglot-check/expected_differences.toml @@ -989,6 +989,41 @@ dialects = ["Snowflake"] sqlquack_error = "Snowflake allows BY NAME only after UNION, without an ON column list" note = "BY NAME ON (...) after EXCEPT is BigQuery's; Snowflake takes BY NAME only after UNION (recorded errno 1003)" +[[rejection]] +id = "snowflake_function_body_number" +sql = "CREATE FUNCTION f(x SMALLDECIMAL ARRAY) RETURNS INT AS 1" +dialects = ["Snowflake"] +sqlquack_error = "Expected the function body as a string" +note = "Snowflake reads a function body only as a string (recorded errno 1003 for CREATE FUNCTION v1 ( ) RETURNS INT AS m1)" + +[[rejection]] +id = "snowflake_function_body_return" +sql = "CREATE FUNCTION a.b(x INT) RETURNS INT AS RETURN x + 1" +dialects = ["Snowflake"] +sqlquack_error = "Expected the function body as a string" +note = "Snowflake has no RETURN body; a function body is a string or $$ block (recorded errno 1003 for CREATE FUNCTION v1 ( ) RETURNS INT AS m1)" + +[[rejection]] +id = "snowflake_function_body_return_contains_sql" +sql = "CREATE FUNCTION a.b(x TEXT) RETURNS TEXT CONTAINS SQL AS RETURN x" +dialects = ["Snowflake"] +sqlquack_error = "Expected the function body as a string" +note = "Snowflake has no RETURN body; a function body is a string or $$ block (recorded errno 1003 for CREATE FUNCTION v1 ( ) RETURNS INT AS m1)" + +[[rejection]] +id = "snowflake_function_body_return_modifies_sql" +sql = "CREATE FUNCTION a.b(x TEXT) RETURNS TEXT LANGUAGE SQL MODIFIES SQL DATA AS RETURN x" +dialects = ["Snowflake"] +sqlquack_error = "Expected the function body as a string" +note = "Snowflake has no RETURN body; a function body is a string or $$ block (recorded errno 1003 for CREATE FUNCTION v1 ( ) RETURNS INT AS m1)" + +[[rejection]] +id = "snowflake_function_body_return_reads_sql" +sql = "CREATE FUNCTION a.b(x TEXT) LANGUAGE SQL READS SQL DATA RETURNS TEXT AS RETURN x" +dialects = ["Snowflake"] +sqlquack_error = "Expected the function body as a string" +note = "Snowflake has no RETURN body; a function body is a string or $$ block (recorded errno 1003 for CREATE FUNCTION v1 ( ) RETURNS INT AS m1)" + [[rejection]] id = "snowflake_string_part_extract" sql = "SELECT EXTRACT('{quoted}' FROM created_at) FROM events" @@ -1049,6 +1084,11 @@ id = "star_exclude_quoted_name" marker = "EXCLUDE(\"" note = "SELECT * EXCLUDE (\"note\") excludes the case-sensitive column note, not NOTE; polyglot drops the quotes of a star's EXCLUDE names" +[[kept_quoting]] +id = "comment_on_quoted_name" +marker = "COMMENTON" +note = "COMMENT ON TABLE \"v1\" names the case-sensitive object v1, not V1; polyglot drops the quotes of a COMMENT ON name; crates/sqlquack-syntax/tests/parser/test_fixes.rs pins the tree" + # Wrapper nodes SQLQuack keeps where polyglot drops them. The check counts an entry only when the # statement, without whitespace and ignoring case, contains `marker`, and both trees are equal once # every `{ "": { "this": … } }` in SQLQuack's tree is replaced by its operand. diff --git a/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs b/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs index 42baa83af..a492a1020 100644 --- a/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs +++ b/crates/sqlquack-polyglot-check/tests/runs/test_exact.rs @@ -24,6 +24,7 @@ fn given_committed_corpora_when_parsed_by_both_parsers_then_results_are_identica ("snowflake_cte_without_as_prose".to_owned(), 1), ("snowflake_reserved_type_name".to_owned(), 1), ("snowflake_string_part_extract".to_owned(), 1), + ("snowflake_function_body_number".to_owned(), 1), ("snowflake_collate_needs_string".to_owned(), 1), ("snowflake_dot_after_literal".to_owned(), 1), ("snowflake_by_name_on_except".to_owned(), 1), @@ -63,6 +64,10 @@ fn given_committed_corpora_when_parsed_by_both_parsers_then_results_are_identica expected_outcome: ExactOutcome { loaded: true, rejected: BTreeMap::from([ + ("snowflake_function_body_return".to_owned(), 1), + ("snowflake_function_body_return_contains_sql".to_owned(), 1), + ("snowflake_function_body_return_modifies_sql".to_owned(), 1), + ("snowflake_function_body_return_reads_sql".to_owned(), 1), ("snowflake_cte_without_as".to_owned(), 1), ("snowflake_empty_call_argument".to_owned(), 1), ("snowflake_reserved_cte_name".to_owned(), 1), diff --git a/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs b/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs index 3eefaa369..0322aafa7 100644 --- a/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs +++ b/crates/sqlquack-syntax/src/expressions/schema_objects/models.rs @@ -1047,6 +1047,9 @@ pub struct CreateType { pub name: TableRef, pub definition: TypeDefinition, pub if_not_exists: bool, + /// OR REPLACE clause + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub or_replace: bool, } /// Type definition @@ -1068,6 +1071,11 @@ pub enum TypeDefinition { output: String, internallength: Option, }, + /// Snowflake user-defined type: CREATE TYPE name AS data_type [COMMENT = 'text'] + Alias { + data_type: DataType, + comment: Option, + }, /// Domain type Domain { base_type: DataType, @@ -1097,6 +1105,7 @@ impl CreateType { name: TableRef::new(name), definition: TypeDefinition::Enum(values), if_not_exists: false, + or_replace: false, } } @@ -1105,6 +1114,7 @@ impl CreateType { name: TableRef::new(name), definition: TypeDefinition::Composite(attributes), if_not_exists: false, + or_replace: false, } } } @@ -1150,6 +1160,32 @@ pub struct AlterObject { pub properties: Vec, } +/// Snowflake CREATE of an object from another source, e.g. `CREATE DATABASE d1 FROM SHARE a1.s1`. +#[derive(sqlquack_syntax_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CreateObject { + /// The object kind, e.g. "DATABASE", "SCHEMA", "TABLE", "DYNAMIC TABLE" + pub kind: String, + /// A modifier before the kind, e.g. "TRANSIENT" + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modifier: Option, + /// OR REPLACE clause + #[serde(default)] + pub or_replace: bool, + /// IF NOT EXISTS clause + #[serde(default)] + pub if_not_exists: bool, + /// The object name + pub name: TableRef, + /// The source's keywords: "BACKUP SET", "SHARE", "REPLICA" or "ARCHIVE" + pub source: String, + /// The source's operands in order: names, an identifier string or a condition + #[serde(default)] + pub operands: Vec, + /// The properties set after the source + #[serde(default)] + pub properties: Vec, +} + /// One property of a Snowflake ALTER action, e.g. `SCHEDULE = '1 MINUTE'` or `TAG t1 = 'v'`. #[derive(sqlquack_syntax_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ObjectProperty { diff --git a/crates/sqlquack-syntax/src/expressions/statements/models.rs b/crates/sqlquack-syntax/src/expressions/statements/models.rs index 6cf7feee1..130c6e502 100644 --- a/crates/sqlquack-syntax/src/expressions/statements/models.rs +++ b/crates/sqlquack-syntax/src/expressions/statements/models.rs @@ -405,8 +405,11 @@ pub struct CreateTask { pub name: String, /// Raw text of properties between name and AS (WAREHOUSE, SCHEDULE, etc.) pub properties: String, - /// The SQL statement body after AS - pub body: Expression, + /// The SQL statement body after AS; absent for `CLONE source` + pub body: Option, + /// Source task of `CREATE TASK name CLONE source` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub clone_source: Option, } /// Raw/unparsed SQL diff --git a/crates/sqlquack-syntax/src/expressions/tree/_helpers/accessors.rs b/crates/sqlquack-syntax/src/expressions/tree/_helpers/accessors.rs index ea0cecb95..ba44b4728 100644 --- a/crates/sqlquack-syntax/src/expressions/tree/_helpers/accessors.rs +++ b/crates/sqlquack-syntax/src/expressions/tree/_helpers/accessors.rs @@ -92,6 +92,7 @@ impl Expression { | Expression::DropView(_) | Expression::AlterView(_) | Expression::AlterObject(_) + | Expression::CreateObject(_) | Expression::AlterIndex(_) | Expression::Truncate(_) | Expression::TruncateTable(_) diff --git a/crates/sqlquack-syntax/src/expressions/tree/_helpers/api.rs b/crates/sqlquack-syntax/src/expressions/tree/_helpers/api.rs index a84bef7df..7e97818e9 100644 --- a/crates/sqlquack-syntax/src/expressions/tree/_helpers/api.rs +++ b/crates/sqlquack-syntax/src/expressions/tree/_helpers/api.rs @@ -407,6 +407,7 @@ impl Expression { Expression::DropView(_) => "drop_view", Expression::AlterView(_) => "alter_view", Expression::AlterObject(_) => "alter_object", + Expression::CreateObject(_) => "create_object", Expression::AlterIndex(_) => "alter_index", Expression::Truncate(_) => "truncate", Expression::Use(_) => "use", diff --git a/crates/sqlquack-syntax/src/expressions/tree/models.rs b/crates/sqlquack-syntax/src/expressions/tree/models.rs index 8cb2f9d7f..beed444ea 100644 --- a/crates/sqlquack-syntax/src/expressions/tree/models.rs +++ b/crates/sqlquack-syntax/src/expressions/tree/models.rs @@ -340,6 +340,7 @@ use crate::expressions::schema_objects::models::AlterSequence; use crate::expressions::schema_objects::models::AlterView; use crate::expressions::schema_objects::models::CreateDatabase; use crate::expressions::schema_objects::models::CreateFunction; +use crate::expressions::schema_objects::models::CreateObject; use crate::expressions::schema_objects::models::CreateProcedure; use crate::expressions::schema_objects::models::CreateSchema; use crate::expressions::schema_objects::models::CreateSequence; @@ -1180,6 +1181,7 @@ pub enum Expression { DropView(Box), AlterView(Box), AlterObject(Box), + CreateObject(Box), AlterIndex(Box), Truncate(Box), Use(Box), diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs index b4d230804..d21558a34 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/commands.rs @@ -1156,11 +1156,11 @@ impl Parser { /// Parse a qualified name (schema.table.column or just table) pub(crate) fn parse_qualified_name(&mut self) -> Result { - let first = self.parse_unquoted_identifier(Self::expect_identifier_or_keyword)?; + let first = self.parse_quoting_identifier(Self::expect_identifier_or_keyword)?; let mut parts = vec![first]; while self.match_token(TokenType::Dot) { - let next = self.parse_unquoted_identifier(Self::expect_identifier_or_keyword)?; + let next = self.parse_quoting_identifier(Self::expect_identifier_or_keyword)?; parts.push(next); } @@ -1186,7 +1186,10 @@ impl Parser { .map(|(start, end)| start.through(end)); let table_name = parts .iter() - .map(|id| id.name.as_str()) + .map(|id| match id.quoted { + true => format!("\"{}\"", id.name.replace('"', "\"\"")), + false => id.name.clone(), + }) .collect::>() .join("."); let mut table = Identifier::new(table_name); @@ -1492,7 +1495,10 @@ impl Parser { let if_not_exists = self.match_keywords(&[TokenType::If, TokenType::Not, TokenType::Exists]); - let name = self.parse_table_ref()?; + let name = match self.is_snowflake() { + true => self.parse_object_name()?, + false => self.parse_table_ref()?, + }; let (parameters, has_parens) = if self.match_token(TokenType::LParen) { let params = self.parse_function_parameters()?; @@ -1731,6 +1737,12 @@ impl Parser { mut function: CreateFunction, ) -> Result { function = record_function_property(function, FunctionPropertyKind::As); + if self.is_snowflake() + && !self.check(TokenType::String) + && !self.check(TokenType::DollarString) + { + return Err(self.parse_error("Expected the function body as a string")); + } if self.match_identifier("RETURN") { let expr = if self.check(TokenType::Select) || self.check(TokenType::With) { self.parse_statement()? diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/create_table.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/create_table.rs index 938342c56..0419f3b6b 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/create_table.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/create_table.rs @@ -69,7 +69,10 @@ impl Parser { ) ); - let name = self.parse_table_ref()?; + let name = match self.is_snowflake() { + true => self.parse_object_name()?, + false => self.parse_table_ref()?, + }; let uuid = { None }; @@ -162,11 +165,7 @@ impl Parser { let copy_grants = self.match_text_seq(&["COPY", "GRANTS"]); - let using_template = if self.match_text_seq(&["USING", "TEMPLATE"]) { - Some(Box::new(self.parse_primary()?)) - } else { - None - }; + let using_template = self.parse_using_template()?; if using_template.is_some() { return self.parse_create_table_using_template( @@ -220,7 +219,8 @@ impl Parser { ); } - if !self.check(TokenType::LParen) && is_special_modifier { + let snowflake_hybrid = self.check_snowflake_hybrid_body(table_modifier)?; + if !self.check(TokenType::LParen) && is_special_modifier && !snowflake_hybrid { return self.parse_create_table_special_without_columns( CreateTableHead { name, @@ -1539,3 +1539,35 @@ impl Parser { Ok(options) } } + +impl Parser { + /// Parses `USING TEMPLATE`; Snowflake takes only a string or a parenthesized query. + fn parse_using_template(&mut self) -> Result>> { + if !self.match_text_seq(&["USING", "TEMPLATE"]) { + return Ok(None); + } + let query = self.check(TokenType::String) + || self.check(TokenType::LParen) + && matches!( + self.peek_nth(1).map(|token| token.token_type), + Some(TokenType::Select | TokenType::With) + ); + if self.is_snowflake() && !query { + return Err( + self.parse_error("Expected a string or a parenthesized query after USING TEMPLATE") + ); + } + Ok(Some(Box::new(self.parse_primary()?))) + } + + /// Reports whether this is a Snowflake hybrid table, which needs columns, AS or LIKE. + fn check_snowflake_hybrid_body(&mut self, table_modifier: Option<&str>) -> Result { + let hybrid = self.is_snowflake() && matches!(table_modifier, Some("HYBRID")); + if hybrid && !self.check(TokenType::LParen) && !self.check(TokenType::Like) { + return Err( + self.parse_error("Expected columns, AS or LIKE after the hybrid table name") + ); + } + Ok(hybrid) + } +} diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/mod.rs index 8c7a28b60..27aedc79f 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_from; pub(crate) mod snowflake_show; pub(crate) mod snowflake_statements; pub(crate) mod table_options; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter.rs index 210ab356b..bcd20fa59 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_alter.rs @@ -102,7 +102,7 @@ impl Parser { } /// Parse an object name: an identifier, never a string or an unquoted reserved keyword. - pub(super) fn parse_object_name(&mut self) -> Result { + pub(crate) fn parse_object_name(&mut self) -> Result { if self.check(TokenType::String) || self.is_snowflake_reserved_keyword() || !self.is_object_name_start() diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_from.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_from.rs new file mode 100644 index 000000000..052997a7d --- /dev/null +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/snowflake_create_from.rs @@ -0,0 +1,97 @@ +//! Snowflake CREATE of a database, schema or table from a backup set, a share, a replica or an archive. + +use crate::expressions::names::models::TableRef; +use crate::expressions::schema_objects::models::CreateObject; +use crate::expressions::schema_objects::models::ObjectProperty; +use crate::expressions::tree::models::Expression; +use crate::parser::models::Parser; +use crate::tokens::models::TokenType; +use crate::tokens::types::Result; + +impl Parser { + /// A Snowflake CREATE from another source, after CREATE and its modifiers; None leaves every other CREATE to its own parser. + pub(crate) fn parse_snowflake_create_from( + &mut self, + or_replace: bool, + modifier: Option<&str>, + ) -> Result> { + let start = self.current; + let plain = matches!(modifier, None | Some("TRANSIENT")); + let dynamic = matches!(modifier, Some("DYNAMIC")); + let table = self.check(TokenType::Table); + let (kind, database) = if dynamic && table { + ("DYNAMIC TABLE", false) + } else if plain && table { + ("TABLE", false) + } else if plain && self.check_text_seq(&["DATABASE"]) { + ("DATABASE", true) + } else if plain && self.check_text_seq(&["SCHEMA"]) { + ("SCHEMA", false) + } else { + return Ok(None); + }; + self.skip(); + let if_not_exists = + self.match_keywords(&[TokenType::If, TokenType::Not, TokenType::Exists]); + let Ok(name) = self.parse_object_name() else { + self.current = start; + return Ok(None); + }; + let mut operands: Vec = Vec::new(); + let mut properties: Vec = Vec::new(); + let source = if self.match_text_seq(&["FROM", "BACKUP"]) { + self.expect_alter_words(&[&["SET"]])?; + operands.push(Expression::Table(Box::new(self.parse_object_name()?))); + self.expect_alter_words(&[&["IDENTIFIER"]])?; + operands.push(self.parse_string_value()?); + "BACKUP SET" + } else if database && self.match_text_seq(&["FROM", "SHARE"]) { + operands.push(Expression::Table(Box::new(self.parse_two_part_name()?))); + "SHARE" + } else if database && self.match_text_seq(&["AS", "REPLICA"]) { + self.expect_alter_words(&[&["OF"]])?; + operands.push(Expression::Table(Box::new(self.parse_two_part_name()?))); + if !self.is_at_end() && !self.check(TokenType::Semicolon) { + properties = self.parse_alter_object_properties(true, false)?; + } + "REPLICA" + } else if plain && table && self.match_text_seq(&["FROM", "ARCHIVE"]) { + self.expect_alter_words(&[&["OF"]])?; + let mut archive = self.parse_object_name()?; + let explicit = self.match_token(TokenType::As); + if explicit || !self.check(TokenType::Where) { + archive.alias = Some(self.parse_simple_name()?); + archive.alias_explicit_as = explicit; + } + operands.push(Expression::Table(Box::new(archive))); + self.expect_token(TokenType::Where)?; + operands.push(self.parse_expression()?); + "ARCHIVE" + } else { + self.current = start; + return Ok(None); + }; + if !self.is_at_end() && !self.check(TokenType::Semicolon) { + return Err(self.parse_error(format!("Unexpected token after CREATE {kind} FROM"))); + } + Ok(Some(Expression::CreateObject(Box::new(CreateObject { + kind: kind.to_owned(), + modifier: modifier.filter(|_| !dynamic).map(str::to_owned), + or_replace, + if_not_exists, + name, + source: source.to_owned(), + operands, + properties, + })))) + } + + /// Parse a name of at least two parts, such as `.`. + fn parse_two_part_name(&mut self) -> Result { + let name = self.parse_object_name()?; + if name.schema.is_none() { + return Err(self.parse_error("Expected a qualified name")); + } + Ok(name) + } +} diff --git a/crates/sqlquack-syntax/src/parser/_helpers/ddl/transactions.rs b/crates/sqlquack-syntax/src/parser/_helpers/ddl/transactions.rs index 3e0f2eaac..e91ffacd6 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/ddl/transactions.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/ddl/transactions.rs @@ -210,6 +210,19 @@ impl Parser { || self.check_identifier("IMMEDIATE") || self.check_identifier("EXCLUSIVE"); + if self.is_snowflake() && self.check_identifier("NAME") { + self.refuse_snowflake_nameless_transaction()?; + self.skip(); + let name = self.expect_identifier_or_safe_keyword_with_quoted()?; + return Ok(Expression::Transaction(Box::new(Transaction { + this: Some(Box::new(Expression::Identifier(name))), + modes: None, + mark: Some(Box::new(Expression::Identifier(Identifier::new( + "BEGIN".to_string(), + )))), + }))); + } + if !is_transaction { if self.check_identifier("TRY") { return self.parse_tsql_try_catch(); diff --git a/crates/sqlquack-syntax/src/parser/_helpers/expressions/grouping.rs b/crates/sqlquack-syntax/src/parser/_helpers/expressions/grouping.rs index d19700d10..13e8c16fd 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/expressions/grouping.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/expressions/grouping.rs @@ -136,7 +136,9 @@ impl Parser { return Ok(None); } - self.match_token(TokenType::RParen); + if !self.match_token(TokenType::RParen) && self.is_snowflake() { + return Err(self.parse_error(format!("Expected ) to close {this}"))); + } Ok(Some(Expression::HistoricalData(Box::new(HistoricalData { this: Box::new(Expression::Identifier(Identifier::new(this))), diff --git a/crates/sqlquack-syntax/src/parser/_helpers/expressions/operators.rs b/crates/sqlquack-syntax/src/parser/_helpers/expressions/operators.rs index b6fc86fe1..42401f1b5 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/expressions/operators.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/expressions/operators.rs @@ -956,11 +956,14 @@ impl Parser { } /// Parse CREATE TYPE statement - pub(crate) fn parse_create_type(&mut self) -> Result { + pub(crate) fn parse_create_type(&mut self, or_replace: bool) -> Result { self.expect_token(TokenType::Type)?; let if_not_exists = self.match_keywords(&[TokenType::If, TokenType::Not, TokenType::Exists]); + if self.is_snowflake() { + return self.parse_snowflake_create_type(or_replace, if_not_exists); + } let name = self.parse_table_ref()?; if !self.match_token(TokenType::As) { @@ -972,6 +975,7 @@ impl Parser { internallength: None, }, if_not_exists, + or_replace, }))); } @@ -1053,6 +1057,33 @@ impl Parser { name, definition, if_not_exists, + or_replace, + }))) + } + + /// Snowflake CREATE TYPE after IF NOT EXISTS: a name, AS, a data type and an optional COMMENT string. + fn parse_snowflake_create_type( + &mut self, + or_replace: bool, + if_not_exists: bool, + ) -> Result { + let name = self.parse_object_name()?; + self.expect_token(TokenType::As)?; + if self.check(TokenType::String) || self.check(TokenType::Semicolon) || self.is_at_end() { + return Err(self.parse_error("Expected a data type after AS")); + } + let data_type = self.parse_data_type()?; + let comment = if self.match_token(TokenType::Comment) { + self.expect_token(TokenType::Eq)?; + Some(self.expect_string()?) + } else { + None + }; + Ok(Expression::CreateType(Box::new(CreateType { + name, + definition: TypeDefinition::Alias { data_type, comment }, + if_not_exists, + or_replace, }))) } @@ -1114,6 +1145,7 @@ impl Parser { constraints, }, if_not_exists, + or_replace: false, }))) } @@ -1182,6 +1214,35 @@ impl Parser { let if_not_exists = self.match_keywords(&[TokenType::If, TokenType::Not, TokenType::Exists]); + if self.is_snowflake() { + let start = self.current; + let target = self.parse_object_name(); + if let (Ok(target), true) = (target, self.check_identifier("CLONE")) { + let name = [target.catalog, target.schema, Some(target.name)] + .into_iter() + .flatten() + .map(|part| match part.quoted { + true => format!("\"{}\"", part.name.replace('"', "\"\"")), + false => part.name, + }) + .collect::>() + .join("."); + self.skip(); + let clone_source = self.parse_object_name()?; + return Ok(Expression::CreateTask(Box::new( + crate::expressions::statements::models::CreateTask { + or_replace, + if_not_exists, + name, + properties: String::new(), + body: None, + clone_source: Some(clone_source), + }, + ))); + } + self.current = start; + } + let mut name = String::new(); if self.check(TokenType::Var) || self.check_keyword() || self.is_identifier_token() { name.push_str(&self.advance_text()?); @@ -1212,7 +1273,8 @@ impl Parser { if_not_exists, name, properties, - body, + body: Some(body), + clone_source: None, }, ))) } diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs index 8dae65189..a72f3ad51 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/generic.rs @@ -60,6 +60,9 @@ impl Parser { name: &str, quoted: bool, ) -> Result { + if self.is_snowflake() && !quoted && name.eq_ignore_ascii_case("IN") { + return Err(self.parse_error("IN is an operator, not a function")); + } let is_known_agg = self.is_aggregate_function(name); let (mut args, distinct) = if self.check(TokenType::RParen) { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/registry.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/registry.rs index 73c000e01..7469fabdb 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/registry.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/registry.rs @@ -379,7 +379,10 @@ impl Parser { self.parse_registry_log() } (crate::functions::typed::models::TypedParseKind::Variadic, "FLATTEN") => { - let args = self.parse_function_arguments()?; + let args = match self.check(TokenType::RParen) { + true => Vec::new(), + false => self.parse_function_arguments()?, + }; self.expect_token(TokenType::RParen)?; Ok(Some(Expression::Function(Box::new(Function { on_error: None, diff --git a/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs b/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs index 2f4167556..ece629793 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/functions/typed.rs @@ -768,6 +768,9 @@ impl Parser { }; return Ok(match upper_name { "MODE" => Expression::Mode(Box::new(agg)), + _ if self.is_snowflake() => { + Expression::Function(Box::new(Function::new(name, Vec::new()))) + } _ => { return Err( self.parse_error(format!("{} cannot have zero arguments", upper_name)) diff --git a/crates/sqlquack-syntax/src/parser/_helpers/queries/select.rs b/crates/sqlquack-syntax/src/parser/_helpers/queries/select.rs index a5835104f..5e0cdec72 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/queries/select.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/queries/select.rs @@ -608,7 +608,9 @@ impl Parser { } else if self.match_token(TokenType::Lateral) { self.parse_table_lateral_source()? } else if self.match_token(TokenType::LParen) { - self.parse_table_parenthesized_source()? + let source = self.parse_table_parenthesized_source()?; + self.refuse_snowflake_parenthesized_call(&source)?; + source } else if self.is_identifier_token() || self.is_safe_keyword_as_identifier() || self.can_be_alias_keyword() diff --git a/crates/sqlquack-syntax/src/parser/_helpers/queries/snowflake_pivot.rs b/crates/sqlquack-syntax/src/parser/_helpers/queries/snowflake_pivot.rs index a2a23b80e..378d3aed0 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/queries/snowflake_pivot.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/queries/snowflake_pivot.rs @@ -1,4 +1,4 @@ -//! Snowflake's PIVOT and UNPIVOT rules: an aggregate of a column, non-empty lists, bare aliases. +//! Snowflake's PIVOT, UNPIVOT and parenthesized table-function rules. use crate::expressions::tree::models::Expression; use crate::parser::models::Parser; @@ -19,7 +19,7 @@ impl Parser { | Expression::Literal(_) | Expression::Tuple(_) | Expression::Identifier(_) - ); + ) || matches!(aggregate, Expression::Function(call) if call.args.is_empty()); let of_string = aggregate.children().iter().any( |argument| matches!(argument, Expression::Literal(literal) if literal.is_string()), ); @@ -57,4 +57,13 @@ impl Parser { false => Ok(None), } } + + /// Refuses Snowflake's `FROM ( f ( ... ) )`: a table function needs TABLE( ), not parentheses. + pub(crate) fn refuse_snowflake_parenthesized_call(&self, source: &Expression) -> Result<()> { + let call = matches!(source, Expression::Paren(paren) if matches!(paren.this, Expression::Function(_))); + match self.is_snowflake() && call { + true => Err(self.parse_error("Expected TABLE( ) around a table function")), + false => Ok(()), + } + } } diff --git a/crates/sqlquack-syntax/src/parser/_helpers/queries/tables.rs b/crates/sqlquack-syntax/src/parser/_helpers/queries/tables.rs index b5c3336b7..312e701dd 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/queries/tables.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/queries/tables.rs @@ -1070,8 +1070,10 @@ impl Parser { let func = self.parse_primary()?; self.refuse_snowflake_pivot_aggregate(&func)?; let expr = if self.match_token(TokenType::As) { - let alias_name = self.expect_identifier_or_keyword()?; - Expression::Alias(Box::new(Alias::new(func, Identifier::new(alias_name)))) + let quoted = self.check(TokenType::QuotedIdentifier); + let mut alias = Identifier::new(self.expect_identifier_or_keyword()?); + alias.quoted = quoted; + Expression::Alias(Box::new(Alias::new(func, alias))) } else if !self.check(TokenType::Comma) && !self.check(TokenType::For) && !self.check(TokenType::RParen) @@ -1080,13 +1082,14 @@ impl Parser { return Err(self.parse_error("Expected an identifier alias in PIVOT")); } if let Some(id) = self.parse_id_var()? { - let alias_name = match &id { - Expression::Identifier(ident) => ident.name.clone(), - Expression::Column(col) => col.name.name.clone(), - _ => String::new(), + let mut alias: Identifier = match id { + Expression::Identifier(ident) => ident, + Expression::Column(col) => col.name, + _ => Identifier::new(String::new()), }; - if !alias_name.is_empty() { - Expression::Alias(Box::new(Alias::new(func, Identifier::new(alias_name)))) + alias.span = None; + if !alias.name.is_empty() { + Expression::Alias(Box::new(Alias::new(func, alias))) } else { func } @@ -1231,29 +1234,31 @@ impl Parser { self.expect_token(TokenType::LParen)?; - let (value_column, value_column_parenthesized, extra_value_columns) = - if self.match_token(TokenType::LParen) { - let col = self.expect_identifier_or_keyword()?; - let mut extra_cols: Vec = Vec::new(); - while self.match_token(TokenType::Comma) { - extra_cols - .push(self.parse_unquoted_identifier(Self::expect_identifier_or_keyword)?); - } - self.expect_token(TokenType::RParen)?; - (Identifier::new(col), true, extra_cols) - } else { - ( - self.parse_unquoted_identifier(Self::expect_identifier_or_keyword)?, - false, - Vec::new(), - ) - }; + let (value_column, value_column_parenthesized, extra_value_columns) = if self + .match_token(TokenType::LParen) + { + let quoted = self.check(TokenType::QuotedIdentifier); + let mut col = Identifier::new(self.expect_identifier_or_keyword()?); + col.quoted = quoted; + let mut extra_cols: Vec = Vec::new(); + while self.match_token(TokenType::Comma) { + extra_cols.push(self.parse_quoting_identifier(Self::expect_identifier_or_keyword)?); + } + self.expect_token(TokenType::RParen)?; + (col, true, extra_cols) + } else { + ( + self.parse_quoting_identifier(Self::expect_identifier_or_keyword)?, + false, + Vec::new(), + ) + }; self.expect_token(TokenType::For)?; if self.is_snowflake() && self.is_snowflake_reserved_keyword() { return Err(self.parse_error("Expected a column name after FOR")); } - let name_column = self.parse_unquoted_identifier(Self::expect_identifier_or_keyword)?; + let name_column = self.parse_quoting_identifier(Self::expect_identifier_or_keyword)?; self.expect_token(TokenType::In)?; self.expect_token(TokenType::LParen)?; diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/analyze.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/analyze.rs index 724a7f471..20a0a9cf4 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/analyze.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/analyze.rs @@ -492,6 +492,17 @@ impl Parser { Ok(Identifier::new(name).with_span(start.through(self.previous().span))) } + /// An identifier parsed by `parse_name` that keeps its span and whether it was quoted. + pub(crate) fn parse_quoting_identifier( + &mut self, + parse_name: fn(&mut Self) -> Result, + ) -> Result { + let quoted = self.check(TokenType::QuotedIdentifier); + let mut identifier = self.parse_unquoted_identifier(parse_name)?; + identifier.quoted = quoted; + Ok(identifier) + } + /// A column whose range comes only from source-backed identifier parts. pub(crate) fn parsed_column(mut column: Column) -> Expression { if column.span.is_none() { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs index 65f20e9a7..e07d1e565 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/dml.rs @@ -265,6 +265,9 @@ impl Parser { let or_replace = self.match_keywords(&[TokenType::Or, TokenType::Replace]); let or_alter = !or_replace && self.match_text_seq(&["OR", "ALTER"]); + if self.is_snowflake() && self.check(TokenType::Or) { + return Err(self.parse_error("Expected REPLACE or ALTER after CREATE OR")); + } let hana_modifier: Option = None; let temporary = hana_modifier @@ -289,6 +292,14 @@ impl Parser { ); } + if self.is_snowflake() && !or_alter && !temporary && !materialized && !secure { + if let Some(create) = + self.parse_snowflake_create_from(or_replace, table_modifier.as_deref())? + { + return Ok(create); + } + } + match self.peek().token_type { TokenType::Table => { if self.current + 1 < self.tokens.len() @@ -353,7 +364,7 @@ impl Parser { self.skip(); self.parse_create_trigger(or_replace, or_alter, true, create_pos) } - TokenType::Type => self.parse_create_type(), + TokenType::Type => self.parse_create_type(or_replace), TokenType::Domain => self.parse_create_domain(), _ => self.parse_create_other_object(materialized, or_replace, temporary), } @@ -428,6 +439,15 @@ impl Parser { return Ok(Expression::Raw(Raw { sql: prefix })); } { + if self.is_snowflake() + && self.tokens.get(self.current + 1).is_some_and(|t| { + ["CLONE", "FROM", "AS", "USING", "LIKE", "("] + .iter() + .any(|word| t.text.eq_ignore_ascii_case(word)) + }) + { + return Err(self.parse_error("Expected an object kind and a name")); + } let start = self.current; while !self.is_at_end() && !self.check(TokenType::Semicolon) { self.skip(); diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/entry.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/entry.rs index a19e2dd96..b35b61b27 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/entry.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/entry.rs @@ -738,14 +738,7 @@ impl Parser { "SCHEMA".to_string() } else if self.match_token(TokenType::Database) { "DATABASE".to_string() - } else if self.match_texts(&[ - "ACCOUNT", - "NOTEBOOK", - "SNAPSHOT", - "STREAMLIT", - "TAG", - "TYPE", - ]) { + } else if self.match_texts(crate::parser::constants::SNOWFLAKE_UNDROP_KINDS) { self.previous_text().to_ascii_uppercase() } else { return Err(self.parse_error( diff --git a/crates/sqlquack-syntax/src/parser/_helpers/statements/sessions.rs b/crates/sqlquack-syntax/src/parser/_helpers/statements/sessions.rs index b8f7ab6c9..ecf3d33c1 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/statements/sessions.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/statements/sessions.rs @@ -554,7 +554,12 @@ impl Parser { None }; - self.match_token(TokenType::RParen); + let closed = self.match_token(TokenType::RParen); + if self.is_snowflake() && (information.is_none() || !closed) { + return Err( + self.parse_error("Expected a value and ) in CHANGES ( INFORMATION => ... )") + ); + } let at_before = self.parse_historical_data()?.map(Box::new); diff --git a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs index 54dcc27d7..6d03c529e 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/types/casts.rs @@ -1132,6 +1132,9 @@ impl Parser { } break; } + if self.is_snowflake() && self.check(TokenType::String) { + return Err(self.parse_error("Expected a type parameter, not a string")); + } let token = self.advance()?; if !after_comma && !args.is_empty() { if let Some(last) = args.last_mut() { diff --git a/crates/sqlquack-syntax/src/parser/_helpers/types/data_types.rs b/crates/sqlquack-syntax/src/parser/_helpers/types/data_types.rs index 1d588de02..68976472e 100644 --- a/crates/sqlquack-syntax/src/parser/_helpers/types/data_types.rs +++ b/crates/sqlquack-syntax/src/parser/_helpers/types/data_types.rs @@ -881,9 +881,9 @@ impl Parser { let mut renames: Vec<(Identifier, Identifier)> = Vec::new(); if self.match_token(TokenType::LParen) { loop { - let old_name = self.parse_unquoted_identifier(Self::expect_identifier)?; + let old_name = self.parse_quoting_identifier(Self::expect_identifier)?; self.expect_token(TokenType::As)?; - let new_name = self.parse_unquoted_identifier(Self::expect_identifier)?; + let new_name = self.parse_quoting_identifier(Self::expect_identifier)?; renames.push((old_name, new_name)); if !self.match_token(TokenType::Comma) { break; @@ -891,9 +891,9 @@ impl Parser { } self.expect_token(TokenType::RParen)?; } else { - let old_name = self.parse_unquoted_identifier(Self::expect_identifier)?; + let old_name = self.parse_quoting_identifier(Self::expect_identifier)?; self.expect_token(TokenType::As)?; - let new_name = self.parse_unquoted_identifier(Self::expect_identifier)?; + let new_name = self.parse_quoting_identifier(Self::expect_identifier)?; renames.push((old_name, new_name)); } rename = Some(renames); diff --git a/crates/sqlquack-syntax/src/parser/constants.rs b/crates/sqlquack-syntax/src/parser/constants.rs index 1f25a16f7..591fd9ed9 100644 --- a/crates/sqlquack-syntax/src/parser/constants.rs +++ b/crates/sqlquack-syntax/src/parser/constants.rs @@ -741,6 +741,18 @@ pub(crate) const SNOWFLAKE_SHOW_KINDS: &[&[&str]] = &[ &["WORKSPACES"], ]; +/// The one-word object kinds UNDROP takes besides TABLE, SCHEMA and DATABASE. +pub(crate) const SNOWFLAKE_UNDROP_KINDS: &[&str] = &[ + "ACCOUNT", + "NOTEBOOK", + "SNAPSHOT", + "STAGE", + "STREAMLIT", + "TAG", + "TYPE", + "VARIABLE", +]; + /// SHOW kinds the shared SHOW still reads for Snowflake, by their leading words. pub(crate) const SHARED_SHOW_KINDS: &[&[&str]] = &[ &["CALLER"], diff --git a/crates/sqlquack-syntax/tests/parser.rs b/crates/sqlquack-syntax/tests/parser.rs index 4c2829a67..88db79448 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_recorded.rs"] +mod test_snowflake_recorded; #[path = "parser/test_types.rs"] mod test_types; diff --git a/crates/sqlquack-syntax/tests/parser/test_fixes.rs b/crates/sqlquack-syntax/tests/parser/test_fixes.rs index cc6ad09b0..0eec132b0 100644 --- a/crates/sqlquack-syntax/tests/parser/test_fixes.rs +++ b/crates/sqlquack-syntax/tests/parser/test_fixes.rs @@ -1224,6 +1224,48 @@ fn given_statements_from_the_parser_only_disagreements_when_parsing_snowflake_th dialect: Dialect::Snowflake, expected_accepted: true, }, + AcceptanceTestCase { + description: "CREATE TASK clones without an AS body (recorded success)", + sql: "CREATE OR REPLACE TASK v1 CLONE v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a clone needs a kind and a name (recorded errno 1003)", + sql: "CREATE v1 CLONE v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "CREATE OR needs REPLACE or ALTER (recorded errno 1003)", + sql: "CREATE OR TASK v1 CLONE v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a backup set restore names its identifier as a string (recorded errno 1003 without)", + sql: "CREATE TABLE v1 FROM BACKUP SET v1 IDENTIFIER m1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a share is named by its account (recorded errno 1003 without)", + sql: "CREATE DATABASE v1 FROM SHARE v1 . ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "an archive alias is no reserved keyword (recorded errno 1003)", + sql: "CREATE TABLE v1 FROM ARCHIVE OF v1 SELECT WHERE 1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a CREATE from a source needs its kind (recorded errno 1003)", + sql: "CREATE v1 FROM SHARE v1 . v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, ]; for test_case in test_cases { @@ -1796,6 +1838,72 @@ fn given_a_documented_snowflake_element_when_parsing_then_the_tree_keeps_it() { pointer: "/0/use/roles/1/quoted", expected_value: json!(true), }, + ProjectionTestCase { + description: "a quoted RENAME target stays quoted", + sql: "SELECT * RENAME v1 AS \"v1 Q\" FROM t1", + pointer: "/0/select/expressions/0/star/rename/0/1/quoted", + expected_value: json!(true), + }, + ProjectionTestCase { + description: "a quoted PIVOT aggregate alias stays quoted", + sql: "SELECT * FROM t1 PIVOT ( SUM ( v1 ) \"v1 Q\" FOR v2 IN ( 1 , 2 ) )", + pointer: "/0/select/from/expressions/0/pivot/expressions/0/alias/alias/quoted", + expected_value: json!(true), + }, + ProjectionTestCase { + description: "a quoted UNPIVOT name column stays quoted", + sql: "SELECT * FROM t1 UNPIVOT ( v1 FOR \"v1 Q\" IN ( a , b ) )", + pointer: "/0/select/from/expressions/0/unpivot/name_column/quoted", + expected_value: json!(true), + }, + ProjectionTestCase { + description: "a quoted COMMENT ON name stays quoted", + sql: "COMMENT ON TABLE \"v1\" IS 'm'", + pointer: "/0/comment/this/identifier/quoted", + expected_value: json!(true), + }, + ProjectionTestCase { + description: "a quoted COMMENT ON COLUMN table stays quoted", + sql: "COMMENT ON COLUMN \"v1 Q\" . v1 IS 'm'", + pointer: "/0/comment/this/column/table/quoted", + expected_value: json!(true), + }, + ProjectionTestCase { + description: "a quoted part of a longer COMMENT ON COLUMN table keeps its quotes", + sql: "COMMENT ON COLUMN \"a.b\" . c . d IS 'm'", + pointer: "/0/comment/this/column/table/name", + expected_value: json!("\"a.b\".c"), + }, + ProjectionTestCase { + description: "a cloned task keeps its source", + sql: "CREATE TASK \"v1 Q\" CLONE db.v2", + pointer: "/0/create_task/clone_source/schema/name", + expected_value: json!("db"), + }, + ProjectionTestCase { + description: "a cloned task keeps its name's quoting", + sql: "CREATE TASK \"v1 Q\" CLONE db.v2", + pointer: "/0/create_task/name", + expected_value: json!("\"v1 Q\""), + }, + ProjectionTestCase { + description: "a database replica keeps its source and properties", + sql: "CREATE DATABASE v1 AS REPLICA OF a1 . \"d Q\" DATA_RETENTION_TIME_IN_DAYS = 1", + pointer: "/0/create_object/properties/0/kind", + expected_value: json!("DATA_RETENTION_TIME_IN_DAYS"), + }, + ProjectionTestCase { + description: "an archive keeps its alias apart from the condition", + sql: "CREATE TRANSIENT TABLE v1 FROM ARCHIVE OF v2 AS a1 WHERE 1", + pointer: "/0/create_object/operands/0/table/alias/name", + expected_value: json!("a1"), + }, + ProjectionTestCase { + description: "a backup set restore keeps the set and its identifier", + sql: "CREATE DYNAMIC TABLE v1 FROM BACKUP SET s1 IDENTIFIER 'm'", + pointer: "/0/create_object/operands/1/literal/value", + expected_value: json!("m"), + }, ]; for test_case in test_cases { diff --git a/crates/sqlquack-syntax/tests/parser/test_scripting.rs b/crates/sqlquack-syntax/tests/parser/test_scripting.rs index 552d2f8d6..01581a8a8 100644 --- a/crates/sqlquack-syntax/tests/parser/test_scripting.rs +++ b/crates/sqlquack-syntax/tests/parser/test_scripting.rs @@ -400,10 +400,10 @@ fn given_begin_and_declare_when_parsing_then_only_snowflake_blocks_change() { expected_root: Ok("transaction"), }, ScriptingDialectTestCase { - description: "Snowflake BEGIN NAME keeps its existing non-block path", + description: "Snowflake BEGIN NAME stays a named transaction, not a block", sql: "BEGIN NAME orders_tx", dialect: Dialect::Snowflake, - expected_root: Ok("command"), + expected_root: Ok("transaction"), }, ScriptingDialectTestCase { description: "Snowflake variable named name is a block member", diff --git a/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs new file mode 100644 index 000000000..70ae687f8 --- /dev/null +++ b/crates/sqlquack-syntax/tests/parser/test_snowflake_recorded.rs @@ -0,0 +1,186 @@ +use serde_json::json; + +use sqlquack_syntax::Dialect; + +use crate::helpers::{accepted, statement_at}; +use crate::test_types::{AcceptanceTestCase, ProjectionTestCase}; + +#[test] +fn given_recorded_snowflake_cases_when_parsing_then_acceptance_matches_snowflake() { + let test_cases = [ + AcceptanceTestCase { + description: "UNDROP takes a stage (recorded success)", + sql: "UNDROP STAGE \"v1 Q\" ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "UNDROP takes a variable (recorded success)", + sql: "UNDROP VARIABLE v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "CREATE TYPE names a data type (recorded success)", + sql: "CREATE OR REPLACE TYPE IF NOT EXISTS v1 AS INT COMMENT = 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a type's COMMENT is a string (recorded errno 1003)", + sql: "CREATE TYPE v1 AS INT COMMENT = m1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a table name is no reserved keyword (recorded errno 1003)", + sql: "CREATE HYBRID TABLE SELECT AS SELECT 1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a hybrid table needs columns, AS or LIKE (recorded errno 1003)", + sql: "CREATE HYBRID TABLE v1 v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "USING TEMPLATE takes a query, not a value (recorded errno 1003)", + sql: "CREATE TABLE v1 USING TEMPLATE ( 1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "USING TEMPLATE takes a string (recorded success)", + sql: "CREATE TABLE v1 USING TEMPLATE 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a function body is a string (recorded errno 1003)", + sql: "CREATE FUNCTION v1 ( ) RETURNS INT AS m1 ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a function name is no reserved keyword (recorded errno 1003)", + sql: "CREATE FUNCTION SELECT ( ) RETURNS INT AS 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "BEGIN NAME needs a name (recorded errno 1003)", + sql: "BEGIN NAME 'v1' ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "BEGIN NAME names the transaction (recorded success)", + sql: "BEGIN NAME v1 ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a lone word before ( is no object kind (recorded errno 1003)", + sql: "CREATE v1 ( ) RETURNS INT AS 'm' ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a window aggregate parses without arguments (recorded semantic error)", + sql: "SELECT SUM ( ) OVER ( ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a PIVOT aggregate needs an argument (recorded errno 1003)", + sql: "SELECT 1 FROM t1 PIVOT ( SUM ( ) FOR c1 IN ( 1 ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "FLATTEN parses without arguments (recorded semantic error)", + sql: "SELECT * FROM TABLE ( FLATTEN ( ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "a table function needs TABLE( ), not parentheses (recorded errno 1003)", + sql: "SELECT * FROM ( FLATTEN ( ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "AT needs its closing parenthesis (recorded errno 1003)", + sql: "SELECT 1 FROM t1 AT ( TIMESTAMP => TO_TIMESTAMP_LTZ ( '2026-01-01' ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "CHANGES needs an INFORMATION value (recorded errno 1003)", + sql: "SELECT 1 FROM t1 CHANGES ( INFORMATION => ) AT ( TIMESTAMP => TO_TIMESTAMP_LTZ ( '2026-01-01' ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "CHANGES takes an INFORMATION value (recorded success)", + sql: "SELECT 1 FROM t1 CHANGES ( INFORMATION => DEFAULT ) AT ( TIMESTAMP => TO_TIMESTAMP_LTZ ( '2026-01-01' ) ) ;", + dialect: Dialect::Snowflake, + expected_accepted: true, + }, + AcceptanceTestCase { + description: "IN is no function (recorded errno 1003)", + sql: "SELECT IN ( 1 ) ;", + dialect: Dialect::Snowflake, + expected_accepted: false, + }, + AcceptanceTestCase { + description: "a type parameter is no string (recorded errno 1003)", + sql: "SELECT CAST ( NULL AS PERIOD ( 'INT' ) ) ;", + 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_snowflake_create_and_begin_name_when_parsing_then_the_tree_keeps_the_element() { + let test_cases = [ + ProjectionTestCase { + description: "BEGIN NAME keeps the name's quoting", + sql: "BEGIN NAME \"v1 Q\"", + pointer: "/0/transaction/this/identifier/quoted", + expected_value: json!(true), + }, + ProjectionTestCase { + description: "a Snowflake type keeps its data type", + sql: "CREATE TYPE v1 AS INT", + pointer: "/0/create_type/definition/Alias/data_type/data_type", + expected_value: json!("int"), + }, + ProjectionTestCase { + description: "an aggregate without arguments stays a call with no arguments", + sql: "SELECT SUM ( ) OVER ( )", + pointer: "/0/select/expressions/0/window_function/this/function/args", + expected_value: json!([]), + }, + ]; + + for test_case in test_cases { + assert_eq!( + statement_at(test_case.sql, test_case.pointer), + test_case.expected_value, + "{}", + test_case.description + ); + } +} diff --git a/docs/syntax.md b/docs/syntax.md index f57e7256d..f58e88beb 100644 --- a/docs/syntax.md +++ b/docs/syntax.md @@ -199,7 +199,8 @@ A statement both parsers parse, where polyglot drops the quoting of a name SQLQu is a `[[kept_quoting]]` entry: an id, a marker the statement contains (whitespace removed) and a reason. The check counts it as kept quoting, not different, only when the marker appears and the two trees differ in nothing but identifiers that are unquoted in polyglot's tree and quoted in -SQLQuack's. The only entry: a star's `EXCLUDE ("note")`, whose quotes make the name case-sensitive. +SQLQuack's. The entries: a star's `EXCLUDE ("note")` and a `COMMENT ON` name, whose quotes make +the name case-sensitive. A statement both parsers parse, where SQLQuack keeps a wrapper node polyglot drops, is a `[[kept_node]]` entry: an id, a marker, the node and a reason. The check counts it as a kept node, diff --git a/scripts/duckshop_measure/_helpers/functions.py b/scripts/duckshop_measure/_helpers/functions.py index 6b5cc9f69..c75904a42 100644 --- a/scripts/duckshop_measure/_helpers/functions.py +++ b/scripts/duckshop_measure/_helpers/functions.py @@ -16,7 +16,7 @@ from scripts.census_functions.constants import FUNCTION_SUFFIX, SQL_LANGUAGE from scripts.census_functions.models import Signature from scripts.duckshop_measure._helpers.running import refusal_names -from scripts.duckshop_measure.constants import BODY_DELIMITER, PYTHON_LANGUAGE +from scripts.duckshop_measure.constants import BODY_DELIMITER, PYTHON_LANGUAGE, UNRESOLVED_UDF from scripts.duckshop_measure.models import FunctionMacro, ProjectFunction from scripts.duckshop_measure.types import FunctionConverter @@ -117,8 +117,10 @@ def create_macros(*, database: Path, macros: list[FunctionMacro]) -> dict[str, s def python_calls(*, texts: tuple[str, ...], python: frozenset[str]) -> tuple[str, ...]: - """The Python functions any of `texts` names, sorted.""" - joined: str = "\n".join(texts) + """The Python functions any of `texts` names or calls as `__udf("stem")`, sorted.""" + joined: str = UNRESOLVED_UDF.sub( + lambda call: placeholder(stem=call.group(1), table=False), "\n".join(texts) + ) return tuple(sorted(name for name in python if re.search(rf"\b{name}\b", joined))) diff --git a/scripts/duckshop_measure/_helpers/measures.py b/scripts/duckshop_measure/_helpers/measures.py index 6baebb2ba..93f3e2554 100644 --- a/scripts/duckshop_measure/_helpers/measures.py +++ b/scripts/duckshop_measure/_helpers/measures.py @@ -63,11 +63,11 @@ def write_schema( def model_python_calls( - *, inputs: SnowflakeInputs, sql: Path, refusals: tuple[str, ...] + *, inputs: SnowflakeInputs, sql: Path, source: Path, refusals: tuple[str, ...] ) -> tuple[str, ...]: - """The Python functions a model's conversion or refusals name.""" - converted: tuple[str, ...] = (sql.read_text(),) if sql.exists() else () - return python_calls(texts=(*converted, *refusals), python=inputs.python_functions) + """The Python functions a model's compiled source, conversion or refusals name.""" + texts: tuple[str, ...] = tuple(path.read_text() for path in (source, sql) if path.exists()) + return python_calls(texts=(*texts, *refusals), python=inputs.python_functions) def unit_test_outcomes( @@ -141,7 +141,14 @@ def snowflake_results(*, inputs: SnowflakeInputs, run: Comparer) -> list[ModelRe model_refusals: tuple[str, ...] = own.get( Path(str(sidecar["compiled_sql_path"])).stem, () ) - if model_python_calls(inputs=inputs, sql=sql, refusals=model_refusals): + if model_python_calls( + inputs=inputs, + sql=sql, + source=compiled_file( + compiled=compiled, compiled_sql_path=str(sidecar["compiled_sql_path"]) + ), + refusals=model_refusals, + ): continue if not sql.exists() or value_divergences(inputs=inputs, name=name): continue @@ -182,6 +189,9 @@ def snowflake_results(*, inputs: SnowflakeInputs, run: Comparer) -> list[ModelRe python: tuple[str, ...] = model_python_calls( inputs=inputs, sql=converted / name, + source=compiled_file( + compiled=compiled, compiled_sql_path=str(sidecar["compiled_sql_path"]) + ), refusals=own.get(Path(str(sidecar["compiled_sql_path"])).stem, ()), ) if python: diff --git a/scripts/duckshop_measure/constants.py b/scripts/duckshop_measure/constants.py index 4b9211ef1..666c6ba7b 100644 --- a/scripts/duckshop_measure/constants.py +++ b/scripts/duckshop_measure/constants.py @@ -69,6 +69,7 @@ WORKERS: int = 3 BODY_DELIMITER: str = "$$" PYTHON_LANGUAGE: str = "python" +UNRESOLVED_UDF: re.Pattern[str] = re.compile(r'__udf\(\s*"([^"]+)"\s*\)') TOP_CONSTRUCTS: int = 15 JSON_SUFFIX: str = ".json" VALUE_FAILURE: str = "value" diff --git a/tests/unit/scripts/duckshop_measure/_test_types.py b/tests/unit/scripts/duckshop_measure/_test_types.py index eed90ec02..15e7c1536 100644 --- a/tests/unit/scripts/duckshop_measure/_test_types.py +++ b/tests/unit/scripts/duckshop_measure/_test_types.py @@ -81,3 +81,4 @@ class FunctionMeasureTestCase: expected_converted: list[str] expected_compared: dict[str, object] expected_outcomes: dict[str, list[str]] + refused: tuple[tuple[str, str], ...] = () diff --git a/tests/unit/scripts/duckshop_measure/test_measure.py b/tests/unit/scripts/duckshop_measure/test_measure.py index d7450803d..3948b22e2 100644 --- a/tests/unit/scripts/duckshop_measure/test_measure.py +++ b/tests/unit/scripts/duckshop_measure/test_measure.py @@ -602,6 +602,16 @@ def test_given_compiled_functions_when_deriving_the_schema_then_they_sit_beside_ "m_py": ["python_udf_not_measured", "__SQLQUACK_UDF__PY_SCORE"], }, ), + FunctionMeasureTestCase( + description="a model census refuses for its unnamed fn: is still a Python caller", + models=(), + refused=(("m_refused", 'SELECT __udf("py_score")(1) AS a'),), + expected_converted=["__SQLQUACK_UDF__SKU_FAMILY"], + expected_compared={}, + expected_outcomes={ + "m_refused": ["python_udf_not_measured", "__SQLQUACK_UDF__PY_SCORE"], + }, + ), ), ids=lambda case: case.description, ) @@ -633,10 +643,23 @@ def test_given_project_functions_when_measuring_snowflake_then_sql_ones_run_and_ (results / f"k_{model}.json").write_text(json.dumps(sidecar)) entries.append({"key": f"k_{model}", "unique_id": f"model.shop.{model}", "outcome": "rows"}) (converted / str(compiled / "models" / f"{model}.sql").replace("/", "_")).write_text(sql) + own: list[dict[str, object]] = [] + for model, source in test_case.refused: + (compiled / "models" / f"{model}.sql").write_text(source) + refused_sidecar: dict[str, object] = { + "key": f"k_{model}", + "model": model, + "target": "dev", + "outcome": "rows", + "compiled_sql_path": f"benchmarks/x/target/compiled/models/{model}.sql", + } + (results / f"k_{model}.json").write_text(json.dumps(refused_sidecar)) + entries.append({"key": f"k_{model}", "unique_id": f"model.shop.{model}", "outcome": "rows"}) + own.append({"model": model, "own": [{"category": "missing rule", "construct": "fn:"}]}) (tmp_path / "index-core.json").write_text(json.dumps(entries)) (tmp_path / "index-edge.json").write_text("[]") census: Path = tmp_path / "census.json" - census.write_text(json.dumps({"blockers": {"files": []}})) + census.write_text(json.dumps({"blockers": {"files": own}})) converted_names: list[str] = [] compared: dict[str, object] = {}