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 7cb6f8a0 fix(datafusion): keep format table partition columns out of 
decoder filters (#811)
7cb6f8a0 is described below

commit 7cb6f8a00fdcf6fcf47edbf066f0c7e53de103c0
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Fri Sep 11 13:37:59 2026 +0800

    fix(datafusion): keep format table partition columns out of decoder filters 
(#811)
---
 .../datafusion/src/physical_plan/scan.rs           |  33 ++++-
 .../tests/format_table_partition_filters.rs        | 139 +++++++++++++++++++++
 2 files changed, 171 insertions(+), 1 deletion(-)

diff --git a/crates/integrations/datafusion/src/physical_plan/scan.rs 
b/crates/integrations/datafusion/src/physical_plan/scan.rs
index fe0a3859..d2fc56c3 100644
--- a/crates/integrations/datafusion/src/physical_plan/scan.rs
+++ b/crates/integrations/datafusion/src/physical_plan/scan.rs
@@ -50,7 +50,9 @@ use 
datafusion::physical_plan::stream::RecordBatchStreamAdapter;
 use datafusion::physical_plan::{DisplayAs, ExecutionPlan, Partitioning, 
PlanProperties};
 use futures::{FutureExt, StreamExt, TryStreamExt};
 use paimon::arrow::ParquetReadBudget;
-use paimon::spec::{DataField, Datum, MergeEngine, Predicate, PredicateBuilder, 
PredicateOperator};
+use paimon::spec::{
+    CoreOptions, DataField, Datum, MergeEngine, Predicate, PredicateBuilder, 
PredicateOperator,
+};
 use paimon::table::{ScanTrace, Table};
 use paimon::DataSplit;
 
@@ -219,6 +221,31 @@ struct RuntimeDecoderFilterPlan {
     datafusion_filters: Vec<Arc<dyn PhysicalExpr>>,
 }
 
+/// Whether a conjunct reads a partition column whose values the data files do 
not hold.
+///
+/// A format table keeps partition values in its directory names only, so a 
decoder filter on such
+/// a column would read it as missing and drop every row. Those conjuncts are 
left to the runtime
+/// filters, which run on batches with the partition columns filled in.
+fn reads_partition_column_absent_from_files(
+    conjunct: &Arc<dyn PhysicalExpr>,
+    table: &Table,
+    case_sensitive: bool,
+) -> bool {
+    if !CoreOptions::new(table.schema().options()).is_format_table() {
+        return false;
+    }
+    let partition_keys = table.schema().partition_keys();
+    collect_columns(conjunct).iter().any(|column| {
+        partition_keys.iter().any(|key| {
+            if case_sensitive {
+                key == column.name()
+            } else {
+                key.eq_ignore_ascii_case(column.name())
+            }
+        })
+    })
+}
+
 fn partition_runtime_decoder_filters(
     decoder_filters: &[Arc<dyn PhysicalExpr>],
     fields: &[DataField],
@@ -1040,6 +1067,10 @@ impl ExecutionPlan for PaimonTableScan {
                             conjunct,
                             self.table.schema().fields(),
                             self.case_sensitive,
+                        ) && !reads_partition_column_absent_from_files(
+                            conjunct,
+                            &self.table,
+                            self.case_sensitive,
                         )
                     })
                     .cloned(),
diff --git 
a/crates/integrations/datafusion/tests/format_table_partition_filters.rs 
b/crates/integrations/datafusion/tests/format_table_partition_filters.rs
new file mode 100644
index 00000000..fc302776
--- /dev/null
+++ b/crates/integrations/datafusion/tests/format_table_partition_filters.rs
@@ -0,0 +1,139 @@
+// 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.
+
+//! Filters on the partition columns of a `type=format-table` table.
+//!
+//! A format table keeps partition values in its directory names only; the data
+//! files do not hold those columns. A filter the scan cannot turn into a
+//! partition predicate still has to see the value from the directory name.
+
+use std::path::Path;
+use std::sync::Arc;
+
+use arrow_array::{Int64Array, RecordBatch};
+use arrow_schema::{DataType as ArrowDataType, Field, Schema as ArrowSchema};
+use paimon::catalog::{Catalog, FileSystemCatalog, Identifier};
+use paimon::spec::{BigIntType, BooleanType, DataType, Schema, VarCharType};
+use paimon::{CatalogOptions, Options};
+use paimon_datafusion::SQLContext;
+use parquet::arrow::ArrowWriter;
+use tempfile::TempDir;
+
+const DATABASE: &str = "test_db";
+const TABLE: &str = "events";
+
+/// A format table partitioned by `(dt, active)` with one data file per 
partition.
+///
+/// The warehouse is given as `file:/...`, the form in which listed file paths 
come back,
+/// so that a scan listing from the table root can relate each file to its 
partition
+/// directory.
+async fn setup_table() -> (TempDir, SQLContext) {
+    let tmp = TempDir::new().expect("Failed to create temp dir");
+    let mut options = Options::new();
+    options.set(
+        CatalogOptions::WAREHOUSE,
+        format!("file:{}", tmp.path().display()),
+    );
+    let catalog = Arc::new(FileSystemCatalog::new(options).expect("Failed to 
create catalog"));
+    catalog
+        .create_database(DATABASE, false, Default::default())
+        .await
+        .expect("CREATE DATABASE failed");
+    let schema = Schema::builder()
+        .column("dt", DataType::VarChar(VarCharType::new(32).unwrap()))
+        .column("active", DataType::Boolean(BooleanType::new()))
+        .column("id", DataType::BigInt(BigIntType::new()))
+        .partition_keys(vec!["dt".to_string(), "active".to_string()])
+        .option("type", "format-table")
+        .option("file.format", "parquet")
+        .build()
+        .unwrap();
+    catalog
+        .create_table(&Identifier::new(DATABASE, TABLE), schema, false)
+        .await
+        .expect("CREATE TABLE failed");
+    let table_dir = tmp.path().join(format!("{DATABASE}.db")).join(TABLE);
+    for (dt, active, id) in [("a", true, 1), ("b", false, 2)] {
+        write_ids(&table_dir.join(format!("dt={dt}/active={active}")), &[id]);
+    }
+    let mut context = SQLContext::new();
+    context.register_catalog("paimon", catalog).await.unwrap();
+    (tmp, context)
+}
+
+fn write_ids(directory: &Path, ids: &[i64]) {
+    std::fs::create_dir_all(directory).unwrap();
+    let schema = Arc::new(ArrowSchema::new(vec![Field::new(
+        "id",
+        ArrowDataType::Int64,
+        true,
+    )]));
+    let batch = RecordBatch::try_new(
+        Arc::clone(&schema),
+        vec![Arc::new(Int64Array::from(ids.to_vec()))],
+    )
+    .unwrap();
+    let file = 
std::fs::File::create(directory.join("part-0.parquet")).unwrap();
+    let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
+    writer.write(&batch).unwrap();
+    writer.close().unwrap();
+}
+
+async fn ids(context: &SQLContext, sql: &str) -> Vec<i64> {
+    let mut ids = Vec::new();
+    for batch in context.sql(sql).await.unwrap().collect().await.unwrap() {
+        let values = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        ids.extend(values.iter().flatten());
+    }
+    ids.sort_unstable();
+    ids
+}
+
+/// A filter on a partition column that is not a partition predicate, such as a
+/// bare boolean column (DataFusion simplifies `active = true` to `active`) or 
a
+/// function over the column, must be evaluated with the value from the
+/// directory name rather than dropping every row as a missing column.
+#[tokio::test]
+async fn test_filter_on_a_partition_column_reads_the_directory_value() {
+    let (_tmp, context) = setup_table().await;
+
+    let mut mismatches = Vec::new();
+    for (predicate, expected) in [
+        ("dt IN ('a', 'b')", vec![1, 2]),
+        ("active", vec![1]),
+        ("active = true", vec![1]),
+        ("NOT active", vec![2]),
+        ("upper(dt) = 'B'", vec![2]),
+        ("concat(dt, '-') = 'a-'", vec![1]),
+    ] {
+        let actual = ids(
+            &context,
+            &format!("SELECT id FROM paimon.{DATABASE}.{TABLE} WHERE 
{predicate}"),
+        )
+        .await;
+        if actual != expected {
+            mismatches.push(format!(
+                "{predicate}: expected {expected:?}, got {actual:?}"
+            ));
+        }
+    }
+    assert!(mismatches.is_empty(), "{mismatches:#?}");
+}

Reply via email to