From fd4cf7c21c83f8b62dd041d9b0c677bfe3dbbb92 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:05:04 +0000 Subject: [PATCH 01/11] WIP: replay case setup tables on DuckDB (D228 part 2, slice 1) Case setup of CREATE TABLE with NUMBER and VARCHAR columns and INSERT INTO it VALUES of literals now converts: the tables join the case's schema and their DuckDB statements run before the query, with the run database and schema placeholders read as DuckDB's memory.main. Every other setup statement still refuses, as do constraints, defaults, quoted names and values a column would round. Incomplete: SET variables, CREATE TYPE and SEMANTIC VIEW, the ALTER SESSION profile overlay, docs, and a full verify are not done. Checked: the new setup test only. --- .../src/conversion/constants.rs | 3 + .../src/conversion/main/convert_setup.rs | 241 ++++++++++++++++++ .../sqlquack-rules/src/conversion/main/mod.rs | 1 + .../sqlquack-rules/src/conversion/models.rs | 9 + crates/sqlquack/src/analysis/constants.rs | 5 +- crates/sqlquack/src/analysis/models.rs | 35 ++- crates/sqlquack/src/analysis/tests.rs | 2 + crates/sqlquack/src/analysis/tests/helpers.rs | 59 +++++ .../src/analysis/tests/test_case_setup.rs | 153 +++++++++++ .../sqlquack/src/analysis/tests/test_types.rs | 8 + 10 files changed, 501 insertions(+), 15 deletions(-) create mode 100644 crates/sqlquack-rules/src/conversion/main/convert_setup.rs create mode 100644 crates/sqlquack/src/analysis/tests/test_case_setup.rs diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index 006eb3e0f..702a42e15 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -1990,3 +1990,6 @@ pub(crate) const OUTPUT_NAMED_FUNCTIONS: &[&str] = &["LOWER", "UPPER", "COALESCE pub(crate) const OUTPUT_NAMED_ARITHMETIC: &[&str] = &["add", "sub", "mul", "div"]; /// The cast target spellings whose unaliased column names receipts record. pub(crate) const OUTPUT_NAMED_CAST_ENDINGS: &[&str] = &[" AS INTEGER)", "::INTEGER"]; +/// The setup statements a case's DuckDB replay runs. +pub(crate) const SETUP_REPLAYED: &str = + "CREATE TABLE of NUMBER and VARCHAR columns and INSERT INTO it VALUES of literals"; diff --git a/crates/sqlquack-rules/src/conversion/main/convert_setup.rs b/crates/sqlquack-rules/src/conversion/main/convert_setup.rs new file mode 100644 index 000000000..a1f05e0b5 --- /dev/null +++ b/crates/sqlquack-rules/src/conversion/main/convert_setup.rs @@ -0,0 +1,241 @@ +//! `convert_setup`: a case's setup statements as DuckDB statements and the relations they create. + +use sqlquack_core::parsing::main::parse_source::parse_source; +use sqlquack_core::parsing::models::SourceDialect; +use sqlquack_core::provenance::models::ProvenanceLog; +use sqlquack_duckdb::blocks::main::column_type::column_type; +use sqlquack_duckdb::blocks::main::quoted_identifier::quoted_identifier; +use sqlquack_duckdb::blocks::main::string_literal::string_literal; +use sqlquack_semantics::types::models::ColumnSpec; +use sqlquack_snowflake::typing::main::cast_target::cast_target; +use sqlquack_snowflake::typing::models::SnowflakeType; +use sqlquack_syntax::expressions::columns::models::ColumnDef; +use sqlquack_syntax::expressions::names::models::{Identifier, Literal, TableRef}; +use sqlquack_syntax::expressions::statements::models::Insert; +use sqlquack_syntax::expressions::tables::models::CreateTable; +use sqlquack_syntax::expressions::tree::models::Expression; + +use crate::conversion::_helpers::expressions::casts::data_type_name; +use crate::conversion::constants::SETUP_REPLAYED; +use crate::conversion::models::ConvertedSetup; + +/// Each statement in order: CREATE TABLE of named, typed columns, or INSERT INTO it of literal rows. +pub fn convert_setup(statements: &[String]) -> Result { + let log = ProvenanceLog::default(); + let mut converted = ConvertedSetup::default(); + for statement in statements { + let refused = |reason: &str| format!("setup statement `{statement}` {reason}"); + let parsed = parse_source(statement, SourceDialect::Snowflake) + .map_err(|_| refused("does not parse"))?; + let duckdb = match parsed.as_slice() { + [Expression::CreateTable(table)] => { + created(table, &mut converted, &log).map_err(|reason| refused(&reason))? + } + [Expression::Insert(insert)] => { + inserted(insert, &converted, &log).map_err(|reason| refused(&reason))? + } + _ => { + return Err(refused(&format!( + "is not replayed on DuckDB: only {SETUP_REPLAYED} are" + ))); + } + }; + converted.statements.push(duckdb); + } + Ok(converted) +} + +/// `CREATE TABLE name (column type, …)` and nothing else, its relation added to `converted`. +fn created( + table: &CreateTable, + converted: &mut ConvertedSetup, + log: &ProvenanceLog, +) -> Result { + let bare = CreateTable { + name: TableRef { + name: table.name.name.clone(), + ..TableRef::new("") + }, + columns: table + .columns + .iter() + .map(|column| ColumnDef { + name: column.name.clone(), + ..ColumnDef::new("", column.data_type.clone()) + }) + .collect(), + ..CreateTable::new("") + }; + if *table != bare || table.columns.is_empty() { + return Err("has a clause other than unqualified, typed columns".to_owned()); + } + let name = resolved(&table.name.name)?; + if converted.relations.contains_key(&name) { + return Err(format!("creates {name} again")); + } + let mut columns: Vec = Vec::with_capacity(table.columns.len()); + let mut written: Vec = Vec::with_capacity(table.columns.len()); + for column in &table.columns { + let column_name = resolved(&column.name)?; + if columns.iter().any(|earlier| earlier.name == column_name) { + return Err(format!("declares {column_name} twice")); + } + let type_name = serde_json::to_value(&column.data_type) + .ok() + .as_ref() + .and_then(data_type_name) + .ok_or_else(|| format!("types {column_name} with a type that has no name"))?; + let data_type = cast_target(&type_name, log)?.data_type; + if !matches!( + data_type, + SnowflakeType::Number { .. } | SnowflakeType::Varchar + ) { + return Err(format!( + "types {column_name} {type_name}, which setup does not replay" + )); + } + written.push(format!( + "{} {}", + quoted_identifier(&column_name, log), + column_type(data_type, log)? + )); + columns.push(ColumnSpec::new(column_name, data_type)); + } + let sql = format!( + "CREATE TABLE {} ({})", + quoted_identifier(&name, log), + written.join(", ") + ); + converted.relations.insert(name, columns); + Ok(sql) +} + +/// `INSERT INTO name VALUES (…), …` into a relation setup created, of literals its columns hold exactly. +fn inserted( + insert: &Insert, + converted: &ConvertedSetup, + log: &ProvenanceLog, +) -> Result { + let Insert { + table, + columns, + values, + query: None, + overwrite: false, + partition, + directory: None, + returning, + output: None, + on_conflict: None, + leading_comments: _, + if_exists: false, + with: None, + ignore: false, + source_alias: None, + alias: None, + alias_explicit_as: false, + default_values: false, + by_name: false, + conflict_action: None, + is_replace: false, + hint: None, + replace_where: None, + source: None, + function_target: None, + partition_by: None, + settings, + } = insert + else { + return Err("has a clause other than VALUES".to_owned()); + }; + let bare_table = TableRef { + name: table.name.clone(), + ..TableRef::new("") + }; + if *table != bare_table + || !columns.is_empty() + || values.is_empty() + || !partition.is_empty() + || !returning.is_empty() + || !settings.is_empty() + { + return Err("has a clause other than VALUES into an unqualified table".to_owned()); + } + let name = resolved(&table.name)?; + let target = converted + .relations + .get(&name) + .ok_or_else(|| format!("inserts into {name}, which setup did not create"))?; + let mut rows: Vec = Vec::with_capacity(values.len()); + for row in values { + if row.len() != target.len() { + return Err(format!( + "has a row of {} values for {} columns", + row.len(), + target.len() + )); + } + let cells: Vec = row + .iter() + .zip(target) + .map(|(value, column)| cell(value, column, log)) + .collect::>()?; + rows.push(format!("({})", cells.join(", "))); + } + Ok(format!( + "INSERT INTO {} VALUES {}", + quoted_identifier(&name, log), + rows.join(", ") + )) +} + +/// One value: NULL, a string for a VARCHAR, or an unsigned number a NUMBER column holds unrounded. +fn cell(value: &Expression, column: &ColumnSpec, log: &ProvenanceLog) -> Result { + match (value, column.data_type) { + (Expression::Null(_), _) => Ok("NULL".to_owned()), + (Expression::Literal(literal), SnowflakeType::Varchar) => match literal.as_ref() { + Literal::String(text) => Ok(string_literal(text, log)), + _ => Err(format!( + "puts a value other than a string or NULL into {}", + column.name + )), + }, + (Expression::Literal(literal), SnowflakeType::Number { precision, scale }) => { + match literal.as_ref() { + Literal::Number(text) if fits(text, precision, scale) => Ok(format!( + "CAST({text} AS {})", + column_type(column.data_type, log)? + )), + _ => Err(format!( + "puts a value into {} that is not a number it holds without rounding", + column.name + )), + } + } + _ => Err(format!( + "puts a value other than a literal or NULL into {}", + column.name + )), + } +} + +/// Whether the digits `text` writes, with at most one point, fit NUMBER(precision, scale) exactly. +fn fits(text: &str, precision: u8, scale: u8) -> bool { + let (whole, fraction) = text.split_once('.').unwrap_or((text, "")); + let whole = whole.trim_start_matches('0'); + !text.is_empty() + && text.matches('.').count() <= 1 + && text + .chars() + .all(|character| character.is_ascii_digit() || character == '.') + && fraction.len() <= usize::from(scale) + && whole.len() <= usize::from(precision.saturating_sub(scale)) +} + +/// An unquoted name as Snowflake resolves it, in upper case; a quoted one refuses. +fn resolved(identifier: &Identifier) -> Result { + match identifier.quoted { + true => Err(format!("quotes the name {}", identifier.name)), + false => Ok(identifier.name.to_ascii_uppercase()), + } +} diff --git a/crates/sqlquack-rules/src/conversion/main/mod.rs b/crates/sqlquack-rules/src/conversion/main/mod.rs index df305206a..5ff212f41 100644 --- a/crates/sqlquack-rules/src/conversion/main/mod.rs +++ b/crates/sqlquack-rules/src/conversion/main/mod.rs @@ -1,5 +1,6 @@ pub mod collect_refusals; pub mod convert_function_body; +pub mod convert_setup; pub mod convert_statement; pub mod override_macro; pub mod override_macro_name; diff --git a/crates/sqlquack-rules/src/conversion/models.rs b/crates/sqlquack-rules/src/conversion/models.rs index 48a158a96..09f76cb6b 100644 --- a/crates/sqlquack-rules/src/conversion/models.rs +++ b/crates/sqlquack-rules/src/conversion/models.rs @@ -210,3 +210,12 @@ pub struct CollectedRefusals { /// The statement's result columns where conversion reached them; refused values are NULL-typed. pub columns: Option>, } + +/// A case's setup as DuckDB replays it: its statements in order and the relations they create. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ConvertedSetup { + /// Each created relation, by its resolved name, with its typed columns. + pub relations: BTreeMap>, + /// The DuckDB statements, in setup order. + pub statements: Vec, +} diff --git a/crates/sqlquack/src/analysis/constants.rs b/crates/sqlquack/src/analysis/constants.rs index 04ebe6d9b..34d21e701 100644 --- a/crates/sqlquack/src/analysis/constants.rs +++ b/crates/sqlquack/src/analysis/constants.rs @@ -15,8 +15,6 @@ pub const SCALAR_ABI: &str = "scalar-v1"; pub const VARIANT_ABI: &str = "variant-json-v1"; /// The DuckDB type a `variant-json-v1` function returns its JSON text as. pub const VARIANT_TEXT_TYPE: &str = "VARCHAR"; -/// Why a case with setup statements refuses: DuckDB does not replay case setup yet. -pub const CASE_SETUP_UNREPLAYED: &str = "case setup statements are not replayed on DuckDB yet"; /// Why a case calling Python functions refuses when no interpreter is given. pub const PYTHON_RUNTIME_MISSING: &str = "a Python function, which needs a Python runtime to register it: verify with --python"; @@ -126,3 +124,6 @@ pub const AGGREGATE_OR_WINDOW_NAMES: &[&str] = &[ "VAR_POP", "VAR_SAMP", ]; +/// Each setup placeholder and the name DuckDB's replay database gives it: its catalog and schema. +pub const SETUP_REPLAY_NAMES: &[(&str, &str)] = + &[("{{run_database}}", "memory"), ("{{run_schema}}", "main")]; diff --git a/crates/sqlquack/src/analysis/models.rs b/crates/sqlquack/src/analysis/models.rs index b6a882ee4..1c5614da0 100644 --- a/crates/sqlquack/src/analysis/models.rs +++ b/crates/sqlquack/src/analysis/models.rs @@ -18,14 +18,13 @@ use sqlquack_evidence::verify::types::CaseConverter; use sqlquack_policy::policy::constants::QUOTED_IDENTIFIERS_IGNORE_CASE; use sqlquack_policy::policy::models::SessionValue; use sqlquack_rules::catalogue::main::builtin_catalogue::builtin_catalogue; +use sqlquack_rules::conversion::main::convert_setup::convert_setup; use sqlquack_rules::conversion::models::{ConversionOptions, Converted, Fixture}; use sqlquack_semantics::types::models::Schema; use crate::analysis::_helpers::case_functions::case_functions; use crate::analysis::_helpers::override_definition::load_override; -use crate::analysis::constants::{ - CASE_SETUP_UNREPLAYED, PYTHON_RUNTIME_MISSING, STATEMENT_REFUSAL, -}; +use crate::analysis::constants::{PYTHON_RUNTIME_MISSING, SETUP_REPLAY_NAMES, STATEMENT_REFUSAL}; use crate::analysis::main::analyze::analyze; use crate::analysis::main::override_macros::override_macros; use crate::analysis::main::refused_construct::refused_construct; @@ -69,12 +68,15 @@ pub struct CaseFunctions { impl CaseConverter for PipelineConverter { fn convert(&self, case: &LoadedCase) -> Conversion { - if !case.setup.is_empty() { - return Conversion::Refused { - reasons: vec![CASE_SETUP_UNREPLAYED.to_owned()], - constructs: vec![STATEMENT_REFUSAL.to_owned()], - }; - } + let setup = match convert_setup(&case.setup) { + Ok(setup) => setup, + Err(reason) => { + return Conversion::Refused { + reasons: vec![reason], + constructs: vec![STATEMENT_REFUSAL.to_owned()], + }; + } + }; let mut options = ConversionOptions { fixture: Some(Fixture { relation: INPUT_RELATION.to_owned(), @@ -99,7 +101,7 @@ impl CaseConverter for PipelineConverter { } } } - let functions = match case_functions(&case.functions, &options) { + let mut functions = match case_functions(&case.functions, &options) { Ok(functions) => functions, Err(reasons) => { return Conversion::Refused { @@ -121,8 +123,14 @@ impl CaseConverter for PipelineConverter { }; } }; + functions.schema.relations.extend(setup.relations); + let source_sql = SETUP_REPLAY_NAMES + .iter() + .fold(case.source_sql.clone(), |sql, (placeholder, name)| { + sql.replace(placeholder, name) + }); let analysis = analyze( - &case.source_sql, + &source_sql, SourceDialect::Snowflake, &builtin_catalogue(), (&functions.schema, &options), @@ -144,9 +152,10 @@ impl CaseConverter for PipelineConverter { (true, [converted]) => Conversion::Converted { sql: converted.sql.clone(), raises: converted.raises.clone(), - setup: functions - .setup + setup: setup + .statements .into_iter() + .chain(functions.setup) .chain(override_macros(&converted.provenance.divergences, &options)) .collect(), python, diff --git a/crates/sqlquack/src/analysis/tests.rs b/crates/sqlquack/src/analysis/tests.rs index df2746a0e..233cfff7a 100644 --- a/crates/sqlquack/src/analysis/tests.rs +++ b/crates/sqlquack/src/analysis/tests.rs @@ -8,6 +8,8 @@ mod test_analyze_function; mod test_bound_arguments; #[path = "tests/test_case_provenance.rs"] mod test_case_provenance; +#[path = "tests/test_case_setup.rs"] +mod test_case_setup; #[path = "tests/test_collect_refusals.rs"] mod test_collect_refusals; #[path = "tests/test_composition.rs"] diff --git a/crates/sqlquack/src/analysis/tests/helpers.rs b/crates/sqlquack/src/analysis/tests/helpers.rs index 67647481c..0b24bc08a 100644 --- a/crates/sqlquack/src/analysis/tests/helpers.rs +++ b/crates/sqlquack/src/analysis/tests/helpers.rs @@ -694,3 +694,62 @@ pub(crate) fn relying_on_utc_session(constructs: &[&str]) -> DivergenceModeOutco categories: vec![], } } + +/// The first value a translation case with `setup` and `query` returns, converted then replayed on +/// DuckDB, or the fragment of a reason containing `expected`'s error, else every reason. +pub(crate) fn setup_case_outcome( + (setup, query): (&[&str], &str), + expected: Result<&str, &str>, +) -> Result { + let quoted = |text: &str| format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\"")); + let statements: Vec = setup.iter().map(|statement| quoted(statement)).collect(); + let directory = tempfile::tempdir().expect("a temporary directory"); + let file = directory + .path() + .join("translation/expressions/name-resolution.toml"); + std::fs::create_dir_all(file.parent().expect("a parent")).expect("the case directory"); + std::fs::write( + &file, + format!( + "category = \"translation\"\nconstruct = \"sf.expressions.name-resolution\"\n\ + session_profile = \"utc-v1\"\n\n[[case]]\nid = \"t001_setup\"\nsetup = [{}]\n\ + description = \"A setup case\"\ncolumns = [{{ name = \"a\", type = \"NUMBER(38,0)\" }}]\n\ + rows = [[\"1\"]]\nquery = {}\norder = \"ordered\"\ntags = [\"type-combination\"]\n", + statements.join(", "), + quoted(query) + ), + ) + .expect("the case file is written"); + let root = repository_root(); + let policy = load_policy(&root.join(POLICY_FILE_NAME)).expect("the repository policy loads"); + let cases = load_cases(directory.path(), &policy).expect("the setup case loads"); + let converter = PipelineConverter { + options: conversion_options(&policy, false), + python: None, + }; + let fragment = expected.err(); + let failed = |reasons: Vec| { + reasons + .iter() + .find(|reason| fragment.is_some_and(|fragment| reason.contains(fragment))) + .and(fragment) + .map_or_else(|| reasons.join("; "), str::to_owned) + }; + let (sql, statements) = match converter.convert(&cases[0]) { + Conversion::Converted { sql, setup, .. } => (sql, setup), + Conversion::Refused { reasons, .. } => return Err(failed(reasons)), + }; + let connection = duckdb::Connection::open_in_memory().expect("DuckDB opens"); + for statement in &statements { + connection + .execute_batch(statement) + .map_err(|error| failed(vec![error.to_string()]))?; + } + connection + .query_row( + &format!("SELECT CAST(COLUMNS(*) AS VARCHAR) FROM ({sql}) AS t"), + [], + |row| row.get::<_, String>(0), + ) + .map_err(|error| failed(vec![error.to_string()])) +} diff --git a/crates/sqlquack/src/analysis/tests/test_case_setup.rs b/crates/sqlquack/src/analysis/tests/test_case_setup.rs new file mode 100644 index 000000000..74d5be238 --- /dev/null +++ b/crates/sqlquack/src/analysis/tests/test_case_setup.rs @@ -0,0 +1,153 @@ +use crate::analysis::tests::helpers::setup_case_outcome; +use crate::analysis::tests::test_types::SetupCaseTestCase; + +const NR: &[&str] = &[ + "CREATE TABLE nr (k NUMBER(38,0), v VARCHAR)", + "INSERT INTO nr VALUES (1, 'one'), (2, 'two'), (3, NULL)", +]; + +#[test] +fn given_setup_tables_when_converting_a_case_then_duckdb_replays_them_or_it_refuses() { + let test_cases = [ + SetupCaseTestCase { + description: "a table read by the run database and schema placeholders", + setup: NR, + query: "SELECT t.v FROM input AS i JOIN {{run_database}}.{{run_schema}}.nr AS t ON t.k = i.a", + expected_outcome: Ok("one"), + }, + SetupCaseTestCase { + description: "a table read by the schema placeholder alone", + setup: NR, + query: "SELECT t.v FROM input AS i JOIN {{run_schema}}.nr AS t ON t.k = i.a", + expected_outcome: Ok("one"), + }, + SetupCaseTestCase { + description: "an inserted NULL stays NULL", + setup: NR, + query: "SELECT COUNT(v) AS n FROM nr", + expected_outcome: Ok("2"), + }, + SetupCaseTestCase { + description: "a decimal keeps its column's scale", + setup: &[ + "CREATE TABLE sales (id NUMBER(38,0), amount NUMBER(10,2))", + "INSERT INTO sales VALUES (1, 2.5), (2, 4.00)", + ], + query: "SELECT SUM(amount) AS total FROM sales", + expected_outcome: Ok("6.50"), + }, + SetupCaseTestCase { + description: "a value with more decimals than the column holds would round", + setup: &[ + "CREATE TABLE sales (amount NUMBER(10,2))", + "INSERT INTO sales VALUES (2.505)", + ], + query: "SELECT amount FROM sales", + expected_outcome: Err("not a number it holds without rounding"), + }, + SetupCaseTestCase { + description: "a value with more whole digits than the column holds", + setup: &[ + "CREATE TABLE sales (amount NUMBER(3,2))", + "INSERT INTO sales VALUES (10)", + ], + query: "SELECT amount FROM sales", + expected_outcome: Err("not a number it holds without rounding"), + }, + SetupCaseTestCase { + description: "a number into a VARCHAR column", + setup: &["CREATE TABLE t (v VARCHAR)", "INSERT INTO t VALUES (1)"], + query: "SELECT v FROM t", + expected_outcome: Err("other than a string or NULL"), + }, + SetupCaseTestCase { + description: "a type other than NUMBER or VARCHAR", + setup: &["CREATE TABLE t (d DATE)"], + query: "SELECT d FROM t", + expected_outcome: Err("which setup does not replay"), + }, + SetupCaseTestCase { + description: "a column constraint", + setup: &["CREATE TABLE t (k NUMBER(38,0) PRIMARY KEY)"], + query: "SELECT k FROM t", + expected_outcome: Err("has a clause other than unqualified, typed columns"), + }, + SetupCaseTestCase { + description: "a column default", + setup: &["CREATE TABLE t (k NUMBER(38,0) DEFAULT 1)"], + query: "SELECT k FROM t", + expected_outcome: Err("has a clause other than unqualified, typed columns"), + }, + SetupCaseTestCase { + description: "a table created twice", + setup: &[ + "CREATE TABLE t (k NUMBER(38,0))", + "CREATE TABLE t (v VARCHAR)", + ], + query: "SELECT k FROM t", + expected_outcome: Err("creates T again"), + }, + SetupCaseTestCase { + description: "a quoted table name", + setup: &["CREATE TABLE \"t\" (k NUMBER(38,0))"], + query: "SELECT k FROM \"t\"", + expected_outcome: Err("quotes the name t"), + }, + SetupCaseTestCase { + description: "an insert naming its columns", + setup: &[ + "CREATE TABLE t (k NUMBER(38,0))", + "INSERT INTO t (k) VALUES (1)", + ], + query: "SELECT k FROM t", + expected_outcome: Err("has a clause other than VALUES into an unqualified table"), + }, + SetupCaseTestCase { + description: "an insert from a query", + setup: &["CREATE TABLE t (k NUMBER(38,0))", "INSERT INTO t SELECT 1"], + query: "SELECT k FROM t", + expected_outcome: Err("has a clause other than VALUES"), + }, + SetupCaseTestCase { + description: "a row with fewer values than columns", + setup: &[ + "CREATE TABLE t (k NUMBER(38,0), v VARCHAR)", + "INSERT INTO t VALUES (1)", + ], + query: "SELECT k FROM t", + expected_outcome: Err("has a row of 1 values for 2 columns"), + }, + SetupCaseTestCase { + description: "an insert into a table setup did not create", + setup: &["INSERT INTO t VALUES (1)"], + query: "SELECT 1 AS k", + expected_outcome: Err("which setup did not create"), + }, + SetupCaseTestCase { + description: "a session variable", + setup: &["SET n = 10"], + query: "SELECT $n AS n", + expected_outcome: Err("is not replayed on DuckDB"), + }, + SetupCaseTestCase { + description: "a user-defined type", + setup: &["CREATE TYPE age AS NUMBER(3,0)"], + query: "SELECT 1 AS k", + expected_outcome: Err("is not replayed on DuckDB"), + }, + ]; + for test_case in test_cases { + assert_eq!( + setup_case_outcome( + (test_case.setup, test_case.query), + test_case.expected_outcome + ), + test_case + .expected_outcome + .map(str::to_owned) + .map_err(str::to_owned), + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack/src/analysis/tests/test_types.rs b/crates/sqlquack/src/analysis/tests/test_types.rs index a4bd3cc7b..687570cae 100644 --- a/crates/sqlquack/src/analysis/tests/test_types.rs +++ b/crates/sqlquack/src/analysis/tests/test_types.rs @@ -206,3 +206,11 @@ pub(crate) struct OverrideCallsTestCase { pub(crate) emitted: &'static str, pub(crate) expected_refused: bool, } + +pub(crate) struct SetupCaseTestCase { + pub(crate) description: &'static str, + pub(crate) setup: &'static [&'static str], + pub(crate) query: &'static str, + /// The first value as text, or a fragment of the refusal reason or DuckDB error. + pub(crate) expected_outcome: Result<&'static str, &'static str>, +} From b9cdbab6c650ad74d76836a5ed877345563743f5 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:51:43 +0000 Subject: [PATCH 02/11] refactor: move the recorded time zone names into a time_zones domain conversion/constants.rs is at 2,000 lines on main, and the BINARY rules add to it. The 601-line table of time zone names Snowflake was recorded accepting now lives in time_zones/constants.rs. No behaviour change. --- .../expressions/temporal_functions.rs | 13 +- .../src/conversion/constants.rs | 601 ----------------- crates/sqlquack-rules/src/lib.rs | 1 + .../src/time_zones/constants.rs | 603 ++++++++++++++++++ .../sqlquack-rules/src/time_zones/main/mod.rs | 1 + .../src/time_zones/main/recorded_zone.rs | 8 + crates/sqlquack-rules/src/time_zones/mod.rs | 4 + 7 files changed, 624 insertions(+), 607 deletions(-) create mode 100644 crates/sqlquack-rules/src/time_zones/constants.rs create mode 100644 crates/sqlquack-rules/src/time_zones/main/mod.rs create mode 100644 crates/sqlquack-rules/src/time_zones/main/recorded_zone.rs create mode 100644 crates/sqlquack-rules/src/time_zones/mod.rs diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs index cb25e49cd..2922f1e1c 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs @@ -62,12 +62,13 @@ use crate::conversion::constants::{ DATE_FORMAT_RULE, DATE_NAME, DATE_PART_RULE, DATE_PARTS, DAY_NAME_RULE, DECIMAL_EPOCH_RULE, FRACTIONAL_DATE_AMOUNT, FROM_PARTS, LAST_DAY_RULE, MAX_FRACTION_DIGITS_U8, MICROSECOND_DIGITS, MONTH_NAME_FIELDS, MONTH_NAME_FOLLOWERS, NULL_BRANCHES, NUMBER_TEXT_FORMAT, OFFSET_FREE_UNITS, - RECORDED_TIME_ZONES, SCALE_DIGITS, TEMPORAL_TEXT_FIELDS, TEMPORAL_TEXT_FORMAT, - TEMPORAL_TEXT_SEPARATORS, TEXT_TO_TIME_RULE, TEXT_TO_TIMESTAMP_RULE, TIME_ARITHMETIC, - TIME_ZONE_CONVERSION, TIMESTAMP_FORMAT_RULE, TIMESTAMP_NTZ_NAME, TIMESTAMP_TIME_RULE, - TO_DATE_TEMPORAL, TO_TIMESTAMP_TEMPORAL, UTC_OFFSET_ARITHMETIC, UTC_ZONE, ZONE_COLUMNS, - ZONED_PARTS, + SCALE_DIGITS, TEMPORAL_TEXT_FIELDS, TEMPORAL_TEXT_FORMAT, TEMPORAL_TEXT_SEPARATORS, + TEXT_TO_TIME_RULE, TEXT_TO_TIMESTAMP_RULE, TIME_ARITHMETIC, TIME_ZONE_CONVERSION, + TIMESTAMP_FORMAT_RULE, TIMESTAMP_NTZ_NAME, TIMESTAMP_TIME_RULE, TO_DATE_TEMPORAL, + TO_TIMESTAMP_TEMPORAL, UTC_OFFSET_ARITHMETIC, UTC_ZONE, ZONE_COLUMNS, ZONED_PARTS, }; +use crate::time_zones::constants::RECORDED_TIME_ZONES; +use crate::time_zones::main::recorded_zone::recorded_zone; use sqlquack_duckdb::raises::main::raised::raised; use sqlquack_duckdb::temporal::constants::{DUCKDB_CURRENT_DATE, DUCKDB_CURRENT_TIMESTAMP}; use sqlquack_policy::policy::constants::{ @@ -1110,7 +1111,7 @@ struct Zone { /// A literal zone name, or a VARCHAR expression naming one per row. fn zone_operand(node: &Value, construct: &Construct, site: Site<'_>) -> Result { if let Some(zone) = zone_literal(node, site) { - if !RECORDED_TIME_ZONES.contains(&zone.as_str()) { + if !recorded_zone(&zone) { return Err(outside_conditions( construct.clone(), format!( diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index dd3004606..7b4ecf1bf 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -1369,607 +1369,6 @@ pub const FLATTEN_ORDERED_COLUMNS: &[&str] = &["PATH", "INDEX", "THIS"]; pub const USER_OVERRIDE_RULE: &str = "rule.user_override"; /// The prefix of the macro converted SQL calls a user override by. pub const OVERRIDE_MACRO_PREFIX: &str = "__sqlquack_override__"; -/// Time zone names recorded as accepted by Snowflake, which matches them exactly, case included. -pub(crate) const RECORDED_TIME_ZONES: &[&str] = &[ - "Africa/Abidjan", - "Africa/Accra", - "Africa/Addis_Ababa", - "Africa/Algiers", - "Africa/Asmara", - "Africa/Asmera", - "Africa/Bamako", - "Africa/Bangui", - "Africa/Banjul", - "Africa/Bissau", - "Africa/Blantyre", - "Africa/Brazzaville", - "Africa/Bujumbura", - "Africa/Cairo", - "Africa/Casablanca", - "Africa/Ceuta", - "Africa/Conakry", - "Africa/Dakar", - "Africa/Dar_es_Salaam", - "Africa/Djibouti", - "Africa/Douala", - "Africa/El_Aaiun", - "Africa/Freetown", - "Africa/Gaborone", - "Africa/Harare", - "Africa/Johannesburg", - "Africa/Juba", - "Africa/Kampala", - "Africa/Khartoum", - "Africa/Kigali", - "Africa/Kinshasa", - "Africa/Lagos", - "Africa/Libreville", - "Africa/Lome", - "Africa/Luanda", - "Africa/Lubumbashi", - "Africa/Lusaka", - "Africa/Malabo", - "Africa/Maputo", - "Africa/Maseru", - "Africa/Mbabane", - "Africa/Mogadishu", - "Africa/Monrovia", - "Africa/Nairobi", - "Africa/Ndjamena", - "Africa/Niamey", - "Africa/Nouakchott", - "Africa/Ouagadougou", - "Africa/Porto-Novo", - "Africa/Sao_Tome", - "Africa/Timbuktu", - "Africa/Tripoli", - "Africa/Tunis", - "Africa/Windhoek", - "America/Adak", - "America/Anchorage", - "America/Anguilla", - "America/Antigua", - "America/Araguaina", - "America/Argentina/Buenos_Aires", - "America/Argentina/Catamarca", - "America/Argentina/ComodRivadavia", - "America/Argentina/Cordoba", - "America/Argentina/Jujuy", - "America/Argentina/La_Rioja", - "America/Argentina/Mendoza", - "America/Argentina/Rio_Gallegos", - "America/Argentina/Salta", - "America/Argentina/San_Juan", - "America/Argentina/San_Luis", - "America/Argentina/Tucuman", - "America/Argentina/Ushuaia", - "America/Aruba", - "America/Asuncion", - "America/Atikokan", - "America/Atka", - "America/Bahia", - "America/Bahia_Banderas", - "America/Barbados", - "America/Belem", - "America/Belize", - "America/Blanc-Sablon", - "America/Boa_Vista", - "America/Bogota", - "America/Boise", - "America/Buenos_Aires", - "America/Cambridge_Bay", - "America/Campo_Grande", - "America/Cancun", - "America/Caracas", - "America/Catamarca", - "America/Cayenne", - "America/Cayman", - "America/Chicago", - "America/Chihuahua", - "America/Ciudad_Juarez", - "America/Coral_Harbour", - "America/Cordoba", - "America/Costa_Rica", - "America/Coyhaique", - "America/Creston", - "America/Cuiaba", - "America/Curacao", - "America/Danmarkshavn", - "America/Dawson", - "America/Dawson_Creek", - "America/Denver", - "America/Detroit", - "America/Dominica", - "America/Edmonton", - "America/Eirunepe", - "America/El_Salvador", - "America/Ensenada", - "America/Fort_Nelson", - "America/Fort_Wayne", - "America/Fortaleza", - "America/Glace_Bay", - "America/Godthab", - "America/Goose_Bay", - "America/Grand_Turk", - "America/Grenada", - "America/Guadeloupe", - "America/Guatemala", - "America/Guayaquil", - "America/Guyana", - "America/Halifax", - "America/Havana", - "America/Hermosillo", - "America/Indiana/Indianapolis", - "America/Indiana/Knox", - "America/Indiana/Marengo", - "America/Indiana/Petersburg", - "America/Indiana/Tell_City", - "America/Indiana/Vevay", - "America/Indiana/Vincennes", - "America/Indiana/Winamac", - "America/Indianapolis", - "America/Inuvik", - "America/Iqaluit", - "America/Jamaica", - "America/Jujuy", - "America/Juneau", - "America/Kentucky/Louisville", - "America/Kentucky/Monticello", - "America/Knox_IN", - "America/Kralendijk", - "America/La_Paz", - "America/Lima", - "America/Los_Angeles", - "America/Louisville", - "America/Lower_Princes", - "America/Maceio", - "America/Managua", - "America/Manaus", - "America/Marigot", - "America/Martinique", - "America/Matamoros", - "America/Mazatlan", - "America/Mendoza", - "America/Menominee", - "America/Merida", - "America/Metlakatla", - "America/Mexico_City", - "America/Miquelon", - "America/Moncton", - "America/Monterrey", - "America/Montevideo", - "America/Montreal", - "America/Montserrat", - "America/Nassau", - "America/New_York", - "America/Nipigon", - "America/Nome", - "America/Noronha", - "America/North_Dakota/Beulah", - "America/North_Dakota/Center", - "America/North_Dakota/New_Salem", - "America/Nuuk", - "America/Ojinaga", - "America/Panama", - "America/Pangnirtung", - "America/Paramaribo", - "America/Phoenix", - "America/Port-au-Prince", - "America/Port_of_Spain", - "America/Porto_Acre", - "America/Porto_Velho", - "America/Puerto_Rico", - "America/Punta_Arenas", - "America/Rainy_River", - "America/Rankin_Inlet", - "America/Recife", - "America/Regina", - "America/Resolute", - "America/Rio_Branco", - "America/Rosario", - "America/Santa_Isabel", - "America/Santarem", - "America/Santiago", - "America/Santo_Domingo", - "America/Sao_Paulo", - "America/Scoresbysund", - "America/Shiprock", - "America/Sitka", - "America/St_Barthelemy", - "America/St_Johns", - "America/St_Kitts", - "America/St_Lucia", - "America/St_Thomas", - "America/St_Vincent", - "America/Swift_Current", - "America/Tegucigalpa", - "America/Thule", - "America/Thunder_Bay", - "America/Tijuana", - "America/Toronto", - "America/Tortola", - "America/Vancouver", - "America/Virgin", - "America/Whitehorse", - "America/Winnipeg", - "America/Yakutat", - "America/Yellowknife", - "Antarctica/Casey", - "Antarctica/Davis", - "Antarctica/DumontDUrville", - "Antarctica/Macquarie", - "Antarctica/Mawson", - "Antarctica/McMurdo", - "Antarctica/Palmer", - "Antarctica/Rothera", - "Antarctica/South_Pole", - "Antarctica/Syowa", - "Antarctica/Troll", - "Antarctica/Vostok", - "Arctic/Longyearbyen", - "Asia/Aden", - "Asia/Almaty", - "Asia/Amman", - "Asia/Anadyr", - "Asia/Aqtau", - "Asia/Aqtobe", - "Asia/Ashgabat", - "Asia/Ashkhabad", - "Asia/Atyrau", - "Asia/Baghdad", - "Asia/Bahrain", - "Asia/Baku", - "Asia/Bangkok", - "Asia/Barnaul", - "Asia/Beirut", - "Asia/Bishkek", - "Asia/Brunei", - "Asia/Calcutta", - "Asia/Chita", - "Asia/Choibalsan", - "Asia/Chongqing", - "Asia/Chungking", - "Asia/Colombo", - "Asia/Dacca", - "Asia/Damascus", - "Asia/Dhaka", - "Asia/Dili", - "Asia/Dubai", - "Asia/Dushanbe", - "Asia/Famagusta", - "Asia/Gaza", - "Asia/Harbin", - "Asia/Hebron", - "Asia/Ho_Chi_Minh", - "Asia/Hong_Kong", - "Asia/Hovd", - "Asia/Irkutsk", - "Asia/Istanbul", - "Asia/Jakarta", - "Asia/Jayapura", - "Asia/Jerusalem", - "Asia/Kabul", - "Asia/Kamchatka", - "Asia/Karachi", - "Asia/Kashgar", - "Asia/Kathmandu", - "Asia/Katmandu", - "Asia/Khandyga", - "Asia/Kolkata", - "Asia/Krasnoyarsk", - "Asia/Kuala_Lumpur", - "Asia/Kuching", - "Asia/Kuwait", - "Asia/Macao", - "Asia/Macau", - "Asia/Magadan", - "Asia/Makassar", - "Asia/Manila", - "Asia/Muscat", - "Asia/Nicosia", - "Asia/Novokuznetsk", - "Asia/Novosibirsk", - "Asia/Omsk", - "Asia/Oral", - "Asia/Phnom_Penh", - "Asia/Pontianak", - "Asia/Pyongyang", - "Asia/Qatar", - "Asia/Qostanay", - "Asia/Qyzylorda", - "Asia/Rangoon", - "Asia/Riyadh", - "Asia/Saigon", - "Asia/Sakhalin", - "Asia/Samarkand", - "Asia/Seoul", - "Asia/Shanghai", - "Asia/Singapore", - "Asia/Srednekolymsk", - "Asia/Taipei", - "Asia/Tashkent", - "Asia/Tbilisi", - "Asia/Tehran", - "Asia/Tel_Aviv", - "Asia/Thimbu", - "Asia/Thimphu", - "Asia/Tokyo", - "Asia/Tomsk", - "Asia/Ujung_Pandang", - "Asia/Ulaanbaatar", - "Asia/Ulan_Bator", - "Asia/Urumqi", - "Asia/Ust-Nera", - "Asia/Vientiane", - "Asia/Vladivostok", - "Asia/Yakutsk", - "Asia/Yangon", - "Asia/Yekaterinburg", - "Asia/Yerevan", - "Atlantic/Azores", - "Atlantic/Bermuda", - "Atlantic/Canary", - "Atlantic/Cape_Verde", - "Atlantic/Faeroe", - "Atlantic/Faroe", - "Atlantic/Jan_Mayen", - "Atlantic/Madeira", - "Atlantic/Reykjavik", - "Atlantic/South_Georgia", - "Atlantic/St_Helena", - "Atlantic/Stanley", - "Australia/ACT", - "Australia/Adelaide", - "Australia/Brisbane", - "Australia/Broken_Hill", - "Australia/Canberra", - "Australia/Currie", - "Australia/Darwin", - "Australia/Eucla", - "Australia/Hobart", - "Australia/LHI", - "Australia/Lindeman", - "Australia/Lord_Howe", - "Australia/Melbourne", - "Australia/NSW", - "Australia/North", - "Australia/Perth", - "Australia/Queensland", - "Australia/South", - "Australia/Sydney", - "Australia/Tasmania", - "Australia/Victoria", - "Australia/West", - "Australia/Yancowinna", - "Brazil/Acre", - "Brazil/DeNoronha", - "Brazil/East", - "Brazil/West", - "CET", - "CST6CDT", - "Canada/Atlantic", - "Canada/Central", - "Canada/Eastern", - "Canada/Mountain", - "Canada/Newfoundland", - "Canada/Pacific", - "Canada/Saskatchewan", - "Canada/Yukon", - "Chile/Continental", - "Chile/EasterIsland", - "Cuba", - "EET", - "EST", - "EST5EDT", - "Egypt", - "Eire", - "Etc/GMT", - "Etc/GMT+0", - "Etc/GMT+1", - "Etc/GMT+10", - "Etc/GMT+11", - "Etc/GMT+12", - "Etc/GMT+2", - "Etc/GMT+3", - "Etc/GMT+4", - "Etc/GMT+5", - "Etc/GMT+6", - "Etc/GMT+7", - "Etc/GMT+8", - "Etc/GMT+9", - "Etc/GMT-0", - "Etc/GMT-1", - "Etc/GMT-10", - "Etc/GMT-11", - "Etc/GMT-12", - "Etc/GMT-13", - "Etc/GMT-14", - "Etc/GMT-2", - "Etc/GMT-3", - "Etc/GMT-4", - "Etc/GMT-5", - "Etc/GMT-6", - "Etc/GMT-7", - "Etc/GMT-8", - "Etc/GMT-9", - "Etc/GMT0", - "Etc/Greenwich", - "Etc/UCT", - "Etc/UTC", - "Etc/Universal", - "Etc/Zulu", - "Europe/Amsterdam", - "Europe/Andorra", - "Europe/Astrakhan", - "Europe/Athens", - "Europe/Belfast", - "Europe/Belgrade", - "Europe/Berlin", - "Europe/Bratislava", - "Europe/Brussels", - "Europe/Bucharest", - "Europe/Budapest", - "Europe/Busingen", - "Europe/Chisinau", - "Europe/Copenhagen", - "Europe/Dublin", - "Europe/Gibraltar", - "Europe/Guernsey", - "Europe/Helsinki", - "Europe/Isle_of_Man", - "Europe/Istanbul", - "Europe/Jersey", - "Europe/Kaliningrad", - "Europe/Kiev", - "Europe/Kirov", - "Europe/Kyiv", - "Europe/Lisbon", - "Europe/Ljubljana", - "Europe/London", - "Europe/Luxembourg", - "Europe/Madrid", - "Europe/Malta", - "Europe/Mariehamn", - "Europe/Minsk", - "Europe/Monaco", - "Europe/Moscow", - "Europe/Nicosia", - "Europe/Oslo", - "Europe/Paris", - "Europe/Podgorica", - "Europe/Prague", - "Europe/Riga", - "Europe/Rome", - "Europe/Samara", - "Europe/San_Marino", - "Europe/Sarajevo", - "Europe/Saratov", - "Europe/Simferopol", - "Europe/Skopje", - "Europe/Sofia", - "Europe/Stockholm", - "Europe/Tallinn", - "Europe/Tirane", - "Europe/Tiraspol", - "Europe/Ulyanovsk", - "Europe/Uzhgorod", - "Europe/Vaduz", - "Europe/Vatican", - "Europe/Vienna", - "Europe/Vilnius", - "Europe/Volgograd", - "Europe/Warsaw", - "Europe/Zagreb", - "Europe/Zaporozhye", - "Europe/Zurich", - "Factory", - "GB", - "GB-Eire", - "GMT", - "GMT+0", - "GMT-0", - "GMT0", - "Greenwich", - "HST", - "Hongkong", - "Iceland", - "Indian/Antananarivo", - "Indian/Chagos", - "Indian/Christmas", - "Indian/Cocos", - "Indian/Comoro", - "Indian/Kerguelen", - "Indian/Mahe", - "Indian/Maldives", - "Indian/Mauritius", - "Indian/Mayotte", - "Indian/Reunion", - "Iran", - "Israel", - "Jamaica", - "Japan", - "Kwajalein", - "Libya", - "MET", - "MST", - "MST7MDT", - "Mexico/BajaNorte", - "Mexico/BajaSur", - "Mexico/General", - "NZ", - "NZ-CHAT", - "Navajo", - "PRC", - "PST8PDT", - "Pacific/Apia", - "Pacific/Auckland", - "Pacific/Bougainville", - "Pacific/Chatham", - "Pacific/Chuuk", - "Pacific/Easter", - "Pacific/Efate", - "Pacific/Enderbury", - "Pacific/Fakaofo", - "Pacific/Fiji", - "Pacific/Funafuti", - "Pacific/Galapagos", - "Pacific/Gambier", - "Pacific/Guadalcanal", - "Pacific/Guam", - "Pacific/Honolulu", - "Pacific/Johnston", - "Pacific/Kanton", - "Pacific/Kiritimati", - "Pacific/Kosrae", - "Pacific/Kwajalein", - "Pacific/Majuro", - "Pacific/Marquesas", - "Pacific/Midway", - "Pacific/Nauru", - "Pacific/Niue", - "Pacific/Norfolk", - "Pacific/Noumea", - "Pacific/Pago_Pago", - "Pacific/Palau", - "Pacific/Pitcairn", - "Pacific/Pohnpei", - "Pacific/Ponape", - "Pacific/Port_Moresby", - "Pacific/Rarotonga", - "Pacific/Saipan", - "Pacific/Samoa", - "Pacific/Tahiti", - "Pacific/Tarawa", - "Pacific/Tongatapu", - "Pacific/Truk", - "Pacific/Wake", - "Pacific/Wallis", - "Pacific/Yap", - "Poland", - "Portugal", - "ROC", - "ROK", - "Singapore", - "Turkey", - "UCT", - "US/Alaska", - "US/Aleutian", - "US/Arizona", - "US/Central", - "US/East-Indiana", - "US/Eastern", - "US/Hawaii", - "US/Indiana-Starke", - "US/Michigan", - "US/Mountain", - "US/Pacific", - "US/Samoa", - "UTC", - "Universal", - "W-SU", - "WET", - "Zulu", -]; /// The keyword of an inner join, whose USING keys all take the earlier side's type, as recorded. pub(crate) const INNER_JOIN_KEYWORD: &str = "JOIN"; /// The sequence-number column of FLATTEN and SPLIT_TO_TABLE, which no rule converts. diff --git a/crates/sqlquack-rules/src/lib.rs b/crates/sqlquack-rules/src/lib.rs index 4c23bf847..d29c3e0e3 100644 --- a/crates/sqlquack-rules/src/lib.rs +++ b/crates/sqlquack-rules/src/lib.rs @@ -5,3 +5,4 @@ pub mod catalogue; pub mod conversion; pub mod session; +pub mod time_zones; diff --git a/crates/sqlquack-rules/src/time_zones/constants.rs b/crates/sqlquack-rules/src/time_zones/constants.rs new file mode 100644 index 000000000..4c7d503a2 --- /dev/null +++ b/crates/sqlquack-rules/src/time_zones/constants.rs @@ -0,0 +1,603 @@ +//! The time zone names Snowflake was recorded accepting. + +/// Time zone names recorded as accepted by Snowflake, which matches them exactly, case included. +pub const RECORDED_TIME_ZONES: &[&str] = &[ + "Africa/Abidjan", + "Africa/Accra", + "Africa/Addis_Ababa", + "Africa/Algiers", + "Africa/Asmara", + "Africa/Asmera", + "Africa/Bamako", + "Africa/Bangui", + "Africa/Banjul", + "Africa/Bissau", + "Africa/Blantyre", + "Africa/Brazzaville", + "Africa/Bujumbura", + "Africa/Cairo", + "Africa/Casablanca", + "Africa/Ceuta", + "Africa/Conakry", + "Africa/Dakar", + "Africa/Dar_es_Salaam", + "Africa/Djibouti", + "Africa/Douala", + "Africa/El_Aaiun", + "Africa/Freetown", + "Africa/Gaborone", + "Africa/Harare", + "Africa/Johannesburg", + "Africa/Juba", + "Africa/Kampala", + "Africa/Khartoum", + "Africa/Kigali", + "Africa/Kinshasa", + "Africa/Lagos", + "Africa/Libreville", + "Africa/Lome", + "Africa/Luanda", + "Africa/Lubumbashi", + "Africa/Lusaka", + "Africa/Malabo", + "Africa/Maputo", + "Africa/Maseru", + "Africa/Mbabane", + "Africa/Mogadishu", + "Africa/Monrovia", + "Africa/Nairobi", + "Africa/Ndjamena", + "Africa/Niamey", + "Africa/Nouakchott", + "Africa/Ouagadougou", + "Africa/Porto-Novo", + "Africa/Sao_Tome", + "Africa/Timbuktu", + "Africa/Tripoli", + "Africa/Tunis", + "Africa/Windhoek", + "America/Adak", + "America/Anchorage", + "America/Anguilla", + "America/Antigua", + "America/Araguaina", + "America/Argentina/Buenos_Aires", + "America/Argentina/Catamarca", + "America/Argentina/ComodRivadavia", + "America/Argentina/Cordoba", + "America/Argentina/Jujuy", + "America/Argentina/La_Rioja", + "America/Argentina/Mendoza", + "America/Argentina/Rio_Gallegos", + "America/Argentina/Salta", + "America/Argentina/San_Juan", + "America/Argentina/San_Luis", + "America/Argentina/Tucuman", + "America/Argentina/Ushuaia", + "America/Aruba", + "America/Asuncion", + "America/Atikokan", + "America/Atka", + "America/Bahia", + "America/Bahia_Banderas", + "America/Barbados", + "America/Belem", + "America/Belize", + "America/Blanc-Sablon", + "America/Boa_Vista", + "America/Bogota", + "America/Boise", + "America/Buenos_Aires", + "America/Cambridge_Bay", + "America/Campo_Grande", + "America/Cancun", + "America/Caracas", + "America/Catamarca", + "America/Cayenne", + "America/Cayman", + "America/Chicago", + "America/Chihuahua", + "America/Ciudad_Juarez", + "America/Coral_Harbour", + "America/Cordoba", + "America/Costa_Rica", + "America/Coyhaique", + "America/Creston", + "America/Cuiaba", + "America/Curacao", + "America/Danmarkshavn", + "America/Dawson", + "America/Dawson_Creek", + "America/Denver", + "America/Detroit", + "America/Dominica", + "America/Edmonton", + "America/Eirunepe", + "America/El_Salvador", + "America/Ensenada", + "America/Fort_Nelson", + "America/Fort_Wayne", + "America/Fortaleza", + "America/Glace_Bay", + "America/Godthab", + "America/Goose_Bay", + "America/Grand_Turk", + "America/Grenada", + "America/Guadeloupe", + "America/Guatemala", + "America/Guayaquil", + "America/Guyana", + "America/Halifax", + "America/Havana", + "America/Hermosillo", + "America/Indiana/Indianapolis", + "America/Indiana/Knox", + "America/Indiana/Marengo", + "America/Indiana/Petersburg", + "America/Indiana/Tell_City", + "America/Indiana/Vevay", + "America/Indiana/Vincennes", + "America/Indiana/Winamac", + "America/Indianapolis", + "America/Inuvik", + "America/Iqaluit", + "America/Jamaica", + "America/Jujuy", + "America/Juneau", + "America/Kentucky/Louisville", + "America/Kentucky/Monticello", + "America/Knox_IN", + "America/Kralendijk", + "America/La_Paz", + "America/Lima", + "America/Los_Angeles", + "America/Louisville", + "America/Lower_Princes", + "America/Maceio", + "America/Managua", + "America/Manaus", + "America/Marigot", + "America/Martinique", + "America/Matamoros", + "America/Mazatlan", + "America/Mendoza", + "America/Menominee", + "America/Merida", + "America/Metlakatla", + "America/Mexico_City", + "America/Miquelon", + "America/Moncton", + "America/Monterrey", + "America/Montevideo", + "America/Montreal", + "America/Montserrat", + "America/Nassau", + "America/New_York", + "America/Nipigon", + "America/Nome", + "America/Noronha", + "America/North_Dakota/Beulah", + "America/North_Dakota/Center", + "America/North_Dakota/New_Salem", + "America/Nuuk", + "America/Ojinaga", + "America/Panama", + "America/Pangnirtung", + "America/Paramaribo", + "America/Phoenix", + "America/Port-au-Prince", + "America/Port_of_Spain", + "America/Porto_Acre", + "America/Porto_Velho", + "America/Puerto_Rico", + "America/Punta_Arenas", + "America/Rainy_River", + "America/Rankin_Inlet", + "America/Recife", + "America/Regina", + "America/Resolute", + "America/Rio_Branco", + "America/Rosario", + "America/Santa_Isabel", + "America/Santarem", + "America/Santiago", + "America/Santo_Domingo", + "America/Sao_Paulo", + "America/Scoresbysund", + "America/Shiprock", + "America/Sitka", + "America/St_Barthelemy", + "America/St_Johns", + "America/St_Kitts", + "America/St_Lucia", + "America/St_Thomas", + "America/St_Vincent", + "America/Swift_Current", + "America/Tegucigalpa", + "America/Thule", + "America/Thunder_Bay", + "America/Tijuana", + "America/Toronto", + "America/Tortola", + "America/Vancouver", + "America/Virgin", + "America/Whitehorse", + "America/Winnipeg", + "America/Yakutat", + "America/Yellowknife", + "Antarctica/Casey", + "Antarctica/Davis", + "Antarctica/DumontDUrville", + "Antarctica/Macquarie", + "Antarctica/Mawson", + "Antarctica/McMurdo", + "Antarctica/Palmer", + "Antarctica/Rothera", + "Antarctica/South_Pole", + "Antarctica/Syowa", + "Antarctica/Troll", + "Antarctica/Vostok", + "Arctic/Longyearbyen", + "Asia/Aden", + "Asia/Almaty", + "Asia/Amman", + "Asia/Anadyr", + "Asia/Aqtau", + "Asia/Aqtobe", + "Asia/Ashgabat", + "Asia/Ashkhabad", + "Asia/Atyrau", + "Asia/Baghdad", + "Asia/Bahrain", + "Asia/Baku", + "Asia/Bangkok", + "Asia/Barnaul", + "Asia/Beirut", + "Asia/Bishkek", + "Asia/Brunei", + "Asia/Calcutta", + "Asia/Chita", + "Asia/Choibalsan", + "Asia/Chongqing", + "Asia/Chungking", + "Asia/Colombo", + "Asia/Dacca", + "Asia/Damascus", + "Asia/Dhaka", + "Asia/Dili", + "Asia/Dubai", + "Asia/Dushanbe", + "Asia/Famagusta", + "Asia/Gaza", + "Asia/Harbin", + "Asia/Hebron", + "Asia/Ho_Chi_Minh", + "Asia/Hong_Kong", + "Asia/Hovd", + "Asia/Irkutsk", + "Asia/Istanbul", + "Asia/Jakarta", + "Asia/Jayapura", + "Asia/Jerusalem", + "Asia/Kabul", + "Asia/Kamchatka", + "Asia/Karachi", + "Asia/Kashgar", + "Asia/Kathmandu", + "Asia/Katmandu", + "Asia/Khandyga", + "Asia/Kolkata", + "Asia/Krasnoyarsk", + "Asia/Kuala_Lumpur", + "Asia/Kuching", + "Asia/Kuwait", + "Asia/Macao", + "Asia/Macau", + "Asia/Magadan", + "Asia/Makassar", + "Asia/Manila", + "Asia/Muscat", + "Asia/Nicosia", + "Asia/Novokuznetsk", + "Asia/Novosibirsk", + "Asia/Omsk", + "Asia/Oral", + "Asia/Phnom_Penh", + "Asia/Pontianak", + "Asia/Pyongyang", + "Asia/Qatar", + "Asia/Qostanay", + "Asia/Qyzylorda", + "Asia/Rangoon", + "Asia/Riyadh", + "Asia/Saigon", + "Asia/Sakhalin", + "Asia/Samarkand", + "Asia/Seoul", + "Asia/Shanghai", + "Asia/Singapore", + "Asia/Srednekolymsk", + "Asia/Taipei", + "Asia/Tashkent", + "Asia/Tbilisi", + "Asia/Tehran", + "Asia/Tel_Aviv", + "Asia/Thimbu", + "Asia/Thimphu", + "Asia/Tokyo", + "Asia/Tomsk", + "Asia/Ujung_Pandang", + "Asia/Ulaanbaatar", + "Asia/Ulan_Bator", + "Asia/Urumqi", + "Asia/Ust-Nera", + "Asia/Vientiane", + "Asia/Vladivostok", + "Asia/Yakutsk", + "Asia/Yangon", + "Asia/Yekaterinburg", + "Asia/Yerevan", + "Atlantic/Azores", + "Atlantic/Bermuda", + "Atlantic/Canary", + "Atlantic/Cape_Verde", + "Atlantic/Faeroe", + "Atlantic/Faroe", + "Atlantic/Jan_Mayen", + "Atlantic/Madeira", + "Atlantic/Reykjavik", + "Atlantic/South_Georgia", + "Atlantic/St_Helena", + "Atlantic/Stanley", + "Australia/ACT", + "Australia/Adelaide", + "Australia/Brisbane", + "Australia/Broken_Hill", + "Australia/Canberra", + "Australia/Currie", + "Australia/Darwin", + "Australia/Eucla", + "Australia/Hobart", + "Australia/LHI", + "Australia/Lindeman", + "Australia/Lord_Howe", + "Australia/Melbourne", + "Australia/NSW", + "Australia/North", + "Australia/Perth", + "Australia/Queensland", + "Australia/South", + "Australia/Sydney", + "Australia/Tasmania", + "Australia/Victoria", + "Australia/West", + "Australia/Yancowinna", + "Brazil/Acre", + "Brazil/DeNoronha", + "Brazil/East", + "Brazil/West", + "CET", + "CST6CDT", + "Canada/Atlantic", + "Canada/Central", + "Canada/Eastern", + "Canada/Mountain", + "Canada/Newfoundland", + "Canada/Pacific", + "Canada/Saskatchewan", + "Canada/Yukon", + "Chile/Continental", + "Chile/EasterIsland", + "Cuba", + "EET", + "EST", + "EST5EDT", + "Egypt", + "Eire", + "Etc/GMT", + "Etc/GMT+0", + "Etc/GMT+1", + "Etc/GMT+10", + "Etc/GMT+11", + "Etc/GMT+12", + "Etc/GMT+2", + "Etc/GMT+3", + "Etc/GMT+4", + "Etc/GMT+5", + "Etc/GMT+6", + "Etc/GMT+7", + "Etc/GMT+8", + "Etc/GMT+9", + "Etc/GMT-0", + "Etc/GMT-1", + "Etc/GMT-10", + "Etc/GMT-11", + "Etc/GMT-12", + "Etc/GMT-13", + "Etc/GMT-14", + "Etc/GMT-2", + "Etc/GMT-3", + "Etc/GMT-4", + "Etc/GMT-5", + "Etc/GMT-6", + "Etc/GMT-7", + "Etc/GMT-8", + "Etc/GMT-9", + "Etc/GMT0", + "Etc/Greenwich", + "Etc/UCT", + "Etc/UTC", + "Etc/Universal", + "Etc/Zulu", + "Europe/Amsterdam", + "Europe/Andorra", + "Europe/Astrakhan", + "Europe/Athens", + "Europe/Belfast", + "Europe/Belgrade", + "Europe/Berlin", + "Europe/Bratislava", + "Europe/Brussels", + "Europe/Bucharest", + "Europe/Budapest", + "Europe/Busingen", + "Europe/Chisinau", + "Europe/Copenhagen", + "Europe/Dublin", + "Europe/Gibraltar", + "Europe/Guernsey", + "Europe/Helsinki", + "Europe/Isle_of_Man", + "Europe/Istanbul", + "Europe/Jersey", + "Europe/Kaliningrad", + "Europe/Kiev", + "Europe/Kirov", + "Europe/Kyiv", + "Europe/Lisbon", + "Europe/Ljubljana", + "Europe/London", + "Europe/Luxembourg", + "Europe/Madrid", + "Europe/Malta", + "Europe/Mariehamn", + "Europe/Minsk", + "Europe/Monaco", + "Europe/Moscow", + "Europe/Nicosia", + "Europe/Oslo", + "Europe/Paris", + "Europe/Podgorica", + "Europe/Prague", + "Europe/Riga", + "Europe/Rome", + "Europe/Samara", + "Europe/San_Marino", + "Europe/Sarajevo", + "Europe/Saratov", + "Europe/Simferopol", + "Europe/Skopje", + "Europe/Sofia", + "Europe/Stockholm", + "Europe/Tallinn", + "Europe/Tirane", + "Europe/Tiraspol", + "Europe/Ulyanovsk", + "Europe/Uzhgorod", + "Europe/Vaduz", + "Europe/Vatican", + "Europe/Vienna", + "Europe/Vilnius", + "Europe/Volgograd", + "Europe/Warsaw", + "Europe/Zagreb", + "Europe/Zaporozhye", + "Europe/Zurich", + "Factory", + "GB", + "GB-Eire", + "GMT", + "GMT+0", + "GMT-0", + "GMT0", + "Greenwich", + "HST", + "Hongkong", + "Iceland", + "Indian/Antananarivo", + "Indian/Chagos", + "Indian/Christmas", + "Indian/Cocos", + "Indian/Comoro", + "Indian/Kerguelen", + "Indian/Mahe", + "Indian/Maldives", + "Indian/Mauritius", + "Indian/Mayotte", + "Indian/Reunion", + "Iran", + "Israel", + "Jamaica", + "Japan", + "Kwajalein", + "Libya", + "MET", + "MST", + "MST7MDT", + "Mexico/BajaNorte", + "Mexico/BajaSur", + "Mexico/General", + "NZ", + "NZ-CHAT", + "Navajo", + "PRC", + "PST8PDT", + "Pacific/Apia", + "Pacific/Auckland", + "Pacific/Bougainville", + "Pacific/Chatham", + "Pacific/Chuuk", + "Pacific/Easter", + "Pacific/Efate", + "Pacific/Enderbury", + "Pacific/Fakaofo", + "Pacific/Fiji", + "Pacific/Funafuti", + "Pacific/Galapagos", + "Pacific/Gambier", + "Pacific/Guadalcanal", + "Pacific/Guam", + "Pacific/Honolulu", + "Pacific/Johnston", + "Pacific/Kanton", + "Pacific/Kiritimati", + "Pacific/Kosrae", + "Pacific/Kwajalein", + "Pacific/Majuro", + "Pacific/Marquesas", + "Pacific/Midway", + "Pacific/Nauru", + "Pacific/Niue", + "Pacific/Norfolk", + "Pacific/Noumea", + "Pacific/Pago_Pago", + "Pacific/Palau", + "Pacific/Pitcairn", + "Pacific/Pohnpei", + "Pacific/Ponape", + "Pacific/Port_Moresby", + "Pacific/Rarotonga", + "Pacific/Saipan", + "Pacific/Samoa", + "Pacific/Tahiti", + "Pacific/Tarawa", + "Pacific/Tongatapu", + "Pacific/Truk", + "Pacific/Wake", + "Pacific/Wallis", + "Pacific/Yap", + "Poland", + "Portugal", + "ROC", + "ROK", + "Singapore", + "Turkey", + "UCT", + "US/Alaska", + "US/Aleutian", + "US/Arizona", + "US/Central", + "US/East-Indiana", + "US/Eastern", + "US/Hawaii", + "US/Indiana-Starke", + "US/Michigan", + "US/Mountain", + "US/Pacific", + "US/Samoa", + "UTC", + "Universal", + "W-SU", + "WET", + "Zulu", +]; diff --git a/crates/sqlquack-rules/src/time_zones/main/mod.rs b/crates/sqlquack-rules/src/time_zones/main/mod.rs new file mode 100644 index 000000000..f1814c444 --- /dev/null +++ b/crates/sqlquack-rules/src/time_zones/main/mod.rs @@ -0,0 +1 @@ +pub mod recorded_zone; diff --git a/crates/sqlquack-rules/src/time_zones/main/recorded_zone.rs b/crates/sqlquack-rules/src/time_zones/main/recorded_zone.rs new file mode 100644 index 000000000..0bb1f3da6 --- /dev/null +++ b/crates/sqlquack-rules/src/time_zones/main/recorded_zone.rs @@ -0,0 +1,8 @@ +//! `recorded_zone`: whether Snowflake was recorded accepting a time zone name. + +use crate::time_zones::constants::RECORDED_TIME_ZONES; + +/// Whether Snowflake was recorded accepting `name`, which it matches exactly, case included. +pub fn recorded_zone(name: &str) -> bool { + RECORDED_TIME_ZONES.contains(&name) +} diff --git a/crates/sqlquack-rules/src/time_zones/mod.rs b/crates/sqlquack-rules/src/time_zones/mod.rs new file mode 100644 index 000000000..bf4bd7e3e --- /dev/null +++ b/crates/sqlquack-rules/src/time_zones/mod.rs @@ -0,0 +1,4 @@ +//! The time zone names Snowflake was recorded accepting. + +pub mod constants; +pub mod main; From 0e447b62112623bd287a76d370dca6f080779d56 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:09:16 +0000 Subject: [PATCH 03/11] WIP: replay SET and UNSET of session variables from case setup (D228 part 2, slice 2) SET of a variable, or of a list of them, to a literal, NULL or a scalar subquery is recorded during setup, and $name reads the expression it was set to (rule.session_variable). Subqueries reading variables or calling functions whose result can change between the SET and the read refuse. Main merged in; the recorded time zone table moved out of conversion/constants.rs. Incomplete: fensu faults in convert_setup.rs (split needed), CREATE TYPE, SEMANTIC VIEW, ALTER SESSION, docs, full verify. The new rule has no case on main until the 6n2b cases land. Checked: setup tests; the 6n2b session-variable and name-resolution cases replay on a scratch merge. --- .../sqlquack-rules/src/catalogue/constants.rs | 33 +++-- .../conversion/_helpers/expressions/atoms.rs | 27 +++- .../conversion/_helpers/expressions/scalar.rs | 6 +- .../src/conversion/constants.rs | 6 +- .../src/conversion/main/convert_setup.rs | 122 +++++++++++++++++- .../sqlquack-rules/src/conversion/models.rs | 4 + crates/sqlquack/src/analysis/models.rs | 1 + .../src/analysis/tests/test_case_setup.rs | 56 +++++++- 8 files changed, 231 insertions(+), 24 deletions(-) diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index 1d6212b15..07eb60a94 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -40,19 +40,20 @@ use crate::conversion::constants::{ PARENTHESES, PARSE_JSON_RULE, PARSE_JSON_VARIANT, PARSE_URL_RULE, PERCENTILE_RULE, PIVOT_RULE, POSITIONAL_COLUMN, POWER_RULE, PROJECT_FUNCTION_CALL, PROJECT_TABLE_FUNCTION, QUALIFY, REDUCE_RULE, REGEXP, REGEXP_COLUMN_PATTERN, REGEXP_COUNTING, REGEXP_PARAMETERS, - REGEXP_POSIX_SPACE, ROUNDING, SCALAR_SUBQUERY, SELECT, SET_OPERATION, SIGN_RULE, SPLIT_RULE, - SPLIT_TO_TABLE_RULE, STAR, SUM, SUM_FLOAT, SYSTEM_TYPE_NAME, TEMPORAL_TEXT_FORMAT, TEXT_AFFIX, - TEXT_BRANCHES, TEXT_CHR, TEXT_CONTAINS, TEXT_HEX, TEXT_HEX_DECODE, TEXT_INITCAP, TEXT_MD5, - TEXT_NUMBER_COMPARISON, TEXT_PAD, TEXT_POSITION, TEXT_REPEAT, TEXT_REPLACE, - TEXT_SEARCH_POSITION, TEXT_SHA2, TEXT_TO_TIME_RULE, TEXT_TO_TIMESTAMP_RULE, TEXT_TRANSLATE, - TIME_ARITHMETIC, TIME_CAST, TIME_ZONE_CONVERSION, TIMESTAMP_FORMAT_RULE, TIMESTAMP_TIME_RULE, - TO_ARRAY_RULE, TO_BOOLEAN_RULE, TO_DATE_TEMPORAL, TO_TIMESTAMP_TEMPORAL, TO_VARIANT_RULE, TRIM, - UNION_BY_NAME, USER_OVERRIDE_RULE, UTC_OFFSET_ARITHMETIC, VALUE_AT_EXTREME_KEY, VALUES_ROWS, - VARIANT_CAST, VARIANT_COMPUTED_GET, VARIANT_EQUALITY, VARIANT_EQUALITY_BY_TEXT, - VARIANT_INSPECT, VARIANT_KIND_CAST, VARIANT_KIND_RULE, VARIANT_MAX, VARIANT_ORDER_KEY, - VARIANT_PARTITION, VARIANT_PATH, VARIANT_TEXT_BRANCHES, VARIANT_TO_VARCHAR, VARIANT_TYPE_NAME, - VARIANT_TYPED_CHECK, WHERE, WINDOW_AGGREGATE, WINDOW_FIRST_LAST, WINDOW_OFFSET, WINDOW_RANKING, - WINDOW_VALUE_FRAME, ZERO_IF_NULL_RULE, ZONE_COLUMNS, ZONED_PARTS, + REGEXP_POSIX_SPACE, ROUNDING, SCALAR_SUBQUERY, SELECT, SESSION_VARIABLE, SET_OPERATION, + SIGN_RULE, SPLIT_RULE, SPLIT_TO_TABLE_RULE, STAR, SUM, SUM_FLOAT, SYSTEM_TYPE_NAME, + TEMPORAL_TEXT_FORMAT, TEXT_AFFIX, TEXT_BRANCHES, TEXT_CHR, TEXT_CONTAINS, TEXT_HEX, + TEXT_HEX_DECODE, TEXT_INITCAP, TEXT_MD5, TEXT_NUMBER_COMPARISON, TEXT_PAD, TEXT_POSITION, + TEXT_REPEAT, TEXT_REPLACE, TEXT_SEARCH_POSITION, TEXT_SHA2, TEXT_TO_TIME_RULE, + TEXT_TO_TIMESTAMP_RULE, TEXT_TRANSLATE, TIME_ARITHMETIC, TIME_CAST, TIME_ZONE_CONVERSION, + TIMESTAMP_FORMAT_RULE, TIMESTAMP_TIME_RULE, TO_ARRAY_RULE, TO_BOOLEAN_RULE, TO_DATE_TEMPORAL, + TO_TIMESTAMP_TEMPORAL, TO_VARIANT_RULE, TRIM, UNION_BY_NAME, USER_OVERRIDE_RULE, + UTC_OFFSET_ARITHMETIC, VALUE_AT_EXTREME_KEY, VALUES_ROWS, VARIANT_CAST, VARIANT_COMPUTED_GET, + VARIANT_EQUALITY, VARIANT_EQUALITY_BY_TEXT, VARIANT_INSPECT, VARIANT_KIND_CAST, + VARIANT_KIND_RULE, VARIANT_MAX, VARIANT_ORDER_KEY, VARIANT_PARTITION, VARIANT_PATH, + VARIANT_TEXT_BRANCHES, VARIANT_TO_VARCHAR, VARIANT_TYPE_NAME, VARIANT_TYPED_CHECK, WHERE, + WINDOW_AGGREGATE, WINDOW_FIRST_LAST, WINDOW_OFFSET, WINDOW_RANKING, WINDOW_VALUE_FRAME, + ZERO_IF_NULL_RULE, ZONE_COLUMNS, ZONED_PARTS, }; use crate::conversion::models::RuleSpec; @@ -124,6 +125,12 @@ pub const RULES: &[RuleSpec] = &[ conditions: "VALUES rows of one width, as a query or FROM source: the UNION ALL of one SELECT per row, so each column takes the set operation's common type; columns are named COLUMN1, COLUMN2 and so on unless an alias list renames them. More than 1,000 rows refuse for now.", failures: &[], }, + RuleSpec { + id: SESSION_VARIABLE, + constructs: &["node:parameter"], + conditions: "`$name` of an unquoted variable the case's setup SET, matched without regard to case, read as the literal, NULL or scalar subquery it was set to, as recorded; the subquery may not read variables or call functions whose result can change between the SET and the read. Other variables, and SET of other values, are refused.", + failures: &[], + }, RuleSpec { id: POSITIONAL_COLUMN, constructs: &["node:parameter"], diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/atoms.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/atoms.rs index e42fe157a..a92b0e666 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/atoms.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/atoms.rs @@ -28,7 +28,7 @@ use crate::conversion::_helpers::support::tree::{ use crate::conversion::constants::{ COLUMN, COMMENT, CONSTANT_LITERAL, DOLLAR_PARAMETER, DOLLAR_STRING_KIND, NEGATE, NUMBER_LITERAL, PARENTHESES, POSITIONAL_COLUMN, SCALAR_SUBQUERY, SEQUENCE_COLUMN, - SEQUENCE_PEERS, TYPED_LITERAL_KINDS, TYPED_LITERAL_PAIR, + SEQUENCE_PEERS, SESSION_VARIABLE, TYPED_LITERAL_KINDS, TYPED_LITERAL_PAIR, }; use sqlquack_semantics::scopes::models::ScopeRelation; use sqlquack_semantics::types::models::Holding; @@ -99,6 +99,31 @@ fn sequence_column(scope: &Scope<'_>, qualifier: Option<&str>, name: &str) -> bo false } +/// `$name`, a session variable the case's setup SET, or `$n`, a column position. +pub(crate) fn parameter(payload: &Value, site: Site<'_>) -> Result { + let Some(name) = payload.get("name").and_then(Value::as_str) else { + return positional_column(payload, site); + }; + let construct = node_construct("parameter"); + let refuse = |reason: String| outside_conditions(construct.clone(), reason); + if payload.get("style").and_then(Value::as_str) != Some(DOLLAR_PARAMETER) { + return Err(refuse(format!( + "the parameter {name}, which is not a $name variable" + ))); + } + if payload.get("quoted").and_then(Value::as_bool) == Some(true) { + return Err(refuse(format!("the quoted variable name {name}"))); + } + let resolved = name.to_ascii_uppercase(); + let Some(value) = site.context.options.session_variables.get(&resolved) else { + return Err(refuse(format!( + "the variable ${name}, which the case's setup does not SET" + ))); + }; + let _rule = site.context.log.rule(SESSION_VARIABLE); + convert_expression(value, site) +} + /// `$n`: the n-th column of the query level's only FROM source, read as that column. pub(crate) fn positional_column(payload: &Value, site: Site<'_>) -> Result { let construct = node_construct("parameter"); diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/scalar.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/scalar.rs index 501895865..60156939c 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/scalar.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/scalar.rs @@ -9,8 +9,8 @@ use sqlquack_duckdb::checking::main::bound_outside_lambdas::bound_outside_lambda use sqlquack_semantics::types::models::Volatility; use crate::conversion::_helpers::expressions::atoms::{ - boolean_literal, column, commented, literal, negate, null_literal, parentheses, - positional_column, scalar_subquery, + boolean_literal, column, commented, literal, negate, null_literal, parameter, parentheses, + scalar_subquery, }; use crate::conversion::_helpers::expressions::calls::{convert_aggregate, convert_function}; use crate::conversion::_helpers::expressions::casts::convert_cast; @@ -277,7 +277,7 @@ fn convert_kind(node: &Value, site: Site<'_>) -> Result { }; match kind { "column" => column(payload, site), - "parameter" => positional_column(payload, site), + "parameter" => parameter(payload, site), "literal" => literal(payload, site), "boolean" => Ok(boolean_literal(payload, site)), "null" => Ok(null_literal(site)), diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index 7b4ecf1bf..6ae2e58a8 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -1398,5 +1398,7 @@ pub(crate) const OUTPUT_NAMED_ARITHMETIC: &[&str] = &["add", "sub", "mul", "div" /// The cast target spellings whose unaliased column names receipts record. pub(crate) const OUTPUT_NAMED_CAST_ENDINGS: &[&str] = &[" AS INTEGER)", "::INTEGER"]; /// The setup statements a case's DuckDB replay runs. -pub(crate) const SETUP_REPLAYED: &str = - "CREATE TABLE of NUMBER and VARCHAR columns and INSERT INTO it VALUES of literals"; +pub(crate) const SETUP_REPLAYED: &str = "CREATE TABLE of NUMBER and VARCHAR columns, INSERT INTO it \ + VALUES of literals, and SET of variables to literals or a deterministic scalar subquery"; +/// `$name` read as the expression the case's setup SET the variable to. +pub const SESSION_VARIABLE: &str = "rule.session_variable"; diff --git a/crates/sqlquack-rules/src/conversion/main/convert_setup.rs b/crates/sqlquack-rules/src/conversion/main/convert_setup.rs index a1f05e0b5..1eca86ce6 100644 --- a/crates/sqlquack-rules/src/conversion/main/convert_setup.rs +++ b/crates/sqlquack-rules/src/conversion/main/convert_setup.rs @@ -1,5 +1,6 @@ //! `convert_setup`: a case's setup statements as DuckDB statements and the relations they create. +use serde_json::Value; use sqlquack_core::parsing::main::parse_source::parse_source; use sqlquack_core::parsing::models::SourceDialect; use sqlquack_core::provenance::models::ProvenanceLog; @@ -11,15 +12,26 @@ use sqlquack_snowflake::typing::main::cast_target::cast_target; use sqlquack_snowflake::typing::models::SnowflakeType; use sqlquack_syntax::expressions::columns::models::ColumnDef; use sqlquack_syntax::expressions::names::models::{Identifier, Literal, TableRef}; -use sqlquack_syntax::expressions::statements::models::Insert; +use sqlquack_syntax::expressions::statements::models::{Insert, SetStatement, UnsetStatement}; use sqlquack_syntax::expressions::tables::models::CreateTable; use sqlquack_syntax::expressions::tree::models::Expression; use crate::conversion::_helpers::expressions::casts::data_type_name; -use crate::conversion::constants::SETUP_REPLAYED; +use crate::conversion::_helpers::support::tree::strip_positions; +use crate::conversion::constants::{NONDETERMINISTIC_FUNCTIONS, SETUP_REPLAYED}; use crate::conversion::models::ConvertedSetup; -/// Each statement in order: CREATE TABLE of named, typed columns, or INSERT INTO it of literal rows. +/// Name prefixes of functions whose result depends on when they run, so a SET differs from its reads. +const TIME_DEPENDENT_PREFIXES: &[&str] = &[ + "CURRENT_", + "LOCALTIME", + "SYSDATE", + "GETDATE", + "SYSTIMESTAMP", +]; + +/// Each statement in order: CREATE TABLE of named, typed columns, INSERT INTO it of literal rows, +/// SET of session variables, or UNSET of them. pub fn convert_setup(statements: &[String]) -> Result { let log = ProvenanceLog::default(); let mut converted = ConvertedSetup::default(); @@ -34,6 +46,14 @@ pub fn convert_setup(statements: &[String]) -> Result { [Expression::Insert(insert)] => { inserted(insert, &converted, &log).map_err(|reason| refused(&reason))? } + [Expression::SetStatement(set)] => { + assigned(set, &mut converted).map_err(|reason| refused(&reason))?; + continue; + } + [Expression::UnsetStatement(unset)] => { + unassigned(unset, &mut converted).map_err(|reason| refused(&reason))?; + continue; + } _ => { return Err(refused(&format!( "is not replayed on DuckDB: only {SETUP_REPLAYED} are" @@ -232,6 +252,102 @@ fn fits(text: &str, precision: u8, scale: u8) -> bool { && whole.len() <= usize::from(precision.saturating_sub(scale)) } +/// `SET name = value` or `SET (name, …) = (value, …)`: each variable recorded for `$name` to read. +fn assigned(set: &SetStatement, converted: &mut ConvertedSetup) -> Result<(), String> { + for item in &set.items { + if item.kind.is_some() || item.no_equals { + return Err("sets other than a session variable with =".to_owned()); + } + let pairs: Vec<(&Expression, &Expression)> = match (&item.name, &item.value) { + (Expression::Tuple(names), Expression::Tuple(values)) + if names.expressions.len() == values.expressions.len() => + { + names.expressions.iter().zip(&values.expressions).collect() + } + (Expression::Tuple(_), _) => { + return Err("sets a list of variables to other than as many values".to_owned()); + } + (name, value) => vec![(name, value)], + }; + for (name, value) in pairs { + let name = match name { + Expression::Identifier(identifier) => resolved(identifier)?, + Expression::Column(column) if column.table.is_none() => resolved(&column.name)?, + _ => return Err("sets a variable whose name is not a plain name".to_owned()), + }; + let value = variable_value(value)?; + converted.variables.insert(name, value); + } + } + Ok(()) +} + +/// `UNSET name` or `UNSET (name, …)` of variables setup SET earlier. +fn unassigned(unset: &UnsetStatement, converted: &mut ConvertedSetup) -> Result<(), String> { + for identifier in &unset.names { + let name = resolved(identifier)?; + if converted.variables.remove(&name).is_none() { + return Err(format!("unsets {name}, which setup did not set")); + } + } + Ok(()) +} + +/// A literal, NULL or a scalar subquery whose value cannot change between the SET and its reads. +fn variable_value(value: &Expression) -> Result { + let fixed = match value { + Expression::Null(_) => true, + Expression::Literal(literal) => { + matches!(literal.as_ref(), Literal::Number(_) | Literal::String(_)) + } + Expression::Subquery(_) => true, + _ => false, + }; + if !fixed { + return Err( + "sets a variable to other than a literal, NULL or a scalar subquery".to_owned(), + ); + } + let tree = serde_json::to_value(value) + .map(|value| strip_positions(&value)) + .map_err(|error| format!("sets a value that does not serialise: {error}"))?; + match unrepeatable(&tree) { + Some(reason) => Err(format!("sets a variable to a subquery {reason}")), + None => Ok(tree), + } +} + +/// Why a value read at each `$name` could differ from the value SET computed once, if it could. +fn unrepeatable(tree: &Value) -> Option { + match tree { + Value::Object(fields) => { + if fields.contains_key("parameter") { + return Some("reading another variable or parameter".to_owned()); + } + if let Some(Value::String(name)) = fields.get("name") { + let name = name.to_ascii_uppercase(); + if NONDETERMINISTIC_FUNCTIONS.contains(&name.as_str()) + || TIME_DEPENDENT_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)) + { + return Some(format!("calling {name}, whose result can change")); + } + } + if let Some(kind) = fields.keys().find(|kind| { + TIME_DEPENDENT_PREFIXES + .iter() + .any(|prefix| kind.to_ascii_uppercase().starts_with(prefix)) + }) { + return Some(format!("reading {kind}, whose result can change")); + } + fields.values().find_map(unrepeatable) + } + Value::Array(items) => items.iter().find_map(unrepeatable), + _ => None, + } +} + /// An unquoted name as Snowflake resolves it, in upper case; a quoted one refuses. fn resolved(identifier: &Identifier) -> Result { match identifier.quoted { diff --git a/crates/sqlquack-rules/src/conversion/models.rs b/crates/sqlquack-rules/src/conversion/models.rs index 09f76cb6b..2f7593ecb 100644 --- a/crates/sqlquack-rules/src/conversion/models.rs +++ b/crates/sqlquack-rules/src/conversion/models.rs @@ -46,6 +46,8 @@ pub struct ConversionOptions { pub overrides: BTreeMap, /// Override names to the SHA-256 of the body recorded cases verified, which strict mode allows. pub verified_overrides: BTreeMap, + /// Session variables a case's setup SET, by upper-case name, with the expression each was set to. + pub session_variables: BTreeMap, } /// A user's DuckDB SQL standing in for a Snowflake function, written in SQLBuild's FUNCTION() format. @@ -218,4 +220,6 @@ pub struct ConvertedSetup { pub relations: BTreeMap>, /// The DuckDB statements, in setup order. pub statements: Vec, + /// Each session variable SET, by its resolved name, with the expression it was set to. + pub variables: BTreeMap, } diff --git a/crates/sqlquack/src/analysis/models.rs b/crates/sqlquack/src/analysis/models.rs index d183c90b5..002201716 100644 --- a/crates/sqlquack/src/analysis/models.rs +++ b/crates/sqlquack/src/analysis/models.rs @@ -87,6 +87,7 @@ impl CaseConverter for PipelineConverter { .session_parameters .get(QUOTED_IDENTIFIERS_IGNORE_CASE) == Some(&SessionValue::Boolean(true)), + session_variables: setup.variables.clone(), ..self.options.clone() }; for definition in &case.overrides { diff --git a/crates/sqlquack/src/analysis/tests/test_case_setup.rs b/crates/sqlquack/src/analysis/tests/test_case_setup.rs index 74d5be238..0ec820a7a 100644 --- a/crates/sqlquack/src/analysis/tests/test_case_setup.rs +++ b/crates/sqlquack/src/analysis/tests/test_case_setup.rs @@ -124,10 +124,62 @@ fn given_setup_tables_when_converting_a_case_then_duckdb_replays_them_or_it_refu expected_outcome: Err("which setup did not create"), }, SetupCaseTestCase { - description: "a session variable", + description: "a session variable read in either case adds to a column", + setup: &["SET MyVar = 10"], + query: "SELECT a + $myvar AS n FROM input", + expected_outcome: Ok("11"), + }, + SetupCaseTestCase { + description: "a pair of variables set in one statement keeps its order", + setup: &["SET (lo, hi) = (40, 70)"], + query: "SELECT $hi - $lo AS n", + expected_outcome: Ok("30"), + }, + SetupCaseTestCase { + description: "a variable set from a subquery over a setup table", + setup: &[ + "CREATE TABLE src (k NUMBER(38,0))", + "INSERT INTO src VALUES (3), (7)", + "SET top = (SELECT MAX(k) FROM src)", + ], + query: "SELECT $top AS n", + expected_outcome: Ok("7"), + }, + SetupCaseTestCase { + description: "a variable the setup did not set", setup: &["SET n = 10"], + query: "SELECT $m AS n", + expected_outcome: Err("which the case's setup does not SET"), + }, + SetupCaseTestCase { + description: "a variable unset before the query", + setup: &["SET n = 10", "UNSET n"], query: "SELECT $n AS n", - expected_outcome: Err("is not replayed on DuckDB"), + expected_outcome: Err("which the case's setup does not SET"), + }, + SetupCaseTestCase { + description: "an unset of a variable never set", + setup: &["UNSET n"], + query: "SELECT 1 AS k", + expected_outcome: Err("which setup did not set"), + }, + SetupCaseTestCase { + description: "a variable set to an expression, which SET computes once", + setup: &["SET n = 10 + 1"], + query: "SELECT $n AS n", + expected_outcome: Err("other than a literal, NULL or a scalar subquery"), + }, + SetupCaseTestCase { + description: "a variable set from a subquery whose result changes between calls", + setup: &["SET n = (SELECT RANDOM())"], + query: "SELECT $n AS n", + expected_outcome: Err("whose result can change"), + }, + SetupCaseTestCase { + description: "a variable set from a subquery reading the clock", + setup: &["SET n = (SELECT CURRENT_TIMESTAMP())"], + query: "SELECT $n AS n", + expected_outcome: Err("whose result can change"), }, SetupCaseTestCase { description: "a user-defined type", From 72f8d2718b4886d5e14c8a4860e366b1ba34237c Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:19:34 +0000 Subject: [PATCH 04/11] refactor: move case setup tables and variables into conversion helpers convert_setup keeps only the statement loop; CREATE TABLE and INSERT live in _helpers/setup/tables.rs, SET and UNSET in variables.rs, and the helpers return what they read instead of mutating the setup. fensu reports no faults. No behaviour change. --- .../src/conversion/_helpers/mod.rs | 1 + .../src/conversion/_helpers/setup/mod.rs | 2 + .../src/conversion/_helpers/setup/tables.rs | 211 +++++++++++ .../conversion/_helpers/setup/variables.rs | 117 ++++++ .../src/conversion/constants.rs | 8 + .../src/conversion/main/convert_setup.rs | 334 +----------------- crates/sqlquack/src/analysis/tests/helpers.rs | 17 +- 7 files changed, 366 insertions(+), 324 deletions(-) create mode 100644 crates/sqlquack-rules/src/conversion/_helpers/setup/mod.rs create mode 100644 crates/sqlquack-rules/src/conversion/_helpers/setup/tables.rs create mode 100644 crates/sqlquack-rules/src/conversion/_helpers/setup/variables.rs diff --git a/crates/sqlquack-rules/src/conversion/_helpers/mod.rs b/crates/sqlquack-rules/src/conversion/_helpers/mod.rs index 0bdc3edd2..29ad00e68 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/mod.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod expressions; pub(crate) mod queries; +pub(crate) mod setup; pub(crate) mod support; diff --git a/crates/sqlquack-rules/src/conversion/_helpers/setup/mod.rs b/crates/sqlquack-rules/src/conversion/_helpers/setup/mod.rs new file mode 100644 index 000000000..a3f3dfdd5 --- /dev/null +++ b/crates/sqlquack-rules/src/conversion/_helpers/setup/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod tables; +pub(crate) mod variables; diff --git a/crates/sqlquack-rules/src/conversion/_helpers/setup/tables.rs b/crates/sqlquack-rules/src/conversion/_helpers/setup/tables.rs new file mode 100644 index 000000000..7e887a063 --- /dev/null +++ b/crates/sqlquack-rules/src/conversion/_helpers/setup/tables.rs @@ -0,0 +1,211 @@ +//! CREATE TABLE and INSERT INTO it VALUES in a case's setup, as DuckDB statements. + +use sqlquack_core::provenance::models::ProvenanceLog; +use sqlquack_duckdb::blocks::main::column_type::column_type; +use sqlquack_duckdb::blocks::main::quoted_identifier::quoted_identifier; +use sqlquack_duckdb::blocks::main::string_literal::string_literal; +use sqlquack_semantics::types::models::ColumnSpec; +use sqlquack_snowflake::typing::main::cast_target::cast_target; +use sqlquack_snowflake::typing::models::SnowflakeType; +use sqlquack_syntax::expressions::columns::models::ColumnDef; +use sqlquack_syntax::expressions::names::models::{Identifier, Literal, TableRef}; +use sqlquack_syntax::expressions::statements::models::Insert; +use sqlquack_syntax::expressions::tables::models::CreateTable; +use sqlquack_syntax::expressions::tree::models::Expression; + +use crate::conversion::_helpers::expressions::casts::data_type_name; +use crate::conversion::models::ConvertedSetup; + +/// `CREATE TABLE name (column type, …)` and nothing else: its DuckDB statement, name and columns. +pub(crate) fn created( + table: &CreateTable, + converted: &ConvertedSetup, + log: &ProvenanceLog, +) -> Result<(String, String, Vec), String> { + let bare = CreateTable { + name: TableRef { + name: table.name.name.clone(), + ..TableRef::new("") + }, + columns: table + .columns + .iter() + .map(|column| ColumnDef { + name: column.name.clone(), + ..ColumnDef::new("", column.data_type.clone()) + }) + .collect(), + ..CreateTable::new("") + }; + if *table != bare || table.columns.is_empty() { + return Err("has a clause other than unqualified, typed columns".to_owned()); + } + let name = resolved(&table.name.name)?; + if converted.relations.contains_key(&name) { + return Err(format!("creates {name} again")); + } + let mut columns: Vec = Vec::with_capacity(table.columns.len()); + let mut written: Vec = Vec::with_capacity(table.columns.len()); + for column in &table.columns { + let column_name = resolved(&column.name)?; + if columns.iter().any(|earlier| earlier.name == column_name) { + return Err(format!("declares {column_name} twice")); + } + let spelled = serde_json::to_value(&column.data_type).map_err(|error| { + format!("types {column_name} with a type that does not serialise: {error}") + })?; + let type_name = data_type_name(&spelled) + .ok_or_else(|| format!("types {column_name} with a type that has no name"))?; + let data_type = cast_target(&type_name, log)?.data_type; + if !matches!( + data_type, + SnowflakeType::Number { .. } | SnowflakeType::Varchar + ) { + return Err(format!( + "types {column_name} {type_name}, which setup does not replay" + )); + } + written.push(format!( + "{} {}", + quoted_identifier(&column_name, log), + column_type(data_type, log)? + )); + columns.push(ColumnSpec::new(column_name, data_type)); + } + let sql = format!( + "CREATE TABLE {} ({})", + quoted_identifier(&name, log), + written.join(", ") + ); + Ok((sql, name, columns)) +} + +/// `INSERT INTO name VALUES (…), …` into a relation setup created, of literals its columns hold exactly. +pub(crate) fn inserted( + insert: &Insert, + converted: &ConvertedSetup, + log: &ProvenanceLog, +) -> Result { + let Insert { + table, + columns, + values, + query: None, + overwrite: false, + partition, + directory: None, + returning, + output: None, + on_conflict: None, + leading_comments: _, + if_exists: false, + with: None, + ignore: false, + source_alias: None, + alias: None, + alias_explicit_as: false, + default_values: false, + by_name: false, + conflict_action: None, + is_replace: false, + hint: None, + replace_where: None, + source: None, + function_target: None, + partition_by: None, + settings, + } = insert + else { + return Err("has a clause other than VALUES".to_owned()); + }; + let bare_table = TableRef { + name: table.name.clone(), + ..TableRef::new("") + }; + if *table != bare_table + || !columns.is_empty() + || values.is_empty() + || !partition.is_empty() + || !returning.is_empty() + || !settings.is_empty() + { + return Err("has a clause other than VALUES into an unqualified table".to_owned()); + } + let name = resolved(&table.name)?; + let target = converted + .relations + .get(&name) + .ok_or_else(|| format!("inserts into {name}, which setup did not create"))?; + let mut rows: Vec = Vec::with_capacity(values.len()); + for row in values { + if row.len() != target.len() { + return Err(format!( + "has a row of {} values for {} columns", + row.len(), + target.len() + )); + } + let cells: Vec = row + .iter() + .zip(target) + .map(|(value, column)| cell(value, column, log)) + .collect::>()?; + rows.push(format!("({})", cells.join(", "))); + } + Ok(format!( + "INSERT INTO {} VALUES {}", + quoted_identifier(&name, log), + rows.join(", ") + )) +} + +/// One value: NULL, a string for a VARCHAR, or an unsigned number a NUMBER column holds unrounded. +fn cell(value: &Expression, column: &ColumnSpec, log: &ProvenanceLog) -> Result { + match (value, column.data_type) { + (Expression::Null(_), _) => Ok("NULL".to_owned()), + (Expression::Literal(literal), SnowflakeType::Varchar) => match literal.as_ref() { + Literal::String(text) => Ok(string_literal(text, log)), + _ => Err(format!( + "puts a value other than a string or NULL into {}", + column.name + )), + }, + (Expression::Literal(literal), SnowflakeType::Number { precision, scale }) => { + match literal.as_ref() { + Literal::Number(text) if fits(text, precision, scale) => Ok(format!( + "CAST({text} AS {})", + column_type(column.data_type, log)? + )), + _ => Err(format!( + "puts a value into {} that is not a number it holds without rounding", + column.name + )), + } + } + _ => Err(format!( + "puts a value other than a literal or NULL into {}", + column.name + )), + } +} + +/// Whether the digits `text` writes, with at most one point, fit NUMBER(precision, scale) exactly. +fn fits(text: &str, precision: u8, scale: u8) -> bool { + let (whole, fraction) = text.split_once('.').unwrap_or((text, "")); + let whole = whole.trim_start_matches('0'); + !text.is_empty() + && text.matches('.').count() <= 1 + && text + .chars() + .all(|character| character.is_ascii_digit() || character == '.') + && fraction.len() <= usize::from(scale) + && whole.len() <= usize::from(precision.saturating_sub(scale)) +} + +/// An unquoted name as Snowflake resolves it, in upper case; a quoted one refuses. +pub(crate) fn resolved(identifier: &Identifier) -> Result { + match identifier.quoted { + true => Err(format!("quotes the name {}", identifier.name)), + false => Ok(identifier.name.to_ascii_uppercase()), + } +} diff --git a/crates/sqlquack-rules/src/conversion/_helpers/setup/variables.rs b/crates/sqlquack-rules/src/conversion/_helpers/setup/variables.rs new file mode 100644 index 000000000..598c6b298 --- /dev/null +++ b/crates/sqlquack-rules/src/conversion/_helpers/setup/variables.rs @@ -0,0 +1,117 @@ +//! SET and UNSET of session variables in a case's setup, which `$name` reads. + +use std::collections::BTreeMap; + +use serde_json::Value; +use sqlquack_syntax::expressions::names::models::Literal; +use sqlquack_syntax::expressions::statements::models::{SetStatement, UnsetStatement}; +use sqlquack_syntax::expressions::tree::models::Expression; + +use crate::conversion::_helpers::setup::tables::resolved; +use crate::conversion::_helpers::support::tree::strip_positions; +use crate::conversion::constants::{NONDETERMINISTIC_FUNCTIONS, TIME_DEPENDENT_PREFIXES}; + +/// `SET name = value` or `SET (name, …) = (value, …)`: each variable recorded for `$name` to read. +pub(crate) fn assigned(set: &SetStatement) -> Result, String> { + let mut assignments: Vec<(String, Value)> = Vec::new(); + for item in &set.items { + if item.kind.is_some() || item.no_equals { + return Err("sets other than a session variable with =".to_owned()); + } + let pairs: Vec<(&Expression, &Expression)> = match (&item.name, &item.value) { + (Expression::Tuple(names), Expression::Tuple(values)) + if names.expressions.len() == values.expressions.len() => + { + names.expressions.iter().zip(&values.expressions).collect() + } + (Expression::Tuple(_), _) => { + return Err("sets a list of variables to other than as many values".to_owned()); + } + (name, value) => vec![(name, value)], + }; + for (name, value) in pairs { + let name = match name { + Expression::Identifier(identifier) => resolved(identifier)?, + Expression::Column(column) if column.table.is_none() => resolved(&column.name)?, + _ => return Err("sets a variable whose name is not a plain name".to_owned()), + }; + assignments.push((name, variable_value(value)?)); + } + } + Ok(assignments) +} + +/// `UNSET name` or `UNSET (name, …)`: the names, each a variable setup SET earlier. +pub(crate) fn unassigned( + unset: &UnsetStatement, + variables: &BTreeMap, +) -> Result, String> { + let mut names: Vec = Vec::with_capacity(unset.names.len()); + for identifier in &unset.names { + let name = resolved(identifier)?; + if !variables.contains_key(&name) || names.contains(&name) { + return Err(format!("unsets {name}, which setup did not set")); + } + names.push(name); + } + Ok(names) +} + +/// A literal, NULL or a scalar subquery whose value cannot change between the SET and its reads. +fn variable_value(value: &Expression) -> Result { + let fixed = match value { + Expression::Null(_) => true, + Expression::Literal(literal) => { + matches!(literal.as_ref(), Literal::Number(_) | Literal::String(_)) + } + Expression::Subquery(_) => true, + _ => false, + }; + if !fixed { + return Err( + "sets a variable to other than a literal, NULL or a scalar subquery".to_owned(), + ); + } + let tree = serde_json::to_value(value) + .map(|value| strip_positions(&value)) + .map_err(|error| format!("sets a value that does not serialise: {error}"))?; + match unrepeatable(&tree) { + Some(reason) => Err(format!("sets a variable to a subquery {reason}")), + None => Ok(tree), + } +} + +/// Why a value read at each `$name` could differ from the value SET computed once, if it could. +fn unrepeatable(tree: &Value) -> Option { + match tree { + Value::Object(fields) => { + if fields.contains_key("parameter") { + return Some("reading another variable or parameter".to_owned()); + } + if let Some(Value::String(name)) = fields.get("name") + && unrepeatable_name(name) + { + return Some(format!("calling {name}, whose result can change")); + } + if let Some(kind) = fields.keys().find(|kind| time_dependent(kind)) { + return Some(format!("reading {kind}, whose result can change")); + } + fields.values().find_map(unrepeatable) + } + Value::Array(items) => items.iter().find_map(unrepeatable), + _ => None, + } +} + +/// Whether a function of this name can give another result between the SET and a read. +fn unrepeatable_name(name: &str) -> bool { + NONDETERMINISTIC_FUNCTIONS.contains(&name.to_ascii_uppercase().as_str()) || time_dependent(name) +} + +/// Whether a function or node kind of this name reads the clock. +fn time_dependent(name: &str) -> bool { + let name = name.to_ascii_uppercase(); + TIME_DEPENDENT_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)) +} diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index 6ae2e58a8..b1934c3ea 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -1400,5 +1400,13 @@ pub(crate) const OUTPUT_NAMED_CAST_ENDINGS: &[&str] = &[" AS INTEGER)", "::INTEG /// The setup statements a case's DuckDB replay runs. pub(crate) const SETUP_REPLAYED: &str = "CREATE TABLE of NUMBER and VARCHAR columns, INSERT INTO it \ VALUES of literals, and SET of variables to literals or a deterministic scalar subquery"; +/// Name prefixes of functions whose result depends on when they run, so a SET differs from its reads. +pub(crate) const TIME_DEPENDENT_PREFIXES: &[&str] = &[ + "CURRENT_", + "LOCALTIME", + "SYSDATE", + "GETDATE", + "SYSTIMESTAMP", +]; /// `$name` read as the expression the case's setup SET the variable to. pub const SESSION_VARIABLE: &str = "rule.session_variable"; diff --git a/crates/sqlquack-rules/src/conversion/main/convert_setup.rs b/crates/sqlquack-rules/src/conversion/main/convert_setup.rs index 1eca86ce6..512ea77aa 100644 --- a/crates/sqlquack-rules/src/conversion/main/convert_setup.rs +++ b/crates/sqlquack-rules/src/conversion/main/convert_setup.rs @@ -1,37 +1,16 @@ //! `convert_setup`: a case's setup statements as DuckDB statements and the relations they create. -use serde_json::Value; use sqlquack_core::parsing::main::parse_source::parse_source; use sqlquack_core::parsing::models::SourceDialect; use sqlquack_core::provenance::models::ProvenanceLog; -use sqlquack_duckdb::blocks::main::column_type::column_type; -use sqlquack_duckdb::blocks::main::quoted_identifier::quoted_identifier; -use sqlquack_duckdb::blocks::main::string_literal::string_literal; -use sqlquack_semantics::types::models::ColumnSpec; -use sqlquack_snowflake::typing::main::cast_target::cast_target; -use sqlquack_snowflake::typing::models::SnowflakeType; -use sqlquack_syntax::expressions::columns::models::ColumnDef; -use sqlquack_syntax::expressions::names::models::{Identifier, Literal, TableRef}; -use sqlquack_syntax::expressions::statements::models::{Insert, SetStatement, UnsetStatement}; -use sqlquack_syntax::expressions::tables::models::CreateTable; use sqlquack_syntax::expressions::tree::models::Expression; -use crate::conversion::_helpers::expressions::casts::data_type_name; -use crate::conversion::_helpers::support::tree::strip_positions; -use crate::conversion::constants::{NONDETERMINISTIC_FUNCTIONS, SETUP_REPLAYED}; +use crate::conversion::_helpers::setup::tables::{created, inserted}; +use crate::conversion::_helpers::setup::variables::{assigned, unassigned}; +use crate::conversion::constants::SETUP_REPLAYED; use crate::conversion::models::ConvertedSetup; -/// Name prefixes of functions whose result depends on when they run, so a SET differs from its reads. -const TIME_DEPENDENT_PREFIXES: &[&str] = &[ - "CURRENT_", - "LOCALTIME", - "SYSDATE", - "GETDATE", - "SYSTIMESTAMP", -]; - -/// Each statement in order: CREATE TABLE of named, typed columns, INSERT INTO it of literal rows, -/// SET of session variables, or UNSET of them. +/// Each statement in order: CREATE TABLE, INSERT INTO it VALUES, or SET and UNSET of variables. pub fn convert_setup(statements: &[String]) -> Result { let log = ProvenanceLog::default(); let mut converted = ConvertedSetup::default(); @@ -41,17 +20,25 @@ pub fn convert_setup(statements: &[String]) -> Result { .map_err(|_| refused("does not parse"))?; let duckdb = match parsed.as_slice() { [Expression::CreateTable(table)] => { - created(table, &mut converted, &log).map_err(|reason| refused(&reason))? + let (sql, name, columns) = + created(table, &converted, &log).map_err(|reason| refused(&reason))?; + converted.relations.insert(name, columns); + sql } [Expression::Insert(insert)] => { inserted(insert, &converted, &log).map_err(|reason| refused(&reason))? } [Expression::SetStatement(set)] => { - assigned(set, &mut converted).map_err(|reason| refused(&reason))?; + let assignments = assigned(set).map_err(|reason| refused(&reason))?; + converted.variables.extend(assignments); continue; } [Expression::UnsetStatement(unset)] => { - unassigned(unset, &mut converted).map_err(|reason| refused(&reason))?; + let names = + unassigned(unset, &converted.variables).map_err(|reason| refused(&reason))?; + names.iter().for_each(|name| { + converted.variables.remove(name); + }); continue; } _ => { @@ -64,294 +51,3 @@ pub fn convert_setup(statements: &[String]) -> Result { } Ok(converted) } - -/// `CREATE TABLE name (column type, …)` and nothing else, its relation added to `converted`. -fn created( - table: &CreateTable, - converted: &mut ConvertedSetup, - log: &ProvenanceLog, -) -> Result { - let bare = CreateTable { - name: TableRef { - name: table.name.name.clone(), - ..TableRef::new("") - }, - columns: table - .columns - .iter() - .map(|column| ColumnDef { - name: column.name.clone(), - ..ColumnDef::new("", column.data_type.clone()) - }) - .collect(), - ..CreateTable::new("") - }; - if *table != bare || table.columns.is_empty() { - return Err("has a clause other than unqualified, typed columns".to_owned()); - } - let name = resolved(&table.name.name)?; - if converted.relations.contains_key(&name) { - return Err(format!("creates {name} again")); - } - let mut columns: Vec = Vec::with_capacity(table.columns.len()); - let mut written: Vec = Vec::with_capacity(table.columns.len()); - for column in &table.columns { - let column_name = resolved(&column.name)?; - if columns.iter().any(|earlier| earlier.name == column_name) { - return Err(format!("declares {column_name} twice")); - } - let type_name = serde_json::to_value(&column.data_type) - .ok() - .as_ref() - .and_then(data_type_name) - .ok_or_else(|| format!("types {column_name} with a type that has no name"))?; - let data_type = cast_target(&type_name, log)?.data_type; - if !matches!( - data_type, - SnowflakeType::Number { .. } | SnowflakeType::Varchar - ) { - return Err(format!( - "types {column_name} {type_name}, which setup does not replay" - )); - } - written.push(format!( - "{} {}", - quoted_identifier(&column_name, log), - column_type(data_type, log)? - )); - columns.push(ColumnSpec::new(column_name, data_type)); - } - let sql = format!( - "CREATE TABLE {} ({})", - quoted_identifier(&name, log), - written.join(", ") - ); - converted.relations.insert(name, columns); - Ok(sql) -} - -/// `INSERT INTO name VALUES (…), …` into a relation setup created, of literals its columns hold exactly. -fn inserted( - insert: &Insert, - converted: &ConvertedSetup, - log: &ProvenanceLog, -) -> Result { - let Insert { - table, - columns, - values, - query: None, - overwrite: false, - partition, - directory: None, - returning, - output: None, - on_conflict: None, - leading_comments: _, - if_exists: false, - with: None, - ignore: false, - source_alias: None, - alias: None, - alias_explicit_as: false, - default_values: false, - by_name: false, - conflict_action: None, - is_replace: false, - hint: None, - replace_where: None, - source: None, - function_target: None, - partition_by: None, - settings, - } = insert - else { - return Err("has a clause other than VALUES".to_owned()); - }; - let bare_table = TableRef { - name: table.name.clone(), - ..TableRef::new("") - }; - if *table != bare_table - || !columns.is_empty() - || values.is_empty() - || !partition.is_empty() - || !returning.is_empty() - || !settings.is_empty() - { - return Err("has a clause other than VALUES into an unqualified table".to_owned()); - } - let name = resolved(&table.name)?; - let target = converted - .relations - .get(&name) - .ok_or_else(|| format!("inserts into {name}, which setup did not create"))?; - let mut rows: Vec = Vec::with_capacity(values.len()); - for row in values { - if row.len() != target.len() { - return Err(format!( - "has a row of {} values for {} columns", - row.len(), - target.len() - )); - } - let cells: Vec = row - .iter() - .zip(target) - .map(|(value, column)| cell(value, column, log)) - .collect::>()?; - rows.push(format!("({})", cells.join(", "))); - } - Ok(format!( - "INSERT INTO {} VALUES {}", - quoted_identifier(&name, log), - rows.join(", ") - )) -} - -/// One value: NULL, a string for a VARCHAR, or an unsigned number a NUMBER column holds unrounded. -fn cell(value: &Expression, column: &ColumnSpec, log: &ProvenanceLog) -> Result { - match (value, column.data_type) { - (Expression::Null(_), _) => Ok("NULL".to_owned()), - (Expression::Literal(literal), SnowflakeType::Varchar) => match literal.as_ref() { - Literal::String(text) => Ok(string_literal(text, log)), - _ => Err(format!( - "puts a value other than a string or NULL into {}", - column.name - )), - }, - (Expression::Literal(literal), SnowflakeType::Number { precision, scale }) => { - match literal.as_ref() { - Literal::Number(text) if fits(text, precision, scale) => Ok(format!( - "CAST({text} AS {})", - column_type(column.data_type, log)? - )), - _ => Err(format!( - "puts a value into {} that is not a number it holds without rounding", - column.name - )), - } - } - _ => Err(format!( - "puts a value other than a literal or NULL into {}", - column.name - )), - } -} - -/// Whether the digits `text` writes, with at most one point, fit NUMBER(precision, scale) exactly. -fn fits(text: &str, precision: u8, scale: u8) -> bool { - let (whole, fraction) = text.split_once('.').unwrap_or((text, "")); - let whole = whole.trim_start_matches('0'); - !text.is_empty() - && text.matches('.').count() <= 1 - && text - .chars() - .all(|character| character.is_ascii_digit() || character == '.') - && fraction.len() <= usize::from(scale) - && whole.len() <= usize::from(precision.saturating_sub(scale)) -} - -/// `SET name = value` or `SET (name, …) = (value, …)`: each variable recorded for `$name` to read. -fn assigned(set: &SetStatement, converted: &mut ConvertedSetup) -> Result<(), String> { - for item in &set.items { - if item.kind.is_some() || item.no_equals { - return Err("sets other than a session variable with =".to_owned()); - } - let pairs: Vec<(&Expression, &Expression)> = match (&item.name, &item.value) { - (Expression::Tuple(names), Expression::Tuple(values)) - if names.expressions.len() == values.expressions.len() => - { - names.expressions.iter().zip(&values.expressions).collect() - } - (Expression::Tuple(_), _) => { - return Err("sets a list of variables to other than as many values".to_owned()); - } - (name, value) => vec![(name, value)], - }; - for (name, value) in pairs { - let name = match name { - Expression::Identifier(identifier) => resolved(identifier)?, - Expression::Column(column) if column.table.is_none() => resolved(&column.name)?, - _ => return Err("sets a variable whose name is not a plain name".to_owned()), - }; - let value = variable_value(value)?; - converted.variables.insert(name, value); - } - } - Ok(()) -} - -/// `UNSET name` or `UNSET (name, …)` of variables setup SET earlier. -fn unassigned(unset: &UnsetStatement, converted: &mut ConvertedSetup) -> Result<(), String> { - for identifier in &unset.names { - let name = resolved(identifier)?; - if converted.variables.remove(&name).is_none() { - return Err(format!("unsets {name}, which setup did not set")); - } - } - Ok(()) -} - -/// A literal, NULL or a scalar subquery whose value cannot change between the SET and its reads. -fn variable_value(value: &Expression) -> Result { - let fixed = match value { - Expression::Null(_) => true, - Expression::Literal(literal) => { - matches!(literal.as_ref(), Literal::Number(_) | Literal::String(_)) - } - Expression::Subquery(_) => true, - _ => false, - }; - if !fixed { - return Err( - "sets a variable to other than a literal, NULL or a scalar subquery".to_owned(), - ); - } - let tree = serde_json::to_value(value) - .map(|value| strip_positions(&value)) - .map_err(|error| format!("sets a value that does not serialise: {error}"))?; - match unrepeatable(&tree) { - Some(reason) => Err(format!("sets a variable to a subquery {reason}")), - None => Ok(tree), - } -} - -/// Why a value read at each `$name` could differ from the value SET computed once, if it could. -fn unrepeatable(tree: &Value) -> Option { - match tree { - Value::Object(fields) => { - if fields.contains_key("parameter") { - return Some("reading another variable or parameter".to_owned()); - } - if let Some(Value::String(name)) = fields.get("name") { - let name = name.to_ascii_uppercase(); - if NONDETERMINISTIC_FUNCTIONS.contains(&name.as_str()) - || TIME_DEPENDENT_PREFIXES - .iter() - .any(|prefix| name.starts_with(prefix)) - { - return Some(format!("calling {name}, whose result can change")); - } - } - if let Some(kind) = fields.keys().find(|kind| { - TIME_DEPENDENT_PREFIXES - .iter() - .any(|prefix| kind.to_ascii_uppercase().starts_with(prefix)) - }) { - return Some(format!("reading {kind}, whose result can change")); - } - fields.values().find_map(unrepeatable) - } - Value::Array(items) => items.iter().find_map(unrepeatable), - _ => None, - } -} - -/// An unquoted name as Snowflake resolves it, in upper case; a quoted one refuses. -fn resolved(identifier: &Identifier) -> Result { - match identifier.quoted { - true => Err(format!("quotes the name {}", identifier.name)), - false => Ok(identifier.name.to_ascii_uppercase()), - } -} diff --git a/crates/sqlquack/src/analysis/tests/helpers.rs b/crates/sqlquack/src/analysis/tests/helpers.rs index 0b24bc08a..04493b68b 100644 --- a/crates/sqlquack/src/analysis/tests/helpers.rs +++ b/crates/sqlquack/src/analysis/tests/helpers.rs @@ -695,8 +695,7 @@ pub(crate) fn relying_on_utc_session(constructs: &[&str]) -> DivergenceModeOutco } } -/// The first value a translation case with `setup` and `query` returns, converted then replayed on -/// DuckDB, or the fragment of a reason containing `expected`'s error, else every reason. +/// A setup case's first value replayed on DuckDB, or the expected reason fragment, else every reason. pub(crate) fn setup_case_outcome( (setup, query): (&[&str], &str), expected: Result<&str, &str>, @@ -735,9 +734,17 @@ pub(crate) fn setup_case_outcome( .and(fragment) .map_or_else(|| reasons.join("; "), str::to_owned) }; - let (sql, statements) = match converter.convert(&cases[0]) { - Conversion::Converted { sql, setup, .. } => (sql, setup), - Conversion::Refused { reasons, .. } => return Err(failed(reasons)), + let conversion = converter.convert(&cases[0]); + let Conversion::Converted { + sql, + setup: statements, + .. + } = conversion.clone() + else { + let Conversion::Refused { reasons, .. } = conversion else { + return Err(failed(Vec::new())); + }; + return Err(failed(reasons)); }; let connection = duckdb::Connection::open_in_memory().expect("DuckDB opens"); for statement in &statements { From 48a72045b63af576f7f3c0a6f5346338090ace27 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:56:10 +0000 Subject: [PATCH 05/11] Replay CREATE TYPE from case setup: a user-defined type resolves to its base type (D228 part 2) Setup records each CREATE TYPE name AS data_type. When the tree is built, a custom type spelled as a plain name that setup created is replaced by its base type, so casts, typing and setup CREATE TABLE columns all see the base type, as Snowflake's receipts report it. Quoted names, redefinitions, OR REPLACE, IF NOT EXISTS, qualified names, built-in type names and non-alias definitions refuse. --- .../src/conversion/_helpers/setup/mod.rs | 1 + .../src/conversion/_helpers/setup/tables.rs | 2 + .../conversion/_helpers/setup/user_types.rs | 45 ++++++++++++++++ .../src/conversion/_helpers/support/tree.rs | 49 ++++++++++++++++- .../src/conversion/constants.rs | 3 +- .../src/conversion/main/collect_refusals.rs | 3 +- .../conversion/main/convert_function_body.rs | 3 +- .../src/conversion/main/convert_setup.rs | 9 +++- .../src/conversion/main/convert_statement.rs | 2 +- .../sqlquack-rules/src/conversion/models.rs | 4 ++ crates/sqlquack/src/analysis/models.rs | 1 + .../src/analysis/tests/test_case_setup.rs | 53 ++++++++++++++++++- 12 files changed, 164 insertions(+), 11 deletions(-) create mode 100644 crates/sqlquack-rules/src/conversion/_helpers/setup/user_types.rs diff --git a/crates/sqlquack-rules/src/conversion/_helpers/setup/mod.rs b/crates/sqlquack-rules/src/conversion/_helpers/setup/mod.rs index a3f3dfdd5..92507805c 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/setup/mod.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/setup/mod.rs @@ -1,2 +1,3 @@ pub(crate) mod tables; +pub(crate) mod user_types; pub(crate) mod variables; diff --git a/crates/sqlquack-rules/src/conversion/_helpers/setup/tables.rs b/crates/sqlquack-rules/src/conversion/_helpers/setup/tables.rs index 7e887a063..5a24f0b87 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/setup/tables.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/setup/tables.rs @@ -14,6 +14,7 @@ use sqlquack_syntax::expressions::tables::models::CreateTable; use sqlquack_syntax::expressions::tree::models::Expression; use crate::conversion::_helpers::expressions::casts::data_type_name; +use crate::conversion::_helpers::support::tree::with_user_types; use crate::conversion::models::ConvertedSetup; /// `CREATE TABLE name (column type, …)` and nothing else: its DuckDB statement, name and columns. @@ -54,6 +55,7 @@ pub(crate) fn created( let spelled = serde_json::to_value(&column.data_type).map_err(|error| { format!("types {column_name} with a type that does not serialise: {error}") })?; + let spelled = with_user_types(&spelled, &converted.types); let type_name = data_type_name(&spelled) .ok_or_else(|| format!("types {column_name} with a type that has no name"))?; let data_type = cast_target(&type_name, log)?.data_type; diff --git a/crates/sqlquack-rules/src/conversion/_helpers/setup/user_types.rs b/crates/sqlquack-rules/src/conversion/_helpers/setup/user_types.rs new file mode 100644 index 000000000..912121b6b --- /dev/null +++ b/crates/sqlquack-rules/src/conversion/_helpers/setup/user_types.rs @@ -0,0 +1,45 @@ +//! CREATE TYPE of a user-defined type in a case's setup, which later statements resolve to its base type. + +use serde_json::Value; +use sqlquack_core::provenance::models::ProvenanceLog; +use sqlquack_snowflake::typing::main::cast_target::cast_target; +use sqlquack_syntax::expressions::schema_objects::models::{CreateType, TypeDefinition}; + +use crate::conversion::_helpers::expressions::casts::data_type_name; +use crate::conversion::_helpers::setup::tables::resolved; +use crate::conversion::_helpers::support::tree::{strip_positions, with_user_types}; +use crate::conversion::models::ConvertedSetup; + +/// `CREATE TYPE name AS data_type`: the resolved name and the serialised base type it stands for. +pub(crate) fn defined( + create: &CreateType, + converted: &ConvertedSetup, +) -> Result<(String, Value), String> { + let TypeDefinition::Alias { data_type, .. } = &create.definition else { + return Err("defines a type other than an alias of a data type".to_owned()); + }; + if create.or_replace + || create.if_not_exists + || create.name.schema.is_some() + || create.name.catalog.is_some() + { + return Err("has a clause other than an unqualified name AS a data type".to_owned()); + } + let name = resolved(&create.name.name)?; + if converted.types.contains_key(&name) { + return Err(format!("creates the type {name} again")); + } + match cast_target(&name, &ProvenanceLog::default()) { + Ok(_) => { + return Err(format!( + "creates the type {name}, which names a built-in type" + )); + } + Err(_unknown) => {} + } + let base = serde_json::to_value(data_type) + .map(|spelled| with_user_types(&strip_positions(&spelled), &converted.types)) + .map_err(|error| format!("defines {name} as a type that does not serialise: {error}"))?; + data_type_name(&base).ok_or_else(|| format!("defines {name} as a type that has no name"))?; + Ok((name, base)) +} diff --git a/crates/sqlquack-rules/src/conversion/_helpers/support/tree.rs b/crates/sqlquack-rules/src/conversion/_helpers/support/tree.rs index 32afd251b..957ae7048 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/support/tree.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/support/tree.rs @@ -1,6 +1,8 @@ //! Reading the serialised source tree: node kinds, payload fields and refusals. use crate::conversion::_helpers::support::collecting::{collecting, events, record, skipped}; +use std::collections::BTreeMap; + use serde_json::{Map, Value}; use sqlquack_core::classification::constants::{ PROJECT_FUNCTION_CONSTRUCT, PROJECT_FUNCTION_PLACEHOLDER, @@ -11,6 +13,8 @@ use sqlquack_core::refusals::models::Refusal; use sqlquack_semantics::dialects::constants::SNOWFLAKE; use sqlquack_semantics::dialects::main::identifier_name::identifier_name as resolved_identifier; +use crate::conversion::models::ConversionOptions; + /// Fields that only record how the source was spelled or where it was. const COSMETIC_FIELDS: &[&str] = &[ "span", @@ -193,14 +197,55 @@ pub(crate) fn integer_literal(node: &Value) -> Option { } /// The tree conversion reads, positions stripped and quoted identifiers folded if case is ignored. -pub(crate) fn session_tree(positioned: &Value, ignore_quoted_case: bool) -> Result { +pub(crate) fn session_tree( + positioned: &Value, + options: &ConversionOptions, +) -> Result { let mut tree = strip_positions(positioned); - if ignore_quoted_case { + if options.quoted_identifiers_ignore_case { fold_quoted(&mut tree)?; } + if !options.user_types.is_empty() { + tree = with_user_types(&tree, &options.user_types); + } Ok(tree) } +/// `value` with each type named for a user-defined type replaced by the base type it stands for. +pub(crate) fn with_user_types(value: &Value, types: &BTreeMap) -> Value { + if let Some(base) = user_type(value, types) { + return base.clone(); + } + match value { + Value::Object(fields) => Value::Object( + fields + .iter() + .map(|(key, field)| (key.clone(), with_user_types(field, types))) + .collect(), + ), + Value::Array(items) => Value::Array( + items + .iter() + .map(|item| with_user_types(item, types)) + .collect(), + ), + other => other.clone(), + } +} + +/// The base type of a custom type spelled as a plain name that setup CREATE TYPE'd. +fn user_type<'a>(value: &Value, types: &'a BTreeMap) -> Option<&'a Value> { + let fields = value.as_object()?; + if fields.get("data_type").and_then(Value::as_str) != Some("custom") { + return None; + } + let name = fields.get("name")?.as_str()?; + let plain = name + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_'); + plain.then(|| types.get(&name.to_ascii_uppercase()))? +} + /// Upper-cases the name of each quoted identifier under `value`. fn fold_quoted(value: &mut Value) -> Result<(), String> { match value { diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index b1934c3ea..48d270392 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -1399,7 +1399,8 @@ pub(crate) const OUTPUT_NAMED_ARITHMETIC: &[&str] = &["add", "sub", "mul", "div" pub(crate) const OUTPUT_NAMED_CAST_ENDINGS: &[&str] = &[" AS INTEGER)", "::INTEGER"]; /// The setup statements a case's DuckDB replay runs. pub(crate) const SETUP_REPLAYED: &str = "CREATE TABLE of NUMBER and VARCHAR columns, INSERT INTO it \ - VALUES of literals, and SET of variables to literals or a deterministic scalar subquery"; + VALUES of literals, CREATE TYPE of an alias of a data type, and SET of variables to literals \ + or a deterministic scalar subquery"; /// Name prefixes of functions whose result depends on when they run, so a SET differs from its reads. pub(crate) const TIME_DEPENDENT_PREFIXES: &[&str] = &[ "CURRENT_", diff --git a/crates/sqlquack-rules/src/conversion/main/collect_refusals.rs b/crates/sqlquack-rules/src/conversion/main/collect_refusals.rs index 40f650a3a..710f48b13 100644 --- a/crates/sqlquack-rules/src/conversion/main/collect_refusals.rs +++ b/crates/sqlquack-rules/src/conversion/main/collect_refusals.rs @@ -71,8 +71,7 @@ fn collected_conversion( positioned: &Value, (schema, options, lines): (&Schema, &ConversionOptions, &SourceLines), ) -> (ConvertedQuery, Collector, Option) { - let tree = session_tree(positioned, options.quoted_identifiers_ignore_case) - .unwrap_or_else(|_| strip_positions(positioned)); + let tree = session_tree(positioned, options).unwrap_or_else(|_| strip_positions(positioned)); let locator = Locator::new(&tree, positioned, lines); let log = ProvenanceLog::default(); let typed = TypedLeaves::default(); diff --git a/crates/sqlquack-rules/src/conversion/main/convert_function_body.rs b/crates/sqlquack-rules/src/conversion/main/convert_function_body.rs index f09e84c65..5bc9770f1 100644 --- a/crates/sqlquack-rules/src/conversion/main/convert_function_body.rs +++ b/crates/sqlquack-rules/src/conversion/main/convert_function_body.rs @@ -28,8 +28,7 @@ pub fn convert_function_body( return Err(refused("only SQL function bodies convert")); } let positioned = positioned(query)?; - let tree = session_tree(&positioned, options.quoted_identifiers_ignore_case) - .map_err(|reason| refused(&reason))?; + let tree = session_tree(&positioned, options).map_err(|reason| refused(&reason))?; let locator = Locator::new(&tree, &positioned, lines); let typed = TypedLeaves::default(); let tracks = StatementTracks::default(); diff --git a/crates/sqlquack-rules/src/conversion/main/convert_setup.rs b/crates/sqlquack-rules/src/conversion/main/convert_setup.rs index 512ea77aa..69dfcd9a6 100644 --- a/crates/sqlquack-rules/src/conversion/main/convert_setup.rs +++ b/crates/sqlquack-rules/src/conversion/main/convert_setup.rs @@ -6,11 +6,12 @@ use sqlquack_core::provenance::models::ProvenanceLog; use sqlquack_syntax::expressions::tree::models::Expression; use crate::conversion::_helpers::setup::tables::{created, inserted}; +use crate::conversion::_helpers::setup::user_types::defined; use crate::conversion::_helpers::setup::variables::{assigned, unassigned}; use crate::conversion::constants::SETUP_REPLAYED; use crate::conversion::models::ConvertedSetup; -/// Each statement in order: CREATE TABLE, INSERT INTO it VALUES, or SET and UNSET of variables. +/// Each statement in order: CREATE TABLE, INSERT INTO it VALUES, CREATE TYPE, or SET and UNSET of variables. pub fn convert_setup(statements: &[String]) -> Result { let log = ProvenanceLog::default(); let mut converted = ConvertedSetup::default(); @@ -28,6 +29,12 @@ pub fn convert_setup(statements: &[String]) -> Result { [Expression::Insert(insert)] => { inserted(insert, &converted, &log).map_err(|reason| refused(&reason))? } + [Expression::CreateType(create)] => { + let (name, base) = + defined(create, &converted).map_err(|reason| refused(&reason))?; + converted.types.insert(name, base); + continue; + } [Expression::SetStatement(set)] => { let assignments = assigned(set).map_err(|reason| refused(&reason))?; converted.variables.extend(assignments); diff --git a/crates/sqlquack-rules/src/conversion/main/convert_statement.rs b/crates/sqlquack-rules/src/conversion/main/convert_statement.rs index cd900df80..652fba551 100644 --- a/crates/sqlquack-rules/src/conversion/main/convert_statement.rs +++ b/crates/sqlquack-rules/src/conversion/main/convert_statement.rs @@ -33,7 +33,7 @@ pub fn convert_statement( log: ProvenanceLog, ) -> Result { let positioned = positioned(statement)?; - let tree = session_tree(&positioned, options.quoted_identifiers_ignore_case) + let tree = session_tree(&positioned, options) .map_err(|reason| outside_conditions(node_construct("statement"), reason))?; let locator = Locator::new(&tree, &positioned, lines); let typed = TypedLeaves::default(); diff --git a/crates/sqlquack-rules/src/conversion/models.rs b/crates/sqlquack-rules/src/conversion/models.rs index 2f7593ecb..f1e39c651 100644 --- a/crates/sqlquack-rules/src/conversion/models.rs +++ b/crates/sqlquack-rules/src/conversion/models.rs @@ -48,6 +48,8 @@ pub struct ConversionOptions { pub verified_overrides: BTreeMap, /// Session variables a case's setup SET, by upper-case name, with the expression each was set to. pub session_variables: BTreeMap, + /// Types a case's setup CREATE TYPE'd, by upper-case name, with the base type each stands for. + pub user_types: BTreeMap, } /// A user's DuckDB SQL standing in for a Snowflake function, written in SQLBuild's FUNCTION() format. @@ -222,4 +224,6 @@ pub struct ConvertedSetup { pub statements: Vec, /// Each session variable SET, by its resolved name, with the expression it was set to. pub variables: BTreeMap, + /// Each user-defined type CREATE TYPE'd, by its resolved name, with its serialised base type. + pub types: BTreeMap, } diff --git a/crates/sqlquack/src/analysis/models.rs b/crates/sqlquack/src/analysis/models.rs index 002201716..6e5c7ed2f 100644 --- a/crates/sqlquack/src/analysis/models.rs +++ b/crates/sqlquack/src/analysis/models.rs @@ -88,6 +88,7 @@ impl CaseConverter for PipelineConverter { .get(QUOTED_IDENTIFIERS_IGNORE_CASE) == Some(&SessionValue::Boolean(true)), session_variables: setup.variables.clone(), + user_types: setup.types.clone(), ..self.options.clone() }; for definition in &case.overrides { diff --git a/crates/sqlquack/src/analysis/tests/test_case_setup.rs b/crates/sqlquack/src/analysis/tests/test_case_setup.rs index 0ec820a7a..f9770be69 100644 --- a/crates/sqlquack/src/analysis/tests/test_case_setup.rs +++ b/crates/sqlquack/src/analysis/tests/test_case_setup.rs @@ -182,10 +182,59 @@ fn given_setup_tables_when_converting_a_case_then_duckdb_replays_them_or_it_refu expected_outcome: Err("whose result can change"), }, SetupCaseTestCase { - description: "a user-defined type", + description: "a cast to a user-defined type named in another case casts to its base type", + setup: &["CREATE TYPE Age AS NUMBER(3,0)"], + query: "SELECT (a + 9)::AGE AS r FROM input", + expected_outcome: Ok("10"), + }, + SetupCaseTestCase { + description: "a cast to a user-defined type keeps its base type's scale", + setup: &["CREATE TYPE price AS NUMBER(5,2)"], + query: "SELECT CAST(a AS price)::VARCHAR AS r FROM input", + expected_outcome: Ok("1.00"), + }, + SetupCaseTestCase { + description: "setup table columns of a user-defined type add as its base type", + setup: &[ + "CREATE TYPE age AS NUMBER(3,0)", + "CREATE TABLE t (a age, b age)", + "INSERT INTO t VALUES (10, 20)", + ], + query: "SELECT a + b AS n FROM t", + expected_outcome: Ok("30"), + }, + SetupCaseTestCase { + description: "a user-defined type defined as another resolves to the first's base type", + setup: &["CREATE TYPE a1 AS NUMBER(3,0)", "CREATE TYPE a2 AS a1"], + query: "SELECT a::a2 AS r FROM input", + expected_outcome: Ok("1"), + }, + SetupCaseTestCase { + description: "a quoted lower-case type name names no type setup created", setup: &["CREATE TYPE age AS NUMBER(3,0)"], + query: "SELECT a::\"age\" AS r FROM input", + expected_outcome: Err("unknown Snowflake type `\"age\"`"), + }, + SetupCaseTestCase { + description: "a user-defined type created twice", + setup: &[ + "CREATE TYPE age AS NUMBER(3,0)", + "CREATE TYPE age AS VARCHAR", + ], + query: "SELECT 1 AS k", + expected_outcome: Err("creates the type AGE again"), + }, + SetupCaseTestCase { + description: "a user-defined type replacing another", + setup: &["CREATE OR REPLACE TYPE age AS NUMBER(3,0)"], + query: "SELECT 1 AS k", + expected_outcome: Err("has a clause other than an unqualified name AS a data type"), + }, + SetupCaseTestCase { + description: "a user-defined type named for a built-in type", + setup: &["CREATE TYPE number AS VARCHAR"], query: "SELECT 1 AS k", - expected_outcome: Err("is not replayed on DuckDB"), + expected_outcome: Err("which names a built-in type"), }, ]; for test_case in test_cases { From cf5fe549e7a3ce449301e3411cc340df0785a32a Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:01:55 +0000 Subject: [PATCH 06/11] docs: what case setup verify replays on DuckDB, and what it refuses --- docs/architecture.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index f34d23515..629195117 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -529,7 +529,20 @@ schema; any other `{{...}}`, or a placeholder in a case without setup, is reject every named object inside the run database), and runs setup and query in a fresh session and case schema, dropping the run database afterwards. A failing setup statement is a case-file error, not an outcome. -- Replay: verify does not replay setup on DuckDB yet, so every setup case refuses. +- Replay: verify replays setup on DuckDB statement by statement, in order, and refuses the whole + case at the first statement it cannot replay: + - `CREATE TABLE` of an unqualified, unquoted name with typed `NUMBER` and `VARCHAR` columns and + no other clause, created once. `INSERT INTO` it `VALUES` of literals or NULL, one per column, + that the column holds without rounding. + - `SET` of session variables to a literal, NULL or a deterministic scalar subquery, and `UNSET` + of a variable setup set. `$name` then reads the value; a variable setup did not set refuses. + - `CREATE TYPE name AS data_type`. A type spelled as that plain name, in either case, resolves + to its base type in casts, typing and later setup columns, as Snowflake reports it. A quoted + name, a redefinition, `OR REPLACE`, `IF NOT EXISTS`, a qualified name, a built-in type's name + and any other kind of type definition refuse. A cast to a structured type stays refused. + - `CREATE VIEW`, `CREATE SEMANTIC VIEW` and `ALTER SESSION` are not replayed. The parser keeps + `CREATE SEMANTIC VIEW` only as raw text, and `SEMANTIC_VIEW(…)` drops the clause order that + decides its column order. ### Hashes From 5e3a016ad598f50b2ec7531c02b021360f5b0f78 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:31:28 +0000 Subject: [PATCH 07/11] cases: the session-variables setup cases, byte for byte from 487be66c (D306) Only cases/translation/expressions/session-variables.toml, which rule.session_variable needs to verify; its obligations stay with C's later landing. --- .../expressions/session-variables.toml | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 cases/translation/expressions/session-variables.toml diff --git a/cases/translation/expressions/session-variables.toml b/cases/translation/expressions/session-variables.toml new file mode 100644 index 000000000..a7f681246 --- /dev/null +++ b/cases/translation/expressions/session-variables.toml @@ -0,0 +1,70 @@ +category = "translation" +construct = "sf.expressions.session-variables" +session_profile = "utc-v1" + +[[case]] +id = "t001_between" +setup = ["SET (min, max) = (40, 70)"] +description = "The page's example: two variables set in one statement bound a BETWEEN, so 40, 55 and 70 are kept. Catches: the variables read as text so the comparison is lexical, or only the first of the pair set" +columns = [{ name = "a", type = "NUMBER(38,0)" }] +rows = [ + ["30"], + ["40"], + ["55"], + ["70"], + ["80"], +] +query = "SELECT ordinal, a FROM input WHERE a BETWEEN $min AND $max ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t002_types" +setup = ["SET my_variable1 = 10", "SET my_variable2 = 'example'"] +description = "The page's SET my_variable1 = 10 and SET my_variable2 = 'example': a number adds to a column and the text concatenates; the type comes from the expression. Catches: the number variable read as text (concatenated), or the text quoted twice" +columns = [{ name = "a", type = "NUMBER(38,0)" }] +rows = [ + ["1"], + ["2"], +] +query = "SELECT ordinal, a + $my_variable1 AS n, $my_variable2 || '-' || a AS s FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t003_case_insensitive" +setup = ["SET MyVar = 5"] +description = "The page: SQL variables are identified by case-insensitive names. Catches: the name matched case-sensitively, so $myvar is unknown" +columns = [{ name = "a", type = "NUMBER(38,0)" }] +rows = [ + ["1"], +] +query = "SELECT ordinal, $myvar + a AS r1, $MYVAR AS r2 FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t004_from_query" +setup = ["CREATE TABLE sv_src (k NUMBER(38,0))", "INSERT INTO sv_src VALUES (3), (7)", "SET top = (SELECT MAX(k) FROM sv_src)"] +description = "The page: a variable can be set from a query that returns a single result; here MAX over a table the setup creates, which is 7. Catches: the subquery stored as text, or evaluated per row" +columns = [{ name = "a", type = "NUMBER(38,0)" }] +rows = [ + ["1"], + ["2"], +] +query = "SELECT ordinal, a * $top AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t005_null" +setup = ["SET n = 10"] +description = "A NULL column added to a variable; the page gives no special rule, so standard NULL handling gives NULL. Catches: the NULL replaced by the variable" +columns = [{ name = "a", type = "NUMBER(38,0)" }] +rows = [ + ["NULL"], + ["2"], +] +query = "SELECT ordinal, a + $n AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["null"] From 7439a3433f3a5eb6e3a73c897c27b3bf9c6504fe Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:03:19 +0000 Subject: [PATCH 08/11] evidence: the session-variables receipts, byte for byte from 7a9436d9 (D306) The ten result files of the five session-variables cases, as A recorded them; nothing else from that commit. --- ...046303da6cceb986c226186d3d117c1fd3fc.arrow | Bin 0 -> 1658 bytes ...3046303da6cceb986c226186d3d117c1fd3fc.json | 70 ++++++++++++++++ ...970660db8691980a23263d1d0aa7dd6649b9.arrow | Bin 0 -> 1858 bytes ...8970660db8691980a23263d1d0aa7dd6649b9.json | 79 ++++++++++++++++++ ...abc1ba68a0448f96dccfe8e9207ed6500423.arrow | Bin 0 -> 1658 bytes ...fabc1ba68a0448f96dccfe8e9207ed6500423.json | 70 ++++++++++++++++ ...d9b1795a1ca52179a1b3b80e56944bc840ab.arrow | Bin 0 -> 1714 bytes ...cd9b1795a1ca52179a1b3b80e56944bc840ab.json | 70 ++++++++++++++++ ...ced96d406bd8b2b73cf5e5b3c8209d0b08d1.arrow | Bin 0 -> 2386 bytes ...5ced96d406bd8b2b73cf5e5b3c8209d0b08d1.json | 79 ++++++++++++++++++ 10 files changed, 368 insertions(+) create mode 100644 evidence/results/13884601cc8671b15a30f7b82af3046303da6cceb986c226186d3d117c1fd3fc.arrow create mode 100644 evidence/results/13884601cc8671b15a30f7b82af3046303da6cceb986c226186d3d117c1fd3fc.json create mode 100644 evidence/results/24b0b834ddee7ef57b784a5483c8970660db8691980a23263d1d0aa7dd6649b9.arrow create mode 100644 evidence/results/24b0b834ddee7ef57b784a5483c8970660db8691980a23263d1d0aa7dd6649b9.json create mode 100644 evidence/results/bbd0b6d5c2947d8c048f7c838d9fabc1ba68a0448f96dccfe8e9207ed6500423.arrow create mode 100644 evidence/results/bbd0b6d5c2947d8c048f7c838d9fabc1ba68a0448f96dccfe8e9207ed6500423.json create mode 100644 evidence/results/c41db8d3b834f40ed37d606f43dcd9b1795a1ca52179a1b3b80e56944bc840ab.arrow create mode 100644 evidence/results/c41db8d3b834f40ed37d606f43dcd9b1795a1ca52179a1b3b80e56944bc840ab.json create mode 100644 evidence/results/fbd897b012ca7103bc253917c405ced96d406bd8b2b73cf5e5b3c8209d0b08d1.arrow create mode 100644 evidence/results/fbd897b012ca7103bc253917c405ced96d406bd8b2b73cf5e5b3c8209d0b08d1.json diff --git a/evidence/results/13884601cc8671b15a30f7b82af3046303da6cceb986c226186d3d117c1fd3fc.arrow b/evidence/results/13884601cc8671b15a30f7b82af3046303da6cceb986c226186d3d117c1fd3fc.arrow new file mode 100644 index 0000000000000000000000000000000000000000..aa49494bb0cee0bc024ce9d53791a933c3edecef GIT binary patch literal 1658 zcmeHIJx>Bb5S>HdPQ4Igh@rHww7_U#OM*rcBIZJ(g$;ImKIDhyR$QI_T%mBUB&Yn*GD2Dk4$QUDjW;sYUzyol^dNqKC=>y8xRQKIqWn9o}c0LkFj4 zHIb9Qap?m4ol7heKI?pRm~l^6uB*6}jV!oc)gmjVq}OF_-|86e*$D6SCo#C_9#<$c zHgQI1$H&IU-iW0Eb|LccJhXU^E?CLq#4bjBf2_6>v6L literal 0 HcmV?d00001 diff --git a/evidence/results/13884601cc8671b15a30f7b82af3046303da6cceb986c226186d3d117c1fd3fc.json b/evidence/results/13884601cc8671b15a30f7b82af3046303da6cceb986c226186d3d117c1fd3fc.json new file mode 100644 index 000000000..d088912af --- /dev/null +++ b/evidence/results/13884601cc8671b15a30f7b82af3046303da6cceb986c226186d3d117c1fd3fc.json @@ -0,0 +1,70 @@ +{ + "arrow_sha256": "2b33cad361c439d9b93284e3f31ceedb601951cbc182301e3656f9487cc02876", + "case_hash": "13884601cc8671b15a30f7b82af3046303da6cceb986c226186d3d117c1fd3fc", + "columns": [ + { + "internal_size": null, + "name": "ORDINAL", + "nullable": false, + "precision": 1, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "R", + "nullable": false, + "precision": 38, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": null, + "format": 1, + "outcome": "rows", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "f855dca6a43e5ad65b15b011186e6d0df1d2910b629dc95e8bf8f4038d2d7a30", + "recorded_at": "2026-11-06T10:55:33.372381+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 2, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.40.102" +} diff --git a/evidence/results/24b0b834ddee7ef57b784a5483c8970660db8691980a23263d1d0aa7dd6649b9.arrow b/evidence/results/24b0b834ddee7ef57b784a5483c8970660db8691980a23263d1d0aa7dd6649b9.arrow new file mode 100644 index 0000000000000000000000000000000000000000..afeda3673f44551c2713cb560047be35afa95893 GIT binary patch literal 1858 zcmeHIJxc>Y5PkW&obiw#L9E0wg+-!`Z6pQ)V&D`^Dw zGG$6j8$I9K%gU0dq!ltSw==VIv-4)=_9|i6xF`b=={*VLA<7|uhYWm5q(quJsQG-R zXHOtS?KJ^Rj1{PTMiHHp1?nTRyyJKPLnGYR+rS;UN!}w%fBuLf`AKFtFdeLs`?u{@ zXVB^0P@d99apjXC*XqsVD%W*#w|CWPU3Slh{WkKX7)!uvc&t`HWSR?2IY$^p qBgJR;UYI|qy!quJeukVaq&V9xFJHG(FGyV(! literal 0 HcmV?d00001 diff --git a/evidence/results/24b0b834ddee7ef57b784a5483c8970660db8691980a23263d1d0aa7dd6649b9.json b/evidence/results/24b0b834ddee7ef57b784a5483c8970660db8691980a23263d1d0aa7dd6649b9.json new file mode 100644 index 000000000..ccd2f2007 --- /dev/null +++ b/evidence/results/24b0b834ddee7ef57b784a5483c8970660db8691980a23263d1d0aa7dd6649b9.json @@ -0,0 +1,79 @@ +{ + "arrow_sha256": "6211da9c7116465900ceee9326582960a796c5f0551a73a13187e7c36a9b1dcb", + "case_hash": "24b0b834ddee7ef57b784a5483c8970660db8691980a23263d1d0aa7dd6649b9", + "columns": [ + { + "internal_size": null, + "name": "ORDINAL", + "nullable": false, + "precision": 1, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "R1", + "nullable": false, + "precision": 38, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "R2", + "nullable": false, + "precision": 1, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": null, + "format": 1, + "outcome": "rows", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "d102de7bae72224eeddaf1ec8450fc4e0af90c57f04542fbc64dbfd0415fe2bd", + "recorded_at": "2026-11-06T10:55:30.422116+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 1, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.40.102" +} diff --git a/evidence/results/bbd0b6d5c2947d8c048f7c838d9fabc1ba68a0448f96dccfe8e9207ed6500423.arrow b/evidence/results/bbd0b6d5c2947d8c048f7c838d9fabc1ba68a0448f96dccfe8e9207ed6500423.arrow new file mode 100644 index 0000000000000000000000000000000000000000..643198b1e3a14e47bab41b1ed1be73947fb2c8d3 GIT binary patch literal 1658 zcmeHIJxc>Y5S@IVdWcAnlol2i77+z)4`MJ7jD&y|HZh#ZK}bT9!hk;{WqyLCMT(S` zVrAiP@qBM@k6e}$?QAkIyE{8?c4yzt-Z}Mp?dDj-hdd`mc7(DiwqzwE7DkXn8n(c) z(57`O0=QfzI3x1@<@@N8c{F*<5gDw7aEu*j7WxXEL7$)v=n<6hHsXorn0Wd&pbuWJ z*=7$Yn5e zTi(d)_b@jHhG%8Ij4fAgFG}dQpxu7k!%`}1OSZv9PVoU(g1m_ANr-;jTD?@ca;l2MH`p_%$>BBb@HY;}1}2Wfsey^(a2|t+a`)Bo{aKTxlZ`saKDI_cuM literal 0 HcmV?d00001 diff --git a/evidence/results/bbd0b6d5c2947d8c048f7c838d9fabc1ba68a0448f96dccfe8e9207ed6500423.json b/evidence/results/bbd0b6d5c2947d8c048f7c838d9fabc1ba68a0448f96dccfe8e9207ed6500423.json new file mode 100644 index 000000000..f633cc8dc --- /dev/null +++ b/evidence/results/bbd0b6d5c2947d8c048f7c838d9fabc1ba68a0448f96dccfe8e9207ed6500423.json @@ -0,0 +1,70 @@ +{ + "arrow_sha256": "0efde51f4fd73ab72f98aa18ec214acaa8a75e3eaa82a0a9c5cacadd1c73e332", + "case_hash": "bbd0b6d5c2947d8c048f7c838d9fabc1ba68a0448f96dccfe8e9207ed6500423", + "columns": [ + { + "internal_size": null, + "name": "ORDINAL", + "nullable": false, + "precision": 1, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "A", + "nullable": false, + "precision": 38, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": null, + "format": 1, + "outcome": "rows", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "93cc17d5c869175a1441d66636f162fba88de57cc3c785be91b6c1965358e023", + "recorded_at": "2026-11-06T10:55:25.153484+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 3, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.40.102" +} diff --git a/evidence/results/c41db8d3b834f40ed37d606f43dcd9b1795a1ca52179a1b3b80e56944bc840ab.arrow b/evidence/results/c41db8d3b834f40ed37d606f43dcd9b1795a1ca52179a1b3b80e56944bc840ab.arrow new file mode 100644 index 0000000000000000000000000000000000000000..13d95edd80d599b62e75a248cf7537b269b05142 GIT binary patch literal 1714 zcmeHIF;Buk82yR`DmI3g#yC1UIAB~{NzjCZh-pZ4U_nx$2>}8OlK4Xy`56ujj*N~D z{s@18^}V~EHnm1a2fpOp-FNryySsPawN>A5UZ03WuyszPEVPoiQjokjXu&KSzQA)( z+HolYy0k5k1JojWs2$8IDsEXk^w6xFhtdO&vvqv*qU{3(U=1vR2cQKU12*8kM-lmK zlh3hj;4U0?2CexxK)aIS;3#U(xAR%h2!_2`AM;Y2O3dl@I}^jX&*0?2n|3<`uCqvt z^W3LrdN1Qi&<&^IXo$WQI6N!&%efczoAVm#9bho(g$Si~?Z_^)C@MWLWKkERpM29# zlzxY?evD5m0!_bGzpOTthR^VqKw85q-0N=|&QhE#4Hqd+mWHbgPEy0y)Hv7SeI_+@ zkUh*WYVtQN-3%>tK0n4WP$BAmoYAhzPua3n-s;>XqXb{C&75xo)Qxv-jQ9GJ8J@mN zov-9F2F;9{cG5R}teNLDD<*vM_yA#$Fk;QPE)*)@^e#L-Yzxv-jsWv!Ug7{x=%am| eSNoect+k>|n>Ve$oyqUwe|ghyxS*=~SA784cHCP4 literal 0 HcmV?d00001 diff --git a/evidence/results/c41db8d3b834f40ed37d606f43dcd9b1795a1ca52179a1b3b80e56944bc840ab.json b/evidence/results/c41db8d3b834f40ed37d606f43dcd9b1795a1ca52179a1b3b80e56944bc840ab.json new file mode 100644 index 000000000..0386adbd1 --- /dev/null +++ b/evidence/results/c41db8d3b834f40ed37d606f43dcd9b1795a1ca52179a1b3b80e56944bc840ab.json @@ -0,0 +1,70 @@ +{ + "arrow_sha256": "8baaec0911aba4ac2275ec94a23da7d247c74b526dacf5d808e41f8fc43cf83d", + "case_hash": "c41db8d3b834f40ed37d606f43dcd9b1795a1ca52179a1b3b80e56944bc840ab", + "columns": [ + { + "internal_size": null, + "name": "ORDINAL", + "nullable": false, + "precision": 1, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "R", + "nullable": true, + "precision": 38, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": null, + "format": 1, + "outcome": "rows", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "32d4f4b3f5844c043a7c6fd8ca3f40b3b44a6281c0ad5145499a1ea0afb58c17", + "recorded_at": "2026-11-06T10:55:36.548233+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 2, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.40.102" +} diff --git a/evidence/results/fbd897b012ca7103bc253917c405ced96d406bd8b2b73cf5e5b3c8209d0b08d1.arrow b/evidence/results/fbd897b012ca7103bc253917c405ced96d406bd8b2b73cf5e5b3c8209d0b08d1.arrow new file mode 100644 index 0000000000000000000000000000000000000000..d09f54f0d3de7d5491955a31e94527e7f3064c8d GIT binary patch literal 2386 zcmeHJy-or_5FYUOQ7?uVVk|8!G!Zn>mIO^m2pA-h!UoByB#1;PFyRq=016A^6Zil; zfTgjr-~oID3-$cIz1w)MXRx$jlDXa4`F3{a+u4O&rBXi3h`5lK8IhDw=ERn`#3YFx zBx1o9SV?>>|0@Ex45A`C5sr%7y}K@&SQ?)dj1ld{LkRQ_pmFFk^d5Q*J%X-5Ie+}- zy2P_jd=)wiJ!!Vj-a-dZ2Go4nm>=9TeI{QU1_np{gQu#2VeaHI$WK26 z6Xa(Y@c+io2pHzd+&OozP}|L;HTg-(0%|9r>Id!!>qul#meoi?)nZE07*V45)Ah#q zlmxU~$rnnwqT=uo_AS)pa36I)iNl_O3FB~RV8S>Yg5S|?0Eyp$=|rN zfi=1G?)$4B0}Y}s`8VEH{K||4zHztdR5AzKTbs4PyG!19)>_E2LwdgNo@ht>3G+HICX#C%*Xh^KJ1u8DqdQqIGLDFEoXXwC3Bd*$XRG$3DmdiL~9^|<13OyAV+zd6%4_50iWpWoCJE__b?m-+-T CI9e6} literal 0 HcmV?d00001 diff --git a/evidence/results/fbd897b012ca7103bc253917c405ced96d406bd8b2b73cf5e5b3c8209d0b08d1.json b/evidence/results/fbd897b012ca7103bc253917c405ced96d406bd8b2b73cf5e5b3c8209d0b08d1.json new file mode 100644 index 000000000..e20acd5f2 --- /dev/null +++ b/evidence/results/fbd897b012ca7103bc253917c405ced96d406bd8b2b73cf5e5b3c8209d0b08d1.json @@ -0,0 +1,79 @@ +{ + "arrow_sha256": "276fc0d7dc67906b5b9b0ba020722d745ec7a2d130de102424360eced84fd25d", + "case_hash": "fbd897b012ca7103bc253917c405ced96d406bd8b2b73cf5e5b3c8209d0b08d1", + "columns": [ + { + "internal_size": null, + "name": "ORDINAL", + "nullable": false, + "precision": 1, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "N", + "nullable": false, + "precision": 38, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": 134217728, + "name": "S", + "nullable": false, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": null, + "format": 1, + "outcome": "rows", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "dcc443029698ffe59fe78974bcefb6f43f48f1be303bddbc8eb36bcf942e6278", + "recorded_at": "2026-11-06T10:55:28.193715+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 2, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.40.102" +} From 58e1094f21c1350a3ca1fd856db6340371ffd188 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:51:21 +0000 Subject: [PATCH 09/11] Refuse a setup write after a SET from a subquery (D228 review round 1) DuckDB recomputes the subquery at each $name read, while Snowflake computed it once at SET time, so a later CREATE TABLE or INSERT could change the value. Such a write now refuses while the variable is set. A user-defined type's name must also parse as a custom type, so GEOGRAPHY and other names SQLQuack does not convert refuse too. --- .../conversion/_helpers/setup/user_types.rs | 39 ++++++++++++++++--- .../src/conversion/main/convert_setup.rs | 19 +++++++++ .../src/analysis/tests/test_case_setup.rs | 28 +++++++++++++ docs/architecture.md | 2 + 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/crates/sqlquack-rules/src/conversion/_helpers/setup/user_types.rs b/crates/sqlquack-rules/src/conversion/_helpers/setup/user_types.rs index 912121b6b..ea43df73a 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/setup/user_types.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/setup/user_types.rs @@ -1,6 +1,8 @@ //! CREATE TYPE of a user-defined type in a case's setup, which later statements resolve to its base type. use serde_json::Value; +use sqlquack_core::parsing::main::parse_source::parse_source; +use sqlquack_core::parsing::models::SourceDialect; use sqlquack_core::provenance::models::ProvenanceLog; use sqlquack_snowflake::typing::main::cast_target::cast_target; use sqlquack_syntax::expressions::schema_objects::models::{CreateType, TypeDefinition}; @@ -29,12 +31,10 @@ pub(crate) fn defined( if converted.types.contains_key(&name) { return Err(format!("creates the type {name} again")); } + let built_in = format!("creates the type {name}, which names a built-in type"); match cast_target(&name, &ProvenanceLog::default()) { - Ok(_) => { - return Err(format!( - "creates the type {name}, which names a built-in type" - )); - } + Ok(_) => return Err(built_in), + Err(_unknown) if !parses_as_custom(&name) => return Err(built_in), Err(_unknown) => {} } let base = serde_json::to_value(data_type) @@ -43,3 +43,32 @@ pub(crate) fn defined( data_type_name(&base).ok_or_else(|| format!("defines {name} as a type that has no name"))?; Ok((name, base)) } + +/// Whether `CAST(NULL AS name)` parses to a custom type of that name, so the name is no built-in type. +fn parses_as_custom(name: &str) -> bool { + let source = format!("SELECT CAST(NULL AS {name})"); + let tree = match parse_source(&source, SourceDialect::Snowflake).map(serde_json::to_value) { + Ok(Ok(tree)) => tree, + Ok(Err(_unserialisable)) => return false, + Err(_unparsed) => return false, + }; + cast_target_type(&tree).is_some_and(|target| { + target.get("data_type").and_then(Value::as_str) == Some("custom") + && target + .get("name") + .and_then(Value::as_str) + .is_some_and(|written| written.eq_ignore_ascii_case(name)) + }) +} + +/// The target type of the first cast in a serialised tree. +fn cast_target_type(tree: &Value) -> Option<&Value> { + match tree { + Value::Object(fields) => fields + .get("cast") + .and_then(|cast| cast.get("to")) + .or_else(|| fields.values().find_map(cast_target_type)), + Value::Array(items) => items.iter().find_map(cast_target_type), + _ => None, + } +} diff --git a/crates/sqlquack-rules/src/conversion/main/convert_setup.rs b/crates/sqlquack-rules/src/conversion/main/convert_setup.rs index 69dfcd9a6..70e4113fd 100644 --- a/crates/sqlquack-rules/src/conversion/main/convert_setup.rs +++ b/crates/sqlquack-rules/src/conversion/main/convert_setup.rs @@ -19,6 +19,16 @@ pub fn convert_setup(statements: &[String]) -> Result { let refused = |reason: &str| format!("setup statement `{statement}` {reason}"); let parsed = parse_source(statement, SourceDialect::Snowflake) .map_err(|_| refused("does not parse"))?; + let writes = matches!( + parsed.as_slice(), + [Expression::CreateTable(_) | Expression::Insert(_)] + ); + if let Some(name) = writes.then(|| computed_variable(&converted)).flatten() { + return Err(refused(&format!( + "writes a table after SET {name} from a subquery, which Snowflake computed once \ + before the write but $name would compute after it" + ))); + } let duckdb = match parsed.as_slice() { [Expression::CreateTable(table)] => { let (sql, name, columns) = @@ -58,3 +68,12 @@ pub fn convert_setup(statements: &[String]) -> Result { } Ok(converted) } + +/// A variable still SET from a subquery, which DuckDB reads by computing the subquery again. +fn computed_variable(converted: &ConvertedSetup) -> Option<&str> { + converted + .variables + .iter() + .find(|(_, value)| value.get("subquery").is_some()) + .map(|(name, _)| name.as_str()) +} diff --git a/crates/sqlquack/src/analysis/tests/test_case_setup.rs b/crates/sqlquack/src/analysis/tests/test_case_setup.rs index f9770be69..70c5b9966 100644 --- a/crates/sqlquack/src/analysis/tests/test_case_setup.rs +++ b/crates/sqlquack/src/analysis/tests/test_case_setup.rs @@ -145,6 +145,28 @@ fn given_setup_tables_when_converting_a_case_then_duckdb_replays_them_or_it_refu query: "SELECT $top AS n", expected_outcome: Ok("7"), }, + SetupCaseTestCase { + description: "a write after a SET from a subquery, which Snowflake computed before it", + setup: &[ + "CREATE TABLE t (k NUMBER(38,0))", + "INSERT INTO t VALUES (1)", + "SET n = (SELECT COUNT(*) FROM t)", + "INSERT INTO t VALUES (2)", + ], + query: "SELECT $n AS n", + expected_outcome: Err("writes a table after SET N from a subquery"), + }, + SetupCaseTestCase { + description: "a write after the variable set from a subquery is unset", + setup: &[ + "CREATE TABLE t (k NUMBER(38,0))", + "SET n = (SELECT COUNT(*) FROM t)", + "UNSET n", + "INSERT INTO t VALUES (2)", + ], + query: "SELECT k FROM t", + expected_outcome: Ok("2"), + }, SetupCaseTestCase { description: "a variable the setup did not set", setup: &["SET n = 10"], @@ -230,6 +252,12 @@ fn given_setup_tables_when_converting_a_case_then_duckdb_replays_them_or_it_refu query: "SELECT 1 AS k", expected_outcome: Err("has a clause other than an unqualified name AS a data type"), }, + SetupCaseTestCase { + description: "a user-defined type named for a built-in type SQLQuack does not convert", + setup: &["CREATE TYPE geography AS NUMBER(3,0)"], + query: "SELECT 1 AS k", + expected_outcome: Err("which names a built-in type"), + }, SetupCaseTestCase { description: "a user-defined type named for a built-in type", setup: &["CREATE TYPE number AS VARCHAR"], diff --git a/docs/architecture.md b/docs/architecture.md index 629195117..93fe1a60c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -536,6 +536,8 @@ schema; any other `{{...}}`, or a placeholder in a case without setup, is reject that the column holds without rounding. - `SET` of session variables to a literal, NULL or a deterministic scalar subquery, and `UNSET` of a variable setup set. `$name` then reads the value; a variable setup did not set refuses. + DuckDB computes a subquery's value at each read, so a `CREATE TABLE` or `INSERT` while a + variable set from a subquery is still set refuses, since Snowflake computed it before the write. - `CREATE TYPE name AS data_type`. A type spelled as that plain name, in either case, resolves to its base type in casts, typing and later setup columns, as Snowflake reports it. A quoted name, a redefinition, `OR REPLACE`, `IF NOT EXISTS`, a qualified name, a built-in type's name From 9d24a0dcbaabe4e4671f58d3577402cf79ea5788 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:07:10 +0000 Subject: [PATCH 10/11] Declare obligations for the session-variables cases SET of one and of two variables read in a BETWEEN, a number and a text variable, case-insensitive names, a variable set from a query, and a NULL column beside a variable. --- .../translation/obligations/expressions.toml | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/coverage/translation/obligations/expressions.toml b/coverage/translation/obligations/expressions.toml index 1c02957a2..66e4c2b2f 100644 --- a/coverage/translation/obligations/expressions.toml +++ b/coverage/translation/obligations/expressions.toml @@ -2851,3 +2851,58 @@ catches = "NULL matched to NULL" reading = "operators-subquery: expr op ALL/ANY (query) and [NOT] IN (query) compare against every value the subquery returns, in a WHERE clause; IN is = ANY and NOT IN is != ALL; [NOT] EXISTS is TRUE when the subquery returns rows (or none)" cases = ["cases/translation/expressions/operators-subquery.in.toml#t004_null_expr"] required = true + +[[obligation]] +id = "sf.expressions.session-variables.t001" +form = "sf.expressions.session-variables" +kind = "type_combination" +target = "SET (min, max) = (40, 70) and BETWEEN $min AND $max" +note = "The page's example: two variables set in one statement bound a BETWEEN, so 40, 55 and 70 are kept." +catches = "the variables read as text so the comparison is lexical, or only the first of the pair set" +reading = "the SQL variables page: SET = or SET (, ) = (, ) sets session variables, read as $ wherever a literal constant is allowed; the type comes from the expression; names are case-insensitive" +cases = ["cases/translation/expressions/session-variables.toml#t001_between"] +required = true + +[[obligation]] +id = "sf.expressions.session-variables.t002" +form = "sf.expressions.session-variables" +kind = "type_combination" +target = "a number and a text variable" +note = "The page's SET my_variable1 = 10 and SET my_variable2 = 'example': a number adds to a column and the text concatenates; the type comes from the expression." +catches = "the number variable read as text (concatenated), or the text quoted twice" +reading = "the SQL variables page: SET = or SET (, ) = (, ) sets session variables, read as $ wherever a literal constant is allowed; the type comes from the expression; names are case-insensitive" +cases = ["cases/translation/expressions/session-variables.toml#t002_types"] +required = true + +[[obligation]] +id = "sf.expressions.session-variables.t003" +form = "sf.expressions.session-variables" +kind = "type_combination" +target = "SET MyVar, read as $myvar and $MYVAR" +note = "The page: SQL variables are identified by case-insensitive names." +catches = "the name matched case-sensitively, so $myvar is unknown" +reading = "the SQL variables page: SET = or SET (, ) = (, ) sets session variables, read as $ wherever a literal constant is allowed; the type comes from the expression; names are case-insensitive" +cases = ["cases/translation/expressions/session-variables.toml#t003_case_insensitive"] +required = true + +[[obligation]] +id = "sf.expressions.session-variables.t004" +form = "sf.expressions.session-variables" +kind = "type_combination" +target = "SET v = (SELECT ...)" +note = "The page: a variable can be set from a query that returns a single result; here MAX over a table the setup creates, which is 7." +catches = "the subquery stored as text, or evaluated per row" +reading = "the SQL variables page: SET = or SET (, ) = (, ) sets session variables, read as $ wherever a literal constant is allowed; the type comes from the expression; names are case-insensitive" +cases = ["cases/translation/expressions/session-variables.toml#t004_from_query"] +required = true + +[[obligation]] +id = "sf.expressions.session-variables.t005" +form = "sf.expressions.session-variables" +kind = "null_argument" +target = "NULL column beside a variable" +note = "A NULL column added to a variable; the page gives no special rule, so standard NULL handling gives NULL." +catches = "the NULL replaced by the variable" +reading = "the SQL variables page: SET = or SET (, ) = (, ) sets session variables, read as $ wherever a literal constant is allowed; the type comes from the expression; names are case-insensitive" +cases = ["cases/translation/expressions/session-variables.toml#t005_null"] +required = true From 98bdfd06d741677a27d8b9efac26b292003f7809 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:38:31 +0000 Subject: [PATCH 11/11] Re-record the translation results baseline with the session-variables cases (D228) sf.expressions.session-variables moves from unmeasured to matching, with its five obligations met and five statements matching; nothing else changes. --- coverage/translation/results-baseline.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/coverage/translation/results-baseline.json b/coverage/translation/results-baseline.json index fd328a67b..2c729b81e 100644 --- a/coverage/translation/results-baseline.json +++ b/coverage/translation/results-baseline.json @@ -8659,6 +8659,11 @@ "translation/expressions/operators-subquery.in.toml#t002_not_in": "matching", "translation/expressions/operators-subquery.in.toml#t003_null_in_subquery": "matching", "translation/expressions/operators-subquery.in.toml#t004_null_expr": "matching", + "translation/expressions/session-variables.toml#t001_between": "matching", + "translation/expressions/session-variables.toml#t002_types": "matching", + "translation/expressions/session-variables.toml#t003_case_insensitive": "matching", + "translation/expressions/session-variables.toml#t004_from_query": "matching", + "translation/expressions/session-variables.toml#t005_null": "matching", "translation/functions/abs.toml#t001_int": "matching", "translation/functions/abs.toml#t002_dec": "matching", "translation/functions/abs.toml#t003_float": "matching", @@ -13200,7 +13205,7 @@ "sf.expressions.operators-subquery.exists": "matching", "sf.expressions.operators-subquery.in": "refused", "sf.expressions.schema-variables": "not_translatable", - "sf.expressions.session-variables": "unmeasured", + "sf.expressions.session-variables": "matching", "sf.expressions.snowflake-db-classes": "not_translatable", "sf.functions.abs": "matching", "sf.functions.accumulate": "refused", @@ -15620,6 +15625,11 @@ "sf.expressions.operators-subquery.in.t002": "met", "sf.expressions.operators-subquery.in.t003": "met", "sf.expressions.operators-subquery.in.t004": "met", + "sf.expressions.session-variables.t001": "met", + "sf.expressions.session-variables.t002": "met", + "sf.expressions.session-variables.t003": "met", + "sf.expressions.session-variables.t004": "met", + "sf.expressions.session-variables.t005": "met", "sf.functions.abs.t001": "met", "sf.functions.abs.t002": "met", "sf.functions.abs.t003": "met",