This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git


The following commit(s) were added to refs/heads/main by this push:
     new 2da78d67ce fix(spark): correct mod ANSI zero divisor and negative zero 
handling (#23987)
2da78d67ce is described below

commit 2da78d67ce6dc3e2975906b3ca60e35cd1d9afed
Author: kid <[email protected]>
AuthorDate: Sun Aug 16 01:23:51 2026 +0000

    fix(spark): correct mod ANSI zero divisor and negative zero handling 
(#23987)
    
    ## Which issue does this PR close?
    
    - Closes #23894
    
    ## Rationale for this change
    
    `datafusion-spark`'s `mod` routes through the shared `try_rem` helper in
    `datafusion/spark/src/function/math/modulus.rs`, which has two gaps in
    its
    zero-divisor handling (both verified against Spark 3.5.8 – 4.2.0 in the
    issue):
    
    1. **ANSI mode, floating-point divisor.** `try_rem` delegates to Arrow's
    `rem`
    kernel in ANSI mode, but Arrow only reports division by zero for integer
    and
    decimal types. Floating-point divisors follow IEEE 754 and quietly
    produce
    `NaN`, while Spark raises `REMAINDER_BY_ZERO` for a zero divisor of any
       numeric type:
    
       ```sql
       set datafusion.execution.enable_ansi_mode = true;
       SELECT mod(10.5::float8, 0.0::float8);  -- NaN, Spark raises
       ```
    
    2. **`-0.0` divisor, both modes.** The legacy path nulls out zero
    divisors via
    `eq(right, 0)`, but Arrow's floating-point comparisons use a total order
    in
    which `-0.0` is distinct from `0.0`, so a `-0.0` divisor goes
    unrecognised.
       Spark's `isZero` is a numeric comparison and treats `-0.0` as zero:
    
       ```sql
    SELECT mod(10.5::float8, -0.0::float8); -- NaN, Spark returns NULL
    (legacy)
       ```
    
    ## What changes are included in this PR?
    
    `try_rem` now detects zero divisors itself, mirroring the shape #23898
    established for `pmod`:
    
    - A new `is_zero` helper counts `-0.0` as zero for the floating-point
    types
      (via a `negative_zero` companion, same as #23898).
    - In ANSI mode, any row with a zero divisor raises
    `ArrowError::DivideByZero`
    — the same error Arrow's `rem` already produces for integers today, so
    the
    message stays uniform across types. The check is masked by the validity
    of
    the dividend because Spark's remainder expressions are null intolerant:
    a
    NULL dividend short-circuits to NULL before the divisor is validated, so
      `mod(NULL, 0)` must return NULL rather than raise.
    - Both modes substitute NULL for zero divisors before calling Arrow's
    `rem`,
    so the kernel never sees a zero divisor: legacy mode gets NULLs, and
    ANSI
      mode has already raised on the rows that required it.
    
    Note on overlap with #23898: that PR rewrites `pmod` to no longer use
    `try_rem` and adds identical `is_zero`/`negative_zero` helpers. This PR
    is
    independent of it — `mod` is fixed either way — but whichever lands
    second
    should dedupe the helpers in a rebase. Until #23898 lands, `pmod` also
    picks
    up the `-0.0` and ANSI floating-point zero-divisor fixes through the
    shared
    helper.
    
    Out of scope: #23897 (reproducing Spark's exact ANSI error text) is a
    repository-wide error-message policy question, as noted in that issue.
    
    ## Are these changes tested?
    
    Yes:
    
    - New unit tests in `modulus.rs`: ANSI floating-point zero divisor
    raises;
    `-0.0` divisor returns NULL in legacy mode and raises in ANSI mode; a
    NULL
    dividend with a zero divisor returns NULL in ANSI mode (integer and
    float).
    - New sqllogictest cases in `spark/math/mod.slt` covering the same
    behavior at
      SQL level.
    - Verified `cargo test -p datafusion-spark`, the `spark/math/mod.slt`
    and
    `spark/math/pmod.slt` sqllogictests, `./dev/rust_lint.sh`, and the
    extended
      workspace suite (ci profile with
    
    
`avro,json,backtrace,extended_tests,recursive_protection,parquet_encryption`)
      — all green.
    
    ## Are there any user-facing changes?
    
    Only the bug fixes, and only for the Spark `mod`/`pmod` functions: in
    ANSI
    mode a floating-point zero divisor now raises instead of returning NaN,
    and a
    `-0.0` divisor is treated as zero in both modes (NULL in legacy mode, an
    error
    in ANSI mode), matching Spark. No API changes.
---
 datafusion/spark/src/function/math/modulus.rs      | 265 +++++++++++++++++++--
 .../sqllogictest/test_files/spark/math/mod.slt     |  31 +++
 .../sqllogictest/test_files/spark/math/pmod.slt    |  25 ++
 3 files changed, 302 insertions(+), 19 deletions(-)

diff --git a/datafusion/spark/src/function/math/modulus.rs 
b/datafusion/spark/src/function/math/modulus.rs
index 97f59c2cbb..c37513c12c 100644
--- a/datafusion/spark/src/function/math/modulus.rs
+++ b/datafusion/spark/src/function/math/modulus.rs
@@ -15,41 +15,77 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use arrow::array::{Scalar, new_null_array};
+use arrow::array::{ArrayRef, BooleanArray, Scalar, new_null_array};
 use arrow::compute::kernels::numeric::add;
 use arrow::compute::kernels::{
+    boolean::{and, is_not_null, or},
     cmp::{eq, lt},
-    numeric::rem,
+    numeric::{neg, rem},
     zip::zip,
 };
 use arrow::datatypes::DataType;
+use arrow::error::ArrowError;
 use datafusion_common::{Result, ScalarValue, assert_eq_or_internal_err};
 use datafusion_expr::{
     ColumnarValue, ScalarFunctionArgs, ScalarUDFImpl, Signature, Volatility,
 };
 
+/// Returns a one element array holding negative zero, for the floating point
+/// types only.
+///
+/// Arrow's comparison kernels order floating point values totally, so `-0.0`
+/// compares as distinct from, and less than, `0.0`. Java, and therefore Spark,
+/// treats `-0.0` as equal to zero. The helper below uses this to restore the
+/// IEEE 754 answer.
+fn negative_zero(data_type: &DataType) -> Result<Option<ArrayRef>> {
+    match data_type {
+        DataType::Float16 | DataType::Float32 | DataType::Float64 => {
+            let zero = ScalarValue::new_zero(data_type)?.to_array()?;
+            Ok(Some(neg(zero.as_ref())?))
+        }
+        _ => Ok(None),
+    }
+}
+
+/// Rows of `values` that equal zero, counting `-0.0` as zero.
+fn is_zero(values: &ArrayRef) -> Result<BooleanArray> {
+    let zero = ScalarValue::new_zero(values.data_type())?.to_array()?;
+    let mask = eq(values, &Scalar::new(zero))?;
+    match negative_zero(values.data_type())? {
+        Some(negative_zero) => Ok(or(&mask, &eq(values, 
&Scalar::new(negative_zero))?)?),
+        None => Ok(mask),
+    }
+}
+
 /// Computes `rem(left, right)` with divide-by-zero handling.
-/// In ANSI mode, any zero divisor causes an error.
-/// In legacy mode (ANSI off), zero divisors are replaced with NULL before
-/// computing the remainder, so those positions return NULL while others
-/// compute normally.
+/// In ANSI mode, a zero divisor of any numeric type causes an error, with
+/// `-0.0` counting as zero; a row whose dividend is NULL never raises, to
+/// match Spark's null-intolerant remainder. In legacy mode (ANSI off), zero
+/// divisors are replaced with NULL before computing the remainder, so those
+/// positions return NULL while others compute normally.
 fn try_rem(
-    left: &arrow::array::ArrayRef,
-    right: &arrow::array::ArrayRef,
+    left: &ArrayRef,
+    right: &ArrayRef,
     enable_ansi_mode: bool,
-) -> Result<arrow::array::ArrayRef> {
+) -> Result<ArrayRef> {
+    let divisor_is_zero = is_zero(right)?;
+    // Null out zero divisors so that the remainder kernels never see one:
+    // division by zero then returns NULL instead of erroring (integers) or
+    // returning NaN (floats). ANSI mode reports the error itself below, so
+    // this substitution is harmless on rows that must raise.
+    let null = Scalar::new(new_null_array(right.data_type(), 1));
+    let safe_right = zip(&divisor_is_zero, &null, right)?;
     if enable_ansi_mode {
-        Ok(rem(left, right)?)
-    } else {
-        // In legacy mode, null out zero divisors so that division by zero
-        // returns NULL instead of erroring (integers) or returning NaN 
(floats).
-        let zero = ScalarValue::new_zero(right.data_type())?.to_array()?;
-        let zero = Scalar::new(zero);
-        let null = Scalar::new(new_null_array(right.data_type(), 1));
-        let is_zero = eq(right, &zero)?;
-        let safe_right = zip(&is_zero, &null, right)?;
-        Ok(rem(left, &safe_right)?)
+        // Spark's remainder expressions are null intolerant, so a row whose
+        // dividend is NULL evaluates to NULL and never raises, even when the
+        // divisor on that row is zero. Mask the check by the validity of the
+        // dividend to match.
+        let raises = and(&divisor_is_zero, &is_not_null(left.as_ref())?)?;
+        if raises.iter().flatten().any(|raises| raises) {
+            return Err(ArrowError::DivideByZero.into());
+        }
     }
+    Ok(rem(left, &safe_right)?)
 }
 
 /// Spark-compatible `mod` function
@@ -418,6 +454,101 @@ mod test {
         assert!(result.is_err());
     }
 
+    #[test]
+    fn test_mod_zero_division_ansi_float() {
+        // In ANSI mode a zero divisor of any numeric type must raise,
+        // including floating point, where Arrow's `rem` follows IEEE 754
+        // and quietly returns NaN (#23894)
+        let left = Float64Array::from(vec![Some(10.5), Some(7.2)]);
+        let right = Float64Array::from(vec![Some(0.0), Some(2.0)]);
+
+        let left_value = ColumnarValue::Array(Arc::new(left));
+        let right_value = ColumnarValue::Array(Arc::new(right));
+
+        let result = spark_mod(&[left_value, right_value], true);
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn test_mod_negative_zero_divisor_legacy() {
+        // `-0.0` counts as a zero divisor, so it returns NULL in legacy
+        // mode rather than NaN (#23894)
+        let left = Float64Array::from(vec![Some(10.5), Some(7.5)]);
+        let right = Float64Array::from(vec![Some(-0.0), Some(2.0)]);
+
+        let left_value = ColumnarValue::Array(Arc::new(left));
+        let right_value = ColumnarValue::Array(Arc::new(right));
+
+        let result = spark_mod(&[left_value, right_value], false).unwrap();
+
+        if let ColumnarValue::Array(result_array) = result {
+            let result_float64 = result_array
+                .as_any()
+                .downcast_ref::<Float64Array>()
+                .unwrap();
+            assert!(result_float64.is_null(0)); // 10.5 % -0.0 = NULL
+            assert_eq!(result_float64.value(1), 1.5); // 7.5 % 2.0 = 1.5
+        } else {
+            panic!("Expected array result");
+        }
+    }
+
+    #[test]
+    fn test_mod_negative_zero_divisor_ansi() {
+        // `-0.0` counts as a zero divisor, so it raises in ANSI mode (#23894)
+        let left = Float64Array::from(vec![Some(10.5)]);
+        let right = Float64Array::from(vec![Some(-0.0)]);
+
+        let left_value = ColumnarValue::Array(Arc::new(left));
+        let right_value = ColumnarValue::Array(Arc::new(right));
+
+        let result = spark_mod(&[left_value, right_value], true);
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn test_mod_zero_division_ansi_null_dividend() {
+        // Spark's remainder expressions are null intolerant: a NULL dividend
+        // short-circuits to NULL before the divisor is validated, so a zero
+        // divisor on such a row must not raise, even in ANSI mode (#23894)
+        let left = Int32Array::from(vec![None, Some(10)]);
+        let right = Int32Array::from(vec![Some(0), Some(3)]);
+
+        let left_value = ColumnarValue::Array(Arc::new(left));
+        let right_value = ColumnarValue::Array(Arc::new(right));
+
+        let result = spark_mod(&[left_value, right_value], true).unwrap();
+
+        if let ColumnarValue::Array(result_array) = result {
+            let result_int32 =
+                result_array.as_any().downcast_ref::<Int32Array>().unwrap();
+            assert!(result_int32.is_null(0)); // NULL % 0 = NULL (no error)
+            assert_eq!(result_int32.value(1), 1); // 10 % 3 = 1
+        } else {
+            panic!("Expected array result");
+        }
+
+        // Same for floating point
+        let left = Float64Array::from(vec![None, Some(10.5)]);
+        let right = Float64Array::from(vec![Some(0.0), Some(2.0)]);
+
+        let left_value = ColumnarValue::Array(Arc::new(left));
+        let right_value = ColumnarValue::Array(Arc::new(right));
+
+        let result = spark_mod(&[left_value, right_value], true).unwrap();
+
+        if let ColumnarValue::Array(result_array) = result {
+            let result_float64 = result_array
+                .as_any()
+                .downcast_ref::<Float64Array>()
+                .unwrap();
+            assert!(result_float64.is_null(0)); // NULL % 0.0 = NULL (no error)
+            assert!((result_float64.value(1) - 0.5).abs() < f64::EPSILON); // 
10.5 % 2.0 = 0.5
+        } else {
+            panic!("Expected array result");
+        }
+    }
+
     // PMOD tests
     #[test]
     fn test_pmod_int32() {
@@ -645,6 +776,102 @@ mod test {
         assert!(result.is_err());
     }
 
+    #[test]
+    fn test_pmod_zero_division_ansi_float() {
+        // pmod routes through `try_rem` twice, so it needs the same coverage
+        // as mod: in ANSI mode a zero divisor of any numeric type must
+        // raise, including floating point, where Arrow's `rem` follows
+        // IEEE 754 and quietly returns NaN (#23894)
+        let left = Float64Array::from(vec![Some(10.5), Some(7.2)]);
+        let right = Float64Array::from(vec![Some(0.0), Some(2.0)]);
+
+        let left_value = ColumnarValue::Array(Arc::new(left));
+        let right_value = ColumnarValue::Array(Arc::new(right));
+
+        let result = spark_pmod(&[left_value, right_value], true);
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn test_pmod_negative_zero_divisor_legacy() {
+        // `-0.0` counts as a zero divisor, so it returns NULL in legacy
+        // mode rather than NaN (#23894)
+        let left = Float64Array::from(vec![Some(10.5), Some(-7.5)]);
+        let right = Float64Array::from(vec![Some(-0.0), Some(2.0)]);
+
+        let left_value = ColumnarValue::Array(Arc::new(left));
+        let right_value = ColumnarValue::Array(Arc::new(right));
+
+        let result = spark_pmod(&[left_value, right_value], false).unwrap();
+
+        if let ColumnarValue::Array(result_array) = result {
+            let result_float64 = result_array
+                .as_any()
+                .downcast_ref::<Float64Array>()
+                .unwrap();
+            assert!(result_float64.is_null(0)); // 10.5 pmod -0.0 = NULL
+            assert!((result_float64.value(1) - 0.5).abs() < f64::EPSILON); // 
-7.5 pmod 2.0 = 0.5
+        } else {
+            panic!("Expected array result");
+        }
+    }
+
+    #[test]
+    fn test_pmod_negative_zero_divisor_ansi() {
+        // `-0.0` counts as a zero divisor, so it raises in ANSI mode (#23894)
+        let left = Float64Array::from(vec![Some(10.5)]);
+        let right = Float64Array::from(vec![Some(-0.0)]);
+
+        let left_value = ColumnarValue::Array(Arc::new(left));
+        let right_value = ColumnarValue::Array(Arc::new(right));
+
+        let result = spark_pmod(&[left_value, right_value], true);
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn test_pmod_zero_division_ansi_null_dividend() {
+        // Spark's remainder expressions are null intolerant: a NULL dividend
+        // short-circuits to NULL before the divisor is validated, so a zero
+        // divisor on such a row must not raise, even in ANSI mode (#23894)
+        let left = Int32Array::from(vec![None, Some(10)]);
+        let right = Int32Array::from(vec![Some(0), Some(3)]);
+
+        let left_value = ColumnarValue::Array(Arc::new(left));
+        let right_value = ColumnarValue::Array(Arc::new(right));
+
+        let result = spark_pmod(&[left_value, right_value], true).unwrap();
+
+        if let ColumnarValue::Array(result_array) = result {
+            let result_int32 =
+                result_array.as_any().downcast_ref::<Int32Array>().unwrap();
+            assert!(result_int32.is_null(0)); // NULL pmod 0 = NULL (no error)
+            assert_eq!(result_int32.value(1), 1); // 10 pmod 3 = 1
+        } else {
+            panic!("Expected array result");
+        }
+
+        // Same for floating point
+        let left = Float64Array::from(vec![None, Some(10.5)]);
+        let right = Float64Array::from(vec![Some(0.0), Some(2.0)]);
+
+        let left_value = ColumnarValue::Array(Arc::new(left));
+        let right_value = ColumnarValue::Array(Arc::new(right));
+
+        let result = spark_pmod(&[left_value, right_value], true).unwrap();
+
+        if let ColumnarValue::Array(result_array) = result {
+            let result_float64 = result_array
+                .as_any()
+                .downcast_ref::<Float64Array>()
+                .unwrap();
+            assert!(result_float64.is_null(0)); // NULL pmod 0.0 = NULL (no 
error)
+            assert!((result_float64.value(1) - 0.5).abs() < f64::EPSILON); // 
10.5 pmod 2.0 = 0.5
+        } else {
+            panic!("Expected array result");
+        }
+    }
+
     #[test]
     fn test_pmod_negative_divisor() {
         // PMOD with negative divisor should still work like regular mod
diff --git a/datafusion/sqllogictest/test_files/spark/math/mod.slt 
b/datafusion/sqllogictest/test_files/spark/math/mod.slt
index 8229bb0651..b9a8499939 100644
--- a/datafusion/sqllogictest/test_files/spark/math/mod.slt
+++ b/datafusion/sqllogictest/test_files/spark/math/mod.slt
@@ -160,6 +160,18 @@ SELECT MOD(10.5::float8, 0.0::float8) as 
mod_div_zero_float;
 ----
 NULL
 
+# A negative zero divisor counts as zero and returns NULL in legacy mode
+query R
+SELECT MOD(10.5::float8, -0.0::float8) as mod_div_zero_neg_float;
+----
+NULL
+
+# A NULL dividend evaluates to NULL regardless of the divisor
+query I
+SELECT MOD(NULL::int, 0::int) as mod_null_dividend_legacy;
+----
+NULL
+
 # Division by zero errors in ANSI mode
 statement ok
 set datafusion.execution.enable_ansi_mode = true;
@@ -170,6 +182,25 @@ SELECT MOD(10::int, 0::int);
 statement error DataFusion error: Arrow error: Divide by zero error
 SELECT MOD(-7::int, 0::int);
 
+# A zero divisor of any numeric type raises, including floating point
+statement error DataFusion error: Arrow error: Divide by zero error
+SELECT MOD(10.5::float8, 0.0::float8);
+
+# A negative zero divisor counts as zero and raises
+statement error DataFusion error: Arrow error: Divide by zero error
+SELECT MOD(10.5::float8, -0.0::float8);
+
+# A NULL dividend short-circuits to NULL before the divisor is validated
+query I
+SELECT MOD(NULL::int, 0::int) as mod_null_dividend_ansi;
+----
+NULL
+
+query R
+SELECT MOD(NULL::float8, 0.0::float8) as mod_null_dividend_ansi_float;
+----
+NULL
+
 statement ok
 set datafusion.execution.enable_ansi_mode = false;
 
diff --git a/datafusion/sqllogictest/test_files/spark/math/pmod.slt 
b/datafusion/sqllogictest/test_files/spark/math/pmod.slt
index aa4a197ba4..1165fdfba3 100644
--- a/datafusion/sqllogictest/test_files/spark/math/pmod.slt
+++ b/datafusion/sqllogictest/test_files/spark/math/pmod.slt
@@ -74,6 +74,12 @@ SELECT pmod(-7::int, 0::int) as pmod_zero_3;
 ----
 NULL
 
+# A negative zero divisor counts as zero and returns NULL in legacy mode
+query R
+SELECT pmod(10.5::float8, -0.0::float8) as pmod_div_zero_neg_float;
+----
+NULL
+
 # Division by zero errors in ANSI mode
 statement ok
 set datafusion.execution.enable_ansi_mode = true;
@@ -84,6 +90,25 @@ SELECT pmod(10::int, 0::int);
 statement error DataFusion error: Arrow error: Divide by zero error
 SELECT pmod(-7::int, 0::int);
 
+# A zero divisor of any numeric type raises, including floating point
+statement error DataFusion error: Arrow error: Divide by zero error
+SELECT pmod(10.5::float8, 0.0::float8);
+
+# A negative zero divisor counts as zero and raises
+statement error DataFusion error: Arrow error: Divide by zero error
+SELECT pmod(10.5::float8, -0.0::float8);
+
+# A NULL dividend short-circuits to NULL before the divisor is validated
+query I
+SELECT pmod(NULL::int, 0::int) as pmod_null_dividend_ansi;
+----
+NULL
+
+query R
+SELECT pmod(NULL::float8, 0.0::float8) as pmod_null_dividend_ansi_float;
+----
+NULL
+
 statement ok
 set datafusion.execution.enable_ansi_mode = false;
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to