Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions cases/relational/unaliased_output_names.toml
Original file line number Diff line number Diff line change
@@ -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"]
7 changes: 6 additions & 1 deletion coverage/translation/results-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 12 additions & 4 deletions crates/sqlquack-core/src/diagnostics/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 8 additions & 2 deletions crates/sqlquack-core/src/diagnostics/main/diagnose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -25,6 +26,11 @@ pub fn diagnose(refusals: &[Refusal], source: &str) -> Vec<Diagnostic> {
{
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)
{
Expand Down
11 changes: 11 additions & 0 deletions crates/sqlquack-core/src/parsing/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pub struct SourceLines {
line_starts: Vec<usize>,
length: usize,
shift: usize,
source: String,
}

impl SourceLines {
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions crates/sqlquack-rules/src/catalogue/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
154 changes: 154 additions & 0 deletions crates/sqlquack-rules/src/conversion/_helpers/queries/output_names.rs
Original file line number Diff line number Diff line change
@@ -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'\\')
})
}
Loading
Loading