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 eaa64bc0 [core] Align numeric narrowing and timestamp schema evolution 
with Java (#1102)
eaa64bc0 is described below

commit eaa64bc0974fb091bb7f00b5ff2eee08a7f1cae3
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 11 16:57:39 2026 +0800

    [core] Align numeric narrowing and timestamp schema evolution with Java 
(#1102)
---
 crates/paimon/src/arrow/evolution_cast.rs         | 426 ++++++++++++++++++++++
 crates/paimon/src/arrow/mod.rs                    |   1 +
 crates/paimon/src/arrow/nested_evolution.rs       |  64 ++++
 crates/paimon/src/file_index/evaluator.rs         |   4 +-
 crates/paimon/src/predicate_stats.rs              | 117 ++++++
 crates/paimon/src/table/data_file_reader.rs       |  49 ++-
 crates/paimon/tests/schema_evolution_cast_test.rs | 180 +++++++++
 7 files changed, 826 insertions(+), 15 deletions(-)

diff --git a/crates/paimon/src/arrow/evolution_cast.rs 
b/crates/paimon/src/arrow/evolution_cast.rs
new file mode 100644
index 00000000..5a980386
--- /dev/null
+++ b/crates/paimon/src/arrow/evolution_cast.rs
@@ -0,0 +1,426 @@
+// 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.
+
+//! Java conversions which Arrow's schema-evolution cast cannot represent.
+
+use std::sync::Arc;
+
+use arrow_array::types::{
+    Decimal128Type, Float32Type, Float64Type, Int16Type, Int32Type, Int64Type, 
Int8Type,
+};
+use arrow_array::{cast::AsArray, ArrayRef, Int16Array, Int32Array, Int64Array, 
Int8Array};
+use arrow_schema::{DataType as ArrowType, TimeUnit};
+
+use crate::spec::DataType;
+
+/// Java Number narrowing keeps low bits for integers. Floating-point values
+/// first truncate and saturate to int/long, then narrow to byte/short. DECIMAL
+/// truncates its fraction before BigDecimal.longValue keeps the low 64 bits.
+pub(super) fn numeric_to_integer(source: &ArrayRef, target: &ArrowType) -> 
crate::Result<ArrayRef> {
+    macro_rules! values {
+        ($ty:ty, $convert:expr) => {
+            source
+                .as_primitive::<$ty>()
+                .iter()
+                .map(|value| value.map($convert))
+                .collect::<Vec<Option<i64>>>()
+        };
+    }
+    let wide = matches!(target, ArrowType::Int64);
+    let values = match source.data_type() {
+        ArrowType::Int8 => values!(Int8Type, |v| v as i64),
+        ArrowType::Int16 => values!(Int16Type, |v| v as i64),
+        ArrowType::Int32 => values!(Int32Type, |v| v as i64),
+        ArrowType::Int64 => values!(Int64Type, |v| v),
+        ArrowType::Float32 => values!(Float32Type, |v| if wide {
+            v as i64
+        } else {
+            v as i32 as i64
+        }),
+        ArrowType::Float64 => values!(Float64Type, |v| if wide {
+            v as i64
+        } else {
+            v as i32 as i64
+        }),
+        ArrowType::Decimal128(_, scale) => {
+            let divisor = 10_i128.pow(*scale as u32);
+            values!(Decimal128Type, |v| (v / divisor) as i64)
+        }
+        other => {
+            return Err(crate::Error::DataInvalid {
+                message: format!("Invalid numeric schema-evolution input 
{other:?}"),
+                source: None,
+            })
+        }
+    };
+    let result: ArrayRef = match target {
+        ArrowType::Int8 => Arc::new(Int8Array::from_iter(
+            values.into_iter().map(|v| v.map(|v| v as i8)),
+        )),
+        ArrowType::Int16 => Arc::new(Int16Array::from_iter(
+            values.into_iter().map(|v| v.map(|v| v as i16)),
+        )),
+        ArrowType::Int32 => Arc::new(Int32Array::from_iter(
+            values.into_iter().map(|v| v.map(|v| v as i32)),
+        )),
+        ArrowType::Int64 => Arc::new(Int64Array::from(values)),
+        _ => unreachable!("validated integral target"),
+    };
+    Ok(result)
+}
+
+/// Match DateTimeUtils.truncate for the same timestamp kind. Work in i128 so
+/// flooring a pre-epoch value cannot overflow the source's i64 unit before
+/// converting to a coarser target unit. Cross-kind timezone casts are handled
+/// by the caller's existing cast path.
+pub(super) fn timestamp_precision(
+    source: &ArrayRef,
+    source_type: &DataType,
+    target_type: &DataType,
+    target_arrow: &ArrowType,
+) -> crate::Result<Option<ArrayRef>> {
+    let precision = match (source_type, target_type) {
+        (DataType::Timestamp(from), DataType::Timestamp(to))
+            if from.precision() > to.precision() =>
+        {
+            to.precision()
+        }
+        (DataType::LocalZonedTimestamp(from), 
DataType::LocalZonedTimestamp(to))
+            if from.precision() > to.precision() =>
+        {
+            to.precision()
+        }
+        _ => return Ok(None),
+    };
+    let nanos_per_tick = |unit: &TimeUnit| -> i128 {
+        match unit {
+            TimeUnit::Second => 1_000_000_000,
+            TimeUnit::Millisecond => 1_000_000,
+            TimeUnit::Microsecond => 1_000,
+            TimeUnit::Nanosecond => 1,
+        }
+    };
+    let ArrowType::Timestamp(source_unit, _) = source.data_type() else {
+        return Err(crate::Error::DataInvalid {
+            message: "Expected timestamp schema-evolution input".into(),
+            source: None,
+        });
+    };
+    let ArrowType::Timestamp(target_unit, _) = target_arrow else {
+        unreachable!()
+    };
+    let raw = arrow_cast::cast(source, &ArrowType::Int64).map_err(cast_error)?;
+    let quantum = 10_i128.pow(9 - precision);
+    let ticks = 
Int64Array::from_iter(raw.as_primitive::<Int64Type>().iter().map(|value| {
+        value.and_then(|value| {
+            let nanos = i128::from(value) * nanos_per_tick(source_unit);
+            let truncated = nanos.div_euclid(quantum) * quantum;
+            i64::try_from(truncated / nanos_per_tick(target_unit)).ok()
+        })
+    }));
+    Ok(Some(
+        arrow_cast::cast(&(Arc::new(ticks) as ArrayRef), 
target_arrow).map_err(cast_error)?,
+    ))
+}
+
+fn cast_error(error: arrow_schema::ArrowError) -> crate::Error {
+    crate::Error::UnexpectedError {
+        message: "Failed to cast timestamp during schema evolution".into(),
+        source: Some(Box::new(error)),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::spec::{LocalZonedTimestampType, TimestampType};
+    use arrow_array::{Decimal128Array, Float32Array, Float64Array};
+
+    #[test]
+    fn floating_point_narrowing_saturates_before_retaining_low_bits() {
+        let values = vec![
+            Some(128.9),
+            Some(-129.9),
+            Some(32768.9),
+            Some(-32769.9),
+            Some(1e30),
+            Some(-1e30),
+            None,
+        ];
+        for source in [
+            Arc::new(Float64Array::from(values.clone())) as ArrayRef,
+            Arc::new(Float32Array::from_iter(
+                values.iter().map(|v| v.map(|v| v as f32)),
+            )) as ArrayRef,
+        ] {
+            for (target, expected) in [
+                (
+                    ArrowType::Int8,
+                    vec![
+                        Some(-128),
+                        Some(127),
+                        Some(0),
+                        Some(-1),
+                        Some(-1),
+                        Some(0),
+                        None,
+                    ],
+                ),
+                (
+                    ArrowType::Int16,
+                    vec![
+                        Some(128),
+                        Some(-129),
+                        Some(-32768),
+                        Some(32767),
+                        Some(-1),
+                        Some(0),
+                        None,
+                    ],
+                ),
+                (
+                    ArrowType::Int32,
+                    vec![
+                        Some(128),
+                        Some(-129),
+                        Some(32768),
+                        Some(-32769),
+                        Some(i32::MAX as i64),
+                        Some(i32::MIN as i64),
+                        None,
+                    ],
+                ),
+                (
+                    ArrowType::Int64,
+                    vec![
+                        Some(128),
+                        Some(-129),
+                        Some(32768),
+                        Some(-32769),
+                        Some(i64::MAX),
+                        Some(i64::MIN),
+                        None,
+                    ],
+                ),
+            ] {
+                let output = numeric_to_integer(&source.slice(0, 
values.len()), &target).unwrap();
+                let widened = arrow_cast::cast(&output, 
&ArrowType::Int64).unwrap();
+                assert_eq!(
+                    widened.as_primitive::<Int64Type>(),
+                    &Int64Array::from(expected)
+                );
+            }
+        }
+    }
+
+    #[test]
+    fn decimal_integrals_truncate_then_wrap_at_each_target_width() {
+        let source: ArrayRef = Arc::new(
+            Decimal128Array::from(vec![
+                Some(12_899),
+                Some(-12_999),
+                Some(922_337_203_685_477_580_800),
+                Some(-922_337_203_685_477_580_900),
+                Some(99_999_999_999_999_999_999_999_999_999_999_999_999),
+                None,
+            ])
+            .with_precision_and_scale(38, 2)
+            .unwrap(),
+        );
+        // BigDecimal.longValue of the largest DECIMAL(38,2), after truncation.
+        let values = [
+            Some(128_i64),
+            Some(-129),
+            Some(i64::MIN),
+            Some(i64::MAX),
+            Some(-5_527_149_226_598_858_753),
+            None,
+        ];
+        for target in [
+            ArrowType::Int8,
+            ArrowType::Int16,
+            ArrowType::Int32,
+            ArrowType::Int64,
+        ] {
+            let output = numeric_to_integer(&source, &target).unwrap();
+            let expected = values
+                .iter()
+                .map(|value| {
+                    value.map(|value| match target {
+                        ArrowType::Int8 => value as i8 as i64,
+                        ArrowType::Int16 => value as i16 as i64,
+                        ArrowType::Int32 => value as i32 as i64,
+                        _ => value,
+                    })
+                })
+                .collect::<Vec<_>>();
+            let widened = arrow_cast::cast(&output, 
&ArrowType::Int64).unwrap();
+            assert_eq!(
+                widened.as_primitive::<Int64Type>(),
+                &Int64Array::from(expected)
+            );
+        }
+    }
+
+    #[test]
+    fn timestamp_precision_covers_every_unit_and_pre_epoch_flooring() {
+        for zoned in [false, true] {
+            let paimon_type = |precision| {
+                if zoned {
+                    
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(precision).unwrap())
+                } else {
+                    DataType::Timestamp(TimestampType::new(precision).unwrap())
+                }
+            };
+            let source_type = paimon_type(9);
+            let source_arrow = 
crate::arrow::paimon_type_to_arrow(&source_type).unwrap();
+            let raw: ArrayRef = Arc::new(Int64Array::from(vec![
+                Some(123_456_789),
+                Some(-1),
+                Some(-123_456_789),
+                Some(i64::MIN),
+                None,
+            ]));
+            let source = arrow_cast::cast(&raw, &source_arrow).unwrap();
+            for precision in 0..9 {
+                let target = paimon_type(precision);
+                let arrow_type = 
crate::arrow::paimon_type_to_arrow(&target).unwrap();
+                let output = timestamp_precision(&source, &source_type, 
&target, &arrow_type)
+                    .unwrap()
+                    .unwrap();
+                assert_eq!(output.data_type(), &arrow_type);
+                let divisor = 10_i128.pow(9 - precision);
+                let quantum_in_target = match precision {
+                    0 => 1,
+                    1..=3 => 10_i128.pow(3 - precision),
+                    4..=6 => 10_i128.pow(6 - precision),
+                    _ => 10_i128.pow(9 - precision),
+                };
+                let expected = raw
+                    .as_primitive::<Int64Type>()
+                    .iter()
+                    .map(|v| {
+                        v.and_then(|v| {
+                            i64::try_from(i128::from(v).div_euclid(divisor) * 
quantum_in_target)
+                                .ok()
+                        })
+                    })
+                    .collect::<Vec<_>>();
+                let widened = arrow_cast::cast(&output, 
&ArrowType::Int64).unwrap();
+                assert_eq!(
+                    widened.as_primitive::<Int64Type>(),
+                    &Int64Array::from(expected),
+                    "precision {precision}"
+                );
+            }
+            assert!(
+                timestamp_precision(&source, &source_type, &source_type, 
&source_arrow)
+                    .unwrap()
+                    .is_none()
+            );
+        }
+    }
+}
+
+#[cfg(test)]
+mod integer_tests {
+    use super::*;
+
+    #[test]
+    fn every_signed_integer_width_preserves_nulls_and_java_narrowing() {
+        let sources: Vec<ArrayRef> = vec![
+            Arc::new(Int8Array::from(vec![
+                Some(i8::MIN),
+                Some(i8::MAX),
+                Some(-1),
+                None,
+            ])),
+            Arc::new(Int16Array::from(vec![
+                Some(i16::MIN),
+                Some(i16::MAX),
+                Some(-129),
+                None,
+            ])),
+            Arc::new(Int32Array::from(vec![
+                Some(i32::MIN),
+                Some(i32::MAX),
+                Some(32768),
+                None,
+            ])),
+            Arc::new(Int64Array::from(vec![
+                Some(i64::MIN),
+                Some(i64::MAX),
+                Some(2147483648),
+                None,
+            ])),
+        ];
+        let expected: [[Vec<Option<i64>>; 4]; 4] = [
+            [
+                vec![Some(-128), Some(127), Some(-1), None],
+                vec![Some(-128), Some(127), Some(-1), None],
+                vec![Some(-128), Some(127), Some(-1), None],
+                vec![Some(-128), Some(127), Some(-1), None],
+            ],
+            [
+                vec![Some(0), Some(-1), Some(127), None],
+                vec![Some(-32768), Some(32767), Some(-129), None],
+                vec![Some(-32768), Some(32767), Some(-129), None],
+                vec![Some(-32768), Some(32767), Some(-129), None],
+            ],
+            [
+                vec![Some(0), Some(-1), Some(0), None],
+                vec![Some(0), Some(-1), Some(-32768), None],
+                vec![
+                    Some(i32::MIN as i64),
+                    Some(i32::MAX as i64),
+                    Some(32768),
+                    None,
+                ],
+                vec![
+                    Some(i32::MIN as i64),
+                    Some(i32::MAX as i64),
+                    Some(32768),
+                    None,
+                ],
+            ],
+            [
+                vec![Some(0), Some(-1), Some(0), None],
+                vec![Some(0), Some(-1), Some(0), None],
+                vec![Some(0), Some(-1), Some(i32::MIN as i64), None],
+                vec![Some(i64::MIN), Some(i64::MAX), Some(2147483648), None],
+            ],
+        ];
+        for (source, expected) in sources.into_iter().zip(expected) {
+            for (target, expected) in [
+                ArrowType::Int8,
+                ArrowType::Int16,
+                ArrowType::Int32,
+                ArrowType::Int64,
+            ]
+            .iter()
+            .zip(expected)
+            {
+                let output = numeric_to_integer(&source, target).unwrap();
+                let widened = arrow_cast::cast(&output, 
&ArrowType::Int64).unwrap();
+                assert_eq!(
+                    widened.as_primitive::<Int64Type>(),
+                    &Int64Array::from(expected)
+                );
+            }
+        }
+    }
+}
diff --git a/crates/paimon/src/arrow/mod.rs b/crates/paimon/src/arrow/mod.rs
index 286580e5..2ef79ced 100644
--- a/crates/paimon/src/arrow/mod.rs
+++ b/crates/paimon/src/arrow/mod.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+mod evolution_cast;
 pub(crate) mod filtering;
 pub(crate) mod format;
 pub(crate) mod nested_evolution;
diff --git a/crates/paimon/src/arrow/nested_evolution.rs 
b/crates/paimon/src/arrow/nested_evolution.rs
index ffcc7bae..e3fad1d3 100644
--- a/crates/paimon/src/arrow/nested_evolution.rs
+++ b/crates/paimon/src/arrow/nested_evolution.rs
@@ -221,6 +221,27 @@ pub(crate) fn evolve_column(
         }
     }
 
+    if matches!(
+        target_type,
+        DataType::TinyInt(_) | DataType::SmallInt(_) | DataType::Int(_) | 
DataType::BigInt(_)
+    ) && matches!(
+        source_type,
+        DataType::TinyInt(_)
+            | DataType::SmallInt(_)
+            | DataType::Int(_)
+            | DataType::BigInt(_)
+            | DataType::Float(_)
+            | DataType::Double(_)
+            | DataType::Decimal(_)
+    ) {
+        return super::evolution_cast::numeric_to_integer(source, 
&target_arrow);
+    }
+    if let Some(result) =
+        super::evolution_cast::timestamp_precision(source, source_type, 
target_type, &target_arrow)?
+    {
+        return Ok(result);
+    }
+
     cast(source, &target_arrow).map_err(|e| crate::Error::UnexpectedError {
         message: format!(
             "failed to cast nested value from {:?} to {:?} during schema 
evolution",
@@ -1033,6 +1054,49 @@ mod tests {
         assert_eq!(strings(&out).value(0), "{k -> 7}");
     }
 
+    #[test]
+    fn narrowing_integers_keeps_low_bits_like_java() {
+        let source: ArrayRef = Arc::new(Int64Array::from(vec![
+            Some(128),
+            Some(-129),
+            Some(i64::MAX),
+            Some(i64::MIN),
+            None,
+        ]));
+        let target = DataType::TinyInt(crate::spec::TinyIntType::new());
+        let output = evolve_column(&source, 
&DataType::BigInt(BigIntType::new()), &target).unwrap();
+        assert_eq!(
+            output
+                .as_any()
+                .downcast_ref::<arrow_array::Int8Array>()
+                .unwrap(),
+            &arrow_array::Int8Array::from(vec![Some(-128), Some(127), 
Some(-1), Some(0), None]),
+        );
+    }
+
+    #[test]
+    fn timestamp_precision_is_truncated_within_the_same_arrow_unit() {
+        let source: ArrayRef = Arc::new(TimestampMillisecondArray::from(vec![
+            Some(123),
+            Some(-1),
+            Some(-123),
+            None,
+        ]));
+        let output = evolve_column(
+            &source,
+            &DataType::Timestamp(TimestampType::new(3).unwrap()),
+            &DataType::Timestamp(TimestampType::new(1).unwrap()),
+        )
+        .unwrap();
+        assert_eq!(
+            output
+                .as_any()
+                .downcast_ref::<TimestampMillisecondArray>()
+                .unwrap(),
+            &TimestampMillisecondArray::from(vec![Some(100), Some(-100), 
Some(-200), None]),
+        );
+    }
+
     #[test]
     fn reducing_decimal_scale_rounds_half_up_for_both_signs() {
         let source: ArrayRef = Arc::new(
diff --git a/crates/paimon/src/file_index/evaluator.rs 
b/crates/paimon/src/file_index/evaluator.rs
index 51c5e82a..8fdd9bfc 100644
--- a/crates/paimon/src/file_index/evaluator.rs
+++ b/crates/paimon/src/file_index/evaluator.rs
@@ -186,8 +186,8 @@ fn devolve_literals(
     if same_type_ignoring_nullability(table_type, data_type) {
         return Some(literals.to_vec());
     }
-    // Narrowing can turn non-null file values into NULL, so reject it even for
-    // predicates without literals, such as IS NULL and IS NOT NULL.
+    // Integer narrowing can wrap values, so it is outside Java's evolution
+    // whitelist, including predicates without literals such as IS NULL.
     if integer_width(table_type)? <= integer_width(data_type)? {
         return None;
     }
diff --git a/crates/paimon/src/predicate_stats.rs 
b/crates/paimon/src/predicate_stats.rs
index a111a658..49e86ac6 100644
--- a/crates/paimon/src/predicate_stats.rs
+++ b/crates/paimon/src/predicate_stats.rs
@@ -53,6 +53,9 @@ pub(crate) fn data_leaf_may_match<T: StatsAccessor>(
     if row_count <= 0 {
         return false;
     }
+    if !stats_evolution_is_safe(stats_data_type, predicate_data_type) {
+        return true;
+    }
 
     let null_count = stats.null_count(index);
     let all_null = null_count.map(|count| count == row_count);
@@ -272,6 +275,9 @@ pub(crate) fn data_leaf_must_match<T: StatsAccessor>(
     if row_count <= 0 {
         return false;
     }
+    if !stats_evolution_is_safe(stats_data_type, predicate_data_type) {
+        return false;
+    }
 
     let null_count = stats.null_count(index);
     match op {
@@ -568,6 +574,24 @@ fn predicate_must_match_with_schema<T: StatsAccessor>(
     }
 }
 
+fn stats_evolution_is_safe(source: &DataType, target: &DataType) -> bool {
+    // Java's CastExecutors.castLiteralsWithEvolution permits unchanged types
+    // and integer widening. Narrowing can wrap, and timestamp precision 
changes
+    // alter values even when their Arrow storage types are identical. Neither
+    // the original bounds nor null counts can prove a match after unsafe 
casts.
+    source.equals_ignore_nullable(target)
+        || matches!(
+            (source, target),
+            (
+                DataType::TinyInt(_),
+                DataType::SmallInt(_) | DataType::Int(_) | DataType::BigInt(_)
+            ) | (
+                DataType::SmallInt(_),
+                DataType::Int(_) | DataType::BigInt(_)
+            ) | (DataType::Int(_), DataType::BigInt(_))
+        )
+}
+
 fn coerce_stats_datum_for_predicate(datum: Datum, predicate_data_type: 
&DataType) -> Option<Datum> {
     match (datum, predicate_data_type) {
         (datum @ Datum::Bool(_), DataType::Boolean(_))
@@ -783,6 +807,99 @@ mod tests {
         nullable_int_stats(Some(min), Some(max), Some(0))
     }
 
+    #[test]
+    fn timestamp_precision_changes_make_bounds_and_null_counts_unknown() {
+        use crate::spec::{LocalZonedTimestampType, TimestampType};
+
+        for (source, target, value) in [
+            (
+                DataType::Timestamp(TimestampType::new(3).unwrap()),
+                DataType::Timestamp(TimestampType::new(1).unwrap()),
+                Datum::Timestamp {
+                    millis: 123,
+                    nanos: 0,
+                },
+            ),
+            (
+                
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(3).unwrap()),
+                
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(1).unwrap()),
+                Datum::LocalZonedTimestamp {
+                    millis: 123,
+                    nanos: 0,
+                },
+            ),
+        ] {
+            let stats = MockStats {
+                row_count: 1,
+                null_count: Some(0),
+                min: Some(value.clone()),
+                max: Some(value.clone()),
+            };
+            // Even a predicate which matches the *old* bounds cannot establish
+            // must-match: NOT would then wrongly prune the converted value.
+            for op in [
+                PredicateOperator::Eq,
+                PredicateOperator::NotEq,
+                PredicateOperator::Lt,
+                PredicateOperator::Gt,
+                PredicateOperator::Between,
+                PredicateOperator::NotBetween,
+                PredicateOperator::IsNull,
+                PredicateOperator::IsNotNull,
+            ] {
+                let literals = [value.clone(), value.clone()];
+                assert!(data_leaf_may_match(
+                    0, &source, &target, op, &literals, &stats
+                ));
+                assert!(!data_leaf_must_match(
+                    0, &source, &target, op, &literals, &stats
+                ));
+            }
+        }
+    }
+
+    #[test]
+    fn integer_widening_preserves_stats_pruning_but_narrowing_is_unknown() {
+        use crate::spec::{BigIntType, TinyIntType};
+
+        let source = DataType::Int(IntType::new());
+        let stats = int_stats(128, 128);
+        let widened = DataType::BigInt(BigIntType::new());
+        assert!(!data_leaf_may_match(
+            0,
+            &source,
+            &widened,
+            PredicateOperator::Eq,
+            &[Datum::Long(100)],
+            &stats
+        ));
+        assert!(data_leaf_must_match(
+            0,
+            &source,
+            &widened,
+            PredicateOperator::Eq,
+            &[Datum::Long(128)],
+            &stats
+        ));
+        let narrowed = DataType::TinyInt(TinyIntType::new());
+        assert!(data_leaf_may_match(
+            0,
+            &source,
+            &narrowed,
+            PredicateOperator::Eq,
+            &[Datum::TinyInt(-128)],
+            &stats
+        ));
+        assert!(!data_leaf_must_match(
+            0,
+            &source,
+            &narrowed,
+            PredicateOperator::IsNotNull,
+            &[],
+            &stats
+        ));
+    }
+
     fn nullable_int_stats(
         min: Option<i32>,
         max: Option<i32>,
diff --git a/crates/paimon/src/table/data_file_reader.rs 
b/crates/paimon/src/table/data_file_reader.rs
index 1b4ac987..d1425d16 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -539,18 +539,40 @@ impl DataFileReader {
         for predicate in &predicates {
             crate::arrow::residual::collect_predicate_leaf_refs(predicate, 
&mut refs);
         }
-        let metadata_residual = (projects_row_id
-            && predicates
-                .iter()
-                .any(|predicate| !matches!(predicate, Predicate::AlwaysTrue)))
+        // A predicate's literals use the current table type. Casting a 
physical
+        // old-file column inside the decoder can change Java narrowing or
+        // timestamp precision semantics, and filtering there loses those rows
+        // before schema reconciliation. Evaluate the complete predicate after
+        // reconciliation, as Java's schema-evolution reader does.
+        let evolved_predicate = data_fields.as_deref().is_some_and(|fields| {
+            refs.iter().any(|(name, index)| {
+                if crate::spec::is_row_tracking_column(name) {
+                    return false;
+                }
+                table_fields.get(*index).is_some_and(|target| {
+                    fields
+                        .iter()
+                        .find(|source| source.id() == target.id())
+                        .is_none_or(|source| {
+                            !source
+                                .data_type()
+                                .equals_ignore_nullable(target.data_type())
+                        })
+                })
+            })
+        });
+        let post_read_filter = evolved_predicate
+            || (projects_row_id
+                && predicates
+                    .iter()
+                    .any(|predicate| !matches!(predicate, 
Predicate::AlwaysTrue)))
             || (row_tracking_enabled
                 && refs
                     .iter()
                     .any(|(name, _)| *name == 
crate::spec::SEQUENCE_NUMBER_FIELD_NAME));
-        if metadata_residual {
-            // Decoder-side filtering can drop rows before positional `_ROW_ID`
-            // attachment. Read predicate-only columns without pushing the 
filter
-            // down, attach the original ids, then evaluate the exact 
predicate.
+        if post_read_filter {
+            // Read predicate-only columns without decoder filtering. Reconcile
+            // old-file types and attach original row IDs before exact 
filtering.
             for (name, index) in refs {
                 if name == crate::spec::SEQUENCE_NUMBER_FIELD_NAME {
                     crate::arrow::residual::push_unique_scan_field(
@@ -572,7 +594,7 @@ impl DataFileReader {
         let projects_row_id = read_type
             .iter()
             .any(|field| field.name() == ROW_ID_FIELD_NAME);
-        let residual_predicates = metadata_residual.then(|| 
crate::arrow::format::FilePredicates {
+        let residual_predicates = post_read_filter.then(|| 
crate::arrow::format::FilePredicates {
             predicates: predicates.clone(),
             row_filter_factory: None,
             file_fields: table_fields.clone(),
@@ -582,7 +604,7 @@ impl DataFileReader {
         // post-filter until expression adaptation is proven for that path.
         // Positional `_ROW_ID` materialization must see the unfiltered row
         // stream, so the engine hook stays disabled for this projection.
-        let row_filter_factory = (data_fields.is_none() && !projects_row_id && 
!metadata_residual)
+        let row_filter_factory = (data_fields.is_none() && !projects_row_id && 
!post_read_filter)
             .then(|| self.row_filter_factory.clone())
             .flatten();
         let file_io = self.file_io.clone();
@@ -629,7 +651,7 @@ impl DataFileReader {
             mosaic_prefetch,
         )?;
         // Remap predicates from table-level to file-level indices.
-        let file_predicates = if metadata_residual {
+        let file_predicates = if post_read_filter {
             None
         } else {
             let remapped = crate::arrow::filtering::remap_predicates_to_file(
@@ -3297,11 +3319,12 @@ mod tests {
         )
         .unwrap();
         for (case, values, expected_values) in [
-            ("overflow_only", vec![Some(383)], vec![None]),
+            // Java's Number.byteValue keeps the low bits: 383 becomes 127.
+            ("overflow_only", vec![Some(383)], vec![Some(127)]),
             (
                 "mixed",
                 vec![Some(127), Some(383), None],
-                vec![Some(127), None, None],
+                vec![Some(127), Some(127), None],
             ),
         ] {
             let mut avro_writer = apache_avro::Writer::new(&avro_schema, 
Vec::new());
diff --git a/crates/paimon/tests/schema_evolution_cast_test.rs 
b/crates/paimon/tests/schema_evolution_cast_test.rs
new file mode 100644
index 00000000..6b3b63f5
--- /dev/null
+++ b/crates/paimon/tests/schema_evolution_cast_test.rs
@@ -0,0 +1,180 @@
+// 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.
+
+mod common;
+
+use std::sync::Arc;
+
+use arrow_array::{
+    ArrayRef, Int32Array, Int64Array, Int8Array, RecordBatch, 
TimestampMillisecondArray,
+};
+use futures::TryStreamExt;
+use paimon::spec::{
+    BigIntType, DataType, Datum, IntType, Predicate, PredicateBuilder, Schema, 
SchemaChange,
+    TableSchema, TimestampType, TinyIntType,
+};
+use paimon::table::Table;
+
+use common::incremental_helpers::{memory_table, persist_table_schema, 
setup_dirs, write_batch};
+
+async fn ids(table: &Table, predicate: Predicate) -> Vec<i32> {
+    let mut builder = table.new_read_builder();
+    builder
+        .with_filter(predicate)
+        .with_projection(&["id"])
+        .unwrap();
+    let plan = builder.new_scan().plan().await.unwrap();
+    let batches = builder
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect::<Vec<_>>()
+        .await
+        .unwrap();
+    let mut ids = batches
+        .iter()
+        .flat_map(|batch| {
+            batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .values()
+                .iter()
+                .copied()
+        })
+        .collect::<Vec<_>>();
+    ids.sort_unstable();
+    ids
+}
+
+#[tokio::test]
+async fn 
converted_predicates_filter_after_schema_evolution_for_every_table_mode() {
+    for mode in ["append", "pk", "de"] {
+        let mut schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("v", DataType::BigInt(BigIntType::new()))
+            .column("t", DataType::Timestamp(TimestampType::new(3).unwrap()))
+            .option("read.batch-size", "1");
+        if mode == "pk" {
+            schema = schema.primary_key(["id"]).option("bucket", "1");
+        }
+        if mode == "de" {
+            schema = schema
+                .option("data-evolution.enabled", "true")
+                .option("row-tracking.enabled", "true");
+        }
+        let (io, old) = memory_table(
+            &format!("memory:/evolution_cast_{mode}"),
+            TableSchema::new(0, &schema.build().unwrap()),
+        );
+        setup_dirs(&io, old.location()).await;
+        persist_table_schema(&io, old.location(), old.schema()).await;
+        write_batch(
+            &old,
+            &RecordBatch::try_from_iter([
+                ("id", Arc::new(Int32Array::from(vec![1, 2, 3])) as ArrayRef),
+                (
+                    "v",
+                    Arc::new(Int64Array::from(vec![Some(128), Some(-129), 
None])) as ArrayRef,
+                ),
+                (
+                    "t",
+                    Arc::new(TimestampMillisecondArray::from(vec![
+                        Some(123),
+                        Some(-1),
+                        None,
+                    ])) as ArrayRef,
+                ),
+            ])
+            .unwrap(),
+        )
+        .await;
+        // A single-value file's bounds must not prune values after truncation
+        // or wrapping. A wide old-file range can hide this stats regression.
+        write_batch(
+            &old,
+            &RecordBatch::try_from_iter([
+                ("id", Arc::new(Int32Array::from(vec![5])) as ArrayRef),
+                ("v", Arc::new(Int64Array::from(vec![128])) as ArrayRef),
+                (
+                    "t",
+                    Arc::new(TimestampMillisecondArray::from(vec![123])) as 
ArrayRef,
+                ),
+            ])
+            .unwrap(),
+        )
+        .await;
+        let schema = old
+            .schema()
+            .apply_changes(vec![
+                SchemaChange::update_column_type("v".into(), 
DataType::TinyInt(TinyIntType::new())),
+                SchemaChange::update_column_type(
+                    "t".into(),
+                    DataType::Timestamp(TimestampType::new(1).unwrap()),
+                ),
+            ])
+            .unwrap();
+        persist_table_schema(&io, old.location(), &schema).await;
+        let current = old.copy_with_resolved_schema(schema, "main").unwrap();
+        write_batch(
+            &current,
+            &RecordBatch::try_from_iter([
+                ("id", Arc::new(Int32Array::from(vec![4])) as ArrayRef),
+                ("v", Arc::new(Int8Array::from(vec![7])) as ArrayRef),
+                (
+                    "t",
+                    Arc::new(TimestampMillisecondArray::from(vec![500])) as 
ArrayRef,
+                ),
+            ])
+            .unwrap(),
+        )
+        .await;
+        let pb = PredicateBuilder::new(current.schema().fields());
+        let narrowed = pb.equal("v", Datum::TinyInt(-128)).unwrap();
+        let truncated = pb
+            .equal(
+                "t",
+                Datum::Timestamp {
+                    millis: 100,
+                    nanos: 0,
+                },
+            )
+            .unwrap();
+        assert_eq!(
+            ids(&current, narrowed.clone()).await,
+            vec![1, 5],
+            "{mode} numeric"
+        );
+        assert_eq!(
+            ids(&current, truncated.clone()).await,
+            vec![1, 5],
+            "{mode} timestamp"
+        );
+        assert_eq!(
+            ids(&current, Predicate::and(vec![narrowed, truncated])).await,
+            vec![1, 5],
+            "{mode} compound"
+        );
+        assert_eq!(
+            ids(&current, pb.is_null("v").unwrap()).await,
+            vec![3],
+            "{mode} NULL"
+        );
+    }
+}

Reply via email to