diff --git a/cases/relational/unaliased_output_names.toml b/cases/relational/unaliased_output_names.toml new file mode 100644 index 000000000..67b2ec409 --- /dev/null +++ b/cases/relational/unaliased_output_names.toml @@ -0,0 +1,66 @@ +# The names Snowflake gives select items without an alias: the first thing a reader types, as in +# SELECT COUNT(*) FROM orders. The recorded column names are the receipts for naming them. +category = "relational" +construct = "node:alias" +session_profile = "utc-v1" + +[[case]] +id = "unaliased_aggregates" +description = "Unaliased aggregates, written in upper, lower and mixed case and with inner spaces, over rows with NULLs and duplicates." +columns = [{ name = "g", type = "VARCHAR" }, { name = "n", type = "NUMBER(10,2)" }] +rows = [ + ["'a'", "1.50"], + ["'a'", "1.50"], + ["'b'", "NULL"], + ["NULL", "-2.25"], +] +query = "SELECT COUNT(*), count(n), Count( DISTINCT g ), SUM(n), max(g), avg( n ) FROM input" +order = "ordered" +tags = ["null", "duplicates"] + +[[case]] +id = "unaliased_group_count" +description = "A grouped column beside an unaliased COUNT(*), as an exploratory query writes it." +columns = [{ name = "g", type = "VARCHAR" }] +rows = [ + ["'a'"], + ["'a'"], + ["NULL"], +] +query = "SELECT g, COUNT(*) FROM input GROUP BY g ORDER BY g" +order = "ordered" +tags = ["null", "duplicates"] + +[[case]] +id = "unaliased_over_no_rows" +description = "Unaliased aggregates over no rows still name their columns." +columns = [{ name = "n", type = "NUMBER(10,2)" }] +rows = [ + ["1.00"], +] +query = "SELECT COUNT(*), SUM(n) FROM input WHERE n > 5" +order = "ordered" +tags = ["empty-input"] + +[[case]] +id = "unaliased_literals" +description = "Unaliased literals: integers, a negative, a decimal, an exponent, text in mixed case, NULL and booleans." +columns = [{ name = "k", type = "NUMBER(38,0)" }] +rows = [ + ["1"], +] +query = "SELECT 1, -2, 1.50, 1e3, 'MiXeD text', NULL, TRUE, false FROM input" +order = "ordered" +tags = ["null"] + +[[case]] +id = "unaliased_expressions" +description = "Unaliased arithmetic with and without spaces, function calls, casts, a qualified and a quoted column, and an expression across two lines." +columns = [{ name = "g", type = "VARCHAR" }, { name = "n", type = "NUMBER(10,2)" }] +rows = [ + ["'Ab'", "2.00"], + ["NULL", "NULL"], +] +query = "SELECT n + 1, n*2, lower(g), UPPER( g ), g || 'x', CAST(n AS INTEGER), n::INTEGER, COALESCE(n, 0), input.n - 1, \"N\" / 2, n\n + 3 FROM input ORDER BY ordinal" +order = "ordered" +tags = ["null"] diff --git a/coverage/translation/results-baseline.json b/coverage/translation/results-baseline.json index 041fe9187..c891a50dd 100644 --- a/coverage/translation/results-baseline.json +++ b/coverage/translation/results-baseline.json @@ -6713,6 +6713,11 @@ "relational/subqueries.toml#scalar_subquery_empty": "matching", "relational/subqueries.toml#select_distinct": "matching", "relational/subqueries.toml#where_filters": "matching", + "relational/unaliased_output_names.toml#unaliased_aggregates": "matching", + "relational/unaliased_output_names.toml#unaliased_expressions": "matching", + "relational/unaliased_output_names.toml#unaliased_group_count": "matching", + "relational/unaliased_output_names.toml#unaliased_literals": "matching", + "relational/unaliased_output_names.toml#unaliased_over_no_rows": "matching", "relational/union_by_name.toml#union_all_by_name_reordered": "matching", "relational/union_by_name.toml#union_by_name_case_insensitive": "matching", "relational/union_by_name.toml#union_by_name_chain": "matching", @@ -11698,7 +11703,7 @@ "translation/query/with.select.syntax.toml#t001_recursive": "refused", "translation/query/with.select.syntax.toml#t002_hierarchy": "refused", "translation/query/with.select.syntax.toml#t003_null_stop": "refused", - "translation/query/with.select.toml#t001_chained": "refused", + "translation/query/with.select.toml#t001_chained": "matching", "translation/query/with.select.toml#t002_null_group": "matching", "typing/array_agg.toml#array_agg_empty": "shape_only", "typing/array_agg.toml#array_agg_ordered": "shape_only", diff --git a/crates/sqlquack-core/src/diagnostics/constants.rs b/crates/sqlquack-core/src/diagnostics/constants.rs index 1757bdcd4..d150544c6 100644 --- a/crates/sqlquack-core/src/diagnostics/constants.rs +++ b/crates/sqlquack-core/src/diagnostics/constants.rs @@ -10,6 +10,11 @@ pub const CASE_ONLY_MATCH: &str = "only without regard to case"; pub const CASE_ONLY_MATCH_HELP: &str = "Snowflake folds unquoted names to upper case, so declare \ the relation or column in the schema as the SQL resolves it (`ORDERS` for `FROM orders`), or \ quote the name in the SQL to keep its case"; +/// The words of an unresolved name's reason when two result columns share a name. +pub const REPEATED_RESULT_NAME: &str = "repeats, possibly differing only in case"; +/// The help for such a name, replacing SQ0004's own. +pub const REPEATED_RESULT_NAME_HELP: &str = + "give each result column its own alias, such as `SELECT 1 AS first, 1 AS second`"; /// The words of a refusal reading a TIMESTAMP_TZ's local time, which needs the offset DuckDB drops. pub const ZONED_LOCAL_TIME: &str = "which reads the local time of an offset DuckDB's TIMESTAMPTZ drops"; @@ -168,11 +173,14 @@ pub const CODES: &[CodeEntry] = &[ help: Some("add an alias, e.g. `AS one`"), explanation: "Snowflake names an unaliased select item after the expression's text, and \ a converted query must return the same column names. SQLQuack emits Snowflake's name \ - only for the expression shapes whose names are verified against recorded results; \ - the expression at the span is not one of them, so the statement is refused rather \ - than converted with a different column name. An alias fixes the name in both \ + only for the shapes recorded results verify, named by their text upper-cased as \ + written: literals, columns and COUNT, SUM, MAX, AVG, LOWER, UPPER and COALESCE of \ + columns, a column plus, minus, times, divided by or concatenated with a literal, and \ + a column cast to INTEGER, written with single spaces and at most one line break. \ + Other items, such as paths, CASE, nested calls or other functions, are refused \ + rather than converted with a different column name. An alias fixes the name in both \ engines.", - example: r#"echo "SELECT 'a' || 'b'" | sqlquack convert --builtin-policy"#, + example: r#"echo "SELECT 'café' || 'b'" | sqlquack convert --builtin-policy"#, }, CodeEntry { code: "SQ0101", diff --git a/crates/sqlquack-core/src/diagnostics/main/diagnose.rs b/crates/sqlquack-core/src/diagnostics/main/diagnose.rs index 200f1c349..30a8ffe0b 100644 --- a/crates/sqlquack-core/src/diagnostics/main/diagnose.rs +++ b/crates/sqlquack-core/src/diagnostics/main/diagnose.rs @@ -6,8 +6,9 @@ use crate::diagnostics::_helpers::coded::coded; use crate::diagnostics::_helpers::line_index::LineIndex; use crate::diagnostics::constants::{ CASE_ONLY_MATCH, CASE_ONLY_MATCH_HELP, CONVERSION_BUDGET, DIVERGENCE_REFUSED, GUARD_PREFIX, - MISSING_RULE, NOT_EXPRESSIBLE, PARSE_FIDELITY, TYPE_CONDITION, UNPARSED_PREFIX, - UNRESOLVED_NAME, ZONED_LOCAL_TIME, ZONED_LOCAL_TIME_HELP, ZONED_LOCAL_TIME_NOTE, + MISSING_RULE, NOT_EXPRESSIBLE, PARSE_FIDELITY, REPEATED_RESULT_NAME, REPEATED_RESULT_NAME_HELP, + TYPE_CONDITION, UNPARSED_PREFIX, UNRESOLVED_NAME, ZONED_LOCAL_TIME, ZONED_LOCAL_TIME_HELP, + ZONED_LOCAL_TIME_NOTE, }; use crate::diagnostics::models::Diagnostic; use crate::refusals::models::{Refusal, RefusalCategory}; @@ -25,6 +26,11 @@ pub fn diagnose(refusals: &[Refusal], source: &str) -> Vec { { diagnostic.help = Some(CASE_ONLY_MATCH_HELP.to_owned()); } + if refusal.category == RefusalCategory::UnresolvedName + && refusal.reason.contains(REPEATED_RESULT_NAME) + { + diagnostic.help = Some(REPEATED_RESULT_NAME_HELP.to_owned()); + } if refusal.category == RefusalCategory::TypeCondition && refusal.reason.contains(ZONED_LOCAL_TIME) { diff --git a/crates/sqlquack-core/src/parsing/models.rs b/crates/sqlquack-core/src/parsing/models.rs index a4cd2df1d..808fefe2a 100644 --- a/crates/sqlquack-core/src/parsing/models.rs +++ b/crates/sqlquack-core/src/parsing/models.rs @@ -53,6 +53,7 @@ pub struct SourceLines { line_starts: Vec, length: usize, shift: usize, + source: String, } impl SourceLines { @@ -75,9 +76,19 @@ impl SourceLines { line_starts, length, shift, + source: source.to_owned(), } } + /// The source text at `location`, a location these lines gave. + pub fn text(&self, location: &SourceLocation) -> String { + self.source + .chars() + .skip(location.start) + .take(location.end.saturating_sub(location.start)) + .collect() + } + /// The location of the characters `start..end` of the parsed text. pub fn location(&self, start: usize, end: usize) -> SourceLocation { let start = start.saturating_sub(self.shift).min(self.length); diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index 33d1e44a1..956582135 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -194,7 +194,7 @@ pub const RULES: &[RuleSpec] = &[ RuleSpec { id: ALIAS, constructs: &["node:alias"], - conditions: "A single name.", + conditions: "A single name. An unaliased select item that is not a column takes Snowflake's name for it: its text upper-cased as written, spacing, line breaks and quotes kept, as recorded; text outside ASCII or holding a comment is refused with SQ0009.", failures: &[], }, RuleSpec { @@ -384,7 +384,7 @@ pub const RULES: &[RuleSpec] = &[ "fn:DATE_PART", "node:extract", ], - conditions: "Every branch an untyped NULL, in a conditional, in every branch of a set operation, or as the operand of DATE_PART, EXTRACT and the single-part functions: the result is NULL of Snowflake's untyped NULL type, described as VARCHAR(0), held as a DuckDB VARCHAR, as recorded.", + conditions: "Every branch an untyped NULL, in a conditional, in every branch of a set operation, as the operand of DATE_PART, EXTRACT and the single-part functions, or as a result column of the statement: the result is NULL of Snowflake's untyped NULL type, described as VARCHAR(0), held as a DuckDB VARCHAR, as recorded.", failures: &[], }, RuleSpec { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/queries/mod.rs b/crates/sqlquack-rules/src/conversion/_helpers/queries/mod.rs index 17a5569da..8cda66712 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/queries/mod.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/queries/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod flatten; pub(crate) mod function_body; pub(crate) mod group_all; pub(crate) mod hierarchy; +pub(crate) mod output_names; pub(crate) mod pivot; pub(crate) mod query; pub(crate) mod set_operations; diff --git a/crates/sqlquack-rules/src/conversion/_helpers/queries/output_names.rs b/crates/sqlquack-rules/src/conversion/_helpers/queries/output_names.rs new file mode 100644 index 000000000..b4904c54e --- /dev/null +++ b/crates/sqlquack-rules/src/conversion/_helpers/queries/output_names.rs @@ -0,0 +1,154 @@ +//! The unaliased select items whose column names Snowflake receipts record. + +use serde_json::Value; + +use crate::conversion::_helpers::support::tree::{ + LITERAL_NUMBER, LITERAL_STRING, list, node_kind, unexpected_fields, +}; +use crate::conversion::constants::{ + OUTPUT_NAMED_AGGREGATES, OUTPUT_NAMED_ARITHMETIC, OUTPUT_NAMED_CAST_ENDINGS, + OUTPUT_NAMED_FUNCTIONS, +}; + +/// Whether an unaliased item written as `text` has a shape whose name receipts record. +pub(crate) fn recorded_name_shape(item: &Value, text: &str) -> bool { + let Some((kind, payload)) = node_kind(item) else { + return false; + }; + let Some(fields) = payload.as_object() else { + return matches!(kind, "null"); + }; + let only = |understood: &[&str]| unexpected_fields(fields, understood).is_empty(); + let named = |names: &[&str]| { + payload + .get("name") + .and_then(Value::as_str) + .is_some_and(|name| names.contains(&name.to_ascii_uppercase().as_str())) + }; + let arguments = list(fields, "args"); + match kind { + "aggregate_function" => { + only(&["name", "args", "distinct"]) + && named(OUTPUT_NAMED_AGGREGATES) + && arguments.len() == 1 + && arguments.iter().all(|argument| { + plain_column(argument) + || (named(&["COUNT"]) && matches!(node_kind(argument), Some(("star", _)))) + }) + } + "function" => { + only(&["name", "args"]) + && named(OUTPUT_NAMED_FUNCTIONS) + && !arguments.is_empty() + && arguments + .iter() + .all(|argument| plain_column(argument) || integer_literal(argument)) + } + "concat" => { + only(&["left", "right"]) + && payload.get("left").is_some_and(plain_column) + && payload.get("right").is_some_and(string_literal) + } + "cast" => { + let upper = text.to_ascii_uppercase(); + only(&["this", "to", "double_colon_syntax"]) + && payload.get("this").is_some_and(plain_column) + && OUTPUT_NAMED_CAST_ENDINGS + .iter() + .any(|ending| upper.ends_with(ending)) + } + "neg" => { + only(&["this"]) + && payload.get("this").is_some_and(integer_literal) + && text.strip_prefix('-').is_some_and(|rest| { + !rest.is_empty() && rest.bytes().all(|byte| byte.is_ascii_digit()) + }) + } + "literal" => recorded_literal(item), + "null" | "boolean" => true, + arithmetic if OUTPUT_NAMED_ARITHMETIC.contains(&arithmetic) => { + only(&["left", "right"]) + && payload.get("left").is_some_and(plain_column) + && payload.get("right").is_some_and(integer_literal) + } + _ => false, + } +} + +/// A column, qualified or not, each part unquoted or quoted and already upper case. +fn plain_column(node: &Value) -> bool { + let Some(("column", payload)) = node_kind(node) else { + return false; + }; + let Some(fields) = payload.as_object() else { + return false; + }; + let upper_or_unquoted = |part: &Value| { + let quoted = part.get("quoted").and_then(Value::as_bool) == Some(true); + let name = part.get("name").and_then(Value::as_str).unwrap_or_default(); + !name.is_empty() && (!quoted || name == name.to_ascii_uppercase()) + }; + unexpected_fields(fields, &["name", "table"]).is_empty() + && payload.get("name").is_some_and(upper_or_unquoted) + && payload + .get("table") + .filter(|table| !table.is_null()) + .is_none_or(upper_or_unquoted) +} + +/// A literal spelled as a receipt records: an integer, a decimal, an exponent or plain text. +fn recorded_literal(node: &Value) -> bool { + let Some(("literal", payload)) = node_kind(node) else { + return false; + }; + let value = payload + .get("value") + .and_then(Value::as_str) + .unwrap_or_default(); + match payload.get("literal_type").and_then(Value::as_str) { + Some(LITERAL_NUMBER) => { + let (mantissa, exponent) = match value.split_once('e') { + Some((mantissa, exponent)) => (mantissa, Some(exponent)), + None => (value, None), + }; + let (whole, fraction) = match mantissa.split_once('.') { + Some((whole, fraction)) => (whole, Some(fraction)), + None => (mantissa, None), + }; + let digits = |text: &str| !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit()); + let leading_zero = whole.starts_with('0'); + digits(whole) + && (whole.len() == 1 || !leading_zero) + && fraction.is_none_or(digits) + && exponent + .is_none_or(|exponent| fraction.is_none() && !leading_zero && digits(exponent)) + } + Some(LITERAL_STRING) => string_literal(node), + _ => false, + } +} + +fn integer_literal(node: &Value) -> bool { + recorded_literal(node) + && node_kind(node) + .and_then(|(_, payload)| payload.get("value")) + .and_then(Value::as_str) + .is_some_and(|value| value.bytes().all(|byte| byte.is_ascii_digit())) +} + +/// Non-empty printable ASCII text without a quote or backslash, which has one spelling. +fn string_literal(node: &Value) -> bool { + let Some(("literal", payload)) = node_kind(node) else { + return false; + }; + payload.get("literal_type").and_then(Value::as_str) == Some(LITERAL_STRING) + && payload + .get("value") + .and_then(Value::as_str) + .is_some_and(|value| { + !value.is_empty() + && value + .bytes() + .all(|byte| (b' '..=b'~').contains(&byte) && byte != b'\'' && byte != b'\\') + }) +} diff --git a/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs b/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs index 04ecd87cc..9f31153c1 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs @@ -2,7 +2,9 @@ use serde_json::{Map, Value, json}; use sqlquack_core::classification::models::{Construct, ConstructFamily}; -use sqlquack_core::diagnostics::constants::UNALIASED_OUTPUT_NAME_CONSTRUCT; +use sqlquack_core::diagnostics::constants::{ + REPEATED_RESULT_NAME, UNALIASED_OUTPUT_NAME_CONSTRUCT, +}; use sqlquack_core::provenance::constants::QUERY_CONSTRUCT; use sqlquack_core::provenance::models::ShapeArgument; use sqlquack_core::refusals::main::missing::missing; @@ -20,14 +22,15 @@ use crate::conversion::_helpers::queries::clauses::{ }; use crate::conversion::_helpers::queries::group_all::group_all_positions; use crate::conversion::_helpers::queries::hierarchy::hierarchy; +use crate::conversion::_helpers::queries::output_names::recorded_name_shape; use crate::conversion::_helpers::queries::set_operations::convert_set_operation; use crate::conversion::_helpers::queries::sources::convert_from; use crate::conversion::_helpers::support::scope::read_column; use crate::conversion::_helpers::support::scope::{Context, Emitted, Scope}; use crate::conversion::_helpers::support::site::{Placement, Site}; use crate::conversion::_helpers::support::tree::{ - Payload, field, flag, function_construct, identifier_name, list, node_construct, node_kind, - payload_object, require_understood, + LITERAL_NUMBER, LITERAL_STRING, Payload, field, flag, function_construct, identifier_name, + list, node_construct, node_kind, payload_object, require_understood, }; use crate::conversion::constants::WINDOW_MARKER; use crate::conversion::constants::{ @@ -622,14 +625,30 @@ fn convert_projection( } Some((kind, _)) => { let emitted = convert_held(item, site)?; - if named { - let refusal = missing( - Construct::new(ConstructFamily::Syntax, UNALIASED_OUTPUT_NAME_CONSTRUCT), - format!("Snowflake names this `{kind}` column after its text"), - ); - return Err(site.context.locator.placed(refusal, item)); + if !named { + return Ok(vec![(None, emitted)]); + } + let written = site + .context + .locator + .source_text(item) + .or_else(|| literal_text(item)); + match written + .filter(|text| recorded_name_shape(item, text)) + .and_then(output_name) + { + Some(name) => { + let _rule = site.context.log.rule(ALIAS); + Ok(vec![(Some(name), emitted)]) + } + None => { + let refusal = missing( + Construct::new(ConstructFamily::Syntax, UNALIASED_OUTPUT_NAME_CONSTRUCT), + format!("Snowflake names this `{kind}` column after its text"), + ); + Err(site.context.locator.placed(refusal, item)) + } } - Ok(vec![(None, emitted)]) } None => Err(missing( node_construct("unknown"), @@ -638,6 +657,45 @@ fn convert_projection( } } +/// Snowflake's name for an unaliased item written as `text` in recorded spacing: it upper-cased. +fn output_name(text: String) -> Option { + let recorded = !text.is_empty() + && text.is_ascii() + && text.trim() == text + && !text.contains("--") + && !text.contains("/*") + && !text.contains(['\t', '\r']) + && !text.contains(" (") + && text.matches('\n').count() <= 1 + && text + .split('\n') + .all(|line| !line.trim_start_matches(' ').contains(" ")); + recorded.then(|| text.to_ascii_uppercase()) +} + +/// A literal's written text: a number as parsed, a plain string quoted, NULL, TRUE or FALSE. +fn literal_text(item: &Value) -> Option { + match node_kind(item)? { + ("literal", payload) => { + let value = payload.get("value")?.as_str()?; + match payload.get("literal_type")?.as_str()? { + LITERAL_NUMBER => Some(value.to_owned()), + LITERAL_STRING if !value.contains(['\'', '\\']) => Some(format!("'{value}'")), + _ => None, + } + } + ("boolean", payload) => Some( + match payload.get("value")?.as_bool()? { + true => "TRUE", + false => "FALSE", + } + .to_owned(), + ), + ("null", _) => Some("NULL".to_owned()), + _ => None, + } +} + /// The columns `*` or `alias.*` names, without those its EXCLUDE list names. pub(crate) fn star(payload: &Value, site: Site<'_>) -> Result, Refusal> { let log = site.context.log; @@ -785,7 +843,7 @@ pub(crate) fn require_distinct_names(columns: &[ColumnSpec]) -> Result<(), Refus .any(|earlier| earlier.name.eq_ignore_ascii_case(&column.name)) { return Err(unresolved(format!( - "the result column name `{}` repeats, possibly differing only in case", + "the result column name `{}` {REPEATED_RESULT_NAME}", column.name ))); } diff --git a/crates/sqlquack-rules/src/conversion/_helpers/support/locations.rs b/crates/sqlquack-rules/src/conversion/_helpers/support/locations.rs index ebb12352d..f10b289cb 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/support/locations.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/support/locations.rs @@ -100,6 +100,11 @@ impl<'a> Locator<'a> { Some(self.lines.location(*start, *end)) } + /// The source text of `node`, a node of the converted tree; `None` for a node built during conversion. + pub(crate) fn source_text(&self, node: &Value) -> Option { + self.locate(node).map(|location| self.lines.text(&location)) + } + /// `refusal`, placed at `node` unless a node inside it placed it already. pub(crate) fn placed(&self, mut refusal: Refusal, node: &Value) -> Refusal { if refusal.location.is_none() { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/support/tree.rs b/crates/sqlquack-rules/src/conversion/_helpers/support/tree.rs index b08730857..32afd251b 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/support/tree.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/support/tree.rs @@ -72,13 +72,9 @@ pub(crate) fn is_inert(value: &Value) -> bool { } } -/// Refuses when a payload sets a field the rule does not understand. -pub(crate) fn require_understood( - payload: &Payload, - understood: &[&str], - construct: &Construct, -) -> Result<(), Refusal> { - let unexpected: Vec<&str> = payload +/// The fields a payload sets beyond `understood`, cosmetic and inert ones aside. +pub(crate) fn unexpected_fields<'a>(payload: &'a Payload, understood: &[&str]) -> Vec<&'a str> { + payload .iter() .filter(|(field, value)| { !understood.contains(&field.as_str()) @@ -86,7 +82,16 @@ pub(crate) fn require_understood( && !is_inert(value) }) .map(|(field, _)| field.as_str()) - .collect(); + .collect() +} + +/// Refuses when a payload sets a field the rule does not understand. +pub(crate) fn require_understood( + payload: &Payload, + understood: &[&str], + construct: &Construct, +) -> Result<(), Refusal> { + let unexpected = unexpected_fields(payload, understood); if unexpected.is_empty() { return Ok(()); } diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index 6269405a0..006eb3e0f 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -1981,3 +1981,12 @@ pub(crate) const CORRELATED_DERIVED: &str = pub(crate) const AMBIGUOUS_SUFFIX: &str = "is ambiguous"; /// A JSON-held VARIANT as a sort key, in ARRAY_SORT's recorded order of kinds. pub const VARIANT_ORDER_KEY: &str = "rule.variant_order_key"; + +/// Aggregates whose unaliased column names Snowflake receipts record. +pub(crate) const OUTPUT_NAMED_AGGREGATES: &[&str] = &["COUNT", "SUM", "MAX", "AVG"]; +/// Scalar functions whose unaliased column names Snowflake receipts record. +pub(crate) const OUTPUT_NAMED_FUNCTIONS: &[&str] = &["LOWER", "UPPER", "COALESCE"]; +/// Arithmetic whose unaliased column names, a column then a number, receipts record. +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"]; diff --git a/crates/sqlquack-rules/src/conversion/main/convert_statement.rs b/crates/sqlquack-rules/src/conversion/main/convert_statement.rs index 88809c501..cd900df80 100644 --- a/crates/sqlquack-rules/src/conversion/main/convert_statement.rs +++ b/crates/sqlquack-rules/src/conversion/main/convert_statement.rs @@ -20,7 +20,7 @@ use crate::conversion::_helpers::support::depth::positioned; use crate::conversion::_helpers::support::locations::Locator; use crate::conversion::_helpers::support::scope::{Context, StatementTracks}; use crate::conversion::_helpers::support::tree::{node_construct, session_tree}; -use crate::conversion::constants::OUTPUT_ALIAS; +use crate::conversion::constants::{NULL_BRANCHES, OUTPUT_ALIAS}; use crate::conversion::models::{ConversionOptions, Converted}; use sqlquack_core::refusals::main::outside_conditions::outside_conditions; use sqlquack_semantics::types::models::TypedLeaves; @@ -51,9 +51,14 @@ pub fn convert_statement( free_projections: false, fixture_body: false, }; - let (sql, columns) = checked(&typed, convert_query(&tree, None, &context, true)) + let (sql, mut columns) = checked(&typed, convert_query(&tree, None, &context, true)) .map_err(|refusal| locator.placed(refusal, &tree))?; let (sql, raises) = locator.finished(&output_variants(sql, &columns, &log), &tree); + for column in &mut columns { + if column.data_type == SnowflakeType::Null { + column.data_type = SnowflakeType::Varchar; + } + } let relation = |name: &str| -> Option> { let columns = schema.relations.get(name)?; Some(column_types(columns)) @@ -75,20 +80,25 @@ pub fn convert_statement( }) } -/// The query with each VARIANT result column written as Snowflake returns it. +/// The query with VARIANT result columns written as Snowflake returns them, NULL-only ones VARCHAR. fn output_variants(sql: String, columns: &[ColumnSpec], log: &ProvenanceLog) -> String { let replaced: Vec = columns .iter() - .filter(|column| column.data_type == SnowflakeType::SemiStructured) - .map(|column| { + .filter_map(|column| { let name = quoted_identifier(&column.name, log); - match column.holding { - Holding::Variant => { - format!("CAST({} AS JSON) AS {name}", variant_written(&name, log)) + match (column.data_type, column.holding) { + (SnowflakeType::SemiStructured, Holding::Variant) => Some(format!( + "CAST({} AS JSON) AS {name}", + variant_written(&name, log) + )), + (SnowflakeType::SemiStructured, Holding::Json | Holding::Chained) => { + Some(format!("{} AS {name}", json_output(&name, log))) } - Holding::Json | Holding::Chained => { - format!("{} AS {name}", json_output(&name, log)) + (SnowflakeType::Null, _) => { + let _rule = log.rule(NULL_BRANCHES); + Some(format!("CAST({name} AS VARCHAR) AS {name}")) } + _ => None, } }) .collect(); diff --git a/crates/sqlquack/tests/cli.rs b/crates/sqlquack/tests/cli.rs index 4d05fd8e7..1f05e64e2 100644 --- a/crates/sqlquack/tests/cli.rs +++ b/crates/sqlquack/tests/cli.rs @@ -18,6 +18,8 @@ mod test_evidence_commands; mod test_explain; #[path = "cli/test_function.rs"] mod test_function; +#[path = "cli/test_output_names.rs"] +mod test_output_names; #[path = "cli/test_review.rs"] mod test_review; #[path = "cli/test_types.rs"] diff --git a/crates/sqlquack/tests/cli/fixtures/unaliased_items.json b/crates/sqlquack/tests/cli/fixtures/unaliased_items.json new file mode 100644 index 000000000..80704c545 --- /dev/null +++ b/crates/sqlquack/tests/cli/fixtures/unaliased_items.json @@ -0,0 +1,11 @@ +{ + "relations": { + "INPUT": [ + ["N", "NUMBER(10,2)"], + ["G", "VARCHAR"], + ["V", "VARIANT"], + ["T", "TIMESTAMP_NTZ(9)"], + ["lc", "NUMBER(10,0)"] + ] + } +} diff --git a/crates/sqlquack/tests/cli/test_convert.rs b/crates/sqlquack/tests/cli/test_convert.rs index 473313d54..6f54b5aa9 100644 --- a/crates/sqlquack/tests/cli/test_convert.rs +++ b/crates/sqlquack/tests/cli/test_convert.rs @@ -26,6 +26,13 @@ const NAIVE_TO_ZONED: &str = #[test] fn given_snowflake_sql_when_converting_then_duckdb_sql_or_named_refusals_are_printed() { let test_cases = [ + ConvertTestCase { + description: "a repeated result name refuses with help to alias each column", + sql: "SELECT 1, 1", + flags: &[], + mention: "give each result column its own alias", + expected_outcome: convert_outcome(1, false), + }, ConvertTestCase { description: "EQUAL_NULL of a VARIANT and text refuses: no recording shows a JSON null", sql: "SELECT EQUAL_NULL(PARSE_JSON('null'), '1') AS e", @@ -1488,11 +1495,11 @@ fn given_refused_sql_when_converting_then_text_and_json_carry_the_same_coded_dia }, ConvertDiagnosticTestCase { description: "an unaliased item has its own code, its own span and an alias as help", - sql: "SELECT 'a' AS a, UPPER('b')", + sql: "SELECT 'a' AS a, UPPER('é')", flags: &[], expected_text: concat!( "error[SQ0009]: this column has no alias, and SQLQuack has not verified the name ", - "Snowflake gives it\n --> :1:18\n |\n1 | SELECT 'a' AS a, UPPER('b')\n", + "Snowflake gives it\n --> :1:18\n |\n1 | SELECT 'a' AS a, UPPER('é')\n", " | ^^^^^^^^^^ Snowflake names this `function` column after its text\n", " |\n = help: add an alias, e.g. `AS one`\n", "sqlquack convert: refused, 1 refusal under the built-in default\n", diff --git a/crates/sqlquack/tests/cli/test_output_names.rs b/crates/sqlquack/tests/cli/test_output_names.rs new file mode 100644 index 000000000..766fbb11c --- /dev/null +++ b/crates/sqlquack/tests/cli/test_output_names.rs @@ -0,0 +1,369 @@ +use crate::helpers::{convert_outcome, converted_column_names, run_convert}; +use crate::test_types::{ColumnNameTestCase, ConvertTestCase}; + +const UNALIASED_SCHEMA: &str = "tests/cli/fixtures/unaliased_items.json"; +const UNVERIFIED_NAME: &str = "has not verified the name Snowflake gives it"; + +#[test] +fn given_unaliased_items_when_converted_and_run_on_duckdb_then_they_take_snowflakes_names() { + let test_cases = [ + ColumnNameTestCase { + description: "aggregates and a literal, named as recorded", + sql: "SELECT COUNT(*), 'MiXeD text', Count( DISTINCT n ) FROM (SELECT 1 AS n) AS input", + flags: &[], + expected_names: vec![ + "COUNT(*)".to_owned(), + "'MIXED TEXT'".to_owned(), + "COUNT( DISTINCT N )".to_owned(), + ], + }, + ColumnNameTestCase { + description: "a column-and-number expression across a line break keeps the break", + sql: "SELECT n\n + 1 FROM (SELECT 1 AS n) AS input", + flags: &[], + expected_names: vec!["N\n + 1".to_owned()], + }, + ColumnNameTestCase { + description: "numbers keep their written form, upper-cased", + sql: "SELECT 1.50, 1e3, -2, NULL, false", + flags: &[], + expected_names: vec![ + "1.50".to_owned(), + "1E3".to_owned(), + "-2".to_owned(), + "NULL".to_owned(), + "FALSE".to_owned(), + ], + }, + ]; + for test_case in test_cases { + assert_eq!( + converted_column_names(test_case.sql, test_case.flags), + test_case.expected_names, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_unaliased_items_no_receipt_names_when_converting_then_each_refuses_with_sq0009() { + let test_cases = [ + ConvertTestCase { + description: "a semi-structured path, which Snowflake names after the whole path refuses with SQ0009", + sql: "SELECT v:a FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a nested path refuses with SQ0009", + sql: "SELECT v:a.b FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a subscript by key refuses with SQ0009", + sql: "SELECT v['a'] FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a path with a subscript by index refuses with SQ0009", + sql: "SELECT v:a[0] FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a path from a quoted column refuses with SQ0009", + sql: "SELECT \"V\":a FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a path from a qualified column refuses with SQ0009", + sql: "SELECT input.v:a FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a path from a call refuses with SQ0009", + sql: "SELECT PARSE_JSON('{}'):a FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a cast of a path refuses with SQ0009", + sql: "SELECT v:a::STRING FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "CASE refuses with SQ0009", + sql: "SELECT CASE WHEN n > 1 THEN 1 END FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "IFF refuses with SQ0009", + sql: "SELECT IFF(n > 1, 1, 0) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a scalar subquery refuses with SQ0009", + sql: "SELECT (SELECT 1) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "EXISTS refuses with SQ0009", + sql: "SELECT EXISTS (SELECT 1) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "IN refuses with SQ0009", + sql: "SELECT n IN (1, 2) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "BETWEEN refuses with SQ0009", + sql: "SELECT n BETWEEN 1 AND 2 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "LIKE refuses with SQ0009", + sql: "SELECT g LIKE 'a%' FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "IS NULL refuses with SQ0009", + sql: "SELECT n IS NULL FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "IS NOT DISTINCT FROM refuses with SQ0009", + sql: "SELECT n IS NOT DISTINCT FROM 1 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "NOT refuses with SQ0009", + sql: "SELECT NOT TRUE FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "AND refuses with SQ0009", + sql: "SELECT TRUE AND FALSE FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a window function refuses with SQ0009", + sql: "SELECT ROW_NUMBER() OVER (ORDER BY n) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a nested call refuses with SQ0009", + sql: "SELECT UPPER(LOWER(g)) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a parenthesised expression refuses with SQ0009", + sql: "SELECT (n + 1) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a negated column refuses with SQ0009", + sql: "SELECT -n FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "modulo refuses with SQ0009", + sql: "SELECT n % 2 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a chained cast refuses with SQ0009", + sql: "SELECT n::INT::STRING FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a cast spelled INT refuses with SQ0009", + sql: "SELECT CAST(n AS INT) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "ROUND, which no receipt names yet refuses with SQ0009", + sql: "SELECT ROUND(n, 1) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "CURRENT_DATE(), which no receipt names yet refuses with SQ0009", + sql: "SELECT CURRENT_DATE() FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "DATEADD refuses with SQ0009", + sql: "SELECT DATEADD(day, 1, t) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "TRY_CAST refuses with SQ0009", + sql: "SELECT TRY_CAST(g AS INTEGER) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a literal before a column refuses with SQ0009", + sql: "SELECT 1 + n FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a tab refuses with SQ0009", + sql: "SELECT n\t+ 1 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a double space refuses with SQ0009", + sql: "SELECT n + 1 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a space before the parenthesis refuses with SQ0009", + sql: "SELECT COUNT (*) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a CRLF line break refuses with SQ0009", + sql: "SELECT n\r\n+ 1 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a decimal without a leading digit refuses with SQ0009", + sql: "SELECT .5 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "an integer with leading zeros refuses with SQ0009", + sql: "SELECT 007 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "an exponent with a fraction and a sign refuses with SQ0009", + sql: "SELECT 1.0e-3 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "an exponent with a plus sign refuses with SQ0009", + sql: "SELECT 1E+3 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a spaced negative refuses with SQ0009", + sql: "SELECT - 2 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a negated parenthesised number refuses with SQ0009", + sql: "SELECT -(2) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "text with a tab refuses with SQ0009", + sql: "SELECT 'a\tb' FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "empty text refuses with SQ0009", + sql: "SELECT '' FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a quoted identifier that is not upper case refuses with SQ0009", + sql: "SELECT \"lc\" + 1 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ]; + for test_case in test_cases { + assert_eq!( + run_convert(test_case.sql, test_case.flags, test_case.mention), + test_case.expected_outcome, + "{}", + test_case.description + ); + } +} diff --git a/evidence/results/2615ead10c0b07c181571b1521fea6fcc37919aa18250bd6c75272a1258c4db9.arrow b/evidence/results/2615ead10c0b07c181571b1521fea6fcc37919aa18250bd6c75272a1258c4db9.arrow new file mode 100644 index 000000000..694a86642 Binary files /dev/null and b/evidence/results/2615ead10c0b07c181571b1521fea6fcc37919aa18250bd6c75272a1258c4db9.arrow differ diff --git a/evidence/results/2615ead10c0b07c181571b1521fea6fcc37919aa18250bd6c75272a1258c4db9.json b/evidence/results/2615ead10c0b07c181571b1521fea6fcc37919aa18250bd6c75272a1258c4db9.json new file mode 100644 index 000000000..afffb732a --- /dev/null +++ b/evidence/results/2615ead10c0b07c181571b1521fea6fcc37919aa18250bd6c75272a1258c4db9.json @@ -0,0 +1,106 @@ +{ + "arrow_sha256": "6e13368a167854a2d2cf771dc204f7b4daa87217fb2ddd71ebcc6618b89b55e2", + "case_hash": "2615ead10c0b07c181571b1521fea6fcc37919aa18250bd6c75272a1258c4db9", + "columns": [ + { + "internal_size": null, + "name": "COUNT(*)", + "nullable": false, + "precision": 18, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "COUNT(N)", + "nullable": false, + "precision": 18, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "COUNT( DISTINCT G )", + "nullable": false, + "precision": 18, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "SUM(N)", + "nullable": true, + "precision": 22, + "scale": 2, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": 134217728, + "name": "MAX(G)", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": null, + "name": "AVG( N )", + "nullable": true, + "precision": 28, + "scale": 8, + "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": "18eda5d3151391d11c1c50447de582dbce97fb2aa8b43b3a2a4cbc034824ffd3", + "recorded_at": "2026-11-06T03:52:30.454184+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/7b148651af0c1b4312384e0845541a74fcefe39fdfcf643958fb8eb5044d2e20.arrow b/evidence/results/7b148651af0c1b4312384e0845541a74fcefe39fdfcf643958fb8eb5044d2e20.arrow new file mode 100644 index 000000000..da6ecaf75 Binary files /dev/null and b/evidence/results/7b148651af0c1b4312384e0845541a74fcefe39fdfcf643958fb8eb5044d2e20.arrow differ diff --git a/evidence/results/7b148651af0c1b4312384e0845541a74fcefe39fdfcf643958fb8eb5044d2e20.json b/evidence/results/7b148651af0c1b4312384e0845541a74fcefe39fdfcf643958fb8eb5044d2e20.json new file mode 100644 index 000000000..021c548e1 --- /dev/null +++ b/evidence/results/7b148651af0c1b4312384e0845541a74fcefe39fdfcf643958fb8eb5044d2e20.json @@ -0,0 +1,151 @@ +{ + "arrow_sha256": "e596e537a12a699db85ffe20d1eb51c36bc53464b02655e00ba7a0f2860f4c3e", + "case_hash": "7b148651af0c1b4312384e0845541a74fcefe39fdfcf643958fb8eb5044d2e20", + "columns": [ + { + "internal_size": null, + "name": "N + 1", + "nullable": true, + "precision": 11, + "scale": 2, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "N*2", + "nullable": true, + "precision": 11, + "scale": 2, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": 134217728, + "name": "LOWER(G)", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": 134217728, + "name": "UPPER( G )", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": 134217728, + "name": "G || 'X'", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": null, + "name": "CAST(N AS INTEGER)", + "nullable": true, + "precision": 38, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "N::INTEGER", + "nullable": true, + "precision": 38, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "COALESCE(N, 0)", + "nullable": false, + "precision": 10, + "scale": 2, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "INPUT.N - 1", + "nullable": true, + "precision": 11, + "scale": 2, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "\"N\" / 2", + "nullable": true, + "precision": 16, + "scale": 8, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "N\n + 3", + "nullable": true, + "precision": 11, + "scale": 2, + "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": "0ab57b7dcb87edadbd3b0bdd949b587aaa9fb7a04566221c9953239fa3d5b5d8", + "recorded_at": "2026-11-06T03:52:30.454184+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/a33d2ac3e1dc2966754bcc7c4829c2b6dcdb1ce3f40f0d7fefc0865e9b263bd3.arrow b/evidence/results/a33d2ac3e1dc2966754bcc7c4829c2b6dcdb1ce3f40f0d7fefc0865e9b263bd3.arrow new file mode 100644 index 000000000..939213f1f Binary files /dev/null and b/evidence/results/a33d2ac3e1dc2966754bcc7c4829c2b6dcdb1ce3f40f0d7fefc0865e9b263bd3.arrow differ diff --git a/evidence/results/a33d2ac3e1dc2966754bcc7c4829c2b6dcdb1ce3f40f0d7fefc0865e9b263bd3.json b/evidence/results/a33d2ac3e1dc2966754bcc7c4829c2b6dcdb1ce3f40f0d7fefc0865e9b263bd3.json new file mode 100644 index 000000000..9d78e52d0 --- /dev/null +++ b/evidence/results/a33d2ac3e1dc2966754bcc7c4829c2b6dcdb1ce3f40f0d7fefc0865e9b263bd3.json @@ -0,0 +1,70 @@ +{ + "arrow_sha256": "c790032dee88ed577cdaa9efe470af5455e4847205a81a7744da3e15d630fc1b", + "case_hash": "a33d2ac3e1dc2966754bcc7c4829c2b6dcdb1ce3f40f0d7fefc0865e9b263bd3", + "columns": [ + { + "internal_size": null, + "name": "COUNT(*)", + "nullable": false, + "precision": 18, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "SUM(N)", + "nullable": true, + "precision": 22, + "scale": 2, + "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": "48d82daac8b8c34cd3f5f3edcb3bc697730d62f1d0f316811c5e1f457458eb1f", + "recorded_at": "2026-11-06T03:52:30.454184+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/b814e299ffaba1caa43bdf0b0f7d12a4a658c70a9db500f24bd827e1d092000e.arrow b/evidence/results/b814e299ffaba1caa43bdf0b0f7d12a4a658c70a9db500f24bd827e1d092000e.arrow new file mode 100644 index 000000000..f6037dd9b Binary files /dev/null and b/evidence/results/b814e299ffaba1caa43bdf0b0f7d12a4a658c70a9db500f24bd827e1d092000e.arrow differ diff --git a/evidence/results/b814e299ffaba1caa43bdf0b0f7d12a4a658c70a9db500f24bd827e1d092000e.json b/evidence/results/b814e299ffaba1caa43bdf0b0f7d12a4a658c70a9db500f24bd827e1d092000e.json new file mode 100644 index 000000000..1ba674834 --- /dev/null +++ b/evidence/results/b814e299ffaba1caa43bdf0b0f7d12a4a658c70a9db500f24bd827e1d092000e.json @@ -0,0 +1,70 @@ +{ + "arrow_sha256": "1a9cf9511edb23bfc9e07a89765de236617bc51e66dcd95349308e304b97bf89", + "case_hash": "b814e299ffaba1caa43bdf0b0f7d12a4a658c70a9db500f24bd827e1d092000e", + "columns": [ + { + "internal_size": 134217728, + "name": "G", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": null, + "name": "COUNT(*)", + "nullable": false, + "precision": 18, + "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": "124b05a8ca46d3aad8061363a47407804955dc38923734e1de5c4779a2a5f251", + "recorded_at": "2026-11-06T03:52:30.454184+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/c63635c159fd9cc02b6e7a8030e4e8338a4f67baf34f9eca42e759d3c49a7f8e.arrow b/evidence/results/c63635c159fd9cc02b6e7a8030e4e8338a4f67baf34f9eca42e759d3c49a7f8e.arrow new file mode 100644 index 000000000..328eb9e06 Binary files /dev/null and b/evidence/results/c63635c159fd9cc02b6e7a8030e4e8338a4f67baf34f9eca42e759d3c49a7f8e.arrow differ diff --git a/evidence/results/c63635c159fd9cc02b6e7a8030e4e8338a4f67baf34f9eca42e759d3c49a7f8e.json b/evidence/results/c63635c159fd9cc02b6e7a8030e4e8338a4f67baf34f9eca42e759d3c49a7f8e.json new file mode 100644 index 000000000..79cb2a1a7 --- /dev/null +++ b/evidence/results/c63635c159fd9cc02b6e7a8030e4e8338a4f67baf34f9eca42e759d3c49a7f8e.json @@ -0,0 +1,124 @@ +{ + "arrow_sha256": "48ca0d6103fec22c1a50ef4bc5122b8f1bf34e167a82638c82537f5f456c4b04", + "case_hash": "c63635c159fd9cc02b6e7a8030e4e8338a4f67baf34f9eca42e759d3c49a7f8e", + "columns": [ + { + "internal_size": null, + "name": "1", + "nullable": false, + "precision": 1, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "-2", + "nullable": false, + "precision": 1, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "1.50", + "nullable": false, + "precision": 2, + "scale": 1, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "1E3", + "nullable": false, + "precision": 4, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": 10, + "name": "'MIXED TEXT'", + "nullable": false, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": 0, + "name": "NULL", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": null, + "name": "TRUE", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 13, + "type_name": "BOOLEAN" + }, + { + "internal_size": null, + "name": "FALSE", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 13, + "type_name": "BOOLEAN" + } + ], + "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": "efbbe9c60b4259421018b9237d01c43db0bb92871cb387ae0c489d917db44c69", + "recorded_at": "2026-11-06T03:52:30.454184+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/website/src/content/docs/docs/diagnostics/reference.mdx b/website/src/content/docs/docs/diagnostics/reference.mdx index 4b3d01460..9b0f820af 100644 --- a/website/src/content/docs/docs/diagnostics/reference.mdx +++ b/website/src/content/docs/docs/diagnostics/reference.mdx @@ -140,14 +140,14 @@ echo "SELECT HASH(1) AS h" | sqlquack convert --builtin-policy **this column has no alias, and SQLQuack has not verified the name Snowflake gives it** (`unaliased_output_name`) -Snowflake names an unaliased select item after the expression's text, and a converted query must return the same column names. SQLQuack emits Snowflake's name only for the expression shapes whose names are verified against recorded results; the expression at the span is not one of them, so the statement is refused rather than converted with a different column name. An alias fixes the name in both engines. +Snowflake names an unaliased select item after the expression's text, and a converted query must return the same column names. SQLQuack emits Snowflake's name only for the shapes recorded results verify, named by their text upper-cased as written: literals, columns and COUNT, SUM, MAX, AVG, LOWER, UPPER and COALESCE of columns, a column plus, minus, times, divided by or concatenated with a literal, and a column cast to INTEGER, written with single spaces and at most one line break. Other items, such as paths, CASE, nested calls or other functions, are refused rather than converted with a different column name. An alias fixes the name in both engines. **Help:** add an alias, e.g. `AS one` **Example:** ```sh -echo "SELECT 'a' || 'b'" | sqlquack convert --builtin-policy +echo "SELECT 'café' || 'b'" | sqlquack convert --builtin-policy ``` ## Input limits