Skip to content

Commit 45e21bd

Browse files
committed
Merge remote-tracking branch 'github/main' into fix/coverage-receipt-join
2 parents 243996d + 215a679 commit 45e21bd

39 files changed

Lines changed: 1337 additions & 18 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# FLOAT to NUMBER at scales 3, 4, 5 and 10, with values whose exact double and shortest decimal reading round differently.
2+
category = "conversion"
3+
construct = "node:cast"
4+
session_profile = "utc-v1"
5+
6+
[[case]]
7+
id = "float_number_10_3"
8+
description = "FLOAT cast to NUMBER(10,3): halves whose double lies below or above them, negatives and nine scaled digits."
9+
columns = [{ name = "x", type = "FLOAT" }]
10+
rows = [
11+
["0"],
12+
["1.0005"],
13+
["-1.0005"],
14+
["4.0035"],
15+
["1.2345"],
16+
["0.1235"],
17+
["2.0015"],
18+
["0.0125"],
19+
["9876543.2105"],
20+
["1234567.8915"],
21+
["1/3"],
22+
["NULL"],
23+
]
24+
expression = "x::NUMBER(10,3)"
25+
tags = ["null", "boundary", "invalid"]
26+
27+
[[case]]
28+
id = "float_number_10_4"
29+
description = "FLOAT cast to NUMBER(10,4): halves whose double lies below or above them, negatives and ten scaled digits."
30+
columns = [{ name = "x", type = "FLOAT" }]
31+
rows = [
32+
["0"],
33+
["0.00015"],
34+
["-0.00015"],
35+
["2.00025"],
36+
["1.00005"],
37+
["1.23455"],
38+
["0.12345"],
39+
["987654.32105"],
40+
["1/3"],
41+
["NULL"],
42+
]
43+
expression = "x::NUMBER(10,4)"
44+
tags = ["null", "boundary", "invalid"]
45+
46+
[[case]]
47+
id = "float_number_10_5"
48+
description = "FLOAT cast to NUMBER(10,5): halves whose double lies below or above them, negatives and ten scaled digits."
49+
columns = [{ name = "x", type = "FLOAT" }]
50+
rows = [
51+
["0"],
52+
["0.123455"],
53+
["2.500005"],
54+
["1.234565"],
55+
["1.270005"],
56+
["-0.000015"],
57+
["98765.432105"],
58+
["12345.678905"],
59+
["1/3"],
60+
["NULL"],
61+
]
62+
expression = "x::NUMBER(10,5)"
63+
tags = ["null", "boundary", "invalid"]
64+
65+
[[case]]
66+
id = "float_number_38_10"
67+
description = "FLOAT cast to NUMBER(38,10) of values below one: halves whose double lies below or above them and negatives."
68+
columns = [{ name = "x", type = "FLOAT" }]
69+
rows = [
70+
["0"],
71+
["0.00000000015"],
72+
["-0.00000000015"],
73+
["0.10000000005"],
74+
["0.99999999995"],
75+
["0.33333333335"],
76+
["0.98765432105"],
77+
["1/3"],
78+
["NULL"],
79+
]
80+
expression = "x::NUMBER(38,10)"
81+
tags = ["null", "boundary", "invalid"]
82+
83+
[[case]]
84+
id = "float_round_then_number_3_4"
85+
description = "ROUND of a FLOAT to 3 and 4 places, then cast to NUMBER(6,3) and NUMBER(9,4), as weights and rates are stored."
86+
columns = [{ name = "x", type = "FLOAT" }]
87+
rows = [
88+
["1.2345"],
89+
["0.1235"],
90+
["2.99995"],
91+
["-1.0005"],
92+
["0.00015"],
93+
["NULL"],
94+
]
95+
query = "SELECT ROUND(x, 3)::NUMBER(6,3) AS w3, ROUND(x, 4)::NUMBER(9,4) AS r4 FROM input ORDER BY ordinal"
96+
order = "ordered"
97+
tags = ["null", "boundary", "invalid"]

‎cases/conversion/text_literal_casts.toml‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,30 @@ rows = [["1"]]
2020
query = "SELECT CAST('twelve' AS NUMBER(10,0)) AS n FROM input ORDER BY ordinal"
2121
order = "ordered"
2222
tags = ["invalid"]
23+
24+
[[case]]
25+
id = "number_literal_casts_to_text"
26+
description = "Integer and decimal literals cast to VARCHAR and VARCHAR(10) beside an input column."
27+
columns = [{ name = "x", type = "NUMBER(10,0)" }]
28+
rows = [["1"], ["NULL"]]
29+
query = "SELECT x, CAST(501 AS VARCHAR) AS a, 501::VARCHAR(10) AS b, CAST(-7 AS VARCHAR) AS c, CAST(0 AS VARCHAR) AS d, CAST(1.50 AS VARCHAR) AS e FROM input ORDER BY ordinal"
30+
order = "ordered"
31+
tags = ["null", "zero", "negative"]
32+
33+
[[case]]
34+
id = "text_literal_minute_timestamps"
35+
description = "String literals with minutes but no seconds cast to TIMESTAMP_NTZ, TIMESTAMP_NTZ(3) and DATE."
36+
columns = [{ name = "x", type = "NUMBER(10,0)" }]
37+
rows = [["1"], ["NULL"]]
38+
query = "SELECT x, CAST('2024-05-01 15:00' AS TIMESTAMP_NTZ) AS t, '2024-05-01 09:05'::TIMESTAMP_NTZ(3) AS t3, CAST('2024-05-01T15:00' AS TIMESTAMP_NTZ) AS iso FROM input ORDER BY ordinal"
39+
order = "ordered"
40+
tags = ["null", "boundary"]
41+
42+
[[case]]
43+
id = "integer_literal_casts_to_text"
44+
description = "Integer literals cast to VARCHAR and VARCHAR(16) beside an input column: positive, negative, zero and thirteen digits."
45+
columns = [{ name = "x", type = "NUMBER(10,0)" }]
46+
rows = [["1"], ["NULL"]]
47+
query = "SELECT x, CAST(501 AS VARCHAR) AS a, 501::VARCHAR(16) AS b, CAST(-7 AS VARCHAR) AS c, CAST(0 AS VARCHAR) AS d, CAST(1234567890123 AS VARCHAR) AS e FROM input ORDER BY ordinal"
48+
order = "ordered"
49+
tags = ["null", "zero", "negative"]
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Casts of VARIANTs built from a FLOAT: a member read as INTEGER or BOOLEAN, an ARRAY cast and an element cast to OBJECT.
2+
category = "semi-structured-typed"
3+
construct = "node:cast"
4+
session_profile = "utc-v1"
5+
6+
[[case]]
7+
id = "held_float_member_integer"
8+
description = "A FLOAT member of OBJECT_CONSTRUCT read as INTEGER and NUMBER(38,0): halves, negatives, a double just below a half and a missing member."
9+
columns = [{ name = "x", type = "FLOAT" }]
10+
rows = [
11+
["1.5"],
12+
["2.5"],
13+
["-2.5"],
14+
["0.49999999999999994"],
15+
["123456.789"],
16+
["1e15"],
17+
["0"],
18+
["NULL"],
19+
]
20+
query = "SELECT OBJECT_CONSTRUCT('r', x):r::INTEGER AS i, OBJECT_CONSTRUCT('r', x):r::NUMBER(38,0) AS n FROM input ORDER BY ordinal"
21+
order = "ordered"
22+
tags = ["null", "boundary", "negative", "zero"]
23+
24+
[[case]]
25+
id = "held_float_object_boolean_member"
26+
description = "A BOOLEAN member of an object that also holds a FLOAT, read as BOOLEAN, with a missing member when the FLOAT is NULL."
27+
columns = [{ name = "x", type = "FLOAT" }]
28+
rows = [
29+
["1.5"],
30+
["-0.25"],
31+
["0"],
32+
["NULL"],
33+
]
34+
query = "SELECT OBJECT_CONSTRUCT('r', x, 'is_positive', x > 0):is_positive::BOOLEAN AS p FROM input ORDER BY ordinal"
35+
order = "ordered"
36+
tags = ["null", "zero", "negative"]
37+
38+
[[case]]
39+
id = "held_float_array_cast"
40+
description = "ARRAY_CONSTRUCT of a FLOAT cast to ARRAY, read by ARRAY_SIZE and TO_JSON, with a NULL element kept as undefined."
41+
columns = [{ name = "x", type = "FLOAT" }]
42+
rows = [
43+
["1.5"],
44+
["0.1"],
45+
["-1e-7"],
46+
["1e300"],
47+
["NULL"],
48+
]
49+
query = "SELECT ARRAY_SIZE(CAST(ARRAY_CONSTRUCT(x) AS ARRAY))::NUMBER(38,0) AS n, TO_JSON(CAST(ARRAY_CONSTRUCT(x) AS ARRAY)) AS j FROM input ORDER BY ordinal"
50+
order = "ordered"
51+
expected_divergence = "json_fidelity"
52+
tags = ["null", "boundary"]
53+
54+
[[case]]
55+
id = "held_float_array_cast_defined"
56+
description = "ARRAY_CONSTRUCT of a non-NULL FLOAT cast to ARRAY, read by ARRAY_SIZE and TO_JSON."
57+
columns = [{ name = "x", type = "FLOAT" }]
58+
rows = [
59+
["1.5"],
60+
["0.1"],
61+
["-1e-7"],
62+
["1e300"],
63+
]
64+
query = "SELECT ARRAY_SIZE(CAST(ARRAY_CONSTRUCT(x) AS ARRAY))::NUMBER(38,0) AS n, TO_JSON(CAST(ARRAY_CONSTRUCT(x) AS ARRAY)) AS j FROM input ORDER BY ordinal"
65+
order = "ordered"
66+
tags = ["boundary", "negative"]
67+
68+
[[case]]
69+
id = "held_float_element_object_insert"
70+
description = "An element of an array of FLOAT-holding objects cast to OBJECT and extended by OBJECT_INSERT, written by TO_JSON."
71+
columns = [{ name = "x", type = "FLOAT" }, { name = "c", type = "VARCHAR" }]
72+
rows = [
73+
["1.5", "'a'"],
74+
["0.1", "'b'"],
75+
["NULL", "'c'"],
76+
["2.5", "NULL"],
77+
]
78+
query = "SELECT TO_JSON(OBJECT_INSERT(ARRAY_CONSTRUCT(OBJECT_CONSTRUCT('r', x))[0]::OBJECT, 'check', c)) AS j FROM input ORDER BY ordinal"
79+
order = "ordered"
80+
tags = ["null", "json-null"]

‎cases/temporal/system_typeof.toml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,12 @@ rows = [["'10:15:00'", "'2026-03-01 10:15:00.123456789'", "'2026-03-01 10:15:00.
1111
query = '''SELECT REGEXP_REPLACE(SYSTEM$TYPEOF(DATEADD('minute', 30, t)), '\\[.*\\]$', '')::VARCHAR AS time_add, REGEXP_REPLACE(SYSTEM$TYPEOF(w::TIME(3)), '\\[.*\\]$', '')::VARCHAR AS time_cast, REGEXP_REPLACE(SYSTEM$TYPEOF(DATEADD('day', 1, w)), '\\[.*\\]$', '')::VARCHAR AS ntz_add, REGEXP_REPLACE(SYSTEM$TYPEOF(DATEADD('day', 1, w3)), '\\[.*\\]$', '')::VARCHAR AS ntz3_add, REGEXP_REPLACE(SYSTEM$TYPEOF(TO_TIMESTAMP(1772359200.123::NUMBER(13, 3))), '\\[.*\\]$', '')::VARCHAR AS epoch_millis, REGEXP_REPLACE(SYSTEM$TYPEOF('2026-03-01 10:15:00'::TIMESTAMP), '\\[.*\\]$', '')::VARCHAR AS bare_timestamp FROM input ORDER BY ordinal'''
1212
order = "ordered"
1313
tags = ["null", "boundary"]
14+
15+
[[case]]
16+
id = "system_typeof_date_add_widening"
17+
description = "SYSTEM$TYPEOF of DATEADD, TIMEADD and TIMESTAMPADD over TIMESTAMP_NTZ(0), (3) and (6) in several units, its [...] suffix removed."
18+
columns = [{ name = "w0", type = "TIMESTAMP_NTZ(0)" }, { name = "w3", type = "TIMESTAMP_NTZ(3)" }, { name = "w6", type = "TIMESTAMP_NTZ(6)" }]
19+
rows = [["'2026-03-01 10:15:00'", "'2026-03-01 10:15:00.123'", "'2026-03-01 10:15:00.123456'"], ["NULL", "NULL", "NULL"]]
20+
query = '''SELECT REGEXP_REPLACE(SYSTEM$TYPEOF(DATEADD('millisecond', 1, w3)), '\\[.*\\]$', '')::VARCHAR AS ms3, REGEXP_REPLACE(SYSTEM$TYPEOF(DATEADD('second', 1, w0)), '\\[.*\\]$', '')::VARCHAR AS s0, REGEXP_REPLACE(SYSTEM$TYPEOF(DATEADD('month', 1, w3)), '\\[.*\\]$', '')::VARCHAR AS m3, REGEXP_REPLACE(SYSTEM$TYPEOF(DATEADD('year', 1, w6)), '\\[.*\\]$', '')::VARCHAR AS y6, REGEXP_REPLACE(SYSTEM$TYPEOF(TIMEADD('hour', 1, w3)), '\\[.*\\]$', '')::VARCHAR AS th3, REGEXP_REPLACE(SYSTEM$TYPEOF(TIMESTAMPADD('day', 1, w0)), '\\[.*\\]$', '')::VARCHAR AS td0, DATEADD('day', 1, w3) AS added FROM input ORDER BY ordinal'''
21+
order = "ordered"
22+
tags = ["null", "boundary"]

‎crates/sqlquack-duckdb/src/floats/constants.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pub const VANISHING_DOUBLE: &str = "1e-19";
1515
/// The power of two a double is scaled by, less its binary exponent, to make it a whole number.
1616
pub const SIGNIFICAND_SHIFT: i32 = 60;
1717
/// The scales recordings show a FLOAT rounded to.
18-
pub const FLOAT_DECIMAL_SCALES: &[u8] = &[2, 6, 8];
18+
pub const FLOAT_DECIMAL_SCALES: &[u8] = &[2, 3, 4, 5, 6, 8, 10];
1919
/// The divergence of a FLOAT rounded to more significant digits than recordings show.
2020
pub const FLOAT_DECIMAL_DIGITS: &str = "float_decimal_digits";
2121
/// The most significant digits of a rounded FLOAT recordings show: its scaled value stays below.

‎crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ use crate::conversion::_helpers::expressions::variants::{to_variant, written_arr
5252
use crate::conversion::_helpers::support::declared::rely_on_declared;
5353
use crate::conversion::_helpers::support::divergence::diverge;
5454
use crate::conversion::_helpers::support::failure::text_failure;
55-
use crate::conversion::_helpers::support::scope::{Context, Emitted, Literal};
55+
use crate::conversion::_helpers::support::scope::{Context, Emitted, Literal, VariantRoot};
5656
use crate::conversion::_helpers::support::shapes::type_family;
5757
use crate::conversion::_helpers::support::site::Site;
5858
use crate::conversion::_helpers::support::tree::{
@@ -115,7 +115,7 @@ pub(crate) fn convert_cast(
115115
{
116116
let _rule = cast_rule(CAST_NUMERIC, log);
117117
let sql = number_literal_cast(&digits, target.data_type, log).map_err(refuse)?;
118-
return Ok(Emitted::new(sql, target.data_type));
118+
return Ok(Emitted::new(bounded(sql, target, log), target.data_type));
119119
}
120120
let trying = matches!(construct.name.as_str(), "try_cast");
121121
if target.data_type == SnowflakeType::Varchar
@@ -134,10 +134,13 @@ pub(crate) fn convert_cast(
134134
}
135135
let operand = convert_held(operand_node, site)?;
136136
if operand.is_variant_held() {
137-
if type_name == OBJECT_TYPE_NAME && !built_object(operand_node) {
137+
if type_name == OBJECT_TYPE_NAME
138+
&& !built_object(operand_node)
139+
&& operand.root != VariantRoot::Element
140+
{
138141
return Err(refuse(
139-
"a VARIANT built from a FLOAT cast to OBJECT, other than an OBJECT_CONSTRUCT, \
140-
which Snowflake may reject as it compiles"
142+
"a VARIANT built from a FLOAT cast to OBJECT, other than an OBJECT_CONSTRUCT or an \
143+
element read from one, which Snowflake may reject as it compiles"
141144
.to_owned(),
142145
));
143146
}
@@ -658,6 +661,12 @@ pub(crate) fn number_literal_text(node: &Value) -> Option<String> {
658661
}
659662
}
660663

664+
/// An integer literal as Snowflake writes it as text: no plus sign, leading zero or negative zero.
665+
fn is_written_integer(text: &str) -> bool {
666+
let digits = text.strip_prefix('-').unwrap_or(text);
667+
is_unsigned_integer(digits) && !(digits.starts_with('0') && text.len() > 1)
668+
}
669+
661670
fn is_plain_decimal(text: &str) -> bool {
662671
let body = text.strip_prefix(['+', '-']).unwrap_or(text);
663672
let (whole, fraction) = body.split_once('.').unwrap_or((body, ""));
@@ -699,6 +708,7 @@ fn number_literal_cast(
699708
cast(&with_leading_zero(text), to, log)
700709
}
701710
SnowflakeType::Float if is_float_spelling(text) => cast(&with_leading_zero(text), to, log),
711+
SnowflakeType::Varchar if is_written_integer(text) => Ok(string_literal(text, log)),
702712
_ => Err(format!(
703713
"casting the literal {text} to {to} has no proven conversion"
704714
)),

‎crates/sqlquack-rules/src/conversion/_helpers/expressions/semi_structured.rs‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ use crate::conversion::constants::{
4343
VARIANT_HELD_UNREAD, VARIANT_INSPECT, VARIANT_NAME, VARIANT_PATH, VARIANT_TYPE_NAME,
4444
};
4545
use crate::conversion::constants::{
46-
IS_INTEGER_NAME, OBJECT_TYPE_NAME, VARIANT_KIND_CAST, VARIANT_KIND_RULE,
46+
IS_INTEGER_NAME, KIND_CAST_TARGETS, VARIANT_KIND_CAST, VARIANT_KIND_RULE,
4747
};
4848

4949
/// PARSE_JSON or TRY_PARSE_JSON of text: raises wherever the two parsers may disagree.
@@ -355,9 +355,12 @@ pub(crate) fn held_cast(
355355
if type_name == VARIANT_NAME {
356356
return Ok(operand);
357357
}
358-
if type_name == OBJECT_TYPE_NAME {
358+
if let Some((_, kind)) = KIND_CAST_TARGETS
359+
.iter()
360+
.find(|(name, _)| *name == type_name)
361+
{
359362
let _kind = log.rule(VARIANT_KIND_CAST);
360-
let checked = json_kind_cast(&variant_json(&operand.sql, log), OBJECT_TYPE_NAME, log);
363+
let checked = json_kind_cast(&variant_json(&operand.sql, log), kind, log);
361364
return Ok(Emitted {
362365
sql: format!(
363366
"(CASE WHEN {checked} IS NULL THEN NULL ELSE {} END)",
@@ -382,9 +385,14 @@ pub(crate) fn held_cast(
382385
diverge(context, JSON_FIDELITY, construct)?;
383386
variant_double(&operand.sql, log)
384387
}
385-
(SnowflakeType::Date, _) => {
388+
(
389+
to @ (SnowflakeType::Date
390+
| SnowflakeType::Boolean
391+
| SnowflakeType::Number { scale: 0, .. }),
392+
_,
393+
) => {
386394
diverge(context, JSON_FIDELITY, construct)?;
387-
json_scalar(&variant_json(&operand.sql, log), SnowflakeType::Date, log)
395+
json_scalar(&variant_json(&operand.sql, log), to, log)
388396
.map_err(|reason| refuse(&reason))?
389397
}
390398
(other, _) => {

‎crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal.rs‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ use crate::conversion::_helpers::support::divergence::diverge;
1818
use crate::conversion::_helpers::support::scope::{Context, Literal};
1919

2020
use crate::conversion::constants::{
21-
HOURS_PER_DAY, MAX_FRACTION_DIGITS, MAX_OFFSET_HOURS, MICROSECOND_DIGITS, MINUTES_PER_HOUR,
22-
NANOSECOND_RANGE_END, NANOSECOND_RANGE_START, OFFSET_WITH_COLON, OFFSET_WITHOUT_COLON,
23-
SESSION_TIMEZONE_UTC, TIMESTAMP_RANGE, TIMESTAMP_TZ_SUBMICRO, ZERO_OFFSETS,
21+
HOURS_PER_DAY, MAX_FRACTION_DIGITS, MAX_OFFSET_HOURS, MICROSECOND_DIGITS, MINUTE_TEXT_LENGTH,
22+
MINUTES_PER_HOUR, NANOSECOND_RANGE_END, NANOSECOND_RANGE_START, OFFSET_WITH_COLON,
23+
OFFSET_WITHOUT_COLON, SESSION_TIMEZONE_UTC, TIMESTAMP_RANGE, TIMESTAMP_TZ_SUBMICRO,
24+
ZERO_OFFSETS,
2425
};
2526

2627
/// A timestamp literal in the recorded ISO form: `YYYY-MM-DD[ HH:MI:SS[.f]][ ±HH:MM]`.
@@ -56,7 +57,9 @@ pub(crate) fn timestamp_literal(
5657
let log = context.log;
5758
match to {
5859
SnowflakeType::TimestampNtz { precision } => {
59-
let parsed = parse_timestamp(text, false).ok_or_else(unproven)?;
60+
let with_seconds = without_seconds(text).then(|| format!("{text}:00"));
61+
let parsed = parse_timestamp(with_seconds.as_deref().unwrap_or(text), false)
62+
.ok_or_else(unproven)?;
6063
fits(&parsed, precision).ok_or_else(unproven)?;
6164
if precision > MICROSECOND_DIGITS && !in_nanosecond_range(parsed.date) {
6265
if context.fixture_body {
@@ -193,6 +196,13 @@ fn in_nanosecond_range(date: (u16, u16, u16)) -> bool {
193196
(NANOSECOND_RANGE_START..NANOSECOND_RANGE_END).contains(&date)
194197
}
195198

199+
/// Whether `text` is `YYYY-MM-DD HH:MI` or `YYYY-MM-DDTHH:MI`, which Snowflake reads with zero seconds.
200+
fn without_seconds(text: &str) -> bool {
201+
text.len() == MINUTE_TEXT_LENGTH
202+
&& text.as_bytes()[MINUTE_TEXT_LENGTH - 3] == b':'
203+
&& parse_timestamp(text, false).is_none()
204+
}
205+
196206
fn parse_timestamp(text: &str, zoned: bool) -> Option<TimestampText<'_>> {
197207
let date = text.get(..10)?;
198208
let parts: Vec<&str> = date.split('-').collect();

0 commit comments

Comments
 (0)