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 15449e9447 fix: Correct two bugs when formatting decimal values
(#10869)
15449e9447 is described below
commit 15449e9447bc0b0a1548527d9f517134ba105bf4
Author: Neil Conway <[email protected]>
AuthorDate: Sat Aug 29 03:51:17 2026 -0400
fix: Correct two bugs when formatting decimal values (#10869)
# Which issue does this PR close?
- Closes #10866.
- Closes #10865.
# Rationale for this change
By default, decimal values are not validated against their type's
declared precision. Such out-of-precision values must therefore be
handled correctly through arrow-rs. `format_decimal_str` attempted to do
so by truncating the digit string to the declared precision, but that is
not correct: for example, the value `12345` in a `Decimal128(3, 1)`
context was formatted as `12.3`, i.e., it silently produced incorrect
values.
There are two principled ways to handle such values: format them in
their full precision or return an error. Returning an error is
impractical: (1) the formatting code doesn't have an easy way to surface
such errors (2) this would be inconsistent with how out-of-precision
decimals are handled throughout the rest of the Arrow ecosystem. Hence,
this PR arranges to format out-of-precision values without truncation;
the declared precision no longer affects formatting.
Along the way, fix a second bug: a `0` decimal value with a negative
scale was zero-padded; for example, formatting with scale `-2` resulted
in the string "000". That is not a legal number in JSON, and is also
just a surprising and likely unexpected behavior.
# What changes are included in this PR?
* Fix decimal formatting bugs as described above
* Add/update tests
# Are these changes tested?
Yes; new tests added.
# Are there any user-facing changes?
Yes, but only in corner-cases. Both scenarios are rare to begin with,
and the implemented behavior is unlikely to be what a user would desire.
`format_decimal_str` still takes a `precision` parameter that is now
unused. I've kept that in the function signature to avoid breaking a
public API.
---
arrow-cast/src/cast/mod.rs | 19 +++++++---
arrow-data/src/decimal.rs | 89 ++++++++++++++++++++++++++------------------
arrow-json/src/writer/mod.rs | 25 +++++++++++++
3 files changed, 92 insertions(+), 41 deletions(-)
diff --git a/arrow-cast/src/cast/mod.rs b/arrow-cast/src/cast/mod.rs
index 52733422ea..459b47ac1c 100644
--- a/arrow-cast/src/cast/mod.rs
+++ b/arrow-cast/src/cast/mod.rs
@@ -11942,6 +11942,19 @@ mod tests {
assert_eq!(1672531200000000000, c.value(0));
}
+ #[test]
+ fn test_cast_out_of_precision_decimal_to_string() {
+ // Decimal values are not validated against their type's declared
+ // precision by default. Check that out-of-precision values are
rendered
+ // in full when cast to strings, rather than truncated to the declared
+ // precision (https://github.com/apache/arrow-rs/issues/10866)
+ let array = create_decimal128_array(vec![Some(123456789),
Some(-123456789)], 7, 3).unwrap();
+ let b = cast(&array, &DataType::Utf8).unwrap();
+ let c = b.as_string::<i32>();
+ assert_eq!("123456.789", c.value(0));
+ assert_eq!("-123456.789", c.value(1));
+ }
+
#[test]
fn test_cast_decimal_to_string() {
assert!(can_cast_types(
@@ -11970,9 +11983,7 @@ mod tests {
assert_eq!("-3123.456", c.value(3));
assert_eq!("0.000", c.value(4));
assert_eq!("0.123", c.value(5));
- assert_eq!("1234.567", c.value(6));
- assert_eq!("-1234.567", c.value(7));
- assert!(c.is_null(8));
+ assert!(c.is_null(6));
};
}
@@ -12003,8 +12014,6 @@ mod tests {
Some(-3123456),
Some(0),
Some(123),
- Some(123456789),
- Some(-123456789),
None,
];
let array64: Vec<Option<i64>> = array32.iter().map(|num| num.map(|x| x
as i64)).collect();
diff --git a/arrow-data/src/decimal.rs b/arrow-data/src/decimal.rs
index de692a3418..c03dfe2f07 100644
--- a/arrow-data/src/decimal.rs
+++ b/arrow-data/src/decimal.rs
@@ -932,8 +932,7 @@ pub fn validate_decimal32_precision(
)));
}
if value > MAX_DECIMAL32_FOR_EACH_PRECISION[precision as usize] {
- let unscaled_value =
- format_decimal_str_internal(&value.to_string(), precision.into(),
scale, false);
+ let unscaled_value = format_decimal_str_internal(&value.to_string(),
scale);
let unscale_max_value = format_decimal_str(
&MAX_DECIMAL32_FOR_EACH_PRECISION[precision as usize].to_string(),
precision.into(),
@@ -943,8 +942,7 @@ pub fn validate_decimal32_precision(
"{unscaled_value} is too large to store in a Decimal32 of
precision {precision}. Max is {unscale_max_value}"
)))
} else if value < MIN_DECIMAL32_FOR_EACH_PRECISION[precision as usize] {
- let unscaled_value =
- format_decimal_str_internal(&value.to_string(), precision.into(),
scale, false);
+ let unscaled_value = format_decimal_str_internal(&value.to_string(),
scale);
let unscale_min_value = format_decimal_str(
&MIN_DECIMAL32_FOR_EACH_PRECISION[precision as usize].to_string(),
precision.into(),
@@ -985,8 +983,7 @@ pub fn validate_decimal64_precision(
)));
}
if value > MAX_DECIMAL64_FOR_EACH_PRECISION[precision as usize] {
- let unscaled_value =
- format_decimal_str_internal(&value.to_string(), precision.into(),
scale, false);
+ let unscaled_value = format_decimal_str_internal(&value.to_string(),
scale);
let unscaled_max_value = format_decimal_str(
&MAX_DECIMAL64_FOR_EACH_PRECISION[precision as usize].to_string(),
precision.into(),
@@ -996,8 +993,7 @@ pub fn validate_decimal64_precision(
"{unscaled_value} is too large to store in a Decimal64 of
precision {precision}. Max is {unscaled_max_value}"
)))
} else if value < MIN_DECIMAL64_FOR_EACH_PRECISION[precision as usize] {
- let unscaled_value =
- format_decimal_str_internal(&value.to_string(), precision.into(),
scale, false);
+ let unscaled_value = format_decimal_str_internal(&value.to_string(),
scale);
let unscaled_min_value = format_decimal_str(
&MIN_DECIMAL64_FOR_EACH_PRECISION[precision as usize].to_string(),
precision.into(),
@@ -1034,8 +1030,7 @@ pub fn validate_decimal_precision(value: i128, precision:
u8, scale: i8) -> Resu
)));
}
if value > MAX_DECIMAL128_FOR_EACH_PRECISION[precision as usize] {
- let unscaled_value =
- format_decimal_str_internal(&value.to_string(), precision.into(),
scale, false);
+ let unscaled_value = format_decimal_str_internal(&value.to_string(),
scale);
let unscaled_max_value = format_decimal_str(
&MAX_DECIMAL128_FOR_EACH_PRECISION[precision as usize].to_string(),
precision.into(),
@@ -1045,8 +1040,7 @@ pub fn validate_decimal_precision(value: i128, precision:
u8, scale: i8) -> Resu
"{unscaled_value} is too large to store in a Decimal128 of
precision {precision}. Max is {unscaled_max_value}"
)))
} else if value < MIN_DECIMAL128_FOR_EACH_PRECISION[precision as usize] {
- let unscaled_value =
- format_decimal_str_internal(&value.to_string(), precision.into(),
scale, false);
+ let unscaled_value = format_decimal_str_internal(&value.to_string(),
scale);
let unscaled_min_value = format_decimal_str(
&MIN_DECIMAL128_FOR_EACH_PRECISION[precision as usize].to_string(),
precision.into(),
@@ -1088,8 +1082,7 @@ pub fn validate_decimal256_precision(
}
if value > MAX_DECIMAL256_FOR_EACH_PRECISION[precision as usize] {
- let unscaled_value =
- format_decimal_str_internal(&value.to_string(), precision.into(),
scale, false);
+ let unscaled_value = format_decimal_str_internal(&value.to_string(),
scale);
let unscaled_max_value = format_decimal_str(
&MAX_DECIMAL256_FOR_EACH_PRECISION[precision as usize].to_string(),
precision.into(),
@@ -1099,8 +1092,7 @@ pub fn validate_decimal256_precision(
"{unscaled_value} is too large to store in a Decimal256 of
precision {precision}. Max is {unscaled_max_value}"
)))
} else if value < MIN_DECIMAL256_FOR_EACH_PRECISION[precision as usize] {
- let unscaled_value =
- format_decimal_str_internal(&value.to_string(), precision.into(),
scale, false);
+ let unscaled_value = format_decimal_str_internal(&value.to_string(),
scale);
let unscaled_min_value = format_decimal_str(
&MIN_DECIMAL256_FOR_EACH_PRECISION[precision as usize].to_string(),
precision.into(),
@@ -1126,36 +1118,31 @@ pub fn is_validate_decimal256_precision(value: i256,
precision: u8) -> bool {
}
#[inline]
-/// Formats a decimal string given the precision and scale.
-pub fn format_decimal_str(value_str: &str, precision: usize, scale: i8) ->
String {
- format_decimal_str_internal(value_str, precision, scale, true)
+/// Formats a decimal string given the scale.
+///
+/// The value is always formatted in full: `_precision` is unused and retained
+/// only for API compatibility.
+pub fn format_decimal_str(value_str: &str, _precision: usize, scale: i8) ->
String {
+ format_decimal_str_internal(value_str, scale)
}
-// Format a decimal string given the precision and scale.
-// If `safe_decimal` is true, the function will ensure that the output string
-// does not exceed the specified precision.
-fn format_decimal_str_internal(
- value_str: &str,
- precision: usize,
- scale: i8,
- safe_decimal: bool,
-) -> String {
+// Format a decimal string given the scale.
+fn format_decimal_str_internal(value_str: &str, scale: i8) -> String {
let (sign, rest) = match value_str.strip_prefix('-') {
Some(stripped) => ("-", stripped),
None => ("", value_str),
};
- let bound = if safe_decimal {
- precision.min(rest.len()) + sign.len()
- } else {
- value_str.len()
- };
- let value_str = &value_str[0..bound];
if scale == 0 {
value_str.to_string()
} else if scale < 0 {
- let padding = value_str.len() + scale.unsigned_abs() as usize;
- format!("{value_str:0<padding$}")
+ if rest == "0" {
+ // Zero must not be zero-padded ("000" is not a valid number)
+ value_str.to_string()
+ } else {
+ let padding = value_str.len() + scale.unsigned_abs() as usize;
+ format!("{value_str:0<padding$}")
+ }
} else if rest.len() > scale as usize {
// Decimal separator is in the middle of the string
let (whole, decimal) = value_str.split_at(value_str.len() - scale as
usize);
@@ -1165,3 +1152,33 @@ fn format_decimal_str_internal(
format!("{}0.{:0>width$}", sign, rest, width = scale as usize)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_format_decimal_str() {
+ assert_eq!(format_decimal_str("12345", 7, 0), "12345");
+ assert_eq!(format_decimal_str("12345", 7, 2), "123.45");
+ assert_eq!(format_decimal_str("-12345", 7, 2), "-123.45");
+ assert_eq!(format_decimal_str("45", 7, 3), "0.045");
+ assert_eq!(format_decimal_str("-45", 7, 3), "-0.045");
+ assert_eq!(format_decimal_str("0", 7, 2), "0.00");
+ assert_eq!(format_decimal_str("12345", 7, 5), "0.12345");
+
+ // negative scales multiply the value by 10^|scale|
+ assert_eq!(format_decimal_str("12", 7, -2), "1200");
+ assert_eq!(format_decimal_str("-12", 7, -2), "-1200");
+ // a zero value is not padded
+ // https://github.com/apache/arrow-rs/issues/10865
+ assert_eq!(format_decimal_str("0", 7, -2), "0");
+
+ // values exceeding the declared precision are still formatted in full
+ // https://github.com/apache/arrow-rs/issues/10866
+ assert_eq!(format_decimal_str("12345", 3, 1), "1234.5");
+ assert_eq!(format_decimal_str("12345", 3, 3), "12.345");
+ assert_eq!(format_decimal_str("-12345", 3, 3), "-12.345");
+ assert_eq!(format_decimal_str("12345", 2, 4), "1.2345");
+ }
+}
diff --git a/arrow-json/src/writer/mod.rs b/arrow-json/src/writer/mod.rs
index 8f1eefe548..6937d2d498 100644
--- a/arrow-json/src/writer/mod.rs
+++ b/arrow-json/src/writer/mod.rs
@@ -2216,6 +2216,31 @@ mod tests {
);
}
+ #[test]
+ fn test_decimal_encoder_negative_scale() {
+ // https://github.com/apache/arrow-rs/issues/10865
+ let array = Decimal128Array::from_iter([Some(0), Some(12), Some(-12)])
+ .with_precision_and_scale(10, -2)
+ .unwrap();
+ let field = Arc::new(Field::new("decimal", array.data_type().clone(),
true));
+ let schema = Schema::new(vec![field]);
+ let batch = RecordBatch::try_new(Arc::new(schema),
vec![Arc::new(array)]).unwrap();
+
+ let mut buf = Vec::new();
+ {
+ let mut writer = LineDelimitedWriter::new(&mut buf);
+ writer.write_batches(&[&batch]).unwrap();
+ }
+
+ assert_json_eq(
+ &buf,
+ r#"{"decimal":0}
+{"decimal":1200}
+{"decimal":-1200}
+"#,
+ );
+ }
+
#[test]
fn write_structs_as_list() {
let schema = Schema::new(vec![