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 694be88c fix(table): support all Java-comparable types in min/max 
aggregators (#827)
694be88c is described below

commit 694be88cb1c9c7f188d65e20261ab5231ce9d18b
Author: jackylee <[email protected]>
AuthorDate: Thu Sep 17 16:07:40 2026 +0800

    fix(table): support all Java-comparable types in min/max aggregators (#827)
---
 crates/paimon/src/spec/aggregation.rs         |  22 ++-
 crates/paimon/src/table/aggregator/numeric.rs | 225 ++++++++++++++++++++++++--
 2 files changed, 230 insertions(+), 17 deletions(-)

diff --git a/crates/paimon/src/spec/aggregation.rs 
b/crates/paimon/src/spec/aggregation.rs
index 5f2b4c28..b78096c2 100644
--- a/crates/paimon/src/spec/aggregation.rs
+++ b/crates/paimon/src/spec/aggregation.rs
@@ -430,9 +430,13 @@ pub(crate) fn validate_aggregator_for_type(
                 | DataType::Float(_)
                 | DataType::Double(_)
         ),
+        // Java `FieldMinAggFactory` / `FieldMaxAggFactory` accept anything
+        // `TypeCheckUtils#isComparable` allows, i.e. everything except MAP,
+        // MULTISET, ROW, ARRAY, VECTOR, VARIANT and BLOB.
         "min" | "max" => matches!(
             dt,
-            DataType::TinyInt(_)
+            DataType::Boolean(_)
+                | DataType::TinyInt(_)
                 | DataType::SmallInt(_)
                 | DataType::Int(_)
                 | DataType::BigInt(_)
@@ -442,8 +446,11 @@ pub(crate) fn validate_aggregator_for_type(
                 | DataType::Date(_)
                 | DataType::Time(_)
                 | DataType::Timestamp(_)
+                | DataType::LocalZonedTimestamp(_)
                 | DataType::Char(_)
                 | DataType::VarChar(_)
+                | DataType::Binary(_)
+                | DataType::VarBinary(_)
         ),
         "bool_and" | "bool_or" => matches!(dt, DataType::Boolean(_)),
         // Java `FieldListaggAggFactory` only accepts unbounded VARCHAR 
(STRING);
@@ -842,8 +849,9 @@ mod tests {
     #[test]
     fn validation_table_matches_constructors() {
         use crate::spec::{
-            BigIntType, BooleanType, DateType, DecimalType, DoubleType, 
FloatType, SmallIntType,
-            TimeType, TimestampType, TinyIntType,
+            BigIntType, BinaryType, BlobType, BooleanType, DateType, 
DecimalType, DoubleType,
+            FloatType, LocalZonedTimestampType, SmallIntType, TimeType, 
TimestampType, TinyIntType,
+            VarBinaryType,
         };
 
         let names = [
@@ -875,6 +883,14 @@ mod tests {
             DataType::Date(DateType::new()),
             DataType::Time(TimeType::new(3).unwrap()),
             DataType::Timestamp(TimestampType::new(6).unwrap()),
+            // Carries a timezone in Arrow, unlike TIMESTAMP — min/max must 
build a
+            // result array that still matches the schema.
+            
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(3).unwrap()),
+            DataType::Binary(BinaryType::new(4).unwrap()),
+            DataType::VarBinary(VarBinaryType::new(8).unwrap()),
+            // Maps to the same Arrow Binary as the two above, but Java's
+            // `isComparable` excludes it, so min/max must keep rejecting it.
+            DataType::Blob(BlobType::new()),
             // Bounded VARCHAR (listagg must reject) and unbounded STRING
             // (listagg must accept) — exercises both sides of the listagg 
rule.
             DataType::VarChar(VarCharType::new(255).unwrap()),
diff --git a/crates/paimon/src/table/aggregator/numeric.rs 
b/crates/paimon/src/table/aggregator/numeric.rs
index f4851193..f093827b 100644
--- a/crates/paimon/src/table/aggregator/numeric.rs
+++ b/crates/paimon/src/table/aggregator/numeric.rs
@@ -42,11 +42,12 @@ use std::sync::Arc;
 
 use arrow_array::builder::Decimal128Builder;
 use arrow_array::{
-    Array, ArrayRef, Date32Array, Decimal128Array, Float32Array, Float64Array, 
Int16Array,
-    Int32Array, Int64Array, Int8Array, StringArray, Time32MillisecondArray,
-    TimestampMicrosecondArray, TimestampMillisecondArray, 
TimestampNanosecondArray,
+    Array, ArrayRef, BinaryArray, BooleanArray, Date32Array, Decimal128Array, 
Float32Array,
+    Float64Array, Int16Array, Int32Array, Int64Array, Int8Array, StringArray,
+    Time32MillisecondArray, TimestampMicrosecondArray, 
TimestampMillisecondArray,
+    TimestampNanosecondArray,
 };
-use arrow_schema::TimeUnit;
+use arrow_schema::{DataType as ArrowDataType, TimeUnit};
 
 use super::{unsupported_type_error, FieldAggregator};
 use crate::spec::DataType;
@@ -386,6 +387,7 @@ impl FieldAggregator for ProductAgg {
 /// `None` means "no non-null value seen yet for the current group".
 #[derive(Debug)]
 enum MinMaxState {
+    Bool(Option<bool>),
     I8(Option<i8>),
     I16(Option<i16>),
     I32(Option<i32>),
@@ -401,11 +403,20 @@ enum MinMaxState {
     /// Paimon `TIME` is encoded as Arrow `Time32(Millisecond)` regardless of
     /// declared precision, so a single accumulator variant suffices.
     Time32Ms(Option<i32>),
+    /// Covers `TIMESTAMP` and `TIMESTAMP WITH LOCAL TIME ZONE`, which Java
+    /// compares through the same `Timestamp#compareTo` branch. `timezone` is
+    /// `Some("UTC")` for the latter, mirroring `paimon_type_to_arrow`, and 
has to
+    /// be carried here because the result array must still match the field's
+    /// Arrow type.
     Timestamp {
         unit: TimeUnit,
+        timezone: Option<Arc<str>>,
         acc: Option<i64>,
     },
     Utf8(Option<String>),
+    /// `BINARY` and `VARBINARY`. Not `BLOB`, which Arrow also maps to `Binary`
+    /// but Java's `TypeCheckUtils#isComparable` excludes.
+    Binary(Option<Vec<u8>>),
 }
 
 fn make_minmax_state(
@@ -414,6 +425,7 @@ fn make_minmax_state(
     op: &str,
 ) -> crate::Result<MinMaxState> {
     Ok(match data_type {
+        DataType::Boolean(_) => MinMaxState::Bool(None),
         DataType::TinyInt(_) => MinMaxState::I8(None),
         DataType::SmallInt(_) => MinMaxState::I16(None),
         DataType::Int(_) => MinMaxState::I32(None),
@@ -429,13 +441,37 @@ fn make_minmax_state(
         DataType::Time(_) => MinMaxState::Time32Ms(None),
         DataType::Timestamp(t) => MinMaxState::Timestamp {
             unit: timestamp_time_unit(t.precision())?,
+            timezone: arrow_timestamp_timezone(field_name, data_type)?,
+            acc: None,
+        },
+        DataType::LocalZonedTimestamp(t) => MinMaxState::Timestamp {
+            unit: timestamp_time_unit(t.precision())?,
+            timezone: arrow_timestamp_timezone(field_name, data_type)?,
             acc: None,
         },
         DataType::Char(_) | DataType::VarChar(_) => MinMaxState::Utf8(None),
+        DataType::Binary(_) | DataType::VarBinary(_) => 
MinMaxState::Binary(None),
         other => return Err(unsupported_type_error(op, field_name, other)),
     })
 }
 
+/// Read the Arrow timezone back out of the very mapping the read schema is 
built
+/// with, so a min/max result array cannot drift from its field's Arrow type.
+fn arrow_timestamp_timezone(
+    field_name: &str,
+    data_type: &DataType,
+) -> crate::Result<Option<Arc<str>>> {
+    match crate::arrow::paimon_type_to_arrow(data_type)? {
+        ArrowDataType::Timestamp(_, timezone) => Ok(timezone),
+        other => Err(crate::Error::DataInvalid {
+            message: format!(
+                "Aggregate column '{field_name}' maps to Arrow type {other:?}, 
expected a timestamp"
+            ),
+            source: None,
+        }),
+    }
+}
+
 fn timestamp_time_unit(precision: u32) -> crate::Result<TimeUnit> {
     match precision {
         0..=3 => Ok(TimeUnit::Millisecond),
@@ -525,6 +561,36 @@ fn agg_minmax(
         }};
     }
     match state {
+        MinMaxState::Bool(acc) => {
+            // Java compares BOOLEAN with `Boolean.compare`, i.e. false < true
+            // (`InternalRowUtils#compare`), which is what Rust's `bool: Ord` 
does.
+            let v = downcast::<BooleanArray>(array, 
field_name)?.value(row_idx);
+            *acc = Some(match acc.take() {
+                None => v,
+                Some(prev) => {
+                    // Ordering rather than `<`/`>`: comparing bools with order
+                    // operators is what `clippy::bool_comparison` objects to, 
and
+                    // this keeps the tie handling identical to the other arms.
+                    let ordering = v.cmp(&prev);
+                    let take_new = if keep_smaller {
+                        if reversed {
+                            ordering.is_lt()
+                        } else {
+                            ordering.is_le()
+                        }
+                    } else if reversed {
+                        ordering.is_ge()
+                    } else {
+                        ordering.is_gt()
+                    };
+                    if take_new {
+                        v
+                    } else {
+                        prev
+                    }
+                }
+            });
+        }
         MinMaxState::I8(acc) => update_primitive!(acc, Int8Array),
         MinMaxState::I16(acc) => update_primitive!(acc, Int16Array),
         MinMaxState::I32(acc) => update_primitive!(acc, Int32Array),
@@ -534,7 +600,7 @@ fn agg_minmax(
         MinMaxState::Decimal128 { acc, .. } => update_primitive!(acc, 
Decimal128Array),
         MinMaxState::Date32(acc) => update_primitive!(acc, Date32Array),
         MinMaxState::Time32Ms(acc) => update_primitive!(acc, 
Time32MillisecondArray),
-        MinMaxState::Timestamp { unit, acc } => match unit {
+        MinMaxState::Timestamp { unit, acc, .. } => match unit {
             TimeUnit::Millisecond => update_primitive!(acc, 
TimestampMillisecondArray),
             TimeUnit::Microsecond => update_primitive!(acc, 
TimestampMicrosecondArray),
             TimeUnit::Nanosecond => update_primitive!(acc, 
TimestampNanosecondArray),
@@ -571,12 +637,39 @@ fn agg_minmax(
                 }
             });
         }
+        MinMaxState::Binary(acc) => {
+            // Java's `byteArrayCompare` is unsigned lexicographic with a 
length
+            // tiebreak, which is exactly `[u8]: Ord`.
+            let v = downcast::<BinaryArray>(array, field_name)?.value(row_idx);
+            *acc = Some(match acc.take() {
+                None => v.to_vec(),
+                Some(prev) => {
+                    let take_new = if keep_smaller {
+                        if reversed {
+                            v < prev.as_slice()
+                        } else {
+                            v <= prev.as_slice()
+                        }
+                    } else if reversed {
+                        v >= prev.as_slice()
+                    } else {
+                        v > prev.as_slice()
+                    };
+                    if take_new {
+                        v.to_vec()
+                    } else {
+                        prev
+                    }
+                }
+            });
+        }
     }
     Ok(())
 }
 
 fn minmax_result(state: &MinMaxState, agg_name: &str, field_name: &str) -> 
crate::Result<ArrayRef> {
     Ok(match state {
+        MinMaxState::Bool(acc) => Arc::new(BooleanArray::from(vec![*acc])),
         MinMaxState::I8(acc) => Arc::new(Int8Array::from(vec![*acc])),
         MinMaxState::I16(acc) => Arc::new(Int16Array::from(vec![*acc])),
         MinMaxState::I32(acc) => Arc::new(Int32Array::from(vec![*acc])),
@@ -590,10 +683,22 @@ fn minmax_result(state: &MinMaxState, agg_name: &str, 
field_name: &str) -> crate
         } => decimal_array(*precision, *scale, *acc, agg_name, field_name)?,
         MinMaxState::Date32(acc) => Arc::new(Date32Array::from(vec![*acc])),
         MinMaxState::Time32Ms(acc) => 
Arc::new(Time32MillisecondArray::from(vec![*acc])),
-        MinMaxState::Timestamp { unit, acc } => match unit {
-            TimeUnit::Millisecond => 
Arc::new(TimestampMillisecondArray::from(vec![*acc])),
-            TimeUnit::Microsecond => 
Arc::new(TimestampMicrosecondArray::from(vec![*acc])),
-            TimeUnit::Nanosecond => 
Arc::new(TimestampNanosecondArray::from(vec![*acc])),
+        MinMaxState::Timestamp {
+            unit,
+            timezone,
+            acc,
+        } => match unit {
+            // `with_timezone_opt` keeps the result array's Arrow type equal 
to the
+            // field's, which `RecordBatch::try_new` checks.
+            TimeUnit::Millisecond => Arc::new(
+                
TimestampMillisecondArray::from(vec![*acc]).with_timezone_opt(timezone.clone()),
+            ),
+            TimeUnit::Microsecond => Arc::new(
+                
TimestampMicrosecondArray::from(vec![*acc]).with_timezone_opt(timezone.clone()),
+            ),
+            TimeUnit::Nanosecond => Arc::new(
+                
TimestampNanosecondArray::from(vec![*acc]).with_timezone_opt(timezone.clone()),
+            ),
             other => {
                 return Err(crate::Error::DataInvalid {
                     message: format!(
@@ -604,11 +709,13 @@ fn minmax_result(state: &MinMaxState, agg_name: &str, 
field_name: &str) -> crate
             }
         },
         MinMaxState::Utf8(acc) => 
Arc::new(StringArray::from(vec![acc.clone()])),
+        MinMaxState::Binary(acc) => 
Arc::new(BinaryArray::from_opt_vec(vec![acc.as_deref()])),
     })
 }
 
 fn reset_minmax(state: &mut MinMaxState) {
     match state {
+        MinMaxState::Bool(acc) => *acc = None,
         MinMaxState::I8(acc) => *acc = None,
         MinMaxState::I16(acc) => *acc = None,
         MinMaxState::I32(acc) => *acc = None,
@@ -620,6 +727,7 @@ fn reset_minmax(state: &mut MinMaxState) {
         MinMaxState::Time32Ms(acc) => *acc = None,
         MinMaxState::Timestamp { acc, .. } => *acc = None,
         MinMaxState::Utf8(acc) => *acc = None,
+        MinMaxState::Binary(acc) => *acc = None,
     }
 }
 
@@ -808,8 +916,9 @@ fn decimal_array(
 mod tests {
     use super::*;
     use crate::spec::{
-        BigIntType, CharType, DateType, DecimalType, DoubleType, FloatType, 
IntType, SmallIntType,
-        TimeType, TimestampType, TinyIntType, VarCharType,
+        BigIntType, BinaryType, CharType, DateType, DecimalType, DoubleType, 
FloatType, IntType,
+        LocalZonedTimestampType, SmallIntType, TimeType, TimestampType, 
TinyIntType, VarBinaryType,
+        VarCharType,
     };
     use arrow_array::builder::Decimal128Builder;
 
@@ -841,6 +950,24 @@ mod tests {
         }
     }
 
+    fn collect_bool(arr: ArrayRef) -> Option<bool> {
+        let a = arr.as_any().downcast_ref::<BooleanArray>().unwrap();
+        if a.is_null(0) {
+            None
+        } else {
+            Some(a.value(0))
+        }
+    }
+
+    fn collect_binary(arr: ArrayRef) -> Option<Vec<u8>> {
+        let a = arr.as_any().downcast_ref::<BinaryArray>().unwrap();
+        if a.is_null(0) {
+            None
+        } else {
+            Some(a.value(0).to_vec())
+        }
+    }
+
     fn collect_string(arr: ArrayRef) -> Option<String> {
         let a = arr.as_any().downcast_ref::<StringArray>().unwrap();
         if a.is_null(0) {
@@ -1260,12 +1387,82 @@ mod tests {
 
     #[test]
     fn test_min_rejects_unsupported_type() {
-        // Boolean has no <, > defined for min/max in Paimon basic mode.
-        let err =
-            MinAgg::new("v", 
&DataType::Boolean(crate::spec::BooleanType::new())).unwrap_err();
+        // Java's `TypeCheckUtils#isComparable` rejects exactly MAP, MULTISET, 
ROW,
+        // ARRAY, VECTOR, VARIANT and BLOB. BLOB is the interesting one here: 
it
+        // shares Arrow's `Binary` with BINARY and VARBINARY, which min/max 
accepts.
+        let err = MinAgg::new("v", 
&DataType::Blob(crate::spec::BlobType::new())).unwrap_err();
         assert!(matches!(err, crate::Error::ConfigInvalid { message } if 
message.contains("min")));
     }
 
+    #[test]
+    fn test_min_max_boolean() {
+        // `Boolean.compare` in Java's `InternalRowUtils#compare`: false < 
true.
+        let arr = BooleanArray::from(vec![Some(true), Some(false), 
Some(true)]);
+        let mut min = 
min_agg(DataType::Boolean(crate::spec::BooleanType::new()));
+        let mut max = 
max_agg(DataType::Boolean(crate::spec::BooleanType::new()));
+        for i in 0..arr.len() {
+            min.agg(&arr, i).unwrap();
+            max.agg(&arr, i).unwrap();
+        }
+        assert_eq!(collect_bool(min.result().unwrap()), Some(false));
+        assert_eq!(collect_bool(max.result().unwrap()), Some(true));
+    }
+
+    #[test]
+    fn test_min_max_binary_compares_bytes_as_unsigned() {
+        // `byteArrayCompare` masks with 0xff, so 0x80 is above 0x7f, and a 
prefix
+        // sorts before the longer value it prefixes.
+        let arr = BinaryArray::from_opt_vec(vec![
+            Some(&[0x7fu8, 0x01][..]),
+            Some(&[0x80u8][..]),
+            Some(&[0x7fu8][..]),
+        ]);
+        for dt in [
+            DataType::Binary(BinaryType::new(2).unwrap()),
+            DataType::VarBinary(VarBinaryType::new(8).unwrap()),
+        ] {
+            let mut min = min_agg(dt.clone());
+            let mut max = max_agg(dt);
+            for i in 0..arr.len() {
+                min.agg(&arr, i).unwrap();
+                max.agg(&arr, i).unwrap();
+            }
+            assert_eq!(collect_binary(min.result().unwrap()), 
Some(vec![0x7f]));
+            assert_eq!(collect_binary(max.result().unwrap()), 
Some(vec![0x80]));
+        }
+    }
+
+    #[test]
+    fn test_min_max_local_zoned_timestamp_keeps_its_arrow_timezone() {
+        let dt = 
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(3).unwrap());
+        let arr = TimestampMillisecondArray::from(vec![Some(30), Some(10), 
Some(20)])
+            .with_timezone_opt(Some("UTC"));
+        let mut min = min_agg(dt.clone());
+        let mut max = max_agg(dt.clone());
+        for i in 0..arr.len() {
+            min.agg(&arr, i).unwrap();
+            max.agg(&arr, i).unwrap();
+        }
+        let min_result = min.result().unwrap();
+        assert_eq!(
+            min_result
+                .as_any()
+                .downcast_ref::<TimestampMillisecondArray>()
+                .unwrap()
+                .value(0),
+            10
+        );
+
+        // The trap: an accumulator that dropped the timezone still holds the 
right
+        // number, but the field's Arrow type no longer matches and the batch 
the
+        // merge engine builds is rejected.
+        let arrow_type = crate::arrow::paimon_type_to_arrow(&dt).unwrap();
+        assert_eq!(min_result.data_type(), &arrow_type);
+        let schema =
+            arrow_schema::Schema::new(vec![arrow_schema::Field::new("v", 
arrow_type, true)]);
+        arrow_array::RecordBatch::try_new(Arc::new(schema), 
vec![max.result().unwrap()]).unwrap();
+    }
+
     #[test]
     fn test_min_max_date_and_timestamp_supported() {
         // Date32

Reply via email to