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 a914bc42 [core] Support additional Arrow inputs in row-level updates 
(#1152)
a914bc42 is described below

commit a914bc42820b65f88e77a284edd293d347d2ebc4
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 18 10:36:20 2026 +0800

    [core] Support additional Arrow inputs in row-level updates (#1152)
---
 crates/paimon/src/table/key_normalization.rs       |   5 +
 .../paimon/src/table/key_normalization/numeric.rs  |   6 +-
 crates/paimon/src/table/update_input.rs            | 106 +++++++++--
 crates/paimon/src/table/update_input/binary.rs     | 177 ++++++++++++++++++
 .../src/table/update_input/container_tests.rs      |  66 +++++++
 crates/paimon/src/table/update_input/decimal.rs    | 200 +++++++++++++++++++--
 crates/paimon/src/table/write_batch_normalize.rs   | 103 +++++++++--
 7 files changed, 613 insertions(+), 50 deletions(-)

diff --git a/crates/paimon/src/table/key_normalization.rs 
b/crates/paimon/src/table/key_normalization.rs
index 16fcef2f..1508a347 100644
--- a/crates/paimon/src/table/key_normalization.rs
+++ b/crates/paimon/src/table/key_normalization.rs
@@ -45,6 +45,11 @@ pub(super) fn normalize_match_key(
     if source.is_numeric() && expected.is_numeric() {
         return numeric::normalize(&decoded, expected);
     }
+    if source == &DataType::LargeUtf8 && expected == &DataType::Utf8 {
+        return super::write_batch_normalize::normalize_large_string_key(
+            decoded.as_any().downcast_ref().unwrap(),
+        );
+    }
     let binary = |t: &DataType| {
         matches!(
             t,
diff --git a/crates/paimon/src/table/key_normalization/numeric.rs 
b/crates/paimon/src/table/key_normalization/numeric.rs
index 5b49bd2f..5ae73b97 100644
--- a/crates/paimon/src/table/key_normalization/numeric.rs
+++ b/crates/paimon/src/table/key_normalization/numeric.rs
@@ -69,11 +69,7 @@ pub(super) fn normalize(array: &ArrayRef, target: &DataType) 
-> crate::Result<Ar
             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);
+            return cast_update_value(array, target, CastMode::Assignment);
         }
     }
     if source.is_floating() || target.is_floating() {
diff --git a/crates/paimon/src/table/update_input.rs 
b/crates/paimon/src/table/update_input.rs
index 38f1fccc..5fa976fa 100644
--- a/crates/paimon/src/table/update_input.rs
+++ b/crates/paimon/src/table/update_input.rs
@@ -28,6 +28,7 @@ use arrow_array::{
 use arrow_buffer::OffsetBuffer;
 use arrow_schema::{DataType, Schema, TimeUnit};
 
+mod binary;
 mod decimal;
 mod encoded;
 
@@ -282,6 +283,14 @@ fn cast_values(array: &ArrayRef, target: &DataType, mode: 
CastMode) -> crate::Re
             }
             DataType::Map(..) => {
                 let input = array.as_any().downcast_ref::<MapArray>().unwrap();
+                if mode == CastMode::Constructor
+                    && matches!(
+                        field.data_type(),
+                        DataType::List(_) | DataType::LargeList(_) | 
DataType::FixedSizeList(..)
+                    )
+                {
+                    return constructor_array_pairs(input, field, target);
+                }
                 let DataType::Struct(fields) = field.data_type() else {
                     return Err(invalid(
                         "Map entries can only be cast to two-field ROW 
elements",
@@ -355,28 +364,83 @@ fn cast_map_entries(
     if fields.len() != 2 {
         return Err(invalid("Map entries require exactly two ROW fields"));
     }
+    let (columns, offsets) =
+        cast_map_columns(input, [fields[0].data_type(), 
fields[1].data_type()], mode)?;
+    let length = *offsets.last().unwrap() as usize;
+    let entries = StructArray::try_new_with_length(fields.clone(), 
columns.into(), None, length)
+        .map_err(|error| invalid(error.to_string()))?;
+    Ok((entries, offsets))
+}
+
+/// Select the visible MAP entries before rebuilding Python constructor values.
+/// Both ROW pairs and ARRAY pairs share the same key/value selection rules.
+fn cast_map_columns(
+    input: &MapArray,
+    types: [&DataType; 2],
+    mode: CastMode,
+) -> crate::Result<([ArrayRef; 2], OffsetBuffer<i32>)> {
     let offsets = input.value_offsets();
     let start = offsets[0];
     let length = offsets[offsets.len() - 1] - start;
     let (offsets, indices) = constructor_offsets(offsets, input.nulls(), mode);
-    let length = indices.as_ref().map_or(length as usize, |i| i.len());
     // MAP field names are aliases. Their key/value roles do not depend on the
     // source names or the target ROW names, unlike a normal ROW-to-ROW cast.
-    let columns = [input.keys(), input.values()]
-        .into_iter()
-        .zip(fields)
-        .map(|(values, field)| {
-            let values = match &indices {
-                Some(indices) => arrow_select::take::take(values.as_ref(), 
indices, None)
-                    .map_err(|error| invalid(error.to_string()))?,
-                None => values.slice(start as usize, length),
-            };
-            cast_values(&values, field.data_type(), mode)
-        })
-        .collect::<crate::Result<Vec<_>>>()?;
-    let entries = StructArray::try_new_with_length(fields.clone(), columns, 
None, length)
+    let cast = |values: &ArrayRef, target: &DataType| {
+        let values = match &indices {
+            Some(indices) => arrow_select::take::take(values.as_ref(), 
indices, None)
+                .map_err(|error| invalid(error.to_string()))?,
+            None => values.slice(start as usize, length as usize),
+        };
+        cast_values(&values, target, mode)
+    };
+    Ok((
+        [
+            cast(input.keys(), types[0])?,
+            cast(input.values(), types[1])?,
+        ],
+        OffsetBuffer::new(offsets.into()),
+    ))
+}
+
+/// A Python MAP value is a sequence of key/value tuples. Rebuilding it as
+/// ARRAY<ARRAY<T>> turns each tuple into a two-element ARRAY; this is input
+/// construction, not a MAP-to-ARRAY schema-evolution cast.
+fn constructor_array_pairs(
+    input: &MapArray,
+    pair_field: &arrow_schema::FieldRef,
+    target: &DataType,
+) -> crate::Result<ArrayRef> {
+    let element = match pair_field.data_type() {
+        DataType::List(element)
+        | DataType::LargeList(element)
+        | DataType::FixedSizeList(element, _) => element,
+        _ => return Err(invalid("Map pairs require two-element ARRAY values")),
+    };
+    let ([keys, values], offsets) =
+        cast_map_columns(input, [element.data_type(); 2], 
CastMode::Constructor)?;
+    let positions = (0..keys.len())
+        .flat_map(|row| [(0, row), (1, row)])
+        .collect::<Vec<_>>();
+    let values =
+        arrow_select::interleave::interleave(&[keys.as_ref(), 
values.as_ref()], &positions)
+            .map_err(|error| invalid(error.to_string()))?;
+    let options = arrow_cast::CastOptions {
+        safe: false,
+        ..Default::default()
+    };
+    let pairs = if keys.is_empty() {
+        arrow_array::new_empty_array(pair_field.data_type())
+    } else {
+        let pairs =
+            FixedSizeListArray::try_new_with_length(element.clone(), 2, 
values, None, keys.len())
+                .map_err(|error| invalid(error.to_string()))?;
+        arrow_cast::cast_with_options(&pairs, pair_field.data_type(), &options)
+            .map_err(|error| invalid(error.to_string()))?
+    };
+    let output = ListArray::try_new(pair_field.clone(), offsets, pairs, 
input.nulls().cloned())
         .map_err(|error| invalid(error.to_string()))?;
-    Ok((entries, OffsetBuffer::new(offsets.into())))
+    arrow_cast::cast_with_options(&output, target, &options)
+        .map_err(|error| invalid(error.to_string()))
 }
 
 // PyPaimon's row-ID fallback reconstructs Python dicts only after a 
whole-column
@@ -774,6 +838,12 @@ fn cast_to_string(array: &ArrayRef, target: &DataType) -> 
crate::Result<ArrayRef
         let binary = 
super::write_batch_normalize::normalize_write_array(array, &DataType::Binary)?;
         return cast_to_string(&binary, target);
     }
+    if matches!(
+        array.data_type(),
+        DataType::Binary | DataType::LargeBinary | DataType::BinaryView
+    ) {
+        return binary::cast_to_string(array, target);
+    }
     let floats: Option<StringArray> = match array.data_type() {
         DataType::Float16 => Some(
             array
@@ -1343,8 +1413,7 @@ fn cast_primitive(array: &ArrayRef, target: &DataType, 
mode: CastMode) -> crate:
     ) && (target.is_numeric() || *target == DataType::Boolean)
     {
         // Binary numeric inputs contain text, never native-endian numeric 
bytes.
-        let text = arrow_cast::cast_with_options(array.as_ref(), 
&DataType::Utf8, &options)
-            .map_err(|error| invalid(error.to_string()))?;
+        let text = binary::cast_to_string(array, &DataType::Utf8)?;
         return cast_primitive(&text, target, mode);
     }
     if matches!(source, DataType::Timestamp(..)) && matches!(target, 
DataType::Timestamp(..)) {
@@ -1445,6 +1514,9 @@ fn cast_primitive(array: &ArrayRef, target: &DataType, 
mode: CastMode) -> crate:
     if source.is_decimal() && matches!(target, DataType::Decimal128(..)) {
         return decimal::cast_exact_decimal(array, target);
     }
+    if source.is_decimal() && target.is_integer() {
+        return decimal::cast_to_integer(array, target, mode == 
CastMode::Constructor);
+    }
     if source.is_decimal() && matches!(target, DataType::Float32 | 
DataType::Float64) {
         return decimal::cast_to_floating(array, target);
     }
diff --git a/crates/paimon/src/table/update_input/binary.rs 
b/crates/paimon/src/table/update_input/binary.rs
new file mode 100644
index 00000000..872d6449
--- /dev/null
+++ b/crates/paimon/src/table/update_input/binary.rs
@@ -0,0 +1,177 @@
+// 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.
+
+//! Text input decoding respects logical NULLs, independently of their bytes.
+
+use arrow_array::{builder::LargeStringBuilder, cast::AsArray, ArrayRef};
+use arrow_schema::DataType;
+use std::sync::Arc;
+
+use super::invalid;
+
+pub(super) fn cast_to_string(array: &ArrayRef, target: &DataType) -> 
crate::Result<ArrayRef> {
+    let options = arrow_cast::CastOptions {
+        safe: false,
+        ..Default::default()
+    };
+    let result = arrow_cast::cast_with_options(array.as_ref(), target, 
&options);
+    match result {
+        Ok(array) => Ok(array),
+        Err(_) => {
+            // The zero-copy kernel validates the entire value buffer, 
including
+            // NULL slots and bytes outside a slice. Arrow C++ skips those 
bytes.
+            // Rebuild only after the ordinary cast fails,
+            // still rejecting every invalid visible value. Large offsets avoid
+            // overflow before the final checked conversion to the target 
width.
+            let strings = match array.data_type() {
+                DataType::Binary => 
visible_text(array.as_binary::<i32>().iter(), target)?,
+                DataType::LargeBinary => 
visible_text(array.as_binary::<i64>().iter(), target)?,
+                DataType::BinaryView => 
visible_text(array.as_binary_view().iter(), target)?,
+                _ => unreachable!("binary text input was validated by the 
caller"),
+            };
+            arrow_cast::cast_with_options(strings.as_ref(), target, &options)
+                .map_err(|error| invalid(error.to_string()))
+        }
+    }
+}
+
+fn visible_text<'a>(
+    values: impl Iterator<Item = Option<&'a [u8]>> + Clone,
+    target: &DataType,
+) -> crate::Result<ArrayRef> {
+    if target == &DataType::Utf8 {
+        values
+            .clone()
+            .flatten()
+            .try_fold(0_usize, |length, bytes| {
+                length
+                    .checked_add(bytes.len())
+                    .filter(|length| *length <= i32::MAX as usize)
+                    .ok_or_else(|| invalid("Binary text exceeds STRING offset 
range"))
+            })?;
+    }
+    let mut builder = LargeStringBuilder::new();
+    for value in values {
+        match value {
+            Some(bytes) => builder.append_value(
+                std::str::from_utf8(bytes).map_err(|error| 
invalid(error.to_string()))?,
+            ),
+            None => builder.append_null(),
+        }
+    }
+    Ok(Arc::new(builder.finish()))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::super::{cast_update_value, CastMode};
+    use super::*;
+    use arrow_array::{Array, BinaryArray, Int32Array, LargeBinaryArray, 
StringArray};
+    use arrow_buffer::{Buffer, NullBuffer, OffsetBuffer};
+    use std::sync::Arc;
+
+    fn hidden_bytes(large: bool) -> ArrayRef {
+        let offsets = vec![0, 1, 3, 5, 6, 8, 9];
+        let values = Buffer::from(b"x\xff\xff42\xc3-7z".as_slice());
+        let nulls = Some(NullBuffer::from(vec![true, false, true, false, true, 
true]));
+        let array: ArrayRef = if large {
+            Arc::new(LargeBinaryArray::new(
+                OffsetBuffer::new(offsets.into()),
+                values,
+                nulls,
+            ))
+        } else {
+            Arc::new(BinaryArray::new(
+                OffsetBuffer::new(
+                    offsets
+                        .into_iter()
+                        .map(|i| i as i32)
+                        .collect::<Vec<_>>()
+                        .into(),
+                ),
+                values,
+                nulls,
+            ))
+        };
+        array.slice(1, 4)
+    }
+
+    #[test]
+    fn hidden_invalid_utf8_is_not_a_text_or_numeric_value() {
+        for large in [false, true] {
+            let source = hidden_bytes(large);
+            for mode in [CastMode::Assignment, CastMode::RowUpdate] {
+                let text = cast_update_value(&source, &DataType::Utf8, 
mode).unwrap();
+                assert_eq!(
+                    text.to_data(),
+                    StringArray::from(vec![None, Some("42"), None, 
Some("-7")]).to_data()
+                );
+                let integers = cast_update_value(&source, &DataType::Int32, 
mode).unwrap();
+                assert_eq!(
+                    integers.to_data(),
+                    Int32Array::from(vec![None, Some(42), None, 
Some(-7)]).to_data()
+                );
+                // A batch boundary may retain only a valid value while its
+                // backing buffer still owns invalid bytes from other slots.
+                let valid_slice = source.slice(1, 1);
+                assert_eq!(valid_slice.null_count(), 0);
+                let text = cast_update_value(&valid_slice, &DataType::Utf8, 
mode).unwrap();
+                assert_eq!(text.to_data(), 
StringArray::from(vec!["42"]).to_data());
+                let null_slice = source.slice(0, 1);
+                let text = cast_update_value(&null_slice, &DataType::Utf8, 
mode).unwrap();
+                assert_eq!(
+                    text.to_data(),
+                    StringArray::from(vec![None::<&str>]).to_data()
+                );
+                let empty = cast_update_value(&source.slice(1, 0), 
&DataType::Utf8, mode).unwrap();
+                assert!(empty.is_empty());
+            }
+        }
+    }
+
+    #[test]
+    fn oversized_visible_text_is_rejected_before_copying_values() {
+        // Reuse one small allocation to represent a >2 GiB logical text input.
+        let bytes = vec![b'x'; 1 << 20];
+        let values = std::iter::repeat_n(Some(bytes.as_slice()), 2048);
+        assert!(visible_text(values, &DataType::Utf8).is_err());
+    }
+
+    #[test]
+    fn visible_invalid_utf8_still_rejects_the_whole_assignment() {
+        let source: ArrayRef = Arc::new(BinaryArray::from(vec![
+            Some(b"42".as_slice()),
+            None,
+            Some(b"\xff".as_slice()),
+        ]));
+        for mode in [CastMode::Assignment, CastMode::RowUpdate] {
+            for target in [DataType::Utf8, DataType::Int32] {
+                assert!(cast_update_value(&source, &target, mode).is_err());
+            }
+        }
+    }
+
+    #[test]
+    fn ordinary_binary_text_keeps_its_shared_value_buffer() {
+        let source = Arc::new(BinaryArray::from(vec![Some(b"42".as_slice()), 
None])) as ArrayRef;
+        let result = cast_to_string(&source, &DataType::Utf8).unwrap();
+        assert_eq!(
+            source.to_data().buffers()[1].as_ptr(),
+            result.to_data().buffers()[1].as_ptr()
+        );
+    }
+}
diff --git a/crates/paimon/src/table/update_input/container_tests.rs 
b/crates/paimon/src/table/update_input/container_tests.rs
index 85dd4186..7f403e38 100644
--- a/crates/paimon/src/table/update_input/container_tests.rs
+++ b/crates/paimon/src/table/update_input/container_tests.rs
@@ -238,3 +238,69 @@ fn 
map_entries_require_two_fields_and_actual_child_nullability() {
     );
     assert!(cast_update_value(&null_value, &target, 
CastMode::Assignment).is_err());
 }
+
+#[test]
+fn map_pairs_construct_array_elements_without_converting_hidden_entries() {
+    let input = map(
+        Arc::new(Int64Array::from(vec![i64::MAX, 1, 3, i64::MAX, 5])),
+        Arc::new(Float64Array::from(vec![
+            Some(f64::MAX),
+            Some(2.75),
+            None,
+            Some(f64::MAX),
+            Some(-6.75),
+        ])),
+        &[1, 2, 1, 0, 1],
+        &[true, true, false, true, true],
+    )
+    .slice(1, 4);
+    let element = Arc::new(Field::new("element", DataType::Int32, true));
+    for pair_type in [
+        DataType::List(element.clone()),
+        DataType::LargeList(element.clone()),
+        DataType::FixedSizeList(element.clone(), 2),
+    ] {
+        let target = DataType::List(Arc::new(Field::new("pair", pair_type, 
false)));
+        assert!(cast_update_value(&input, &target, 
CastMode::Assignment).is_err());
+        let result = cast_update_value(&input, &target, 
CastMode::RowUpdate).unwrap();
+        let outer = result.as_list::<i32>();
+        assert_eq!(outer.value_offsets(), &[0, 2, 2, 2, 3]);
+        assert!(outer.is_null(1));
+        let pairs = arrow_cast::cast(
+            outer.values().as_ref(),
+            &DataType::FixedSizeList(element.clone(), 2),
+        )
+        .unwrap();
+        assert_eq!(
+            pairs.as_fixed_size_list().values().to_data(),
+            Int32Array::from(vec![Some(1), Some(2), Some(3), None, Some(5), 
Some(-6)]).to_data()
+        );
+    }
+}
+
+#[test]
+fn empty_map_pairs_do_not_require_a_particular_vector_width() {
+    let input = map(
+        Arc::new(Int32Array::from(vec![1])),
+        Arc::new(Int32Array::from(vec![2])),
+        &[0, 1],
+        &[true, false],
+    );
+    for size in [0, 1, 3] {
+        let target = DataType::List(Arc::new(Field::new(
+            "pair",
+            DataType::FixedSizeList(Arc::new(Field::new("item", 
DataType::Int32, true)), size),
+            false,
+        )));
+        let result = cast_update_value(&input, &target, 
CastMode::RowUpdate).unwrap();
+        assert_eq!(result.as_list::<i32>().value_offsets(), &[0, 0, 0]);
+        assert!(result.is_null(1));
+        let visible = map(
+            Arc::new(Int32Array::from(vec![1])),
+            Arc::new(Int32Array::from(vec![2])),
+            &[1],
+            &[true],
+        );
+        assert!(cast_update_value(&visible, &target, 
CastMode::RowUpdate).is_err());
+    }
+}
diff --git a/crates/paimon/src/table/update_input/decimal.rs 
b/crates/paimon/src/table/update_input/decimal.rs
index 609d47e7..315b5565 100644
--- a/crates/paimon/src/table/update_input/decimal.rs
+++ b/crates/paimon/src/table/update_input/decimal.rs
@@ -17,7 +17,8 @@
 
 //! Exact DECIMAL input rescaling shared by updates and logical match keys.
 
-use arrow_array::{cast::AsArray, ArrayRef, Decimal128Array, Float32Array, 
Float64Array};
+use arrow_array::{cast::AsArray, Array, ArrayRef, Decimal128Array, 
Float32Array, Float64Array};
+use arrow_buffer::i256;
 use arrow_schema::DataType;
 use std::sync::Arc;
 
@@ -38,26 +39,35 @@ fn map_decimal<T>(
     parse: impl Fn(&str) -> crate::Result<T>,
 ) -> crate::Result<Vec<Option<T>>> {
     let (_, scale) = precision_and_scale(array);
+    map_coefficients(array, |value| {
+        parse(&format!("{value}e{}", -i16::from(scale)))
+    })
+}
+
+fn map_coefficients<T>(
+    array: &ArrayRef,
+    convert: impl Fn(i256) -> crate::Result<T>,
+) -> crate::Result<Vec<Option<T>>> {
     // Preserve the full coefficient. A generic display can clip digits to the
     // declared source precision for foreign buffers containing more digits.
     macro_rules! values {
-        ($ty:ty) => {
+        ($ty:ty, $widen:expr) => {
             array
                 .as_primitive::<$ty>()
                 .iter()
-                .map(|value| {
-                    value
-                        .map(|value| parse(&format!("{value}e{}", 
-i16::from(scale))))
-                        .transpose()
-                })
+                .map(|value| value.map(|value| 
convert(($widen)(value))).transpose())
                 .collect()
         };
     }
     match array.data_type() {
-        DataType::Decimal32(..) => values!(arrow_array::types::Decimal32Type),
-        DataType::Decimal64(..) => values!(arrow_array::types::Decimal64Type),
-        DataType::Decimal128(..) => 
values!(arrow_array::types::Decimal128Type),
-        DataType::Decimal256(..) => 
values!(arrow_array::types::Decimal256Type),
+        DataType::Decimal32(..) => values!(arrow_array::types::Decimal32Type, 
|v| i256::from_i128(
+            v as i128
+        )),
+        DataType::Decimal64(..) => values!(arrow_array::types::Decimal64Type, 
|v| i256::from_i128(
+            v as i128
+        )),
+        DataType::Decimal128(..) => 
values!(arrow_array::types::Decimal128Type, i256::from_i128),
+        DataType::Decimal256(..) => 
values!(arrow_array::types::Decimal256Type, |v| v),
         _ => unreachable!("validated DECIMAL input"),
     }
 }
@@ -89,6 +99,65 @@ pub(super) fn cast_to_floating(array: &ArrayRef, target: 
&DataType) -> crate::Re
     })
 }
 
+/// Extract the integral value before narrowing its physical width. Generic
+/// Arrow kernels build 10^scale even for zero, overflowing at extreme external
+/// scales. Row-ID constructors truncate fractions; evaluated assignments and
+/// match keys require exact values. Both contracts still reject width 
overflow.
+pub(super) fn cast_to_integer(
+    array: &ArrayRef,
+    target: &DataType,
+    truncate: bool,
+) -> crate::Result<ArrayRef> {
+    let (_, scale) = precision_and_scale(array);
+    // Compute the scale factor once per array, without formatting each value
+    // or overflowing the narrower DECIMAL32/64/128 physical coefficient.
+    let power = 
i256::from_i128(10).checked_pow(u32::from(scale.unsigned_abs()));
+    let overflow = || invalid("Decimal assignment exceeds target integer 
range");
+    let values = map_coefficients(array, |value| {
+        if value == i256::ZERO {
+            return Ok(0);
+        }
+        let integral = if scale > 0 {
+            let Some(power) = power else {
+                return if truncate {
+                    Ok(0)
+                } else {
+                    Err(invalid("Decimal assignment would lose scale 
precision"))
+                };
+            };
+            if !truncate && value % power != i256::ZERO {
+                return Err(invalid("Decimal assignment would lose scale 
precision"));
+            }
+            value / power
+        } else {
+            value
+                .checked_mul(power.ok_or_else(overflow)?)
+                .ok_or_else(overflow)?
+        };
+        let value = integral.to_i128().ok_or_else(overflow)?;
+        if value.unsigned_abs() >= 10_u128.pow(38) {
+            return Err(overflow());
+        }
+        Ok(value)
+    })?;
+    let integral = Decimal128Array::from(values)
+        .with_precision_and_scale(38, 0)
+        .map_err(|error| invalid(error.to_string()))?;
+    let options = arrow_cast::CastOptions {
+        safe: false,
+        ..Default::default()
+    };
+    let converted = arrow_cast::cast_with_options(&integral, target, &options)
+        .map_err(|error| invalid(error.to_string()))?;
+    let restored =
+        arrow_cast::cast_with_options(converted.as_ref(), 
integral.data_type(), &options)
+            .map_err(|error| invalid(error.to_string()))?;
+    if restored.to_data() != integral.to_data() {
+        return Err(invalid("Decimal assignment exceeds target integer range"));
+    }
+    Ok(converted)
+}
+
 pub(super) fn cast_exact_decimal(array: &ArrayRef, target: &DataType) -> 
crate::Result<ArrayRef> {
     let (_, source_scale) = precision_and_scale(array);
     let DataType::Decimal128(target_precision, target_scale) = target else {
@@ -236,4 +305,113 @@ mod tests {
             );
         }
     }
+
+    #[test]
+    fn integer_zero_does_not_need_an_oversized_decimal_power() {
+        for source in [
+            DataType::Decimal32(9, -128),
+            DataType::Decimal64(18, -90),
+            DataType::Decimal128(38, -90),
+            DataType::Decimal256(76, -128),
+        ] {
+            for target in [
+                DataType::Int8,
+                DataType::Int32,
+                DataType::Int64,
+                DataType::UInt64,
+            ] {
+                for mode in [CastMode::Assignment, CastMode::RowUpdate] {
+                    let output = cast_update_value(&decimal(&source, 0), 
&target, mode).unwrap();
+                    let expected = arrow_cast::cast(
+                        &arrow_array::Int64Array::from(vec![Some(0), None]),
+                        &target,
+                    )
+                    .unwrap();
+                    assert_eq!(
+                        output.to_data(),
+                        expected.to_data(),
+                        "{source:?} -> {target:?}"
+                    );
+                    assert!(cast_update_value(&decimal(&source, 1), &target, 
mode).is_err());
+                }
+            }
+        }
+    }
+
+    #[test]
+    fn integer_width_bounds_apply_after_fraction_truncation() {
+        for source in [DataType::Decimal128(38, 2), DataType::Decimal256(76, 
2)] {
+            for (target, minimum, maximum) in [
+                (DataType::Int8, i8::MIN as i128, i8::MAX as i128),
+                (DataType::Int16, i16::MIN as i128, i16::MAX as i128),
+                (DataType::Int32, i32::MIN as i128, i32::MAX as i128),
+                (DataType::Int64, i64::MIN as i128, i64::MAX as i128),
+                (DataType::UInt8, 0, u8::MAX as i128),
+                (DataType::UInt16, 0, u16::MAX as i128),
+                (DataType::UInt32, 0, u32::MAX as i128),
+                (DataType::UInt64, 0, u64::MAX as i128),
+            ] {
+                for value in [minimum, maximum] {
+                    let input = decimal(&source, value * 100);
+                    for mode in [CastMode::Assignment, CastMode::RowUpdate] {
+                        let output = cast_update_value(&input, &target, 
mode).unwrap();
+                        // The output must contain the integer boundary itself,
+                        // rather than a wrapped value or a newly introduced 
NULL.
+                        let expected: ArrayRef = if target.is_signed_integer() 
{
+                            Arc::new(arrow_array::Int64Array::from(vec![
+                                Some(value as i64),
+                                None,
+                            ]))
+                        } else {
+                            Arc::new(arrow_array::UInt64Array::from(vec![
+                                Some(value as u64),
+                                None,
+                            ]))
+                        };
+                        let expected = arrow_cast::cast(&expected, 
&target).unwrap();
+                        assert_eq!(output.to_data(), expected.to_data());
+                    }
+                }
+                for value in [minimum - 1, maximum + 1] {
+                    for mode in [CastMode::Assignment, CastMode::RowUpdate] {
+                        assert!(
+                            cast_update_value(&decimal(&source, value * 100), 
&target, mode)
+                                .is_err()
+                        );
+                    }
+                }
+                let fraction = decimal(&source, maximum * 100 + 99);
+                assert!(cast_update_value(&fraction, &target, 
CastMode::Assignment).is_err());
+                assert!(cast_update_value(&fraction, &target, 
CastMode::RowUpdate).is_ok());
+            }
+        }
+    }
+
+    #[test]
+    fn wide_decimal_coefficients_are_reduced_before_integer_narrowing() {
+        let coefficient = format!("123{}", 
"0".repeat(60)).parse::<i256>().unwrap();
+        let input: ArrayRef = Arc::new(
+            Decimal256Array::from(vec![Some(coefficient), None, 
Some(-coefficient)])
+                .with_precision_and_scale(76, 60)
+                .unwrap(),
+        );
+        let expected = arrow_array::Int32Array::from(vec![Some(123), None, 
Some(-123)]);
+        for mode in [CastMode::Assignment, CastMode::RowUpdate] {
+            let output = cast_update_value(&input, &DataType::Int32, 
mode).unwrap();
+            assert_eq!(output.to_data(), expected.to_data());
+        }
+        let fractional: ArrayRef = Arc::new(
+            Decimal256Array::from(vec![
+                Some(coefficient + i256::ONE),
+                None,
+                Some(-coefficient - i256::ONE),
+            ])
+            .with_precision_and_scale(76, 60)
+            .unwrap(),
+        );
+        assert!(cast_update_value(&fractional, &DataType::Int32, 
CastMode::Assignment).is_err());
+        let truncated =
+            cast_update_value(&fractional, &DataType::Int32, 
CastMode::RowUpdate).unwrap();
+        assert_eq!(truncated.to_data(), expected.to_data());
+    }
 }
diff --git a/crates/paimon/src/table/write_batch_normalize.rs 
b/crates/paimon/src/table/write_batch_normalize.rs
index d732215f..36bd939c 100644
--- a/crates/paimon/src/table/write_batch_normalize.rs
+++ b/crates/paimon/src/table/write_batch_normalize.rs
@@ -444,27 +444,59 @@ fn normalize_large_string(array: &LargeStringArray) -> 
Result<ArrayRef> {
 /// 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) {
+    let (offsets, values) =
+        normalize_large_key_bytes(array.value_offsets(), array.values(), 
array.nulls())?;
+    BinaryArray::try_new(offsets, values, array.nulls().cloned())
+        .map(|array| Arc::new(array) as ArrayRef)
+        .map_err(normalization_error)
+}
+
+/// Matching reads logical string values, including NULLs with hidden storage.
+/// Ordinary write normalization keeps its existing offset-limit validation.
+pub(super) fn normalize_large_string_key(array: &LargeStringArray) -> 
Result<ArrayRef> {
+    let (offsets, values) =
+        normalize_large_key_bytes(array.value_offsets(), array.values(), 
array.nulls())?;
+    StringArray::try_new(offsets, values, array.nulls().cloned())
+        .map(|array| Arc::new(array) as ArrayRef)
+        .map_err(normalization_error)
+}
+
+fn normalize_large_key_bytes(
+    source: &[i64],
+    values: &Buffer,
+    nulls: Option<&NullBuffer>,
+) -> Result<(OffsetBuffer<i32>, Buffer)> {
+    if let Some(nulls) = nulls.filter(|nulls| {
+        nulls.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) {
+        let visible_len = source
+            .windows(2)
+            .enumerate()
+            .filter(|(row, _)| nulls.is_valid(*row))
+            .try_fold(0_i64, |len, (_, range)| {
+                len.checked_add(range[1] - range[0])
+            });
+        if visible_len.is_none_or(|len| len > i64::from(i32::MAX)) {
             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 mut compacted = Vec::with_capacity(visible_len.unwrap() as usize);
+        let mut offsets = Vec::with_capacity(source.len());
+        offsets.push(0);
+        for (row, range) in source.windows(2).enumerate() {
+            if nulls.is_valid(row) {
+                compacted.extend_from_slice(&values[range[0] as 
usize..range[1] as usize]);
+            }
+            offsets.push(compacted.len() as i32);
+        }
+        return Ok((OffsetBuffer::new(offsets.into()), 
Buffer::from(compacted)));
     }
-    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)
+    narrow_byte_range(source, values)
 }
 
 fn narrow_byte_range(source: &[i64], values: &Buffer) -> 
Result<(OffsetBuffer<i32>, Buffer)> {
@@ -652,11 +684,8 @@ mod tests {
             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]
-                })),
+                // Retain a nonzero validity-bit offset through compaction.
+                Some(NullBuffer::from(vec![true, false, true]).slice(1, if 
mixed { 2 } else { 1 })),
             ));
             let output =
                 crate::table::key_normalization::normalize_match_key(&input, 
&DataType::Binary)
@@ -667,6 +696,29 @@ mod tests {
                 vec![None]
             };
             assert_eq!(output.to_data(), 
BinaryArray::from(expected).to_data());
+            let bytes = 
input.as_any().downcast_ref::<LargeBinaryArray>().unwrap();
+            // SAFETY: the shared mapping contains valid UTF-8. Avoid scanning
+            // the hidden 2 GiB NULL range merely to construct the test input.
+            let strings: ArrayRef = Arc::new(unsafe {
+                LargeStringArray::new_unchecked(
+                    bytes.offsets().clone(),
+                    bytes.values().clone(),
+                    bytes.nulls().cloned(),
+                )
+            });
+            let output =
+                crate::table::key_normalization::normalize_match_key(&strings, 
&DataType::Utf8)
+                    .unwrap();
+            assert_eq!(
+                output.to_data(),
+                StringArray::from(if mixed {
+                    vec![None, Some("abc")]
+                } else {
+                    vec![None]
+                })
+                .to_data(),
+            );
+            assert!(normalize_write_array(&strings, &DataType::Utf8).is_err());
         }
         let visible_overflow: ArrayRef = Arc::new(LargeBinaryArray::new(
             OffsetBuffer::new(vec![0, start + 3, start + 3].into()),
@@ -678,6 +730,23 @@ mod tests {
             &DataType::Binary,
         )
         .is_err());
+        let bytes = visible_overflow
+            .as_any()
+            .downcast_ref::<LargeBinaryArray>()
+            .unwrap();
+        // SAFETY: as above, both the zero-filled prefix and tail are UTF-8.
+        let visible_string_overflow: ArrayRef = Arc::new(unsafe {
+            LargeStringArray::new_unchecked(
+                bytes.offsets().clone(),
+                bytes.values().clone(),
+                bytes.nulls().cloned(),
+            )
+        });
+        assert!(crate::table::key_normalization::normalize_match_key(
+            &visible_string_overflow,
+            &DataType::Utf8,
+        )
+        .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.

Reply via email to