diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index 2ea64640b..944b953e5 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..5c0ee6e91 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/intervals.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/intervals.rs @@ -30,7 +30,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 +41,23 @@ 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 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( + "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()))?; let accepted = match value.data_type { @@ -54,8 +71,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-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/src/analysis/tests/test_coverage_batch.rs b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs index 750ad4fad..6e187592b 100644 --- a/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs +++ b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs @@ -321,11 +321,26 @@ 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", 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",