From b10d336e706ae6676f62133aa3a30fd160830f3b Mon Sep 17 00:00:00 2001 From: Matt Katz Date: Fri, 7 Aug 2026 10:33:12 -0700 Subject: [PATCH 1/8] cast primitive arrays to decimal Signed-off-by: Matt Katz --- .../src/arrays/primitive/compute/cast.rs | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) diff --git a/vortex-array/src/arrays/primitive/compute/cast.rs b/vortex-array/src/arrays/primitive/compute/cast.rs index 09defa442b8..a0c60d0b46d 100644 --- a/vortex-array/src/arrays/primitive/compute/cast.rs +++ b/vortex-array/src/arrays/primitive/compute/cast.rs @@ -2,12 +2,14 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors use num_traits::AsPrimitive; +use num_traits::CheckedMul; use num_traits::NumCast; use vortex_buffer::Buffer; use vortex_buffer::BufferMut; use vortex_compute::lane_kernels::IndexedSinkExt; use vortex_compute::lane_kernels::IndexedSourceExt; use vortex_compute::lane_kernels::ReinterpretSink; +use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_error::vortex_bail; use vortex_error::vortex_err; @@ -18,16 +20,27 @@ use crate::ExecutionCtx; use crate::IntoArray; use crate::aggregate_fn; use crate::array::ArrayView; +use crate::arrays::DecimalArray; use crate::arrays::Primitive; use crate::arrays::PrimitiveArray; use crate::arrays::primitive::PrimitiveArrayExt; +use crate::dtype::BigCast; use crate::dtype::DType; +use crate::dtype::DecimalDType; +use crate::dtype::DecimalType; +use crate::dtype::IntegerPType; +use crate::dtype::NativeDecimalType; use crate::dtype::NativePType; use crate::dtype::Nullability; use crate::dtype::PType; +use crate::dtype::ToI256; +use crate::dtype::i256; use crate::expr::stats::Stat; use crate::expr::stats::StatsProvider; +use crate::match_each_decimal_value_type; +use crate::match_each_integer_ptype; use crate::match_each_native_ptype; +use crate::scalar::DecimalValue; use crate::scalar_fn::fns::cast::CastKernel; use crate::scalar_fn::fns::cast::CastReduce; use crate::validity::Validity; @@ -68,6 +81,9 @@ impl CastKernel for Primitive { dtype: &DType, ctx: &mut ExecutionCtx, ) -> VortexResult> { + if let DType::Decimal(decimal_dtype, nullability) = dtype { + return cast_to_decimal(array, *decimal_dtype, *nullability, ctx).map(Some); + } let DType::Primitive(new_ptype, new_nullability) = dtype else { return Ok(None); }; @@ -105,6 +121,139 @@ impl CastKernel for Primitive { } } +fn cast_to_decimal( + array: ArrayView<'_, Primitive>, + decimal_dtype: DecimalDType, + nullability: Nullability, + ctx: &mut ExecutionCtx, +) -> VortexResult { + if !array.ptype().is_int() { + vortex_bail!( + Compute: "Cannot cast floating primitive {} to decimal {}", + array.ptype(), decimal_dtype + ); + } + + let source_validity = array.validity()?; + let validity = source_validity + .clone() + .cast_nullability(nullability, array.len(), ctx)?; + let valid_values = source_validity.execute_mask(array.len(), ctx)?; + let values_type = DecimalType::smallest_decimal_value_type(&decimal_dtype); + + match_each_integer_ptype!(array.ptype(), |S| { + match_each_decimal_value_type!(values_type, |T| { + cast_integer_values_to_decimal::(array, decimal_dtype, validity, &valid_values) + }) + }) +} + +fn cast_integer_values_to_decimal( + array: ArrayView<'_, Primitive>, + decimal_dtype: DecimalDType, + validity: Validity, + valid_values: &Mask, +) -> VortexResult +where + S: IntegerPType + ToI256, + T: NativeDecimalType, +{ + let values = array.as_slice::(); + let scale = decimal_dtype.scale(); + let scale_factor = if scale == 0 { + i256::ONE + } else { + let exponent = if scale > 0 { + scale as u32 + } else { + (-(scale as i16)) as u32 + }; + i256::from_i128(10).checked_pow(exponent).ok_or_else(|| { + vortex_err!( + Compute: "Cannot cast primitive values to {}: scale factor overflows", + decimal_dtype + ) + })? + }; + let (min, max) = ( + T::MIN_BY_PRECISION[decimal_dtype.precision() as usize], + T::MAX_BY_PRECISION[decimal_dtype.precision() as usize], + ); + + let buffer = cast_primitive_to_decimal_buffer(values, valid_values, |value| { + let value = ::from(value)?; + let value = if scale > 0 { + value.checked_mul(&scale_factor)? + } else if scale < 0 { + (value % scale_factor == i256::ZERO).then_some(value / scale_factor)? + } else { + value + }; + let value = ::from(value)?; + (value >= min && value <= max).then_some(value) + }) + .map_err(|idx| primitive_to_decimal_cast_error(values[idx], decimal_dtype))?; + + Ok(DecimalArray::new(buffer, decimal_dtype, validity).into_array()) +} + +fn cast_primitive_to_decimal_buffer( + values: &[S], + valid_values: &Mask, + mut cast: impl FnMut(S) -> Option, +) -> Result, usize> +where + S: NativePType, + T: NativeDecimalType, +{ + let mut buffer = BufferMut::::with_capacity(values.len()); + match valid_values { + Mask::AllTrue(_) => { + values.try_map_into(&mut buffer.spare_capacity_mut()[..values.len()], &mut cast)?; + } + Mask::AllFalse(_) => return Ok(BufferMut::::zeroed(values.len()).freeze()), + Mask::Values(mask) => { + values.try_map_masked_into( + mask.bit_buffer(), + &mut buffer.spare_capacity_mut()[..values.len()], + &mut cast, + )?; + } + } + // SAFETY: the selected map kernel initialized every lane before returning Ok. + unsafe { buffer.set_len(values.len()) }; + Ok(buffer.freeze()) +} + +#[cold] +fn primitive_to_decimal_cast_error(value: S, decimal_dtype: DecimalDType) -> VortexError +where + S: IntegerPType + ToI256, +{ + let Some(value) = ::from(value) else { + return vortex_err!( + Compute: "primitive value cannot be represented while casting to {}", + decimal_dtype + ); + }; + + match DecimalValue::rescale_i256(value, 0, decimal_dtype.scale()) + .and_then(|value| DecimalValue::try_from_i256(value, decimal_dtype)) + { + Err(error) => error, + Ok(_) => { + debug_assert!( + false, + "primitive-to-decimal fast path rejected a value that the scalar cast accepts" + ); + vortex_err!( + Compute: "primitive value cannot be represented while casting to {}", + decimal_dtype + ) + } + } +} + /// Cast Primitive values from `F` to `T`. fn cast_values( array: ArrayView<'_, Primitive>, @@ -274,11 +423,14 @@ mod test { use crate::IntoArray; use crate::VortexSessionExecute; use crate::array_session; + use crate::arrays::DecimalArray; use crate::arrays::PrimitiveArray; use crate::assert_arrays_eq; use crate::builtins::ArrayBuiltins; use crate::compute::conformance::cast::test_cast_conformance; use crate::dtype::DType; + use crate::dtype::DecimalDType; + use crate::dtype::DecimalType; use crate::dtype::Nullability; use crate::dtype::PType; use crate::validity::Validity; @@ -362,6 +514,110 @@ mod test { ); } + #[test] + fn cast_integer_to_decimal_rescales() -> vortex_error::VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let decimal_dtype = DecimalDType::new(5, 2); + let casted = PrimitiveArray::from_iter([42i32, -7]) + .into_array() + .cast(DType::Decimal(decimal_dtype, Nullability::NonNullable))? + .execute::(&mut ctx)?; + + assert_eq!( + casted.dtype(), + &DType::Decimal(decimal_dtype, Nullability::NonNullable) + ); + assert_eq!(casted.values_type(), DecimalType::I32); + assert_eq!(casted.buffer::().as_ref(), &[4_200, -700]); + Ok(()) + } + + #[test] + fn cast_u64_to_decimal() -> vortex_error::VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let decimal_dtype = DecimalDType::new(20, 0); + let casted = PrimitiveArray::from_iter([u64::MAX]) + .into_array() + .cast(DType::Decimal(decimal_dtype, Nullability::NonNullable))? + .execute::(&mut ctx)?; + + assert_eq!(casted.values_type(), DecimalType::I128); + assert_eq!(casted.buffer::().as_ref(), &[i128::from(u64::MAX)]); + Ok(()) + } + + #[test] + fn cast_integer_to_negative_scale_decimal_requires_exact_rescale() + -> vortex_error::VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let decimal_dtype = DecimalDType::new(3, -2); + let casted = PrimitiveArray::from_iter([1_200i32, -500]) + .into_array() + .cast(DType::Decimal(decimal_dtype, Nullability::NonNullable))? + .execute::(&mut ctx)?; + + assert_eq!(casted.buffer::().as_ref(), &[12, -5]); + + let error = PrimitiveArray::from_iter([42i32]) + .into_array() + .cast(DType::Decimal(decimal_dtype, Nullability::NonNullable))? + .execute::(&mut ctx) + .unwrap_err(); + assert!(error.to_string().contains("would lose precision")); + Ok(()) + } + + #[test] + fn cast_integer_to_decimal_ignores_out_of_range_null_lanes() -> vortex_error::VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let decimal_dtype = DecimalDType::new(3, 1); + let casted = PrimitiveArray::new(buffer![999i32, 42], Validity::from_iter([false, true])) + .into_array() + .cast(DType::Decimal(decimal_dtype, Nullability::Nullable))? + .execute::(&mut ctx)?; + + assert_eq!(casted.buffer::().as_ref()[1], 420); + assert_eq!( + casted.validity()?.execute_mask(casted.len(), &mut ctx)?, + Mask::from(BitBuffer::from(vec![false, true])) + ); + Ok(()) + } + + #[test] + fn cast_integer_to_decimal_checks_precision() -> vortex_error::VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let casted = PrimitiveArray::from_iter([100i32]) + .into_array() + .cast(DType::Decimal( + DecimalDType::new(2, 1), + Nullability::NonNullable, + ))?; + + let error = casted.execute::(&mut ctx).unwrap_err(); + assert!(error.to_string().contains("does not fit in precision")); + Ok(()) + } + + #[test] + fn cast_floating_primitive_to_decimal_fails() -> vortex_error::VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let casted = PrimitiveArray::from_iter([1.0f64]) + .into_array() + .cast(DType::Decimal( + DecimalDType::new(3, 1), + Nullability::NonNullable, + ))?; + + let error = casted.execute::(&mut ctx).unwrap_err(); + assert!( + error + .to_string() + .contains("Cannot cast floating primitive f64 to decimal decimal(3,1)") + ); + Ok(()) + } + #[test] fn cast_i32_u32() { let arr = buffer![-1i32].into_array(); From d8c5238bc658b6c42ab2df827084459683a12f2c Mon Sep 17 00:00:00 2001 From: Matt Katz Date: Fri, 7 Aug 2026 11:40:51 -0700 Subject: [PATCH 2/8] avoid i256 for primitive decimal casts Signed-off-by: Matt Katz --- vortex-array/benches/cast_primitive.rs | 19 +++ .../src/arrays/primitive/compute/cast.rs | 131 ++++++++++++++---- 2 files changed, 124 insertions(+), 26 deletions(-) diff --git a/vortex-array/benches/cast_primitive.rs b/vortex-array/benches/cast_primitive.rs index 69a4c5d2cc5..1b0ecf76807 100644 --- a/vortex-array/benches/cast_primitive.rs +++ b/vortex-array/benches/cast_primitive.rs @@ -14,6 +14,7 @@ use vortex_array::array_session; use vortex_array::arrays::PrimitiveArray; use vortex_array::builtins::ArrayBuiltins; use vortex_array::dtype::DType; +use vortex_array::dtype::DecimalDType; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; use vortex_array::expr::stats::Stat; @@ -87,3 +88,21 @@ fn cast_i32_to_u32(bencher: Bencher, n: usize) { .execute::(ctx) }); } + +/// Integer-to-decimal cast at the largest precision that uses an i128 backing buffer. This +/// exercises the common rescaling path without paying for i256 arithmetic per input value. +#[divan::bench(args = SIZES)] +fn cast_i64_to_decimal38_scale2(bencher: Bencher, n: usize) { + let arr = + PrimitiveArray::from_iter((0..n).map(|value| i64::try_from(value).unwrap())).into_array(); + bencher + .with_inputs(|| (arr.clone(), SESSION.create_execution_ctx())) + .bench_refs(|(a, ctx)| { + a.cast(DType::Decimal( + DecimalDType::new(38, 2), + Nullability::NonNullable, + )) + .unwrap() + .execute::(ctx) + }); +} diff --git a/vortex-array/src/arrays/primitive/compute/cast.rs b/vortex-array/src/arrays/primitive/compute/cast.rs index a0c60d0b46d..d9de8394826 100644 --- a/vortex-array/src/arrays/primitive/compute/cast.rs +++ b/vortex-array/src/arrays/primitive/compute/cast.rs @@ -160,41 +160,92 @@ where { let values = array.as_slice::(); let scale = decimal_dtype.scale(); - let scale_factor = if scale == 0 { - i256::ONE + let buffer = if scale == 0 { + cast_primitive_to_decimal_buffer(values, valid_values, |value| { + let value = ::from(value)?; + decimal_value_fits_precision(value, decimal_dtype).then_some(value) + }) + } else if scale < -19 { + // Primitive values are at most 20 decimal digits wide (`u64::MAX` is less than 10^20), + // so only zero can be exactly rescaled by 10^-20 or smaller. + cast_primitive_to_decimal_buffer(values, valid_values, |value| { + (value == S::default()).then_some(T::default()) + }) + } else if decimal_dtype.precision() <= ::MAX_PRECISION { + let scale_factor = decimal_scale_factor::(scale)?; + cast_rescaled_integer_values_to_decimal::( + values, + decimal_dtype, + valid_values, + scale_factor, + ) } else { - let exponent = if scale > 0 { - scale as u32 - } else { - (-(scale as i16)) as u32 - }; - i256::from_i128(10).checked_pow(exponent).ok_or_else(|| { - vortex_err!( - Compute: "Cannot cast primitive values to {}: scale factor overflows", - decimal_dtype - ) - })? - }; - let (min, max) = ( - T::MIN_BY_PRECISION[decimal_dtype.precision() as usize], - T::MAX_BY_PRECISION[decimal_dtype.precision() as usize], - ); + let scale_factor = decimal_scale_factor::(scale)?; + cast_rescaled_integer_values_to_decimal::( + values, + decimal_dtype, + valid_values, + scale_factor, + ) + } + .map_err(|idx| primitive_to_decimal_cast_error(values[idx], decimal_dtype))?; - let buffer = cast_primitive_to_decimal_buffer(values, valid_values, |value| { - let value = ::from(value)?; + Ok(DecimalArray::new(buffer, decimal_dtype, validity).into_array()) +} + +fn cast_rescaled_integer_values_to_decimal( + values: &[S], + decimal_dtype: DecimalDType, + valid_values: &Mask, + scale_factor: W, +) -> Result, usize> +where + S: IntegerPType + ToI256, + T: NativeDecimalType, + W: NativeDecimalType + CheckedMul + std::ops::Div + std::ops::Rem, +{ + let scale = decimal_dtype.scale(); + cast_primitive_to_decimal_buffer(values, valid_values, |value| { + let value = ::from(value)?; let value = if scale > 0 { value.checked_mul(&scale_factor)? - } else if scale < 0 { - (value % scale_factor == i256::ZERO).then_some(value / scale_factor)? } else { - value + (value % scale_factor == W::default()).then_some(value / scale_factor)? }; let value = ::from(value)?; - (value >= min && value <= max).then_some(value) + decimal_value_fits_precision(value, decimal_dtype).then_some(value) }) - .map_err(|idx| primitive_to_decimal_cast_error(values[idx], decimal_dtype))?; +} - Ok(DecimalArray::new(buffer, decimal_dtype, validity).into_array()) +fn decimal_scale_factor(scale: i8) -> VortexResult +where + T: NativeDecimalType + CheckedMul, +{ + let exponent = if scale > 0 { + scale as u32 + } else { + (-(scale as i16)) as u32 + }; + let ten = ::from(10i8).ok_or_else( + || vortex_err!(Compute: "Cannot create decimal scale factor for scale {scale}"), + )?; + let mut factor = ::from(1i8).ok_or_else( + || vortex_err!(Compute: "Cannot create decimal scale factor for scale {scale}"), + )?; + for _ in 0..exponent { + factor = factor.checked_mul(&ten).ok_or_else( + || vortex_err!(Compute: "Cannot create decimal scale factor for scale {scale}"), + )?; + } + Ok(factor) +} + +fn decimal_value_fits_precision( + value: T, + decimal_dtype: DecimalDType, +) -> bool { + let precision = decimal_dtype.precision() as usize; + value >= T::MIN_BY_PRECISION[precision] && value <= T::MAX_BY_PRECISION[precision] } fn cast_primitive_to_decimal_buffer( @@ -433,6 +484,7 @@ mod test { use crate::dtype::DecimalType; use crate::dtype::Nullability; use crate::dtype::PType; + use crate::dtype::i256; use crate::validity::Validity; #[test] @@ -546,6 +598,20 @@ mod test { Ok(()) } + #[test] + fn cast_integer_to_i256_decimal() -> vortex_error::VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let decimal_dtype = DecimalDType::new(39, 2); + let casted = PrimitiveArray::from_iter([42i64]) + .into_array() + .cast(DType::Decimal(decimal_dtype, Nullability::NonNullable))? + .execute::(&mut ctx)?; + + assert_eq!(casted.values_type(), DecimalType::I256); + assert_eq!(casted.buffer::().as_ref(), &[i256::from_i128(4_200)]); + Ok(()) + } + #[test] fn cast_integer_to_negative_scale_decimal_requires_exact_rescale() -> vortex_error::VortexResult<()> { @@ -567,6 +633,19 @@ mod test { Ok(()) } + #[test] + fn cast_zero_to_large_negative_scale_decimal() -> vortex_error::VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let decimal_dtype = DecimalDType::new(3, -128); + let casted = PrimitiveArray::from_iter([0i32]) + .into_array() + .cast(DType::Decimal(decimal_dtype, Nullability::NonNullable))? + .execute::(&mut ctx)?; + + assert_eq!(casted.buffer::().as_ref(), &[0]); + Ok(()) + } + #[test] fn cast_integer_to_decimal_ignores_out_of_range_null_lanes() -> vortex_error::VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); From e336ed981b2303ff35d8d31c662781d5704f91d5 Mon Sep 17 00:00:00 2001 From: Matt Katz Date: Fri, 7 Aug 2026 15:06:19 -0700 Subject: [PATCH 3/8] specialize primitive decimal rescaling Signed-off-by: Matt Katz --- vortex-array/benches/cast_primitive.rs | 17 +++ .../src/arrays/primitive/compute/cast.rs | 116 ++++++++++++++---- 2 files changed, 111 insertions(+), 22 deletions(-) diff --git a/vortex-array/benches/cast_primitive.rs b/vortex-array/benches/cast_primitive.rs index 1b0ecf76807..f3cb3c6ee5d 100644 --- a/vortex-array/benches/cast_primitive.rs +++ b/vortex-array/benches/cast_primitive.rs @@ -106,3 +106,20 @@ fn cast_i64_to_decimal38_scale2(bencher: Bencher, n: usize) { .execute::(ctx) }); } + +/// Common integer-to-decimal cast that rescales directly in its i32 output buffer. +#[divan::bench(args = SIZES)] +fn cast_i32_to_decimal9_scale2(bencher: Bencher, n: usize) { + let arr = + PrimitiveArray::from_iter((0..n).map(|value| i32::try_from(value).unwrap())).into_array(); + bencher + .with_inputs(|| (arr.clone(), SESSION.create_execution_ctx())) + .bench_refs(|(a, ctx)| { + a.cast(DType::Decimal( + DecimalDType::new(9, 2), + Nullability::NonNullable, + )) + .unwrap() + .execute::(ctx) + }); +} diff --git a/vortex-array/src/arrays/primitive/compute/cast.rs b/vortex-array/src/arrays/primitive/compute/cast.rs index d9de8394826..63e7f8628c7 100644 --- a/vortex-array/src/arrays/primitive/compute/cast.rs +++ b/vortex-array/src/arrays/primitive/compute/cast.rs @@ -156,7 +156,7 @@ fn cast_integer_values_to_decimal( ) -> VortexResult where S: IntegerPType + ToI256, - T: NativeDecimalType, + T: NativeDecimalType + CheckedMul, { let values = array.as_slice::(); let scale = decimal_dtype.scale(); @@ -165,35 +165,84 @@ where let value = ::from(value)?; decimal_value_fits_precision(value, decimal_dtype).then_some(value) }) - } else if scale < -19 { - // Primitive values are at most 20 decimal digits wide (`u64::MAX` is less than 10^20), - // so only zero can be exactly rescaled by 10^-20 or smaller. + } else if scale < 0 && scale.unsigned_abs() >= primitive_max_decimal_digits(array.ptype()) { + // The scale factor exceeds every source value, so only zero can be exactly rescaled. cast_primitive_to_decimal_buffer(values, valid_values, |value| { (value == S::default()).then_some(T::default()) }) - } else if decimal_dtype.precision() <= ::MAX_PRECISION { - let scale_factor = decimal_scale_factor::(scale)?; - cast_rescaled_integer_values_to_decimal::( + } else if scale > 0 { + // The target physical type can hold both the scale factor and every valid result, so + // scale up directly in it. + let scale_factor = decimal_scale_factor::(scale)?; + cast_scaled_up_integer_values_to_decimal::( values, decimal_dtype, valid_values, scale_factor, ) } else { - let scale_factor = decimal_scale_factor::(scale)?; - cast_rescaled_integer_values_to_decimal::( - values, - decimal_dtype, - valid_values, - scale_factor, - ) + // Scaling down can shrink a value into a narrower target type, so first select the + // smallest signed carrier that can represent both the source and target. + let carrier_type = primitive_decimal_carrier_type(array.ptype()).max(T::DECIMAL_TYPE); + match_each_decimal_value_type!(carrier_type, |W| { + let scale_factor = decimal_scale_factor::(scale)?; + cast_scaled_down_integer_values_to_decimal::( + values, + decimal_dtype, + valid_values, + scale_factor, + ) + }) } .map_err(|idx| primitive_to_decimal_cast_error(values[idx], decimal_dtype))?; Ok(DecimalArray::new(buffer, decimal_dtype, validity).into_array()) } -fn cast_rescaled_integer_values_to_decimal( +fn primitive_decimal_carrier_type(ptype: PType) -> DecimalType { + match ptype { + PType::I8 => DecimalType::I8, + PType::U8 | PType::I16 => DecimalType::I16, + PType::U16 | PType::I32 => DecimalType::I32, + PType::U32 | PType::I64 => DecimalType::I64, + PType::U64 => DecimalType::I128, + PType::F16 | PType::F32 | PType::F64 => { + unreachable!("floating primitives are rejected before selecting a decimal carrier") + } + } +} + +fn primitive_max_decimal_digits(ptype: PType) -> u8 { + match ptype { + PType::U8 | PType::I8 => 3, + PType::U16 | PType::I16 => 5, + PType::U32 | PType::I32 => 10, + PType::U64 => 20, + PType::I64 => 19, + PType::F16 | PType::F32 | PType::F64 => { + unreachable!("floating primitives are rejected before inspecting decimal digits") + } + } +} + +fn cast_scaled_up_integer_values_to_decimal( + values: &[S], + decimal_dtype: DecimalDType, + valid_values: &Mask, + scale_factor: T, +) -> Result, usize> +where + S: IntegerPType + ToI256, + T: NativeDecimalType + CheckedMul, +{ + cast_primitive_to_decimal_buffer(values, valid_values, |value| { + let value = ::from(value)?; + let value = value.checked_mul(&scale_factor)?; + decimal_value_fits_precision(value, decimal_dtype).then_some(value) + }) +} + +fn cast_scaled_down_integer_values_to_decimal( values: &[S], decimal_dtype: DecimalDType, valid_values: &Mask, @@ -202,16 +251,11 @@ fn cast_rescaled_integer_values_to_decimal( where S: IntegerPType + ToI256, T: NativeDecimalType, - W: NativeDecimalType + CheckedMul + std::ops::Div + std::ops::Rem, + W: NativeDecimalType + std::ops::Div + std::ops::Rem, { - let scale = decimal_dtype.scale(); cast_primitive_to_decimal_buffer(values, valid_values, |value| { let value = ::from(value)?; - let value = if scale > 0 { - value.checked_mul(&scale_factor)? - } else { - (value % scale_factor == W::default()).then_some(value / scale_factor)? - }; + let value = (value % scale_factor == W::default()).then_some(value / scale_factor)?; let value = ::from(value)?; decimal_value_fits_precision(value, decimal_dtype).then_some(value) }) @@ -598,6 +642,34 @@ mod test { Ok(()) } + #[test] + fn cast_u8_to_negative_scale_decimal() -> vortex_error::VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let decimal_dtype = DecimalDType::new(2, -2); + let casted = PrimitiveArray::from_iter([200u8]) + .into_array() + .cast(DType::Decimal(decimal_dtype, Nullability::NonNullable))? + .execute::(&mut ctx)?; + + assert_eq!(casted.values_type(), DecimalType::I8); + assert_eq!(casted.buffer::().as_ref(), &[2]); + Ok(()) + } + + #[test] + fn cast_u64_to_negative_scale_decimal() -> vortex_error::VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let decimal_dtype = DecimalDType::new(1, -19); + let casted = PrimitiveArray::from_iter([10_000_000_000_000_000_000u64]) + .into_array() + .cast(DType::Decimal(decimal_dtype, Nullability::NonNullable))? + .execute::(&mut ctx)?; + + assert_eq!(casted.values_type(), DecimalType::I8); + assert_eq!(casted.buffer::().as_ref(), &[1]); + Ok(()) + } + #[test] fn cast_integer_to_i256_decimal() -> vortex_error::VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); From 8670d97b790ff2bb449cf5b49f02b514a8df6f0a Mon Sep 17 00:00:00 2001 From: Matt Katz Date: Fri, 7 Aug 2026 15:11:44 -0700 Subject: [PATCH 4/8] avoid discarded all-null cast allocation Signed-off-by: Matt Katz --- .../src/arrays/primitive/compute/cast.rs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/vortex-array/src/arrays/primitive/compute/cast.rs b/vortex-array/src/arrays/primitive/compute/cast.rs index 63e7f8628c7..8ea9687f495 100644 --- a/vortex-array/src/arrays/primitive/compute/cast.rs +++ b/vortex-array/src/arrays/primitive/compute/cast.rs @@ -301,12 +301,15 @@ where S: NativePType, T: NativeDecimalType, { + if matches!(valid_values, Mask::AllFalse(_)) { + return Ok(BufferMut::::zeroed(values.len()).freeze()); + } + let mut buffer = BufferMut::::with_capacity(values.len()); match valid_values { Mask::AllTrue(_) => { values.try_map_into(&mut buffer.spare_capacity_mut()[..values.len()], &mut cast)?; } - Mask::AllFalse(_) => return Ok(BufferMut::::zeroed(values.len()).freeze()), Mask::Values(mask) => { values.try_map_masked_into( mask.bit_buffer(), @@ -314,6 +317,7 @@ where &mut cast, )?; } + Mask::AllFalse(_) => unreachable!("all-null values are handled before allocating"), } // SAFETY: the selected map kernel initialized every lane before returning Ok. unsafe { buffer.set_len(values.len()) }; @@ -684,6 +688,20 @@ mod test { Ok(()) } + #[test] + fn cast_all_null_integer_to_decimal() -> vortex_error::VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let decimal_dtype = DecimalDType::new(39, 2); + let casted = PrimitiveArray::new(buffer![i64::MAX, i64::MIN], Validity::AllInvalid) + .into_array() + .cast(DType::Decimal(decimal_dtype, Nullability::Nullable))? + .execute::(&mut ctx)?; + + assert!(matches!(casted.validity(), Ok(Validity::AllInvalid))); + assert_eq!(casted.buffer::().as_ref(), &[i256::ZERO, i256::ZERO]); + Ok(()) + } + #[test] fn cast_integer_to_negative_scale_decimal_requires_exact_rescale() -> vortex_error::VortexResult<()> { From ee1f548a1cd07f50b301aa6c802614e148f76403 Mon Sep 17 00:00:00 2001 From: Matt Katz Date: Fri, 7 Aug 2026 15:25:51 -0700 Subject: [PATCH 5/8] split primitive decimal cast paths Signed-off-by: Matt Katz --- .../src/arrays/primitive/compute/cast.rs | 152 ++++++++++-------- 1 file changed, 84 insertions(+), 68 deletions(-) diff --git a/vortex-array/src/arrays/primitive/compute/cast.rs b/vortex-array/src/arrays/primitive/compute/cast.rs index 8ea9687f495..ebda38185df 100644 --- a/vortex-array/src/arrays/primitive/compute/cast.rs +++ b/vortex-array/src/arrays/primitive/compute/cast.rs @@ -158,45 +158,98 @@ where S: IntegerPType + ToI256, T: NativeDecimalType + CheckedMul, { - let values = array.as_slice::(); let scale = decimal_dtype.scale(); let buffer = if scale == 0 { - cast_primitive_to_decimal_buffer(values, valid_values, |value| { - let value = ::from(value)?; - decimal_value_fits_precision(value, decimal_dtype).then_some(value) - }) - } else if scale < 0 && scale.unsigned_abs() >= primitive_max_decimal_digits(array.ptype()) { - // The scale factor exceeds every source value, so only zero can be exactly rescaled. - cast_primitive_to_decimal_buffer(values, valid_values, |value| { - (value == S::default()).then_some(T::default()) - }) + cast_unscaled_integer_values_to_decimal::(array, decimal_dtype, valid_values)? } else if scale > 0 { - // The target physical type can hold both the scale factor and every valid result, so - // scale up directly in it. - let scale_factor = decimal_scale_factor::(scale)?; - cast_scaled_up_integer_values_to_decimal::( + cast_scaled_up_integer_values_to_decimal::(array, decimal_dtype, valid_values)? + } else { + cast_scaled_down_integer_values_to_decimal::(array, decimal_dtype, valid_values)? + }; + + Ok(DecimalArray::new(buffer, decimal_dtype, validity).into_array()) +} + +fn cast_unscaled_integer_values_to_decimal( + array: ArrayView<'_, Primitive>, + decimal_dtype: DecimalDType, + valid_values: &Mask, +) -> VortexResult> +where + S: IntegerPType + ToI256, + T: NativeDecimalType, +{ + let values = array.as_slice::(); + cast_integer_values_to_decimal_buffer(values, decimal_dtype, valid_values, |value| { + let value = ::from(value)?; + decimal_value_fits_precision(value, decimal_dtype).then_some(value) + }) +} + +fn cast_scaled_up_integer_values_to_decimal( + array: ArrayView<'_, Primitive>, + decimal_dtype: DecimalDType, + valid_values: &Mask, +) -> VortexResult> +where + S: IntegerPType + ToI256, + T: NativeDecimalType + CheckedMul, +{ + let values = array.as_slice::(); + let scale_factor = decimal_scale_factor::(decimal_dtype.scale())?; + cast_integer_values_to_decimal_buffer(values, decimal_dtype, valid_values, |value| { + let value = ::from(value)?; + let value = value.checked_mul(&scale_factor)?; + decimal_value_fits_precision(value, decimal_dtype).then_some(value) + }) +} + +fn cast_scaled_down_integer_values_to_decimal( + array: ArrayView<'_, Primitive>, + decimal_dtype: DecimalDType, + valid_values: &Mask, +) -> VortexResult> +where + S: IntegerPType + ToI256, + T: NativeDecimalType, +{ + let values = array.as_slice::(); + if decimal_dtype.scale().unsigned_abs() >= primitive_max_decimal_digits(array.ptype()) { + // The scale factor exceeds every source value, so only zero can be exactly rescaled. + return cast_integer_values_to_decimal_buffer( values, decimal_dtype, valid_values, - scale_factor, - ) - } else { - // Scaling down can shrink a value into a narrower target type, so first select the - // smallest signed carrier that can represent both the source and target. - let carrier_type = primitive_decimal_carrier_type(array.ptype()).max(T::DECIMAL_TYPE); - match_each_decimal_value_type!(carrier_type, |W| { - let scale_factor = decimal_scale_factor::(scale)?; - cast_scaled_down_integer_values_to_decimal::( - values, - decimal_dtype, - valid_values, - scale_factor, - ) - }) + |value| (value == S::default()).then_some(T::default()), + ); } - .map_err(|idx| primitive_to_decimal_cast_error(values[idx], decimal_dtype))?; - Ok(DecimalArray::new(buffer, decimal_dtype, validity).into_array()) + // Scaling down can shrink a value into a narrower target type, so first select the smallest + // signed carrier that can represent both the source and target. + let carrier_type = primitive_decimal_carrier_type(array.ptype()).max(T::DECIMAL_TYPE); + match_each_decimal_value_type!(carrier_type, |W| { + let scale_factor = decimal_scale_factor::(decimal_dtype.scale())?; + cast_integer_values_to_decimal_buffer(values, decimal_dtype, valid_values, |value| { + let value = ::from(value)?; + let value = (value % scale_factor == W::default()).then_some(value / scale_factor)?; + let value = ::from(value)?; + decimal_value_fits_precision(value, decimal_dtype).then_some(value) + }) + }) +} + +fn cast_integer_values_to_decimal_buffer( + values: &[S], + decimal_dtype: DecimalDType, + valid_values: &Mask, + cast: impl FnMut(S) -> Option, +) -> VortexResult> +where + S: IntegerPType + ToI256, + T: NativeDecimalType, +{ + cast_primitive_to_decimal_buffer(values, valid_values, cast) + .map_err(|idx| primitive_to_decimal_cast_error(values[idx], decimal_dtype)) } fn primitive_decimal_carrier_type(ptype: PType) -> DecimalType { @@ -225,42 +278,6 @@ fn primitive_max_decimal_digits(ptype: PType) -> u8 { } } -fn cast_scaled_up_integer_values_to_decimal( - values: &[S], - decimal_dtype: DecimalDType, - valid_values: &Mask, - scale_factor: T, -) -> Result, usize> -where - S: IntegerPType + ToI256, - T: NativeDecimalType + CheckedMul, -{ - cast_primitive_to_decimal_buffer(values, valid_values, |value| { - let value = ::from(value)?; - let value = value.checked_mul(&scale_factor)?; - decimal_value_fits_precision(value, decimal_dtype).then_some(value) - }) -} - -fn cast_scaled_down_integer_values_to_decimal( - values: &[S], - decimal_dtype: DecimalDType, - valid_values: &Mask, - scale_factor: W, -) -> Result, usize> -where - S: IntegerPType + ToI256, - T: NativeDecimalType, - W: NativeDecimalType + std::ops::Div + std::ops::Rem, -{ - cast_primitive_to_decimal_buffer(values, valid_values, |value| { - let value = ::from(value)?; - let value = (value % scale_factor == W::default()).then_some(value / scale_factor)?; - let value = ::from(value)?; - decimal_value_fits_precision(value, decimal_dtype).then_some(value) - }) -} - fn decimal_scale_factor(scale: i8) -> VortexResult where T: NativeDecimalType + CheckedMul, @@ -324,7 +341,6 @@ where Ok(buffer.freeze()) } -#[cold] fn primitive_to_decimal_cast_error(value: S, decimal_dtype: DecimalDType) -> VortexError where S: IntegerPType + ToI256, From 5bcf8888a5ef596a60cfb3d8d93b2bc964d2ab52 Mon Sep 17 00:00:00 2001 From: Matt Katz Date: Fri, 7 Aug 2026 16:01:48 -0700 Subject: [PATCH 6/8] polish primitive decimal casts Signed-off-by: Matt Katz --- .../src/arrays/primitive/compute/cast.rs | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/vortex-array/src/arrays/primitive/compute/cast.rs b/vortex-array/src/arrays/primitive/compute/cast.rs index ebda38185df..829583bee5d 100644 --- a/vortex-array/src/arrays/primitive/compute/cast.rs +++ b/vortex-array/src/arrays/primitive/compute/cast.rs @@ -231,7 +231,8 @@ where let scale_factor = decimal_scale_factor::(decimal_dtype.scale())?; cast_integer_values_to_decimal_buffer(values, decimal_dtype, valid_values, |value| { let value = ::from(value)?; - let value = (value % scale_factor == W::default()).then_some(value / scale_factor)?; + let quotient = value / scale_factor; + let value = (quotient * scale_factor == value).then_some(quotient)?; let value = ::from(value)?; decimal_value_fits_precision(value, decimal_dtype).then_some(value) }) @@ -252,6 +253,7 @@ where .map_err(|idx| primitive_to_decimal_cast_error(values[idx], decimal_dtype)) } +/// The smallest signed decimal physical type that represents every `ptype` value. fn primitive_decimal_carrier_type(ptype: PType) -> DecimalType { match ptype { PType::I8 => DecimalType::I8, @@ -265,6 +267,7 @@ fn primitive_decimal_carrier_type(ptype: PType) -> DecimalType { } } +/// Maximum decimal digits in `ptype`; scales at least this large can exactly rescale only zero. fn primitive_max_decimal_digits(ptype: PType) -> u8 { match ptype { PType::U8 | PType::I8 => 3, @@ -532,6 +535,7 @@ mod test { use vortex_buffer::BitBuffer; use vortex_buffer::buffer; use vortex_error::VortexError; + use vortex_error::VortexResult; use vortex_mask::Mask; use crate::ArrayRef; @@ -549,6 +553,7 @@ mod test { use crate::dtype::Nullability; use crate::dtype::PType; use crate::dtype::i256; + use crate::scalar::Scalar; use crate::validity::Validity; #[test] @@ -631,7 +636,7 @@ mod test { } #[test] - fn cast_integer_to_decimal_rescales() -> vortex_error::VortexResult<()> { + fn cast_integer_to_decimal_rescales() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); let decimal_dtype = DecimalDType::new(5, 2); let casted = PrimitiveArray::from_iter([42i32, -7]) @@ -649,7 +654,7 @@ mod test { } #[test] - fn cast_u64_to_decimal() -> vortex_error::VortexResult<()> { + fn cast_u64_to_decimal() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); let decimal_dtype = DecimalDType::new(20, 0); let casted = PrimitiveArray::from_iter([u64::MAX]) @@ -663,7 +668,23 @@ mod test { } #[test] - fn cast_u8_to_negative_scale_decimal() -> vortex_error::VortexResult<()> { + fn cast_integer_to_decimal_reports_scale_up_overflow() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let dtype = DType::Decimal(DecimalDType::new(38, 20), Nullability::NonNullable); + let expected = Scalar::primitive(u64::MAX, Nullability::NonNullable) + .cast(&dtype) + .unwrap_err(); + let casted = PrimitiveArray::from_iter([u64::MAX]) + .into_array() + .cast(dtype)?; + let actual = casted.execute::(&mut ctx).unwrap_err(); + + assert_eq!(actual.to_string(), expected.to_string()); + Ok(()) + } + + #[test] + fn cast_u8_to_negative_scale_decimal() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); let decimal_dtype = DecimalDType::new(2, -2); let casted = PrimitiveArray::from_iter([200u8]) @@ -677,7 +698,7 @@ mod test { } #[test] - fn cast_u64_to_negative_scale_decimal() -> vortex_error::VortexResult<()> { + fn cast_u64_to_negative_scale_decimal() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); let decimal_dtype = DecimalDType::new(1, -19); let casted = PrimitiveArray::from_iter([10_000_000_000_000_000_000u64]) @@ -691,7 +712,7 @@ mod test { } #[test] - fn cast_integer_to_i256_decimal() -> vortex_error::VortexResult<()> { + fn cast_integer_to_i256_decimal() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); let decimal_dtype = DecimalDType::new(39, 2); let casted = PrimitiveArray::from_iter([42i64]) @@ -705,7 +726,7 @@ mod test { } #[test] - fn cast_all_null_integer_to_decimal() -> vortex_error::VortexResult<()> { + fn cast_all_null_integer_to_decimal() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); let decimal_dtype = DecimalDType::new(39, 2); let casted = PrimitiveArray::new(buffer![i64::MAX, i64::MIN], Validity::AllInvalid) @@ -719,8 +740,7 @@ mod test { } #[test] - fn cast_integer_to_negative_scale_decimal_requires_exact_rescale() - -> vortex_error::VortexResult<()> { + fn cast_integer_to_negative_scale_decimal_requires_exact_rescale() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); let decimal_dtype = DecimalDType::new(3, -2); let casted = PrimitiveArray::from_iter([1_200i32, -500]) @@ -740,7 +760,7 @@ mod test { } #[test] - fn cast_zero_to_large_negative_scale_decimal() -> vortex_error::VortexResult<()> { + fn cast_zero_to_large_negative_scale_decimal() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); let decimal_dtype = DecimalDType::new(3, -128); let casted = PrimitiveArray::from_iter([0i32]) @@ -753,7 +773,7 @@ mod test { } #[test] - fn cast_integer_to_decimal_ignores_out_of_range_null_lanes() -> vortex_error::VortexResult<()> { + fn cast_integer_to_decimal_ignores_out_of_range_null_lanes() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); let decimal_dtype = DecimalDType::new(3, 1); let casted = PrimitiveArray::new(buffer![999i32, 42], Validity::from_iter([false, true])) @@ -770,7 +790,7 @@ mod test { } #[test] - fn cast_integer_to_decimal_checks_precision() -> vortex_error::VortexResult<()> { + fn cast_integer_to_decimal_checks_precision() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); let casted = PrimitiveArray::from_iter([100i32]) .into_array() @@ -785,7 +805,7 @@ mod test { } #[test] - fn cast_floating_primitive_to_decimal_fails() -> vortex_error::VortexResult<()> { + fn cast_floating_primitive_to_decimal_fails() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); let casted = PrimitiveArray::from_iter([1.0f64]) .into_array() @@ -866,7 +886,7 @@ mod test { /// Same-width integer cast where all values fit: should reinterpret the /// buffer without allocation (pointer identity). #[test] - fn cast_same_width_int_reinterprets_buffer() -> vortex_error::VortexResult<()> { + fn cast_same_width_int_reinterprets_buffer() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); let src = PrimitiveArray::from_iter([0u32, 10, 100]); let src_ptr = src.as_slice::().as_ptr(); @@ -899,7 +919,7 @@ mod test { /// All-null array cast between same-width types should succeed without /// touching the buffer contents. #[test] - fn cast_same_width_all_null() -> vortex_error::VortexResult<()> { + fn cast_same_width_all_null() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); let arr = PrimitiveArray::new(buffer![0xFFu8, 0xFF], Validity::AllInvalid); let casted = arr @@ -914,7 +934,7 @@ mod test { /// Same-width integer cast with nullable values: out-of-range nulls should /// not prevent the cast from succeeding. #[test] - fn cast_same_width_int_nullable_with_out_of_range_nulls() -> vortex_error::VortexResult<()> { + fn cast_same_width_int_nullable_with_out_of_range_nulls() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); // The null position holds u32::MAX which doesn't fit in i32, but it's // masked as invalid so the cast should still succeed via reinterpret. @@ -935,7 +955,7 @@ mod test { } #[test] - fn cast_u32_to_u8_with_out_of_range_nulls() -> vortex_error::VortexResult<()> { + fn cast_u32_to_u8_with_out_of_range_nulls() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); let arr = PrimitiveArray::new( buffer![1000u32, 10u32, 42u32], From 1971803d2fc179fcdd8d89e40957aed2733b01dd Mon Sep 17 00:00:00 2001 From: Matt Katz Date: Fri, 7 Aug 2026 16:12:58 -0700 Subject: [PATCH 7/8] reuse buffers for unscaled decimal casts Signed-off-by: Matt Katz --- vortex-array/benches/cast_primitive.rs | 17 ++ .../src/arrays/primitive/compute/cast.rs | 165 +++++++++++++++++- 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/vortex-array/benches/cast_primitive.rs b/vortex-array/benches/cast_primitive.rs index f3cb3c6ee5d..8dd6826bec8 100644 --- a/vortex-array/benches/cast_primitive.rs +++ b/vortex-array/benches/cast_primitive.rs @@ -123,3 +123,20 @@ fn cast_i32_to_decimal9_scale2(bencher: Bencher, n: usize) { .execute::(ctx) }); } + +/// Same-width scale-zero cast that validates then reuses the source values buffer. +#[divan::bench(args = SIZES)] +fn cast_i32_to_decimal9_scale0(bencher: Bencher, n: usize) { + let arr = + PrimitiveArray::from_iter((0..n).map(|value| i32::try_from(value).unwrap())).into_array(); + bencher + .with_inputs(|| (arr.clone(), SESSION.create_execution_ctx())) + .bench_refs(|(a, ctx)| { + a.cast(DType::Decimal( + DecimalDType::new(9, 0), + Nullability::NonNullable, + )) + .unwrap() + .execute::(ctx) + }); +} diff --git a/vortex-array/src/arrays/primitive/compute/cast.rs b/vortex-array/src/arrays/primitive/compute/cast.rs index 829583bee5d..bc10009a55b 100644 --- a/vortex-array/src/arrays/primitive/compute/cast.rs +++ b/vortex-array/src/arrays/primitive/compute/cast.rs @@ -40,6 +40,7 @@ use crate::expr::stats::StatsProvider; use crate::match_each_decimal_value_type; use crate::match_each_integer_ptype; use crate::match_each_native_ptype; +use crate::match_each_signed_integer_ptype; use crate::scalar::DecimalValue; use crate::scalar_fn::fns::cast::CastKernel; use crate::scalar_fn::fns::cast::CastReduce; @@ -138,9 +139,23 @@ fn cast_to_decimal( let validity = source_validity .clone() .cast_nullability(nullability, array.len(), ctx)?; - let valid_values = source_validity.execute_mask(array.len(), ctx)?; let values_type = DecimalType::smallest_decimal_value_type(&decimal_dtype); + if decimal_dtype.scale() == 0 + && signed_primitive_decimal_type(array.ptype()) == Some(values_type) + { + return match_each_signed_integer_ptype!(array.ptype(), |S| { + cast_unscaled_same_width_signed_integer_to_decimal::( + array, + decimal_dtype, + validity, + &source_validity, + ctx, + ) + }); + } + + let valid_values = source_validity.execute_mask(array.len(), ctx)?; match_each_integer_ptype!(array.ptype(), |S| { match_each_decimal_value_type!(values_type, |T| { cast_integer_values_to_decimal::(array, decimal_dtype, validity, &valid_values) @@ -148,6 +163,64 @@ fn cast_to_decimal( }) } +fn cast_unscaled_same_width_signed_integer_to_decimal( + array: ArrayView<'_, Primitive>, + decimal_dtype: DecimalDType, + validity: Validity, + source_validity: &Validity, + ctx: &mut ExecutionCtx, +) -> VortexResult +where + S: IntegerPType + NativeDecimalType + ToI256, +{ + let values = array.as_slice::(); + let target_dtype = DType::Decimal(decimal_dtype, Nullability::NonNullable); + if !cached_values_fit_in(array, &target_dtype).unwrap_or(false) { + let valid_values = source_validity.execute_mask(array.len(), ctx)?; + validate_unscaled_signed_integer_values_to_decimal(values, decimal_dtype, &valid_values) + .map_err(|idx| primitive_to_decimal_cast_error(values[idx], decimal_dtype))?; + } + + // SAFETY: `S::DECIMAL_TYPE` has the same physical representation as the source ptype, and + // either exact min/max statistics or the validation above prove every valid value fits. + Ok(unsafe { + DecimalArray::new_unchecked_handle( + array.buffer_handle().clone(), + S::DECIMAL_TYPE, + decimal_dtype, + validity, + ) + .into_array() + }) +} + +fn validate_unscaled_signed_integer_values_to_decimal( + values: &[S], + decimal_dtype: DecimalDType, + valid_values: &Mask, +) -> Result<(), usize> +where + S: NativeDecimalType, +{ + let fits = |value| decimal_value_fits_precision(value, decimal_dtype); + match valid_values { + Mask::AllTrue(_) => values + .iter() + .position(|&value| !fits(value)) + .map_or(Ok(()), Err), + Mask::AllFalse(_) => Ok(()), + Mask::Values(mask) => { + let mut first_failure = None; + mask.bit_buffer().for_each_set_index(|idx| { + if first_failure.is_none() && !fits(values[idx]) { + first_failure = Some(idx); + } + }); + first_failure.map_or(Ok(()), Err) + } + } +} + fn cast_integer_values_to_decimal( array: ArrayView<'_, Primitive>, decimal_dtype: DecimalDType, @@ -253,6 +326,20 @@ where .map_err(|idx| primitive_to_decimal_cast_error(values[idx], decimal_dtype)) } +/// Decimal physical type with the same representation, when `ptype` is signed. +fn signed_primitive_decimal_type(ptype: PType) -> Option { + match ptype { + PType::I8 => Some(DecimalType::I8), + PType::I16 => Some(DecimalType::I16), + PType::I32 => Some(DecimalType::I32), + PType::I64 => Some(DecimalType::I64), + PType::U8 | PType::U16 | PType::U32 | PType::U64 => None, + PType::F16 | PType::F32 | PType::F64 => { + unreachable!("floating primitives are rejected before selecting a decimal type") + } + } +} + /// The smallest signed decimal physical type that represents every `ptype` value. fn primitive_decimal_carrier_type(ptype: PType) -> DecimalType { match ptype { @@ -553,6 +640,7 @@ mod test { use crate::dtype::Nullability; use crate::dtype::PType; use crate::dtype::i256; + use crate::expr::stats::Stat; use crate::scalar::Scalar; use crate::validity::Validity; @@ -653,6 +741,81 @@ mod test { Ok(()) } + #[test] + fn cast_same_width_signed_integer_to_decimal_reuses_buffer() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let source = PrimitiveArray::from_iter([42i32, -7]); + let source_ptr = source.as_slice::().as_ptr(); + let casted = source + .into_array() + .cast(DType::Decimal( + DecimalDType::new(9, 0), + Nullability::NonNullable, + ))? + .execute::(&mut ctx)?; + + assert_eq!(casted.buffer::().as_ptr(), source_ptr); + assert_eq!(casted.buffer::().as_ref(), &[42, -7]); + Ok(()) + } + + #[test] + fn cast_same_width_signed_integer_to_decimal_reuses_buffer_with_cached_bounds() + -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let source = PrimitiveArray::from_iter([42i32, -7]); + let source_ptr = source.as_slice::().as_ptr(); + let source = source.into_array(); + source + .statistics() + .compute_all(&[Stat::Min, Stat::Max], &mut ctx)?; + let casted = source + .cast(DType::Decimal( + DecimalDType::new(9, 0), + Nullability::NonNullable, + ))? + .execute::(&mut ctx)?; + + assert_eq!(casted.buffer::().as_ptr(), source_ptr); + Ok(()) + } + + #[test] + fn cast_same_width_signed_integer_to_decimal_ignores_out_of_range_nulls() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let source = PrimitiveArray::new(buffer![i32::MAX, 42], Validity::from_iter([false, true])); + let source_ptr = source.as_slice::().as_ptr(); + let casted = source + .into_array() + .cast(DType::Decimal( + DecimalDType::new(9, 0), + Nullability::Nullable, + ))? + .execute::(&mut ctx)?; + + assert_eq!(casted.buffer::().as_ptr(), source_ptr); + assert_eq!( + casted.validity()?.execute_mask(casted.len(), &mut ctx)?, + Mask::from(BitBuffer::from(vec![false, true])) + ); + Ok(()) + } + + #[test] + fn cast_same_width_signed_integer_to_decimal_checks_precision() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let casted = PrimitiveArray::from_iter([i32::MAX]) + .into_array() + .cast(DType::Decimal( + DecimalDType::new(9, 0), + Nullability::NonNullable, + ))?; + + let error = casted.execute::(&mut ctx).unwrap_err(); + assert!(error.to_string().contains("does not fit in precision")); + Ok(()) + } + #[test] fn cast_u64_to_decimal() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); From cc85221c0c362095271e16e1d4c066048c96884f Mon Sep 17 00:00:00 2001 From: Matt Katz Date: Fri, 7 Aug 2026 16:14:43 -0700 Subject: [PATCH 8/8] stabilize decimal overflow error test Signed-off-by: Matt Katz --- vortex-array/src/arrays/primitive/compute/cast.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/vortex-array/src/arrays/primitive/compute/cast.rs b/vortex-array/src/arrays/primitive/compute/cast.rs index bc10009a55b..8f5a0f95fcd 100644 --- a/vortex-array/src/arrays/primitive/compute/cast.rs +++ b/vortex-array/src/arrays/primitive/compute/cast.rs @@ -641,7 +641,6 @@ mod test { use crate::dtype::PType; use crate::dtype::i256; use crate::expr::stats::Stat; - use crate::scalar::Scalar; use crate::validity::Validity; #[test] @@ -834,15 +833,16 @@ mod test { fn cast_integer_to_decimal_reports_scale_up_overflow() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Decimal(DecimalDType::new(38, 20), Nullability::NonNullable); - let expected = Scalar::primitive(u64::MAX, Nullability::NonNullable) - .cast(&dtype) - .unwrap_err(); let casted = PrimitiveArray::from_iter([u64::MAX]) .into_array() .cast(dtype)?; let actual = casted.execute::(&mut ctx).unwrap_err(); - assert_eq!(actual.to_string(), expected.to_string()); + assert!( + actual + .to_string() + .contains("does not fit in precision of decimal(38,20)") + ); Ok(()) }