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 feca4dc  feat: add conservative ORC predicate pushdown (#388)
feca4dc is described below

commit feca4dc24fd3adfd3772e5e8db83735579a4119b
Author: Jiwen liu <[email protected]>
AuthorDate: Tue Jun 16 13:52:55 2026 +0800

    feat: add conservative ORC predicate pushdown (#388)
---
 crates/integration_tests/tests/read_tables.rs | 167 ++++++++
 crates/paimon/src/arrow/format/mod.rs         |   3 +-
 crates/paimon/src/arrow/format/orc.rs         | 570 +++++++++++++++++++++++++-
 crates/paimon/src/table/read_builder.rs       |  11 +-
 4 files changed, 738 insertions(+), 13 deletions(-)

diff --git a/crates/integration_tests/tests/read_tables.rs 
b/crates/integration_tests/tests/read_tables.rs
index e5c3b05..c9e941a 100644
--- a/crates/integration_tests/tests/read_tables.rs
+++ b/crates/integration_tests/tests/read_tables.rs
@@ -96,8 +96,19 @@ async fn scan_and_read_with_fs_catalog(
 async fn scan_and_read_with_filter(
     table: &paimon::Table,
     filter: Predicate,
+) -> (Plan, Vec<RecordBatch>) {
+    scan_and_read_with_projection_and_filter(table, None, filter).await
+}
+
+async fn scan_and_read_with_projection_and_filter(
+    table: &paimon::Table,
+    projection: Option<&[&str]>,
+    filter: Predicate,
 ) -> (Plan, Vec<RecordBatch>) {
     let mut read_builder = table.new_read_builder();
+    if let Some(cols) = projection {
+        read_builder.with_projection(cols);
+    }
     read_builder.with_filter(filter);
     let scan = read_builder.new_scan();
     let plan = scan.plan().await.expect("Failed to plan scan");
@@ -2935,6 +2946,162 @@ async fn test_read_full_types_table() {
     assert_eq!(r.18, ("carol".into(), 300)); // struct
 }
 
+#[tokio::test]
+async fn test_read_orc_with_filter_only_column_projection() {
+    use paimon::spec::{Datum, PredicateBuilder};
+
+    let catalog = create_file_system_catalog();
+    let table = get_table_from_catalog(&catalog, "full_types_table").await;
+    let pb = PredicateBuilder::new(table.schema().fields());
+    let filter = pb
+        .equal("id", Datum::Int(2))
+        .expect("Failed to build id predicate");
+
+    let (_, batches) =
+        scan_and_read_with_projection_and_filter(&table, 
Some(&["col_string"]), filter).await;
+
+    let mut values = Vec::new();
+    for batch in &batches {
+        assert_eq!(batch.num_columns(), 1);
+        assert_eq!(batch.schema().field(0).name(), "col_string");
+        let col_string = batch
+            .column_by_name("col_string")
+            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
+            .expect("Expected StringArray for col_string");
+        values.extend((0..batch.num_rows()).map(|row| 
col_string.value(row).to_string()));
+    }
+
+    assert_eq!(values, vec!["orc-world"]);
+}
+
+async fn assert_full_types_orc_filter_matches(
+    filter: Predicate,
+    projected_column: &str,
+    expected_string_values: &[&str],
+) {
+    let catalog = create_file_system_catalog();
+    let table = get_table_from_catalog(&catalog, "full_types_table").await;
+
+    let (_, batches) =
+        scan_and_read_with_projection_and_filter(&table, 
Some(&[projected_column]), filter).await;
+
+    let mut values = Vec::new();
+    for batch in &batches {
+        assert_eq!(batch.num_columns(), 1);
+        assert_eq!(batch.schema().field(0).name(), projected_column);
+        let column = batch
+            .column_by_name(projected_column)
+            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
+            .expect("Expected StringArray for projected column");
+        values.extend((0..batch.num_rows()).map(|row| 
column.value(row).to_string()));
+    }
+
+    assert_eq!(values, expected_string_values);
+}
+
+#[tokio::test]
+async fn test_read_orc_with_supported_predicate_pushdown_types() {
+    use paimon::spec::{Datum, PredicateBuilder};
+
+    let catalog = create_file_system_catalog();
+    let table = get_table_from_catalog(&catalog, "full_types_table").await;
+    let pb = PredicateBuilder::new(table.schema().fields());
+
+    let cases = vec![
+        (
+            "col_boolean_eq",
+            pb.equal("col_boolean", Datum::Bool(false))
+                .expect("build boolean predicate"),
+            vec!["orc-world"],
+        ),
+        (
+            "col_tinyint_eq",
+            pb.equal("col_tinyint", Datum::TinyInt(2))
+                .expect("build tinyint predicate"),
+            vec!["orc-world"],
+        ),
+        (
+            "col_smallint_eq",
+            pb.equal("col_smallint", Datum::SmallInt(200))
+                .expect("build smallint predicate"),
+            vec!["orc-world"],
+        ),
+        (
+            "col_int_eq",
+            pb.equal("col_int", Datum::Int(2000))
+                .expect("build int predicate"),
+            vec!["orc-world"],
+        ),
+        (
+            "col_bigint_eq",
+            pb.equal("col_bigint", Datum::Long(200000))
+                .expect("build bigint predicate"),
+            vec!["orc-world"],
+        ),
+        (
+            "col_string_gte",
+            pb.greater_or_equal("col_string", 
Datum::String("orc-world".to_string()))
+                .expect("build string lower-bound predicate"),
+            vec!["parquet-hello", "orc-world"],
+        ),
+        (
+            "col_string_lte",
+            pb.less_or_equal("col_string", 
Datum::String("orc-world".to_string()))
+                .expect("build string upper-bound predicate"),
+            vec!["orc-world", "avro-test"],
+        ),
+    ];
+
+    for (case_name, filter, expected_string_values) in cases {
+        let (_, batches) =
+            scan_and_read_with_projection_and_filter(&table, 
Some(&["col_string"]), filter).await;
+
+        let mut values = Vec::new();
+        for batch in &batches {
+            assert_eq!(batch.num_columns(), 1);
+            assert_eq!(batch.schema().field(0).name(), "col_string");
+            let col_string = batch
+                .column_by_name("col_string")
+                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
+                .expect("Expected StringArray for col_string");
+            values.extend((0..batch.num_rows()).map(|row| 
col_string.value(row).to_string()));
+        }
+
+        assert_eq!(values, expected_string_values, "case {case_name}");
+    }
+}
+
+#[tokio::test]
+async fn test_read_orc_with_unsupported_date_predicate_remains_residual() {
+    use paimon::spec::{Datum, PredicateBuilder};
+
+    let catalog = create_file_system_catalog();
+    let table = get_table_from_catalog(&catalog, "full_types_table").await;
+    let pb = PredicateBuilder::new(table.schema().fields());
+    let filter = pb
+        .greater_or_equal("col_date", Datum::Date(19889))
+        .expect("build date predicate");
+
+    assert_full_types_orc_filter_matches(filter, "col_string", &["orc-world", 
"avro-test"]).await;
+}
+
+#[tokio::test]
+async fn test_read_orc_predicate_pushdown_remains_conservative() {
+    use paimon::spec::{Datum, PredicateBuilder};
+
+    let catalog = create_file_system_catalog();
+    let table = get_table_from_catalog(&catalog, "full_types_table").await;
+    let pb = PredicateBuilder::new(table.schema().fields());
+    let filter = pb.equal("id", Datum::Int(2)).expect("build id predicate");
+
+    assert!(
+        !table.new_read_builder().is_exact_filter_pushdown(&filter),
+        "ORC reader pruning must not make data predicates exact at the table 
boundary"
+    );
+
+    assert_full_types_orc_filter_matches(filter, "col_string", 
&["orc-world"]).await;
+}
+
 #[tokio::test]
 async fn test_read_full_types_boundary_table() {
     use arrow_array::{
diff --git a/crates/paimon/src/arrow/format/mod.rs 
b/crates/paimon/src/arrow/format/mod.rs
index 17552e1..e568bc5 100644
--- a/crates/paimon/src/arrow/format/mod.rs
+++ b/crates/paimon/src/arrow/format/mod.rs
@@ -43,7 +43,8 @@ pub(crate) struct FilePredicates {
 ///
 /// Each implementation (Parquet, ORC, ...) handles:
 /// - Column projection
-/// - Predicate pushdown (row-group/stripe pruning + row-level filtering)
+/// - Predicate pushdown where supported (row-group/stripe pruning and, for
+///   some formats, row-level filtering)
 /// - Row range selection
 #[async_trait]
 pub(crate) trait FormatFileReader: Send + Sync {
diff --git a/crates/paimon/src/arrow/format/orc.rs 
b/crates/paimon/src/arrow/format/orc.rs
index 8806fec..f4b078c 100644
--- a/crates/paimon/src/arrow/format/orc.rs
+++ b/crates/paimon/src/arrow/format/orc.rs
@@ -17,16 +17,20 @@
 
 use super::{FilePredicates, FormatFileReader};
 use crate::io::FileRead;
-use crate::spec::DataField;
+use crate::spec::{DataField, DataType, Datum, Predicate, PredicateOperator};
 use crate::table::{ArrowRecordBatchStream, RowRange};
 use crate::Error;
+use arrow_array::RecordBatch;
 use async_trait::async_trait;
 use bytes::Bytes;
 use futures::{future::BoxFuture, StreamExt};
+use orc_rust::predicate::PredicateValue;
 use orc_rust::projection::ProjectionMask;
 use orc_rust::reader::AsyncChunkReader;
 use orc_rust::ArrowReaderBuilder;
 
+const ORC_IN_PREDICATE_MAX_LITERALS: usize = 20;
+
 pub(crate) struct OrcFormatReader;
 
 #[async_trait]
@@ -36,8 +40,7 @@ impl FormatFileReader for OrcFormatReader {
         reader: Box<dyn FileRead>,
         file_size: u64,
         read_fields: &[DataField],
-        // TODO: support predicate pushdown for ORC (stripe pruning + 
row-level filtering)
-        _predicates: Option<&FilePredicates>,
+        predicates: Option<&FilePredicates>,
         batch_size: Option<usize>,
         row_selection: Option<Vec<RowRange>>,
     ) -> crate::Result<ArrowRecordBatchStream> {
@@ -50,12 +53,21 @@ impl FormatFileReader for OrcFormatReader {
                 source: Some(Box::new(e)),
             })?;
 
-        let projected_names: Vec<&str> = read_fields.iter().map(|f| 
f.name()).collect();
+        let mut projected_names: Vec<String> =
+            read_fields.iter().map(|f| f.name().to_string()).collect();
+        let orc_predicate = build_orc_predicate(predicates);
+        if let Some(ref predicate) = orc_predicate {
+            collect_orc_predicate_columns(predicate, &mut projected_names);
+        }
         let projection =
             
ProjectionMask::named_roots(builder.file_metadata().root_data_type(), 
&projected_names);
 
         let mut builder = builder.with_projection(projection);
 
+        if let Some(predicate) = orc_predicate {
+            builder = builder.with_predicate(predicate);
+        }
+
         if let Some(size) = batch_size {
             builder = builder.with_batch_size(size);
         }
@@ -72,17 +84,263 @@ impl FormatFileReader for OrcFormatReader {
         }
 
         let stream = builder.build_async();
+        let requested_names: Vec<String> =
+            read_fields.iter().map(|f| f.name().to_string()).collect();
         Ok(stream
-            .map(|r| {
-                r.map_err(|e| Error::UnexpectedError {
+            .map(move |r| {
+                let batch = r.map_err(|e| Error::UnexpectedError {
                     message: format!("ORC read error: {e}"),
                     source: Some(Box::new(e)),
-                })
+                })?;
+                project_orc_batch_to_requested_fields(batch, &requested_names)
             })
             .boxed())
     }
 }
 
+// ---------------------------------------------------------------------------
+// Paimon predicates → orc-rust conservative row-group predicates.
+//
+// orc-rust evaluates these predicates against row-group statistics and may 
keep
+// non-matching rows from a selected row group. Exact residual filtering 
remains
+// the caller's responsibility.
+// ---------------------------------------------------------------------------
+
+fn build_orc_predicate(
+    predicates: Option<&FilePredicates>,
+) -> Option<orc_rust::predicate::Predicate> {
+    let predicates = predicates?;
+    let mut orc_predicates = Vec::new();
+    for predicate in &predicates.predicates {
+        if let Some(predicate) = build_orc_predicate_inner(
+            predicate,
+            &predicates.file_fields,
+            CompoundPredicateMode::RootAnd,
+        ) {
+            orc_predicates.push(predicate);
+        }
+    }
+
+    match orc_predicates.len() {
+        0 => None,
+        1 => orc_predicates.pop(),
+        _ => Some(orc_rust::predicate::Predicate::and(orc_predicates)),
+    }
+}
+
+fn build_orc_predicate_inner(
+    predicate: &Predicate,
+    file_fields: &[DataField],
+    mode: CompoundPredicateMode,
+) -> Option<orc_rust::predicate::Predicate> {
+    match predicate {
+        Predicate::Leaf { .. } => build_orc_leaf_predicate(predicate, 
file_fields),
+        Predicate::And(children) => build_orc_and_predicate(children, 
file_fields, mode),
+        Predicate::Or(children) => build_orc_or_predicate(children, 
file_fields),
+        Predicate::AlwaysTrue => None,
+        Predicate::AlwaysFalse | Predicate::Not(_) => None,
+    }
+}
+
+#[derive(Clone, Copy)]
+enum CompoundPredicateMode {
+    RootAnd,
+    RequireExact,
+}
+
+fn build_orc_and_predicate(
+    children: &[Predicate],
+    file_fields: &[DataField],
+    mode: CompoundPredicateMode,
+) -> Option<orc_rust::predicate::Predicate> {
+    let require_exact = matches!(mode, CompoundPredicateMode::RequireExact);
+
+    let mut converted = Vec::with_capacity(children.len());
+    for child in children {
+        match build_orc_predicate_inner(child, file_fields, 
CompoundPredicateMode::RootAnd) {
+            Some(predicate) => converted.push(predicate),
+            None if require_exact => return None,
+            None => {}
+        }
+    }
+
+    match converted.len() {
+        0 => None,
+        1 => converted.pop(),
+        _ => Some(orc_rust::predicate::Predicate::and(converted)),
+    }
+}
+
+fn build_orc_or_predicate(
+    children: &[Predicate],
+    file_fields: &[DataField],
+) -> Option<orc_rust::predicate::Predicate> {
+    let mut converted = Vec::with_capacity(children.len());
+    for child in children {
+        converted.push(build_orc_predicate_inner(
+            child,
+            file_fields,
+            CompoundPredicateMode::RequireExact,
+        )?);
+    }
+
+    match converted.len() {
+        0 => None,
+        1 => converted.pop(),
+        _ => Some(orc_rust::predicate::Predicate::or(converted)),
+    }
+}
+
+fn build_orc_leaf_predicate(
+    predicate: &Predicate,
+    file_fields: &[DataField],
+) -> Option<orc_rust::predicate::Predicate> {
+    let Predicate::Leaf {
+        index,
+        op,
+        literals,
+        ..
+    } = predicate
+    else {
+        return None;
+    };
+    let file_field = file_fields.get(*index)?;
+    let column = file_field.name();
+
+    match op {
+        PredicateOperator::IsNotNull
+            if data_type_supported_for_orc_predicate(file_field.data_type()) =>
+        {
+            Some(orc_rust::predicate::Predicate::is_not_null(column))
+        }
+        PredicateOperator::Eq
+        | PredicateOperator::Lt
+        | PredicateOperator::LtEq
+        | PredicateOperator::Gt
+        | PredicateOperator::GtEq => {
+            if *op == PredicateOperator::Eq
+                && matches!(
+                    file_field.data_type(),
+                    DataType::Float(_) | DataType::Double(_)
+                )
+            {
+                return None;
+            }
+            let literal = literals.first()?;
+            let value = datum_to_orc_value(literal, file_field.data_type())?;
+            Some(match op {
+                PredicateOperator::Eq => 
orc_rust::predicate::Predicate::eq(column, value),
+                PredicateOperator::Lt => 
orc_rust::predicate::Predicate::lt(column, value),
+                PredicateOperator::LtEq => 
orc_rust::predicate::Predicate::lte(column, value),
+                PredicateOperator::Gt => 
orc_rust::predicate::Predicate::gt(column, value),
+                PredicateOperator::GtEq => 
orc_rust::predicate::Predicate::gte(column, value),
+                _ => unreachable!(),
+            })
+        }
+        PredicateOperator::In => {
+            if literals.is_empty() || literals.len() > 
ORC_IN_PREDICATE_MAX_LITERALS {
+                return None;
+            }
+            let mut values = Vec::with_capacity(literals.len());
+            for literal in literals {
+                values.push(orc_rust::predicate::Predicate::eq(
+                    column,
+                    datum_to_orc_value(literal, file_field.data_type())?,
+                ));
+            }
+            Some(orc_rust::predicate::Predicate::or(values))
+        }
+        PredicateOperator::IsNull | PredicateOperator::NotEq | 
PredicateOperator::NotIn => None,
+        PredicateOperator::IsNotNull => None,
+    }
+}
+
+fn data_type_supported_for_orc_predicate(data_type: &DataType) -> bool {
+    matches!(
+        data_type,
+        DataType::Boolean(_)
+            | DataType::TinyInt(_)
+            | DataType::SmallInt(_)
+            | DataType::Int(_)
+            | DataType::BigInt(_)
+            | DataType::Float(_)
+            | DataType::Double(_)
+            | DataType::Char(_)
+            | DataType::VarChar(_)
+    )
+}
+
+fn datum_to_orc_value(datum: &Datum, data_type: &DataType) -> 
Option<PredicateValue> {
+    match (datum, data_type) {
+        (Datum::Bool(value), DataType::Boolean(_)) => 
Some(PredicateValue::Boolean(Some(*value))),
+        (Datum::TinyInt(value), DataType::TinyInt(_)) => 
Some(PredicateValue::Int8(Some(*value))),
+        (Datum::SmallInt(value), DataType::SmallInt(_)) => {
+            Some(PredicateValue::Int16(Some(*value)))
+        }
+        (Datum::Int(value), DataType::Int(_)) => 
Some(PredicateValue::Int32(Some(*value))),
+        (Datum::Long(value), DataType::BigInt(_)) => 
Some(PredicateValue::Int64(Some(*value))),
+        (Datum::Float(value), DataType::Float(_)) => 
Some(PredicateValue::Float32(Some(*value))),
+        (Datum::Double(value), DataType::Double(_)) => 
Some(PredicateValue::Float64(Some(*value))),
+        (Datum::String(value), DataType::Char(_) | DataType::VarChar(_)) => {
+            Some(PredicateValue::Utf8(Some(value.clone())))
+        }
+        _ => None,
+    }
+}
+
+fn collect_orc_predicate_columns(
+    predicate: &orc_rust::predicate::Predicate,
+    projected_names: &mut Vec<String>,
+) {
+    collect_orc_predicate_columns_inner(predicate, projected_names);
+}
+
+fn collect_orc_predicate_columns_inner(
+    predicate: &orc_rust::predicate::Predicate,
+    projected_names: &mut Vec<String>,
+) {
+    match predicate {
+        orc_rust::predicate::Predicate::Comparison { column, .. }
+        | orc_rust::predicate::Predicate::IsNull { column }
+        | orc_rust::predicate::Predicate::IsNotNull { column } => {
+            if !projected_names.iter().any(|name| name == column) {
+                projected_names.push(column.clone());
+            }
+        }
+        orc_rust::predicate::Predicate::And(children)
+        | orc_rust::predicate::Predicate::Or(children) => {
+            for child in children {
+                collect_orc_predicate_columns_inner(child, projected_names);
+            }
+        }
+        orc_rust::predicate::Predicate::Not(child) => {
+            collect_orc_predicate_columns_inner(child, projected_names);
+        }
+    }
+}
+
+fn project_orc_batch_to_requested_fields(
+    batch: RecordBatch,
+    requested_names: &[String],
+) -> crate::Result<RecordBatch> {
+    let indices: Vec<usize> = requested_names
+        .iter()
+        .map(|name| {
+            batch
+                .schema()
+                .index_of(name)
+                .map_err(|e| Error::UnexpectedError {
+                    message: format!("ORC batch is missing requested column 
'{name}': {e}"),
+                    source: Some(Box::new(e)),
+                })
+        })
+        .collect::<crate::Result<_>>()?;
+    batch.project(&indices).map_err(|e| Error::UnexpectedError {
+        message: format!("Failed to project ORC batch: {e}"),
+        source: Some(Box::new(e)),
+    })
+}
+
 // ---------------------------------------------------------------------------
 // Row ranges → orc_rust::RowSelection
 // ---------------------------------------------------------------------------
@@ -156,7 +414,33 @@ impl AsyncChunkReader for OrcFileReader {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use arrow_array::{Int32Array, StringArray};
+    use arrow_schema::{DataType as ArrowDataType, Field, Schema};
     use orc_rust::row_selection::RowSelector;
+    use std::sync::Arc;
+
+    use crate::spec::{DateType, DecimalType, DoubleType, FloatType, IntType};
+
+    fn field(index: i32, name: &str, data_type: DataType) -> DataField {
+        DataField::new(index, name.to_string(), data_type)
+    }
+
+    fn leaf(index: usize, op: PredicateOperator, literals: Vec<Datum>) -> 
Predicate {
+        Predicate::Leaf {
+            column: format!("c{index}"),
+            index,
+            data_type: DataType::Int(IntType::new()),
+            op,
+            literals,
+        }
+    }
+
+    fn file_predicates(predicates: Vec<Predicate>, file_fields: 
Vec<DataField>) -> FilePredicates {
+        FilePredicates {
+            predicates,
+            file_fields,
+        }
+    }
 
     #[test]
     fn test_build_range_row_selection_single_range() {
@@ -192,4 +476,276 @@ mod tests {
         let expected: orc_rust::row_selection::RowSelection = 
vec![RowSelector::skip(5)].into();
         assert_eq!(sel, expected);
     }
+
+    #[test]
+    fn test_build_orc_predicate_supported_leaf() {
+        let predicates = file_predicates(
+            vec![leaf(0, PredicateOperator::GtEq, vec![Datum::Int(7)])],
+            vec![field(0, "id", DataType::Int(IntType::new()))],
+        );
+
+        let predicate = build_orc_predicate(Some(&predicates)).unwrap();
+        assert_eq!(
+            predicate,
+            orc_rust::predicate::Predicate::gte("id", 
PredicateValue::Int32(Some(7)))
+        );
+    }
+
+    #[test]
+    fn test_build_orc_predicate_type_mismatch_fails_open() {
+        let predicates = file_predicates(
+            vec![leaf(0, PredicateOperator::Eq, vec![Datum::Long(7)])],
+            vec![field(0, "id", DataType::Int(IntType::new()))],
+        );
+
+        assert!(build_orc_predicate(Some(&predicates)).is_none());
+    }
+
+    #[test]
+    fn test_build_orc_predicate_unsupported_type_fails_open() {
+        let predicates = file_predicates(
+            vec![Predicate::Leaf {
+                column: "dt".to_string(),
+                index: 0,
+                data_type: DataType::Date(DateType::new()),
+                op: PredicateOperator::Eq,
+                literals: vec![Datum::Date(1)],
+            }],
+            vec![field(0, "dt", DataType::Date(DateType::new()))],
+        );
+
+        assert!(build_orc_predicate(Some(&predicates)).is_none());
+    }
+
+    #[test]
+    fn test_build_orc_predicate_unsupported_operator_fails_open() {
+        let predicates = file_predicates(
+            vec![leaf(0, PredicateOperator::NotEq, vec![Datum::Int(7)])],
+            vec![field(0, "id", DataType::Int(IntType::new()))],
+        );
+
+        assert!(build_orc_predicate(Some(&predicates)).is_none());
+    }
+
+    #[test]
+    fn test_build_orc_predicate_float_eq_fails_open() {
+        let float_predicates = file_predicates(
+            vec![Predicate::Leaf {
+                column: "f".to_string(),
+                index: 0,
+                data_type: DataType::Float(FloatType::new()),
+                op: PredicateOperator::Eq,
+                literals: vec![Datum::Float(1.5)],
+            }],
+            vec![field(0, "f", DataType::Float(FloatType::new()))],
+        );
+        let double_predicates = file_predicates(
+            vec![Predicate::Leaf {
+                column: "d".to_string(),
+                index: 0,
+                data_type: DataType::Double(DoubleType::new()),
+                op: PredicateOperator::Eq,
+                literals: vec![Datum::Double(2.5)],
+            }],
+            vec![field(0, "d", DataType::Double(DoubleType::new()))],
+        );
+
+        assert!(build_orc_predicate(Some(&float_predicates)).is_none());
+        assert!(build_orc_predicate(Some(&double_predicates)).is_none());
+    }
+
+    #[test]
+    fn test_build_orc_predicate_is_not_null_requires_supported_type() {
+        let decimal_type = DataType::Decimal(DecimalType::new(10, 2).unwrap());
+        let predicates = file_predicates(
+            vec![Predicate::Leaf {
+                column: "amount".to_string(),
+                index: 0,
+                data_type: decimal_type.clone(),
+                op: PredicateOperator::IsNotNull,
+                literals: vec![],
+            }],
+            vec![field(0, "amount", decimal_type)],
+        );
+
+        assert!(build_orc_predicate(Some(&predicates)).is_none());
+    }
+
+    #[test]
+    fn test_build_orc_predicate_index_out_of_bounds_fails_open() {
+        let predicates = file_predicates(
+            vec![leaf(1, PredicateOperator::Eq, vec![Datum::Int(7)])],
+            vec![field(0, "id", DataType::Int(IntType::new()))],
+        );
+
+        assert!(build_orc_predicate(Some(&predicates)).is_none());
+    }
+
+    #[test]
+    fn test_build_orc_predicate_and_pushes_supported_children() {
+        let predicates = file_predicates(
+            vec![Predicate::and(vec![
+                leaf(0, PredicateOperator::Gt, vec![Datum::Int(1)]),
+                leaf(0, PredicateOperator::NotEq, vec![Datum::Int(7)]),
+            ])],
+            vec![field(0, "id", DataType::Int(IntType::new()))],
+        );
+
+        let predicate = build_orc_predicate(Some(&predicates)).unwrap();
+        assert_eq!(
+            predicate,
+            orc_rust::predicate::Predicate::gt("id", 
PredicateValue::Int32(Some(1)))
+        );
+    }
+
+    #[test]
+    fn test_build_orc_predicate_top_level_and_pushes_supported_predicates() {
+        let predicates = file_predicates(
+            vec![
+                leaf(0, PredicateOperator::Gt, vec![Datum::Int(1)]),
+                leaf(0, PredicateOperator::LtEq, vec![Datum::Int(9)]),
+                leaf(0, PredicateOperator::NotEq, vec![Datum::Int(7)]),
+            ],
+            vec![field(0, "id", DataType::Int(IntType::new()))],
+        );
+
+        let predicate = build_orc_predicate(Some(&predicates)).unwrap();
+        assert_eq!(
+            predicate,
+            orc_rust::predicate::Predicate::and(vec![
+                orc_rust::predicate::Predicate::gt("id", 
PredicateValue::Int32(Some(1))),
+                orc_rust::predicate::Predicate::lte("id", 
PredicateValue::Int32(Some(9))),
+            ])
+        );
+    }
+
+    #[test]
+    fn test_build_orc_predicate_or_requires_all_children_supported() {
+        let predicates = file_predicates(
+            vec![Predicate::or(vec![
+                leaf(0, PredicateOperator::Lt, vec![Datum::Int(1)]),
+                Predicate::Not(Box::new(leaf(
+                    0,
+                    PredicateOperator::Eq,
+                    vec![Datum::Int(7)],
+                ))),
+            ])],
+            vec![field(0, "id", DataType::Int(IntType::new()))],
+        );
+
+        assert!(build_orc_predicate(Some(&predicates)).is_none());
+    }
+
+    #[test]
+    fn test_build_orc_predicate_or_with_nested_and_requires_exact_children() {
+        let predicates = file_predicates(
+            vec![Predicate::or(vec![
+                Predicate::and(vec![
+                    leaf(0, PredicateOperator::Gt, vec![Datum::Int(1)]),
+                    leaf(0, PredicateOperator::NotEq, vec![Datum::Int(7)]),
+                ]),
+                leaf(0, PredicateOperator::Lt, vec![Datum::Int(0)]),
+            ])],
+            vec![field(0, "id", DataType::Int(IntType::new()))],
+        );
+
+        assert!(build_orc_predicate(Some(&predicates)).is_none());
+    }
+
+    #[test]
+    fn test_build_orc_predicate_in_limit() {
+        let predicates = file_predicates(
+            vec![leaf(
+                0,
+                PredicateOperator::In,
+                (0..=ORC_IN_PREDICATE_MAX_LITERALS)
+                    .map(|value| Datum::Int(value as i32))
+                    .collect(),
+            )],
+            vec![field(0, "id", DataType::Int(IntType::new()))],
+        );
+
+        assert!(build_orc_predicate(Some(&predicates)).is_none());
+    }
+
+    #[test]
+    fn test_build_orc_predicate_in_supported_literals() {
+        let predicates = file_predicates(
+            vec![leaf(
+                0,
+                PredicateOperator::In,
+                vec![Datum::Int(1), Datum::Int(3)],
+            )],
+            vec![field(0, "id", DataType::Int(IntType::new()))],
+        );
+
+        let predicate = build_orc_predicate(Some(&predicates)).unwrap();
+        assert_eq!(
+            predicate,
+            orc_rust::predicate::Predicate::or(vec![
+                orc_rust::predicate::Predicate::eq("id", 
PredicateValue::Int32(Some(1))),
+                orc_rust::predicate::Predicate::eq("id", 
PredicateValue::Int32(Some(3))),
+            ])
+        );
+    }
+
+    #[test]
+    fn test_collect_orc_predicate_columns_adds_filter_columns_once() {
+        let predicate = orc_rust::predicate::Predicate::and(vec![
+            orc_rust::predicate::Predicate::eq(
+                "category",
+                PredicateValue::Utf8(Some("a".to_string())),
+            ),
+            orc_rust::predicate::Predicate::gt("score", 
PredicateValue::Int32(Some(1))),
+        ]);
+        let mut projected_names = vec!["name".to_string()];
+
+        collect_orc_predicate_columns(&predicate, &mut projected_names);
+        collect_orc_predicate_columns(&predicate, &mut projected_names);
+
+        assert_eq!(projected_names, vec!["name", "category", "score"]);
+    }
+
+    #[test]
+    fn test_project_orc_batch_to_requested_fields() {
+        let schema = Arc::new(Schema::new(vec![
+            Field::new("name", ArrowDataType::Utf8, true),
+            Field::new("id", ArrowDataType::Int32, true),
+        ]));
+        let batch = RecordBatch::try_new(
+            schema,
+            vec![
+                Arc::new(StringArray::from(vec!["a", "b"])),
+                Arc::new(Int32Array::from(vec![1, 2])),
+            ],
+        )
+        .unwrap();
+
+        let projected = project_orc_batch_to_requested_fields(batch, 
&["name".to_string()])
+            .expect("project ORC batch");
+
+        assert_eq!(projected.num_columns(), 1);
+        assert_eq!(projected.schema().field(0).name(), "name");
+    }
+
+    #[test]
+    fn test_project_orc_batch_to_requested_fields_errors_on_missing_column() {
+        let schema = Arc::new(Schema::new(vec![Field::new(
+            "name",
+            ArrowDataType::Utf8,
+            true,
+        )]));
+        let batch =
+            RecordBatch::try_new(schema, 
vec![Arc::new(StringArray::from(vec!["a"]))]).unwrap();
+
+        let error = project_orc_batch_to_requested_fields(batch, 
&["id".to_string()])
+            .expect_err("missing requested column should error");
+
+        assert!(
+            error
+                .to_string()
+                .contains("ORC batch is missing requested column 'id'"),
+            "unexpected error: {error}"
+        );
+    }
 }
diff --git a/crates/paimon/src/table/read_builder.rs 
b/crates/paimon/src/table/read_builder.rs
index 3e7684d..a884d3e 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -150,11 +150,12 @@ impl<'a> ReadBuilder<'a> {
     /// Stats pruning is per file. Files with a different `schema_id`,
     /// incompatible stats layout, or inconclusive stats are kept.
     ///
-    /// [`TableRead`] may use supported non-partition data predicates only on
-    /// the regular Parquet read path for conservative row-group pruning and
-    /// native Parquet row filtering. Unsupported predicates, non-Parquet
-    /// reads, and data-evolution reads remain residual and should still be
-    /// applied by the caller if exact filtering semantics are required.
+    /// [`TableRead`] may use supported non-partition data predicates on 
regular
+    /// Parquet and ORC read paths for conservative row-group pruning. Parquet
+    /// may also use native row filtering. Unsupported predicates, formats
+    /// without reader pruning, and data-evolution reads remain residual and
+    /// should still be applied by the caller if exact filtering semantics are
+    /// required.
     pub fn with_filter(&mut self, filter: Predicate) -> &mut Self {
         self.filter = normalize_filter(self.table, filter);
         self.try_extract_row_id_ranges();

Reply via email to