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
92 changes: 92 additions & 0 deletions cases/relational/unaliased_round_current.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# The names Snowflake gives unaliased ROUND and current-time items, as a playground query writes them.
# The recorded column names are the receipts for naming them.
category = "relational"
construct = "node:alias"
session_profile = "utc-v1"

[[case]]
id = "unaliased_round"
description = "Unaliased ROUND with one and two arguments, in upper and lower case and with inner spaces, over a NULL, a half and a negative."
columns = [{ name = "n", type = "NUMBER(10,2)" }]
rows = [
["1.25"],
["1.25"],
["-2.55"],
["NULL"],
]
query = "SELECT ROUND(n, 1), ROUND(n), round( n, 0 ) FROM input"
order = "ordered"
tags = ["null", "boundary", "duplicates"]

[[case]]
id = "unaliased_round_no_rows"
description = "Unaliased ROUND over no rows still names its columns."
columns = [{ name = "n", type = "NUMBER(10,2)" }]
rows = [
["1.00"],
]
query = "SELECT ROUND(n, 1), ROUND(n) FROM input WHERE n > 5"
order = "ordered"
tags = ["empty-input"]

[[case]]
id = "unaliased_current_time"
description = "Unaliased CURRENT_DATE(), CURRENT_DATE and CURRENT_TIMESTAMP(): only the column names and types are the receipt; the values read the clock, so it is typing evidence."
evidence = "typing"
columns = [{ name = "n", type = "NUMBER(10,2)" }]
rows = [
["1.00"],
]
query = "SELECT CURRENT_DATE(), CURRENT_DATE, CURRENT_TIMESTAMP() FROM input"
order = "ordered"
tags = ["boundary"]

[[case]]
id = "unaliased_distinct_aggregates"
description = "Unaliased SUM, MAX and AVG over DISTINCT, over a duplicate and a NULL. The aggregates are one query and the row expressions another, since one select list cannot hold both without GROUP BY; each item is written as given."
columns = [{ name = "n", type = "NUMBER(10,2)" }, { name = "g", type = "VARCHAR" }]
rows = [
["1.50", "'12'"],
["1.50", "'12'"],
["NULL", "NULL"],
["-2.25", "'-3'"],
]
query = "SELECT SUM(DISTINCT n), MAX(DISTINCT n), AVG(DISTINCT n) FROM input"
order = "ordered"
tags = ["null", "duplicates"]

[[case]]
id = "unaliased_distinct_aggregates_no_rows"
description = "The unaliased DISTINCT aggregates over no rows still name their columns."
columns = [{ name = "n", type = "NUMBER(10,2)" }, { name = "g", type = "VARCHAR" }]
rows = [
["1.00", "'1'"],
]
query = "SELECT SUM(DISTINCT n), MAX(DISTINCT n), AVG(DISTINCT n) FROM input WHERE n > 5"
order = "ordered"
tags = ["empty-input"]

[[case]]
id = "unaliased_row_expressions"
description = "Unaliased COALESCE with a column and with literals only, CAST of numeric text to INTEGER, and lower with a space before its closing parenthesis, over a duplicate and a NULL."
columns = [{ name = "n", type = "NUMBER(10,2)" }, { name = "g", type = "VARCHAR" }]
rows = [
["1.50", "'12'"],
["1.50", "'12'"],
["NULL", "NULL"],
["-2.25", "'-3'"],
]
query = "SELECT COALESCE(n, 0, 1), COALESCE(1, 2), CAST(g AS INTEGER), lower(g ) FROM input"
order = "bag"
tags = ["null", "duplicates"]

[[case]]
id = "unaliased_row_expressions_no_rows"
description = "The unaliased row expressions over no rows still name their columns."
columns = [{ name = "n", type = "NUMBER(10,2)" }, { name = "g", type = "VARCHAR" }]
rows = [
["1.00", "'1'"],
]
query = "SELECT COALESCE(n, 0, 1), COALESCE(1, 2), CAST(g AS INTEGER), lower(g ) FROM input WHERE n > 5"
order = "bag"
tags = ["empty-input"]
7 changes: 7 additions & 0 deletions coverage/translation/results-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -6718,6 +6718,13 @@
"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/unaliased_round_current.toml#unaliased_current_time": "shape_only",
"relational/unaliased_round_current.toml#unaliased_distinct_aggregates": "refused",
"relational/unaliased_round_current.toml#unaliased_distinct_aggregates_no_rows": "refused",
"relational/unaliased_round_current.toml#unaliased_round": "matching",
"relational/unaliased_round_current.toml#unaliased_round_no_rows": "matching",
"relational/unaliased_round_current.toml#unaliased_row_expressions": "matching",
"relational/unaliased_round_current.toml#unaliased_row_expressions_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
5 changes: 3 additions & 2 deletions crates/sqlquack-core/src/diagnostics/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,9 @@ pub const CODES: &[CodeEntry] = &[
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. \
columns, ROUND of a column, a column plus, minus, times, divided by or concatenated \
with a literal, a column cast to INTEGER, and CURRENT_DATE, CURRENT_DATE() or \
CURRENT_TIMESTAMP(), 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.",
Expand Down
4 changes: 4 additions & 0 deletions crates/sqlquack-duckdb/src/temporal/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,7 @@ pub const DATE_FROM_PARTS: &str = "duckdb.date_from_parts";
pub const TIMESTAMP_FROM_PARTS: &str = "duckdb.timestamp_from_parts";
/// The dates whose every instant DuckDB's nanosecond timestamps hold.
pub const NANOSECOND_DATE_RANGE: (&str, &str) = ("1677-09-22", "2262-04-10");
/// DuckDB's current date as a call, which a select item's alias `CURRENT_DATE` cannot capture.
pub const DUCKDB_CURRENT_DATE: &str = "current_date()";
/// DuckDB's current timestamp as a call, which an alias `CURRENT_TIMESTAMP` cannot capture.
pub const DUCKDB_CURRENT_TIMESTAMP: &str = "get_current_timestamp()";
2 changes: 1 addition & 1 deletion crates/sqlquack-rules/src/catalogue/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1250,7 +1250,7 @@ pub const RULES: &[RuleSpec] = &[
RuleSpec {
id: AVERAGE,
constructs: &["fn:AVG"],
conditions: "A NUMBER of scale at most 6 and precision at most 26.",
conditions: "A NUMBER of scale at most 6 and precision at most 26. AVG(DISTINCT) of a NUMBER is refused, since Snowflake types it with 3 more decimal places and no recording shows how it rounds.",
failures: &[FailureMode {
kind: ErrorKind::NumericOverflow,
snowflake: &[NUMBER_OUT_OF_RANGE],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,14 @@ impl AggregateCall<'_> {
let _rule = log.rule(AVERAGE);
let refuse = |reason: String| outside_conditions(node_construct("avg"), reason);
let argument = self.argument("avg")?;
if !self.framed
&& flag(self.payload, "distinct")
&& matches!(argument.data_type, SnowflakeType::Number { .. })
{
return Err(refuse(
"AVG(DISTINCT) of a NUMBER, which Snowflake types with 3 more decimal places, not 6, and no recording shows how it rounds".to_owned(),
));
}
let total = sum_type(argument.data_type, self.site.context, "avg").map_err(refuse)?;
let data_type = match self.framed {
true => framed_average(argument.data_type, log),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ use crate::conversion::constants::{
ZONED_PARTS,
};
use sqlquack_duckdb::raises::main::raised::raised;
use sqlquack_duckdb::temporal::constants::{DUCKDB_CURRENT_DATE, DUCKDB_CURRENT_TIMESTAMP};
use sqlquack_policy::policy::constants::{
CURRENT_TIME, DATE_TEXT_SPELLING, SESSION_TIMEZONE_UTC, TIME_ZONE_SPELLING, TIMESTAMP_RANGE,
};
Expand Down Expand Up @@ -1189,9 +1190,9 @@ pub(crate) fn current_time(
diverge(site.context, CURRENT_TIME, construct)?;
diverge(site.context, SESSION_TIMEZONE_UTC, construct)?;
Ok(match name {
"CURRENT_DATE" => Emitted::new("current_date".to_owned(), SnowflakeType::Date),
"CURRENT_DATE" => Emitted::new(DUCKDB_CURRENT_DATE.to_owned(), SnowflakeType::Date),
_ => Emitted::new(
"current_timestamp".to_owned(),
DUCKDB_CURRENT_TIMESTAMP.to_owned(),
SnowflakeType::TimestampLtz { precision: 9 },
),
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ pub(crate) fn recorded_name_shape(item: &Value, text: &str) -> bool {
|| (named(&["COUNT"]) && matches!(node_kind(argument), Some(("star", _))))
})
}
"function" if arguments.is_empty() => {
only(&["name", "args", "no_parens"])
&& matches!(
text.to_ascii_uppercase().as_str(),
"CURRENT_DATE()" | "CURRENT_DATE" | "CURRENT_TIMESTAMP()"
)
}
"function" if named(&["ROUND"]) => {
only(&["name", "args"])
&& arguments.get(2).is_none()
&& arguments.first().is_some_and(plain_column)
&& arguments.iter().skip(1).all(integer_literal)
}
"function" => {
only(&["name", "args"])
&& named(OUTPUT_NAMED_FUNCTIONS)
Expand Down
72 changes: 68 additions & 4 deletions crates/sqlquack/tests/cli/test_output_names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,44 @@ fn given_unaliased_items_when_converted_and_run_on_duckdb_then_they_take_snowfla
flags: &[],
expected_names: vec!["N\n + 1".to_owned()],
},
ColumnNameTestCase {
description: "ROUND and the current-time items, named as recorded",
sql: "SELECT ROUND(n, 1), ROUND(n), round( n, 0 ), current_date(), CURRENT_DATE, \
CURRENT_TIMESTAMP() FROM (SELECT 1.25 AS n) AS input",
flags: &[],
expected_names: vec![
"ROUND(N, 1)".to_owned(),
"ROUND(N)".to_owned(),
"ROUND( N, 0 )".to_owned(),
"CURRENT_DATE()".to_owned(),
"CURRENT_DATE".to_owned(),
"CURRENT_TIMESTAMP()".to_owned(),
],
},
ColumnNameTestCase {
description: "an alias spelled as the current-time keyword runs on DuckDB",
sql: "SELECT CURRENT_DATE() AS current_date, CURRENT_TIMESTAMP() AS current_timestamp",
flags: &[],
expected_names: vec!["CURRENT_DATE".to_owned(), "CURRENT_TIMESTAMP".to_owned()],
},
ColumnNameTestCase {
description: "DISTINCT aggregates, named as recorded",
sql: "SELECT SUM(DISTINCT n), MAX(DISTINCT n) FROM (SELECT 1.25 AS n) AS input",
flags: &[],
expected_names: vec!["SUM(DISTINCT N)".to_owned(), "MAX(DISTINCT N)".to_owned()],
},
ColumnNameTestCase {
description: "COALESCE of three and of numbers, a text cast and a spaced call, as recorded",
sql: "SELECT COALESCE(n, 0, 1), COALESCE(1, 2), CAST(g AS INTEGER), lower(g ) \
FROM (SELECT 1.25 AS n, '7' AS g) AS input",
flags: &[],
expected_names: vec![
"COALESCE(N, 0, 1)".to_owned(),
"COALESCE(1, 2)".to_owned(),
"CAST(G AS INTEGER)".to_owned(),
"LOWER(G )".to_owned(),
],
},
ColumnNameTestCase {
description: "numbers keep their written form, upper-cased",
sql: "SELECT 1.50, 1e3, -2, NULL, false",
Expand All @@ -46,6 +84,25 @@ fn given_unaliased_items_when_converted_and_run_on_duckdb_then_they_take_snowfla
}
}

#[test]
fn given_avg_distinct_of_a_number_when_converting_then_it_refuses() {
let test_cases = [ConvertTestCase {
description: "AVG(DISTINCT) of a NUMBER, which Snowflake types NUMBER(25,5) for NUMBER(10,2), refuses",
sql: "SELECT AVG(DISTINCT n) AS a FROM (SELECT 1.25 AS n) AS input",
flags: &[],
mention: "AVG(DISTINCT) of a NUMBER",
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
);
}
}

#[test]
fn given_unaliased_items_no_receipt_names_when_converting_then_each_refuses_with_sq0009() {
let test_cases = [
Expand Down Expand Up @@ -232,15 +289,22 @@ fn given_unaliased_items_no_receipt_names_when_converting_then_each_refuses_with
expected_outcome: convert_outcome(1, false),
},
ConvertTestCase {
description: "ROUND, which no receipt names yet refuses with SQ0009",
sql: "SELECT ROUND(n, 1) FROM input",
description: "ROUND of an expression, which no receipt names, refuses with SQ0009",
sql: "SELECT ROUND(n + 1, 1) FROM input",
flags: &["--schema", UNALIASED_SCHEMA],
mention: UNVERIFIED_NAME,
expected_outcome: convert_outcome(1, false),
},
ConvertTestCase {
description: "CURRENT_TIMESTAMP without parentheses, which no receipt names, refuses with SQ0009",
sql: "SELECT CURRENT_TIMESTAMP 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",
description: "CURRENT_DATE( ) with a space, which no receipt names, refuses with SQ0009",
sql: "SELECT CURRENT_DATE( ) FROM input",
flags: &["--schema", UNALIASED_SCHEMA],
mention: UNVERIFIED_NAME,
expected_outcome: convert_outcome(1, false),
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
{
"arrow_sha256": "261e58fbaa07677713c9cb5fb326e3b02de3c82241f71950914c9aa3f3796db1",
"case_hash": "03a4a84eb27f2f213174ea73ca1b0dc604892b2443238307bdb53d0f571fe26c",
"columns": [
{
"internal_size": null,
"name": "COALESCE(N, 0, 1)",
"nullable": false,
"precision": 10,
"scale": 2,
"type_code": 0,
"type_name": "FIXED"
},
{
"internal_size": null,
"name": "COALESCE(1, 2)",
"nullable": false,
"precision": 1,
"scale": 0,
"type_code": 0,
"type_name": "FIXED"
},
{
"internal_size": null,
"name": "CAST(G AS INTEGER)",
"nullable": false,
"precision": 38,
"scale": 0,
"type_code": 0,
"type_name": "FIXED"
},
{
"internal_size": 134217728,
"name": "LOWER(G )",
"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": "2ac39cda67c648de58cfdcd77a9d876c15950427554c30e1b0beb30004789363",
"recorded_at": "2026-11-06T10:29:57.611560+00:00",
"recorder": "sqlquack-record 0.1.0",
"row_count": 0,
"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"
}
Binary file not shown.
Loading
Loading