From ec34616ce2f8e5a44e57a5484d3b1c13dc776e31 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:21:03 +0000 Subject: [PATCH 01/13] fix: compare naive timestamps with local ones and count sub-day DATEDIFF of a DATE A TIMESTAMP_NTZ beside a TIMESTAMP_LTZ, such as CURRENT_TIMESTAMP() or DATEADD of it, now reads the local value as its wall time in the UTC session, at its own precision, as Snowflake does. DATEADD takes a TIMESTAMP_LTZ the same way. DATEDIFF in HOUR, MINUTE or SECOND reads a DATE operand as its midnight. --- cases/temporal/naive_local_comparison.toml | 90 ++++++++++++++++++ .../sqlquack-duckdb/src/temporal/constants.rs | 3 + .../src/temporal/main/date_diff_value.rs | 11 +++ .../sqlquack-rules/src/catalogue/constants.rs | 53 ++++++----- .../_helpers/expressions/operators.rs | 29 +++++- .../expressions/temporal_functions.rs | 9 ++ .../src/conversion/constants.rs | 2 + .../analysis/tests/test_session_timezone.rs | 92 ++++++++++++++++++- sqlquack.toml | 2 +- 9 files changed, 264 insertions(+), 27 deletions(-) create mode 100644 cases/temporal/naive_local_comparison.toml diff --git a/cases/temporal/naive_local_comparison.toml b/cases/temporal/naive_local_comparison.toml new file mode 100644 index 000000000..aa7a2e522 --- /dev/null +++ b/cases/temporal/naive_local_comparison.toml @@ -0,0 +1,90 @@ +# A TIMESTAMP_NTZ compared with a TIMESTAMP_LTZ (a column, CURRENT_TIMESTAMP() or DATEADD of it) in +# the UTC session, the incremental and freshness filters compiled models write. +category = "temporal" +construct = "node:lte" +session_profile = "utc-v1" + +[[case]] +id = "ntz_beside_ltz_columns" +description = "Every comparison of a naive timestamp with a local-zone one, equal, a microsecond apart and across offsets." +columns = [{ name = "n", type = "TIMESTAMP_NTZ(9)" }, { name = "l", type = "TIMESTAMP_LTZ(9)" }] +rows = [ + ["'2024-06-01 10:00:00'", "'2024-06-01 10:00:00 +00:00'"], + ["'2024-06-01 10:00:00'", "'2024-06-01 12:00:00 +02:00'"], + ["'2024-06-01 10:00:00.000001'", "'2024-06-01 10:00:00 +00:00'"], + ["'2024-06-01 09:59:59.999999'", "'2024-06-01 10:00:00 +00:00'"], + ["'2024-06-01 10:00:00'", "'2024-06-01 05:00:00 -05:00'"], + ["'1969-12-31 23:59:59.5'", "'1970-01-01 00:00:00 +00:00'"], + ["NULL", "'2024-06-01 10:00:00 +00:00'"], +] +expression = "ARRAY_CONSTRUCT(n = l, n <> l, n < l, n <= l, n > l, n >= l, l < n)" +expected_divergence = "json_fidelity" +tags = ["null", "boundary"] + +[[case]] +id = "ntz_millisecond_beside_ltz" +description = "A TIMESTAMP_NTZ(3) beside a TIMESTAMP_LTZ with microseconds compares at full precision." +columns = [{ name = "n", type = "TIMESTAMP_NTZ(3)" }, { name = "l", type = "TIMESTAMP_LTZ(9)" }] +rows = [ + ["'2024-06-01 10:00:00.123'", "'2024-06-01 10:00:00.123 +00:00'"], + ["'2024-06-01 10:00:00.123'", "'2024-06-01 10:00:00.123001 +00:00'"], + ["'2024-06-01 10:00:00.124'", "'2024-06-01 10:00:00.123999 +00:00'"], +] +expression = "ARRAY_CONSTRUCT(n = l, n < l, n > l)" +expected_divergence = "json_fidelity" +tags = ["boundary"] + +[[case]] +id = "ntz_at_most_current_timestamp" +description = "A past naive timestamp is at most CURRENT_TIMESTAMP(); a far-future one is not." +columns = [{ name = "updated_at", type = "TIMESTAMP_NTZ(9)" }] +rows = [ + ["'2020-01-01 00:00:00'"], + ["'2199-12-31 23:59:59'"], + ["NULL"], +] +expression = "updated_at <= CURRENT_TIMESTAMP()" +expected_divergence = "current_time" +tags = ["null"] + +[[case]] +id = "ntz_after_dateadd_of_current_timestamp" +description = "The incremental filter updated_at >= DATEADD(day, -3, CURRENT_TIMESTAMP()) over past and future rows." +columns = [{ name = "updated_at", type = "TIMESTAMP_NTZ(9)" }] +rows = [ + ["'2020-01-01 00:00:00'"], + ["'2199-12-31 23:59:59'"], + ["NULL"], +] +expression = "updated_at >= DATEADD(day, -3, CURRENT_TIMESTAMP())" +expected_divergence = "current_time" +tags = ["null"] + +[[case]] +id = "dateadd_of_ltz_column" +description = "DATEADD of a local-zone column in DAY, HOUR, MONTH and MINUTE, compared with naive values." +columns = [{ name = "l", type = "TIMESTAMP_LTZ(9)" }] +rows = [ + ["'2024-01-31 23:30:00 +00:00'"], + ["'2024-02-29 01:00:00 +05:30'"], + ["'2024-03-31 00:00:00.000001 -08:00'"], + ["NULL"], +] +expression = "ARRAY_CONSTRUCT(DATEADD(day, -3, l) = '2024-01-28 23:30:00'::TIMESTAMP_NTZ, TO_VARCHAR(DATEADD(hour, 5, l)::TIMESTAMP_NTZ), TO_VARCHAR(DATEADD(month, 1, l)::TIMESTAMP_NTZ), TO_VARCHAR(DATEADD(minute, -90, l)::TIMESTAMP_NTZ))" +expected_divergence = "json_fidelity" +tags = ["null", "boundary"] + +[[case]] +id = "datediff_subday_of_dates" +description = "DATEDIFF in HOUR, MINUTE and SECOND with a DATE operand, read as its midnight." +columns = [{ name = "d", type = "DATE" }, { name = "t", type = "TIMESTAMP_NTZ(9)" }] +rows = [ + ["'2024-06-01'", "'2024-06-01 10:30:00'"], + ["'2024-06-01'", "'2024-05-31 23:59:59.999999999'"], + ["'2024-02-28'", "'2024-03-01 00:00:00'"], + ["'1969-12-31'", "'1970-01-01 00:00:01'"], + ["NULL", "'2024-06-01 10:30:00'"], +] +expression = "ARRAY_CONSTRUCT(DATEDIFF(hour, d, t), DATEDIFF(minute, t, d), DATEDIFF(second, d, t), DATEDIFF(hour, d, '2024-06-03'::DATE))" +expected_divergence = "json_fidelity" +tags = ["null", "boundary"] diff --git a/crates/sqlquack-duckdb/src/temporal/constants.rs b/crates/sqlquack-duckdb/src/temporal/constants.rs index 8e20055df..e29b92d15 100644 --- a/crates/sqlquack-duckdb/src/temporal/constants.rs +++ b/crates/sqlquack-duckdb/src/temporal/constants.rs @@ -166,3 +166,6 @@ pub const DATE_FROM_PARTS: &str = "duckdb.date_from_parts"; pub const TIMESTAMP_FROM_PARTS: &str = "duckdb.timestamp_from_parts"; /// The dates whose every instant DuckDB's nanosecond timestamps hold. pub const NANOSECOND_DATE_RANGE: (&str, &str) = ("1677-09-22", "2262-04-10"); + +/// The precision a DATE is read at, as its midnight, when DATEDIFF counts units shorter than a day. +pub const MIDNIGHT_PRECISION: u8 = 0; diff --git a/crates/sqlquack-duckdb/src/temporal/main/date_diff_value.rs b/crates/sqlquack-duckdb/src/temporal/main/date_diff_value.rs index 9ed9dca52..32b1d75ce 100644 --- a/crates/sqlquack-duckdb/src/temporal/main/date_diff_value.rs +++ b/crates/sqlquack-duckdb/src/temporal/main/date_diff_value.rs @@ -5,6 +5,7 @@ use sqlquack_snowflake::typing::models::SnowflakeType; use crate::blocks::constants::DATE_DIFF; use crate::temporal::_helpers::timestamps::{epoch_units, floor_divide, stored_digits}; +use crate::temporal::constants::MIDNIGHT_PRECISION; use crate::temporal::main::truncate_timestamp_to_date::truncate_timestamp_to_date; /// Boundaries of the unit named by its canonical Snowflake name crossed from `start` to `end`. @@ -41,6 +42,16 @@ pub fn date_diff_value( _ => 0, }; let units = |sql: &str, source: SnowflakeType| -> Result { + let (sql, source) = match source { + SnowflakeType::Date => ( + format!("CAST({sql} AS TIMESTAMP)"), + SnowflakeType::TimestampNtz { + precision: MIDNIGHT_PRECISION, + }, + ), + other => (sql.to_owned(), other), + }; + let sql = sql.as_str(); let SnowflakeType::TimestampNtz { precision } = source else { return Err(format!( "DATEDIFF in {unit} of {source} has no proven conversion" diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index c6bab09bc..69edba5dc 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -33,26 +33,27 @@ use crate::conversion::constants::{ HASH_RULE, HAVING, HIERARCHY_RULE, IFF, ILIKE_FOLDING, IN_LIST, IN_SUBQUERY, INTERVAL_ARITHMETIC, IS_NULL, JARO_WINKLER, JOIN_CROSS, JOIN_ON, JOIN_USING, LAMBDA_RULE, LAST_DAY_RULE, LATERAL_SUBQUERY, LEAST_GREATEST, LIKE, LIMIT, LISTAGG_RULE, LOGARITHM, LOGIC, - MEDIAN_RULE, MIN_MAX, MIN_MAX_FLOAT, MODE_RULE, NAMED_AGGREGATES, NEGATE, NOT_IN_SUBQUERY, - NULL_ARITHMETIC, NULL_BRANCHES, NULL_IF_ZERO_RULE, NULL_SAFE_COMPARISON, NULLIF, - NUMBER_LITERAL, NUMBER_TEXT_FORMAT, NUMERIC_FUNCTION, OBJECT_CONSTRUCT_RULE, - OBJECT_DELETE_RULE, OBJECT_INSERT_RULE, OBJECT_KEYS_RULE, OBJECT_PICK_RULE, ORDER_BY, - PARENTHESES, PARSE_JSON_RULE, PARSE_JSON_VARIANT, PARSE_URL_RULE, PERCENTILE_RULE, PIVOT_RULE, - POSITIONAL_COLUMN, POWER_RULE, PROJECT_FUNCTION_CALL, PROJECT_TABLE_FUNCTION, QUALIFY, - REDUCE_RULE, REGEXP, REGEXP_COLUMN_PATTERN, REGEXP_COUNTING, REGEXP_PARAMETERS, - REGEXP_POSIX_SPACE, ROUNDING, SCALAR_SUBQUERY, SELECT, SET_OPERATION, SIGN_RULE, SPLIT_RULE, - SPLIT_TO_TABLE_RULE, STAR, SUM, SUM_FLOAT, SYSTEM_TYPE_NAME, TEMPORAL_TEXT_FORMAT, TEXT_AFFIX, - TEXT_BRANCHES, TEXT_CHR, TEXT_CONTAINS, TEXT_HEX, TEXT_HEX_DECODE, TEXT_INITCAP, TEXT_MD5, - TEXT_NUMBER_COMPARISON, TEXT_PAD, TEXT_POSITION, TEXT_REPEAT, TEXT_REPLACE, - TEXT_SEARCH_POSITION, TEXT_SHA2, TEXT_TO_TIME_RULE, TEXT_TO_TIMESTAMP_RULE, TEXT_TRANSLATE, - TIME_ARITHMETIC, TIME_CAST, TIME_ZONE_CONVERSION, TIMESTAMP_FORMAT_RULE, TIMESTAMP_TIME_RULE, - TO_ARRAY_RULE, TO_BOOLEAN_RULE, TO_DATE_TEMPORAL, TO_TIMESTAMP_TEMPORAL, TO_VARIANT_RULE, TRIM, - UNION_BY_NAME, USER_OVERRIDE_RULE, UTC_OFFSET_ARITHMETIC, VALUE_AT_EXTREME_KEY, VALUES_ROWS, - VARIANT_CAST, VARIANT_COMPUTED_GET, VARIANT_EQUALITY, VARIANT_EQUALITY_BY_TEXT, - VARIANT_INSPECT, VARIANT_KIND_CAST, VARIANT_KIND_RULE, VARIANT_MAX, VARIANT_ORDER_KEY, - VARIANT_PARTITION, VARIANT_PATH, VARIANT_TEXT_BRANCHES, VARIANT_TO_VARCHAR, VARIANT_TYPE_NAME, - VARIANT_TYPED_CHECK, WHERE, WINDOW_AGGREGATE, WINDOW_FIRST_LAST, WINDOW_OFFSET, WINDOW_RANKING, - WINDOW_VALUE_FRAME, ZERO_IF_NULL_RULE, ZONE_COLUMNS, ZONED_PARTS, + MEDIAN_RULE, MIN_MAX, MIN_MAX_FLOAT, MODE_RULE, NAIVE_LOCAL_COMPARISON, NAMED_AGGREGATES, + NEGATE, NOT_IN_SUBQUERY, NULL_ARITHMETIC, NULL_BRANCHES, NULL_IF_ZERO_RULE, + NULL_SAFE_COMPARISON, NULLIF, NUMBER_LITERAL, NUMBER_TEXT_FORMAT, NUMERIC_FUNCTION, + OBJECT_CONSTRUCT_RULE, OBJECT_DELETE_RULE, OBJECT_INSERT_RULE, OBJECT_KEYS_RULE, + OBJECT_PICK_RULE, ORDER_BY, PARENTHESES, PARSE_JSON_RULE, PARSE_JSON_VARIANT, PARSE_URL_RULE, + PERCENTILE_RULE, PIVOT_RULE, POSITIONAL_COLUMN, POWER_RULE, PROJECT_FUNCTION_CALL, + PROJECT_TABLE_FUNCTION, QUALIFY, REDUCE_RULE, REGEXP, REGEXP_COLUMN_PATTERN, REGEXP_COUNTING, + REGEXP_PARAMETERS, REGEXP_POSIX_SPACE, ROUNDING, SCALAR_SUBQUERY, SELECT, SET_OPERATION, + SIGN_RULE, SPLIT_RULE, SPLIT_TO_TABLE_RULE, STAR, SUM, SUM_FLOAT, SYSTEM_TYPE_NAME, + TEMPORAL_TEXT_FORMAT, TEXT_AFFIX, TEXT_BRANCHES, TEXT_CHR, TEXT_CONTAINS, TEXT_HEX, + TEXT_HEX_DECODE, TEXT_INITCAP, TEXT_MD5, TEXT_NUMBER_COMPARISON, TEXT_PAD, TEXT_POSITION, + TEXT_REPEAT, TEXT_REPLACE, TEXT_SEARCH_POSITION, TEXT_SHA2, TEXT_TO_TIME_RULE, + TEXT_TO_TIMESTAMP_RULE, TEXT_TRANSLATE, TIME_ARITHMETIC, TIME_CAST, TIME_ZONE_CONVERSION, + TIMESTAMP_FORMAT_RULE, TIMESTAMP_TIME_RULE, TO_ARRAY_RULE, TO_BOOLEAN_RULE, TO_DATE_TEMPORAL, + TO_TIMESTAMP_TEMPORAL, TO_VARIANT_RULE, TRIM, UNION_BY_NAME, USER_OVERRIDE_RULE, + UTC_OFFSET_ARITHMETIC, VALUE_AT_EXTREME_KEY, VALUES_ROWS, VARIANT_CAST, VARIANT_COMPUTED_GET, + VARIANT_EQUALITY, VARIANT_EQUALITY_BY_TEXT, VARIANT_INSPECT, VARIANT_KIND_CAST, + VARIANT_KIND_RULE, VARIANT_MAX, VARIANT_ORDER_KEY, VARIANT_PARTITION, VARIANT_PATH, + VARIANT_TEXT_BRANCHES, VARIANT_TO_VARCHAR, VARIANT_TYPE_NAME, VARIANT_TYPED_CHECK, WHERE, + WINDOW_AGGREGATE, WINDOW_FIRST_LAST, WINDOW_OFFSET, WINDOW_RANKING, WINDOW_VALUE_FRAME, + ZERO_IF_NULL_RULE, ZONE_COLUMNS, ZONED_PARTS, }; use crate::conversion::models::RuleSpec; @@ -316,6 +317,14 @@ pub const RULES: &[RuleSpec] = &[ snowflake: &[NUMBER_OUT_OF_RANGE], }], }, + RuleSpec { + id: NAIVE_LOCAL_COMPARISON, + constructs: &[ + "node:eq", "node:neq", "node:lt", "node:lte", "node:gt", "node:gte", + ], + conditions: "A TIMESTAMP_NTZ beside a TIMESTAMP_LTZ, such as CURRENT_TIMESTAMP() or DATEADD of it: the TIMESTAMP_LTZ is read as its wall time in the session time zone, which relies on session_timezone_utc, and compared with the naive value.", + failures: &[], + }, RuleSpec { id: DATE_LITERAL_COMPARISON, constructs: &[ @@ -491,7 +500,7 @@ pub const RULES: &[RuleSpec] = &[ "fn:TIMEDIFF", "fn:TIMESTAMPDIFF", ], - conditions: "DATE or TIMESTAMP_NTZ values. DATE_TRUNC to YEAR, QUARTER, MONTH, WEEK (Monday) or DAY, and for timestamps HOUR, MINUTE or SECOND, keeping the type. DATEADD of a NUMBER(p,0) amount (to text read as TIMESTAMP_NTZ(9) as CAST reads it, as recorded), which fails beyond 2^53 as Snowflake's error 100046, of YEAR, QUARTER, MONTH (clamped to the month's end), WEEK or DAY, and for timestamps HOUR, MINUTE, SECOND, MILLISECOND or MICROSECOND, keeping the type and its nanoseconds; a result beyond DuckDB's range (for precision 7-9 its nanosecond range) raises timestamp_range. DATEDIFF between two DATEs or two timestamps counts YEAR, QUARTER, MONTH and DAY boundaries of the dates, and for timestamps HOUR, MINUTE, SECOND, MILLISECOND or MICROSECOND boundaries toward the past. Between two TIMESTAMP_TZ values at any offsets, MINUTE and SECOND boundaries are counted between their UTC instants, which every whole-minute offset shares, as recorded; other units of such values refuse, as Snowflake counts DAY boundaries in each value's own offset.", + conditions: "DATE or TIMESTAMP_NTZ values. DATE_TRUNC to YEAR, QUARTER, MONTH, WEEK (Monday) or DAY, and for timestamps HOUR, MINUTE or SECOND, keeping the type. DATEADD of a NUMBER(p,0) amount (to text read as TIMESTAMP_NTZ(9) as CAST reads it, as recorded), which fails beyond 2^53 as Snowflake's error 100046, of YEAR, QUARTER, MONTH (clamped to the month's end), WEEK or DAY, and for timestamps HOUR, MINUTE, SECOND, MILLISECOND or MICROSECOND, keeping the type and its nanoseconds; a result beyond DuckDB's range (for precision 7-9 its nanosecond range) raises timestamp_range. DATEDIFF between two DATEs or two timestamps counts YEAR, QUARTER, MONTH and DAY boundaries of the dates, and for timestamps HOUR, MINUTE, SECOND, MILLISECOND or MICROSECOND boundaries toward the past, a DATE beside a timestamp or another DATE read as its midnight for those units. Between two TIMESTAMP_TZ values at any offsets, MINUTE and SECOND boundaries are counted between their UTC instants, which every whole-minute offset shares, as recorded; other units of such values refuse, as Snowflake counts DAY boundaries in each value's own offset.", failures: &[FailureMode { kind: ErrorKind::NumericOverflow, snowflake: &[NUMBER_OUT_OF_RANGE], @@ -1564,7 +1573,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: "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; DATEADD of a TIMESTAMP_LTZ the same way, which relies on session_timezone_utc; 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..998ba698f 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/operators.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/operators.rs @@ -16,6 +16,7 @@ use sqlquack_duckdb::semi_structured::main::json_output::json_output; use sqlquack_duckdb::semi_structured::main::json_text::json_text; use sqlquack_duckdb::temporal::main::date_add_amount::date_add_amount; use sqlquack_duckdb::temporal::main::date_add_value::date_add_value; +use sqlquack_duckdb::temporal::main::utc_wall_time::utc_wall_time; use sqlquack_duckdb::text::constants::TEXT_NUMBER_SCALE; use sqlquack_duckdb::text::main::compared_number_range::compared_number_range; use sqlquack_duckdb::text::main::compared_number_text::compared_number_text; @@ -43,8 +44,8 @@ use sqlquack_duckdb::blocks::constants::NANOSECOND_DIGITS; 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, - VARIANT_EQUALITY, + NAIVE_LOCAL_COMPARISON, 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 +593,14 @@ pub(crate) fn comparable( let left = zoned_literal(left, construct, context)?; return Ok((left, right)); } + (SnowflakeType::TimestampNtz { .. }, SnowflakeType::TimestampLtz { precision }) => { + let right = local_as_naive(right, precision, construct, context)?; + return comparable(left, right, construct, context); + } + (SnowflakeType::TimestampLtz { precision }, SnowflakeType::TimestampNtz { .. }) => { + let left = local_as_naive(left, precision, construct, context)?; + return comparable(left, right, construct, context); + } (SnowflakeType::Varchar, number @ SnowflakeType::Number { .. }) => { let text = number_text(left, (number, &right.sql), construct, context)?; return Ok((text, number_range(right, context))); @@ -617,6 +626,22 @@ pub(crate) fn comparable( } } +/// A TIMESTAMP_LTZ beside a TIMESTAMP_NTZ, as its wall time in the UTC session at its own precision. +fn local_as_naive( + emitted: Emitted, + precision: u8, + construct: &Construct, + context: &Context<'_>, +) -> Result { + let log = context.log; + let _rule = log.rule(NAIVE_LOCAL_COMPARISON); + diverge(context, SESSION_TIMEZONE_UTC, construct)?; + let naive = SnowflakeType::TimestampNtz { precision }; + let sql = cast(&utc_wall_time(&emitted.sql, log), naive, log) + .map_err(|reason| outside_conditions(construct.clone(), reason))?; + Ok(Emitted::new(sql, naive)) +} + /// 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..fd9018cb0 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs @@ -384,6 +384,15 @@ pub(crate) fn date_add( }; let (operand, zoned) = match operand.data_type { SnowflakeType::Time { .. } => (operand, None), + local @ SnowflakeType::TimestampLtz { precision } => { + let _zoned = site.context.log.rule(UTC_OFFSET_ARITHMETIC); + diverge(site.context, SESSION_TIMEZONE_UTC, construct)?; + let wall = SnowflakeType::TimestampNtz { + precision: precision.min(MICROSECOND_DIGITS), + }; + let sql = utc_wall_time(&operand.sql, site.context.log); + (Emitted::new(sql, wall), Some(local)) + } _ => temporal_or_refused(operand, construct, site.context)?, }; let amount = Emitted::new( diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index 6269405a0..eca6b3214 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -591,6 +591,8 @@ pub const DECIMAL_DIVISION: &str = "rule.decimal_division"; pub const FLOAT_ARITHMETIC: &str = "rule.float_arithmetic"; /// `= <> < <= > >=`. pub const COMPARISON: &str = "rule.comparison"; +/// A TIMESTAMP_NTZ compared with a TIMESTAMP_LTZ in the UTC session. +pub const NAIVE_LOCAL_COMPARISON: &str = "rule.naive_local_comparison"; /// A DATE compared with an ISO date string literal. pub const DATE_LITERAL_COMPARISON: &str = "rule.date_literal_comparison"; /// AND, OR and NOT. diff --git a/crates/sqlquack/src/analysis/tests/test_session_timezone.rs b/crates/sqlquack/src/analysis/tests/test_session_timezone.rs index 28fdb2a88..4c4271ca9 100644 --- a/crates/sqlquack/src/analysis/tests/test_session_timezone.rs +++ b/crates/sqlquack/src/analysis/tests/test_session_timezone.rs @@ -1,11 +1,18 @@ use sqlquack_core::refusals::models::RefusalCategory; -use crate::analysis::tests::helpers::{relying_on_utc_session, session_outcome}; -use crate::analysis::tests::test_types::{DivergenceModeOutcome, SessionTimeZoneTestCase}; +use crate::analysis::tests::helpers::{ + relying_on_utc_session, session_outcome, shop_first_outcome, +}; +use crate::analysis::tests::test_types::{ + DivergenceModeOutcome, FirstValueTestCase, SessionTimeZoneTestCase, +}; const NAIVE_TO_ZONED: &str = "SELECT '2024-03-10 22:30:00'::TIMESTAMP_NTZ(6)::TIMESTAMP_TZ AS placed_at"; +const NAIVE_BESIDE_LOCAL: &str = "SELECT '2024-06-01 10:00:00'::TIMESTAMP_NTZ < \ + '2024-06-01 12:00:00 +02:00'::TIMESTAMP_LTZ AS v"; + #[test] fn given_session_dependent_statements_when_converting_then_they_rely_on_a_utc_session() { let test_cases = [ @@ -72,6 +79,24 @@ fn given_session_dependent_statements_when_converting_then_they_rely_on_a_utc_se utc_session: false, expected_outcome: relying_on_utc_session(&["fn:CONVERT_TIMEZONE"]), }, + SessionTimeZoneTestCase { + description: "a naive timestamp compared with a local one relies on it", + sql: NAIVE_BESIDE_LOCAL, + strict: false, + utc_session: false, + expected_outcome: relying_on_utc_session(&["node:lt"]), + }, + SessionTimeZoneTestCase { + description: "strict mode refuses that comparison without a confirmed UTC session", + sql: NAIVE_BESIDE_LOCAL, + strict: true, + utc_session: false, + expected_outcome: DivergenceModeOutcome { + converted: false, + divergences: vec![], + categories: vec![RefusalCategory::DivergenceRefused], + }, + }, ]; for test_case in test_cases { assert_eq!( @@ -82,3 +107,66 @@ fn given_session_dependent_statements_when_converting_then_they_rely_on_a_utc_se ); } } + +#[test] +fn given_naive_and_local_timestamps_when_converting_then_they_compare_as_utc_wall_times() { + let test_cases = [ + FirstValueTestCase { + description: "a local value at +02:00 equals the naive value of its UTC wall time", + sql: "SELECT '2024-06-01 10:00:00'::TIMESTAMP_NTZ = \ + '2024-06-01 12:00:00 +02:00'::TIMESTAMP_LTZ AS v", + expected_outcome: Ok("true"), + }, + FirstValueTestCase { + description: "the local side keeps its microseconds beside a millisecond naive value", + sql: "SELECT '2024-06-01 10:00:00.123'::TIMESTAMP_NTZ(3) < \ + '2024-06-01 05:00:00.123001 -05:00'::TIMESTAMP_LTZ(9) AS v", + expected_outcome: Ok("true"), + }, + FirstValueTestCase { + description: "a past naive timestamp is at most CURRENT_TIMESTAMP()", + sql: "SELECT '2020-01-01 00:00:00'::TIMESTAMP_NTZ <= CURRENT_TIMESTAMP() AS v", + expected_outcome: Ok("true"), + }, + FirstValueTestCase { + description: "the incremental filter against DATEADD of CURRENT_TIMESTAMP()", + sql: "SELECT '2199-01-01 00:00:00'::TIMESTAMP_NTZ >= \ + DATEADD(day, -3, CURRENT_TIMESTAMP()) AS v", + expected_outcome: Ok("true"), + }, + FirstValueTestCase { + description: "a naive timestamp four days back is outside a three-day window", + sql: "SELECT DATEADD(day, -4, CURRENT_TIMESTAMP())::TIMESTAMP_NTZ >= \ + DATEADD(day, -3, CURRENT_TIMESTAMP()) AS v", + expected_outcome: Ok("false"), + }, + FirstValueTestCase { + description: "DATEDIFF in hours reads a DATE as its midnight", + sql: "SELECT DATEDIFF(hour, '1969-12-31'::DATE, '1970-01-01 01:00:00'::TIMESTAMP_NTZ) \ + AS v", + expected_outcome: Ok("25"), + }, + FirstValueTestCase { + description: "DATEDIFF in hours to the hour before a DATE's midnight is -1", + sql: "SELECT DATEDIFF(hour, '2024-06-02'::DATE, '2024-06-01 23:59:59'::TIMESTAMP_NTZ) \ + AS v", + expected_outcome: Ok("-1"), + }, + FirstValueTestCase { + description: "DATEDIFF in minutes between two DATEs", + sql: "SELECT DATEDIFF(minute, '2024-06-01'::DATE, '2024-06-02'::DATE) AS v", + expected_outcome: Ok("1440"), + }, + ]; + for test_case in test_cases { + assert_eq!( + shop_first_outcome(test_case.sql, test_case.expected_outcome), + test_case + .expected_outcome + .map(str::to_owned) + .map_err(str::to_owned), + "{}", + test_case.description + ); + } +} diff --git a/sqlquack.toml b/sqlquack.toml index c8e85799f..3b157d481 100644 --- a/sqlquack.toml +++ b/sqlquack.toml @@ -384,7 +384,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:TIMEADD", "fn:TIMESTAMPADD", "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" From 9916e9e90581a5df23e02a33781bd864b16abd48 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:52:44 +0000 Subject: [PATCH 02/13] evidence: naive and local timestamp comparisons, DATEADD of an LTZ, sub-day DATEDIFF of a DATE Recorded from fix/ntz-current-timestamp ec34616c (6 cases, utc-v1, no failures). --- ...4665d90a1909f076c6a39fe4d430f1ce3d69.arrow | Bin 0 -> 986 bytes ...14665d90a1909f076c6a39fe4d430f1ce3d69.json | 61 ++++++++++++++++++ ...e107ee04ad055a4af7b8dd45df2a891e6b78.arrow | Bin 0 -> 1394 bytes ...5e107ee04ad055a4af7b8dd45df2a891e6b78.json | 61 ++++++++++++++++++ ...dec1a7cd43312dc98f7d3df38381cf2702af.arrow | Bin 0 -> 1226 bytes ...0dec1a7cd43312dc98f7d3df38381cf2702af.json | 61 ++++++++++++++++++ ...3a5e28adc294520cafffbe751b238f0c81eb.arrow | Bin 0 -> 986 bytes ...13a5e28adc294520cafffbe751b238f0c81eb.json | 61 ++++++++++++++++++ ...1e4a658cffb88d41e97131716ee179713eb1.arrow | Bin 0 -> 1514 bytes ...91e4a658cffb88d41e97131716ee179713eb1.json | 61 ++++++++++++++++++ ...d7d798b1d4d99e9aca9df247e6b19a4080ef.arrow | Bin 0 -> 1122 bytes ...8d7d798b1d4d99e9aca9df247e6b19a4080ef.json | 61 ++++++++++++++++++ 12 files changed, 366 insertions(+) create mode 100644 evidence/results/2b87658656f254923348f2d90be14665d90a1909f076c6a39fe4d430f1ce3d69.arrow create mode 100644 evidence/results/2b87658656f254923348f2d90be14665d90a1909f076c6a39fe4d430f1ce3d69.json create mode 100644 evidence/results/4a0b34afce04e95a1b2ca94c3a85e107ee04ad055a4af7b8dd45df2a891e6b78.arrow create mode 100644 evidence/results/4a0b34afce04e95a1b2ca94c3a85e107ee04ad055a4af7b8dd45df2a891e6b78.json create mode 100644 evidence/results/b25def58913a26c02294ce05b560dec1a7cd43312dc98f7d3df38381cf2702af.arrow create mode 100644 evidence/results/b25def58913a26c02294ce05b560dec1a7cd43312dc98f7d3df38381cf2702af.json create mode 100644 evidence/results/bb7bc504565d556a3338907aa8513a5e28adc294520cafffbe751b238f0c81eb.arrow create mode 100644 evidence/results/bb7bc504565d556a3338907aa8513a5e28adc294520cafffbe751b238f0c81eb.json create mode 100644 evidence/results/e0486d75b1aa140b5c61911aef091e4a658cffb88d41e97131716ee179713eb1.arrow create mode 100644 evidence/results/e0486d75b1aa140b5c61911aef091e4a658cffb88d41e97131716ee179713eb1.json create mode 100644 evidence/results/faf3cf5525a8d16b201b026c2e18d7d798b1d4d99e9aca9df247e6b19a4080ef.arrow create mode 100644 evidence/results/faf3cf5525a8d16b201b026c2e18d7d798b1d4d99e9aca9df247e6b19a4080ef.json diff --git a/evidence/results/2b87658656f254923348f2d90be14665d90a1909f076c6a39fe4d430f1ce3d69.arrow b/evidence/results/2b87658656f254923348f2d90be14665d90a1909f076c6a39fe4d430f1ce3d69.arrow new file mode 100644 index 0000000000000000000000000000000000000000..c14f26857fac5eb641c389d4b88d154ec8538fa6 GIT binary patch literal 986 zcmeHGF;2rk5L^?-F~Nm}B1FLhC@4TQ6f{OkXJHftsEuV3OO_KQE{MbfQc_apAv}Ty z@Rwxf&MS~bNy$iax4XA@H@jy?qfzpBA+iNt9Fb6nz69b)SA1B2K=Q2x6-t+ZieGmTSmDMs| zfc-yBw=JJjJDm_zeXtkjVv_nooeXDhI Z&>LdJw{GkIe@nJkfBh|O(D%sv(>om8cNG8t literal 0 HcmV?d00001 diff --git a/evidence/results/2b87658656f254923348f2d90be14665d90a1909f076c6a39fe4d430f1ce3d69.json b/evidence/results/2b87658656f254923348f2d90be14665d90a1909f076c6a39fe4d430f1ce3d69.json new file mode 100644 index 000000000..57fb29504 --- /dev/null +++ b/evidence/results/2b87658656f254923348f2d90be14665d90a1909f076c6a39fe4d430f1ce3d69.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "185438aa5c54a46e8f93e26052aee6c9851b0d116ea3c59fc6cff4419ef945d7", + "case_hash": "2b87658656f254923348f2d90be14665d90a1909f076c6a39fe4d430f1ce3d69", + "columns": [ + { + "internal_size": null, + "name": "VALUE", + "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": "8dd8205e8ee2891390da991f5926d6adecd533dfa18ce14f940856d47aaa892f", + "recorded_at": "2026-11-06T10:50:38.322420+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/4a0b34afce04e95a1b2ca94c3a85e107ee04ad055a4af7b8dd45df2a891e6b78.arrow b/evidence/results/4a0b34afce04e95a1b2ca94c3a85e107ee04ad055a4af7b8dd45df2a891e6b78.arrow new file mode 100644 index 0000000000000000000000000000000000000000..a688b50021303b4ca98c5c6b04a48fcbbbffa920 GIT binary patch literal 1394 zcmeHH!Ac`R5UrR=jI%ie5%D+(f`^Rsbcap!v^jVS2*FiEJ&ehyfehQ_9Ky3&}~knA|zkRQj~&}Ac1lo zbb(gF>P=UChPDETEP|J?W8V5zWC>qLxxy0GDjd0!GN}OUHCw;8!Hd8PFahp>BcKX+ zz%SIqdMGp3F0cTc_6Bi(_vSKz+=UNopbu@4sp+U**A2+5{p4nxw35Nu_*~@v3*XLp zJaoQ~pK>e2V%?)o+-KhBjC~HeK|0PcELmgVVeC@7dm8voV%@=xIKly9K!3rFN0L6J~IihrJ{n z{*9aV-qn>_(=`Tr0@mQ`{15#g7!R+KD)_2ax)v%OswhykEu*%S+E7ZZelY6IK%3dd zC^S)^HMY5({xK7r#{JR93C*Qf6#v*ZopV8fcU#7E6z!B%(wV^YaPHRu-WRX%VS0~$Ad}wb TCDp&*^#6I&6WjydJ^r9jGdGgknCf^ZYhNwi3#x$gA@oWkPtAUg_W&^g{7sRU}NV; z_yHFFiJo(piNu&_W#vuIeaxJhd*{y1Hd?LSLto?_a+47$3B{G7PSP&JU zow>#^q76V~4Yq(4%gQTB86%WTt_)oTkt^v_a=<~OxxWLO2i|~3;1)Ors(=G*qbAlv zpSkvc72tF<>5liV&Jy^2a99I5oby8y_)!$Z@LBuG)kV@wCWDKi$lVXz-mrUaxzAs6 zd)SM0&wAZ4^S&<3FCh+*konKflip}Hnoi&s&<%FWo}D#vk4CH2ID%aT#?!$FTk2^X zu*sod`}ccT+q0sq&u^rtJ(jGay?;?Qm5Vsd$8$FU_Kx|iWUcd!We+LVKBb{*_I`06 zJGBW1$Cfs54RB4{tyFxk3SFy*YQre@)G@K!;dIUCw5rs#6DHPmK^!aJ$1SESgU~bS z+Q2BqH%2LO-FUi@i6SowQqJY1pWqWo`lS9Ma671*{f5z=718y)-d-u~HExCH+yd;k dc+aLl9#o5b-a|U@|5pCytvuo$@SgAw`v6+!r<4Ey literal 0 HcmV?d00001 diff --git a/evidence/results/b25def58913a26c02294ce05b560dec1a7cd43312dc98f7d3df38381cf2702af.json b/evidence/results/b25def58913a26c02294ce05b560dec1a7cd43312dc98f7d3df38381cf2702af.json new file mode 100644 index 000000000..229c361fa --- /dev/null +++ b/evidence/results/b25def58913a26c02294ce05b560dec1a7cd43312dc98f7d3df38381cf2702af.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "95a7ee28c3733134197fad14721aa29cf53acd23420a332f6bcacc4405170783", + "case_hash": "b25def58913a26c02294ce05b560dec1a7cd43312dc98f7d3df38381cf2702af", + "columns": [ + { + "internal_size": null, + "name": "VALUE", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 10, + "type_name": "ARRAY" + } + ], + "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": "ee615ba6a6499eb2eb62890e56613bf47eae3875e4d66b6622efb554c8707310", + "recorded_at": "2026-11-06T10:50:38.322420+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/bb7bc504565d556a3338907aa8513a5e28adc294520cafffbe751b238f0c81eb.arrow b/evidence/results/bb7bc504565d556a3338907aa8513a5e28adc294520cafffbe751b238f0c81eb.arrow new file mode 100644 index 0000000000000000000000000000000000000000..5be3962d068ed3c16260596c03acb829f0d9eb95 GIT binary patch literal 986 zcmeHGF;2rk5L^?-F~Nm}B9wv$P*8wqC}@n7&cY}PP#eo6mMkYqTo8!|q@<+GLwE!a z;4jI{omZfUG9@F;-R|Dr-Rzzn4Ts6orN{<&bwolTx)O*dZSi3NjtgJl`54`1L>OKl z6gkH1V@Jp2h0;TWaJ(MIQzUXHZK4f6M)AV{vj={GYw!&mf#;wL-a`}h(B@rSI|N^f zDlJFLSq{72$w4Vb^YgOKj&_!uC12Y zJkN?nF|82uQ9Zk(wNiHnyiJmL5Z%cU>~cCTaHLW1BAYn`j^C^>YmWjsXVfGf>}Z b)g1JW81bXq`v2dO?bW`&r8W8Vk{{wCXd~mTe!MmWgJdn-o!=m<%#0OBvE3fl?ZD zf~I5Dq7~cFHUXk_a2*~wX_u!0c9>F`0@e}?*^_0a3~*xAj}O7KzzZ-1Zh;G+1Zcnk zYT|lW=3EV673jFW<29}Z0&*KRTm#$iS-HAXDc5S1J;+@9<<&^kh2I_Zi0(eIZS|a? zvVC00y^UC0chGV?&ifoYUx8jlLe4)Jik2I=eIK%pZg98Mw{wjg!?JDb415jn`dt?x z<=1GiW*a7ipH0A3&rI5gZp4&5rnDw||H_S#WIzw|@psn&?p;ovg-bdnFN(!zL-D71 z+_3lYILdb)!Zq*!yaBuL2>|vzH;Tp4a4bsT9mflj!qQ1%!)Sird!929IZ=%|iSsb^ zl5^Z75o2yCgTj2K)ZLad?6r;)Wh%1&eWnwC`a=P>3EMjW;f7=4ozdDKL Z=N@}ZA9EV&|8L3Pyd_iI1Ku0F@$Z)}2?GEC literal 0 HcmV?d00001 diff --git a/evidence/results/e0486d75b1aa140b5c61911aef091e4a658cffb88d41e97131716ee179713eb1.json b/evidence/results/e0486d75b1aa140b5c61911aef091e4a658cffb88d41e97131716ee179713eb1.json new file mode 100644 index 000000000..16a91589b --- /dev/null +++ b/evidence/results/e0486d75b1aa140b5c61911aef091e4a658cffb88d41e97131716ee179713eb1.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "e98af85b95aba3afa0535ba022714508df55665e2ece531731c104ce93bfe265", + "case_hash": "e0486d75b1aa140b5c61911aef091e4a658cffb88d41e97131716ee179713eb1", + "columns": [ + { + "internal_size": null, + "name": "VALUE", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 10, + "type_name": "ARRAY" + } + ], + "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": "4f27046491d9d1d5c166a930fc3fa9f3aa770cfde9d7f33605e7b650408e51a6", + "recorded_at": "2026-11-06T10:50:38.322420+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 7, + "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/faf3cf5525a8d16b201b026c2e18d7d798b1d4d99e9aca9df247e6b19a4080ef.arrow b/evidence/results/faf3cf5525a8d16b201b026c2e18d7d798b1d4d99e9aca9df247e6b19a4080ef.arrow new file mode 100644 index 0000000000000000000000000000000000000000..a4f2fb6f39356edfc4ec213de4afca6631055417 GIT binary patch literal 1122 zcmeHHu};EJ6up&FYwge&VjLV89XePr!O;$GBqj}Nj55?(ph*J+aNrLd92gn>1U|q| zaqv&@oJT8#n7BE4lk@I<_nr6deWx$=gW&4c6IsGuG!aK=me^uQSxiJQO~+g?GcoGf zh%{zLfXD&7i54#}+I*buNfx{li;hI-Jt9gw^?Hnh+)H*k6|I^-M%eMr?K`(Y+FIKnS#2Dvbf9NnHqkMNY!>#W XMv=8XtHl2s{+l Date: Fri, 6 Nov 2026 20:39:47 +0000 Subject: [PATCH 03/13] cases: compare a naive timestamp with a zoned one, as dbt's incremental bound does TIMESTAMP_NTZ against CONVERT_TIMEZONE('UTC', tz), against a TIMESTAMP_TZ column at a non-UTC offset, and against CONVERT_TIMEZONE('UTC', CURRENT_TIMESTAMP()), under utc-v1. --- cases/temporal/naive_zoned_comparison.toml | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 cases/temporal/naive_zoned_comparison.toml diff --git a/cases/temporal/naive_zoned_comparison.toml b/cases/temporal/naive_zoned_comparison.toml new file mode 100644 index 000000000..60a43050f --- /dev/null +++ b/cases/temporal/naive_zoned_comparison.toml @@ -0,0 +1,46 @@ +category = "temporal" +construct = "node:lte" +session_profile = "utc-v1" + +[[case]] +id = "ntz_beside_utc_tz" +description = "Every comparison of a naive timestamp with CONVERT_TIMEZONE('UTC', tz), equal, a microsecond apart and across source offsets." +columns = [{ name = "n", type = "TIMESTAMP_NTZ(9)" }, { name = "tz", type = "TIMESTAMP_TZ(9)" }] +rows = [ + ["'2024-06-01 10:00:00'", "'2024-06-01 10:00:00 +00:00'"], + ["'2024-06-01 10:00:00'", "'2024-06-01 12:00:00 +02:00'"], + ["'2024-06-01 10:00:00.000001'", "'2024-06-01 10:00:00 +00:00'"], + ["'2024-06-01 09:59:59.999999'", "'2024-06-01 10:00:00 +00:00'"], + ["'2024-06-01 10:00:00'", "'2024-06-01 05:00:00 -05:00'"], + ["'1969-12-31 23:59:59.5'", "'1970-01-01 00:00:00 +00:00'"], + ["NULL", "'2024-06-01 10:00:00 +00:00'"], +] +expression = "ARRAY_CONSTRUCT(n = CONVERT_TIMEZONE('UTC', tz), n <> CONVERT_TIMEZONE('UTC', tz), n < CONVERT_TIMEZONE('UTC', tz), n <= CONVERT_TIMEZONE('UTC', tz), n > CONVERT_TIMEZONE('UTC', tz), n >= CONVERT_TIMEZONE('UTC', tz), CONVERT_TIMEZONE('UTC', tz) < n)" +expected_divergence = "json_fidelity" +tags = ["null", "boundary"] + +[[case]] +id = "ntz_beside_offset_tz_column" +description = "A naive timestamp compared with a TIMESTAMP_TZ column at a non-UTC offset, without CONVERT_TIMEZONE: whether Snowflake compares instants or wall times." +columns = [{ name = "n", type = "TIMESTAMP_NTZ(9)" }, { name = "tz", type = "TIMESTAMP_TZ(9)" }] +rows = [ + ["'2024-06-01 10:00:00'", "'2024-06-01 12:00:00 +02:00'"], + ["'2024-06-01 12:00:00'", "'2024-06-01 12:00:00 +02:00'"], + ["'2024-06-01 10:00:00'", "'2024-06-01 05:00:00 -05:00'"], +] +expression = "ARRAY_CONSTRUCT(n = tz, n < tz, n > tz)" +expected_divergence = "json_fidelity" +tags = ["boundary"] + +[[case]] +id = "ntz_at_most_utc_current_timestamp" +description = "dbt's incremental upper bound: a past naive timestamp is at most CONVERT_TIMEZONE('UTC', CURRENT_TIMESTAMP()); a far-future one is not." +columns = [{ name = "updated_at", type = "TIMESTAMP_NTZ(9)" }] +rows = [ + ["'2020-01-01 00:00:00'"], + ["'2199-12-31 23:59:59'"], + ["NULL"], +] +expression = "updated_at <= CONVERT_TIMEZONE('UTC', CURRENT_TIMESTAMP())" +expected_divergence = "current_time" +tags = ["null"] From 19acc9b9397d0526decc80d317992e417efa19d6 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:57:38 +0000 Subject: [PATCH 04/13] evidence: record naive timestamps compared with zoned ones Snowflake results for temporal/naive_zoned_comparison at 05d20a32. A TIMESTAMP_NTZ beside a TIMESTAMP_TZ compares instants, the naive value read in the session's time zone (UTC), with or without CONVERT_TIMEZONE('UTC', ...). --- ...d15da1b199d789026666df673a6ca9550014.arrow | Bin 0 -> 1514 bytes ...5d15da1b199d789026666df673a6ca9550014.json | 61 ++++++++++++++++++ ...1dc5c19f73ad715847a2fa441c03ad85e35e.arrow | Bin 0 -> 986 bytes ...d1dc5c19f73ad715847a2fa441c03ad85e35e.json | 61 ++++++++++++++++++ ...0dc0c3f702f134b85fa937f7b2cf7abf2591.arrow | Bin 0 -> 1122 bytes ...80dc0c3f702f134b85fa937f7b2cf7abf2591.json | 61 ++++++++++++++++++ 6 files changed, 183 insertions(+) create mode 100644 evidence/results/4847627d6a2122542b24fe9aa765d15da1b199d789026666df673a6ca9550014.arrow create mode 100644 evidence/results/4847627d6a2122542b24fe9aa765d15da1b199d789026666df673a6ca9550014.json create mode 100644 evidence/results/b5a431e5c5268eb4225aabe13a3d1dc5c19f73ad715847a2fa441c03ad85e35e.arrow create mode 100644 evidence/results/b5a431e5c5268eb4225aabe13a3d1dc5c19f73ad715847a2fa441c03ad85e35e.json create mode 100644 evidence/results/da9bcba92cbdbae85a17e271c4080dc0c3f702f134b85fa937f7b2cf7abf2591.arrow create mode 100644 evidence/results/da9bcba92cbdbae85a17e271c4080dc0c3f702f134b85fa937f7b2cf7abf2591.json diff --git a/evidence/results/4847627d6a2122542b24fe9aa765d15da1b199d789026666df673a6ca9550014.arrow b/evidence/results/4847627d6a2122542b24fe9aa765d15da1b199d789026666df673a6ca9550014.arrow new file mode 100644 index 0000000000000000000000000000000000000000..464206cf8c0f797d1daf745867bd0187cd7a144b GIT binary patch literal 1514 zcmeHHy-or_5FX@!^Spu>Vk{{wCXd~mTe!MmWgJdn-o!=m<%#0OBvE3fl?ZD zf~I5Dq7~cFHUXk_a2*~wX_u!0c9>F`0@e}?*^_0a3~*xAj}O7KzzZ-1Zh;G+1Zcnk zYT|lW=3EV673jFW<29}Z0&*KRTm#$iS-HAXDc5S1J;+@9<<&^kh2I_Zi0(eIZS|a? zvVC00y^UC0chGV?&ifoYUx8jlLe4)Jik2I=eIK%pZg98Mw{wjg!?JDb415jn`dt?x z<=1GiW*a7ipH0A3&rI5gZp4&5rnDw||H_S#WIzw|@psn&?p;ovg-bdnFN(!zL-D71 z+_3lYILdb)!Zq*!yaBuL2>|vzH;Tp4a4bsT9mflj!qQ1%!)Sird!929IZ=%|iSsb^ zl5^Z75o2yCgTj2K)ZLad?6r;)Wh%1&eWnwC`a=P>3EMjW;f7=4ozdDKL Z=N@}ZA9EV&|8L3Pyd_iI1Ku0F@$Z)}2?GEC literal 0 HcmV?d00001 diff --git a/evidence/results/4847627d6a2122542b24fe9aa765d15da1b199d789026666df673a6ca9550014.json b/evidence/results/4847627d6a2122542b24fe9aa765d15da1b199d789026666df673a6ca9550014.json new file mode 100644 index 000000000..96933c2cb --- /dev/null +++ b/evidence/results/4847627d6a2122542b24fe9aa765d15da1b199d789026666df673a6ca9550014.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "e98af85b95aba3afa0535ba022714508df55665e2ece531731c104ce93bfe265", + "case_hash": "4847627d6a2122542b24fe9aa765d15da1b199d789026666df673a6ca9550014", + "columns": [ + { + "internal_size": null, + "name": "VALUE", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 10, + "type_name": "ARRAY" + } + ], + "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": "d109d43ce82f44fe9037384a6690017c31e804dfd9ec096e1a6e32a986b1c56e", + "recorded_at": "2026-11-06T20:46:16.623971+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 7, + "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/b5a431e5c5268eb4225aabe13a3d1dc5c19f73ad715847a2fa441c03ad85e35e.arrow b/evidence/results/b5a431e5c5268eb4225aabe13a3d1dc5c19f73ad715847a2fa441c03ad85e35e.arrow new file mode 100644 index 0000000000000000000000000000000000000000..c14f26857fac5eb641c389d4b88d154ec8538fa6 GIT binary patch literal 986 zcmeHGF;2rk5L^?-F~Nm}B1FLhC@4TQ6f{OkXJHftsEuV3OO_KQE{MbfQc_apAv}Ty z@Rwxf&MS~bNy$iax4XA@H@jy?qfzpBA+iNt9Fb6nz69b)SA1B2K=Q2x6-t+ZieGmTSmDMs| zfc-yBw=JJjJDm_zeXtkjVv_nooeXDhI Z&>LdJw{GkIe@nJkfBh|O(D%sv(>om8cNG8t literal 0 HcmV?d00001 diff --git a/evidence/results/b5a431e5c5268eb4225aabe13a3d1dc5c19f73ad715847a2fa441c03ad85e35e.json b/evidence/results/b5a431e5c5268eb4225aabe13a3d1dc5c19f73ad715847a2fa441c03ad85e35e.json new file mode 100644 index 000000000..9369a9e87 --- /dev/null +++ b/evidence/results/b5a431e5c5268eb4225aabe13a3d1dc5c19f73ad715847a2fa441c03ad85e35e.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "185438aa5c54a46e8f93e26052aee6c9851b0d116ea3c59fc6cff4419ef945d7", + "case_hash": "b5a431e5c5268eb4225aabe13a3d1dc5c19f73ad715847a2fa441c03ad85e35e", + "columns": [ + { + "internal_size": null, + "name": "VALUE", + "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": "09e1e024cb2942b8a38e044600254d4547af79e592410cfdd6fc2996e418dafa", + "recorded_at": "2026-11-06T20:46:16.623971+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/da9bcba92cbdbae85a17e271c4080dc0c3f702f134b85fa937f7b2cf7abf2591.arrow b/evidence/results/da9bcba92cbdbae85a17e271c4080dc0c3f702f134b85fa937f7b2cf7abf2591.arrow new file mode 100644 index 0000000000000000000000000000000000000000..f039244c930a5e7bc61c13f3be15e59ecaefd6ad GIT binary patch literal 1122 zcmeHHF;2rk5Zn+a7~{f15u%_-Ns$64NTI|9oh2kIfDlj^<0O_WCyGsxA5c(GrsN5E z08gRdCt>DnD+Yn+s90%k_x9#)_r@nXo_BU>i>zReH4#f_hM3ZnhUkc3H4SsYOvk7d zBhr}d0U|r_I(BR-o+T}0XthQQV+Ta;WK3%S7mj;=3f~0YfhF(++yEUw15VHr`(eyl z17HgnMu{H}=F<@IJvi)v9In~DqpscWcaIUX_q%x(x?wWP#v;$ZaD%ZwE4i;D9HNLSr|lVG)WNaP=jifcV~}0!|^=l3Vs`iC!+{Qs@rH_lS9Mt zs|9?inJK%)7crH_QrDFBxK?2X=6OBd+Xbjyy;!BI#+9@R$)%N^T9-!czuUWyJas1b z+Gw}4*+bZYANp~+4vbr_ea?|ZcE0x0J{+rbpl4qu)MzSIty}}B#rg+8`9>CO7WSe> Wk+nW~;{Of*%^P0gJK&w;-|_)(5|*C; literal 0 HcmV?d00001 diff --git a/evidence/results/da9bcba92cbdbae85a17e271c4080dc0c3f702f134b85fa937f7b2cf7abf2591.json b/evidence/results/da9bcba92cbdbae85a17e271c4080dc0c3f702f134b85fa937f7b2cf7abf2591.json new file mode 100644 index 000000000..023ee837a --- /dev/null +++ b/evidence/results/da9bcba92cbdbae85a17e271c4080dc0c3f702f134b85fa937f7b2cf7abf2591.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "1ff7a750f1062f3178c14b0a9835965d0d871bda92fe0611c4b895c7a8ef55dc", + "case_hash": "da9bcba92cbdbae85a17e271c4080dc0c3f702f134b85fa937f7b2cf7abf2591", + "columns": [ + { + "internal_size": null, + "name": "VALUE", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 10, + "type_name": "ARRAY" + } + ], + "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": "1da7fe8d0fb3ebdf79560077eeae89a2074bba65bf9967a53448bc3cea710a50", + "recorded_at": "2026-11-06T20:46:16.623971+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" +} From d97c099f9e3f0c69cdddf2ba7af99799546ed790 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:08:27 +0000 Subject: [PATCH 05/13] fix: compare naive timestamps with TIMESTAMP_TZ values as instants, as recorded Snowflake reads the naive value in the session time zone and compares instants beside a TIMESTAMP_TZ at any offset, with or without CONVERT_TIMEZONE, so the naive-local comparison now reads a TIMESTAMP_TZ as its UTC wall time too. This covers dbt's incremental upper bound, convert_timezone('UTC', current_timestamp()). Cases whose outcome now matches Snowflake no longer declare a divergence, and the zoned columns' NTZ comparison declares timestamp_tz_submicro for its nanosecond row, as their text comparisons do. --- cases/generated/zoned_texts/column_comparisons.toml | 2 ++ cases/temporal/naive_local_comparison.toml | 3 --- cases/temporal/naive_zoned_comparison.toml | 2 -- crates/sqlquack-rules/src/catalogue/constants.rs | 4 ++-- .../src/conversion/_helpers/expressions/operators.rs | 12 +++++++++--- scripts/family_cases/_helpers/zoned_texts.py | 4 ++-- scripts/family_cases/constants.py | 4 ++-- 7 files changed, 17 insertions(+), 14 deletions(-) diff --git a/cases/generated/zoned_texts/column_comparisons.toml b/cases/generated/zoned_texts/column_comparisons.toml index c8d7e73ad..c4cd16ce4 100644 --- a/cases/generated/zoned_texts/column_comparisons.toml +++ b/cases/generated/zoned_texts/column_comparisons.toml @@ -110,6 +110,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_tz_column_gte_date" @@ -233,6 +234,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_local_comparison.toml b/cases/temporal/naive_local_comparison.toml index aa7a2e522..63b7cb692 100644 --- a/cases/temporal/naive_local_comparison.toml +++ b/cases/temporal/naive_local_comparison.toml @@ -31,7 +31,6 @@ rows = [ ["'2024-06-01 10:00:00.124'", "'2024-06-01 10:00:00.123999 +00:00'"], ] expression = "ARRAY_CONSTRUCT(n = l, n < l, n > l)" -expected_divergence = "json_fidelity" tags = ["boundary"] [[case]] @@ -44,7 +43,6 @@ rows = [ ["NULL"], ] expression = "updated_at <= CURRENT_TIMESTAMP()" -expected_divergence = "current_time" tags = ["null"] [[case]] @@ -57,7 +55,6 @@ rows = [ ["NULL"], ] expression = "updated_at >= DATEADD(day, -3, CURRENT_TIMESTAMP())" -expected_divergence = "current_time" tags = ["null"] [[case]] diff --git a/cases/temporal/naive_zoned_comparison.toml b/cases/temporal/naive_zoned_comparison.toml index 60a43050f..1375e73cc 100644 --- a/cases/temporal/naive_zoned_comparison.toml +++ b/cases/temporal/naive_zoned_comparison.toml @@ -29,7 +29,6 @@ rows = [ ["'2024-06-01 10:00:00'", "'2024-06-01 05:00:00 -05:00'"], ] expression = "ARRAY_CONSTRUCT(n = tz, n < tz, n > tz)" -expected_divergence = "json_fidelity" tags = ["boundary"] [[case]] @@ -42,5 +41,4 @@ rows = [ ["NULL"], ] expression = "updated_at <= CONVERT_TIMEZONE('UTC', CURRENT_TIMESTAMP())" -expected_divergence = "current_time" tags = ["null"] diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index 607d2426e..4e4bc3e83 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -56,7 +56,7 @@ use crate::conversion::constants::{ }; use crate::conversion::models::RuleSpec; -/// A TIMESTAMP_NTZ compared with a TIMESTAMP_LTZ in the UTC session. +/// A TIMESTAMP_NTZ compared with a TIMESTAMP_LTZ or TIMESTAMP_TZ in the UTC session. pub const NAIVE_LOCAL_COMPARISON: &str = "rule.naive_local_comparison"; /// Every rule. pub const RULES: &[RuleSpec] = &[ @@ -323,7 +323,7 @@ pub const RULES: &[RuleSpec] = &[ constructs: &[ "node:eq", "node:neq", "node:lt", "node:lte", "node:gt", "node:gte", ], - conditions: "A TIMESTAMP_NTZ beside a TIMESTAMP_LTZ, such as CURRENT_TIMESTAMP() or DATEADD of it: the TIMESTAMP_LTZ is read as its wall time in the session time zone, which relies on session_timezone_utc, and compared with the naive value.", + conditions: "A TIMESTAMP_NTZ beside a TIMESTAMP_LTZ, such as CURRENT_TIMESTAMP() or DATEADD of it, or beside a TIMESTAMP_TZ at any offset, such as CONVERT_TIMEZONE('UTC', CURRENT_TIMESTAMP()): Snowflake reads the naive value in the session time zone and compares instants, as recorded, so the zoned value is read as its wall time in UTC, which relies on session_timezone_utc.", 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 c47b4f6d0..b37c68187 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/operators.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/operators.rs @@ -594,11 +594,17 @@ pub(crate) fn comparable( let left = zoned_literal(left, construct, context)?; return Ok((left, right)); } - (SnowflakeType::TimestampNtz { .. }, SnowflakeType::TimestampLtz { precision }) => { + ( + SnowflakeType::TimestampNtz { .. }, + SnowflakeType::TimestampLtz { precision } | SnowflakeType::TimestampTz { precision }, + ) => { let right = local_as_naive(right, precision, construct, context)?; return comparable(left, right, construct, context); } - (SnowflakeType::TimestampLtz { precision }, SnowflakeType::TimestampNtz { .. }) => { + ( + SnowflakeType::TimestampLtz { precision } | SnowflakeType::TimestampTz { precision }, + SnowflakeType::TimestampNtz { .. }, + ) => { let left = local_as_naive(left, precision, construct, context)?; return comparable(left, right, construct, context); } @@ -627,7 +633,7 @@ pub(crate) fn comparable( } } -/// A TIMESTAMP_LTZ beside a TIMESTAMP_NTZ, as its wall time in the UTC session at its own precision. +/// A TIMESTAMP_LTZ or TIMESTAMP_TZ beside a TIMESTAMP_NTZ, as its instant's UTC wall time. fn local_as_naive( emitted: Emitted, precision: u8, diff --git a/scripts/family_cases/_helpers/zoned_texts.py b/scripts/family_cases/_helpers/zoned_texts.py index c5d4f2eca..4d7de7799 100644 --- a/scripts/family_cases/_helpers/zoned_texts.py +++ b/scripts/family_cases/_helpers/zoned_texts.py @@ -29,7 +29,7 @@ ZONED_STRICT_FORMS, ZONED_SUBMICRO_DIVERGENCE, ZONED_SUBMICRO_PROJECTIONS, - ZONED_TEXT_COMPARISONS, + ZONED_SUBMICRO_COMPARISONS, ZONED_TEXTS, ZONED_WALL_TIMES, ) @@ -185,7 +185,7 @@ 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_SUBMICRO_COMPARISONS else None ), ) diff --git a/scripts/family_cases/constants.py b/scripts/family_cases/constants.py index ba5e0e3b9..ec5b0801b 100644 --- a/scripts/family_cases/constants.py +++ b/scripts/family_cases/constants.py @@ -140,6 +140,6 @@ "date": "variant_typed_values", "ntz": "variant_typed_values", } -ZONED_TEXT_COMPARISONS: frozenset[str] = frozenset( - {"gte_naive_text", "lt_date_text", "eq_offset_text", "between_texts"} +ZONED_SUBMICRO_COMPARISONS: frozenset[str] = frozenset( + {"gte_naive_text", "lt_date_text", "eq_offset_text", "between_texts", "eq_ntz"} ) From fbc4331c297e623371334febc2b240f288c71857 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:24:39 +0000 Subject: [PATCH 06/13] fix: rely on timestamp_range where a zoned comparison widens to nanoseconds A zoned value read as its UTC wall time at a precision above microseconds is cast to nanoseconds, which DuckDB cannot hold past 2262, where Snowflake compares it. BETWEEN joins the comparisons the rule and timestamp_range cover. --- crates/sqlquack-rules/src/catalogue/constants.rs | 10 ++++++++-- .../src/conversion/_helpers/expressions/operators.rs | 9 ++++++--- sqlquack.toml | 2 +- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index 4e4bc3e83..22390f364 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -321,9 +321,15 @@ pub const RULES: &[RuleSpec] = &[ RuleSpec { id: NAIVE_LOCAL_COMPARISON, constructs: &[ - "node:eq", "node:neq", "node:lt", "node:lte", "node:gt", "node:gte", + "node:eq", + "node:neq", + "node:lt", + "node:lte", + "node:gt", + "node:gte", + "node:between", ], - conditions: "A TIMESTAMP_NTZ beside a TIMESTAMP_LTZ, such as CURRENT_TIMESTAMP() or DATEADD of it, or beside a TIMESTAMP_TZ at any offset, such as CONVERT_TIMEZONE('UTC', CURRENT_TIMESTAMP()): Snowflake reads the naive value in the session time zone and compares instants, as recorded, so the zoned value is read as its wall time in UTC, which relies on session_timezone_utc.", + conditions: "A TIMESTAMP_NTZ beside a TIMESTAMP_LTZ, such as CURRENT_TIMESTAMP() or DATEADD of it, or beside a TIMESTAMP_TZ at any offset, such as CONVERT_TIMEZONE('UTC', CURRENT_TIMESTAMP()): Snowflake reads the naive value in the session time zone and compares instants, as recorded, so the zoned value is read as its wall time in UTC, which relies on session_timezone_utc, and on timestamp_range at precisions above microseconds.", 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 b37c68187..13fa661e9 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/operators.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/operators.rs @@ -44,11 +44,11 @@ use sqlquack_duckdb::blocks::constants::NANOSECOND_DIGITS; use crate::catalogue::constants::NAIVE_LOCAL_COMPARISON; 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}; +use sqlquack_policy::policy::constants::{NUMERIC_RANGE, SESSION_TIMEZONE_UTC, TIMESTAMP_RANGE}; /// Characters in a `YYYY-MM-DD` date. const ISO_DATE_LENGTH: usize = 10; @@ -643,6 +643,9 @@ fn local_as_naive( let log = context.log; let _rule = log.rule(NAIVE_LOCAL_COMPARISON); diverge(context, SESSION_TIMEZONE_UTC, construct)?; + if precision > MICROSECOND_DIGITS { + diverge(context, TIMESTAMP_RANGE, construct)?; + } let naive = SnowflakeType::TimestampNtz { precision }; let sql = cast(&utc_wall_time(&emitted.sql, log), naive, log) .map_err(|reason| outside_conditions(construct.clone(), reason))?; diff --git a/sqlquack.toml b/sqlquack.toml index 3b157d481..392c65231 100644 --- a/sqlquack.toml +++ b/sqlquack.toml @@ -434,7 +434,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:neq", "node:lt", "node:lte", "node:gt", "node:gte", "node:between", "node:in", "fn:TO_TIMESTAMP_TZ", "fn:TO_TIMESTAMP_LTZ", "fn:TRY_TO_TIMESTAMP_LTZ"] default = "warn" strict = "refuse" failure = "loud" From 757ee763d6c2dd4414a1fef7f80171f798c22d4b Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:27:38 +0000 Subject: [PATCH 07/13] cases: compare naive timestamps with zoned values past 2262 and in BETWEEN TIMESTAMP_NTZ(9) beside TIMESTAMP_TZ and TIMESTAMP_LTZ sentinels past the nanosecond range, and BETWEEN with mixed naive and zoned bounds, under utc-v1. --- cases/temporal/naive_zoned_comparison.toml | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/cases/temporal/naive_zoned_comparison.toml b/cases/temporal/naive_zoned_comparison.toml index 60a43050f..20ef8869f 100644 --- a/cases/temporal/naive_zoned_comparison.toml +++ b/cases/temporal/naive_zoned_comparison.toml @@ -44,3 +44,30 @@ rows = [ expression = "updated_at <= CONVERT_TIMEZONE('UTC', CURRENT_TIMESTAMP())" expected_divergence = "current_time" tags = ["null"] + +[[case]] +id = "ntz_beside_zoned_past_nanosecond_range" +description = "A naive TIMESTAMP_NTZ(9) compared with TIMESTAMP_TZ and TIMESTAMP_LTZ sentinels past 2262, which DuckDB cannot widen to nanoseconds." +columns = [{ name = "n", type = "TIMESTAMP_NTZ(9)" }, { name = "tz", type = "TIMESTAMP_TZ(9)" }, { name = "l", type = "TIMESTAMP_LTZ(9)" }] +rows = [ + ["'2024-06-01 10:00:00'", "'9999-12-31 00:00:00 +00:00'", "'9999-12-31 00:00:00 +00:00'"], + ["'2024-06-01 10:00:00'", "'2300-01-01 12:00:00 +02:00'", "'2300-01-01 12:00:00 +00:00'"], + ["'2024-06-01 10:00:00'", "'2024-06-01 12:00:00 +02:00'", "'2024-06-01 10:00:00 +00:00'"], +] +expression = "ARRAY_CONSTRUCT(n < tz, n = tz, n < l, n >= l)" +expected_divergence = "timestamp_range" +tags = ["boundary"] + +[[case]] +id = "ntz_between_zoned_bounds" +description = "A naive timestamp BETWEEN a TIMESTAMP_TZ and a TIMESTAMP_LTZ bound, and a zoned value BETWEEN naive bounds." +columns = [{ name = "n", type = "TIMESTAMP_NTZ(9)" }, { name = "tz", type = "TIMESTAMP_TZ(9)" }, { name = "l", type = "TIMESTAMP_LTZ(9)" }] +rows = [ + ["'2024-06-01 10:00:00'", "'2024-06-01 11:00:00 +02:00'", "'2024-06-01 11:00:00 +00:00'"], + ["'2024-06-01 10:00:00'", "'2024-06-01 12:00:00 +02:00'", "'2024-06-01 10:00:00 +00:00'"], + ["'2024-06-01 10:00:00'", "'2024-06-01 13:00:00 +02:00'", "'2024-06-01 09:00:00 +00:00'"], + ["NULL", "'2024-06-01 10:00:00 +00:00'", "'2024-06-01 10:00:00 +00:00'"], +] +query = "SELECT n BETWEEN tz AND l AS a, tz BETWEEN n AND '2024-06-01 10:30:00'::TIMESTAMP_NTZ AS b FROM input ORDER BY ordinal" +order = "ordered" +tags = ["null", "boundary"] From 0cdfba3553fc4ce1bded9291ab37af7887e5263c Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:39:38 +0000 Subject: [PATCH 08/13] evidence: record naive timestamps beside zoned values past 2262 and in BETWEEN Snowflake results for the two cases added at 757ee763. Past the nanosecond range and in BETWEEN with mixed bounds, a TIMESTAMP_NTZ still compares with TIMESTAMP_TZ and TIMESTAMP_LTZ values as instants, read in the session's time zone (UTC). --- ...10ae772771572eb154db3c0a0eac0024b93b.arrow | Bin 0 -> 1626 bytes ...210ae772771572eb154db3c0a0eac0024b93b.json | 70 ++++++++++++++++++ ...e451c7dfac66e05406624c0744301434c1ee.arrow | Bin 0 -> 1146 bytes ...8e451c7dfac66e05406624c0744301434c1ee.json | 61 +++++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 evidence/results/5a1b4c80d91d82d07126c1ba580210ae772771572eb154db3c0a0eac0024b93b.arrow create mode 100644 evidence/results/5a1b4c80d91d82d07126c1ba580210ae772771572eb154db3c0a0eac0024b93b.json create mode 100644 evidence/results/f8b693ec5e28961d189d3aa6fe68e451c7dfac66e05406624c0744301434c1ee.arrow create mode 100644 evidence/results/f8b693ec5e28961d189d3aa6fe68e451c7dfac66e05406624c0744301434c1ee.json diff --git a/evidence/results/5a1b4c80d91d82d07126c1ba580210ae772771572eb154db3c0a0eac0024b93b.arrow b/evidence/results/5a1b4c80d91d82d07126c1ba580210ae772771572eb154db3c0a0eac0024b93b.arrow new file mode 100644 index 0000000000000000000000000000000000000000..3fdf1f527ab26336e20ac8fdb46408664158a5f9 GIT binary patch literal 1626 zcmeHIu}%U(5FO+=Po%gQVkj&qEi5!P78V|f9q|kyv9USvgiFXlfQG~m@DnI0`2-6K zN(&1=!1yVi@9o|Oayg@w6_dPWX7}yx%)8m(wOanoiAVyQZHc%-D@#ReDTxCQR66d39pE8~ zgMNED4B=m7I7nONcQ>7c%`omxdLs83&T-1=^n#J$Jmqjo;Eg*$pZ6@*)>-4}na*W6 z3Oms_8pN1;1BbOT-)*4b`^`)5N_OG*2i*uqsjMwjO^(Q()S!wDHnnu6iW!>Ltd^sh zhgE9!4$Oh9X4m*;ziBqfaPl;pWjJ}7z2tD#HTzgw|4B0km9u-;8-LTzO10Dd`JC>< z4^G;LHtf)f(o>2S^cAUdQCS(WzK=b83mC2Ry8d>?XV1}?8_mSrLo+e&pqZFH>($?Q zHJ^>yBebIaQ9VPKdufml??>lYfjYLT%+HdaFzHrKkQ=z5%A+;bi~- literal 0 HcmV?d00001 diff --git a/evidence/results/5a1b4c80d91d82d07126c1ba580210ae772771572eb154db3c0a0eac0024b93b.json b/evidence/results/5a1b4c80d91d82d07126c1ba580210ae772771572eb154db3c0a0eac0024b93b.json new file mode 100644 index 000000000..ca202f9ee --- /dev/null +++ b/evidence/results/5a1b4c80d91d82d07126c1ba580210ae772771572eb154db3c0a0eac0024b93b.json @@ -0,0 +1,70 @@ +{ + "arrow_sha256": "8fb7c2e3c6229061420e56660e1755df17895af2ba77873641ad97c48b4bc096", + "case_hash": "5a1b4c80d91d82d07126c1ba580210ae772771572eb154db3c0a0eac0024b93b", + "columns": [ + { + "internal_size": null, + "name": "A", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 13, + "type_name": "BOOLEAN" + }, + { + "internal_size": null, + "name": "B", + "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": "8db9e137833ba763bbec5e6afa43f20f24a11a6e395fb85f9cfe60b902a1d48f", + "recorded_at": "2026-11-06T21:34:15.187868+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/f8b693ec5e28961d189d3aa6fe68e451c7dfac66e05406624c0744301434c1ee.arrow b/evidence/results/f8b693ec5e28961d189d3aa6fe68e451c7dfac66e05406624c0744301434c1ee.arrow new file mode 100644 index 0000000000000000000000000000000000000000..267070c0ed3da4a53d361afc3d0fe33d80dc32bf GIT binary patch literal 1146 zcmeHHJx{_=6up&#wRUI>F^&ul4h$AdaI}MoLlct*HAWdK7HHA{DLAk(GB`Lg`V;s8 z{uBrQ37+%XCe@JW9{}DNyfjQiBnkNmX)oPr==j;!&Y3PN+{&XPn@iefJi27l=pw2qo3m=*VUc z1?AUE*qk#<_OlzY6vtB1758%{D=`tL{rKG;K<-LvFI7dqu&i9>QgR!oq0;1j)7e9; zUjWR#Gi$YJ@)Xu#dqF%=&X)zV^V^)w=UJHr%?{crerP$b1(qs`Z9C-#Eb{)30P@NC bmsF5frA6kM&CUPY{+qYG!ad;q Date: Fri, 6 Nov 2026 21:40:47 +0000 Subject: [PATCH 09/13] style: sort the zoned-text generator's imports --- scripts/family_cases/_helpers/zoned_texts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/family_cases/_helpers/zoned_texts.py b/scripts/family_cases/_helpers/zoned_texts.py index 4d7de7799..347c00dfc 100644 --- a/scripts/family_cases/_helpers/zoned_texts.py +++ b/scripts/family_cases/_helpers/zoned_texts.py @@ -27,9 +27,9 @@ ZONED_SPELLING_EDGES, ZONED_STRICT_EDGES_DEFERRED, ZONED_STRICT_FORMS, + ZONED_SUBMICRO_COMPARISONS, ZONED_SUBMICRO_DIVERGENCE, ZONED_SUBMICRO_PROJECTIONS, - ZONED_SUBMICRO_COMPARISONS, ZONED_TEXTS, ZONED_WALL_TIMES, ) From a91604ee06aad1b26e56dfb427f26f3679e9c458 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:10:44 +0000 Subject: [PATCH 10/13] coverage: re-record the translation baseline with instant comparisons of naive and zoned timestamps --- coverage/translation/results-baseline.json | 37 ++++++++++++++-------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/coverage/translation/results-baseline.json b/coverage/translation/results-baseline.json index 8e22ff07d..36c84325e 100644 --- a/coverage/translation/results-baseline.json +++ b/coverage/translation/results-baseline.json @@ -6277,14 +6277,14 @@ "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", "generated/zoned_texts/column_comparisons.toml#timestamp_ltz_column_gte_naive_text": "declared_divergence", "generated/zoned_texts/column_comparisons.toml#timestamp_ltz_column_lt_date_text": "declared_divergence", "generated/zoned_texts/column_comparisons.toml#timestamp_tz_column_between_texts": "declared_divergence", - "generated/zoned_texts/column_comparisons.toml#timestamp_tz_column_eq_ntz": "refused", + "generated/zoned_texts/column_comparisons.toml#timestamp_tz_column_eq_ntz": "declared_divergence", "generated/zoned_texts/column_comparisons.toml#timestamp_tz_column_eq_offset_text": "declared_divergence", "generated/zoned_texts/column_comparisons.toml#timestamp_tz_column_eq_t_text": "refused", "generated/zoned_texts/column_comparisons.toml#timestamp_tz_column_gte_date": "refused", @@ -7484,6 +7484,17 @@ "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_local_comparison.toml#dateadd_of_ltz_column": "declared_divergence", + "temporal/naive_local_comparison.toml#datediff_subday_of_dates": "declared_divergence", + "temporal/naive_local_comparison.toml#ntz_after_dateadd_of_current_timestamp": "matching", + "temporal/naive_local_comparison.toml#ntz_at_most_current_timestamp": "matching", + "temporal/naive_local_comparison.toml#ntz_beside_ltz_columns": "declared_divergence", + "temporal/naive_local_comparison.toml#ntz_millisecond_beside_ltz": "matching", + "temporal/naive_zoned_comparison.toml#ntz_at_most_utc_current_timestamp": "matching", + "temporal/naive_zoned_comparison.toml#ntz_beside_offset_tz_column": "matching", + "temporal/naive_zoned_comparison.toml#ntz_beside_utc_tz": "declared_divergence", + "temporal/naive_zoned_comparison.toml#ntz_beside_zoned_past_nanosecond_range": "declared_divergence", + "temporal/naive_zoned_comparison.toml#ntz_between_zoned_bounds": "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", @@ -9370,10 +9381,10 @@ "translation/functions/current-time.current-time.toml#t003_precision_range": "refused", "translation/functions/current-time.current-time.toml#t004_null_none": "refused", "translation/functions/current-timestamp.current-timestamp.syntax.toml#t001_type": "refused", - "translation/functions/current-timestamp.current-timestamp.syntax.toml#t002_null_none": "refused", + "translation/functions/current-timestamp.current-timestamp.syntax.toml#t002_null_none": "matching", "translation/functions/current-timestamp.current-timestamp.toml#t001_type": "refused", "translation/functions/current-timestamp.current-timestamp.toml#t002_precision": "refused", - "translation/functions/current-timestamp.current-timestamp.toml#t003_null_none": "refused", + "translation/functions/current-timestamp.current-timestamp.toml#t003_null_none": "matching", "translation/functions/datasketches-hll-accumulate.toml#t001_estimate": "refused", "translation/functions/datasketches-hll-accumulate.toml#t002_float": "refused", "translation/functions/datasketches-hll-accumulate.toml#t003_max_log_k": "refused", @@ -9439,7 +9450,7 @@ "translation/functions/datediff.datediff.toml#t001_date": "matching", "translation/functions/datediff.datediff.toml#t002_timestamp_ntz": "matching", "translation/functions/datediff.datediff.toml#t003_time": "matching", - "translation/functions/datediff.datediff.toml#t004_mixed": "refused", + "translation/functions/datediff.datediff.toml#t004_mixed": "matching", "translation/functions/datediff.datediff.toml#t005_null_date_or_time_expr1": "matching", "translation/functions/datediff.datediff.toml#t006_null_date_or_time_expr2": "matching", "translation/functions/datediff.datediff.toml#t007_leap": "matching", @@ -11263,7 +11274,7 @@ "translation/functions/timediff.toml#t001_date": "matching", "translation/functions/timediff.toml#t002_timestamp_ntz": "matching", "translation/functions/timediff.toml#t003_time": "matching", - "translation/functions/timediff.toml#t004_mixed": "refused", + "translation/functions/timediff.toml#t004_mixed": "matching", "translation/functions/timediff.toml#t005_null_date_or_time_expr1": "matching", "translation/functions/timediff.toml#t006_null_date_or_time_expr2": "matching", "translation/functions/timediff.toml#t007_leap": "matching", @@ -11335,7 +11346,7 @@ "translation/functions/timestampdiff.toml#t001_date": "matching", "translation/functions/timestampdiff.toml#t002_timestamp_ntz": "matching", "translation/functions/timestampdiff.toml#t003_time": "matching", - "translation/functions/timestampdiff.toml#t004_mixed": "refused", + "translation/functions/timestampdiff.toml#t004_mixed": "matching", "translation/functions/timestampdiff.toml#t005_null_date_or_time_expr1": "matching", "translation/functions/timestampdiff.toml#t006_null_date_or_time_expr2": "matching", "translation/functions/timestampdiff.toml#t007_leap": "matching", @@ -13458,7 +13469,7 @@ "sf.functions.current-time.current-time": "refused", "sf.functions.current-time.current-time.syntax": "refused", "sf.functions.current-timestamp.current-timestamp": "refused", - "sf.functions.current-timestamp.current-timestamp.syntax": "unmeasured", + "sf.functions.current-timestamp.current-timestamp.syntax": "unmet_obligations", "sf.functions.current-transaction": "not_translatable", "sf.functions.current-user.current-user": "not_translatable", "sf.functions.current-user.current-user.syntax": "not_translatable", @@ -16354,10 +16365,10 @@ "sf.functions.current-time.current-time.t003": "unmet", "sf.functions.current-time.current-time.t004": "unmet", "sf.functions.current-timestamp.current-timestamp.syntax.t001": "unmet", - "sf.functions.current-timestamp.current-timestamp.syntax.t002": "unmet", + "sf.functions.current-timestamp.current-timestamp.syntax.t002": "met", "sf.functions.current-timestamp.current-timestamp.t001": "unmet", "sf.functions.current-timestamp.current-timestamp.t002": "unmet", - "sf.functions.current-timestamp.current-timestamp.t003": "unmet", + "sf.functions.current-timestamp.current-timestamp.t003": "met", "sf.functions.datasketches-hll-accumulate.t001": "unmet", "sf.functions.datasketches-hll-accumulate.t002": "unmet", "sf.functions.datasketches-hll-accumulate.t003": "unmet", @@ -16424,7 +16435,7 @@ "sf.functions.datediff.datediff.t001": "met", "sf.functions.datediff.datediff.t002": "met", "sf.functions.datediff.datediff.t003": "met", - "sf.functions.datediff.datediff.t004": "unmet", + "sf.functions.datediff.datediff.t004": "met", "sf.functions.datediff.datediff.t005": "met", "sf.functions.datediff.datediff.t006": "met", "sf.functions.datediff.datediff.t007": "met", @@ -18246,7 +18257,7 @@ "sf.functions.timediff.t001": "met", "sf.functions.timediff.t002": "met", "sf.functions.timediff.t003": "met", - "sf.functions.timediff.t004": "unmet", + "sf.functions.timediff.t004": "met", "sf.functions.timediff.t005": "met", "sf.functions.timediff.t006": "met", "sf.functions.timediff.t007": "met", @@ -18318,7 +18329,7 @@ "sf.functions.timestampdiff.t001": "met", "sf.functions.timestampdiff.t002": "met", "sf.functions.timestampdiff.t003": "met", - "sf.functions.timestampdiff.t004": "unmet", + "sf.functions.timestampdiff.t004": "met", "sf.functions.timestampdiff.t005": "met", "sf.functions.timestampdiff.t006": "met", "sf.functions.timestampdiff.t007": "met", From 849ada328d3913d2b3db4b086f21b396e677eb84 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 7 Nov 2026 06:32:54 +0000 Subject: [PATCH 11/13] test: expect the nanosecond range when a naive timestamp is compared with a nanosecond local one --- .../src/analysis/tests/test_session_timezone.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/sqlquack/src/analysis/tests/test_session_timezone.rs b/crates/sqlquack/src/analysis/tests/test_session_timezone.rs index 4c4271ca9..bf49f14f9 100644 --- a/crates/sqlquack/src/analysis/tests/test_session_timezone.rs +++ b/crates/sqlquack/src/analysis/tests/test_session_timezone.rs @@ -80,11 +80,22 @@ fn given_session_dependent_statements_when_converting_then_they_rely_on_a_utc_se expected_outcome: relying_on_utc_session(&["fn:CONVERT_TIMEZONE"]), }, SessionTimeZoneTestCase { - description: "a naive timestamp compared with a local one relies on it", + description: "a naive timestamp compared with a nanosecond local one relies on it, \ + and on the nanosecond range", sql: NAIVE_BESIDE_LOCAL, strict: false, utc_session: false, - expected_outcome: relying_on_utc_session(&["node:lt"]), + expected_outcome: DivergenceModeOutcome { + converted: true, + divergences: vec![ + ( + "session_timezone_utc".to_owned(), + vec!["node:lt".to_owned()], + ), + ("timestamp_range".to_owned(), vec!["node:lt".to_owned()]), + ], + categories: vec![], + }, }, SessionTimeZoneTestCase { description: "strict mode refuses that comparison without a confirmed UTC session", From b1130107e36cd468a92292efec3bddbf33cb8801 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 7 Nov 2026 06:32:56 +0000 Subject: [PATCH 12/13] corpus: record the clock filters and timestamp differences that now translate --- corpora/basics/queries.toml | 25 +++++++++++++++---------- corpora/everyday-models/queries.toml | 18 +++++++++--------- corpora/everyday/queries.toml | 4 ++-- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/corpora/basics/queries.toml b/corpora/basics/queries.toml index 9f90d9954..003f6bbb5 100644 --- a/corpora/basics/queries.toml +++ b/corpora/basics/queries.toml @@ -14565,29 +14565,34 @@ expect_repository = "refused SQ0003 node:gte at 42..87" [[query]] category = "last_days" sql = '''SELECT order_id FROM orders WHERE ordered_at >= DATEADD(day, -30, CURRENT_TIMESTAMP())''' -expect = "refused SQ0003 fn:DATEADD at 48..86" -expect_repository = "refused SQ0003 fn:DATEADD at 48..86" +expect = "refused SQ0008 fn:DATEADD at 48..86" +expect_repository = "translated" +result_repository = "0 rows e3b0c44298fc1c14" [[query]] category = "last_days" sql = '''select /* c */ order_id from "ORDERS" where ordered_at >= dateadd(day, -30, current_timestamp())''' -expect = "refused SQ0003 fn:DATEADD at 58..96" -expect_repository = "refused SQ0003 fn:DATEADD at 58..96" +expect = "refused SQ0008 fn:DATEADD at 58..96" +expect_repository = "translated" +result_repository = "0 rows e3b0c44298fc1c14" [[query]] category = "last_days" sql = '''SELECT /* c */ ORDER_ID FROM ORDERS WHERE ORDERED_AT >= DATEADD(DAY, -30, CURRENT_TIMESTAMP())''' -expect = "refused SQ0003 fn:DATEADD at 60..98" -expect_repository = "refused SQ0003 fn:DATEADD at 60..98" +expect = "refused SQ0008 fn:DATEADD at 60..98" +expect_repository = "translated" +result_repository = "0 rows e3b0c44298fc1c14" [[query]] category = "last_days" sql = '''select order_id from orders where ordered_at >= DATEADD(day, -30, CURRENT_TIMESTAMP())''' -expect = "refused SQ0003 fn:DATEADD at 48..86" -expect_repository = "refused SQ0003 fn:DATEADD at 48..86" +expect = "refused SQ0008 fn:DATEADD at 48..86" +expect_repository = "translated" +result_repository = "0 rows e3b0c44298fc1c14" [[query]] category = "last_days" sql = '''SeLect ordEr_Id FrOM "ORDERS" wHERe oRDeRed_at >= DAteAdD(DAY, -30, cURrENt_tImEsTamP())''' -expect = "refused SQ0003 fn:DATEADD at 54..92" -expect_repository = "refused SQ0003 fn:DATEADD at 54..92" +expect = "refused SQ0008 fn:DATEADD at 54..92" +expect_repository = "translated" +result_repository = "0 rows e3b0c44298fc1c14" diff --git a/corpora/everyday-models/queries.toml b/corpora/everyday-models/queries.toml index aad2a7303..b3fe98972 100644 --- a/corpora/everyday-models/queries.toml +++ b/corpora/everyday-models/queries.toml @@ -150,8 +150,8 @@ sql = '''select _loaded_at from raw_orders where _loaded_at >= dateadd(day, -3, current_timestamp())''' -expect = "refused SQ0003 fn:DATEADD at 152..189" -expect_repository = "refused SQ0003 fn:DATEADD at 152..189" +expect = "refused SQ0008 fn:DATEADD at 152..189" +expect_repository = "translated" [[query]] category = "dbt_staging" @@ -4066,8 +4066,8 @@ category = "dbt_tests" sql = '''select max(updated_at) as max_updated_at from stg_orders having max(updated_at) < dateadd(hour, -24, current_timestamp())''' -expect = "refused SQ0003 fn:DATEADD at 82..121" -expect_repository = "refused SQ0003 fn:DATEADD at 82..121" +expect = "refused SQ0008 fn:DATEADD at 82..121" +expect_repository = "translated" [[query]] category = "dbt_tests" @@ -4704,8 +4704,8 @@ sql = '''select * from stg_orders where updated_at > (select max(updated_at) from fct_orders) and updated_at <= current_timestamp()''' -expect = "refused SQ0003 node:lte at 91..124" -expect_repository = "refused SQ0003 node:lte at 91..124" +expect = "refused SQ0008 node:lte at 91..124" +expect_repository = "translated" [[query]] category = "dbt_incremental" @@ -4797,8 +4797,8 @@ expect_repository = "translated" category = "dates" sql = '''select order_id, timestampdiff(hour, order_date, updated_at) as hours_to_update from stg_orders''' -expect = "refused SQ0003 fn:TIMESTAMPDIFF at 17..60" -expect_repository = "refused SQ0003 fn:TIMESTAMPDIFF at 17..60" +expect = "translated" +expect_repository = "translated" [[query]] category = "strings" @@ -6091,7 +6091,7 @@ sql = '''select * from analytics.dbt_dev.fct_orders where updated_at > (select max(updated_at) from analytics.dbt_dev.inc_event_time) and updated_at <= convert_timezone('UTC', current_timestamp())''' expect = "refused SQ0008 fn:CONVERT_TIMEZONE at 145..189" -expect_repository = "refused SQ0003 node:lte at 131..189" +expect_repository = "translated" [[query]] category = "dbt_incremental" diff --git a/corpora/everyday/queries.toml b/corpora/everyday/queries.toml index 41384201a..be5ecc792 100644 --- a/corpora/everyday/queries.toml +++ b/corpora/everyday/queries.toml @@ -1506,8 +1506,8 @@ expect_repository = "refused SQ0003 node:gte at 34..79" [[query]] category = "dates" sql = '''SELECT order_id FROM orders WHERE ordered_at >= DATEADD(day, -30, CURRENT_TIMESTAMP())''' -expect = "refused SQ0003 fn:DATEADD at 48..86" -expect_repository = "refused SQ0003 fn:DATEADD at 48..86" +expect = "refused SQ0008 fn:DATEADD at 48..86" +expect_repository = "translated" [[query]] category = "dates" From 559fc80ba86d45a60272d6ed3d673ee7f44610f3 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 7 Nov 2026 06:37:10 +0000 Subject: [PATCH 13/13] test: pin the zone and nanosecond-range reliance of a naive-vs-local comparison separately --- .../analysis/tests/test_session_timezone.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/sqlquack/src/analysis/tests/test_session_timezone.rs b/crates/sqlquack/src/analysis/tests/test_session_timezone.rs index bf49f14f9..be7ce94bc 100644 --- a/crates/sqlquack/src/analysis/tests/test_session_timezone.rs +++ b/crates/sqlquack/src/analysis/tests/test_session_timezone.rs @@ -13,6 +13,9 @@ const NAIVE_TO_ZONED: &str = const NAIVE_BESIDE_LOCAL: &str = "SELECT '2024-06-01 10:00:00'::TIMESTAMP_NTZ < \ '2024-06-01 12:00:00 +02:00'::TIMESTAMP_LTZ AS v"; +const NAIVE_BESIDE_MICROSECOND_LOCAL: &str = "SELECT '2024-06-01 10:00:00'::TIMESTAMP_NTZ < \ + '2024-06-01 12:00:00 +02:00'::TIMESTAMP_LTZ(6) AS v"; + #[test] fn given_session_dependent_statements_when_converting_then_they_rely_on_a_utc_session() { let test_cases = [ @@ -97,6 +100,24 @@ fn given_session_dependent_statements_when_converting_then_they_rely_on_a_utc_se categories: vec![], }, }, + SessionTimeZoneTestCase { + description: "a naive timestamp compared with a microsecond local one relies only on it", + sql: NAIVE_BESIDE_MICROSECOND_LOCAL, + strict: false, + utc_session: false, + expected_outcome: relying_on_utc_session(&["node:lt"]), + }, + SessionTimeZoneTestCase { + description: "strict mode with a confirmed UTC session still refuses the nanosecond range", + sql: NAIVE_BESIDE_LOCAL, + strict: true, + utc_session: true, + expected_outcome: DivergenceModeOutcome { + converted: false, + divergences: vec![], + categories: vec![RefusalCategory::DivergenceRefused], + }, + }, SessionTimeZoneTestCase { description: "strict mode refuses that comparison without a confirmed UTC session", sql: NAIVE_BESIDE_LOCAL,