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 12c0b32  fix(scan): prune IN predicates using file stats (#382)
12c0b32 is described below

commit 12c0b32f4f331d0cf5044180a0d30063f7c8ae85
Author: Huang Qiwei <[email protected]>
AuthorDate: Sat Jul 4 23:26:27 2026 +0800

    fix(scan): prune IN predicates using file stats (#382)
---
 crates/paimon/src/predicate_stats.rs    |  28 +++++++-
 crates/paimon/src/table/stats_filter.rs |   7 ++
 crates/paimon/src/table/table_scan.rs   | 116 ++++++++++++++++++++++++++++++++
 3 files changed, 149 insertions(+), 2 deletions(-)

diff --git a/crates/paimon/src/predicate_stats.rs 
b/crates/paimon/src/predicate_stats.rs
index fc1a79a..d293d5c 100644
--- a/crates/paimon/src/predicate_stats.rs
+++ b/crates/paimon/src/predicate_stats.rs
@@ -23,6 +23,9 @@ pub(crate) trait StatsAccessor {
     fn null_count(&self, index: usize) -> Option<i64>;
     fn min_value(&self, index: usize, data_type: &DataType) -> Option<Datum>;
     fn max_value(&self, index: usize, data_type: &DataType) -> Option<Datum>;
+    fn supports_in_min_max_pruning(&self) -> bool {
+        false
+    }
 }
 
 pub(crate) fn predicates_may_match_with_schema<T: StatsAccessor>(
@@ -59,7 +62,11 @@ pub(crate) fn data_leaf_may_match<T: StatsAccessor>(
         PredicateOperator::IsNotNull => {
             return all_null != Some(true);
         }
-        PredicateOperator::In | PredicateOperator::NotIn => {
+        PredicateOperator::In if stats.supports_in_min_max_pruning() => {}
+        PredicateOperator::In => {
+            return true;
+        }
+        PredicateOperator::NotIn => {
             return true;
         }
         PredicateOperator::EndsWith | PredicateOperator::Contains => {
@@ -113,6 +120,18 @@ pub(crate) fn data_leaf_may_match<T: StatsAccessor>(
     };
 
     match op {
+        PredicateOperator::In => {
+            if !matches!(
+                min_value.partial_cmp(&max_value),
+                Some(Ordering::Less | Ordering::Equal)
+            ) {
+                return true;
+            }
+            literals.iter().any(|literal| {
+                !matches!(literal.partial_cmp(&min_value), 
Some(Ordering::Less))
+                    && !matches!(literal.partial_cmp(&max_value), 
Some(Ordering::Greater))
+            })
+        }
         PredicateOperator::Eq => {
             !matches!(literal.partial_cmp(&min_value), Some(Ordering::Less))
                 && !matches!(literal.partial_cmp(&max_value), 
Some(Ordering::Greater))
@@ -180,7 +199,6 @@ pub(crate) fn data_leaf_may_match<T: StatsAccessor>(
         }
         PredicateOperator::IsNull
         | PredicateOperator::IsNotNull
-        | PredicateOperator::In
         | PredicateOperator::NotIn
         | PredicateOperator::EndsWith
         | PredicateOperator::Contains
@@ -526,6 +544,12 @@ mod tests {
         data_leaf_may_match(0, &dt, &dt, op, lits, stats)
     }
 
+    #[test]
+    fn in_falls_open_when_accessor_does_not_opt_in() {
+        let stats = int_stats(10, 20);
+        assert!(run_int(PredicateOperator::In, &[Datum::Int(30)], &stats));
+    }
+
     /// Stage 3 invariant: a `Between` leaf and the equivalent `GtEq+LtEq`
     /// conjunction must produce identical stats-prune verdicts. If they
     /// diverge, the DataFusion translator switch (And-of-comparisons →
diff --git a/crates/paimon/src/table/stats_filter.rs 
b/crates/paimon/src/table/stats_filter.rs
index 999213d..63cf520 100644
--- a/crates/paimon/src/table/stats_filter.rs
+++ b/crates/paimon/src/table/stats_filter.rs
@@ -32,6 +32,7 @@ pub(super) struct FileStatsRows {
     min_values: Option<BinaryRow>,
     max_values: Option<BinaryRow>,
     null_counts: Vec<Option<i64>>,
+    supports_in_min_max_pruning: bool,
     /// Maps schema field index → stats index. `None` means identity mapping
     /// (stats cover all schema fields in order). `Some` is used when
     /// `value_stats_cols` or `write_cols` is present (dense mode).
@@ -51,6 +52,7 @@ impl FileStatsRows {
             min_values,
             max_values,
             null_counts,
+            supports_in_min_max_pruning: false,
             stats_col_mapping: None,
         }
     }
@@ -92,6 +94,7 @@ impl FileStatsRows {
             min_values: 
BinaryRow::from_serialized_bytes(file.value_stats.min_values()).ok(),
             max_values: 
BinaryRow::from_serialized_bytes(file.value_stats.max_values()).ok(),
             null_counts: file.value_stats.null_counts().clone(),
+            supports_in_min_max_pruning: true,
             stats_col_mapping,
         }
     }
@@ -132,6 +135,10 @@ impl StatsAccessor for FileStatsRows {
             .as_ref()
             .and_then(|row| extract_stats_datum(row, stats_index, data_type))
     }
+
+    fn supports_in_min_max_pruning(&self) -> bool {
+        self.supports_in_min_max_pruning
+    }
 }
 
 #[derive(Debug)]
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index e8ee0a9..df59954 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -2020,6 +2020,122 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_data_file_matches_in_prunes_when_all_literals_out_of_range() {
+        let fields = int_field();
+        let file = test_data_file_meta(
+            int_stats_row(Some(10)),
+            int_stats_row(Some(20)),
+            vec![Some(0)],
+            5,
+        );
+        let predicate = PredicateBuilder::new(&fields)
+            .is_in("id", vec![Datum::Int(1), Datum::Int(30)])
+            .unwrap();
+
+        assert!(!data_file_matches_predicates(
+            &file,
+            &[predicate],
+            TEST_SCHEMA_ID,
+            &test_schema_fields(),
+        ));
+    }
+
+    #[test]
+    fn test_data_file_matches_in_keeps_when_any_literal_in_range() {
+        let fields = int_field();
+        let file = test_data_file_meta(
+            int_stats_row(Some(10)),
+            int_stats_row(Some(20)),
+            vec![Some(0)],
+            5,
+        );
+        let predicate = PredicateBuilder::new(&fields)
+            .is_in("id", vec![Datum::Int(1), Datum::Int(15), Datum::Int(30)])
+            .unwrap();
+
+        assert!(data_file_matches_predicates(
+            &file,
+            &[predicate],
+            TEST_SCHEMA_ID,
+            &test_schema_fields(),
+        ));
+    }
+
+    #[test]
+    fn test_data_file_matches_in_prunes_all_null_file() {
+        let fields = int_field();
+        let file = test_data_file_meta(int_stats_row(None), 
int_stats_row(None), vec![Some(5)], 5);
+        let predicate = PredicateBuilder::new(&fields)
+            .is_in("id", vec![Datum::Int(10)])
+            .unwrap();
+
+        assert!(!data_file_matches_predicates(
+            &file,
+            &[predicate],
+            TEST_SCHEMA_ID,
+            &test_schema_fields(),
+        ));
+    }
+
+    #[test]
+    fn test_data_file_matches_in_with_corrupt_stats_fails_open() {
+        let fields = int_field();
+        let file = test_data_file_meta(Vec::new(), Vec::new(), vec![Some(0)], 
5);
+        let predicate = PredicateBuilder::new(&fields)
+            .is_in("id", vec![Datum::Int(30)])
+            .unwrap();
+
+        assert!(data_file_matches_predicates(
+            &file,
+            &[predicate],
+            TEST_SCHEMA_ID,
+            &test_schema_fields(),
+        ));
+    }
+
+    #[test]
+    fn test_data_file_matches_in_with_inverted_stats_fails_open() {
+        let fields = int_field();
+        let file = test_data_file_meta(
+            int_stats_row(Some(20)),
+            int_stats_row(Some(10)),
+            vec![Some(0)],
+            5,
+        );
+        let predicate = PredicateBuilder::new(&fields)
+            .is_in("id", vec![Datum::Int(15)])
+            .unwrap();
+
+        assert!(data_file_matches_predicates(
+            &file,
+            &[predicate],
+            TEST_SCHEMA_ID,
+            &test_schema_fields(),
+        ));
+    }
+
+    #[test]
+    fn test_data_file_matches_not_in_fails_open() {
+        let fields = int_field();
+        let file = test_data_file_meta(
+            int_stats_row(Some(10)),
+            int_stats_row(Some(20)),
+            vec![Some(0)],
+            5,
+        );
+        let predicate = PredicateBuilder::new(&fields)
+            .is_not_in("id", vec![Datum::Int(10), Datum::Int(20)])
+            .unwrap();
+
+        assert!(data_file_matches_predicates(
+            &file,
+            &[predicate],
+            TEST_SCHEMA_ID,
+            &test_schema_fields(),
+        ));
+    }
+
     #[test]
     fn test_data_file_matches_is_null_prunes_when_null_count_is_zero() {
         let fields = int_field();

Reply via email to