This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 619843d1 [core] Preserve decimal predicates and equivalent Arrow match
keys (#1150)
619843d1 is described below
commit 619843d194eed364788d1d8e530670ef1ba21305
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 18 08:46:58 2026 +0800
[core] Preserve decimal predicates and equivalent Arrow match keys (#1150)
---
bindings/python/src/predicate.rs | 133 ++++++++-
bindings/python/tests/test_read.py | 33 ++-
crates/paimon/src/table/key_normalization.rs | 157 +++++++++-
.../paimon/src/table/key_normalization/numeric.rs | 329 +++++++++++++++++++++
crates/paimon/src/table/table_update.rs | 3 +
crates/paimon/src/table/write_batch_normalize.rs | 100 ++++++-
6 files changed, 719 insertions(+), 36 deletions(-)
diff --git a/bindings/python/src/predicate.rs b/bindings/python/src/predicate.rs
index df15470b..85d066e5 100644
--- a/bindings/python/src/predicate.rs
+++ b/bindings/python/src/predicate.rs
@@ -302,6 +302,64 @@ fn decimal_datum(value: &Bound<'_, PyAny>, dec:
&DecimalType) -> PyResult<Datum>
})
}
+/// Predicate literals are values to compare, not values to store in the
column.
+/// Java Decimal.compareTo permits a different precision and scale. Keep the
+/// strict column conversion above for partition writes and other stored
values.
+fn predicate_datum(value: &Bound<'_, PyAny>, data_type: &DataType) ->
PyResult<Datum> {
+ let DataType::Decimal(column) = data_type else {
+ return py_to_datum(value, data_type);
+ };
+ if let Ok(value) = decimal_datum(value, column) {
+ return Ok(value);
+ }
+ let decimal_class = value.py().import("decimal")?.getattr("Decimal")?;
+ let value = if value.is_instance_of::<PyInt>() &&
!value.is_instance_of::<PyBool>() {
+ decimal_class.call1((value,))?
+ } else if value.is_instance(&decimal_class)? {
+ value.clone()
+ } else {
+ return decimal_datum(value, column);
+ };
+ let tuple = value.call_method0("as_tuple")?;
+ let sign: u8 = tuple.getattr("sign")?.extract()?;
+ let mut exponent: i64 = tuple
+ .getattr("exponent")?
+ .extract()
+ .map_err(|_| PyValueError::new_err("expected a finite decimal
predicate literal"))?;
+ let digits: Vec<u8> = tuple.getattr("digits")?.extract()?;
+ let start = digits.iter().position(|d| *d != 0).unwrap_or(digits.len());
+ let mut digits = &digits[start..];
+ if digits.is_empty() {
+ return Ok(Datum::Decimal {
+ unscaled: 0,
+ precision: column.precision(),
+ scale: column.scale(),
+ });
+ }
+ while digits.last() == Some(&0) {
+ digits = &digits[..digits.len() - 1];
+ exponent = exponent
+ .checked_add(1)
+ .ok_or_else(|| PyValueError::new_err("decimal exponent
overflow"))?;
+ }
+ let scale = exponent.checked_neg().unwrap_or(i64::MAX).max(0);
+ let precision = (digits.len() as i64)
+ .saturating_add(exponent.max(0))
+ .max(scale);
+ if precision > 38 || scale > 38 {
+ return Err(PyValueError::new_err(
+ "decimal predicate literal exceeds Java DECIMAL precision",
+ ));
+ }
+ let magnitude = digits.iter().fold(0_i128, |v, d| v * 10 + i128::from(*d))
+ * 10_i128.pow(exponent.max(0) as u32);
+ Ok(Datum::Decimal {
+ unscaled: if sign == 1 { -magnitude } else { magnitude },
+ precision: precision as u32,
+ scale: scale as u32,
+ })
+}
+
/// Rescale a finite `decimal.Decimal` to `scale` exactly via `as_tuple()`
/// (sign, digits, exponent), avoiding Decimal arithmetic whose context
/// precision could silently round.
@@ -494,7 +552,7 @@ fn leaf_to_predicate(
continue;
}
out.push(Some(
- py_to_datum(&item, &data_type)
+ predicate_datum(&item, &data_type)
.map_err(|e| with_field_context(e, &field,
&data_type))?,
));
}
@@ -1540,6 +1598,79 @@ value = datetime.datetime(2024, 1, 1,
tzinfo=FloatingTz())",
DataType::Decimal(paimon::spec::DecimalType::new(precision,
scale).unwrap())
}
+ #[test]
+ fn decimal_predicate_literals_preserve_comparison_value() {
+ Python::attach(|py| {
+ for (text, unscaled, precision, scale) in [
+ ("1.235", 1235, 4, 3),
+ ("-1.23500000000000000000000000000000000000000", -1235, 4, 3),
+ ("123456789.12", 12345678912, 11, 2),
+ ("1E-38", 1, 38, 38),
+ ("1E+37", 10_i128.pow(37), 38, 0),
+ ("0E+100", 0, 10, 2),
+ ] {
+ let value = py_decimal(py, text);
+ assert_eq!(
+ predicate_datum(&value, &decimal_type(10, 2)).unwrap(),
+ Datum::Decimal {
+ unscaled,
+ precision,
+ scale
+ },
+ "{text}"
+ );
+ // A comparison literal must not relax stored partition values.
+ assert!(py_to_datum(&value, &decimal_type(10, 2)).is_err(),
"{text}");
+ }
+ let value = 123456789_i64.into_pyobject(py).unwrap();
+ assert_eq!(
+ predicate_datum(&value, &decimal_type(10, 2)).unwrap(),
+ Datum::Decimal {
+ unscaled: 123456789,
+ precision: 9,
+ scale: 0
+ }
+ );
+ for text in ["1E-39", "1E+38"] {
+ assert!(predicate_datum(&py_decimal(py, text),
&decimal_type(10, 2)).is_err());
+ }
+ });
+ }
+
+ #[test]
+ fn decimal_comparison_and_membership_use_literal_scale() {
+ Python::attach(|py| {
+ let fields = vec![DataField::new(0, "amount".into(),
decimal_type(10, 2))];
+ for method in [
+ "equal",
+ "notEqual",
+ "lessThan",
+ "lessOrEqual",
+ "greaterThan",
+ "greaterOrEqual",
+ "in",
+ "notIn",
+ ] {
+ let node = leaf_dict(py, method, "amount", &[]);
+ let literals = PyList::empty(py);
+ literals.append(py_decimal(py, "1.235")).unwrap();
+ node.set_item("literals", literals).unwrap();
+ let predicate = dict_to_predicate(&node, &fields,
true).unwrap();
+ let Predicate::Leaf { literals, .. } = predicate else {
+ panic!("expected leaf")
+ };
+ assert_eq!(
+ literals,
+ vec![Datum::Decimal {
+ unscaled: 1235,
+ precision: 4,
+ scale: 3
+ }]
+ );
+ }
+ });
+ }
+
#[test]
fn decimal_field_accepts_exact_scale() {
Python::attach(|py| {
diff --git a/bindings/python/tests/test_read.py
b/bindings/python/tests/test_read.py
index 2183942a..4e7d199d 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -1032,17 +1032,38 @@ def test_filter_decimal_literal_prunes_and_reads():
assert t.column("id").to_pylist() == [1]
-def test_filter_decimal_rejects_float_and_rounding():
- from decimal import Decimal
-
+def test_filter_decimal_rejects_float():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_temporal_table(warehouse)
b = table.new_read_builder()
with pytest.raises(ValueError):
b.with_filter({"method": "equal", "field": "dec", "literals":
[12.34]})
- with pytest.raises(ValueError):
- b.with_filter(
- {"method": "equal", "field": "dec", "literals":
[Decimal("0.005")]})
+
+
[email protected]("method,literal,expected_ids", [
+ ("equal", "0.005", []),
+ ("equal", "12.345", []),
+ ("equal", "12.340", [1]),
+ ("lessThan", "12.345", [1]),
+ ("greaterThan", "56.775", [2]),
+ ("lessThan", "100000000.00", [1, 2]),
+ ("greaterThan", "-100000000.00", [1, 2]),
+])
+def test_filter_decimal_preserves_literal_precision_and_scale(
+ method, literal, expected_ids):
+ from decimal import Decimal
+
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_temporal_table(warehouse)
+ builder = table.new_read_builder().with_filter(
+ {"method": method, "field": "dec", "literals": [Decimal(literal)]})
+ filtered_splits = builder.new_scan().plan().splits()
+ all_splits = table.new_read_builder().new_scan().plan().splits()
+ # Check both file-stat pruning and row filtering without file pruning.
+ for splits in (filtered_splits, all_splits):
+ ids = [row["id"] for batch in builder.new_read().read(splits)
+ for row in batch.to_pylist()]
+ assert sorted(ids) == expected_ids
def test_filter_unknown_field_raises():
diff --git a/crates/paimon/src/table/key_normalization.rs
b/crates/paimon/src/table/key_normalization.rs
index 50b78dc8..16fcef2f 100644
--- a/crates/paimon/src/table/key_normalization.rs
+++ b/crates/paimon/src/table/key_normalization.rs
@@ -23,10 +23,13 @@ use arrow_schema::DataType;
use super::update_input::{cast_update_value, decode_dictionary, CastMode};
use super::write_batch_normalize::normalize_write_array;
+mod numeric;
+
/// Unify Arrow representations without changing key identity. Dictionary
-/// indices are batch-local; integer widths, decimal scales and temporal units
-/// may differ only when conversion is exact. Callers validate the logical type
-/// families and decide whether NULL keys match. Payload write rules are
separate.
+/// indices are batch-local; numeric widths, decimal scales and temporal units
+/// may differ only when conversion is exact. Binary keys compare their bytes.
+/// Callers validate logical type families and decide whether NULL keys match.
+/// Payload write rules are separate.
pub(super) fn normalize_match_key(
array: &ArrayRef,
expected: &DataType,
@@ -36,19 +39,54 @@ pub(super) fn normalize_match_key(
return Ok(new_null_array(expected, decoded.len()));
}
let source = decoded.data_type();
- let exact_family = source.is_integer() && expected.is_integer()
- || source.is_decimal() && expected.is_decimal()
- || matches!(
- (source, expected),
- (
- DataType::Date32 | DataType::Date64,
- DataType::Date32 | DataType::Date64
- ) | (
- DataType::Time32(_) | DataType::Time64(_),
- DataType::Time32(_) | DataType::Time64(_)
- )
+ if source == expected {
+ return Ok(decoded);
+ }
+ if source.is_numeric() && expected.is_numeric() {
+ return numeric::normalize(&decoded, expected);
+ }
+ let binary = |t: &DataType| {
+ matches!(
+ t,
+ DataType::Binary | DataType::LargeBinary |
DataType::FixedSizeBinary(_)
)
- || matches!((source, expected), (DataType::Timestamp(_, from),
DataType::Timestamp(_, to))
+ };
+ if binary(source) && binary(expected) {
+ if source == &DataType::LargeBinary && expected == &DataType::Binary {
+ return super::write_batch_normalize::normalize_large_binary(
+ decoded.as_any().downcast_ref().unwrap(),
+ );
+ }
+ // Key identity is its bytes, independently of Arrow offset width.
+ // Ordinary append validation still sees the original source array.
+ let values = if matches!(source, DataType::FixedSizeBinary(_)) {
+ normalize_write_array(&decoded, &DataType::Binary)?
+ } else {
+ decoded.clone()
+ };
+ return arrow_cast::cast_with_options(
+ values.as_ref(),
+ expected,
+ &arrow_cast::CastOptions {
+ safe: false,
+ ..Default::default()
+ },
+ )
+ .map_err(|error| crate::Error::DataInvalid {
+ message: error.to_string(),
+ source: None,
+ });
+ }
+ let exact_family = matches!(
+ (source, expected),
+ (
+ DataType::Date32 | DataType::Date64,
+ DataType::Date32 | DataType::Date64
+ ) | (
+ DataType::Time32(_) | DataType::Time64(_),
+ DataType::Time32(_) | DataType::Time64(_)
+ )
+ ) || matches!((source, expected), (DataType::Timestamp(_, from),
DataType::Timestamp(_, to))
if from.is_some() == to.is_some());
if exact_family {
cast_update_value(&decoded, expected, CastMode::Assignment)
@@ -62,7 +100,8 @@ mod tests {
use super::*;
use arrow_array::types::Int8Type;
use arrow_array::{
- Array, Decimal128Array, DictionaryArray, Int32Array, Int64Array,
Int8Array, StringArray,
+ Array, BinaryArray, Decimal128Array, DictionaryArray,
FixedSizeBinaryArray, Float32Array,
+ Float64Array, Int32Array, Int64Array, Int8Array, LargeBinaryArray,
StringArray,
TimestampMicrosecondArray, TimestampMillisecondArray,
};
use arrow_schema::TimeUnit;
@@ -102,6 +141,92 @@ mod tests {
assert!(normalize_match_key(&text, &DataType::Int32).is_err());
}
+ #[test]
+ fn floating_keys_change_width_only_when_identity_is_preserved() {
+ let input: ArrayRef = Arc::new(Float64Array::from(vec![
+ Some(99.0),
+ Some(1.5),
+ None,
+ Some(-2.25),
+ ]));
+ let input = input.slice(1, 3);
+ let narrow = normalize_match_key(&input, &DataType::Float32).unwrap();
+ assert_eq!(
+ narrow.as_any().downcast_ref::<Float32Array>().unwrap(),
+ &Float32Array::from(vec![Some(1.5), None, Some(-2.25)])
+ );
+ assert_eq!(
+ normalize_match_key(&narrow, &DataType::Float64)
+ .unwrap()
+ .to_data(),
+ input.to_data()
+ );
+ for value in [1.00000001, f64::MAX, f64::MIN_POSITIVE] {
+ let lossy: ArrayRef = Arc::new(Float64Array::from(vec![value]));
+ assert!(normalize_match_key(&lossy, &DataType::Float32).is_err());
+ }
+ let dictionary: ArrayRef = Arc::new(
+ DictionaryArray::<Int8Type>::try_new(
+ Int8Array::from(vec![Some(0), None, Some(1), Some(0)]),
+ Arc::new(Float32Array::from(vec![Some(1.5), None])),
+ )
+ .unwrap(),
+ );
+ let decoded = normalize_match_key(&dictionary,
&DataType::Float64).unwrap();
+ assert_eq!(
+ decoded.as_any().downcast_ref::<Float64Array>().unwrap(),
+ &Float64Array::from(vec![Some(1.5), None, None, Some(1.5)])
+ );
+ }
+
+ #[test]
+ fn binary_keys_compare_bytes_across_offsets_and_dictionaries() {
+ let expected =
+ BinaryArray::from(vec![Some(b"\xff\0".as_slice()), None,
Some(b"".as_slice())]);
+ let large: ArrayRef = Arc::new(LargeBinaryArray::from(vec![
+ Some(b"padding".as_slice()),
+ Some(b"\xff\0".as_slice()),
+ None,
+ Some(b"".as_slice()),
+ ]));
+ let output = normalize_match_key(&large.slice(1, 3),
&DataType::Binary).unwrap();
+ assert_eq!(
+ output.as_any().downcast_ref::<BinaryArray>().unwrap(),
+ &expected
+ );
+ let restored = normalize_match_key(&output,
&DataType::LargeBinary).unwrap();
+ assert_eq!(restored.to_data(), large.slice(1, 3).to_data());
+
+ let fixed: ArrayRef = Arc::new(
+ FixedSizeBinaryArray::try_from_sparse_iter_with_size(
+ [Some(b"ab".as_slice()), None,
Some(b"cd".as_slice())].into_iter(),
+ 2,
+ )
+ .unwrap(),
+ );
+ for target in [DataType::Binary, DataType::LargeBinary] {
+ let normalized = normalize_match_key(&fixed.slice(1, 2),
&target).unwrap();
+ assert_eq!(
+ normalized.to_data(),
+ arrow_cast::cast(&fixed.slice(1, 2), &target)
+ .unwrap()
+ .to_data()
+ );
+ }
+ let dictionary: ArrayRef = Arc::new(
+ DictionaryArray::<Int8Type>::try_new(
+ Int8Array::from(vec![Some(0), None, Some(1)]),
+ Arc::new(LargeBinaryArray::from(vec![Some(b"ab".as_slice()),
None])),
+ )
+ .unwrap(),
+ );
+ let output = normalize_match_key(&dictionary,
&DataType::Binary).unwrap();
+ assert_eq!(
+ output.as_any().downcast_ref::<BinaryArray>().unwrap(),
+ &BinaryArray::from(vec![Some(b"ab".as_slice()), None, None])
+ );
+ }
+
#[test]
fn decimal_keys_cannot_round_into_existing_keys() {
let array = |value| {
diff --git a/crates/paimon/src/table/key_normalization/numeric.rs
b/crates/paimon/src/table/key_normalization/numeric.rs
new file mode 100644
index 00000000..5b49bd2f
--- /dev/null
+++ b/crates/paimon/src/table/key_normalization/numeric.rs
@@ -0,0 +1,329 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Exact numeric equality for external Arrow match keys. Assignment casts may
+//! round; a matching key must retain its mathematical value across families.
+
+use std::sync::Arc;
+
+use arrow_array::{cast::AsArray, Array, ArrayRef, Decimal128Array,
Float32Array, Float64Array};
+use arrow_buffer::i256;
+use arrow_schema::DataType;
+
+use super::super::update_input::{cast_update_value, CastMode};
+
+fn invalid(message: impl Into<String>) -> crate::Error {
+ crate::Error::DataInvalid {
+ message: message.into(),
+ source: None,
+ }
+}
+
+pub(super) fn normalize(array: &ArrayRef, target: &DataType) ->
crate::Result<ArrayRef> {
+ let source = array.data_type();
+ if source.is_floating() {
+ if let DataType::Decimal128(precision, scale) = target {
+ let values = arrow_cast::cast(array, &DataType::Float64)
+ .map_err(|error| invalid(error.to_string()))?;
+ let values = values
+ .as_primitive::<arrow_array::types::Float64Type>()
+ .iter()
+ .map(|value| {
+ value
+ .map(|value| {
+ let coefficient = float_coefficient(value, *scale)
+ .and_then(i256::to_i128)
+ .filter(|value| {
+ value.unsigned_abs() <
10_u128.pow(u32::from(*precision))
+ })
+ .ok_or_else(|| {
+ invalid("Floating match key is not an
exact DECIMAL value")
+ })?;
+ Ok(coefficient)
+ })
+ .transpose()
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+ return Decimal128Array::from(values)
+ .with_precision_and_scale(*precision, *scale)
+ .map(|array| Arc::new(array) as ArrayRef)
+ .map_err(|error| invalid(error.to_string()));
+ }
+ }
+ if source.is_decimal() {
+ if target.is_floating() {
+ return decimal_to_float(array, target);
+ }
+ if target.is_integer() {
+ // Canonicalize first to avoid scale arithmetic overflow in generic
+ // Arrow integer casts, including external Decimal256 zero values.
+ let integral =
+ cast_update_value(array, &DataType::Decimal128(38, 0),
CastMode::Assignment)?;
+ return cast_update_value(&integral, target, CastMode::Assignment);
+ }
+ }
+ if source.is_floating() || target.is_floating() {
+ // Unlike assignment's consecutive-integer range, identity only needs
+ // an exact round trip (e.g. 2^53 + 2 is a valid DOUBLE key).
+ let options = arrow_cast::CastOptions {
+ safe: false,
+ ..Default::default()
+ };
+ let converted = arrow_cast::cast_with_options(array.as_ref(), target,
&options)
+ .map_err(|error| invalid(error.to_string()))?;
+ let restored = arrow_cast::cast_with_options(converted.as_ref(),
source, &options)
+ .map_err(|error| invalid(error.to_string()))?;
+ if restored.to_data() != array.to_data() {
+ return Err(invalid(
+ "Numeric match key conversion would change key identity",
+ ));
+ }
+ return Ok(converted);
+ }
+ cast_update_value(array, target, CastMode::Assignment)
+}
+
+fn decimal_to_float(array: &ArrayRef, target: &DataType) ->
crate::Result<ArrayRef> {
+ let scale = match array.data_type() {
+ DataType::Decimal32(_, s)
+ | DataType::Decimal64(_, s)
+ | DataType::Decimal128(_, s)
+ | DataType::Decimal256(_, s) => *s,
+ _ => unreachable!("validated DECIMAL key"),
+ };
+ let values = (0..array.len())
+ .map(|row| {
+ if array.is_null(row) {
+ return Ok(None);
+ }
+ let coefficient = match array.data_type() {
+ DataType::Decimal32(..) => i256::from_i128(i128::from(
+ array
+ .as_primitive::<arrow_array::types::Decimal32Type>()
+ .value(row),
+ )),
+ DataType::Decimal64(..) => i256::from_i128(i128::from(
+ array
+ .as_primitive::<arrow_array::types::Decimal64Type>()
+ .value(row),
+ )),
+ DataType::Decimal128(..) => i256::from_i128(
+ array
+ .as_primitive::<arrow_array::types::Decimal128Type>()
+ .value(row),
+ ),
+ DataType::Decimal256(..) => array
+ .as_primitive::<arrow_array::types::Decimal256Type>()
+ .value(row),
+ _ => unreachable!("validated DECIMAL key"),
+ };
+ let text = format!("{coefficient}e{}", -i16::from(scale));
+ let value = if target == &DataType::Float32 {
+ f64::from(
+ text.parse::<f32>()
+ .map_err(|error| invalid(error.to_string()))?,
+ )
+ } else {
+ text.parse::<f64>()
+ .map_err(|error| invalid(error.to_string()))?
+ };
+ if float_coefficient(value, scale) != Some(coefficient) {
+ return Err(invalid("DECIMAL match key is not an exact floating
value"));
+ }
+ Ok(Some(value))
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+ Ok(if target == &DataType::Float32 {
+ Arc::new(Float32Array::from_iter(
+ values.into_iter().map(|value| value.map(|v| v as f32)),
+ )) as ArrayRef
+ } else {
+ Arc::new(Float64Array::from(values)) as ArrayRef
+ })
+}
+
+/// Return the exact decimal coefficient of a binary float at the given scale.
+/// Check divisibility before multiplying, so a fractional key cannot round
+/// into an existing row. All work is bounded by the Arrow coefficient width.
+fn float_coefficient(value: f64, scale: i8) -> Option<i256> {
+ if !value.is_finite() {
+ return None;
+ }
+ if value == 0.0 {
+ return Some(i256::ZERO);
+ }
+ let bits = value.abs().to_bits();
+ let raw_exponent = ((bits >> 52) & 0x7ff) as i32;
+ let mut mantissa = bits & ((1_u64 << 52) - 1);
+ let mut exponent = if raw_exponent == 0 {
+ -1074
+ } else {
+ mantissa |= 1_u64 << 52;
+ raw_exponent - 1023 - 52
+ };
+ let zeros = mantissa.trailing_zeros();
+ mantissa >>= zeros;
+ exponent += zeros as i32 + i32::from(scale);
+ if exponent < 0 {
+ return None;
+ }
+ let five = if scale < 0 {
+ for _ in 0..-i16::from(scale) {
+ if !mantissa.is_multiple_of(5) {
+ return None;
+ }
+ mantissa /= 5;
+ }
+ i256::ONE
+ } else {
+ i256::from_i128(5).checked_pow(scale as u32)?
+ };
+ let coefficient = i256::from_i128(i128::from(mantissa))
+ .checked_mul(five)?
+ .checked_mul(i256::from_i128(2).checked_pow(exponent as u32)?)?;
+ if value < 0.0 {
+ coefficient.checked_neg()
+ } else {
+ Some(coefficient)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use arrow_array::{
+ Decimal256Array, Decimal32Array, Decimal64Array, Int32Array,
Int64Array, UInt64Array,
+ };
+
+ #[test]
+ fn integer_float_keys_require_identity_without_assignment_range_limits() {
+ let integer: ArrayRef = Arc::new(Int64Array::from(vec![Some((1_i64 <<
53) + 2), None]));
+ let converted = normalize(&integer, &DataType::Float64).unwrap();
+ assert_eq!(
+ normalize(&converted, &DataType::Int64).unwrap().to_data(),
+ integer.to_data()
+ );
+ let fractional: ArrayRef =
Arc::new(Float64Array::from(vec![1.00000001]));
+ assert!(normalize(&fractional, &DataType::Int32).is_err());
+ let rounded: ArrayRef = Arc::new(Int64Array::from(vec![(1_i64 << 53) +
1]));
+ assert!(normalize(&rounded, &DataType::Float64).is_err());
+ let overflow: ArrayRef = Arc::new(UInt64Array::from(vec![u64::MAX]));
+ assert!(normalize(&overflow, &DataType::Float64).is_err());
+ let overflow: ArrayRef =
Arc::new(Float64Array::from(vec![2_f64.powi(63)]));
+ assert!(normalize(&overflow, &DataType::Int64).is_err());
+ }
+
+ #[test]
+ fn decimal_integer_keys_share_the_exact_rescaler() {
+ let decimal: ArrayRef = Arc::new(
+ Decimal128Array::from(vec![Some(12500), None])
+ .with_precision_and_scale(15, 4)
+ .unwrap(),
+ );
+ assert!(normalize(&decimal, &DataType::Int32).is_err());
+ let integer: ArrayRef = Arc::new(Int32Array::from(vec![Some(-1), None,
Some(2)]));
+ let converted = normalize(&integer, &DataType::Decimal128(10,
2)).unwrap();
+ assert_eq!(
+ normalize(&converted, &DataType::Int32).unwrap().to_data(),
+ integer.to_data()
+ );
+ let zero: ArrayRef = Arc::new(
+ Decimal256Array::from(vec![Some(i256::ZERO), None])
+ .with_precision_and_scale(76, -128)
+ .unwrap(),
+ );
+ let actual = normalize(&zero, &DataType::Int64).unwrap();
+ assert_eq!(
+ actual.to_data(),
+ Int64Array::from(vec![Some(0), None]).to_data()
+ );
+ }
+
+ #[test]
+ fn decimal_float_keys_do_not_use_rounded_decimal_equality() {
+ let floating: ArrayRef = Arc::new(Float64Array::from(vec![Some(1.5),
None, Some(-2.25)]));
+ let decimal = normalize(&floating, &DataType::Decimal128(10,
2)).unwrap();
+ assert_eq!(
+ decimal.to_data(),
+ Decimal128Array::from(vec![Some(150), None, Some(-225)])
+ .with_precision_and_scale(10, 2)
+ .unwrap()
+ .to_data()
+ );
+ assert_eq!(
+ normalize(&decimal, &DataType::Float64).unwrap().to_data(),
+ floating.to_data()
+ );
+ for value in [0.1, 1.235, 1.0 + f64::EPSILON, f64::MAX,
f64::MIN_POSITIVE] {
+ let input: ArrayRef = Arc::new(Float64Array::from(vec![value]));
+ assert!(normalize(&input, &DataType::Decimal128(10, 2)).is_err());
+ }
+ let rounded: ArrayRef = Arc::new(
+ Decimal128Array::from(vec![10])
+ .with_precision_and_scale(10, 2)
+ .unwrap(),
+ );
+ assert!(normalize(&rounded, &DataType::Float64).is_err());
+ assert!(normalize(&rounded, &DataType::Float32).is_err());
+ let small: ArrayRef = Arc::new(
+ Decimal32Array::from(vec![Some(150), None])
+ .with_precision_and_scale(9, 2)
+ .unwrap(),
+ );
+ let medium: ArrayRef = Arc::new(
+ Decimal64Array::from(vec![Some(150), None])
+ .with_precision_and_scale(18, 2)
+ .unwrap(),
+ );
+ for input in [small, medium] {
+ assert_eq!(
+ normalize(&input, &DataType::Float64).unwrap().to_data(),
+ Float64Array::from(vec![Some(1.5), None]).to_data()
+ );
+ }
+ }
+
+ #[test]
+ fn exact_binary_values_support_wide_and_negative_decimal_scales() {
+ assert_eq!(float_coefficient(1e20, -20), Some(i256::ONE));
+ assert_eq!(float_coefficient(-1e20, -20), Some(-i256::ONE));
+ assert_eq!(float_coefficient(1e20, -21), None);
+ assert_eq!(float_coefficient(0.0, -128), Some(i256::ZERO));
+ assert_eq!(float_coefficient(0.0, 127), Some(i256::ZERO));
+ // 2^-70 needs 70 decimal places, beyond Decimal128's coefficient
width.
+ let coefficient = i256::from_i128(5).checked_pow(70).unwrap();
+ let input: ArrayRef = Arc::new(
+ Decimal256Array::from(vec![Some(coefficient), None,
Some(-coefficient)])
+ .with_precision_and_scale(76, 70)
+ .unwrap(),
+ );
+ let actual = normalize(&input, &DataType::Float64).unwrap();
+ assert_eq!(
+ actual.to_data(),
+ Float64Array::from(vec![Some(2_f64.powi(-70)), None,
Some(-2_f64.powi(-70))]).to_data()
+ );
+ let large: ArrayRef = Arc::new(
+ Decimal256Array::from(vec![Some(i256::ONE), None])
+ .with_precision_and_scale(76, -20)
+ .unwrap(),
+ );
+ assert_eq!(
+ normalize(&large, &DataType::Float64).unwrap().to_data(),
+ Float64Array::from(vec![Some(1e20), None]).to_data()
+ );
+ }
+}
diff --git a/crates/paimon/src/table/table_update.rs
b/crates/paimon/src/table/table_update.rs
index fdaa256e..49001080 100644
--- a/crates/paimon/src/table/table_update.rs
+++ b/crates/paimon/src/table/table_update.rs
@@ -245,6 +245,9 @@ impl TableUpdate {
/// matched rows retain their values; on new rows they are absent from the
file.
/// After last-write-wins, matched values follow row-ID update conversion
/// rules; new rows must satisfy the ordinary Arrow write type.
+ /// Numeric keys may use different Arrow types only when conversion keeps
+ /// their exact mathematical value. This matching conversion does not alter
+ /// the original key columns used as update or append payloads.
/// Input batches may have different non-key columns. After last-write-wins
/// deduplication, matched rows must supply their update columns and
appended
/// rows in each partition must share a field set.
diff --git a/crates/paimon/src/table/write_batch_normalize.rs
b/crates/paimon/src/table/write_batch_normalize.rs
index 44b689b0..d732215f 100644
--- a/crates/paimon/src/table/write_batch_normalize.rs
+++ b/crates/paimon/src/table/write_batch_normalize.rs
@@ -26,9 +26,9 @@
use crate::{Error, Result};
use arrow_array::{
Array, ArrayRef, BinaryArray, Decimal128Array, FixedSizeBinaryArray,
FixedSizeListArray,
- LargeStringArray, ListArray, MapArray, StringArray, StructArray,
+ LargeBinaryArray, LargeStringArray, ListArray, MapArray, StringArray,
StructArray,
};
-use arrow_buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
+use arrow_buffer::{Buffer, NullBuffer, OffsetBuffer, ScalarBuffer};
use arrow_schema::{DataType, Field};
use std::sync::Arc;
@@ -435,28 +435,52 @@ fn normalize_visible_array(
/// in its backing buffer, so casting the original absolute offsets is wrong.
/// The value buffer remains shared and only the offset buffer is rebuilt.
fn normalize_large_string(array: &LargeStringArray) -> Result<ArrayRef> {
+ let (offsets, values) = narrow_byte_range(array.value_offsets(),
array.values())?;
+ StringArray::try_new(offsets, values, array.nulls().cloned())
+ .map(|array| Arc::new(array) as ArrayRef)
+ .map_err(normalization_error)
+}
+
+/// Offset adaptation for matching keys; ordinary append type validation
remains
+/// separate so this does not broaden accepted write schemas.
+pub(super) fn normalize_large_binary(array: &LargeBinaryArray) ->
Result<ArrayRef> {
let source = array.value_offsets();
+ if array.null_count() != 0 && source[source.len() - 1] - source[0] >
i64::from(i32::MAX) {
+ // NULL slots can retain arbitrarily large, unobservable byte ranges.
+ // Compact only when they prevent narrowing; ordinary arrays continue
+ // to share the visible value buffer below.
+ let visible_len = array
+ .iter()
+ .flatten()
+ .try_fold(0_usize, |len, value| len.checked_add(value.len()));
+ if visible_len.is_none_or(|len| len > i32::MAX as usize) {
+ return Err(Error::DataInvalid {
+ message: "Arrow byte range exceeds 32-bit offsets".into(),
+ source: None,
+ });
+ }
+ return Ok(Arc::new(BinaryArray::from_iter(array.iter())));
+ }
+ let (offsets, values) = narrow_byte_range(array.value_offsets(),
array.values())?;
+ BinaryArray::try_new(offsets, values, array.nulls().cloned())
+ .map(|array| Arc::new(array) as ArrayRef)
+ .map_err(normalization_error)
+}
+
+fn narrow_byte_range(source: &[i64], values: &Buffer) ->
Result<(OffsetBuffer<i32>, Buffer)> {
let start = source[0];
let end = source[source.len() - 1];
let offsets = source
.iter()
.map(|offset| {
i32::try_from(offset - start).map_err(|_| Error::DataInvalid {
- message: "Arrow STRING byte range exceeds 32-bit
offsets".into(),
+ message: "Arrow byte range exceeds 32-bit offsets".into(),
source: None,
})
})
.collect::<Result<Vec<_>>>()?;
- let values = array
- .values()
- .slice_with_length(start as usize, (end - start) as usize);
- StringArray::try_new(
- OffsetBuffer::new(offsets.into()),
- values,
- array.nulls().cloned(),
- )
- .map(|array| Arc::new(array) as ArrayRef)
- .map_err(normalization_error)
+ let values = values.slice_with_length(start as usize, (end - start) as
usize);
+ Ok((OffsetBuffer::new(offsets.into()), values))
}
/// Keep only children referenced by visible parents. Ordinary inputs and
@@ -604,6 +628,56 @@ mod tests {
});
let empty = normalize_write_array(&input.slice(3, 0),
&DataType::Utf8).unwrap();
assert!(empty.is_empty());
+ let text = input.as_any().downcast_ref::<LargeStringArray>().unwrap();
+ let binary: ArrayRef = Arc::new(LargeBinaryArray::new(
+ text.offsets().clone(),
+ text.values().clone(),
+ text.nulls().cloned(),
+ ));
+ let normalized =
+ crate::table::key_normalization::normalize_match_key(&binary,
&DataType::Binary)
+ .unwrap();
+ assert_eq!(
+ normalized.to_data(),
+ BinaryArray::from(vec![Some("abc".as_bytes()), None,
Some("中文".as_bytes())])
+ .to_data()
+ );
+ assert!(normalize_write_array(&binary, &DataType::Binary).is_err());
+ for mixed in [false, true] {
+ let offsets = if mixed {
+ vec![0, start, start + 3]
+ } else {
+ vec![0, start]
+ };
+ let input: ArrayRef = Arc::new(LargeBinaryArray::new(
+ OffsetBuffer::new(offsets.into()),
+ text.values().clone(),
+ Some(NullBuffer::from(if mixed {
+ vec![false, true]
+ } else {
+ vec![false]
+ })),
+ ));
+ let output =
+ crate::table::key_normalization::normalize_match_key(&input,
&DataType::Binary)
+ .unwrap();
+ let expected = if mixed {
+ vec![None, Some(b"abc".as_slice())]
+ } else {
+ vec![None]
+ };
+ assert_eq!(output.to_data(),
BinaryArray::from(expected).to_data());
+ }
+ let visible_overflow: ArrayRef = Arc::new(LargeBinaryArray::new(
+ OffsetBuffer::new(vec![0, start + 3, start + 3].into()),
+ text.values().clone(),
+ Some(NullBuffer::from(vec![true, false])),
+ ));
+ assert!(crate::table::key_normalization::normalize_match_key(
+ &visible_overflow,
+ &DataType::Binary,
+ )
+ .is_err());
// A visible range larger than i32::MAX still cannot be represented.
// SAFETY: the mapped bytes are zeroes followed by a valid UTF-8 tail;
// offsets and NULL bitmap are valid. Skip scanning the 2 GiB prefix.