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"] 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 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", 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/_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/_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..92507805c --- /dev/null +++ b/crates/sqlquack-rules/src/conversion/_helpers/setup/mod.rs @@ -0,0 +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 new file mode 100644 index 000000000..5a24f0b87 --- /dev/null +++ b/crates/sqlquack-rules/src/conversion/_helpers/setup/tables.rs @@ -0,0 +1,213 @@ +//! 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::_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. +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 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; + 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/user_types.rs b/crates/sqlquack-rules/src/conversion/_helpers/setup/user_types.rs new file mode 100644 index 000000000..ea43df73a --- /dev/null +++ b/crates/sqlquack-rules/src/conversion/_helpers/setup/user_types.rs @@ -0,0 +1,74 @@ +//! 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}; + +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")); + } + let built_in = format!("creates the type {name}, which names a built-in type"); + match cast_target(&name, &ProvenanceLog::default()) { + 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) + .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)) +} + +/// 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/_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/_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 c0a81f28c..48d270392 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. @@ -1998,3 +1397,17 @@ 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, INSERT INTO it \ + 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_", + "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/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 new file mode 100644 index 000000000..70e4113fd --- /dev/null +++ b/crates/sqlquack-rules/src/conversion/main/convert_setup.rs @@ -0,0 +1,79 @@ +//! `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_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, CREATE TYPE, or SET and UNSET of variables. +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 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) = + 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::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); + continue; + } + [Expression::UnsetStatement(unset)] => { + let names = + unassigned(unset, &converted.variables).map_err(|reason| refused(&reason))?; + names.iter().for_each(|name| { + converted.variables.remove(name); + }); + continue; + } + _ => { + return Err(refused(&format!( + "is not replayed on DuckDB: only {SETUP_REPLAYED} are" + ))); + } + }; + converted.statements.push(duckdb); + } + 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-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/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..f1e39c651 100644 --- a/crates/sqlquack-rules/src/conversion/models.rs +++ b/crates/sqlquack-rules/src/conversion/models.rs @@ -46,6 +46,10 @@ 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, + /// 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. @@ -210,3 +214,16 @@ 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, + /// 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-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; 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 f8d8e63cd..6e5c7ed2f 100644 --- a/crates/sqlquack/src/analysis/models.rs +++ b/crates/sqlquack/src/analysis/models.rs @@ -19,14 +19,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; @@ -70,12 +69,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(), @@ -85,6 +87,8 @@ impl CaseConverter for PipelineConverter { .session_parameters .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 { @@ -100,7 +104,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 { @@ -122,8 +126,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), @@ -145,9 +155,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..04493b68b 100644 --- a/crates/sqlquack/src/analysis/tests/helpers.rs +++ b/crates/sqlquack/src/analysis/tests/helpers.rs @@ -694,3 +694,69 @@ pub(crate) fn relying_on_utc_session(constructs: &[&str]) -> DivergenceModeOutco categories: vec![], } } + +/// 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>, +) -> 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 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 { + 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..70c5b9966 --- /dev/null +++ b/crates/sqlquack/src/analysis/tests/test_case_setup.rs @@ -0,0 +1,282 @@ +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 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 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"], + 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("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 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 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"], + query: "SELECT 1 AS k", + expected_outcome: Err("which names a built-in type"), + }, + ]; + 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>, +} diff --git a/docs/architecture.md b/docs/architecture.md index f34d23515..93fe1a60c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -529,7 +529,22 @@ 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. + 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 + 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 diff --git a/evidence/results/13884601cc8671b15a30f7b82af3046303da6cceb986c226186d3d117c1fd3fc.arrow b/evidence/results/13884601cc8671b15a30f7b82af3046303da6cceb986c226186d3d117c1fd3fc.arrow new file mode 100644 index 000000000..aa49494bb Binary files /dev/null and b/evidence/results/13884601cc8671b15a30f7b82af3046303da6cceb986c226186d3d117c1fd3fc.arrow differ 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 000000000..afeda3673 Binary files /dev/null and b/evidence/results/24b0b834ddee7ef57b784a5483c8970660db8691980a23263d1d0aa7dd6649b9.arrow differ 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 000000000..643198b1e Binary files /dev/null and b/evidence/results/bbd0b6d5c2947d8c048f7c838d9fabc1ba68a0448f96dccfe8e9207ed6500423.arrow differ 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 000000000..13d95edd8 Binary files /dev/null and b/evidence/results/c41db8d3b834f40ed37d606f43dcd9b1795a1ca52179a1b3b80e56944bc840ab.arrow differ 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 000000000..d09f54f0d Binary files /dev/null and b/evidence/results/fbd897b012ca7103bc253917c405ced96d406bd8b2b73cf5e5b3c8209d0b08d1.arrow differ 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" +}