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 21878712 fix(scan): prune NOT IN predicates using file stats (#795)
21878712 is described below

commit 218787120a71852b0a696540182e2ca96e045e1e
Author: jackylee <[email protected]>
AuthorDate: Thu Sep 10 15:41:38 2026 +0800

    fix(scan): prune NOT IN predicates using file stats (#795)
---
 .../datafusion/tests/scan_pruning_trace.rs         | 134 ++++++++++++
 crates/paimon/src/predicate_stats.rs               | 233 ++++++++++++++++++++-
 crates/paimon/src/table/table_scan.rs              |   2 +-
 3 files changed, 359 insertions(+), 10 deletions(-)

diff --git a/crates/integrations/datafusion/tests/scan_pruning_trace.rs 
b/crates/integrations/datafusion/tests/scan_pruning_trace.rs
index ad5d5734..1b4465b6 100644
--- a/crates/integrations/datafusion/tests/scan_pruning_trace.rs
+++ b/crates/integrations/datafusion/tests/scan_pruning_trace.rs
@@ -112,6 +112,67 @@ fn trace_manifest_counts(plan_text: &str) -> (usize, 
usize) {
     (after, before)
 }
 
+/// `NOT IN` must prune a data file whose min and max are both a forbidden
+/// literal, and must not change what the query returns. Each INSERT here 
writes
+/// one file holding a single distinct value, so `value NOT IN (20)` leaves the
+/// 20-only file with min == max == 20.
+#[tokio::test]
+async fn test_scan_trace_records_not_in_data_stats_pruning() {
+    let (tmp, catalog) = common::create_test_env();
+    let sql_context = common::create_sql_context(catalog.clone()).await;
+    sql_context
+        .sql("CREATE SCHEMA paimon.test_db")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    sql_context
+        .sql("CREATE TABLE paimon.test_db.trace_not_in (id INT, value INT)")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    for (id, value) in [(1, 10), (2, 20), (3, 30)] {
+        common::exec(
+            &sql_context,
+            &format!("INSERT INTO paimon.test_db.trace_not_in VALUES ({id}, 
{value})"),
+        )
+        .await;
+    }
+
+    let table = load_table(&catalog, "trace_not_in").await;
+    let fields = table.schema().fields();
+    let pb = PredicateBuilder::new(fields);
+
+    let (_plan, all_trace) = table
+        .new_read_builder()
+        .new_scan()
+        .plan_with_trace()
+        .await
+        .unwrap();
+    assert_eq!(all_trace.final_files, 3);
+
+    let mut reader = table.new_read_builder();
+    reader.with_filter(pb.is_not_in("value", vec![Datum::Int(20)]).unwrap());
+    let (_not_in_plan, not_in_trace) = 
reader.new_scan().plan_with_trace().await.unwrap();
+
+    assert_eq!(
+        not_in_trace.manifest_entries_pruned_by_data_stats, 1,
+        "the file holding only 20 should be pruned: {not_in_trace:?}"
+    );
+    assert_eq!(not_in_trace.final_files, 2);
+
+    let rows = common::collect_id_value(
+        &sql_context,
+        "SELECT id, value FROM paimon.test_db.trace_not_in WHERE value NOT IN 
(20) ORDER BY id",
+    )
+    .await;
+    assert_eq!(rows, vec![(1, 10), (3, 30)]);
+    drop(tmp);
+}
+
 #[tokio::test]
 async fn test_scan_trace_records_partition_pruning() {
     let (_tmp, catalog) = setup_trace_table().await;
@@ -301,3 +362,76 @@ async fn test_physical_plan_displays_scan_trace_summary() {
         "physical plan should include scan trace summary:\n{plan_text}"
     );
 }
+
+/// Equality-based negative pruning must fail open for FLOAT and DOUBLE. Two
+/// files here are exactly the shapes the writer produces for the two ways the
+/// rule breaks: `[1.0, NaN]` reports `min == max == 1.0` over two rows because
+/// NaN goes into neither min/max nor the null count, and a file holding only
+/// `-0.0` reports `min = -0.0, max = 0.0`, which `Datum` sees as equal to 
`0.0`
+/// while the row-level filter does not. Pruning either file drops a row the
+/// filter would have returned.
+#[tokio::test]
+async fn test_not_equal_keeps_float_files_with_nan_or_signed_zero() {
+    let (_tmp, catalog) = common::create_test_env();
+    let sql_context = common::create_sql_context(catalog.clone()).await;
+    sql_context
+        .sql("CREATE SCHEMA paimon.test_db")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+
+    sql_context
+        .sql("CREATE TABLE paimon.test_db.trace_float (id INT, d DOUBLE)")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    common::exec(
+        &sql_context,
+        "INSERT INTO paimon.test_db.trace_float VALUES \
+         (1, CAST(1.0 AS DOUBLE)), (2, CAST('NaN' AS DOUBLE))",
+    )
+    .await;
+    common::exec(
+        &sql_context,
+        "INSERT INTO paimon.test_db.trace_float VALUES (3, CAST(-0.0 AS 
DOUBLE))",
+    )
+    .await;
+
+    for (sql, expected) in [
+        // The NaN row satisfies `<> 1.0`; pruning the file used to drop it.
+        (
+            "SELECT id FROM paimon.test_db.trace_float WHERE d <> 1.0 ORDER BY 
id",
+            vec![2, 3],
+        ),
+        // `-0.0` is not `0.0` to the filter, so row 3 survives; rows 1 and 2 
are
+        // kept as well because `1.0 <> 0.0` and NaN is not `0.0` either.
+        (
+            "SELECT id FROM paimon.test_db.trace_float WHERE d <> 0.0 ORDER BY 
id",
+            vec![1, 2, 3],
+        ),
+        (
+            "SELECT id FROM paimon.test_db.trace_float WHERE d NOT IN (1.0, 
2.0) ORDER BY id",
+            vec![2, 3],
+        ),
+    ] {
+        let batches = 
sql_context.sql(sql).await.unwrap().collect().await.unwrap();
+        let mut ids = Vec::new();
+        for batch in &batches {
+            let column = batch
+                .column_by_name("id")
+                .and_then(|c| {
+                    c.as_any()
+                        .downcast_ref::<datafusion::arrow::array::Int32Array>()
+                })
+                .expect("id column");
+            for row in 0..column.len() {
+                ids.push(column.value(row));
+            }
+        }
+        assert_eq!(ids, expected, "wrong rows for `{sql}`");
+    }
+}
diff --git a/crates/paimon/src/predicate_stats.rs 
b/crates/paimon/src/predicate_stats.rs
index d2b8308f..a8d9553c 100644
--- a/crates/paimon/src/predicate_stats.rs
+++ b/crates/paimon/src/predicate_stats.rs
@@ -66,9 +66,6 @@ pub(crate) fn data_leaf_may_match<T: StatsAccessor>(
         PredicateOperator::In => {
             return true;
         }
-        PredicateOperator::NotIn => {
-            return true;
-        }
         PredicateOperator::ArrayContains => {
             return all_null != Some(true);
         }
@@ -96,6 +93,7 @@ pub(crate) fn data_leaf_may_match<T: StatsAccessor>(
         }
         PredicateOperator::Eq
         | PredicateOperator::NotEq
+        | PredicateOperator::NotIn
         | PredicateOperator::Lt
         | PredicateOperator::LtEq
         | PredicateOperator::Gt
@@ -145,7 +143,29 @@ pub(crate) fn data_leaf_may_match<T: StatsAccessor>(
             !matches!(literal.partial_cmp(&min_value), Some(Ordering::Less))
                 && !matches!(literal.partial_cmp(&max_value), 
Some(Ordering::Greater))
         }
-        PredicateOperator::NotEq => !(min_value == *literal && max_value == 
*literal),
+        // Skipping a file because every non-null value in it is forbidden 
needs
+        // two things that do not hold for FLOAT and DOUBLE, so both operators
+        // below fail open there (see `equality_exclusion_is_sound`).
+        PredicateOperator::NotEq => {
+            !(equality_exclusion_is_sound(&min_value)
+                && min_value == *literal
+                && max_value == *literal)
+        }
+        // The n-ary form of `NotEq` above: a file can only be skipped when 
some
+        // literal equals both bounds. Java `NotIn#test` does the same
+        // (`compareLiteral(lit, min) == 0 && compareLiteral(lit, max) == 0`). 
Its
+        // extra `literal == null` arm has no counterpart because `Datum` has 
no
+        // null variant at all: the REST parser folds `NOT IN (.., null)` into
+        // `AlwaysFalse`, and DataFusion declines to push the predicate down. 
Files
+        // that are entirely null were already skipped by the shared `all_null`
+        // check, and files that are partly null are still pruned -- a null row
+        // satisfies neither `NotIn` nor the equality that prunes it.
+        PredicateOperator::NotIn => {
+            !(equality_exclusion_is_sound(&min_value)
+                && literals
+                    .iter()
+                    .any(|literal| min_value == *literal && max_value == 
*literal))
+        }
         PredicateOperator::Lt => !matches!(
             min_value.partial_cmp(literal),
             Some(Ordering::Greater | Ordering::Equal)
@@ -208,7 +228,6 @@ pub(crate) fn data_leaf_may_match<T: StatsAccessor>(
         }
         PredicateOperator::IsNull
         | PredicateOperator::IsNotNull
-        | PredicateOperator::NotIn
         | PredicateOperator::EndsWith
         | PredicateOperator::Contains
         | PredicateOperator::Between
@@ -219,6 +238,26 @@ pub(crate) fn data_leaf_may_match<T: StatsAccessor>(
     }
 }
 
+/// Whether `min == max == literal` is enough to conclude that no row in a file
+/// can satisfy `!= literal`.
+///
+/// The conclusion needs two properties, and FLOAT and DOUBLE have neither:
+///
+/// * `Datum` equality must agree with the row-level filter. `datum_cmp` 
compares
+///   floats with IEEE `partial_cmp`, so it reports `-0.0 == +0.0`, while the
+///   residual filter tells them apart. Java is unaffected here because
+///   `CompareUtils#compareLiteral` goes through `Double.compareTo`, which 
orders
+///   `-0.0` below `+0.0`.
+/// * min and max must cover every non-null row. The writer leaves NaN out of
+///   min/max without counting it as null, so a file holding `[1.0, NaN]` 
reports
+///   `min == max == 1.0` over two rows.
+///
+/// Either one alone drops rows: on those two files `<> 0.0` and `<> 1.0` 
skipped
+/// the file even though the filter would have returned the row.
+fn equality_exclusion_is_sound(bound: &Datum) -> bool {
+    !matches!(bound, Datum::Float(_) | Datum::Double(_))
+}
+
 pub(crate) fn data_leaf_must_match<T: StatsAccessor>(
     index: usize,
     stats_data_type: &DataType,
@@ -545,7 +584,7 @@ fn coerce_stats_datum_for_predicate(datum: Datum, 
predicate_data_type: &DataType
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::spec::{BinaryType, IntType, VarCharType};
+    use crate::spec::{BinaryType, DoubleType, IntType, VarCharType};
 
     struct MockStats {
         row_count: i64,
@@ -725,11 +764,19 @@ mod tests {
     }
 
     fn int_stats(min: i32, max: i32) -> MockStats {
+        nullable_int_stats(Some(min), Some(max), Some(0))
+    }
+
+    fn nullable_int_stats(
+        min: Option<i32>,
+        max: Option<i32>,
+        null_count: Option<i64>,
+    ) -> MockStats {
         MockStats {
             row_count: 10,
-            null_count: Some(0),
-            min: Some(Datum::Int(min)),
-            max: Some(Datum::Int(max)),
+            null_count,
+            min: min.map(Datum::Int),
+            max: max.map(Datum::Int),
         }
     }
 
@@ -960,4 +1007,172 @@ mod tests {
             &stats,
         ));
     }
+
+    #[test]
+    fn not_in_prunes_a_file_holding_only_a_forbidden_value() {
+        let stats = nullable_int_stats(Some(7), Some(7), Some(0));
+        assert!(!run_int(PredicateOperator::NotIn, &[Datum::Int(7)], &stats));
+        // One matching literal is enough, wherever it sits in the list.
+        assert!(!run_int(
+            PredicateOperator::NotIn,
+            &[Datum::Int(1), Datum::Int(7), Datum::Int(9)],
+            &stats
+        ));
+    }
+
+    #[test]
+    fn not_in_keeps_a_file_whose_bounds_differ() {
+        let stats = nullable_int_stats(Some(10), Some(20), Some(0));
+        assert!(run_int(
+            PredicateOperator::NotIn,
+            &[Datum::Int(10), Datum::Int(20)],
+            &stats
+        ));
+        assert!(run_int(PredicateOperator::NotIn, &[Datum::Int(15)], &stats));
+    }
+
+    #[test]
+    fn not_in_prunes_an_all_null_file() {
+        // Every row is null, and a null row never satisfies NOT IN.
+        let stats = MockStats {
+            row_count: 10,
+            null_count: Some(10),
+            min: None,
+            max: None,
+        };
+        assert!(!run_int(PredicateOperator::NotIn, &[Datum::Int(7)], &stats));
+    }
+
+    #[test]
+    fn not_in_prunes_a_partially_null_constant_file() {
+        // Nulls do not rescue the file: the non-null rows all hold 7, and the
+        // null rows do not satisfy NOT IN either.
+        let stats = nullable_int_stats(Some(7), Some(7), Some(4));
+        assert!(!run_int(PredicateOperator::NotIn, &[Datum::Int(7)], &stats));
+        // Same when the null count is unknown: min/max already describe every
+        // non-null row, so an unknown number of nulls cannot rescue the file.
+        let unknown_nulls = nullable_int_stats(Some(7), Some(7), None);
+        assert!(!run_int(
+            PredicateOperator::NotIn,
+            &[Datum::Int(7)],
+            &unknown_nulls
+        ));
+    }
+
+    #[test]
+    fn not_in_falls_open_without_usable_stats() {
+        assert!(run_int(
+            PredicateOperator::NotIn,
+            &[Datum::Int(7)],
+            &nullable_int_stats(None, None, Some(0))
+        ));
+        assert!(run_int(
+            PredicateOperator::NotIn,
+            &[Datum::Int(7)],
+            &nullable_int_stats(Some(7), None, Some(0))
+        ));
+        // Inverted bounds: no literal can equal two different bounds, so the
+        // rule structurally cannot fire -- unlike `In`, which needs an 
explicit
+        // guard here.
+        assert!(run_int(
+            PredicateOperator::NotIn,
+            &[Datum::Int(7)],
+            &nullable_int_stats(Some(20), Some(10), Some(0))
+        ));
+        // No literals at all — Java's NotIn returns true here as well.
+        assert!(run_int(
+            PredicateOperator::NotIn,
+            &[],
+            &nullable_int_stats(Some(7), Some(7), Some(0))
+        ));
+    }
+
+    /// `x NOT IN (l)` is `x <> l`, so the two must always return the same
+    /// verdict. Changing one rule without the other would let the same file be
+    /// pruned by one spelling and kept by the other.
+    #[test]
+    fn not_in_with_one_literal_agrees_with_not_eq() {
+        for (min, max, null_count) in [
+            (Some(7), Some(7), Some(0)),
+            (Some(10), Some(20), Some(0)),
+            (Some(7), Some(7), Some(4)),
+            (None, None, Some(0)),
+            (Some(20), Some(10), Some(0)),
+            (Some(7), Some(7), None),
+            // All-null is the one shape where Rust deliberately parts from 
Java:
+            // `NotEqual` there takes no null count, while the shared check 
here
+            // prunes. Both operators must still agree with each other.
+            (None, None, Some(10)),
+        ] {
+            let stats = nullable_int_stats(min, max, null_count);
+            for literal in [7, 10, 20, 15] {
+                assert_eq!(
+                    run_int(PredicateOperator::NotIn, &[Datum::Int(literal)], 
&stats),
+                    run_int(PredicateOperator::NotEq, &[Datum::Int(literal)], 
&stats),
+                    "NotIn and NotEq disagree on ({min:?}, {max:?}, \
+                     null_count={null_count:?}, literal={literal})"
+                );
+            }
+        }
+    }
+
+    fn double_stats(min: f64, max: f64) -> MockStats {
+        MockStats {
+            row_count: 2,
+            null_count: Some(0),
+            min: Some(Datum::Double(min)),
+            max: Some(Datum::Double(max)),
+        }
+    }
+
+    fn run_double(op: PredicateOperator, literals: &[f64], stats: &MockStats) 
-> bool {
+        let dt = DataType::Double(DoubleType::new());
+        let literals: Vec<Datum> = 
literals.iter().copied().map(Datum::Double).collect();
+        data_leaf_may_match(0, &dt, &dt, op, &literals, stats)
+    }
+
+    /// FLOAT and DOUBLE fail open, because neither property the rule rests on
+    /// holds for them. Both shapes below are what the writer really produces: 
a
+    /// file holding `[1.0, NaN]` reports `min == max == 1.0` over two rows, 
and a
+    /// file holding only `-0.0` reports `min = -0.0, max = 0.0`.
+    #[test]
+    fn equality_exclusion_falls_open_for_floats() {
+        let nan_file = double_stats(1.0, 1.0);
+        assert!(run_double(PredicateOperator::NotEq, &[1.0], &nan_file));
+        assert!(run_double(PredicateOperator::NotIn, &[1.0, 2.0], &nan_file));
+
+        let signed_zero_file = double_stats(-0.0, 0.0);
+        assert!(run_double(
+            PredicateOperator::NotEq,
+            &[0.0],
+            &signed_zero_file
+        ));
+        assert!(run_double(
+            PredicateOperator::NotIn,
+            &[0.0],
+            &signed_zero_file
+        ));
+
+        // Ordering-based rules are unaffected and must keep pruning.
+        assert!(!run_double(PredicateOperator::Gt, &[5.0], &nan_file));
+        assert!(!run_double(PredicateOperator::Lt, &[0.5], &nan_file));
+        // So is `Eq`, whose direction of error is to keep files.
+        assert!(run_double(PredicateOperator::Eq, &[1.0], &nan_file));
+    }
+
+    /// The equivalence between `NOT IN (l)` and `<> l` has to survive the 
float
+    /// carve-out: both fail open together, rather than one of them pruning.
+    #[test]
+    fn not_in_agrees_with_not_eq_on_floats_too() {
+        for (min, max) in [(1.0, 1.0), (-0.0, 0.0), (0.0, 0.0), (1.0, 5.0)] {
+            let stats = double_stats(min, max);
+            for literal in [1.0, 0.0, -0.0, 5.0] {
+                assert_eq!(
+                    run_double(PredicateOperator::NotIn, &[literal], &stats),
+                    run_double(PredicateOperator::NotEq, &[literal], &stats),
+                    "NotIn and NotEq disagree on ({min}, {max}, 
literal={literal})"
+                );
+            }
+        }
+    }
 }
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index d5de9048..37139e58 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -3953,7 +3953,7 @@ mod tests {
     }
 
     #[test]
-    fn test_data_file_matches_not_in_fails_open() {
+    fn test_data_file_matches_not_in_keeps_when_bounds_differ() {
         let fields = int_field();
         let file = test_data_file_meta(
             int_stats_row(Some(10)),

Reply via email to