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

Jefffrey pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new db915ece97 fix(arrow-cast): do not truncate integers when casting to 
Decimal32/64 (#10707)
db915ece97 is described below

commit db915ece97cd71bf55a4c108eb96c1784a7d435d
Author: yongster <[email protected]>
AuthorDate: Thu Aug 20 10:04:10 2026 +0800

    fix(arrow-cast): do not truncate integers when casting to Decimal32/64 
(#10707)
    
    # Which issue does this PR close?
    
    - Closes #10706.
    
    # Rationale for this change
    
    `cast` / `cast_with_options` from an integer array to `Decimal32` or
    `Decimal64` can rewrite the value instead of rejecting it.
    
    `cast_integer_to_decimal` first used `AsPrimitive` (`as`), which wraps
    when
    the source integer does not fit the decimal native type. The precision
    check
    then ran on the already-truncated value. If that wrapped value happened
    to
    fit the requested precision, it was stored as if it were the original
    number.
    
    Examples on current `main`:
    
    - `Int64(5_000_000_000) -> Decimal32(9, 0)` (`safe: true`) becomes
    `705032704`
    - `UInt32(4_000_000_000) -> Decimal32(9, 0)` (`safe: false`) becomes
    `-294967296`
    - `UInt64::MAX -> Decimal64(18, 0)` (`safe: false`) becomes `-1`
    
    The same inputs cast to `Decimal128` already return null / `Err`,
    because
    `i64 as i128` is lossless.
    
    # What changes are included in this PR?
    
    - Convert the source integer through a range-checked `i128` path
      (`num_cast` + `DecimalCast`) before applying scale and precision.
    - `safe: true` writes null when the original value does not fit; `safe:
    false`
      returns `Err` and reports the original value.
    - Add regression tests for `Int64 -> Decimal32`, `UInt32 -> Decimal32`,
    and
      `UInt64::MAX -> Decimal64`.
    
    # Are these changes tested?
    
    Yes. I ran:
    
    ```text
    cargo test -p arrow-cast --lib -- cast
    cargo clippy -p arrow-cast --all-targets --all-features -- -D warnings
    cargo +stable fmt --all -- --check
    
    ---------
    
    Co-authored-by: yang3.xie <[email protected]>
---
 arrow-cast/src/cast/mod.rs    | 191 +++++++++++++++++++++++++++++++++++++-----
 arrow/benches/cast_kernels.rs |   6 ++
 2 files changed, 175 insertions(+), 22 deletions(-)

diff --git a/arrow-cast/src/cast/mod.rs b/arrow-cast/src/cast/mod.rs
index 1e1b828943..b6e03bdef3 100644
--- a/arrow-cast/src/cast/mod.rs
+++ b/arrow-cast/src/cast/mod.rs
@@ -348,6 +348,21 @@ pub fn cast(array: &dyn Array, to_type: &DataType) -> 
Result<ArrayRef, ArrowErro
     cast_with_options(array, to_type, &CastOptions::default())
 }
 
+/// Convert an integer to a decimal native value without wrapping.
+///
+/// `AsPrimitive` / `as` silently truncates when the source is wider than `M`
+/// (for example `5_000_000_000i64 as i32`). All integer sources fit in `i128`
+/// losslessly, which [`DecimalCast`] then converts to the decimal native type
+/// with a range check. For types that always fit (e.g. `i64` to `Decimal128`) 
this
+/// should get optimized to being equivalent to `i64 as i128`.
+fn integer_to_decimal_native<I, M>(value: I) -> Option<M>
+where
+    I: Into<i128>,
+    M: DecimalCast,
+{
+    M::from_decimal(value.into())
+}
+
 fn cast_integer_to_decimal<
     T: ArrowPrimitiveType,
     D: DecimalType + ArrowPrimitiveType<Native = M>,
@@ -360,37 +375,66 @@ fn cast_integer_to_decimal<
     cast_options: &CastOptions,
 ) -> Result<ArrayRef, ArrowError>
 where
-    <T as ArrowPrimitiveType>::Native: AsPrimitive<M>,
-    M: ArrowNativeTypeOp,
+    <T as ArrowPrimitiveType>::Native: ArrowNativeTypeOp + Into<i128>,
+    M: ArrowNativeTypeOp + DecimalCast,
 {
-    let scale_factor = base.pow_checked(scale.unsigned_abs() as 
u32).map_err(|_| {
+    let overflow = |v: T::Native| {
         ArrowError::CastError(format!(
-            "Cannot cast to {:?}({}, {}). The scale causes overflow.",
+            "Cannot cast to {}({precision}, {scale}). Overflowing on {v:?}",
             D::PREFIX,
-            precision,
-            scale,
         ))
-    })?;
+    };
 
     let array = if scale < 0 {
-        match cast_options.safe {
-            true => array.unary_opt::<_, D>(|v| {
-                let v = v.as_().div_checked(scale_factor).ok()?;
+        // Compute the scale factor once in the source type. Scaling before the
+        // checked conversion permits values that only fit the decimal native
+        // type after scaling.
+        let scale_factor = T::Native::usize_as(10)
+            .pow_checked(scale.unsigned_abs() as u32)
+            .ok();
+
+        match (scale_factor, cast_options.safe) {
+            (Some(scale_factor), true) => array.unary_opt::<_, D>(|v| {
+                let v = v
+                    .div_checked(scale_factor)
+                    .ok()
+                    .and_then(integer_to_decimal_native::<_, M>)?;
                 (D::is_valid_decimal_precision(v, precision)).then_some(v)
             }),
-            false => array.try_unary::<_, D, _>(|v| {
-                let v = v.as_().div_checked(scale_factor)?;
+            (Some(scale_factor), false) => array.try_unary::<_, D, _>(|v| {
+                let v = v
+                    .div_checked(scale_factor)
+                    .ok()
+                    .and_then(integer_to_decimal_native::<_, M>)
+                    .ok_or_else(|| overflow(v))?;
                 D::validate_decimal_precision(v, precision, scale).map(|()| v)
             })?,
+            // A scale factor that overflows the source type is larger than all
+            // source values, so integer division produces zero.
+            //
+            // For a well formed decimal scale, this path should never be 
reachable.
+            (None, _) => array.unary::<_, D>(|_| M::ZERO),
         }
     } else {
+        let scale_factor = base.pow_checked(scale.unsigned_abs() as 
u32).map_err(|_| {
+            ArrowError::CastError(format!(
+                "Cannot cast to {:?}({}, {}). The scale causes overflow.",
+                D::PREFIX,
+                precision,
+                scale,
+            ))
+        })?;
+
         match cast_options.safe {
             true => array.unary_opt::<_, D>(|v| {
-                let v = v.as_().mul_checked(scale_factor).ok()?;
+                let v = integer_to_decimal_native::<_, M>(v)
+                    .and_then(|v| v.mul_checked(scale_factor).ok())?;
                 (D::is_valid_decimal_precision(v, precision)).then_some(v)
             }),
             false => array.try_unary::<_, D, _>(|v| {
-                let v = v.as_().mul_checked(scale_factor)?;
+                let v = integer_to_decimal_native::<_, M>(v)
+                    .ok_or_else(|| overflow(v))
+                    .and_then(|v| v.mul_checked(scale_factor))?;
                 D::validate_decimal_precision(v, precision, scale).map(|()| v)
             })?,
         }
@@ -2403,14 +2447,6 @@ fn cast_to_decimal<D, M>(
 where
     D: DecimalType + ArrowPrimitiveType<Native = M>,
     M: ArrowNativeTypeOp + DecimalCast,
-    u8: num_traits::AsPrimitive<M>,
-    u16: num_traits::AsPrimitive<M>,
-    u32: num_traits::AsPrimitive<M>,
-    u64: num_traits::AsPrimitive<M>,
-    i8: num_traits::AsPrimitive<M>,
-    i16: num_traits::AsPrimitive<M>,
-    i32: num_traits::AsPrimitive<M>,
-    i64: num_traits::AsPrimitive<M>,
 {
     use DataType::*;
     // cast data to decimal
@@ -10613,6 +10649,117 @@ mod tests {
         assert!(casted_array.is_err());
     }
 
+    #[test]
+    fn test_cast_integer_to_decimal32_does_not_truncate() {
+        let array = Int64Array::from(vec![5_000_000_000i64, 10_000_000_000, 
42]);
+        let safe = CastOptions {
+            safe: true,
+            format_options: FormatOptions::default(),
+        };
+        let unsafe_opts = CastOptions {
+            safe: false,
+            format_options: FormatOptions::default(),
+        };
+
+        let result = cast_with_options(&array, &DataType::Decimal32(9, 0), 
&safe).unwrap();
+        let result = result.as_primitive::<Decimal32Type>();
+        assert!(
+            result.is_null(0),
+            "5e9 must not wrap to {}",
+            result.value(0)
+        );
+        assert!(result.is_null(1));
+        assert_eq!(result.value(2), 42);
+
+        let err = cast_with_options(&array, &DataType::Decimal32(9, 0), 
&unsafe_opts)
+            .unwrap_err()
+            .to_string();
+        assert_eq!(
+            err,
+            "Cast error: Cannot cast to Decimal32(9, 0). Overflowing on 
5000000000"
+        );
+
+        let result = cast_with_options(&array, &DataType::Decimal128(9, 0), 
&safe).unwrap();
+        let result = result.as_primitive::<Decimal128Type>();
+        assert!(result.is_null(0));
+        assert!(result.is_null(1));
+        assert_eq!(result.value(2), 42);
+    }
+
+    #[test]
+    fn test_cast_integer_to_decimal32_scales_before_narrowing() {
+        let array = Int64Array::from(vec![5_000_000_000i64]);
+        let safe = CastOptions {
+            safe: true,
+            format_options: FormatOptions::default(),
+        };
+        let unsafe_opts = CastOptions {
+            safe: false,
+            format_options: FormatOptions::default(),
+        };
+        let data_type = DataType::Decimal32(9, -1);
+
+        let result = cast_with_options(&array, &data_type, &safe).unwrap();
+        let result = result.as_primitive::<Decimal32Type>();
+        assert_eq!(result.value(0), 500_000_000);
+
+        let result = cast_with_options(&array, &data_type, 
&unsafe_opts).unwrap();
+        let result = result.as_primitive::<Decimal32Type>();
+        assert_eq!(result.value(0), 500_000_000);
+    }
+
+    #[test]
+    fn test_cast_uint_to_decimal32_does_not_wrap() {
+        let array = UInt32Array::from(vec![4_000_000_000u32]);
+        let safe = CastOptions {
+            safe: true,
+            format_options: FormatOptions::default(),
+        };
+        let unsafe_opts = CastOptions {
+            safe: false,
+            format_options: FormatOptions::default(),
+        };
+
+        let result = cast_with_options(&array, &DataType::Decimal32(9, 0), 
&safe).unwrap();
+        let result = result.as_primitive::<Decimal32Type>();
+        assert!(
+            result.is_null(0),
+            "u32 4e9 must not wrap to {}",
+            result.value(0)
+        );
+
+        let err = cast_with_options(&array, &DataType::Decimal32(9, 0), 
&unsafe_opts)
+            .unwrap_err()
+            .to_string();
+        assert_eq!(
+            err,
+            "Cast error: Cannot cast to Decimal32(9, 0). Overflowing on 
4000000000"
+        );
+
+        let result = cast_with_options(&array, &DataType::Decimal128(9, 0), 
&safe).unwrap();
+        assert!(result.is_null(0));
+        assert!(cast_with_options(&array, &DataType::Decimal128(9, 0), 
&unsafe_opts).is_err());
+    }
+
+    #[test]
+    fn test_cast_uint64_max_to_decimal64_does_not_wrap() {
+        let array = UInt64Array::from(vec![u64::MAX]);
+        let unsafe_opts = CastOptions {
+            safe: false,
+            format_options: FormatOptions::default(),
+        };
+
+        let err = cast_with_options(&array, &DataType::Decimal64(18, 0), 
&unsafe_opts)
+            .unwrap_err()
+            .to_string();
+        assert_eq!(
+            err,
+            "Cast error: Cannot cast to Decimal64(18, 0). Overflowing on 
18446744073709551615"
+        );
+
+        assert!(cast_with_options(&array, &DataType::Decimal128(18, 0), 
&unsafe_opts).is_err());
+    }
+
     #[test]
     fn test_cast_floating_point_to_decimal128_precision_overflow() {
         let array = Float64Array::from(vec![1.1]);
diff --git a/arrow/benches/cast_kernels.rs b/arrow/benches/cast_kernels.rs
index 37d44663e5..2a6336b338 100644
--- a/arrow/benches/cast_kernels.rs
+++ b/arrow/benches/cast_kernels.rs
@@ -331,6 +331,12 @@ fn add_benchmark(c: &mut Criterion) {
     c.bench_function("cast int64 to int32 512", |b| {
         b.iter(|| cast_array(&i64_array, DataType::Int32))
     });
+    c.bench_function("cast int64 to decimal32(9, 0) 512", |b| {
+        b.iter(|| cast_array(&i64_array, DataType::Decimal32(9, 0)))
+    });
+    c.bench_function("cast int64 to decimal32(9, -1) 512", |b| {
+        b.iter(|| cast_array(&i64_array, DataType::Decimal32(9, -1)))
+    });
     c.bench_function("cast date64 to date32 512", |b| {
         b.iter(|| cast_array(&date64_array, DataType::Date32))
     });

Reply via email to