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", 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"