diff --git a/cases/conditional/text_literal_timestamps.toml b/cases/conditional/text_literal_timestamps.toml new file mode 100644 index 000000000..2d19a97f3 --- /dev/null +++ b/cases/conditional/text_literal_timestamps.toml @@ -0,0 +1,32 @@ +# A string literal beside a TIMESTAMP_NTZ(9) branch, as an incremental model's high-water mark +# writes it: the literal is read as the timestamp it spells. +category = "conditional" +construct = "fn:COALESCE" +session_profile = "utc-v1" + +[[case]] +id = "high_water_mark_defaults" +description = "COALESCE, IFNULL and NVL of MAX of a TIMESTAMP_NTZ(9) and a date or timestamp literal, over rows and over none." +columns = [{ name = "g", type = "NUMBER(38,0)" }, { name = "t", type = "TIMESTAMP_NTZ(9)" }] +rows = [ + ["1", "'2026-01-10 08:30:00.123456789'"], + ["1", "'2026-01-02 00:00:00'"], + ["2", "NULL"], + ["3", "NULL"], + ["NULL", "NULL"], +] +query = "SELECT g, COALESCE(MAX(t), '1900-01-01') AS c, IFNULL(MAX(t), '1900-01-01 00:00:00') AS i, NVL(MAX(t), '1970-01-01T12:00:00.5') AS n FROM input GROUP BY g ORDER BY g" +order = "ordered" +tags = ["null", "all-null", "mixed-types"] + +[[case]] +id = "literal_branches_beside_timestamps" +description = "A timestamp literal first, beside a column, and as an IFF or CASE branch beside a TIMESTAMP_NTZ(9)." +columns = [{ name = "t", type = "TIMESTAMP_NTZ(9)" }] +rows = [ + ["'2026-01-10 08:30:00.123456789'"], + ["NULL"], +] +query = "SELECT COALESCE('2000-02-29 23:59:59', t) AS first, COALESCE(t, '2262-04-10') AS last_day, IFF(t IS NULL, '1999-12-31', t) AS i, CASE WHEN t IS NULL THEN t ELSE '2001-01-01 01:01:01.000000001' END AS c FROM input ORDER BY ordinal" +order = "ordered" +tags = ["null", "mixed-types", "boundary"] diff --git a/cases/conversion/cast_batch.toml b/cases/conversion/cast_batch.toml index c37e9417b..9d5a36ce9 100644 --- a/cases/conversion/cast_batch.toml +++ b/cases/conversion/cast_batch.toml @@ -449,4 +449,3 @@ rows = [ ] expression = "n::VARCHAR" tags = ["null", "boundary", "invalid"] -expected_divergence = "timestamp_text_rounding" diff --git a/cases/generated/hazards/cast_to_text__conversion.toml b/cases/generated/hazards/cast_to_text__conversion.toml index 04d37495f..cbb8c5ebf 100644 --- a/cases/generated/hazards/cast_to_text__conversion.toml +++ b/cases/generated/hazards/cast_to_text__conversion.toml @@ -309,7 +309,6 @@ rows = [ expression = "CAST(x0 AS VARCHAR)" tags = ["null", "invalid", "boundary"] origin = "generated: hazards@1" -expected_divergence = "timestamp_text_rounding" [[case]] id = "node_cast_5_0_timestamp_ntz_extreme_years" @@ -676,7 +675,6 @@ rows = [ expression = "TO_VARCHAR(x0)" tags = ["null", "invalid", "boundary"] origin = "generated: hazards@1" -expected_divergence = "timestamp_text_rounding" [[case]] id = "fn_to_varchar_6_0_timestamp_ntz_extreme_years" diff --git a/cases/generated/zoned_texts/column_comparisons.toml b/cases/generated/zoned_texts/column_comparisons.toml index c8d7e73ad..ad4bd126c 100644 --- a/cases/generated/zoned_texts/column_comparisons.toml +++ b/cases/generated/zoned_texts/column_comparisons.toml @@ -233,6 +233,7 @@ query = "SELECT z = '2024-01-01 00:00:00'::TIMESTAMP_NTZ AS r FROM input ORDER B order = "ordered" tags = ["null", "boundary"] origin = "generated: zoned_texts@1" +expected_divergence = "timestamp_tz_submicro" [[case]] id = "timestamp_ltz_column_gte_date" diff --git a/cases/temporal/naive_beside_local.toml b/cases/temporal/naive_beside_local.toml new file mode 100644 index 000000000..416c2b038 --- /dev/null +++ b/cases/temporal/naive_beside_local.toml @@ -0,0 +1,46 @@ +# Staging-model lookbacks: DATEADD and DATEDIFF of a TIMESTAMP_LTZ, and a TIMESTAMP_NTZ compared with a +# DATE or a TIMESTAMP_LTZ, in the UTC session. +category = "temporal" +construct = "fn:DATEADD" +session_profile = "utc-v1" + +[[case]] +id = "dateadd_of_local" +description = "DATEADD of a TIMESTAMP_LTZ by hours, days and months, from values written at other offsets, across a day and a month end." +columns = [{ name = "s", type = "TIMESTAMP_LTZ(9)" }] +rows = [ + ["'2026-01-31 02:00:00 +05:00'"], + ["'2024-02-29 23:30:00.123456 -05:00'"], + ["'1970-01-01 00:00:00 +00:00'"], + ["NULL"], +] +query = "SELECT DATEADD(HOUR, 5, s) AS h, DATEADD(DAY, -3, s) AS d, DATEADD(MONTH, 1, s) AS m FROM input ORDER BY ordinal" +order = "ordered" +tags = ["null", "boundary", "timezone"] + +[[case]] +id = "datediff_with_local" +description = "DATEDIFF between a TIMESTAMP_NTZ or a DATE and a TIMESTAMP_LTZ, in days and hours, both ways round." +columns = [{ name = "t", type = "TIMESTAMP_NTZ(9)" }, { name = "d", type = "DATE" }, { name = "s", type = "TIMESTAMP_LTZ(9)" }] +rows = [ + ["'2026-01-30 23:59:59.999999999'", "'2026-01-30'", "'2026-01-31 02:00:00 +05:00'"], + ["'2026-01-01 00:00:00'", "'2025-12-31'", "'2026-01-01 00:30:00 +01:00'"], + ["NULL", "'2026-01-01'", "'2026-01-01 00:00:00 +00:00'"], +] +query = "SELECT DATEDIFF(DAY, t, s) AS td, DATEDIFF(HOUR, s, t) AS st, DATEDIFF(DAY, d, s) AS dd, DATEDIFF('day', s, d) AS sd FROM input ORDER BY ordinal" +order = "ordered" +tags = ["null", "boundary", "timezone"] + +[[case]] +id = "naive_compared_with_date_and_local" +description = "A TIMESTAMP_NTZ compared with a DATE as its midnight, and with a TIMESTAMP_LTZ by its UTC wall time, down to a nanosecond." +columns = [{ name = "t", type = "TIMESTAMP_NTZ(9)" }, { name = "d", type = "DATE" }, { name = "s", type = "TIMESTAMP_LTZ(9)" }] +rows = [ + ["'2026-01-30 00:00:00'", "'2026-01-30'", "'2026-01-30 05:00:00 +05:00'"], + ["'2026-01-30 00:00:00.000000001'", "'2026-01-30'", "'2026-01-30 00:00:00 +00:00'"], + ["'2026-01-29 23:59:59.999999999'", "'2026-01-30'", "'2026-01-29 19:00:00 -05:00'"], + ["NULL", "'2026-01-30'", "NULL"], +] +query = "SELECT t >= d AS on_or_after, t < d AS before_, t = s AS same, t > s AS after_, s <= t AS local_first, t BETWEEN d AND DATEADD(DAY, 1, d) AS in_day FROM input ORDER BY ordinal" +order = "ordered" +tags = ["null", "boundary", "timezone"] diff --git a/cases/temporal/timestamp_text_digits.toml b/cases/temporal/timestamp_text_digits.toml new file mode 100644 index 000000000..7b4f46667 --- /dev/null +++ b/cases/temporal/timestamp_text_digits.toml @@ -0,0 +1,34 @@ +category = "temporal" +construct = "node:cast" +session_profile = "utc-v1" + +[[case]] +id = "ntz9_to_varchar_dropped_digits" +description = "TO_VARCHAR of a nanosecond naive timestamp keeps three fractional digits. Catches: rounding where Snowflake truncates or the reverse, at .1235 against .1234999, and a carry into the next second, day and year." +columns = [{ name = "t", type = "TIMESTAMP_NTZ(9)" }] +rows = [ + ["'2024-02-29 13:45:01.123499999'"], + ["'2024-02-29 13:45:01.123500000'"], + ["'2024-02-29 13:45:01.123999999'"], + ["'2024-02-29 13:45:01.000500000'"], + ["'2024-02-29 13:45:01.999500000'"], + ["'2024-12-31 23:59:59.999999999'"], + ["'1969-12-31 23:59:59.999500000'"], + ["NULL"], +] +expression = "TO_VARCHAR(t)" +tags = ["null", "boundary"] + +[[case]] +id = "ntz9_cast_to_varchar_dropped_digits" +description = "A nanosecond naive timestamp cast to VARCHAR, the same digits as TO_VARCHAR. Catches: the cast and TO_VARCHAR treating the dropped digits differently." +columns = [{ name = "t", type = "TIMESTAMP_NTZ(9)" }] +rows = [ + ["'2024-02-29 13:45:01.123499999'"], + ["'2024-02-29 13:45:01.123500000'"], + ["'2024-02-29 13:45:01.999500000'"], + ["'2024-12-31 23:59:59.999999999'"], + ["NULL"], +] +expression = "t::VARCHAR" +tags = ["null", "boundary"] diff --git a/coverage/domains/casts.toml b/coverage/domains/casts.toml index 083b1299f..a26eb67d1 100644 --- a/coverage/domains/casts.toml +++ b/coverage/domains/casts.toml @@ -127,7 +127,8 @@ arguments = [ facets = { target = ["text"] } template = "CAST({0} AS VARCHAR)" category = "conversion" -divergences = { "timestamp_ntz.half_millisecond" = "timestamp_text_rounding" } +# Half-millisecond fractions keep their own case, recorded while the dropped digits were unknown. +alone = ["0.timestamp_ntz.half_millisecond"] [[rule.form]] construct = "fn:TO_VARCHAR" @@ -138,9 +139,8 @@ arguments = [ template = "TO_VARCHAR({0})" category = "conversion" variants = ["TO_CHAR({0})"] -divergences = { "timestamp_ntz.half_millisecond" = "timestamp_text_rounding" } # Subnormal FLOATs are written with ten significant digits, and negative zero as -0, as recorded. -alone = ["0.float.subnormal", "0.float.negative_zero"] +alone = ["0.float.subnormal", "0.float.negative_zero", "0.timestamp_ntz.half_millisecond"] [[rule]] id = "rule.cast_boolean" diff --git a/coverage/translation/results-baseline.json b/coverage/translation/results-baseline.json index 9e7b25139..7c002da3d 100644 --- a/coverage/translation/results-baseline.json +++ b/coverage/translation/results-baseline.json @@ -209,6 +209,8 @@ "conditional/nvl.toml#ifnull_text": "matching", "conditional/nvl.toml#nvl_mixed_scales": "matching", "conditional/nvl.toml#nvl_text_and_number": "matching_error", + "conditional/text_literal_timestamps.toml#high_water_mark_defaults": "matching", + "conditional/text_literal_timestamps.toml#literal_branches_beside_timestamps": "matching", "conditional/text_unification.toml#case_without_else_readable_text": "matching", "conditional/text_unification.toml#case_without_else_unreadable_text": "matching", "conditional/text_unification.toml#coalesce_text_with_float_and_timestamp": "refused", @@ -238,7 +240,7 @@ "conversion/cast_batch.toml#float_varchar_special": "matching", "conversion/cast_batch.toml#ltz_to_ntz": "matching", "conversion/cast_batch.toml#ntz_to_varchar": "declared_divergence", - "conversion/cast_batch.toml#ntz_to_varchar_rounding": "declared_divergence", + "conversion/cast_batch.toml#ntz_to_varchar_rounding": "matching", "conversion/cast_batch.toml#tz_coalesce_ntz": "refused", "conversion/cast_batch.toml#tz_to_date_direct": "refused", "conversion/cast_batch.toml#tz_to_ntz_cast_syntax": "refused", @@ -1180,7 +1182,7 @@ "generated/hazards/cast_to_text__conversion.toml#fn_to_varchar_6_0_integer_digits_38": "matching", "generated/hazards/cast_to_text__conversion.toml#fn_to_varchar_6_0_integer_hazards": "matching", "generated/hazards/cast_to_text__conversion.toml#fn_to_varchar_6_0_timestamp_ntz_extreme_years": "declared_divergence", - "generated/hazards/cast_to_text__conversion.toml#fn_to_varchar_6_0_timestamp_ntz_half_millisecond": "declared_divergence", + "generated/hazards/cast_to_text__conversion.toml#fn_to_varchar_6_0_timestamp_ntz_half_millisecond": "matching", "generated/hazards/cast_to_text__conversion.toml#fn_to_varchar_6_0_timestamp_ntz_hazards": "matching", "generated/hazards/cast_to_text__conversion.toml#fn_to_varchar_6_combination_0_boolean": "matching", "generated/hazards/cast_to_text__conversion.toml#fn_to_varchar_6_combination_0_date": "matching", @@ -1213,7 +1215,7 @@ "generated/hazards/cast_to_text__conversion.toml#node_cast_4_combination_0_float": "matching", "generated/hazards/cast_to_text__conversion.toml#node_cast_4_extra_rows": "matching", "generated/hazards/cast_to_text__conversion.toml#node_cast_5_0_timestamp_ntz_extreme_years": "declared_divergence", - "generated/hazards/cast_to_text__conversion.toml#node_cast_5_0_timestamp_ntz_half_millisecond": "declared_divergence", + "generated/hazards/cast_to_text__conversion.toml#node_cast_5_0_timestamp_ntz_half_millisecond": "matching", "generated/hazards/cast_to_text__conversion.toml#node_cast_5_0_timestamp_ntz_hazards": "matching", "generated/hazards/cast_to_text__conversion.toml#node_cast_5_combination_0_timestamp_ntz": "matching", "generated/hazards/cast_to_text__conversion.toml#node_cast_combination_0_integer": "matching", @@ -6277,7 +6279,7 @@ "generated/zoned_texts/cast_tz.toml#cast_tz_wall": "declared_divergence", "generated/zoned_texts/cast_tz.toml#cast_tz_year": "declared_divergence", "generated/zoned_texts/column_comparisons.toml#timestamp_ltz_column_between_texts": "declared_divergence", - "generated/zoned_texts/column_comparisons.toml#timestamp_ltz_column_eq_ntz": "refused", + "generated/zoned_texts/column_comparisons.toml#timestamp_ltz_column_eq_ntz": "declared_divergence", "generated/zoned_texts/column_comparisons.toml#timestamp_ltz_column_eq_offset_text": "declared_divergence", "generated/zoned_texts/column_comparisons.toml#timestamp_ltz_column_eq_t_text": "refused", "generated/zoned_texts/column_comparisons.toml#timestamp_ltz_column_gte_date": "refused", @@ -6582,7 +6584,7 @@ "relational/alias_resolution.toml#order_by_function_alias_shadows_column": "matching", "relational/alias_resolution.toml#qualify_alias_shadows_column": "matching", "relational/alias_resolution.toml#qualify_filter_alias_shadows_column": "matching", - "relational/asof_dates.toml#asof_join_date_against_timestamp": "refused", + "relational/asof_dates.toml#asof_join_date_against_timestamp": "matching", "relational/asof_dates.toml#asof_join_dates_duplicate_left_rows": "matching", "relational/asof_dates.toml#asof_join_dates_empty_right": "matching", "relational/asof_dates.toml#asof_join_dates_greater_equal": "matching", @@ -7410,7 +7412,7 @@ "temporal/current_time_batch2.toml#current_8": "matching", "temporal/current_time_batch2.toml#current_9": "matching", "temporal/current_time_batch2.toml#current_date_shapes": "matching", - "temporal/current_time_batch2.toml#current_timestamp_shape": "refused", + "temporal/current_time_batch2.toml#current_timestamp_shape": "matching", "temporal/date_part.toml#date_part_day": "matching", "temporal/date_part.toml#date_part_dayofweek": "matching", "temporal/date_part.toml#date_part_dayofweekiso": "matching", @@ -7472,6 +7474,9 @@ "temporal/last_day.toml#last_day_month_unit": "refused", "temporal/last_day.toml#last_day_of_dates": "matching", "temporal/last_day.toml#last_day_of_timestamps": "matching", + "temporal/naive_beside_local.toml#dateadd_of_local": "matching", + "temporal/naive_beside_local.toml#datediff_with_local": "matching", + "temporal/naive_beside_local.toml#naive_compared_with_date_and_local": "matching", "temporal/parse_followups.toml#dateadd_amount_above_int32": "matching", "temporal/parse_followups.toml#dateadd_amount_below_int32": "matching", "temporal/parse_followups.toml#dateadd_amount_int32_bounds": "declared_divergence", @@ -7571,6 +7576,8 @@ "temporal/timestamp_precision.toml#ntz_to_precision_6": "matching", "temporal/timestamp_precision.toml#tz9_min_max": "declared_divergence", "temporal/timestamp_precision.toml#tz9_values": "declared_divergence", + "temporal/timestamp_text_digits.toml#ntz9_cast_to_varchar_dropped_digits": "matching", + "temporal/timestamp_text_digits.toml#ntz9_to_varchar_dropped_digits": "matching", "temporal/to_timestamp.toml#cast_text_beyond_nanosecond_range": "declared_divergence", "temporal/to_timestamp.toml#to_date_text": "matching", "temporal/to_timestamp.toml#to_timestamp_format": "matching", diff --git a/crates/sqlquack-policy/src/policy/constants.rs b/crates/sqlquack-policy/src/policy/constants.rs index a02a76364..8a2c2a806 100644 --- a/crates/sqlquack-policy/src/policy/constants.rs +++ b/crates/sqlquack-policy/src/policy/constants.rs @@ -59,8 +59,6 @@ pub const PERCENTILE_INTERPOLATION: &str = "percentile_interpolation"; pub const DATE_TEXT_SPELLING: &str = "date_text_spelling"; /// The divergence of the current date and time, which differ between engines and runs. pub const CURRENT_TIME: &str = "current_time"; -/// The divergence of a timestamp's text where truncating and rounding to milliseconds differ. -pub const TIMESTAMP_TEXT_ROUNDING: &str = "timestamp_text_rounding"; /// The divergence of ANY_VALUE, whose choice among a group's values differs between engines. pub const ANY_VALUE_CHOICE: &str = "any_value_choice"; /// The divergence of MODE, whose choice among values tied for most frequent differs between engines. diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index c6bab09bc..6a89576a4 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -310,7 +310,7 @@ pub const RULES: &[RuleSpec] = &[ constructs: &[ "node:eq", "node:neq", "node:lt", "node:lte", "node:gt", "node:gte", ], - conditions: "Both NUMBER or FLOAT (compared as doubles when one is FLOAT), both VARCHAR (bytewise), both BOOLEAN, both DATE, both TIMESTAMP_NTZ (any precisions) or both zoned (TIMESTAMP_TZ or TIMESTAMP_LTZ, compared as instants). NUMBERs whose common type needs more than 38 digits rely on numeric_range; Snowflake also raises 100046 for values beyond it, as recorded.", + conditions: "Both NUMBER or FLOAT (compared as doubles when one is FLOAT), both VARCHAR (bytewise), both BOOLEAN, both DATE, both TIMESTAMP_NTZ (any precisions) or both zoned (TIMESTAMP_TZ or TIMESTAMP_LTZ, compared as instants). A TIMESTAMP_NTZ beside a DATE reads the DATE as its midnight, and beside a TIMESTAMP_LTZ reads the LTZ as its UTC wall time, relying on session_timezone_utc; both rely on timestamp_range at nanosecond precision. NUMBERs whose common type needs more than 38 digits rely on numeric_range; Snowflake also raises 100046 for values beyond it, as recorded.", failures: &[FailureMode { kind: ErrorKind::NumericOverflow, snowflake: &[NUMBER_OUT_OF_RANGE], @@ -367,7 +367,7 @@ pub const RULES: &[RuleSpec] = &[ "node:intersect", "fn:DECODE", ], - conditions: "Non-literal text branches beside NUMBER branches are read as NUMBER(18,5), beside a FLOAT as FLOAT and beside a TIMESTAMP_NTZ(9) as one, then widened to the unified type, as recorded; a unified NUMBER of more than 5 fraction digits refuses. COALESCE and NVL read every text branch for every row, whichever branch is chosen, so unreadable text raises as Snowflake raises (error 100038). IFF, CASE and DECODE read only the branch chosen, as recorded: an unreadable text branch not chosen raises nothing. A set operation reads a text column beside a NUMBER or FLOAT column the same way, for every row of that branch. NULLIF, LEAST and GREATEST, which no recording shows reading text, refuse text beside other types.", + conditions: "Non-literal text branches beside NUMBER branches are read as NUMBER(18,5), beside a FLOAT as FLOAT and beside a TIMESTAMP_NTZ(9) as one, then widened to the unified type, as recorded; a unified NUMBER of more than 5 fraction digits refuses. A string literal beside a TIMESTAMP_NTZ(9) branch is read as the timestamp it spells, YYYY-MM-DD[ HH:MI:SS[.f]] with a space or T, and a literal outside DuckDB's nanosecond range refuses. COALESCE and NVL read every text branch for every row, whichever branch is chosen, so unreadable text raises as Snowflake raises (error 100038). IFF, CASE and DECODE read only the branch chosen, as recorded: an unreadable text branch not chosen raises nothing. A set operation reads a text column beside a NUMBER or FLOAT column the same way, for every row of that branch. NULLIF, LEAST and GREATEST, which no recording shows reading text, refuse text beside other types.", failures: &[], }, RuleSpec { @@ -417,7 +417,7 @@ pub const RULES: &[RuleSpec] = &[ RuleSpec { id: CAST_TO_TEXT, constructs: &["node:cast"], - conditions: "From NUMBER, BOOLEAN, DATE or VARCHAR; from FLOAT as recorded: max(10, min(15, whole digits + 9)) significant digits as %.{n}g writes them, so ten below 10, one more per whole digit, fifteen from 1e5, plain from 1e-4 to below 1e15 and an exponent outside, NaN, inf, -inf, 0 and -0; from TIMESTAMP_NTZ(9) in the output format YYYY-MM-DD HH24:MI:SS.FF3 where the dropped digits are below half a millisecond, so truncating and rounding agree, raising timestamp_text_rounding otherwise; a length is enforced by raising an error; not TRY_CAST unless from VARCHAR.", + conditions: "From NUMBER, BOOLEAN, DATE or VARCHAR; from FLOAT as recorded: max(10, min(15, whole digits + 9)) significant digits as %.{n}g writes them, so ten below 10, one more per whole digit, fifteen from 1e5, plain from 1e-4 to below 1e15 and an exponent outside, NaN, inf, -inf, 0 and -0; from TIMESTAMP_NTZ(9) in the output format YYYY-MM-DD HH24:MI:SS.FF3, the digits past the millisecond truncated, never rounded or carried, as recorded; a length is enforced by raising an error; not TRY_CAST unless from VARCHAR.", failures: &[FailureMode { kind: ErrorKind::StringTooLong, snowflake: &[STRING_TOO_LONG], @@ -1564,7 +1564,7 @@ pub const RULES: &[RuleSpec] = &[ "fn:TIMEDIFF", "fn:TIMESTAMPDIFF", ], - conditions: "DATE_TRUNC and DATEADD of a TIMESTAMP_TZ known to hold the UTC offset, +00:00 (from CONVERT_TIMEZONE('UTC', x), a cast of a DATE or naive timestamp in the UTC session, or values chosen from such), computed on its UTC wall time and read back at +00:00, keeping its type; DATEDIFF of two such values from their UTC wall times. A UTC-offset value against a naive one, and zoned values of unknown offset, refuse.", + conditions: "DATEADD of a TIMESTAMP_LTZ, and DATEDIFF between one and a DATE, naive or LTZ value, computed on its UTC wall time and relying on session_timezone_utc, DATEADD read back as TIMESTAMP_LTZ. DATE_TRUNC and DATEADD of a TIMESTAMP_TZ known to hold the UTC offset, +00:00 (from CONVERT_TIMEZONE('UTC', x), a cast of a DATE or naive timestamp in the UTC session, or values chosen from such), computed on its UTC wall time and read back at +00:00, keeping its type; DATEDIFF of two such values from their UTC wall times. A UTC-offset value against a naive one, and zoned values of unknown offset, refuse.", failures: &[], }, RuleSpec { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs index d55811335..f78e0294b 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs @@ -18,7 +18,6 @@ use sqlquack_duckdb::blocks::main::varchar_length_try::varchar_length_try; use sqlquack_duckdb::floats::constants::FLOAT_DECIMAL_DIGITS; use sqlquack_duckdb::floats::main::float_to_decimal::float_to_decimal; use sqlquack_duckdb::floats::main::float_to_text::float_to_text; -use sqlquack_duckdb::raises::main::raised::raised; use sqlquack_duckdb::semi_structured::constants::JSON_FIDELITY; use sqlquack_duckdb::semi_structured::main::json_text::json_text; use sqlquack_duckdb::temporal::main::text_to_timestamp::text_to_timestamp; @@ -68,8 +67,7 @@ use crate::conversion::constants::{ VARIANT_NAME, VARIANT_TEXT_FUNCTION, VARIANT_TO_VARCHAR, }; use sqlquack_policy::policy::constants::{ - DATE_TEXT_SPELLING, SESSION_TIMEZONE_UTC, TIMESTAMP_RANGE, TIMESTAMP_TEXT_ROUNDING, - TIMESTAMP_TZ_OFFSET, + DATE_TEXT_SPELLING, SESSION_TIMEZONE_UTC, TIMESTAMP_RANGE, TIMESTAMP_TZ_OFFSET, }; /// Serialised kinds of Snowflake's structured types. @@ -364,17 +362,9 @@ fn cast_sql( } (SnowflakeType::TimestampNtz { precision: 9 }, SnowflakeType::Varchar, _) => { let _rule = cast_rule(CAST_TO_TEXT, log); - diverge(context, TIMESTAMP_TEXT_ROUNDING, construct) - .map_err(|refusal| refusal.reason)?; - let unproven = raised(&format!( - "{TIMESTAMP_TEXT_ROUNDING}: a timestamp whose dropped digits past the millisecond \ - decide between truncating and rounding, which no recording shows" - )); Ok(bounded( format!( - "list_transform([{}], lambda ntz_text: CASE WHEN ((epoch_ns(ntz_text) % 1000000) \ - + 1000000) % 1000000 >= 500000 THEN CAST({unproven} AS VARCHAR) \ - ELSE strftime(ntz_text, {}) END)[1]", + "strftime({}, {})", operand.sql, string_literal(NTZ_OUTPUT_PATTERN, log) ), diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/conditionals.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/conditionals.rs index f5e28e313..e3011f231 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/conditionals.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/conditionals.rs @@ -14,6 +14,7 @@ use sqlquack_snowflake::typing::models::SnowflakeType; use crate::conversion::_helpers::expressions::casts::cast_to; use crate::conversion::_helpers::expressions::operators::{boolean_operand, comparable}; use crate::conversion::_helpers::expressions::scalar::convert_expression; +use crate::conversion::_helpers::expressions::temporal::timestamp_literal; use crate::conversion::_helpers::support::declared::declared_of; use crate::conversion::_helpers::support::divergence::require_widening_range; use crate::conversion::_helpers::support::scope::{Context, Emitted, Literal}; @@ -83,10 +84,17 @@ fn unified_branches( let read = |branch: &Emitted| { branch.data_type == SnowflakeType::Varchar && branch.literal.is_none() && reading.is_some() }; + let read_literal = |branch: &Emitted| match (&branch.literal, reading) { + (Some(Literal::Text(text)), Some(ntz @ SnowflakeType::TimestampNtz { .. })) => { + Some((text.clone(), ntz)) + } + _ => None, + }; let types: Vec = branches .iter() .map(|branch| match (read(branch), reading) { (true, Some(reading)) => reading, + _ if read_literal(branch).is_some() => reading.unwrap_or(branch.data_type), _ => branch.data_type, }) .collect(); @@ -100,12 +108,17 @@ fn unified_branches( let mut cast_branches: Vec = Vec::with_capacity(branches.len()); let mut readings: Vec = Vec::new(); for branch in branches { - let branch = match (read(branch), reading) { - (true, Some(reading)) => { + let branch = match (read(branch), reading, read_literal(branch)) { + (true, Some(reading), _) => { let text = read_text(branch, (reading, target), construct, context)?; readings.push(text.sql.clone()); &text.clone() } + (_, _, Some((text, ntz))) => { + let _rule = context.log.rule(TEXT_BRANCHES); + let sql = timestamp_literal(&text, ntz, (context, construct)).map_err(refuse)?; + &Emitted::new(sql, ntz) + } _ => branch, }; cast_branches.push(match (target, branch.data_type) { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/operators.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/operators.rs index 42757da02..874c98fe7 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/operators.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/operators.rs @@ -29,7 +29,9 @@ use sqlquack_snowflake::typing::models::SnowflakeType; use crate::conversion::_helpers::expressions::casts::cast_to; use crate::conversion::_helpers::expressions::scalar::convert_expression; -use crate::conversion::_helpers::expressions::temporal::{literal_date, timestamp_literal}; +use crate::conversion::_helpers::expressions::temporal::{ + literal_date, temporal_cast, timestamp_literal, +}; use crate::conversion::_helpers::support::divergence::{diverge, require_widening_range}; use crate::conversion::_helpers::support::scope::{Context, Emitted, Literal}; use crate::conversion::_helpers::support::site::Site; @@ -39,11 +41,12 @@ use crate::conversion::_helpers::support::tree::{ use crate::conversion::_helpers::support::typed_leaves::compared_by_text; use crate::conversion::constants::DATE_NAME; use sqlquack_duckdb::blocks::constants::NANOSECOND_DIGITS; +use sqlquack_duckdb::temporal::main::utc_wall_time::utc_wall_time; use crate::conversion::constants::{ COMPARISON, DATE_DAY_ARITHMETIC, DATE_LITERAL_COMPARISON, DAY_UNIT, DECIMAL_ARITHMETIC, - DECIMAL_DIVISION, DECIMAL_MODULO, FLOAT_ARITHMETIC, FLOAT_MODULO, LOGIC, MOD_PAIR, - NULL_ARITHMETIC, NULL_SAFE_COMPARISON, NULL_SUM_PRECISION, TEXT_NUMBER_COMPARISON, + DECIMAL_DIVISION, DECIMAL_MODULO, FLOAT_ARITHMETIC, FLOAT_MODULO, LOGIC, MICROSECOND_DIGITS, + MOD_PAIR, NULL_ARITHMETIC, NULL_SAFE_COMPARISON, NULL_SUM_PRECISION, TEXT_NUMBER_COMPARISON, VARIANT_EQUALITY, }; use sqlquack_policy::policy::constants::{NUMERIC_RANGE, SESSION_TIMEZONE_UTC}; @@ -592,6 +595,20 @@ pub(crate) fn comparable( let left = zoned_literal(left, construct, context)?; return Ok((left, right)); } + ( + ntz @ SnowflakeType::TimestampNtz { .. }, + SnowflakeType::Date | SnowflakeType::TimestampLtz { .. }, + ) => { + let right = beside_naive(right, ntz, construct, context)?; + return Ok((left, right)); + } + ( + SnowflakeType::Date | SnowflakeType::TimestampLtz { .. }, + ntz @ SnowflakeType::TimestampNtz { .. }, + ) => { + let left = beside_naive(left, ntz, construct, context)?; + return Ok((left, right)); + } (SnowflakeType::Varchar, number @ SnowflakeType::Number { .. }) => { let text = number_text(left, (number, &right.sql), construct, context)?; return Ok((text, number_range(right, context))); @@ -617,6 +634,32 @@ pub(crate) fn comparable( } } +/// A DATE, or a TIMESTAMP_LTZ at its UTC wall time, read as the TIMESTAMP_NTZ it is compared with. +fn beside_naive( + emitted: Emitted, + ntz: SnowflakeType, + construct: &Construct, + context: &Context<'_>, +) -> Result { + let refuse = |reason: String| outside_conditions(construct.clone(), reason); + let _rule = context.log.rule(COMPARISON); + let (sql, from) = match emitted.data_type { + SnowflakeType::TimestampLtz { precision } => { + diverge(context, SESSION_TIMEZONE_UTC, construct)?; + let wall = SnowflakeType::TimestampNtz { + precision: precision.min(MICROSECOND_DIGITS), + }; + (utc_wall_time(&emitted.sql, context.log), wall) + } + other => (emitted.sql.clone(), other), + }; + let sql = temporal_cast(&sql, (from, ntz), (context, construct)).map_err(refuse)?; + Ok(Emitted { + declared: emitted.declared, + ..Emitted::new(sql, ntz) + }) +} + /// A NUMBER compared with text, raising where Snowflake's comparison overflows. fn number_range(number: Emitted, context: &Context<'_>) -> Emitted { match number.data_type { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs index 3cee47429..397651f52 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs @@ -382,6 +382,9 @@ pub(crate) fn date_add( } _ => operand, }; + let local = matches!(operand.data_type, SnowflakeType::TimestampLtz { .. }) + .then_some(operand.data_type); + let operand = local_wall(operand, construct, site.context)?; let (operand, zoned) = match operand.data_type { SnowflakeType::Time { .. } => (operand, None), _ => temporal_or_refused(operand, construct, site.context)?, @@ -409,7 +412,9 @@ pub(crate) fn date_add( ) .map_err(refuse)?; let (sql, data_type) = match operand.data_type { - SnowflakeType::TimestampNtz { precision } if precision < MAX_FRACTION_DIGITS_U8 => { + SnowflakeType::TimestampNtz { precision } + if precision < MAX_FRACTION_DIGITS_U8 && local.is_none() => + { let nanoseconds = SnowflakeType::TimestampNtz { precision: MAX_FRACTION_DIGITS_U8, }; @@ -423,6 +428,12 @@ pub(crate) fn date_add( } other => (sql, other), }; + if let Some(local) = local { + return Ok(Emitted { + declared: operand.declared, + ..Emitted::new(utc_zoned(&sql, site.context.log), local) + }); + } Ok(rezoned( Emitted { declared: operand.declared, @@ -433,6 +444,28 @@ pub(crate) fn date_add( )) } +/// A TIMESTAMP_LTZ as its wall time in the UTC session it relies on; other values as given. +fn local_wall( + emitted: Emitted, + construct: &Construct, + context: &Context<'_>, +) -> Result { + let SnowflakeType::TimestampLtz { precision } = emitted.data_type else { + return Ok(emitted); + }; + diverge(context, SESSION_TIMEZONE_UTC, construct)?; + let _zoned = context.log.rule(UTC_OFFSET_ARITHMETIC); + Ok(Emitted { + declared: emitted.declared.clone(), + ..Emitted::new( + utc_wall_time(&emitted.sql, context.log), + SnowflakeType::TimestampNtz { + precision: precision.min(MICROSECOND_DIGITS), + }, + ) + }) +} + /// DATEDIFF(unit, start, end) with a keyword unit. pub(crate) fn date_diff(payload: &Value, site: Site<'_>) -> Result { let construct = node_construct("date_diff"); @@ -557,6 +590,7 @@ fn date_diff_operand( _, ) => Ok(emitted), (SnowflakeType::TimestampTz { .. }, _) => Ok(emitted), + (SnowflakeType::TimestampLtz { .. }, _) => local_wall(emitted, construct, site.context), (SnowflakeType::Varchar, Some(Literal::Text(text))) if is_iso_date(text) => { Ok(Emitted::new( cast(&emitted.sql, SnowflakeType::Date, site.context.log) diff --git a/crates/sqlquack/tests/cli/test_convert.rs b/crates/sqlquack/tests/cli/test_convert.rs index 304766dd3..0192a9755 100644 --- a/crates/sqlquack/tests/cli/test_convert.rs +++ b/crates/sqlquack/tests/cli/test_convert.rs @@ -1059,6 +1059,34 @@ fn given_a_subquery_operand_when_converting_and_running_then_it_is_bound_outside } } +#[test] +fn given_a_high_water_mark_with_a_text_default_when_running_then_the_literal_reads_as_a_timestamp() +{ + let test_cases = [ + ValueTestCase { + description: "with no rows, COALESCE of MAX and a date literal gives that midnight", + sql: "WITH events AS (SELECT '2026-01-10 00:00:00'::TIMESTAMP_NTZ AS updated_at) \ + SELECT COALESCE(MAX(updated_at), '1900-01-01')::VARCHAR AS v FROM events \ + WHERE updated_at < '2000-01-01'", + expected_value: "1900-01-01 00:00:00.000", + }, + ValueTestCase { + description: "with rows, NVL keeps the latest timestamp over the literal", + sql: "WITH events AS (SELECT '2026-01-10 08:30:00'::TIMESTAMP_NTZ AS updated_at) \ + SELECT NVL(MAX(updated_at), '1900-01-01 00:00:00')::VARCHAR AS v FROM events", + expected_value: "2026-01-10 08:30:00.000", + }, + ]; + for test_case in test_cases { + assert_eq!( + converted_first_value(test_case.sql), + test_case.expected_value, + "{}", + test_case.description + ); + } +} + #[test] fn given_connect_by_root_of_a_qualified_column_when_running_then_it_reads_the_root_value() { let test_cases = [ValueTestCase { diff --git a/evidence/results/3a8403ac09f96f83b731bb5847924b5bf81a026b23bf1668e50f89fd66ead3d4.arrow b/evidence/results/3a8403ac09f96f83b731bb5847924b5bf81a026b23bf1668e50f89fd66ead3d4.arrow new file mode 100644 index 000000000..aa3fcea46 Binary files /dev/null and b/evidence/results/3a8403ac09f96f83b731bb5847924b5bf81a026b23bf1668e50f89fd66ead3d4.arrow differ diff --git a/evidence/results/3a8403ac09f96f83b731bb5847924b5bf81a026b23bf1668e50f89fd66ead3d4.json b/evidence/results/3a8403ac09f96f83b731bb5847924b5bf81a026b23bf1668e50f89fd66ead3d4.json new file mode 100644 index 000000000..92ccb6e8d --- /dev/null +++ b/evidence/results/3a8403ac09f96f83b731bb5847924b5bf81a026b23bf1668e50f89fd66ead3d4.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "cb12d4b4f888a6e2dbbded5cbbc58725fe78d19fd09fc40fe90e7fa3cd3786f1", + "case_hash": "3a8403ac09f96f83b731bb5847924b5bf81a026b23bf1668e50f89fd66ead3d4", + "columns": [ + { + "internal_size": 134217728, + "name": "VALUE", + "nullable": true, + "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": "9facf26c74ba5676536e7f14e1e917a291e4ea01edb02772aa7d7651269ed5bb", + "recorded_at": "2026-11-06T04:48:58.285226+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 8, + "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/3f00fea3102b9bae1aee3f825efadb5560f822ce8e2f264af5d2820224b3b515.arrow b/evidence/results/3f00fea3102b9bae1aee3f825efadb5560f822ce8e2f264af5d2820224b3b515.arrow new file mode 100644 index 000000000..b4f2e1322 Binary files /dev/null and b/evidence/results/3f00fea3102b9bae1aee3f825efadb5560f822ce8e2f264af5d2820224b3b515.arrow differ diff --git a/evidence/results/3f00fea3102b9bae1aee3f825efadb5560f822ce8e2f264af5d2820224b3b515.json b/evidence/results/3f00fea3102b9bae1aee3f825efadb5560f822ce8e2f264af5d2820224b3b515.json new file mode 100644 index 000000000..192537c0c --- /dev/null +++ b/evidence/results/3f00fea3102b9bae1aee3f825efadb5560f822ce8e2f264af5d2820224b3b515.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "08f78a751aa52b4d66ac2e18d6d7b00f90a655e966e9dff1f75a02bddbbde80d", + "case_hash": "3f00fea3102b9bae1aee3f825efadb5560f822ce8e2f264af5d2820224b3b515", + "columns": [ + { + "internal_size": 134217728, + "name": "VALUE", + "nullable": true, + "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": "760321f16e09dee85545194d4cf91159759f5b19e25e1af9e53f1829288d46c2", + "recorded_at": "2026-11-06T04:48:58.285226+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 5, + "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/42940f973d949a4c037dc96964c93874f8057de10ad6196ac4ed2c2a76437453.arrow b/evidence/results/42940f973d949a4c037dc96964c93874f8057de10ad6196ac4ed2c2a76437453.arrow new file mode 100644 index 000000000..d4701d854 Binary files /dev/null and b/evidence/results/42940f973d949a4c037dc96964c93874f8057de10ad6196ac4ed2c2a76437453.arrow differ diff --git a/evidence/results/42940f973d949a4c037dc96964c93874f8057de10ad6196ac4ed2c2a76437453.json b/evidence/results/42940f973d949a4c037dc96964c93874f8057de10ad6196ac4ed2c2a76437453.json new file mode 100644 index 000000000..d3da2c3fb --- /dev/null +++ b/evidence/results/42940f973d949a4c037dc96964c93874f8057de10ad6196ac4ed2c2a76437453.json @@ -0,0 +1,79 @@ +{ + "arrow_sha256": "896f4fea489b54e6b4c2de129f0ec0667318e8f237988ff30d7d685cff771261", + "case_hash": "42940f973d949a4c037dc96964c93874f8057de10ad6196ac4ed2c2a76437453", + "columns": [ + { + "internal_size": null, + "name": "H", + "nullable": true, + "precision": 0, + "scale": 9, + "type_code": 6, + "type_name": "TIMESTAMP_LTZ" + }, + { + "internal_size": null, + "name": "D", + "nullable": true, + "precision": 0, + "scale": 9, + "type_code": 6, + "type_name": "TIMESTAMP_LTZ" + }, + { + "internal_size": null, + "name": "M", + "nullable": true, + "precision": 0, + "scale": 9, + "type_code": 6, + "type_name": "TIMESTAMP_LTZ" + } + ], + "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": "dae3e93dc0b6fbe6bbab18beff032fd772afa95c111166eb76e0bc5997b474f3", + "recorded_at": "2026-11-06T03:36:24.931779+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 4, + "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/5fa24637fa5b7ef846a484b7ca96f5a739162e8a579424396f491d316cc61895.arrow b/evidence/results/5fa24637fa5b7ef846a484b7ca96f5a739162e8a579424396f491d316cc61895.arrow new file mode 100644 index 000000000..00244cb60 Binary files /dev/null and b/evidence/results/5fa24637fa5b7ef846a484b7ca96f5a739162e8a579424396f491d316cc61895.arrow differ diff --git a/evidence/results/5fa24637fa5b7ef846a484b7ca96f5a739162e8a579424396f491d316cc61895.json b/evidence/results/5fa24637fa5b7ef846a484b7ca96f5a739162e8a579424396f491d316cc61895.json new file mode 100644 index 000000000..7c7797a2a --- /dev/null +++ b/evidence/results/5fa24637fa5b7ef846a484b7ca96f5a739162e8a579424396f491d316cc61895.json @@ -0,0 +1,106 @@ +{ + "arrow_sha256": "17951cf767cf38ed0758830bf729a50391c532f2765eb0f951b506c45d2c4ea0", + "case_hash": "5fa24637fa5b7ef846a484b7ca96f5a739162e8a579424396f491d316cc61895", + "columns": [ + { + "internal_size": null, + "name": "ON_OR_AFTER", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 13, + "type_name": "BOOLEAN" + }, + { + "internal_size": null, + "name": "BEFORE_", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 13, + "type_name": "BOOLEAN" + }, + { + "internal_size": null, + "name": "SAME", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 13, + "type_name": "BOOLEAN" + }, + { + "internal_size": null, + "name": "AFTER_", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 13, + "type_name": "BOOLEAN" + }, + { + "internal_size": null, + "name": "LOCAL_FIRST", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 13, + "type_name": "BOOLEAN" + }, + { + "internal_size": null, + "name": "IN_DAY", + "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": "8967f07175368b9d2d6e617011d0defa91f0413b94059057aa037c93867d370e", + "recorded_at": "2026-11-06T03:36:24.931779+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 4, + "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/6dad0e8d45979ff8519d3e46e9f4b5d17491e93d44169d9bdfeb96929d5c7b24.arrow b/evidence/results/6dad0e8d45979ff8519d3e46e9f4b5d17491e93d44169d9bdfeb96929d5c7b24.arrow new file mode 100644 index 000000000..dbf0818d8 Binary files /dev/null and b/evidence/results/6dad0e8d45979ff8519d3e46e9f4b5d17491e93d44169d9bdfeb96929d5c7b24.arrow differ diff --git a/evidence/results/6dad0e8d45979ff8519d3e46e9f4b5d17491e93d44169d9bdfeb96929d5c7b24.json b/evidence/results/6dad0e8d45979ff8519d3e46e9f4b5d17491e93d44169d9bdfeb96929d5c7b24.json new file mode 100644 index 000000000..8440d3434 --- /dev/null +++ b/evidence/results/6dad0e8d45979ff8519d3e46e9f4b5d17491e93d44169d9bdfeb96929d5c7b24.json @@ -0,0 +1,88 @@ +{ + "arrow_sha256": "4a210544133621eed64f3ea62178d0228a0515cff72b4ef082685a4b83b609b6", + "case_hash": "6dad0e8d45979ff8519d3e46e9f4b5d17491e93d44169d9bdfeb96929d5c7b24", + "columns": [ + { + "internal_size": null, + "name": "FIRST", + "nullable": true, + "precision": 0, + "scale": 9, + "type_code": 8, + "type_name": "TIMESTAMP_NTZ" + }, + { + "internal_size": null, + "name": "LAST_DAY", + "nullable": false, + "precision": 0, + "scale": 9, + "type_code": 8, + "type_name": "TIMESTAMP_NTZ" + }, + { + "internal_size": null, + "name": "I", + "nullable": true, + "precision": 0, + "scale": 9, + "type_code": 8, + "type_name": "TIMESTAMP_NTZ" + }, + { + "internal_size": null, + "name": "C", + "nullable": true, + "precision": 0, + "scale": 9, + "type_code": 8, + "type_name": "TIMESTAMP_NTZ" + } + ], + "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": "107e0ec737104cc1c71b342ba5b507c82597a85470f040ffc44c4d49965ca764", + "recorded_at": "2026-11-06T03:36:24.931779+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/e41ff65437b253d98bdfc2639a79e2f560ceae4a9a3b897ecb19d64ece458043.arrow b/evidence/results/e41ff65437b253d98bdfc2639a79e2f560ceae4a9a3b897ecb19d64ece458043.arrow new file mode 100644 index 000000000..4d7b759f2 Binary files /dev/null and b/evidence/results/e41ff65437b253d98bdfc2639a79e2f560ceae4a9a3b897ecb19d64ece458043.arrow differ diff --git a/evidence/results/e41ff65437b253d98bdfc2639a79e2f560ceae4a9a3b897ecb19d64ece458043.json b/evidence/results/e41ff65437b253d98bdfc2639a79e2f560ceae4a9a3b897ecb19d64ece458043.json new file mode 100644 index 000000000..180e02cef --- /dev/null +++ b/evidence/results/e41ff65437b253d98bdfc2639a79e2f560ceae4a9a3b897ecb19d64ece458043.json @@ -0,0 +1,88 @@ +{ + "arrow_sha256": "11f8d7995034fb326699fa7809abd3f606fabb856fe91f67366744337e18609f", + "case_hash": "e41ff65437b253d98bdfc2639a79e2f560ceae4a9a3b897ecb19d64ece458043", + "columns": [ + { + "internal_size": null, + "name": "TD", + "nullable": true, + "precision": 9, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "ST", + "nullable": true, + "precision": 9, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "DD", + "nullable": false, + "precision": 9, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "SD", + "nullable": false, + "precision": 9, + "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": "b2132ef778321bb54260886e3f2e44a7d7b28340131cae270a01a6ede0d6bbea", + "recorded_at": "2026-11-06T03:36:24.931779+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 3, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.40.102" +} diff --git a/evidence/results/e522587e7cc4e2782855b26f0dcec66e151d7f27c84a739fffe46266ffe965df.arrow b/evidence/results/e522587e7cc4e2782855b26f0dcec66e151d7f27c84a739fffe46266ffe965df.arrow new file mode 100644 index 000000000..c46b86b19 Binary files /dev/null and b/evidence/results/e522587e7cc4e2782855b26f0dcec66e151d7f27c84a739fffe46266ffe965df.arrow differ diff --git a/evidence/results/e522587e7cc4e2782855b26f0dcec66e151d7f27c84a739fffe46266ffe965df.json b/evidence/results/e522587e7cc4e2782855b26f0dcec66e151d7f27c84a739fffe46266ffe965df.json new file mode 100644 index 000000000..57fcd6251 --- /dev/null +++ b/evidence/results/e522587e7cc4e2782855b26f0dcec66e151d7f27c84a739fffe46266ffe965df.json @@ -0,0 +1,88 @@ +{ + "arrow_sha256": "ffe868f872fbd963b22f1234c72087bd778d67abaf3c66a934390b7eac808f29", + "case_hash": "e522587e7cc4e2782855b26f0dcec66e151d7f27c84a739fffe46266ffe965df", + "columns": [ + { + "internal_size": null, + "name": "G", + "nullable": true, + "precision": 38, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "C", + "nullable": false, + "precision": 0, + "scale": 9, + "type_code": 8, + "type_name": "TIMESTAMP_NTZ" + }, + { + "internal_size": null, + "name": "I", + "nullable": false, + "precision": 0, + "scale": 9, + "type_code": 8, + "type_name": "TIMESTAMP_NTZ" + }, + { + "internal_size": null, + "name": "N", + "nullable": false, + "precision": 0, + "scale": 9, + "type_code": 8, + "type_name": "TIMESTAMP_NTZ" + } + ], + "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": "2eb6c26ad980ee535a26e189d2f1b76d5824b81176677935b00c4015f77bd90a", + "recorded_at": "2026-11-06T03:36:24.931779+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 4, + "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/scripts/family_cases/_helpers/zoned_texts.py b/scripts/family_cases/_helpers/zoned_texts.py index c5d4f2eca..a7653db26 100644 --- a/scripts/family_cases/_helpers/zoned_texts.py +++ b/scripts/family_cases/_helpers/zoned_texts.py @@ -17,6 +17,7 @@ ZONED_LENIENT_FORMS, ZONED_LITERAL_TYPES, ZONED_LITERALS, + ZONED_NAIVE_COMPARISONS, ZONED_OFFSET_DIVERGENCE, ZONED_OFFSET_LITERALS, ZONED_OFFSET_PROJECTIONS, @@ -185,7 +186,10 @@ def comparison_case(*, column_type: str, comparison: tuple[str, str]) -> Case: order="ordered", tags=("null", "boundary"), expected_divergence=( - ZONED_SUBMICRO_DIVERGENCE if comparison_slug in ZONED_TEXT_COMPARISONS else None + ZONED_SUBMICRO_DIVERGENCE + if comparison_slug in ZONED_TEXT_COMPARISONS + or (column_type, comparison_slug) in ZONED_NAIVE_COMPARISONS + else None ), ) diff --git a/scripts/family_cases/constants.py b/scripts/family_cases/constants.py index ba5e0e3b9..f8914e377 100644 --- a/scripts/family_cases/constants.py +++ b/scripts/family_cases/constants.py @@ -143,3 +143,4 @@ ZONED_TEXT_COMPARISONS: frozenset[str] = frozenset( {"gte_naive_text", "lt_date_text", "eq_offset_text", "between_texts"} ) +ZONED_NAIVE_COMPARISONS: frozenset[tuple[str, str]] = frozenset({("TIMESTAMP_LTZ(9)", "eq_ntz")}) diff --git a/sqlquack.toml b/sqlquack.toml index bd8f43939..abc88d421 100644 --- a/sqlquack.toml +++ b/sqlquack.toml @@ -380,7 +380,7 @@ failure = "value" reason = "A TIMESTAMP_TZ source column's values carry their own offsets, which DuckDB does not keep. Where the schema declares a column's offset (only +00:00 is supported), operations depending on the offset, such as its wall time, date or parts, convert as for a value known to hold +00:00, and rely on `declared_offset:`. A value with another offset gives a wrong result silently. Strict mode refuses it unless the caller confirms the declared offsets." [divergences.session_timezone_utc] -constructs = ["node:cast", "node:try_cast", "fn:TRY_TO_TIMESTAMP_TZ", "fn:CONVERT_TIMEZONE", "fn:CURRENT_DATE", "fn:CURRENT_TIMESTAMP", "fn:DATE_PART", "node:extract", "node:year", "node:quarter", "node:month", "node:day", "node:hour", "node:minute", "node:second", "fn:TO_VARIANT", "fn:OBJECT_CONSTRUCT", "fn:OBJECT_CONSTRUCT_KEEP_NULL", "fn:OBJECT_INSERT", "fn:ARRAY_CONSTRUCT", "node:map_func", "fn:TO_TIMESTAMP_TZ", "fn:TO_TIMESTAMP_LTZ", "fn:TRY_TO_TIMESTAMP_LTZ", "node:eq", "node:neq", "node:lt", "node:lte", "node:gt", "node:gte", "node:between"] +constructs = ["node:cast", "node:try_cast", "fn:TRY_TO_TIMESTAMP_TZ", "fn:CONVERT_TIMEZONE", "fn:CURRENT_DATE", "fn:CURRENT_TIMESTAMP", "fn:DATEADD", "fn:DATEDIFF", "node:date_diff", "fn:DATE_PART", "node:extract", "node:year", "node:quarter", "node:month", "node:day", "node:hour", "node:minute", "node:second", "fn:TO_VARIANT", "fn:OBJECT_CONSTRUCT", "fn:OBJECT_CONSTRUCT_KEEP_NULL", "fn:OBJECT_INSERT", "fn:ARRAY_CONSTRUCT", "node:map_func", "fn:TO_TIMESTAMP_TZ", "fn:TO_TIMESTAMP_LTZ", "fn:TRY_TO_TIMESTAMP_LTZ", "node:eq", "node:neq", "node:lt", "node:lte", "node:gt", "node:gte", "node:between"] assumption = true default = "warn" strict = "refuse" @@ -430,7 +430,7 @@ failure = "loud" reason = "User decision 2026-10-25: a DATE, TIMESTAMP or TIME inside a VARIANT keeps its type in Snowflake, which DuckDB JSON cannot. The converted SQL stores the JSON string Snowflake writes for the value (batch 8 recorded YYYY-MM-DD, YYYY-MM-DD HH:MI:SS.FFF and a ' Z' suffix for TIMESTAMP_LTZ in UTC), raising where that text drops digits or an offset. TYPEOF and IS_DATE-style reads of a string in such a text raise rather than answer VARCHAR or false. TIMESTAMP_TZ (its offset is not kept), TIME and BINARY (its hex text is indistinguishable from plain strings) stay refused." [divergences.timestamp_range] -constructs = ["node:cast", "node:try_cast", "node:add", "node:sub", "fn:DATEADD", "fn:TIMEADD", "fn:TIMESTAMPADD", "fn:CONVERT_TIMEZONE", "fn:TO_TIMESTAMP", "fn:TO_TIMESTAMP_NTZ", "fn:TRY_TO_TIMESTAMP", "fn:TRY_TO_TIMESTAMP_NTZ", "fn:TRY_TO_TIMESTAMP_TZ", "fn:TIMESTAMP_NTZ_FROM_PARTS", "node:eq", "node:neq", "node:lt", "node:lte", "node:gt", "node:gte", "node:in", "fn:TO_TIMESTAMP_TZ", "fn:TO_TIMESTAMP_LTZ", "fn:TRY_TO_TIMESTAMP_LTZ"] +constructs = ["node:cast", "node:try_cast", "node:add", "node:sub", "fn:DATEADD", "fn:TIMEADD", "fn:TIMESTAMPADD", "fn:CONVERT_TIMEZONE", "fn:TO_TIMESTAMP", "fn:TO_TIMESTAMP_NTZ", "fn:TRY_TO_TIMESTAMP", "fn:TRY_TO_TIMESTAMP_NTZ", "fn:TRY_TO_TIMESTAMP_TZ", "fn:TIMESTAMP_NTZ_FROM_PARTS", "node:eq", "node:between", "node:neq", "node:lt", "node:lte", "node:gt", "node:gte", "node:in", "fn:TO_TIMESTAMP_TZ", "fn:TO_TIMESTAMP_LTZ", "fn:TRY_TO_TIMESTAMP_LTZ"] default = "warn" strict = "refuse" failure = "loud" @@ -562,10 +562,3 @@ default = "warn" strict = "refuse" failure = "loud" reason = "Recordings show a FLOAT rounded to NUMBER scales 2, 6 and 8 with up to ten digits once scaled, scale padding included, where Snowflake rounds the double's exact value half away from zero, also where the shortest decimal reading rounds the other way. Other scales refuse; a value with more significant digits, where Snowflake may round a shorter decimal reading instead, raises. The failure is loud, never a wrong value." - -[divergences.timestamp_text_rounding] -constructs = ["node:cast", "fn:TO_VARCHAR", "fn:TO_CHAR"] -default = "warn" -strict = "refuse" -failure = "loud" -reason = "A TIMESTAMP_NTZ(9) is written with three fractional digits, and recordings do not yet show whether Snowflake truncates or rounds the digits it drops. Where the two agree, a dropped part below half a millisecond, the converted SQL writes the text; otherwise it raises. The failure is loud, never a wrong value."