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 0601db4c fix(scan): keep the predicate when a partition set cannot 
express it (#886)
0601db4c is described below

commit 0601db4c9b3ffdb03d244753a366c003f05678ea
Author: jackylee <[email protected]>
AuthorDate: Tue Sep 22 14:15:33 2026 +0800

    fix(scan): keep the predicate when a partition set cannot express it (#886)
---
 .../tests/partition_conjunct_pushdown.rs           | 136 +++++++++++++++++++++
 crates/paimon/src/table/partition_filter.rs        | 132 +++++++++++++++++---
 2 files changed, 248 insertions(+), 20 deletions(-)

diff --git 
a/crates/integrations/datafusion/tests/partition_conjunct_pushdown.rs 
b/crates/integrations/datafusion/tests/partition_conjunct_pushdown.rs
new file mode 100644
index 00000000..a8c8aeb2
--- /dev/null
+++ b/crates/integrations/datafusion/tests/partition_conjunct_pushdown.rs
@@ -0,0 +1,136 @@
+// 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.
+
+//! A partition-only filter is pushed down as `Exact`, so DataFusion keeps no
+//! residual. These assert that the partition filter alone enforces the whole
+//! predicate, including conjuncts a partition set cannot express.
+
+mod common;
+
+use common::setup_sql_context;
+use paimon_datafusion::SQLContext;
+
+const TABLE: &str = "paimon.test_db.t_partition_conjuncts";
+
+async fn setup() -> (tempfile::TempDir, SQLContext) {
+    let (tmp, sql_context) = setup_sql_context().await;
+    sql_context
+        .sql(&format!(
+            "CREATE TABLE {TABLE} (dt STRING, id INT) PARTITIONED BY (dt)"
+        ))
+        .await
+        .unwrap();
+    sql_context
+        .sql(&format!(
+            "INSERT INTO {TABLE} VALUES \
+             ('2024-01-01', 1), ('2024-01-02', 2), ('2024-01-03', 3), 
('2024-01-04', 4)"
+        ))
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    (tmp, sql_context)
+}
+
+async fn partitions_matching(sql_context: &SQLContext, where_clause: &str) -> 
Vec<String> {
+    let batches = sql_context
+        .sql(&format!(
+            "SELECT dt FROM {TABLE} WHERE {where_clause} ORDER BY dt"
+        ))
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    let mut out = Vec::new();
+    for batch in &batches {
+        for row in 0..batch.num_rows() {
+            out.push(common::string_value(batch.column(0).as_ref(), 
row).to_string());
+        }
+    }
+    out
+}
+
+async fn plan_of(sql_context: &SQLContext, where_clause: &str) -> String {
+    let batches = sql_context
+        .sql(&format!(
+            "EXPLAIN SELECT dt FROM {TABLE} WHERE {where_clause}"
+        ))
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    datafusion::arrow::util::pretty::pretty_format_batches(&batches)
+        .unwrap()
+        .to_string()
+}
+
+#[tokio::test]
+async fn contradictory_partition_conjuncts_match_nothing() {
+    let (_tmp, sql_context) = setup().await;
+    let where_clause = "dt = '2024-01-01' AND dt >= '2024-01-02'";
+
+    let plan = plan_of(&sql_context, where_clause).await;
+    assert!(
+        !plan.contains("FilterExec"),
+        "a partition-only filter is Exact both before and after this fix, \
+         so the scan must be the only enforcement:\n{plan}"
+    );
+
+    assert!(
+        partitions_matching(&sql_context, where_clause)
+            .await
+            .is_empty(),
+        "no partition can be both = 2024-01-01 and >= 2024-01-02"
+    );
+}
+
+/// The narrower `=` comes first, so the old second assignment overwrote it 
with
+/// the wider `IN`. Order matters: with `IN` first the overwrite keeps the `=`
+/// and the answer is accidentally right.
+#[tokio::test]
+async fn an_equality_before_a_wider_in_list_is_not_widened() {
+    let (_tmp, sql_context) = setup().await;
+    let where_clause =
+        "dt = '2024-01-02' AND dt IN 
('2024-01-01','2024-01-02','2024-01-03','2024-01-04')";
+
+    assert_eq!(
+        partitions_matching(&sql_context, where_clause).await,
+        vec!["2024-01-02".to_string()]
+    );
+}
+
+#[tokio::test]
+async fn a_range_beside_an_in_list_still_narrows() {
+    let (_tmp, sql_context) = setup().await;
+    let where_clause =
+        "dt >= '2024-01-03' AND dt IN 
('2024-01-01','2024-01-02','2024-01-03','2024-01-04')";
+
+    let plan = plan_of(&sql_context, where_clause).await;
+    assert!(
+        !plan.contains("FilterExec"),
+        "a partition-only filter is Exact both before and after this fix, \
+         so the scan must be the only enforcement:\n{plan}"
+    );
+
+    assert_eq!(
+        partitions_matching(&sql_context, where_clause).await,
+        vec!["2024-01-03".to_string(), "2024-01-04".to_string()]
+    );
+}
diff --git a/crates/paimon/src/table/partition_filter.rs 
b/crates/paimon/src/table/partition_filter.rs
index 81713019..bbea7104 100644
--- a/crates/paimon/src/table/partition_filter.rs
+++ b/crates/paimon/src/table/partition_filter.rs
@@ -54,7 +54,9 @@ impl PartitionFilter {
 
         let num_fields = partition_fields.len();
         let mut field_candidates: Vec<Option<Vec<Option<&Datum>>>> = 
vec![None; num_fields];
-        collect_eq_candidates(&predicate, &mut field_candidates);
+        if !collect_eq_candidates(&predicate, &mut field_candidates) {
+            return PartitionFilter::Predicate(predicate);
+        }
 
         if field_candidates.iter().any(|c| c.is_none()) {
             return PartitionFilter::Predicate(predicate);
@@ -271,36 +273,49 @@ fn build_field_bounds(
     })
 }
 
+/// Collect `Eq`/`In`/`IsNull` candidate values per partition field.
+///
+/// Returns `false` as soon as any node of the tree is not fully represented by
+/// the collected candidates. Callers must then keep the original predicate: a
+/// `PartitionSet` is the sole authority in `matches_entry`, which never looks 
at
+/// the predicate again, and `ReadBuilder::is_exact_filter_pushdown` lets
+/// DataFusion drop its residual filter for a partition-only predicate.
 fn collect_eq_candidates<'a>(
     predicate: &'a Predicate,
     field_candidates: &mut Vec<Option<Vec<Option<&'a Datum>>>>,
-) {
+) -> bool {
     match predicate {
-        Predicate::And(children) => {
-            for child in children {
-                collect_eq_candidates(child, field_candidates);
-            }
-        }
+        Predicate::And(children) => children
+            .iter()
+            .all(|child| collect_eq_candidates(child, field_candidates)),
         Predicate::Leaf {
             index,
             op,
             literals,
             ..
-        } if *index < field_candidates.len() => match op {
-            PredicateOperator::Eq => {
-                if let Some(lit) = literals.first() {
-                    field_candidates[*index] = Some(vec![Some(lit)]);
-                }
-            }
-            PredicateOperator::In if !literals.is_empty() => {
-                field_candidates[*index] = 
Some(literals.iter().map(Some).collect());
+        } if *index < field_candidates.len() => {
+            // A second conjunct on the same field used to overwrite the first,
+            // keeping only whichever came last.
+            if field_candidates[*index].is_some() {
+                return false;
             }
-            PredicateOperator::IsNull => {
-                field_candidates[*index] = Some(vec![None]);
+            match op {
+                PredicateOperator::Eq if !literals.is_empty() => {
+                    field_candidates[*index] = Some(vec![Some(&literals[0])]);
+                    true
+                }
+                PredicateOperator::In if !literals.is_empty() => {
+                    field_candidates[*index] = 
Some(literals.iter().map(Some).collect());
+                    true
+                }
+                PredicateOperator::IsNull => {
+                    field_candidates[*index] = Some(vec![None]);
+                    true
+                }
+                _ => false,
             }
-            _ => {}
-        },
-        _ => {}
+        }
+        _ => false,
     }
 }
 
@@ -460,6 +475,83 @@ mod tests {
         assert!(matches!(filter, PartitionFilter::Predicate(_)));
     }
 
+    fn serialized_dt(fields: &[DataField], dt: &str) -> Vec<u8> {
+        let mut builder = BinaryRowBuilder::new(1);
+        builder.write_datum(0, &Datum::String(dt.into()), 
fields[0].data_type());
+        builder.build_serialized()
+    }
+
+    /// Coverage is complete, but `>=` is not expressible as a set of values.
+    #[test]
+    fn test_unexpressible_conjunct_on_covered_field_falls_back() {
+        let fields = partition_fields_dt();
+        let pb = PredicateBuilder::new(&fields);
+        let pred = Predicate::and(vec![
+            pb.equal("dt", Datum::String("2024-01-01".into())).unwrap(),
+            pb.greater_or_equal("dt", Datum::String("2024-01-02".into()))
+                .unwrap(),
+        ]);
+        let filter = PartitionFilter::from_predicate(pred, &fields);
+        assert!(matches!(filter, PartitionFilter::Predicate(_)));
+        assert!(!filter
+            .matches_entry(&serialized_dt(&fields, "2024-01-01"))
+            .unwrap());
+    }
+
+    /// Two expressible conjuncts on one field: the second assignment used to
+    /// overwrite the first, keeping whichever came last — here the wider `In`.
+    #[test]
+    fn test_second_conjunct_on_same_field_falls_back() {
+        let fields = partition_fields_dt();
+        let pb = PredicateBuilder::new(&fields);
+        let pred = Predicate::and(vec![
+            pb.equal("dt", Datum::String("2024-01-02".into())).unwrap(),
+            pb.is_in(
+                "dt",
+                vec![
+                    Datum::String("2024-01-01".into()),
+                    Datum::String("2024-01-02".into()),
+                ],
+            )
+            .unwrap(),
+        ]);
+        let filter = PartitionFilter::from_predicate(pred, &fields);
+        assert!(matches!(filter, PartitionFilter::Predicate(_)));
+        assert!(!filter
+            .matches_entry(&serialized_dt(&fields, "2024-01-01"))
+            .unwrap());
+        assert!(filter
+            .matches_entry(&serialized_dt(&fields, "2024-01-02"))
+            .unwrap());
+    }
+
+    /// An `Or` over the partition field narrows the `In` beside it.
+    #[test]
+    fn test_or_conjunct_beside_covering_in_falls_back() {
+        let fields = partition_fields_dt();
+        let pb = PredicateBuilder::new(&fields);
+        let pred = Predicate::and(vec![
+            Predicate::or(vec![
+                pb.equal("dt", Datum::String("2024-01-01".into())).unwrap(),
+                pb.equal("dt", Datum::String("2024-01-02".into())).unwrap(),
+            ]),
+            pb.is_in(
+                "dt",
+                vec![
+                    Datum::String("2024-01-01".into()),
+                    Datum::String("2024-01-02".into()),
+                    Datum::String("2024-01-03".into()),
+                ],
+            )
+            .unwrap(),
+        ]);
+        let filter = PartitionFilter::from_predicate(pred, &fields);
+        assert!(matches!(filter, PartitionFilter::Predicate(_)));
+        assert!(!filter
+            .matches_entry(&serialized_dt(&fields, "2024-01-03"))
+            .unwrap());
+    }
+
     #[test]
     fn test_is_null_in_partition_set() {
         let fields = partition_fields_dt();

Reply via email to