Skip to content

fix(arrow-cast): do not truncate integers when casting to Decimal32/64 - #10707

Open
yongster wants to merge 7 commits into
apache:mainfrom
yongster:fix/cast-integer-to-decimal32-truncation
Open

fix(arrow-cast): do not truncate integers when casting to Decimal32/64#10707
yongster wants to merge 7 commits into
apache:mainfrom
yongster:fix/cast-integer-to-decimal32-truncation

Conversation

@yongster

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

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:

cargo test -p arrow-cast --lib -- cast
cargo clippy -p arrow-cast --all-targets --all-features -- -D warnings
cargo +stable fmt --all -- --check

Closes apache#10706.

cast_integer_to_decimal used AsPrimitive before the precision check.
That silently wraps values that do not fit the decimal native type, so
Int64(5_000_000_000) became Decimal32 705032704, and UInt32(4e9) /
UInt64::MAX could become negative.

Convert through a range-checked i128 path first, then apply scale and
precision. Safe casts become null; unsafe casts error with the original
value.

Add regression tests for Int64 -> Decimal32, UInt32 -> Decimal32, and
UInt64::MAX -> Decimal64.
@github-actions github-actions Bot added arrow Changes to the arrow crate arrow-cast labels Aug 16, 2026
@Jefffrey Jefffrey added the bug label Aug 16, 2026
Comment on lines +351 to +362
/// 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`,
/// so go through that and then use [`DecimalCast`] which is range-checked.
fn integer_to_decimal_native<I, M>(value: I) -> Option<M>
where
I: NumCast,
M: DecimalCast,
{
num_cast::<I, i128>(value).and_then(M::from_decimal)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we go through i128 because theres no easy way to go directly from the input integer type to the native decimal type?

e.g. for i64 -> decimal32, on main we do i64 as i32 then do the scaling, this PR seems to do i32::try_from(i64 as i128) if im understanding it correctly?

though maybe LLVM optimizes this away, not sure if we should rely on that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the i128 intermediate is intentional.

This helper is shared by casts to Decimal32, Decimal64, Decimal128, and
Decimal256, so the target native type M can be i32, i64, i128, or
i256. A direct generic num_cast::<I, M> is not available for all of these
targets, and DecimalCast::from_decimal provides the checked final conversion.

All integer input types dispatched to this helper fit losslessly in i128.
I agree that the current num_cast expression obscures that invariant, so I
will replace it with an explicit lossless Into<i128> conversion.

Comment thread arrow-cast/src/cast/mod.rs Outdated
yang3.xie added 2 commits August 17, 2026 16:42
Scale negative values in the source integer type before the checked
conversion to the decimal native type. This allows values that fit after
scaling to be cast successfully and avoids per-element i128 division.

Use an explicit lossless i128 intermediate for decimal conversion and add
Criterion benchmarks for integer-to-decimal casts.
Comment thread arrow-cast/benches/integer_to_decimal.rs Outdated
Comment thread arrow-cast/src/cast/mod.rs Outdated
Compute negative scale factors once in the source integer type before
casting to the decimal native type. This allows values that fit after
scaling to be converted without moving exponentiation into the hot loop.

Move integer-to-decimal benchmarks into the existing cast kernel suite and
remove the standalone arrow-cast benchmark target.
Comment thread arrow-cast/src/cast/mod.rs Outdated
/// `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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// with a range check.
/// 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`.

Comment on lines +409 to +410
// A scale factor that overflows the source type is larger than all
// source values, so integer division produces zero.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// A scale factor that overflows the source type is larger than all
// source values, so integer division produces zero.
// 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.

i8: num_traits::AsPrimitive<M>,
i16: num_traits::AsPrimitive<M>,
i32: num_traits::AsPrimitive<M>,
i64: num_traits::AsPrimitive<M>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice 👍

Comment thread arrow-cast/src/cast/mod.rs Outdated
Comment on lines +10676 to +10680
err.contains("5000000000"),
"unsafe error should report the original value, got {err}"
);
assert!(
!err.contains("705032704"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we assert more of the error message; this isnt clearly obvious that we're getting the error we expect

Comment thread arrow/benches/cast_kernels.rs Outdated
Comment on lines +241 to +242
let i64_decimal32_array: ArrayRef = Arc::new(Int64Array::from_iter_values(0..512));
let i64_decimal32_scaled_array: ArrayRef = Arc::new(Int64Array::from_value(5_000_000_000, 512));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need these new arrays? can we use the existing i64_array above?

yongster and others added 2 commits August 18, 2026 16:54
Assert full overflow error messages, reuse the existing i64 array in
cast benchmarks, and clarify integer-to-decimal conversion comments.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arrow Changes to the arrow crate arrow-cast bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cast from integer to Decimal32/64 silently truncates values that do not fit

2 participants