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 49fd093  feat(parquet): add page-level pruning via 
ColumnIndex/OffsetIndex (#427)
49fd093 is described below

commit 49fd093e4e27f27a8f1676773141641be1159335
Author: Junrui Lee <[email protected]>
AuthorDate: Wed Jul 1 14:55:23 2026 +0800

    feat(parquet): add page-level pruning via ColumnIndex/OffsetIndex (#427)
    
    The reader only pruned at row-group granularity; Java Paimon also prunes
    at page granularity through parquet-mr. arrow-rs does not do this
    automatically, so load the page index (PageIndexPolicy::Optional) and
    build a page-level RowSelection from per-page min/max/null_count, reusing
    the same StatsAccessor evaluator as row-group pruning.
    
    Pages are split per column chunk, so each predicate column is pruned
    against its own page layout and the per-column selections are intersected
    (the top-level predicate list is a conjunction).
    
    Fail open (keep the whole row group, leaving row-group pruning to decide)
    whenever the page index cannot be trusted: no predicate, missing page
    index (older files / writer without it), a column with only chunk-level
    statistics (ColumnIndexMetaData::NONE, whose accessors panic), a page
    count mismatch between the column and offset indexes, malformed offset
    page boundaries (non-zero start, non-monotonic, or out-of-range), or a
    predicate the stats cannot decide. Pruning never drops a page it is
    unsure about, and the page index is only loaded when a predicate can use
    it.
    
    Also fix ArrowFileReader::get_metadata, which dropped the column/offset
    index policies and would have made page-index loading silently no-op.
---
 crates/paimon/src/arrow/format/parquet.rs | 657 +++++++++++++++++++++++++++++-
 1 file changed, 644 insertions(+), 13 deletions(-)

diff --git a/crates/paimon/src/arrow/format/parquet.rs 
b/crates/paimon/src/arrow/format/parquet.rs
index 0d70e41..982f518 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -45,8 +45,10 @@ use parquet::arrow::arrow_reader::{
 use parquet::arrow::async_reader::{AsyncFileReader, MetadataFetch};
 use parquet::arrow::{AsyncArrowWriter, ParquetRecordBatchStreamBuilder, 
ProjectionMask};
 use parquet::basic::{Compression, ZstdLevel};
-use parquet::file::metadata::ParquetMetaDataReader;
-use parquet::file::metadata::{ParquetMetaData, RowGroupMetaData};
+use parquet::file::metadata::{
+    PageIndexPolicy, ParquetMetaData, ParquetMetaDataReader, RowGroupMetaData,
+};
+use parquet::file::page_index::column_index::ColumnIndexMetaData;
 use parquet::file::properties::WriterProperties;
 use parquet::file::statistics::Statistics as ParquetStatistics;
 use std::collections::HashMap;
@@ -151,8 +153,27 @@ impl FormatFileReader for ParquetFormatReader {
     ) -> crate::Result<ArrowRecordBatchStream> {
         let arrow_file_reader = ArrowFileReader::new(file_size, reader);
 
+        let empty_predicates = Vec::new();
+        let (preds, file_fields): (&[Predicate], &[DataField]) = match 
predicates {
+            Some(fp) => (&fp.predicates, &fp.file_fields),
+            None => (&empty_predicates, &[]),
+        };
+
+        // Only load the Parquet page index (ColumnIndex + OffsetIndex) when a
+        // predicate can use it for page-level pruning — matching Java Paimon,
+        // which gets page-level skipping for free via parquet-mr's
+        // `readNextFilteredRowGroup`. arrow-rs does not do this 
automatically, so
+        // we build the RowSelection ourselves below. Without a predicate the
+        // index is pure overhead (an extra metadata read with no benefit), so 
we
+        // skip it. `Optional` lets files without a page index fall through to
+        // row-group-level pruning instead of erroring.
+        let mut arrow_options = ArrowReaderOptions::new();
+        if !preds.is_empty() {
+            arrow_options = 
arrow_options.with_page_index_policy(PageIndexPolicy::Optional);
+        }
         let mut batch_stream_builder =
-            ParquetRecordBatchStreamBuilder::new(arrow_file_reader).await?;
+            
ParquetRecordBatchStreamBuilder::new_with_options(arrow_file_reader, 
arrow_options)
+                .await?;
 
         let parquet_schema = batch_stream_builder.parquet_schema().clone();
         let root_schema = parquet_schema.root_schema();
@@ -169,12 +190,6 @@ impl FormatFileReader for ParquetFormatReader {
         let mask = ProjectionMask::roots(&parquet_schema, root_indices);
         batch_stream_builder = batch_stream_builder.with_projection(mask);
 
-        let empty_predicates = Vec::new();
-        let (preds, file_fields): (&[Predicate], &[DataField]) = match 
predicates {
-            Some(fp) => (&fp.predicates, &fp.file_fields),
-            None => (&empty_predicates, &[]),
-        };
-
         let parquet_row_filter = build_parquet_row_filter(&parquet_schema, 
preds, file_fields)?;
         if let Some(f) = parquet_row_filter {
             batch_stream_builder = batch_stream_builder.with_row_filter(f);
@@ -187,6 +202,13 @@ impl FormatFileReader for ParquetFormatReader {
         )?;
         let mut combined_selection = predicate_row_selection;
 
+        // Page-level selection. Returns `None` when ColumnIndex / OffsetIndex 
are
+        // absent (page index not loaded, older files, writer without page 
index)
+        // or when no page could be skipped, so intersecting is a no-op then.
+        let page_selection =
+            build_predicate_page_selection(batch_stream_builder.metadata(), 
preds, file_fields)?;
+        combined_selection = 
intersect_optional_row_selections(combined_selection, page_selection);
+
         if let Some(ref ranges) = row_selection {
             let range_selection =
                 
build_row_ranges_selection(batch_stream_builder.metadata().row_groups(), 
ranges);
@@ -692,6 +714,311 @@ fn build_row_group_column_indices(
         .collect()
 }
 
+// ---------------------------------------------------------------------------
+// Page-index (ColumnIndex / OffsetIndex) pruning
+// ---------------------------------------------------------------------------
+
+/// Stats view over one data page of a **single** column, backed by the Parquet
+/// ColumnIndex. Plugs into the same [`StatsAccessor`] evaluator as row-group
+/// pruning so both layers share identical fail-open semantics.
+///
+/// Only `target_index` is exposed; every other column reports no stats so its
+/// leaves fail open. Parquet pages are laid out per column chunk (different
+/// columns may have different page counts and boundaries), so each column must
+/// be pruned against its own page layout — see 
[`build_predicate_page_selection`].
+struct ParquetPageStats<'a> {
+    /// File-field index this page belongs to.
+    target_index: usize,
+    /// ColumnIndex of the target column for the current row group.
+    column_index: &'a ColumnIndexMetaData,
+    page_idx: usize,
+    page_row_count: i64,
+}
+
+impl StatsAccessor for ParquetPageStats<'_> {
+    fn row_count(&self) -> i64 {
+        self.page_row_count
+    }
+
+    fn null_count(&self, index: usize) -> Option<i64> {
+        if index != self.target_index {
+            return None;
+        }
+        self.column_index.null_count(self.page_idx)
+    }
+
+    fn min_value(&self, index: usize, data_type: &DataType) -> Option<Datum> {
+        if index != self.target_index {
+            return None;
+        }
+        page_index_value_to_datum(self.column_index, self.page_idx, data_type, 
true)
+    }
+
+    fn max_value(&self, index: usize, data_type: &DataType) -> Option<Datum> {
+        if index != self.target_index {
+            return None;
+        }
+        page_index_value_to_datum(self.column_index, self.page_idx, data_type, 
false)
+    }
+}
+
+/// Decode a per-page min/max from a [`ColumnIndexMetaData`] into a [`Datum`].
+///
+/// Returns `None` (fail-open: keep the page) for null pages, missing values, 
or
+/// any type that the footer-side path also excludes (decimals, sub-millisecond
+/// timestamps).
+fn page_index_value_to_datum(
+    column_index: &ColumnIndexMetaData,
+    page_idx: usize,
+    data_type: &DataType,
+    is_min: bool,
+) -> Option<Datum> {
+    if column_index.is_null_page(page_idx) {
+        return None;
+    }
+    macro_rules! primitive {
+        ($idx:expr) => {
+            if is_min {
+                $idx.min_values().get(page_idx)
+            } else {
+                $idx.max_values().get(page_idx)
+            }
+        };
+    }
+    macro_rules! bytes {
+        ($idx:expr) => {
+            if is_min {
+                $idx.min_value(page_idx)
+            } else {
+                $idx.max_value(page_idx)
+            }
+        };
+    }
+    match (column_index, data_type) {
+        (ColumnIndexMetaData::BOOLEAN(idx), DataType::Boolean(_)) => {
+            primitive!(idx).copied().map(Datum::Bool)
+        }
+        (ColumnIndexMetaData::INT32(idx), DataType::TinyInt(_)) => 
primitive!(idx)
+            .and_then(|v| i8::try_from(*v).ok())
+            .map(Datum::TinyInt),
+        (ColumnIndexMetaData::INT32(idx), DataType::SmallInt(_)) => 
primitive!(idx)
+            .and_then(|v| i16::try_from(*v).ok())
+            .map(Datum::SmallInt),
+        (ColumnIndexMetaData::INT32(idx), DataType::Int(_)) => {
+            primitive!(idx).copied().map(Datum::Int)
+        }
+        (ColumnIndexMetaData::INT32(idx), DataType::Date(_)) => {
+            primitive!(idx).copied().map(Datum::Date)
+        }
+        (ColumnIndexMetaData::INT32(idx), DataType::Time(_)) => {
+            primitive!(idx).copied().map(Datum::Time)
+        }
+        (ColumnIndexMetaData::INT64(idx), DataType::BigInt(_)) => {
+            primitive!(idx).copied().map(Datum::Long)
+        }
+        (ColumnIndexMetaData::INT64(idx), DataType::Timestamp(ts)) if 
ts.precision() <= 3 => {
+            primitive!(idx)
+                .copied()
+                .map(|millis| Datum::Timestamp { millis, nanos: 0 })
+        }
+        (ColumnIndexMetaData::INT64(idx), DataType::LocalZonedTimestamp(ts))
+            if ts.precision() <= 3 =>
+        {
+            primitive!(idx)
+                .copied()
+                .map(|millis| Datum::LocalZonedTimestamp { millis, nanos: 0 })
+        }
+        (ColumnIndexMetaData::FLOAT(idx), DataType::Float(_)) => {
+            primitive!(idx).copied().map(Datum::Float)
+        }
+        (ColumnIndexMetaData::DOUBLE(idx), DataType::Double(_)) => {
+            primitive!(idx).copied().map(Datum::Double)
+        }
+        (ColumnIndexMetaData::BYTE_ARRAY(idx), DataType::Char(_))
+        | (ColumnIndexMetaData::BYTE_ARRAY(idx), DataType::VarChar(_)) => 
bytes!(idx)
+            .and_then(|bytes| std::str::from_utf8(bytes).ok())
+            .map(|s| Datum::String(s.to_string())),
+        (ColumnIndexMetaData::BYTE_ARRAY(idx), DataType::Binary(_))
+        | (ColumnIndexMetaData::BYTE_ARRAY(idx), DataType::VarBinary(_)) => {
+            bytes!(idx).map(|bytes| Datum::Bytes(bytes.to_vec()))
+        }
+        (ColumnIndexMetaData::FIXED_LEN_BYTE_ARRAY(idx), DataType::Binary(_))
+        | (ColumnIndexMetaData::FIXED_LEN_BYTE_ARRAY(idx), 
DataType::VarBinary(_)) => {
+            bytes!(idx).map(|bytes| Datum::Bytes(bytes.to_vec()))
+        }
+        _ => None,
+    }
+}
+
+/// Build a page-granular [`RowSelection`] from the Parquet ColumnIndex /
+/// OffsetIndex by pruning each predicate column against **its own** page 
layout,
+/// then intersecting the per-column selections.
+///
+/// Parquet splits pages per column chunk, so different columns in a row group
+/// may have different page counts and boundaries. Each column is therefore
+/// evaluated over its own `OffsetIndex` pages, exposing only that column to 
the
+/// stats evaluator (every other column fails open). Because the top-level
+/// predicate list is a conjunction, intersecting the per-column selections is
+/// sound.
+///
+/// Returns `None` when the page index is absent or when no page could be 
skipped
+/// (so the caller leaves the coarser row-group selection untouched). A page is
+/// kept whenever its stats are unavailable or the predicate cannot be decided
+/// from them — pruning never drops a page it is unsure about.
+fn build_predicate_page_selection(
+    metadata: &ParquetMetaData,
+    predicates: &[Predicate],
+    file_fields: &[DataField],
+) -> crate::Result<Option<RowSelection>> {
+    if predicates.is_empty() {
+        return Ok(None);
+    }
+    let (Some(column_index), Some(offset_index)) =
+        (metadata.column_index(), metadata.offset_index())
+    else {
+        return Ok(None);
+    };
+    let row_groups = metadata.row_groups();
+    if row_groups.is_empty() {
+        return Ok(None);
+    }
+
+    // Predicates are already remapped to file-level indices by the caller, so 
an
+    // identity mapping suffices (same convention as row-group pruning).
+    let identity_mapping: Vec<Option<usize>> = 
(0..file_fields.len()).map(Some).collect();
+    let column_lookup = 
build_row_group_column_indices(row_groups[0].columns(), file_fields);
+    let total_rows: usize = row_groups.iter().map(|rg| rg.num_rows() as 
usize).sum();
+
+    let mut referenced_fields = Vec::new();
+    for predicate in predicates {
+        collect_leaf_field_indices(predicate, &mut referenced_fields);
+    }
+    referenced_fields.sort_unstable();
+    referenced_fields.dedup();
+
+    let mut combined: Option<RowSelection> = None;
+    for field_index in referenced_fields {
+        // The column must resolve to a single parquet column present in the 
file.
+        let Some(parquet_col) = 
column_lookup.get(field_index).copied().flatten() else {
+            continue;
+        };
+
+        let mut ranges: Vec<Range<usize>> = Vec::new();
+        let mut rg_base = 0usize;
+        let mut any_skipped = false;
+
+        for (rg_idx, row_group) in row_groups.iter().enumerate() {
+            let rg_rows = row_group.num_rows() as usize;
+            let base = rg_base;
+            rg_base += rg_rows;
+
+            // Missing page index for this column/row group: keep the whole 
group
+            // (row-group pruning still applied).
+            let (Some(col_index), Some(col_offset)) = (
+                column_index.get(rg_idx).and_then(|rg| rg.get(parquet_col)),
+                offset_index.get(rg_idx).and_then(|rg| rg.get(parquet_col)),
+            ) else {
+                ranges.push(base..base + rg_rows);
+                continue;
+            };
+            let pages = col_offset.page_locations();
+            // Fail open when the column index is absent for this chunk
+            // (`ColumnIndexMetaData::NONE` — its accessors panic rather than
+            // return `None`), when the two indexes disagree on the page count,
+            // or when the page row boundaries are malformed. Either way we
+            // cannot safely map page stats to row ranges.
+            if pages.is_empty()
+                || matches!(col_index, ColumnIndexMetaData::NONE)
+                || col_index.num_pages() as usize != pages.len()
+                || !page_boundaries_valid(pages, rg_rows)
+            {
+                ranges.push(base..base + rg_rows);
+                continue;
+            }
+
+            for (page_idx, page) in pages.iter().enumerate() {
+                let page_start = page.first_row_index as usize;
+                let page_end = pages
+                    .get(page_idx + 1)
+                    .map_or(rg_rows, |next| next.first_row_index as usize);
+                if page_end <= page_start {
+                    continue;
+                }
+                let stats = ParquetPageStats {
+                    target_index: field_index,
+                    column_index: col_index,
+                    page_idx,
+                    page_row_count: (page_end - page_start) as i64,
+                };
+                if predicates_may_match_with_schema(
+                    predicates,
+                    &stats,
+                    &identity_mapping,
+                    file_fields,
+                ) {
+                    ranges.push(base + page_start..base + page_end);
+                } else {
+                    any_skipped = true;
+                }
+            }
+        }
+
+        if !any_skipped {
+            continue;
+        }
+        let selection = 
RowSelection::from_consecutive_ranges(ranges.into_iter(), total_rows);
+        combined = Some(match combined {
+            Some(prev) => prev.intersection(&selection),
+            None => selection,
+        });
+    }
+
+    Ok(combined)
+}
+
+/// Collect the file-field indices referenced by leaf predicates (recursing
+/// through compound nodes). Indices are already file-level (see caller).
+fn collect_leaf_field_indices(predicate: &Predicate, out: &mut Vec<usize>) {
+    match predicate {
+        Predicate::Leaf { index, .. } => out.push(*index),
+        Predicate::And(children) | Predicate::Or(children) => {
+            for child in children {
+                collect_leaf_field_indices(child, out);
+            }
+        }
+        Predicate::Not(child) => collect_leaf_field_indices(child, out),
+        Predicate::AlwaysTrue | Predicate::AlwaysFalse => {}
+    }
+}
+
+/// Validate that an OffsetIndex's page row boundaries are well-formed for a 
row
+/// group of `rg_rows` rows: the first page starts at row 0, `first_row_index`
+/// is strictly increasing, and every value stays within `[0, rg_rows]`.
+///
+/// Malformed metadata (negative, non-monotonic, or out-of-range boundaries)
+/// would otherwise produce invalid row ranges — huge values from `i64 as 
usize`
+/// underflow, or ranges past the row group — that panic
+/// `RowSelection::from_consecutive_ranges`. Callers fail open when this 
returns
+/// `false`.
+fn page_boundaries_valid(
+    pages: &[parquet::file::page_index::offset_index::PageLocation],
+    rg_rows: usize,
+) -> bool {
+    let rg_rows = rg_rows as i64;
+    let mut prev: i64 = -1;
+    for (page_idx, page) in pages.iter().enumerate() {
+        let first_row = page.first_row_index;
+        if page_idx == 0 && first_row != 0 {
+            return false;
+        }
+        if first_row <= prev || first_row > rg_rows {
+            return false;
+        }
+        prev = first_row;
+    }
+    true
+}
+
 // ---------------------------------------------------------------------------
 // Parquet statistics → Datum conversion
 // ---------------------------------------------------------------------------
@@ -1158,14 +1485,26 @@ impl AsyncFileReader for ArrowFileReader {
         options: Option<&ArrowReaderOptions>,
     ) -> BoxFuture<'_, parquet::errors::Result<Arc<ParquetMetaData>>> {
         let metadata_opts = options.map(|o| o.metadata_options().clone());
+        // The page-index policies live on `ArrowReaderOptions` directly, not
+        // inside `metadata_options`, so they must be forwarded explicitly (the
+        // upstream default `AsyncFileReader::get_metadata` does the same).
+        // Without this, `with_page_index_policy` would silently no-op here and
+        // no page index would ever be loaded.
+        let column_index_policy = options.map(|o| o.column_index_policy());
+        let offset_index_policy = options.map(|o| o.offset_index_policy());
         let prefetch_hint = Some(METADATA_SIZE_HINT);
         Box::pin(async move {
             let file_size = self.file_size;
-            let metadata = ParquetMetaDataReader::new()
+            let mut reader = ParquetMetaDataReader::new()
                 .with_prefetch_hint(prefetch_hint)
-                .with_metadata_options(metadata_opts)
-                .load_and_finish(self, file_size)
-                .await?;
+                .with_metadata_options(metadata_opts);
+            if let Some(policy) = column_index_policy {
+                reader = reader.with_column_index_policy(policy);
+            }
+            if let Some(policy) = offset_index_policy {
+                reader = reader.with_offset_index_policy(policy);
+            }
+            let metadata = reader.load_and_finish(self, file_size).await?;
             Ok(Arc::new(metadata))
         })
     }
@@ -1271,6 +1610,10 @@ fn split_ranges_for_concurrency(merged: Vec<Range<u64>>, 
concurrency: usize) ->
 mod tests {
     use super::build_parquet_row_filter;
     use super::ParquetFormatWriter;
+    use super::{
+        AsyncArrowWriter, PageIndexPolicy, ParquetMetaDataReader, Predicate, 
PredicateOperator,
+        RowSelection,
+    };
     use crate::arrow::format::FormatFileWriter;
     use crate::io::FileIOBuilder;
     use crate::spec::{DataField, DataType, Datum, IntType, PredicateBuilder};
@@ -1736,4 +2079,292 @@ mod tests {
             .expect("child should be Float32Array");
         assert_eq!(floats.values(), &[1.0, 2.0]);
     }
+
+    // -----------------------------------------------------------------------
+    // Page-index (ColumnIndex / OffsetIndex) pruning
+    // -----------------------------------------------------------------------
+
+    /// Write a single row group split into `total_rows / page_row_limit` data
+    /// pages. `id` runs 0..total_rows so page `p` covers ids
+    /// `[p*page_row_limit, (p+1)*page_row_limit)`.
+    async fn write_multi_page_parquet(page_row_limit: usize, total_rows: i32) 
-> Vec<u8> {
+        let schema = writer_arrow_schema();
+        let props = parquet::file::properties::WriterProperties::builder()
+            .set_data_page_row_count_limit(page_row_limit)
+            .set_write_batch_size(page_row_limit)
+            .set_max_row_group_row_count(Some(total_rows as usize))
+            .build();
+        let mut buf: Vec<u8> = Vec::new();
+        {
+            let mut writer =
+                AsyncArrowWriter::try_new(&mut buf, schema.clone(), 
Some(props)).unwrap();
+            let ids: Vec<i32> = (0..total_rows).collect();
+            let values: Vec<i32> = ids.iter().map(|v| v * 10).collect();
+            writer
+                .write(&writer_test_batch(&schema, ids, values))
+                .await
+                .unwrap();
+            writer.close().await.unwrap();
+        }
+        buf
+    }
+
+    /// Parse metadata from in-memory parquet bytes, optionally loading the 
page
+    /// index — mirrors what the reader does via `with_page_index_policy`.
+    fn load_metadata_with_page_index(
+        bytes: &[u8],
+        page_index: bool,
+    ) -> Arc<parquet::file::metadata::ParquetMetaData> {
+        let mut reader = ParquetMetaDataReader::new();
+        if page_index {
+            reader = reader
+                .with_column_index_policy(PageIndexPolicy::Optional)
+                .with_offset_index_policy(PageIndexPolicy::Optional);
+        }
+        let owned: bytes::Bytes = bytes.to_vec().into();
+        Arc::new(reader.parse_and_finish(&owned).unwrap())
+    }
+
+    fn int_field(name: &str) -> DataField {
+        DataField::new(0, name.to_string(), DataType::Int(IntType::new()))
+    }
+
+    fn id_leaf(op: PredicateOperator, literals: Vec<Datum>) -> Predicate {
+        Predicate::Leaf {
+            column: "id".to_string(),
+            index: 0,
+            data_type: DataType::Int(IntType::new()),
+            op,
+            literals,
+        }
+    }
+
+    #[tokio::test]
+    async fn test_page_selection_eq_keeps_only_matching_page() {
+        // 80 rows / 10 per page = 8 pages; page p covers ids [p*10, p*10+10).
+        let bytes = write_multi_page_parquet(10, 80).await;
+        let metadata = load_metadata_with_page_index(&bytes, true);
+        let fields = vec![int_field("id"), int_field("value")];
+
+        // Eq(35) falls only in page 3 ([30, 40)).
+        let predicates = vec![id_leaf(PredicateOperator::Eq, 
vec![Datum::Int(35)])];
+        let sel = super::build_predicate_page_selection(&metadata, 
&predicates, &fields)
+            .unwrap()
+            .expect("a page should be skipped");
+        assert_eq!(sel.row_count(), 10);
+    }
+
+    #[tokio::test]
+    async fn test_page_selection_eq_outside_all_pages_skips_everything() {
+        let bytes = write_multi_page_parquet(10, 80).await;
+        let metadata = load_metadata_with_page_index(&bytes, true);
+        let fields = vec![int_field("id"), int_field("value")];
+
+        // 1000 is past every page's max (79).
+        let predicates = vec![id_leaf(PredicateOperator::Eq, 
vec![Datum::Int(1000)])];
+        let sel = super::build_predicate_page_selection(&metadata, 
&predicates, &fields)
+            .unwrap()
+            .expect("all pages should be skipped");
+        assert_eq!(sel.row_count(), 0);
+    }
+
+    #[tokio::test]
+    async fn test_page_selection_range_keeps_overlapping_pages() {
+        let bytes = write_multi_page_parquet(10, 80).await;
+        let metadata = load_metadata_with_page_index(&bytes, true);
+        let fields = vec![int_field("id"), int_field("value")];
+
+        // Lt(25) overlaps pages 0 ([0,10)), 1 ([10,20)), 2 ([20,30)) — 30 
rows.
+        let predicates = vec![id_leaf(PredicateOperator::Lt, 
vec![Datum::Int(25)])];
+        let sel = super::build_predicate_page_selection(&metadata, 
&predicates, &fields)
+            .unwrap()
+            .expect("some pages should be skipped");
+        assert_eq!(sel.row_count(), 30);
+    }
+
+    #[tokio::test]
+    async fn test_page_selection_neq_falls_open() {
+        let bytes = write_multi_page_parquet(10, 80).await;
+        let metadata = load_metadata_with_page_index(&bytes, true);
+        let fields = vec![int_field("id"), int_field("value")];
+
+        // NotEq is conservative under stats: every page holds other values, so
+        // no page can be excluded → helper returns None (selection unchanged).
+        let predicates = vec![id_leaf(PredicateOperator::NotEq, 
vec![Datum::Int(35)])];
+        let sel = super::build_predicate_page_selection(&metadata, 
&predicates, &fields).unwrap();
+        assert!(sel.is_none(), "NotEq must not skip any page (got {sel:?})");
+    }
+
+    #[tokio::test]
+    async fn test_page_selection_returns_none_without_page_index() {
+        let bytes = write_multi_page_parquet(10, 80).await;
+        // Metadata parsed without the page index → helper must fall open.
+        let metadata = load_metadata_with_page_index(&bytes, false);
+        let fields = vec![int_field("id"), int_field("value")];
+
+        let predicates = vec![id_leaf(PredicateOperator::Eq, 
vec![Datum::Int(35)])];
+        let sel = super::build_predicate_page_selection(&metadata, 
&predicates, &fields).unwrap();
+        assert!(
+            sel.is_none(),
+            "missing page index must fall open (got {sel:?})"
+        );
+    }
+
+    /// Expand a [`RowSelection`] into the set of selected 0-based row indices.
+    fn selected_rows(sel: &RowSelection) -> Vec<usize> {
+        let mut rows = Vec::new();
+        let mut pos = 0usize;
+        for selector in sel.iter() {
+            if !selector.skip {
+                rows.extend(pos..pos + selector.row_count);
+            }
+            pos += selector.row_count;
+        }
+        rows
+    }
+
+    /// Write two columns whose page layouts differ: `id` is a single page 
while
+    /// `value` is forced into ~10-row pages via a per-column byte-size limit.
+    /// This is the layout that exposes the "borrow another column's page rows"
+    /// bug — the driver column's pages must not be reused for `value`'s stats.
+    async fn write_divergent_page_layout_parquet(total_rows: i32) -> Vec<u8> {
+        use parquet::schema::types::ColumnPath;
+        let schema = writer_arrow_schema();
+        let props = parquet::file::properties::WriterProperties::builder()
+            // `value` (4 bytes/row): ~40-byte pages ≈ 10 rows per page.
+            .set_column_data_page_size_limit(ColumnPath::from("value"), 40)
+            .set_write_batch_size(10)
+            // `id` keeps the default large page limit → a single page.
+            .set_max_row_group_row_count(Some(total_rows as usize))
+            .build();
+        let mut buf: Vec<u8> = Vec::new();
+        {
+            let mut writer =
+                AsyncArrowWriter::try_new(&mut buf, schema.clone(), 
Some(props)).unwrap();
+            let ids: Vec<i32> = (0..total_rows).collect();
+            let values: Vec<i32> = ids.iter().map(|v| v * 10).collect();
+            writer
+                .write(&writer_test_batch(&schema, ids, values))
+                .await
+                .unwrap();
+            writer.close().await.unwrap();
+        }
+        buf
+    }
+
+    #[tokio::test]
+    async fn test_page_selection_predicate_on_non_driver_column() {
+        // Regression: pruning must use each column's own page boundaries. Here
+        // `value` has many pages while `id` has one; a predicate on `value`
+        // must never drop rows that actually match.
+        let bytes = write_divergent_page_layout_parquet(80).await;
+        let metadata = load_metadata_with_page_index(&bytes, true);
+        let fields = vec![int_field("id"), int_field("value")];
+
+        // value == 350 (i.e. id == 35). Predicate is on the second field.
+        let predicates = vec![Predicate::Leaf {
+            column: "value".to_string(),
+            index: 1,
+            data_type: DataType::Int(IntType::new()),
+            op: PredicateOperator::Eq,
+            literals: vec![Datum::Int(350)],
+        }];
+        let sel = super::build_predicate_page_selection(&metadata, 
&predicates, &fields)
+            .unwrap()
+            .expect("value pages should be prunable");
+
+        // The matching row (index 35) must survive; pruning is still effective
+        // (not every row is kept).
+        let rows = selected_rows(&sel);
+        assert!(rows.contains(&35), "matching row 35 must be kept: {rows:?}");
+        assert!(
+            sel.row_count() < 80,
+            "some non-matching pages should be skipped (kept {} rows)",
+            sel.row_count()
+        );
+    }
+
+    #[tokio::test]
+    async fn test_page_selection_fails_open_on_missing_column_index() {
+        // A column with only chunk-level statistics has no ColumnIndex
+        // (`ColumnIndexMetaData::NONE`) but still gets an OffsetIndex. Its
+        // accessors panic rather than return None, so pruning must fail open
+        // for that column instead of touching the index.
+        use parquet::file::properties::EnabledStatistics;
+        use parquet::schema::types::ColumnPath;
+
+        let schema = writer_arrow_schema();
+        let props = parquet::file::properties::WriterProperties::builder()
+            .set_data_page_row_count_limit(10)
+            .set_write_batch_size(10)
+            .set_max_row_group_row_count(Some(80))
+            // `value` keeps chunk stats only → no column index, but an offset
+            // index is still written.
+            .set_column_statistics_enabled(ColumnPath::from("value"), 
EnabledStatistics::Chunk)
+            .build();
+        let mut buf: Vec<u8> = Vec::new();
+        {
+            let mut writer =
+                AsyncArrowWriter::try_new(&mut buf, schema.clone(), 
Some(props)).unwrap();
+            let ids: Vec<i32> = (0..80).collect();
+            let values: Vec<i32> = ids.iter().map(|v| v * 10).collect();
+            writer
+                .write(&writer_test_batch(&schema, ids, values))
+                .await
+                .unwrap();
+            writer.close().await.unwrap();
+        }
+        let metadata = load_metadata_with_page_index(&buf, true);
+        let fields = vec![int_field("id"), int_field("value")];
+
+        // Predicate on the column without a column index must not panic; it
+        // falls open (kept whole), so no page is skipped for it.
+        let predicates = vec![Predicate::Leaf {
+            column: "value".to_string(),
+            index: 1,
+            data_type: DataType::Int(IntType::new()),
+            op: PredicateOperator::Eq,
+            literals: vec![Datum::Int(350)],
+        }];
+        let sel = super::build_predicate_page_selection(&metadata, 
&predicates, &fields).unwrap();
+        assert!(
+            sel.is_none(),
+            "column without a ColumnIndex must fall open (got {sel:?})"
+        );
+    }
+
+    #[test]
+    fn test_page_boundaries_valid() {
+        use parquet::file::page_index::offset_index::PageLocation;
+        let page = |first_row_index: i64| PageLocation {
+            offset: 0,
+            compressed_page_size: 0,
+            first_row_index,
+        };
+
+        // Well-formed: starts at 0, strictly increasing, within [0, rg_rows].
+        assert!(super::page_boundaries_valid(
+            &[page(0), page(10), page(20)],
+            30
+        ));
+        // Single page starting at 0.
+        assert!(super::page_boundaries_valid(&[page(0)], 10));
+
+        // First page not at row 0.
+        assert!(!super::page_boundaries_valid(&[page(5), page(10)], 30));
+        // Negative first_row_index (would cast to a huge usize).
+        assert!(!super::page_boundaries_valid(&[page(0), page(-1)], 30));
+        // Non-monotonic.
+        assert!(!super::page_boundaries_valid(
+            &[page(0), page(20), page(10)],
+            30
+        ));
+        // Duplicate (not strictly increasing).
+        assert!(!super::page_boundaries_valid(
+            &[page(0), page(10), page(10)],
+            30
+        ));
+        // Beyond the row group.
+        assert!(!super::page_boundaries_valid(&[page(0), page(40)], 30));
+    }
 }

Reply via email to