From 2a0f31d265d2374fc8cc71032393774efcba1dda Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:18:25 +0000 Subject: [PATCH 1/2] Convert GREATEST_IGNORE_NULLS and LEAST_IGNORE_NULLS They share LEAST/GREATEST's rule, skipping NULLs as DuckDB's own greatest/least do, NULL only when every argument is. They join fn:GREATEST and fn:LEAST in numeric_range's constructs. Recorded cases t001, t003, t004 and t005 of both files now match; t002 (text first beside a number) stays refused by text_number_scale. --- .../sqlquack-rules/src/catalogue/constants.rs | 9 ++++-- .../conversion/_helpers/expressions/calls.rs | 29 ++++++++++++------- sqlquack.toml | 2 +- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index d16c3e424..4423d35e8 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -1126,8 +1126,13 @@ pub const RULES: &[RuleSpec] = &[ }, RuleSpec { id: LEAST_GREATEST, - constructs: &["fn:LEAST", "fn:GREATEST"], - conditions: "Arguments that compare directly with each other, NUMBER beside FLOAT included as recorded; the result has their unified type and is NULL when any argument is NULL (DuckDB's own functions skip NULLs).", + constructs: &[ + "fn:LEAST", + "fn:GREATEST", + "fn:LEAST_IGNORE_NULLS", + "fn:GREATEST_IGNORE_NULLS", + ], + conditions: "Arguments that compare directly with each other, NUMBER beside FLOAT included as recorded; the result has their unified type. LEAST and GREATEST are NULL when any argument is NULL; LEAST_IGNORE_NULLS and GREATEST_IGNORE_NULLS skip NULLs, as DuckDB's own functions do, and are NULL only when every argument is.", failures: &[], }, RuleSpec { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/calls.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/calls.rs index ea2be5126..41fd01e0a 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/calls.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/calls.rs @@ -76,6 +76,9 @@ use crate::conversion::constants::{ }; use sqlquack_policy::policy::constants::NUMERIC_RANGE; +/// The suffix of LEAST and GREATEST forms that skip NULL arguments. +const IGNORE_NULLS_SUFFIX: &str = "_IGNORE_NULLS"; + /// SEQ4() or SEQ8() in a query level reading only a generator: the row's number from 0. fn generator_sequence( name: &str, @@ -327,14 +330,16 @@ pub(crate) fn convert_function(payload: &Value, site: Site<'_>) -> Result { zoned_text_call(&name, value, &construct, site) } - ("LEAST" | "GREATEST", parts) if !parts.is_empty() => { + ("LEAST" | "GREATEST" | "LEAST_IGNORE_NULLS" | "GREATEST_IGNORE_NULLS", parts) + if !parts.is_empty() => + { least_greatest(&name, parts, &construct, site) } _ => Err(missing(construct, format!("no rule converts fn:{name}"))), } } -/// LEAST or GREATEST, NULL when any argument is NULL, as in Snowflake. +/// LEAST or GREATEST, NULL when any argument is NULL; the _IGNORE_NULLS forms skip NULLs. fn least_greatest( name: &str, arguments: &[Value], @@ -350,19 +355,23 @@ fn least_greatest( comparable(converted[0].clone(), other.clone(), construct, site.context)?; } let (branches, target) = unified(&converted, construct, site.context)?; + let (function, ignoring) = match name.strip_suffix(IGNORE_NULLS_SUFFIX) { + Some(function) => (function.to_ascii_lowercase(), true), + None => (name.to_ascii_lowercase(), false), + }; + let call = format!("{function}({})", branches.join(", ")); let any_null: Vec = branches .iter() .map(|branch| format!("{branch} IS NULL")) .collect(); - Ok(Emitted::new( - format!( - "(CASE WHEN {} THEN NULL ELSE {}({}) END)", - any_null.join(" OR "), - name.to_ascii_lowercase(), - branches.join(", ") + let sql = match ignoring { + true => call, + false => format!( + "(CASE WHEN {} THEN NULL ELSE {call} END)", + any_null.join(" OR ") ), - target, - )) + }; + Ok(Emitted::new(sql, target)) } fn nullif(left: &Value, right: &Value, site: Site<'_>) -> Result { diff --git a/sqlquack.toml b/sqlquack.toml index c8e85799f..b3856e2ab 100644 --- a/sqlquack.toml +++ b/sqlquack.toml @@ -302,7 +302,7 @@ minimum_bytes = 4194304 # can return another value. [divergences.numeric_range] -constructs = ["fn:SUM", "fn:AVG", "node:add", "node:sub", "node:mul", "node:div", "node:eq", "node:neq", "node:lt", "node:lte", "node:gt", "node:gte", "node:null_safe_eq", "node:null_safe_neq", "node:in", "fn:EQUAL_NULL", "fn:NULLIF", "node:union", "node:except", "node:intersect", "node:case", "fn:COALESCE", "node:coalesce", "fn:DECODE", "fn:GREATEST", "fn:LEAST", "node:nvl", "fn:NVL2", "fn:IFF", "fn:IFNULL", "fn:ZEROIFNULL", "fn:EXP"] +constructs = ["fn:SUM", "fn:AVG", "node:add", "node:sub", "node:mul", "node:div", "node:eq", "node:neq", "node:lt", "node:lte", "node:gt", "node:gte", "node:null_safe_eq", "node:null_safe_neq", "node:in", "fn:EQUAL_NULL", "fn:NULLIF", "node:union", "node:except", "node:intersect", "node:case", "fn:COALESCE", "node:coalesce", "fn:DECODE", "fn:GREATEST", "fn:LEAST", "fn:GREATEST_IGNORE_NULLS", "fn:LEAST_IGNORE_NULLS", "node:nvl", "fn:NVL2", "fn:IFF", "fn:IFNULL", "fn:ZEROIFNULL", "fn:EXP"] default = "warn" strict = "refuse" failure = "loud" From 840c7e5306a6383ab96eec0382db7f88be26efc8 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:42:52 +0000 Subject: [PATCH 2/2] Translation baseline: 8 GREATEST/LEAST_IGNORE_NULLS statements now match Against #98's baseline (4ce4490c): 0 regressions, 8 statements refused -> matching (greatest- and least-ignore-nulls t001, t003, t004, t005); no form changes. verify-recorded: 11606 cases, 7664 passed, 0 failed, 220 of 220 verified. --- coverage/translation/results-baseline.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/coverage/translation/results-baseline.json b/coverage/translation/results-baseline.json index 89ff97c50..9f0086846 100644 --- a/coverage/translation/results-baseline.json +++ b/coverage/translation/results-baseline.json @@ -9373,11 +9373,11 @@ "translation/functions/getdate.toml#t003_null_none": "refused", "translation/functions/getvariable.toml#t001_unset": "refused", "translation/functions/getvariable.toml#t002_null_name": "refused", - "translation/functions/greatest-ignore-nulls.toml#t001_number": "refused", + "translation/functions/greatest-ignore-nulls.toml#t001_number": "matching", "translation/functions/greatest-ignore-nulls.toml#t002_first_type": "refused", - "translation/functions/greatest-ignore-nulls.toml#t003_varchar": "refused", - "translation/functions/greatest-ignore-nulls.toml#t004_date": "refused", - "translation/functions/greatest-ignore-nulls.toml#t005_null_expr": "refused", + "translation/functions/greatest-ignore-nulls.toml#t003_varchar": "matching", + "translation/functions/greatest-ignore-nulls.toml#t004_date": "matching", + "translation/functions/greatest-ignore-nulls.toml#t005_null_expr": "matching", "translation/functions/greatest.toml#t001_number": "matching", "translation/functions/greatest.toml#t002_first_type": "refused", "translation/functions/greatest.toml#t003_varchar": "matching", @@ -9750,11 +9750,11 @@ "translation/functions/lead.toml#t002_ignore_nulls": "matching", "translation/functions/lead.toml#t003_negative_offset": "matching", "translation/functions/lead.toml#t004_null_expr": "matching", - "translation/functions/least-ignore-nulls.toml#t001_number": "refused", + "translation/functions/least-ignore-nulls.toml#t001_number": "matching", "translation/functions/least-ignore-nulls.toml#t002_first_type": "refused", - "translation/functions/least-ignore-nulls.toml#t003_varchar": "refused", - "translation/functions/least-ignore-nulls.toml#t004_date": "refused", - "translation/functions/least-ignore-nulls.toml#t005_null_expr": "refused", + "translation/functions/least-ignore-nulls.toml#t003_varchar": "matching", + "translation/functions/least-ignore-nulls.toml#t004_date": "matching", + "translation/functions/least-ignore-nulls.toml#t005_null_expr": "matching", "translation/functions/least.toml#t001_number": "matching", "translation/functions/least.toml#t002_first_type": "refused", "translation/functions/least.toml#t003_varchar": "matching",