From f91407f1ace4cb7b400cdc5f1d22c64c1a81f68f Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:00:59 +0000 Subject: [PATCH 01/11] fix: read a timestamp literal beside a TIMESTAMP_NTZ branch COALESCE(MAX(updated_at), '1900-01-01') is the incremental high-water mark idiom. A string literal beside a TIMESTAMP_NTZ(9) branch in COALESCE, IFNULL, NVL, IFF or CASE is now read as the timestamp it spells, checked when converting: text outside DuckDB's nanosecond range or in an unproven form still refuses. --- .../conditional/text_literal_timestamps.toml | 31 +++++++++++++++++++ .../sqlquack-rules/src/catalogue/constants.rs | 2 +- .../_helpers/expressions/conditionals.rs | 17 ++++++++-- crates/sqlquack/tests/cli/test_convert.rs | 28 +++++++++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 cases/conditional/text_literal_timestamps.toml diff --git a/cases/conditional/text_literal_timestamps.toml b/cases/conditional/text_literal_timestamps.toml new file mode 100644 index 000000000..c6f9417e5 --- /dev/null +++ b/cases/conditional/text_literal_timestamps.toml @@ -0,0 +1,31 @@ +# 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"], +] +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", "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/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index c6bab09bc..9e6e6f485 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -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 { 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/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 { From 8b779bd0d90c414ccee9a92956112a7feb50f36b Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:08:43 +0000 Subject: [PATCH 02/11] fix: convert lookbacks from CURRENT_TIMESTAMP and naive timestamps beside dates Staging models filter with occurred_at >= DATEADD(day, -3, CURRENT_TIMESTAMP()) and BETWEEN DATEADD(day, -7, CURRENT_DATE()) AND CURRENT_DATE(). DATEADD and DATEDIFF of a TIMESTAMP_LTZ now compute on its UTC wall time, relying on session_timezone_utc. A TIMESTAMP_NTZ compared with a DATE reads the DATE as its midnight, and compared with a TIMESTAMP_LTZ reads it at its UTC wall time. --- .../conditional/text_literal_timestamps.toml | 3 +- cases/temporal/naive_beside_local.toml | 46 ++++++++++++++++ .../sqlquack-rules/src/catalogue/constants.rs | 4 +- .../_helpers/expressions/operators.rs | 53 +++++++++++++++++-- .../expressions/temporal_functions.rs | 36 ++++++++++++- sqlquack.toml | 4 +- 6 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 cases/temporal/naive_beside_local.toml diff --git a/cases/conditional/text_literal_timestamps.toml b/cases/conditional/text_literal_timestamps.toml index c6f9417e5..2d19a97f3 100644 --- a/cases/conditional/text_literal_timestamps.toml +++ b/cases/conditional/text_literal_timestamps.toml @@ -13,10 +13,11 @@ rows = [ ["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", "mixed-types"] +tags = ["null", "all-null", "mixed-types"] [[case]] id = "literal_branches_beside_timestamps" 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/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index 9e6e6f485..dc0933f8e 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], @@ -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/operators.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/operators.rs index 42757da02..4324ed392 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,24 @@ pub(crate) fn comparable( let left = zoned_literal(left, construct, context)?; return Ok((left, right)); } + (ntz @ SnowflakeType::TimestampNtz { .. }, other) + if matches!( + other, + SnowflakeType::Date | SnowflakeType::TimestampLtz { .. } + ) => + { + let right = beside_naive(right, ntz, construct, context)?; + return Ok((left, right)); + } + (other, ntz @ SnowflakeType::TimestampNtz { .. }) + if matches!( + other, + SnowflakeType::Date | SnowflakeType::TimestampLtz { .. } + ) => + { + 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 +638,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/sqlquack.toml b/sqlquack.toml index bd8f43939..bc4e5b444 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" From 354f141246746ebbd60d029ed1b6d8b98371aa38 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:40:10 +0000 Subject: [PATCH 03/11] evidence: record the incremental-idiom timestamp cases Snowflake results for the five cases in cases/conditional/text_literal_timestamps.toml and cases/temporal/naive_beside_local.toml at 8b779bd. --- ...3874f8057de10ad6196ac4ed2c2a76437453.arrow | Bin 0 -> 914 bytes ...93874f8057de10ad6196ac4ed2c2a76437453.json | 79 +++++++++++++ ...f5a739162e8a579424396f491d316cc61895.arrow | Bin 0 -> 4250 bytes ...6f5a739162e8a579424396f491d316cc61895.json | 106 ++++++++++++++++++ ...b5d17491e93d44169d9bdfeb96929d5c7b24.arrow | Bin 0 -> 978 bytes ...4b5d17491e93d44169d9bdfeb96929d5c7b24.json | 88 +++++++++++++++ ...e2f560ceae4a9a3b897ecb19d64ece458043.arrow | Bin 0 -> 3034 bytes ...9e2f560ceae4a9a3b897ecb19d64ece458043.json | 88 +++++++++++++++ ...c66e151d7f27c84a739fffe46266ffe965df.arrow | Bin 0 -> 1546 bytes ...ec66e151d7f27c84a739fffe46266ffe965df.json | 88 +++++++++++++++ 10 files changed, 449 insertions(+) create mode 100644 evidence/results/42940f973d949a4c037dc96964c93874f8057de10ad6196ac4ed2c2a76437453.arrow create mode 100644 evidence/results/42940f973d949a4c037dc96964c93874f8057de10ad6196ac4ed2c2a76437453.json create mode 100644 evidence/results/5fa24637fa5b7ef846a484b7ca96f5a739162e8a579424396f491d316cc61895.arrow create mode 100644 evidence/results/5fa24637fa5b7ef846a484b7ca96f5a739162e8a579424396f491d316cc61895.json create mode 100644 evidence/results/6dad0e8d45979ff8519d3e46e9f4b5d17491e93d44169d9bdfeb96929d5c7b24.arrow create mode 100644 evidence/results/6dad0e8d45979ff8519d3e46e9f4b5d17491e93d44169d9bdfeb96929d5c7b24.json create mode 100644 evidence/results/e41ff65437b253d98bdfc2639a79e2f560ceae4a9a3b897ecb19d64ece458043.arrow create mode 100644 evidence/results/e41ff65437b253d98bdfc2639a79e2f560ceae4a9a3b897ecb19d64ece458043.json create mode 100644 evidence/results/e522587e7cc4e2782855b26f0dcec66e151d7f27c84a739fffe46266ffe965df.arrow create mode 100644 evidence/results/e522587e7cc4e2782855b26f0dcec66e151d7f27c84a739fffe46266ffe965df.json diff --git a/evidence/results/42940f973d949a4c037dc96964c93874f8057de10ad6196ac4ed2c2a76437453.arrow b/evidence/results/42940f973d949a4c037dc96964c93874f8057de10ad6196ac4ed2c2a76437453.arrow new file mode 100644 index 0000000000000000000000000000000000000000..d4701d8543ddf1d51d27d5f78291da24ccc83bc6 GIT binary patch literal 914 zcmds0Jxc>Y5Pjh;$$6}B;9;&1xk5p#6f8xI3R);i@T>B7SlF3D5EL6ZA~yC>uu&wH zwST}rpq2q^iErk1Jqe<{4`%mfXXeev?$sNO^_^*;M|1&16d;5_1!V;MB1%4WPz$*F zb_G0YP0FmE0vS2z912Hl&$yvPS)&}O-6KODyS2H9r(a^0=-2rnCL(*l`l?!0WNXgz zjxa{}1(J7#5@yW_qhEY{HTX!sv+Uz=z`5!B5=BxN lPP?MF66G^y7c)E`qe%VQi*2Y&T~N&bf4;+n{nqtg`~*n5S(g9+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..00244cb608d7fbae3a1fa9d09342adecca41bf61 GIT binary patch literal 4250 zcmeI0J#SJ`7{{O5Tg1vh8e$kaFm`azxG*?ywU9PJE}_JiqzNEmOG4VFCB`Nl82kW^ zjd5V`BXly34vr2T8q<&A;0ucX|GD>slaq6i!8q|G7tX`^pL3q`yU%$EeytWfx-Bw> z<*$mQg|;jyNyw5UQGzXa@C9BHpI-DSP>7r_iJW>O{Y8=L6_K?CDnuUs7?05~kwH0) z-`J968ryAF8teX(u>1-B5YGvoI-Z+&*vISd<1yo58C|HlYxsWN-D~yg#|Ish&nyk5 zqGEn}-0ze-dprGIkqb+6E7G)gTZdXRa%eC$Icm3h9A_2t(Xn8fawjjsSPl+5?e0-` ze-GsXG@K*j1xmRW1m&V%k?Sb;_IJAIN=2QA?#0gupG?Fj3muL6a&X@-H%gnet-9hB z$LP$6D~{2HrExJDSsE9k9AefCI2JV~qk>T%$LJX}voP9*?#*bz$dyBWsa~u#rsvSP z5mOwWZeH@=x(9FW;1iJUX;`uNKxBLf1d`-=tuSQI9 ze6kqFoV$z9T}#8N@?L8=OXGe<;LB#YfS0V3Sv)7Yg~)bHWm zokLP^?>6_Qpwb9x4Ym3dyU(Z};<5MdD$bO@vFlqJ7rRqS<6`&0p)uKA8ufAPk|4m- zjq{z2p%*a>&(D`&rSmH#H^7D`Xi9OFMNctoqVLd_;fLdKom}G4tRg|!E^@dwX^f4g zeYT-#U%w~q=Tz;ae$Dd9lBV@I)|6?#VEG%CU$y+lmQR=GeVA*LasIW>^-RMxM49&W zINI0q(mr#6rhU7;_K`lg&xG$Ng}0ngZ#EshBTvYUY&jms*oZnDL<2VQn`!gARLClB uEza*!lf5&J(flqoJ%{FZsp)*?cd6-o=69*^EaM&Ff8C{;I5&OupTr-mSjV*h literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..dbf0818d8bcc334d0a73f0e7e35274d7219fda8e GIT binary patch literal 978 zcmd^8ze~eF7=1Bm4B-M9R5~aW(aoWPN^uZ^A{GR(Er^Q+T^(GU-9*vN#lgkRO*dC@ zaP^;Yw!U|{Bdwsg`@whLz59OT<>gXzIy-xdz<`zqKn__7gBUd=+|h!F7(@xz$gSD| zPxNDY(`JAZ&S68UITP-T0UV8W`hm@dxc#NB&AM|}mvf4qI&K-{jvws&X`YLNOf}m@ zx7Xh&_J!X@`r~i?CBNbR8hbX|oorbXynU5u^us?73go_^geAAsvcUh|pBXgzQFfWz-75lkZS(v|n zUe0`+oLPJ9?_N@p!|RKq!C8erURFP6Gv61yzMoz_++>cH)meM-H&k0+!4T=*=_@aB YowK|0E;ZaBA1nR;=RIvvkHY@TU*`o{oB#j- literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d7b759f2a1c35ba2d51c252054173f5cd83f71f GIT binary patch literal 3034 zcmeH}y-piJ6ot=%F%DTptH?qWDYi(Nf=Dis&Pae12`Ls>id)BVuqDeNa1kUPgMz{Z z1tn$56e&}pR0#11d4oWlb7ppu^<+%kBKb-)p8K=&^ZD+bUEAJ%`|c$$q9hAI0?86$ z6tRF3N0g{Q9n?zf3t6uba4`>b=74Hs_3K}w5zTXDb}?;w!p=z}$2;rb%)mFcGqz*4 zI@=32&G-2y_mPeHxbZ#W@5gSx*{dJzw>iG@Vk)$0pYM+b?X7lychCWDy_nT3rqyX4 zxR~^Id@aQg0S;Tup4O>){@1i=FZ&1WR`;;G*XQ^HV{}&SUC(a5X>9O_c+A$@+wF2H zv+MIa!zD^-2cBZZkXbZb6O?H&GLhT*f4Ked_9~`6w}gAhvkh^pcrhVvjX+GE+b7Q- z;x=T=G~7-Y$D>3kyYD8qBEzU0$}JxHzT8UqeE$3)Minn6#HiuLgcuzMV)BfJo)rjEW0v2u0Ka;cW0#CSWAoq5nrZ~?%JoU_=D>>y z@jLZmLj1l2V)FcMJb#E^i6>XJQVp7pWBd~!asTmPfmms@9& zu6T*_TWwqyE8nh~@8)*C`+nUVViuEnkz`^LwEHeV`ShI4-ty-bll>Zi*-B-3wJ@otP73<-e`KJF3>es@O literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c46b86b1996504e9f86873fb2d54023507c5f530 GIT binary patch literal 1546 zcmeHIy-EW?5T46j%o(o;66FeobfyrD*jS0dKO&+r1WYF}_Dc7iOOn>LWs5n$asQ;`D#DntkLL-FhT|F-|;+up`pl+}O0 EC)aMUHvj+t literal 0 HcmV?d00001 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" +} From 639ffa7fbea617a0d94983dfb8430add3dd2350c Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:54:07 +0000 Subject: [PATCH 04/11] test: expect timestamp_tz_submicro for a TIMESTAMP_LTZ column compared with a naive timestamp The comparison now converts, reading the TIMESTAMP_LTZ at its UTC wall time, so the case's nanosecond value raises the declared guard like the other zoned comparisons of that column. --- cases/generated/zoned_texts/column_comparisons.toml | 1 + scripts/family_cases/_helpers/zoned_texts.py | 6 +++++- scripts/family_cases/constants.py | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) 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/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..df4788b60 100644 --- a/scripts/family_cases/constants.py +++ b/scripts/family_cases/constants.py @@ -143,3 +143,5 @@ ZONED_TEXT_COMPARISONS: frozenset[str] = frozenset( {"gte_naive_text", "lt_date_text", "eq_offset_text", "between_texts"} ) +# A TIMESTAMP_LTZ compared with a TIMESTAMP_NTZ is read at its UTC wall time: nanoseconds raise. +ZONED_NAIVE_COMPARISONS: frozenset[tuple[str, str]] = frozenset({("TIMESTAMP_LTZ(9)", "eq_ntz")}) From 2c18e365379a8bffb3be112a27a5b077a0945a34 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:03:57 +0000 Subject: [PATCH 05/11] chore: update translation baseline for incremental idioms Five new recorded statements now match, two existing cases move from refused to matching, and the LTZ-equals-NTZ comparison becomes a declared divergence. Nothing regressed. --- coverage/translation/results-baseline.json | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/coverage/translation/results-baseline.json b/coverage/translation/results-baseline.json index 9e7b25139..6ad85ec6c 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", @@ -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", From 290f8c166dd33bb29b61fa71f09b6a42c45f20cd Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:06:05 +0000 Subject: [PATCH 06/11] style: match naive-timestamp comparisons by pattern instead of a guard --- .../_helpers/expressions/operators.rs | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/operators.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/operators.rs index 4324ed392..874c98fe7 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/operators.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/operators.rs @@ -595,21 +595,17 @@ pub(crate) fn comparable( let left = zoned_literal(left, construct, context)?; return Ok((left, right)); } - (ntz @ SnowflakeType::TimestampNtz { .. }, other) - if matches!( - other, - SnowflakeType::Date | SnowflakeType::TimestampLtz { .. } - ) => - { + ( + ntz @ SnowflakeType::TimestampNtz { .. }, + SnowflakeType::Date | SnowflakeType::TimestampLtz { .. }, + ) => { let right = beside_naive(right, ntz, construct, context)?; return Ok((left, right)); } - (other, ntz @ SnowflakeType::TimestampNtz { .. }) - if matches!( - other, - SnowflakeType::Date | SnowflakeType::TimestampLtz { .. } - ) => - { + ( + SnowflakeType::Date | SnowflakeType::TimestampLtz { .. }, + ntz @ SnowflakeType::TimestampNtz { .. }, + ) => { let left = beside_naive(left, ntz, construct, context)?; return Ok((left, right)); } From bf751b137633cde72bf8d3532ea45007c77779f4 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:12:06 +0000 Subject: [PATCH 07/11] style: drop a standalone comment from the family case constants --- scripts/family_cases/constants.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/family_cases/constants.py b/scripts/family_cases/constants.py index df4788b60..f8914e377 100644 --- a/scripts/family_cases/constants.py +++ b/scripts/family_cases/constants.py @@ -143,5 +143,4 @@ ZONED_TEXT_COMPARISONS: frozenset[str] = frozenset( {"gte_naive_text", "lt_date_text", "eq_offset_text", "between_texts"} ) -# A TIMESTAMP_LTZ compared with a TIMESTAMP_NTZ is read at its UTC wall time: nanoseconds raise. ZONED_NAIVE_COMPARISONS: frozenset[tuple[str, str]] = frozenset({("TIMESTAMP_LTZ(9)", "eq_ntz")}) From 0d869224ed6d9f97b723415d238ebdffeac1d113 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:33:24 +0000 Subject: [PATCH 08/11] test: record how a nanosecond naive timestamp drops digits when written as text --- cases/temporal/timestamp_text_digits.toml | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 cases/temporal/timestamp_text_digits.toml 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"] From d42bddae089bf6b6b1a5a88c235bf5f0271739f2 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:50:53 +0000 Subject: [PATCH 09/11] evidence(temporal): record the text of nanosecond naive timestamps Snowflake results for the two cases in cases/temporal/timestamp_text_digits.toml at 0d86922. --- ...4b5bf81a026b23bf1668e50f89fd66ead3d4.arrow | Bin 0 -> 1234 bytes ...24b5bf81a026b23bf1668e50f89fd66ead3d4.json | 61 ++++++++++++++++++ ...db5560f822ce8e2f264af5d2820224b3b515.arrow | Bin 0 -> 1146 bytes ...adb5560f822ce8e2f264af5d2820224b3b515.json | 61 ++++++++++++++++++ 4 files changed, 122 insertions(+) create mode 100644 evidence/results/3a8403ac09f96f83b731bb5847924b5bf81a026b23bf1668e50f89fd66ead3d4.arrow create mode 100644 evidence/results/3a8403ac09f96f83b731bb5847924b5bf81a026b23bf1668e50f89fd66ead3d4.json create mode 100644 evidence/results/3f00fea3102b9bae1aee3f825efadb5560f822ce8e2f264af5d2820224b3b515.arrow create mode 100644 evidence/results/3f00fea3102b9bae1aee3f825efadb5560f822ce8e2f264af5d2820224b3b515.json diff --git a/evidence/results/3a8403ac09f96f83b731bb5847924b5bf81a026b23bf1668e50f89fd66ead3d4.arrow b/evidence/results/3a8403ac09f96f83b731bb5847924b5bf81a026b23bf1668e50f89fd66ead3d4.arrow new file mode 100644 index 0000000000000000000000000000000000000000..aa3fcea466624d566020d21aac1553985a6311fc GIT binary patch literal 1234 zcmeHHy-or_5FYfvQ?DS#s3nDkg-34pKs+ib=txWm{uDNdr;rdJKx1WVjD;nozJU+m zgIHMjCis1OY&2?OVPfTkZ|C=BW@qlG)a(04N@NPUPKjiNv?QKnBrPr^P|ATW&|I9( z?1UNG1|YHm?&66B6PJ}$SV*b#D$Z>L@=401G;mm{9aO}avay<-o>~ra`!`QtKA&h*r!jm z1LR`gqgJ!ax-TF6bI?N+Wc|S~ZgobTeh;#XX>eNh?(C6hRF8QTSq8fOiw;tn+c@yj zPeS_X1l-QdleO7KJTqgNcg*acO*D@s=t(`^y9RJ}tZixD3gDb4$@(`FRd`R*L&D6- zSQ}@byZYc?hnxqvPwsC7+yKmB^XNbq{6PCU%BiqiD3$}2S33N?IS7L9l0;Et%u4&A z%IUCNjPT3DtfHOB|7u3BB+i|jv-2?h3~ug02-_=>Ol$ O-+VKV*h9tqm%RainWD!4 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b4f2e1322d63395c0d6b6fe607f18982c02d4378 GIT binary patch literal 1146 zcmeHHJx>Bb5FO;e6EBe%qL!2vKyLRScvMo*k(dztC~OcBE+IgGhK|z0!jjUTzz^`J zSolxyz2!C<8)IYT3vXt2cXns??S*=)b#bjkHo#*-Br8NlJV{GRTtq;^!B{YIVV&4w z8lw|XL@EbcKq7ZCCQ{&4y?NPyPlNB^8hiq8!2;-j=je(3FlMbb zxCcH&qt39snDh|eBZoaOhifV*YgMi4GsNuuZZYpQd!zn*AoBb}ZguA&21d4a-G2O zn-#p(%#)+|MLeUiBpsvulF0@cjJNf8?gGS^0|iu((EtDd literal 0 HcmV?d00001 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" +} From 51c3909a2133ae5d50d0bdd7c75bf57979541ce6 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:55:37 +0000 Subject: [PATCH 10/11] fix: truncate a naive timestamp's digits past the millisecond when writing it as text The recorded receipts show Snowflake truncates TIMESTAMP_NTZ(9) text to milliseconds and never rounds or carries, through TO_VARCHAR and casts alike. The runtime guard that raised where truncating and rounding differ, and its timestamp_text_rounding divergence, are removed; the cases that expected it now compare values. --- cases/conversion/cast_batch.toml | 1 - .../hazards/cast_to_text__conversion.toml | 2 -- coverage/domains/casts.toml | 6 +++--- crates/sqlquack-policy/src/policy/constants.rs | 2 -- crates/sqlquack-rules/src/catalogue/constants.rs | 2 +- .../src/conversion/_helpers/expressions/casts.rs | 14 ++------------ sqlquack.toml | 7 ------- 7 files changed, 6 insertions(+), 28 deletions(-) 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/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/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 dc0933f8e..6a89576a4 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -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], 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/sqlquack.toml b/sqlquack.toml index bc4e5b444..abc88d421 100644 --- a/sqlquack.toml +++ b/sqlquack.toml @@ -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." From 8190cd7d9312ca0ff4641c5507f8d5bf6b413d61 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 05:17:43 +0000 Subject: [PATCH 11/11] chore: update translation baseline for truncated timestamp text Three timestamp-to-text cases that declared timestamp_text_rounding now match, and the two new receipt cases match. Nothing regressed. --- coverage/translation/results-baseline.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/coverage/translation/results-baseline.json b/coverage/translation/results-baseline.json index 6ad85ec6c..7c002da3d 100644 --- a/coverage/translation/results-baseline.json +++ b/coverage/translation/results-baseline.json @@ -240,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", @@ -1182,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", @@ -1215,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", @@ -7576,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",