From 893cb9d4724af614a8dedebbf3d165910fc99bbf Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:15:05 +0000 Subject: [PATCH 1/3] fix: type a DATE plus INTERVAL 'n' DAY as TIMESTAMP_NTZ(9) Snowflake keeps a DATE for INTERVAL '7 days', with the unit inside the quotes, but gives a TIMESTAMP_NTZ(9) at midnight for INTERVAL '3' DAY, with the unit after them. The second spelling converted as a DATE. --- crates/sqlquack-rules/src/catalogue/constants.rs | 2 +- .../src/conversion/_helpers/expressions/intervals.rs | 6 +++++- .../sqlquack/src/analysis/tests/test_coverage_batch.rs | 10 ++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index 956582135..0ba601e94 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -1441,7 +1441,7 @@ pub const RULES: &[RuleSpec] = &[ RuleSpec { id: INTERVAL_ARITHMETIC, constructs: &["node:add", "node:sub", "node:interval"], - conditions: "A DATE or TIMESTAMP_NTZ plus or minus an INTERVAL literal of one whole number of days, hours, minutes or seconds, written ' ' with the unit singular or plural in any case: DATEADD of n or -n of that unit. A DATE takes only days and stays a DATE; a TIMESTAMP_NTZ of any precision becomes a TIMESTAMP_NTZ(9), relying on timestamp_range. Other intervals and operands stay refused.", + conditions: "A DATE or TIMESTAMP_NTZ plus or minus an INTERVAL literal of one whole number of days, hours, minutes or seconds, written ' ' with the unit singular or plural in any case: DATEADD of n or -n of that unit. A DATE takes only days: with the unit inside the quotes ('7 days') it stays a DATE, and with the unit after them (INTERVAL '3' DAY) it becomes a TIMESTAMP_NTZ(9) at midnight, as recorded, relying on timestamp_range; a TIMESTAMP_NTZ of any precision becomes a TIMESTAMP_NTZ(9), relying on timestamp_range. Other intervals and operands stay refused.", failures: &[], }, RuleSpec { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/intervals.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/intervals.rs index 00ea0c12d..1467d16ba 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/intervals.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/intervals.rs @@ -54,8 +54,12 @@ pub(crate) fn interval_arithmetic( value.data_type ))); } + let unit_outside_quotes = node_kind(interval) + .and_then(|(_, payload)| payload.get("unit")) + .is_some_and(|unit| !is_inert(unit)); let result_type = match value.data_type { - SnowflakeType::TimestampNtz { .. } => { + SnowflakeType::Date if !unit_outside_quotes => SnowflakeType::Date, + SnowflakeType::Date | SnowflakeType::TimestampNtz { .. } => { diverge(site.context, TIMESTAMP_RANGE, &construct)?; SnowflakeType::TimestampNtz { precision: INTERVAL_TIMESTAMP_PRECISION, diff --git a/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs index bf4bad852..a08dc4b7c 100644 --- a/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs +++ b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs @@ -321,6 +321,16 @@ fn given_interval_literals_when_converting_then_dates_and_timestamps_move_by_the sql: "SELECT ('2024-03-05'::DATE - INTERVAL '7 days')::VARCHAR AS v", expected_outcome: Ok("2024-02-27"), }, + FirstValueTestCase { + description: "a DATE plus days written after the quotes is a TIMESTAMP_NTZ at midnight", + sql: "SELECT ('2026-04-01'::DATE + INTERVAL '3' DAYS)::VARCHAR AS v", + expected_outcome: Ok("2026-04-04 00:00:00.000"), + }, + FirstValueTestCase { + description: "a DATE minus one day written after the quotes is a TIMESTAMP_NTZ too", + sql: "SELECT ('2024-03-01'::DATE - INTERVAL '1' DAY)::VARCHAR AS v", + expected_outcome: Ok("2024-02-29 00:00:00.000"), + }, FirstValueTestCase { description: "a timestamp plus hours crosses midnight", sql: "SELECT ('2024-02-28 23:30:00'::TIMESTAMP_NTZ + INTERVAL '2 hours')::VARCHAR AS v", From ffc4dff11fdb18f1624e9befbb3706aade0c722b Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:00:04 +0000 Subject: [PATCH 2/3] fix: refuse an interval whose value is not a quoted string INTERVAL 3 DAY parses as INTERVAL '3' DAY, but no recording shows how Snowflake types it, so DATE or timestamp arithmetic with an unquoted interval value now refuses. --- .../_helpers/expressions/intervals.rs | 22 ++++++++++++++++--- .../conversion/_helpers/expressions/scalar.rs | 2 +- .../src/conversion/constants.rs | 5 +++++ .../src/analysis/tests/test_coverage_batch.rs | 5 +++++ 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/intervals.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/intervals.rs index 1467d16ba..e58501dcd 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/intervals.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/intervals.rs @@ -15,8 +15,9 @@ use crate::conversion::_helpers::support::tree::{ field, is_inert, node_construct, node_kind, payload_object, }; use crate::conversion::constants::{ - INTERVAL_ARITHMETIC, INTERVAL_DATE_UNITS, INTERVAL_FIELDS, INTERVAL_KIND, INTERVAL_MAX_DIGITS, - INTERVAL_REFUSAL, INTERVAL_SIMPLE_UNIT, INTERVAL_TIMESTAMP_PRECISION, INTERVAL_UNITS, + INTERVAL_ARITHMETIC, INTERVAL_DATE_UNITS, INTERVAL_FIELDS, INTERVAL_KEYWORD, INTERVAL_KIND, + INTERVAL_MAX_DIGITS, INTERVAL_REFUSAL, INTERVAL_SIMPLE_UNIT, INTERVAL_TIMESTAMP_PRECISION, + INTERVAL_UNITS, INTERVAL_UNQUOTED_REFUSAL, }; use sqlquack_policy::policy::constants::TIMESTAMP_RANGE; @@ -30,7 +31,7 @@ pub(crate) fn adds_interval(payload: &Value) -> bool { /// `value + INTERVAL 'n unit'` or `value - INTERVAL 'n unit'`, as DATEADD of n or -n units. pub(crate) fn interval_arithmetic( - kind: &str, + (kind, node): (&str, &Value), payload: &Value, site: Site<'_>, ) -> Result { @@ -41,6 +42,21 @@ pub(crate) fn interval_arithmetic( let payload = payload_object(payload, &construct)?; let value = convert_expression(field(payload, "left", &construct)?, site)?; let interval = field(payload, "right", &construct)?; + let quoted_value = site + .context + .locator + .source_text(node) + .is_some_and(|written| { + let upper = written.to_ascii_uppercase(); + upper.rfind(INTERVAL_KEYWORD).is_some_and(|start| { + upper[start + INTERVAL_KEYWORD.len()..] + .trim_start() + .starts_with('\'') + }) + }); + if !quoted_value { + return Err(refuse(INTERVAL_UNQUOTED_REFUSAL.to_owned())); + } let (amount, unit) = interval_literal(interval, site)?.ok_or_else(|| refuse(INTERVAL_REFUSAL.to_owned()))?; let accepted = match value.data_type { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/scalar.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/scalar.rs index 8292a8383..501895865 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/scalar.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/scalar.rs @@ -285,7 +285,7 @@ fn convert_kind(node: &Value, site: Site<'_>) -> Result { "annotated" => commented(payload, site), "neg" => negate(payload, site), "subquery" => scalar_subquery(payload, site), - "add" | "sub" if adds_interval(payload) => interval_arithmetic(kind, payload, site), + "add" | "sub" if adds_interval(payload) => interval_arithmetic((kind, node), payload, site), "add" | "sub" | "mul" | "div" => arithmetic(kind, payload, site), "mod" => modulo(payload, site), "eq" | "neq" | "lt" | "lte" | "gt" | "gte" => comparison_predicate(kind, payload, site), diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index 006eb3e0f..dc2fb9663 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -234,6 +234,11 @@ pub const STAR_KIND: &str = "star"; /// Why an interval is refused. pub const INTERVAL_REFUSAL: &str = "an INTERVAL other than a literal of one whole number of days, hours, minutes or seconds"; +/// The keyword an interval literal is written after. +pub(crate) const INTERVAL_KEYWORD: &str = "INTERVAL"; +/// Why an interval whose value is not a quoted string is refused. +pub(crate) const INTERVAL_UNQUOTED_REFUSAL: &str = + "an INTERVAL whose value is not a quoted string, which no recording shows"; /// The fields an interval literal the conversion reads may set. pub const INTERVAL_FIELDS: &[&str] = &["this", "unit"]; /// The parser's kind of an interval unit written as one word, such as `DAY`. diff --git a/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs index a08dc4b7c..416e97c7b 100644 --- a/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs +++ b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs @@ -336,6 +336,11 @@ fn given_interval_literals_when_converting_then_dates_and_timestamps_move_by_the sql: "SELECT ('2024-02-28 23:30:00'::TIMESTAMP_NTZ + INTERVAL '2 hours')::VARCHAR AS v", expected_outcome: Ok("2024-02-29 01:30:00.000"), }, + FirstValueTestCase { + description: "an interval of an unquoted number is refused", + sql: "SELECT '2024-03-05'::DATE + INTERVAL 3 DAY AS v", + expected_outcome: Err("not a quoted string"), + }, FirstValueTestCase { description: "a DATE plus hours stays refused", sql: "SELECT '2024-03-05'::DATE + INTERVAL '2 hours' AS v", From 50e3efb52585296c591f8f50e503041cbcac4337 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:11:59 +0000 Subject: [PATCH 3/3] refactor: keep the unquoted-interval refusal beside its check With main merged, the conversion constants file reached 2,005 lines, over the 2,000-line limit. The refusal reason now sits in intervals.rs like the other interval refusals, and the keyword check reuses INTERVAL_KIND. --- .../conversion/_helpers/expressions/intervals.rs | 15 ++++++++------- crates/sqlquack-rules/src/conversion/constants.rs | 5 ----- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/intervals.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/intervals.rs index e58501dcd..5c0ee6e91 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/intervals.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/intervals.rs @@ -15,9 +15,8 @@ use crate::conversion::_helpers::support::tree::{ field, is_inert, node_construct, node_kind, payload_object, }; use crate::conversion::constants::{ - INTERVAL_ARITHMETIC, INTERVAL_DATE_UNITS, INTERVAL_FIELDS, INTERVAL_KEYWORD, INTERVAL_KIND, - INTERVAL_MAX_DIGITS, INTERVAL_REFUSAL, INTERVAL_SIMPLE_UNIT, INTERVAL_TIMESTAMP_PRECISION, - INTERVAL_UNITS, INTERVAL_UNQUOTED_REFUSAL, + INTERVAL_ARITHMETIC, INTERVAL_DATE_UNITS, INTERVAL_FIELDS, INTERVAL_KIND, INTERVAL_MAX_DIGITS, + INTERVAL_REFUSAL, INTERVAL_SIMPLE_UNIT, INTERVAL_TIMESTAMP_PRECISION, INTERVAL_UNITS, }; use sqlquack_policy::policy::constants::TIMESTAMP_RANGE; @@ -47,15 +46,17 @@ pub(crate) fn interval_arithmetic( .locator .source_text(node) .is_some_and(|written| { - let upper = written.to_ascii_uppercase(); - upper.rfind(INTERVAL_KEYWORD).is_some_and(|start| { - upper[start + INTERVAL_KEYWORD.len()..] + let lower = written.to_ascii_lowercase(); + lower.rfind(INTERVAL_KIND).is_some_and(|start| { + lower[start + INTERVAL_KIND.len()..] .trim_start() .starts_with('\'') }) }); if !quoted_value { - return Err(refuse(INTERVAL_UNQUOTED_REFUSAL.to_owned())); + return Err(refuse( + "an INTERVAL whose value is not a quoted string, which no recording shows".to_owned(), + )); } let (amount, unit) = interval_literal(interval, site)?.ok_or_else(|| refuse(INTERVAL_REFUSAL.to_owned()))?; diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index cbae6dfdc..c0a81f28c 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -234,11 +234,6 @@ pub const STAR_KIND: &str = "star"; /// Why an interval is refused. pub const INTERVAL_REFUSAL: &str = "an INTERVAL other than a literal of one whole number of days, hours, minutes or seconds"; -/// The keyword an interval literal is written after. -pub(crate) const INTERVAL_KEYWORD: &str = "INTERVAL"; -/// Why an interval whose value is not a quoted string is refused. -pub(crate) const INTERVAL_UNQUOTED_REFUSAL: &str = - "an INTERVAL whose value is not a quoted string, which no recording shows"; /// The fields an interval literal the conversion reads may set. pub const INTERVAL_FIELDS: &[&str] = &["this", "unit"]; /// The parser's kind of an interval unit written as one word, such as `DAY`.