This is an automated email from the ASF dual-hosted git repository.
liurenjie1024 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 7aa8bdd Table Scan: Add Row Group Skipping (#558)
7aa8bdd is described below
commit 7aa8bddeb09420b2f81a50112603de28aeaf3be7
Author: Scott Donnelly <[email protected]>
AuthorDate: Thu Aug 29 04:37:48 2024 +0100
Table Scan: Add Row Group Skipping (#558)
* feat(scan): add row group and page index row selection filtering
* fix(row selection): off-by-one error
* feat: remove row selection to defer to a second PR
* feat: better min/max val conversion in RowGroupMetricsEvaluator
* test(row_group_filtering): first three tests
* test(row_group_filtering): next few tests
* test: add more tests for RowGroupMetricsEvaluator
* chore: refactor test assertions to silence clippy lints
* refactor: consolidate parquet stat min/max parsing in one place
---
Cargo.toml | 2 +
crates/iceberg/Cargo.toml | 2 +
crates/iceberg/src/arrow/reader.rs | 210 ++-
crates/iceberg/src/arrow/schema.rs | 103 ++
crates/iceberg/src/expr/visitors/mod.rs | 1 +
.../expr/visitors/row_group_metrics_evaluator.rs | 1872 ++++++++++++++++++++
crates/iceberg/src/scan.rs | 23 +-
.../src/writer/file_writer/parquet_writer.rs | 212 +--
8 files changed, 2187 insertions(+), 238 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index b59d432..8d04f67 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -72,9 +72,11 @@ once_cell = "1"
opendal = "0.49"
ordered-float = "4"
parquet = "52"
+paste = "1"
pilota = "0.11.2"
pretty_assertions = "1.4"
port_scanner = "0.1.5"
+rand = "0.8"
regex = "1.10.5"
reqwest = { version = "0.12", default-features = false, features = ["json"] }
rust_decimal = "1.31"
diff --git a/crates/iceberg/Cargo.toml b/crates/iceberg/Cargo.toml
index 6218e98..6166d36 100644
--- a/crates/iceberg/Cargo.toml
+++ b/crates/iceberg/Cargo.toml
@@ -66,6 +66,7 @@ once_cell = { workspace = true }
opendal = { workspace = true }
ordered-float = { workspace = true }
parquet = { workspace = true, features = ["async"] }
+paste = { workspace = true }
reqwest = { workspace = true }
rust_decimal = { workspace = true }
serde = { workspace = true }
@@ -84,5 +85,6 @@ ctor = { workspace = true }
iceberg-catalog-memory = { workspace = true }
iceberg_test_utils = { path = "../test_utils", features = ["tests"] }
pretty_assertions = { workspace = true }
+rand = { workspace = true }
tempfile = { workspace = true }
tera = { workspace = true }
diff --git a/crates/iceberg/src/arrow/reader.rs
b/crates/iceberg/src/arrow/reader.rs
index ebef735..b058c8d 100644
--- a/crates/iceberg/src/arrow/reader.rs
+++ b/crates/iceberg/src/arrow/reader.rs
@@ -23,7 +23,7 @@ use std::str::FromStr;
use std::sync::Arc;
use arrow_arith::boolean::{and, is_not_null, is_null, not, or};
-use arrow_array::{ArrayRef, BooleanArray, RecordBatch};
+use arrow_array::{Array, ArrayRef, BooleanArray, RecordBatch};
use arrow_ord::cmp::{eq, gt, gt_eq, lt, lt_eq, neq};
use arrow_schema::{ArrowError, DataType, SchemaRef as ArrowSchemaRef};
use arrow_string::like::starts_with;
@@ -32,7 +32,7 @@ use fnv::FnvHashSet;
use futures::channel::mpsc::{channel, Sender};
use futures::future::BoxFuture;
use futures::{try_join, SinkExt, StreamExt, TryFutureExt, TryStreamExt};
-use parquet::arrow::arrow_reader::{ArrowPredicateFn, RowFilter};
+use parquet::arrow::arrow_reader::{ArrowPredicateFn, ArrowReaderOptions,
RowFilter};
use parquet::arrow::async_reader::{AsyncFileReader, MetadataLoader};
use parquet::arrow::{ParquetRecordBatchStreamBuilder, ProjectionMask,
PARQUET_FIELD_ID_META_KEY};
use parquet::file::metadata::ParquetMetaData;
@@ -41,6 +41,7 @@ use parquet::schema::types::{SchemaDescriptor, Type as
ParquetType};
use crate::arrow::{arrow_schema_to_schema, get_arrow_datum};
use crate::error::Result;
use crate::expr::visitors::bound_predicate_visitor::{visit,
BoundPredicateVisitor};
+use
crate::expr::visitors::row_group_metrics_evaluator::RowGroupMetricsEvaluator;
use crate::expr::{BoundPredicate, BoundReference};
use crate::io::{FileIO, FileMetadata, FileRead};
use crate::runtime::spawn;
@@ -54,6 +55,7 @@ pub struct ArrowReaderBuilder {
batch_size: Option<usize>,
file_io: FileIO,
concurrency_limit_data_files: usize,
+ row_group_filtering_enabled: bool,
}
impl ArrowReaderBuilder {
@@ -65,13 +67,13 @@ impl ArrowReaderBuilder {
batch_size: None,
file_io,
concurrency_limit_data_files: num_cpus,
+ row_group_filtering_enabled: true,
}
}
/// Sets the max number of in flight data files that are being fetched
pub fn with_data_file_concurrency_limit(mut self, val: usize) -> Self {
self.concurrency_limit_data_files = val;
-
self
}
@@ -82,12 +84,19 @@ impl ArrowReaderBuilder {
self
}
+ /// Determines whether to enable row group filtering.
+ pub fn with_row_group_filtering_enabled(mut self,
row_group_filtering_enabled: bool) -> Self {
+ self.row_group_filtering_enabled = row_group_filtering_enabled;
+ self
+ }
+
/// Build the ArrowReader.
pub fn build(self) -> ArrowReader {
ArrowReader {
batch_size: self.batch_size,
file_io: self.file_io,
concurrency_limit_data_files: self.concurrency_limit_data_files,
+ row_group_filtering_enabled: self.row_group_filtering_enabled,
}
}
}
@@ -100,6 +109,8 @@ pub struct ArrowReader {
/// the maximum number of data files that can be fetched at the same time
concurrency_limit_data_files: usize,
+
+ row_group_filtering_enabled: bool,
}
impl ArrowReader {
@@ -109,6 +120,7 @@ impl ArrowReader {
let file_io = self.file_io.clone();
let batch_size = self.batch_size;
let concurrency_limit_data_files = self.concurrency_limit_data_files;
+ let row_group_filtering_enabled = self.row_group_filtering_enabled;
let (tx, rx) = channel(concurrency_limit_data_files);
let mut channel_for_error = tx.clone();
@@ -124,8 +136,14 @@ impl ArrowReader {
let file_path =
task.data_file_path().to_string();
spawn(async move {
- Self::process_file_scan_task(task,
batch_size, file_io, tx)
- .await
+ Self::process_file_scan_task(
+ task,
+ batch_size,
+ file_io,
+ tx,
+ row_group_filtering_enabled,
+ )
+ .await
})
.await
.map_err(|e| e.with_context("file_path",
file_path))
@@ -149,55 +167,95 @@ impl ArrowReader {
batch_size: Option<usize>,
file_io: FileIO,
mut tx: Sender<Result<RecordBatch>>,
+ row_group_filtering_enabled: bool,
) -> Result<()> {
- // Collect Parquet column indices from field ids
- let mut collector = CollectFieldIdVisitor {
- field_ids: HashSet::default(),
- };
-
- if let Some(predicates) = task.predicate() {
- visit(&mut collector, predicates)?;
- }
-
+ // Get the metadata for the Parquet file we need to read and build
+ // a reader for the data within
let parquet_file = file_io.new_input(task.data_file_path())?;
-
let (parquet_metadata, parquet_reader) =
try_join!(parquet_file.metadata(), parquet_file.reader())?;
- let arrow_file_reader = ArrowFileReader::new(parquet_metadata,
parquet_reader);
+ let parquet_file_reader = ArrowFileReader::new(parquet_metadata,
parquet_reader);
- let mut batch_stream_builder =
- ParquetRecordBatchStreamBuilder::new(arrow_file_reader).await?;
+ // Start creating the record batch stream, which wraps the parquet
file reader
+ let mut record_batch_stream_builder =
ParquetRecordBatchStreamBuilder::new_with_options(
+ parquet_file_reader,
+ // Page index will be required in upcoming row selection PR
+ ArrowReaderOptions::new().with_page_index(false),
+ )
+ .await?;
- let parquet_schema = batch_stream_builder.parquet_schema();
- let arrow_schema = batch_stream_builder.schema();
+ // Create a projection mask for the batch stream to select which
columns in the
+ // Parquet file that we want in the response
let projection_mask = Self::get_arrow_projection_mask(
task.project_field_ids(),
task.schema(),
- parquet_schema,
- arrow_schema,
+ record_batch_stream_builder.parquet_schema(),
+ record_batch_stream_builder.schema(),
)?;
- batch_stream_builder =
batch_stream_builder.with_projection(projection_mask);
-
- let parquet_schema = batch_stream_builder.parquet_schema();
- let row_filter = Self::get_row_filter(task.predicate(),
parquet_schema, &collector)?;
-
- if let Some(row_filter) = row_filter {
- batch_stream_builder =
batch_stream_builder.with_row_filter(row_filter);
- }
+ record_batch_stream_builder =
record_batch_stream_builder.with_projection(projection_mask);
if let Some(batch_size) = batch_size {
- batch_stream_builder =
batch_stream_builder.with_batch_size(batch_size);
+ record_batch_stream_builder =
record_batch_stream_builder.with_batch_size(batch_size);
}
- let mut batch_stream = batch_stream_builder.build()?;
+ if let Some(predicate) = task.predicate() {
+ let (iceberg_field_ids, field_id_map) =
Self::build_field_id_set_and_map(
+ record_batch_stream_builder.parquet_schema(),
+ predicate,
+ )?;
+
+ let row_filter = Self::get_row_filter(
+ predicate,
+ record_batch_stream_builder.parquet_schema(),
+ &iceberg_field_ids,
+ &field_id_map,
+ )?;
+ record_batch_stream_builder =
record_batch_stream_builder.with_row_filter(row_filter);
+
+ let mut selected_row_groups = None;
+ if row_group_filtering_enabled {
+ let result = Self::get_selected_row_group_indices(
+ predicate,
+ record_batch_stream_builder.metadata(),
+ &field_id_map,
+ task.schema(),
+ )?;
+
+ selected_row_groups = Some(result);
+ }
+
+ if let Some(selected_row_groups) = selected_row_groups {
+ record_batch_stream_builder =
+
record_batch_stream_builder.with_row_groups(selected_row_groups);
+ }
+ }
- while let Some(batch) = batch_stream.try_next().await? {
+ // Build the batch stream and send all the RecordBatches that it
generates
+ // to the requester.
+ let mut record_batch_stream = record_batch_stream_builder.build()?;
+ while let Some(batch) = record_batch_stream.try_next().await? {
tx.send(Ok(batch)).await?
}
Ok(())
}
+ fn build_field_id_set_and_map(
+ parquet_schema: &SchemaDescriptor,
+ predicate: &BoundPredicate,
+ ) -> Result<(HashSet<i32>, HashMap<i32, usize>)> {
+ // Collects all Iceberg field IDs referenced in the filter predicate
+ let mut collector = CollectFieldIdVisitor {
+ field_ids: HashSet::default(),
+ };
+ visit(&mut collector, predicate)?;
+
+ let iceberg_field_ids = collector.field_ids();
+ let field_id_map = build_field_id_map(parquet_schema)?;
+
+ Ok((iceberg_field_ids, field_id_map))
+ }
+
fn get_arrow_projection_mask(
field_ids: &[i32],
iceberg_schema_of_task: &Schema,
@@ -269,43 +327,59 @@ impl ArrowReader {
}
fn get_row_filter(
- predicates: Option<&BoundPredicate>,
+ predicates: &BoundPredicate,
parquet_schema: &SchemaDescriptor,
- collector: &CollectFieldIdVisitor,
- ) -> Result<Option<RowFilter>> {
- if let Some(predicates) = predicates {
- let field_id_map = build_field_id_map(parquet_schema)?;
-
- // Collect Parquet column indices from field ids.
- // If the field id is not found in Parquet schema, it will be
ignored due to schema evolution.
- let mut column_indices = collector
- .field_ids
- .iter()
- .filter_map(|field_id| field_id_map.get(field_id).cloned())
- .collect::<Vec<_>>();
-
- column_indices.sort();
-
- // The converter that converts `BoundPredicates` to
`ArrowPredicates`
- let mut converter = PredicateConverter {
- parquet_schema,
- column_map: &field_id_map,
- column_indices: &column_indices,
- };
-
- // After collecting required leaf column indices used in the
predicate,
- // creates the projection mask for the Arrow predicates.
- let projection_mask = ProjectionMask::leaves(parquet_schema,
column_indices.clone());
- let predicate_func = visit(&mut converter, predicates)?;
- let arrow_predicate = ArrowPredicateFn::new(projection_mask,
predicate_func);
- Ok(Some(RowFilter::new(vec![Box::new(arrow_predicate)])))
- } else {
- Ok(None)
+ iceberg_field_ids: &HashSet<i32>,
+ field_id_map: &HashMap<i32, usize>,
+ ) -> Result<RowFilter> {
+ // Collect Parquet column indices from field ids.
+ // If the field id is not found in Parquet schema, it will be ignored
due to schema evolution.
+ let mut column_indices = iceberg_field_ids
+ .iter()
+ .filter_map(|field_id| field_id_map.get(field_id).cloned())
+ .collect::<Vec<_>>();
+ column_indices.sort();
+
+ // The converter that converts `BoundPredicates` to `ArrowPredicates`
+ let mut converter = PredicateConverter {
+ parquet_schema,
+ column_map: field_id_map,
+ column_indices: &column_indices,
+ };
+
+ // After collecting required leaf column indices used in the predicate,
+ // creates the projection mask for the Arrow predicates.
+ let projection_mask = ProjectionMask::leaves(parquet_schema,
column_indices.clone());
+ let predicate_func = visit(&mut converter, predicates)?;
+ let arrow_predicate = ArrowPredicateFn::new(projection_mask,
predicate_func);
+ Ok(RowFilter::new(vec![Box::new(arrow_predicate)]))
+ }
+
+ fn get_selected_row_group_indices(
+ predicate: &BoundPredicate,
+ parquet_metadata: &Arc<ParquetMetaData>,
+ field_id_map: &HashMap<i32, usize>,
+ snapshot_schema: &Schema,
+ ) -> Result<Vec<usize>> {
+ let row_groups_metadata = parquet_metadata.row_groups();
+ let mut results = Vec::with_capacity(row_groups_metadata.len());
+
+ for (idx, row_group_metadata) in
row_groups_metadata.iter().enumerate() {
+ if RowGroupMetricsEvaluator::eval(
+ predicate,
+ row_group_metadata,
+ field_id_map,
+ snapshot_schema,
+ )? {
+ results.push(idx);
+ }
}
+
+ Ok(results)
}
}
-/// Build the map of field id to Parquet column index in the schema.
+/// Build the map of parquet field id to Parquet column index in the schema.
fn build_field_id_map(parquet_schema: &SchemaDescriptor) ->
Result<HashMap<i32, usize>> {
let mut column_map = HashMap::new();
for (idx, field) in parquet_schema.columns().iter().enumerate() {
@@ -345,6 +419,12 @@ struct CollectFieldIdVisitor {
field_ids: HashSet<i32>,
}
+impl CollectFieldIdVisitor {
+ fn field_ids(self) -> HashSet<i32> {
+ self.field_ids
+ }
+}
+
impl BoundPredicateVisitor for CollectFieldIdVisitor {
type T = ();
diff --git a/crates/iceberg/src/arrow/schema.rs
b/crates/iceberg/src/arrow/schema.rs
index a412437..2ff43e0 100644
--- a/crates/iceberg/src/arrow/schema.rs
+++ b/crates/iceberg/src/arrow/schema.rs
@@ -30,7 +30,9 @@ use arrow_array::{
use arrow_schema::{DataType, Field, Fields, Schema as ArrowSchema, TimeUnit};
use bitvec::macros::internal::funty::Fundamental;
use parquet::arrow::PARQUET_FIELD_ID_META_KEY;
+use parquet::file::statistics::Statistics;
use rust_decimal::prelude::ToPrimitive;
+use uuid::Uuid;
use crate::error::Result;
use crate::spec::{
@@ -652,6 +654,107 @@ pub(crate) fn get_arrow_datum(datum: &Datum) ->
Result<Box<dyn ArrowDatum + Send
}
}
+macro_rules! get_parquet_stat_as_datum {
+ ($limit_type:ident) => {
+ paste::paste! {
+ /// Gets the $limit_type value from a parquet Statistics struct, as a
Datum
+ pub(crate) fn [<get_parquet_stat_ $limit_type _as_datum>](
+ primitive_type: &PrimitiveType, stats: &Statistics
+ ) -> Result<Option<Datum>> {
+ Ok(Some(match (primitive_type, stats) {
+ (PrimitiveType::Boolean, Statistics::Boolean(stats)) =>
Datum::bool(*stats.$limit_type()),
+ (PrimitiveType::Int, Statistics::Int32(stats)) =>
Datum::int(*stats.$limit_type()),
+ (PrimitiveType::Date, Statistics::Int32(stats)) =>
Datum::date(*stats.$limit_type()),
+ (PrimitiveType::Long, Statistics::Int64(stats)) =>
Datum::long(*stats.$limit_type()),
+ (PrimitiveType::Time, Statistics::Int64(stats)) =>
Datum::time_micros(*stats.$limit_type())?,
+ (PrimitiveType::Timestamp, Statistics::Int64(stats)) => {
+ Datum::timestamp_micros(*stats.$limit_type())
+ }
+ (PrimitiveType::Timestamptz, Statistics::Int64(stats)) => {
+ Datum::timestamptz_micros(*stats.$limit_type())
+ }
+ (PrimitiveType::TimestampNs, Statistics::Int64(stats)) => {
+ Datum::timestamp_nanos(*stats.$limit_type())
+ }
+ (PrimitiveType::TimestamptzNs, Statistics::Int64(stats)) => {
+ Datum::timestamptz_nanos(*stats.$limit_type())
+ }
+ (PrimitiveType::Float, Statistics::Float(stats)) =>
Datum::float(*stats.$limit_type()),
+ (PrimitiveType::Double, Statistics::Double(stats)) =>
Datum::double(*stats.$limit_type()),
+ (PrimitiveType::String, Statistics::ByteArray(stats)) => {
+ Datum::string(stats.$limit_type().as_utf8()?)
+ }
+ (PrimitiveType::Decimal {
+ precision: _,
+ scale: _,
+ }, Statistics::ByteArray(stats)) => {
+ Datum::new(
+ primitive_type.clone(),
+
PrimitiveLiteral::Int128(i128::from_le_bytes(stats.[<$limit_type
_bytes>]().try_into()?)),
+ )
+ }
+ (
+ PrimitiveType::Decimal {
+ precision: _,
+ scale: _,
+ },
+ Statistics::Int32(stats)) => {
+ Datum::new(
+ primitive_type.clone(),
+
PrimitiveLiteral::Int128(i128::from(*stats.$limit_type())),
+ )
+ }
+
+ (
+ PrimitiveType::Decimal {
+ precision: _,
+ scale: _,
+ },
+ Statistics::Int64(stats),
+ ) => {
+ Datum::new(
+ primitive_type.clone(),
+
PrimitiveLiteral::Int128(i128::from(*stats.$limit_type())),
+ )
+ }
+ (PrimitiveType::Uuid, Statistics::FixedLenByteArray(stats)) =>
{
+ let raw = stats.[<$limit_type _bytes>]();
+ if raw.len() != 16 {
+ return Err(Error::new(
+ ErrorKind::Unexpected,
+ "Invalid length of uuid bytes.",
+ ));
+ }
+ Datum::uuid(Uuid::from_bytes(
+ raw[..16].try_into().unwrap(),
+ ))
+ }
+ (PrimitiveType::Fixed(len),
Statistics::FixedLenByteArray(stat)) => {
+ let raw = stat.[<$limit_type _bytes>]();
+ if raw.len() != *len as usize {
+ return Err(Error::new(
+ ErrorKind::Unexpected,
+ "Invalid length of fixed bytes.",
+ ));
+ }
+ Datum::fixed(raw.to_vec())
+ }
+ (PrimitiveType::Binary, Statistics::ByteArray(stat)) => {
+ Datum::binary(stat.[<$limit_type _bytes>]().to_vec())
+ }
+ _ => {
+ return Ok(None);
+ }
+ }))
+ }
+ }
+ }
+}
+
+get_parquet_stat_as_datum!(min);
+
+get_parquet_stat_as_datum!(max);
+
impl TryFrom<&ArrowSchema> for crate::spec::Schema {
type Error = Error;
diff --git a/crates/iceberg/src/expr/visitors/mod.rs
b/crates/iceberg/src/expr/visitors/mod.rs
index d686b11..06bfd8c 100644
--- a/crates/iceberg/src/expr/visitors/mod.rs
+++ b/crates/iceberg/src/expr/visitors/mod.rs
@@ -20,3 +20,4 @@ pub(crate) mod expression_evaluator;
pub(crate) mod inclusive_metrics_evaluator;
pub(crate) mod inclusive_projection;
pub(crate) mod manifest_evaluator;
+pub(crate) mod row_group_metrics_evaluator;
diff --git a/crates/iceberg/src/expr/visitors/row_group_metrics_evaluator.rs
b/crates/iceberg/src/expr/visitors/row_group_metrics_evaluator.rs
new file mode 100644
index 0000000..4bf53d6
--- /dev/null
+++ b/crates/iceberg/src/expr/visitors/row_group_metrics_evaluator.rs
@@ -0,0 +1,1872 @@
+// 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.
+
+//! Evaluates Parquet Row Group metrics
+
+use std::collections::HashMap;
+
+use fnv::FnvHashSet;
+use parquet::file::metadata::RowGroupMetaData;
+use parquet::file::statistics::Statistics;
+
+use crate::arrow::{get_parquet_stat_max_as_datum,
get_parquet_stat_min_as_datum};
+use crate::expr::visitors::bound_predicate_visitor::{visit,
BoundPredicateVisitor};
+use crate::expr::{BoundPredicate, BoundReference};
+use crate::spec::{Datum, PrimitiveLiteral, PrimitiveType, Schema};
+use crate::{Error, ErrorKind, Result};
+
+pub(crate) struct RowGroupMetricsEvaluator<'a> {
+ row_group_metadata: &'a RowGroupMetaData,
+ iceberg_field_id_to_parquet_column_index: &'a HashMap<i32, usize>,
+ snapshot_schema: &'a Schema,
+}
+
+const IN_PREDICATE_LIMIT: usize = 200;
+const ROW_GROUP_MIGHT_MATCH: Result<bool> = Ok(true);
+const ROW_GROUP_CANT_MATCH: Result<bool> = Ok(false);
+
+impl<'a> RowGroupMetricsEvaluator<'a> {
+ fn new(
+ row_group_metadata: &'a RowGroupMetaData,
+ field_id_map: &'a HashMap<i32, usize>,
+ snapshot_schema: &'a Schema,
+ ) -> Self {
+ Self {
+ row_group_metadata,
+ iceberg_field_id_to_parquet_column_index: field_id_map,
+ snapshot_schema,
+ }
+ }
+
+ /// Evaluate this `RowGroupMetricsEvaluator`'s filter predicate against the
+ /// provided [`RowGroupMetaData`]'. Used by [`ArrowReader`] to
+ /// see if a Parquet file RowGroup could possibly contain data that matches
+ /// the scan's filter.
+ pub(crate) fn eval(
+ filter: &'a BoundPredicate,
+ row_group_metadata: &'a RowGroupMetaData,
+ field_id_map: &'a HashMap<i32, usize>,
+ snapshot_schema: &'a Schema,
+ ) -> Result<bool> {
+ if row_group_metadata.num_rows() == 0 {
+ return ROW_GROUP_CANT_MATCH;
+ }
+
+ let mut evaluator = Self::new(row_group_metadata, field_id_map,
snapshot_schema);
+
+ visit(&mut evaluator, filter)
+ }
+
+ fn stats_for_field_id(&self, field_id: i32) -> Option<&Statistics> {
+ let parquet_column_index = *self
+ .iceberg_field_id_to_parquet_column_index
+ .get(&field_id)?;
+ self.row_group_metadata
+ .column(parquet_column_index)
+ .statistics()
+ }
+
+ fn null_count(&self, field_id: i32) -> Option<u64> {
+ self.stats_for_field_id(field_id)
+ .map(|stats| stats.null_count())
+ }
+
+ fn value_count(&self) -> u64 {
+ self.row_group_metadata.num_rows() as u64
+ }
+
+ fn contains_nulls_only(&self, field_id: i32) -> bool {
+ let null_count = self.null_count(field_id);
+ let value_count = self.value_count();
+
+ null_count == Some(value_count)
+ }
+
+ fn may_contain_null(&self, field_id: i32) -> bool {
+ if let Some(null_count) = self.null_count(field_id) {
+ null_count > 0
+ } else {
+ true
+ }
+ }
+
+ fn stats_and_type_for_field_id(
+ &self,
+ field_id: i32,
+ ) -> Result<Option<(&Statistics, PrimitiveType)>> {
+ let Some(stats) = self.stats_for_field_id(field_id) else {
+ // No statistics for column
+ return Ok(None);
+ };
+
+ let Some(field) = self.snapshot_schema.field_by_id(field_id) else {
+ return Err(Error::new(
+ ErrorKind::Unexpected,
+ format!(
+ "Could not find a field with id '{}' in the snapshot
schema",
+ &field_id
+ ),
+ ));
+ };
+
+ let Some(primitive_type) = field.field_type.as_primitive_type() else {
+ return Err(Error::new(
+ ErrorKind::Unexpected,
+ format!(
+ "Could not determine the PrimitiveType for field id '{}'",
+ &field_id
+ ),
+ ));
+ };
+
+ Ok(Some((stats, primitive_type.clone())))
+ }
+
+ fn min_value(&self, field_id: i32) -> Result<Option<Datum>> {
+ let Some((stats, primitive_type)) =
self.stats_and_type_for_field_id(field_id)? else {
+ return Ok(None);
+ };
+
+ if !stats.has_min_max_set() {
+ return Ok(None);
+ }
+
+ get_parquet_stat_min_as_datum(&primitive_type, stats)
+ }
+
+ fn max_value(&self, field_id: i32) -> Result<Option<Datum>> {
+ let Some((stats, primitive_type)) =
self.stats_and_type_for_field_id(field_id)? else {
+ return Ok(None);
+ };
+
+ if !stats.has_min_max_set() {
+ return Ok(None);
+ }
+
+ get_parquet_stat_max_as_datum(&primitive_type, stats)
+ }
+
+ fn visit_inequality(
+ &mut self,
+ reference: &BoundReference,
+ datum: &Datum,
+ cmp_fn: fn(&Datum, &Datum) -> bool,
+ use_lower_bound: bool,
+ ) -> Result<bool> {
+ let field_id = reference.field().id;
+
+ if self.contains_nulls_only(field_id) {
+ return ROW_GROUP_CANT_MATCH;
+ }
+
+ if datum.is_nan() {
+ // NaN indicates unreliable bounds.
+ // See the InclusiveMetricsEvaluator docs for more.
+ return ROW_GROUP_MIGHT_MATCH;
+ }
+
+ let bound = if use_lower_bound {
+ self.min_value(field_id)
+ } else {
+ self.max_value(field_id)
+ }?;
+
+ if let Some(bound) = bound {
+ if cmp_fn(&bound, datum) {
+ return ROW_GROUP_MIGHT_MATCH;
+ }
+
+ return ROW_GROUP_CANT_MATCH;
+ }
+
+ ROW_GROUP_MIGHT_MATCH
+ }
+}
+
+impl BoundPredicateVisitor for RowGroupMetricsEvaluator<'_> {
+ type T = bool;
+
+ fn always_true(&mut self) -> Result<bool> {
+ ROW_GROUP_MIGHT_MATCH
+ }
+
+ fn always_false(&mut self) -> Result<bool> {
+ ROW_GROUP_CANT_MATCH
+ }
+
+ fn and(&mut self, lhs: bool, rhs: bool) -> Result<bool> {
+ Ok(lhs && rhs)
+ }
+
+ fn or(&mut self, lhs: bool, rhs: bool) -> Result<bool> {
+ Ok(lhs || rhs)
+ }
+
+ fn not(&mut self, inner: bool) -> Result<bool> {
+ Ok(!inner)
+ }
+
+ fn is_null(&mut self, reference: &BoundReference, _predicate:
&BoundPredicate) -> Result<bool> {
+ let field_id = reference.field().id;
+
+ match self.null_count(field_id) {
+ Some(0) => ROW_GROUP_CANT_MATCH,
+ Some(_) => ROW_GROUP_MIGHT_MATCH,
+ None => ROW_GROUP_MIGHT_MATCH,
+ }
+ }
+
+ fn not_null(
+ &mut self,
+ reference: &BoundReference,
+ _predicate: &BoundPredicate,
+ ) -> Result<bool> {
+ let field_id = reference.field().id;
+
+ if self.contains_nulls_only(field_id) {
+ return ROW_GROUP_CANT_MATCH;
+ }
+
+ ROW_GROUP_MIGHT_MATCH
+ }
+
+ fn is_nan(&mut self, _reference: &BoundReference, _predicate:
&BoundPredicate) -> Result<bool> {
+ // NaN counts not in ColumnChunkMetadata Statistics
+ ROW_GROUP_MIGHT_MATCH
+ }
+
+ fn not_nan(
+ &mut self,
+ _reference: &BoundReference,
+ _predicate: &BoundPredicate,
+ ) -> Result<bool> {
+ // NaN counts not in ColumnChunkMetadata Statistics
+ ROW_GROUP_MIGHT_MATCH
+ }
+
+ fn less_than(
+ &mut self,
+ reference: &BoundReference,
+ datum: &Datum,
+ _predicate: &BoundPredicate,
+ ) -> Result<bool> {
+ self.visit_inequality(reference, datum, PartialOrd::lt, true)
+ }
+
+ fn less_than_or_eq(
+ &mut self,
+ reference: &BoundReference,
+ datum: &Datum,
+ _predicate: &BoundPredicate,
+ ) -> Result<bool> {
+ self.visit_inequality(reference, datum, PartialOrd::le, true)
+ }
+
+ fn greater_than(
+ &mut self,
+ reference: &BoundReference,
+ datum: &Datum,
+ _predicate: &BoundPredicate,
+ ) -> Result<bool> {
+ self.visit_inequality(reference, datum, PartialOrd::gt, false)
+ }
+
+ fn greater_than_or_eq(
+ &mut self,
+ reference: &BoundReference,
+ datum: &Datum,
+ _predicate: &BoundPredicate,
+ ) -> Result<bool> {
+ self.visit_inequality(reference, datum, PartialOrd::ge, false)
+ }
+
+ fn eq(
+ &mut self,
+ reference: &BoundReference,
+ datum: &Datum,
+ _predicate: &BoundPredicate,
+ ) -> Result<bool> {
+ let field_id = reference.field().id;
+
+ if self.contains_nulls_only(field_id) {
+ return ROW_GROUP_CANT_MATCH;
+ }
+
+ if let Some(lower_bound) = self.min_value(field_id)? {
+ if lower_bound.is_nan() {
+ // NaN indicates unreliable bounds.
+ // See the InclusiveMetricsEvaluator docs for more.
+ return ROW_GROUP_MIGHT_MATCH;
+ } else if lower_bound.gt(datum) {
+ return ROW_GROUP_CANT_MATCH;
+ }
+ }
+
+ if let Some(upper_bound) = self.max_value(field_id)? {
+ if upper_bound.is_nan() {
+ // NaN indicates unreliable bounds.
+ // See the InclusiveMetricsEvaluator docs for more.
+ return ROW_GROUP_MIGHT_MATCH;
+ } else if upper_bound.lt(datum) {
+ return ROW_GROUP_CANT_MATCH;
+ }
+ }
+
+ ROW_GROUP_MIGHT_MATCH
+ }
+
+ fn not_eq(
+ &mut self,
+ _reference: &BoundReference,
+ _datum: &Datum,
+ _predicate: &BoundPredicate,
+ ) -> Result<bool> {
+ // Because the bounds are not necessarily a min or max value,
+ // this cannot be answered using them. notEq(col, X) with (X, Y)
+ // doesn't guarantee that X is a value in col.
+ ROW_GROUP_MIGHT_MATCH
+ }
+
+ fn starts_with(
+ &mut self,
+ reference: &BoundReference,
+ datum: &Datum,
+ _predicate: &BoundPredicate,
+ ) -> Result<bool> {
+ let field_id = reference.field().id;
+
+ if self.contains_nulls_only(field_id) {
+ return ROW_GROUP_CANT_MATCH;
+ }
+
+ let PrimitiveLiteral::String(datum) = datum.literal() else {
+ return Err(Error::new(
+ ErrorKind::Unexpected,
+ "Cannot use StartsWith operator on non-string values",
+ ));
+ };
+
+ if let Some(lower_bound) = self.min_value(field_id)? {
+ let PrimitiveLiteral::String(lower_bound) = lower_bound.literal()
else {
+ return Err(Error::new(
+ ErrorKind::Unexpected,
+ "Cannot use StartsWith operator on non-string lower_bound
value",
+ ));
+ };
+
+ let prefix_length =
lower_bound.chars().count().min(datum.chars().count());
+
+ // truncate lower bound so that its length
+ // is not greater than the length of prefix
+ let truncated_lower_bound =
lower_bound.chars().take(prefix_length).collect::<String>();
+ if datum < &truncated_lower_bound {
+ return ROW_GROUP_CANT_MATCH;
+ }
+ }
+
+ if let Some(upper_bound) = self.max_value(field_id)? {
+ let PrimitiveLiteral::String(upper_bound) = upper_bound.literal()
else {
+ return Err(Error::new(
+ ErrorKind::Unexpected,
+ "Cannot use StartsWith operator on non-string upper_bound
value",
+ ));
+ };
+
+ let prefix_length =
upper_bound.chars().count().min(datum.chars().count());
+
+ // truncate upper bound so that its length
+ // is not greater than the length of prefix
+ let truncated_upper_bound =
upper_bound.chars().take(prefix_length).collect::<String>();
+ if datum > &truncated_upper_bound {
+ return ROW_GROUP_CANT_MATCH;
+ }
+ }
+
+ ROW_GROUP_MIGHT_MATCH
+ }
+
+ fn not_starts_with(
+ &mut self,
+ reference: &BoundReference,
+ datum: &Datum,
+ _predicate: &BoundPredicate,
+ ) -> Result<bool> {
+ let field_id = reference.field().id;
+
+ if self.may_contain_null(field_id) {
+ return ROW_GROUP_MIGHT_MATCH;
+ }
+
+ // notStartsWith will match unless all values must start with the
prefix.
+ // This happens when the lower and upper bounds both start with the
prefix.
+
+ let PrimitiveLiteral::String(prefix) = datum.literal() else {
+ return Err(Error::new(
+ ErrorKind::Unexpected,
+ "Cannot use StartsWith operator on non-string values",
+ ));
+ };
+
+ let Some(lower_bound) = self.min_value(field_id)? else {
+ return ROW_GROUP_MIGHT_MATCH;
+ };
+
+ let PrimitiveLiteral::String(lower_bound_str) = lower_bound.literal()
else {
+ return Err(Error::new(
+ ErrorKind::Unexpected,
+ "Cannot use NotStartsWith operator on non-string lower_bound
value",
+ ));
+ };
+
+ if lower_bound_str < prefix {
+ // if lower is shorter than the prefix then lower doesn't start
with the prefix
+ return ROW_GROUP_MIGHT_MATCH;
+ }
+
+ let prefix_len = prefix.chars().count();
+
+ if lower_bound_str.chars().take(prefix_len).collect::<String>() ==
*prefix {
+ // lower bound matches the prefix
+
+ let Some(upper_bound) = self.max_value(field_id)? else {
+ return ROW_GROUP_MIGHT_MATCH;
+ };
+
+ let PrimitiveLiteral::String(upper_bound) = upper_bound.literal()
else {
+ return Err(Error::new(
+ ErrorKind::Unexpected,
+ "Cannot use NotStartsWith operator on non-string
upper_bound value",
+ ));
+ };
+
+ // if upper is shorter than the prefix then upper can't start with
the prefix
+ if upper_bound.chars().count() < prefix_len {
+ return ROW_GROUP_MIGHT_MATCH;
+ }
+
+ if upper_bound.chars().take(prefix_len).collect::<String>() ==
*prefix {
+ // both bounds match the prefix, so all rows must match the
+ // prefix and therefore do not satisfy the predicate
+ return ROW_GROUP_CANT_MATCH;
+ }
+ }
+
+ ROW_GROUP_MIGHT_MATCH
+ }
+
+ fn r#in(
+ &mut self,
+ reference: &BoundReference,
+ literals: &FnvHashSet<Datum>,
+ _predicate: &BoundPredicate,
+ ) -> Result<bool> {
+ let field_id = reference.field().id;
+
+ if self.contains_nulls_only(field_id) {
+ return ROW_GROUP_CANT_MATCH;
+ }
+
+ if literals.len() > IN_PREDICATE_LIMIT {
+ // skip evaluating the predicate if the number of values is too big
+ return ROW_GROUP_MIGHT_MATCH;
+ }
+
+ if let Some(lower_bound) = self.min_value(field_id)? {
+ if lower_bound.is_nan() {
+ // NaN indicates unreliable bounds. See the
InclusiveMetricsEvaluator docs for more.
+ return ROW_GROUP_MIGHT_MATCH;
+ }
+
+ if !literals.iter().any(|datum| datum.ge(&lower_bound)) {
+ // if all values are less than lower bound, rows cannot match.
+ return ROW_GROUP_CANT_MATCH;
+ }
+ }
+
+ if let Some(upper_bound) = self.max_value(field_id)? {
+ if upper_bound.is_nan() {
+ // NaN indicates unreliable bounds. See the
InclusiveMetricsEvaluator docs for more.
+ return ROW_GROUP_MIGHT_MATCH;
+ }
+
+ if !literals.iter().any(|datum| datum.le(&upper_bound)) {
+ // if all values are greater than upper bound, rows cannot
match.
+ return ROW_GROUP_CANT_MATCH;
+ }
+ }
+
+ ROW_GROUP_MIGHT_MATCH
+ }
+
+ fn not_in(
+ &mut self,
+ _reference: &BoundReference,
+ _literals: &FnvHashSet<Datum>,
+ _predicate: &BoundPredicate,
+ ) -> Result<bool> {
+ // Because the bounds are not necessarily a min or max value,
+ // this cannot be answered using them. notIn(col, {X, ...})
+ // with (X, Y) doesn't guarantee that X is a value in col.
+ ROW_GROUP_MIGHT_MATCH
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::HashMap;
+ use std::sync::Arc;
+
+ use parquet::basic::{LogicalType as ParquetLogicalType, Type as
ParquetPhysicalType};
+ use parquet::data_type::ByteArray;
+ use parquet::file::metadata::{ColumnChunkMetaData, RowGroupMetaData};
+ use parquet::file::statistics::Statistics;
+ use parquet::schema::types::{
+ ColumnDescriptor, ColumnPath, SchemaDescriptor, Type as
parquetSchemaType,
+ };
+ use rand::{thread_rng, Rng};
+
+ use super::RowGroupMetricsEvaluator;
+ use crate::expr::{Bind, Reference};
+ use crate::spec::{Datum, NestedField, PrimitiveType, Schema, Type};
+ use crate::Result;
+
+ #[test]
+ fn eval_matches_no_rows_for_empty_row_group() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(0, 0, None, 0,
None)?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .greater_than(Datum::float(1.0))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(!result);
+
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_row_group_no_bounds_present() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(1, 1, None, 1,
None)?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .greater_than(Datum::float(1.0))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+
+ Ok(())
+ }
+
+ #[test]
+ fn eval_false_for_meta_all_null_filter_not_null() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(None, None, None, 1, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .is_not_null()
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(!result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_meta_all_null_filter_is_null() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(None, None, None, 1, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .is_null()
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_meta_none_null_filter_not_null() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(None, None, None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .is_not_null()
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_false_for_meta_none_null_filter_is_null() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(None, None, None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .is_null()
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(!result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_false_for_meta_all_nulls_filter_inequality() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(None, None, None, 1, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .greater_than(Datum::float(1.0))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(!result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_datum_nan_filter_inequality() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(Some(0.0), Some(2.0), None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .greater_than(Datum::float(f32::NAN))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_meta_missing_bound_valid_other_bound_filter_inequality()
-> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(None, Some(2.0), None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .greater_than(Datum::float(1.0))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_false_for_meta_failing_bound_filter_inequality() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(Some(0.0), Some(0.9), None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .greater_than(Datum::float(1.0))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(!result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_meta_passing_bound_filter_inequality() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(Some(0.0), Some(2.0), None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .greater_than(Datum::float(1.0))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_false_for_meta_all_nulls_filter_eq() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(None, None, None, 1, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .equal_to(Datum::float(1.0))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(!result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_meta_lower_nan_filter_eq() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(Some(f32::NAN), Some(2.0), None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .equal_to(Datum::float(1.0))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_false_for_meta_lower_gt_than_datum_filter_eq() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(Some(1.5), Some(2.0), None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .equal_to(Datum::float(1.0))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(!result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_meta_upper_nan_filter_eq() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(Some(0.0), Some(f32::NAN), None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .equal_to(Datum::float(1.0))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_false_for_meta_upper_lt_than_datum_filter_eq() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(Some(0.0), Some(0.5), None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .equal_to(Datum::float(1.0))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(!result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_meta_good_bounds_than_datum_filter_eq() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(Some(0.0), Some(2.0), None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .equal_to(Datum::float(1.0))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_meta_bounds_eq_datum_filter_neq() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(Some(1.0), Some(1.0), None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .not_equal_to(Datum::float(1.0))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_false_for_meta_all_nulls_filter_starts_with() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ Some(Statistics::byte_array(None, None, None, 1, false)),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .starts_with(Datum::string("iceberg"))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(!result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_error_for_starts_with_non_string_filter_datum() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ Some(Statistics::byte_array(None, None, None, 0, false)),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .starts_with(Datum::float(1.0))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ );
+
+ assert!(result.is_err());
+ Ok(())
+ }
+
+ #[test]
+ fn eval_error_for_starts_with_non_utf8_lower_bound() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ // min val of 0xff is not valid utf-8 string. Max val of 0x20 is
valid utf8
+ Some(Statistics::byte_array(
+ Some(ByteArray::from(vec![255u8])),
+ Some(ByteArray::from(vec![32u8])),
+ None,
+ 0,
+ false,
+ )),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .starts_with(Datum::string("iceberg"))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ );
+
+ assert!(result.is_err());
+ Ok(())
+ }
+
+ #[test]
+ fn eval_error_for_starts_with_non_utf8_upper_bound() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ // Max val of 0xFF is not valid utf8
+ Some(Statistics::byte_array(
+ Some(ByteArray::from("ice".as_bytes())),
+ Some(ByteArray::from(vec![255u8])),
+ None,
+ 0,
+ false,
+ )),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .starts_with(Datum::string("iceberg"))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ );
+
+ assert!(result.is_err());
+ Ok(())
+ }
+
+ #[test]
+ fn eval_false_for_starts_with_meta_all_nulls() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ // Max val of 0xFF is not valid utf8
+ Some(Statistics::byte_array(None, None, None, 1, false)),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .starts_with(Datum::string("iceberg"))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(!result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_false_for_starts_with_datum_below_min_bound() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ // Max val of 0xFF is not valid utf8
+ Some(Statistics::byte_array(
+ Some(ByteArray::from("id".as_bytes())),
+ Some(ByteArray::from("ie".as_bytes())),
+ None,
+ 0,
+ false,
+ )),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .starts_with(Datum::string("iceberg"))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(!result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_false_for_starts_with_datum_above_max_bound() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ // Max val of 0xFF is not valid utf8
+ Some(Statistics::byte_array(
+ Some(ByteArray::from("h".as_bytes())),
+ Some(ByteArray::from("ib".as_bytes())),
+ None,
+ 0,
+ false,
+ )),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .starts_with(Datum::string("iceberg"))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(!result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_starts_with_datum_between_bounds() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ // Max val of 0xFF is not valid utf8
+ Some(Statistics::byte_array(
+ Some(ByteArray::from("h".as_bytes())),
+ Some(ByteArray::from("j".as_bytes())),
+ None,
+ 0,
+ false,
+ )),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .starts_with(Datum::string("iceberg"))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_meta_all_nulls_filter_not_starts_with() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ Some(Statistics::byte_array(None, None, None, 1, false)),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .not_starts_with(Datum::string("iceberg"))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_error_for_not_starts_with_non_utf8_lower_bound() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ // min val of 0xff is not valid utf-8 string. Max val of 0x20 is
valid utf8
+ Some(Statistics::byte_array(
+ Some(ByteArray::from(vec![255u8])),
+ Some(ByteArray::from(vec![32u8])),
+ None,
+ 0,
+ false,
+ )),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .not_starts_with(Datum::string("iceberg"))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ );
+
+ assert!(result.is_err());
+ Ok(())
+ }
+
+ #[test]
+ fn eval_error_for_not_starts_with_non_utf8_upper_bound() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ // Max val of 0xFF is not valid utf8
+ Some(Statistics::byte_array(
+ Some(ByteArray::from("iceberg".as_bytes())),
+ Some(ByteArray::from(vec![255u8])),
+ None,
+ 0,
+ false,
+ )),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .not_starts_with(Datum::string("iceberg"))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ );
+
+ assert!(result.is_err());
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_not_starts_with_no_min_bound() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ // Max val of 0xFF is not valid utf8
+ Some(Statistics::byte_array(
+ None,
+ Some(ByteArray::from("iceberg".as_bytes())),
+ None,
+ 0,
+ false,
+ )),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .not_starts_with(Datum::string("iceberg"))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_not_starts_with_datum_longer_min_max_bound() ->
Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ // Max val of 0xFF is not valid utf8
+ Some(Statistics::byte_array(
+ Some(ByteArray::from("ice".as_bytes())),
+ Some(ByteArray::from("iceberg".as_bytes())),
+ None,
+ 0,
+ false,
+ )),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .not_starts_with(Datum::string("iceberg"))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_not_starts_with_datum_matches_lower_no_upper() ->
Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ // Max val of 0xFF is not valid utf8
+ Some(Statistics::byte_array(
+ Some(ByteArray::from("iceberg".as_bytes())),
+ None,
+ None,
+ 0,
+ false,
+ )),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .not_starts_with(Datum::string("iceberg"))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_not_starts_with_datum_matches_lower_upper_shorter() ->
Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ // Max val of 0xFF is not valid utf8
+ Some(Statistics::byte_array(
+ Some(ByteArray::from("iceberg".as_bytes())),
+ Some(ByteArray::from("icy".as_bytes())),
+ None,
+ 0,
+ false,
+ )),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .not_starts_with(Datum::string("iceberg"))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_false_for_not_starts_with_datum_matches_lower_and_upper() ->
Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ // Max val of 0xFF is not valid utf8
+ Some(Statistics::byte_array(
+ Some(ByteArray::from("iceberg".as_bytes())),
+ Some(ByteArray::from("iceberg".as_bytes())),
+ None,
+ 0,
+ false,
+ )),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .not_starts_with(Datum::string("iceberg"))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(!result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_false_for_meta_all_nulls_filter_is_in() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ Some(Statistics::byte_array(
+ Some(ByteArray::from("iceberg".as_bytes())),
+ Some(ByteArray::from("iceberg".as_bytes())),
+ None,
+ 1,
+ false,
+ )),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .is_in([Datum::string("ice"), Datum::string("berg")])
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(!result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_too_many_literals_filter_is_in() -> Result<()> {
+ let mut rng = thread_rng();
+
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(Some(11.0), Some(12.0), None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .is_in(std::iter::repeat_with(||
Datum::float(rng.gen_range(0.0..10.0))).take(1000))
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_missing_bounds_filter_is_in() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ Some(Statistics::byte_array(None, None, None, 0, false)),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .is_in([Datum::string("ice")])
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_lower_bound_is_nan_filter_is_in() -> Result<()> {
+ // TODO: should this be false, since the max stat
+ // is lower than the min val in the set?
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(Some(f32::NAN), Some(1.0), None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .is_in([Datum::float(2.0), Datum::float(3.0)])
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_false_for_lower_bound_greater_than_all_vals_is_in() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(Some(4.0), None, None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .is_in([Datum::float(2.0), Datum::float(3.0)])
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_nan_upper_bound_is_in() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(Some(0.0), Some(f32::NAN), None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .is_in([Datum::float(2.0), Datum::float(3.0)])
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_false_for_upper_bound_below_all_vals_is_in() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ Some(Statistics::float(Some(0.0), Some(1.0), None, 0, false)),
+ 1,
+ None,
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_float")
+ .is_in([Datum::float(2.0), Datum::float(3.0)])
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(!result);
+ Ok(())
+ }
+
+ #[test]
+ fn eval_true_for_not_in() -> Result<()> {
+ let row_group_metadata = create_row_group_metadata(
+ 1,
+ 1,
+ None,
+ 1,
+ // Max val of 0xFF is not valid utf8
+ Some(Statistics::byte_array(
+ Some(ByteArray::from("iceberg".as_bytes())),
+ Some(ByteArray::from("iceberg".as_bytes())),
+ None,
+ 0,
+ false,
+ )),
+ )?;
+
+ let (iceberg_schema_ref, field_id_map) =
build_iceberg_schema_and_field_map()?;
+
+ let filter = Reference::new("col_string")
+ .is_not_in([Datum::string("iceberg")])
+ .bind(iceberg_schema_ref.clone(), false)?;
+
+ let result = RowGroupMetricsEvaluator::eval(
+ &filter,
+ &row_group_metadata,
+ &field_id_map,
+ iceberg_schema_ref.as_ref(),
+ )?;
+
+ assert!(result);
+ Ok(())
+ }
+
+ fn build_iceberg_schema_and_field_map() -> Result<(Arc<Schema>,
HashMap<i32, usize>)> {
+ let iceberg_schema = Schema::builder()
+ .with_fields([
+ Arc::new(NestedField::new(
+ 1,
+ "col_float",
+ Type::Primitive(PrimitiveType::Float),
+ false,
+ )),
+ Arc::new(NestedField::new(
+ 2,
+ "col_string",
+ Type::Primitive(PrimitiveType::String),
+ false,
+ )),
+ ])
+ .build()?;
+ let iceberg_schema_ref = Arc::new(iceberg_schema);
+
+ let field_id_map = HashMap::from_iter([(1, 0), (2, 1)]);
+
+ Ok((iceberg_schema_ref, field_id_map))
+ }
+
+ fn build_parquet_schema_descriptor() -> Result<Arc<SchemaDescriptor>> {
+ let field_1 = Arc::new(
+ parquetSchemaType::primitive_type_builder("col_float",
ParquetPhysicalType::FLOAT)
+ .with_id(Some(1))
+ .build()?,
+ );
+
+ let field_2 = Arc::new(
+ parquetSchemaType::primitive_type_builder(
+ "col_string",
+ ParquetPhysicalType::BYTE_ARRAY,
+ )
+ .with_id(Some(2))
+ .with_logical_type(Some(ParquetLogicalType::String))
+ .build()?,
+ );
+
+ let group_type = Arc::new(
+ parquetSchemaType::group_type_builder("all")
+ .with_id(Some(1000))
+ .with_fields(vec![field_1, field_2])
+ .build()?,
+ );
+
+ let schema_descriptor = SchemaDescriptor::new(group_type);
+ let schema_descriptor_arc = Arc::new(schema_descriptor);
+ Ok(schema_descriptor_arc)
+ }
+
+ fn create_row_group_metadata(
+ num_rows: i64,
+ col_1_num_vals: i64,
+ col_1_stats: Option<Statistics>,
+ col_2_num_vals: i64,
+ col_2_stats: Option<Statistics>,
+ ) -> Result<RowGroupMetaData> {
+ let schema_descriptor_arc = build_parquet_schema_descriptor()?;
+
+ let column_1_desc_ptr = Arc::new(ColumnDescriptor::new(
+ schema_descriptor_arc.column(0).self_type_ptr(),
+ 1,
+ 1,
+ ColumnPath::new(vec!["col_float".to_string()]),
+ ));
+
+ let column_2_desc_ptr = Arc::new(ColumnDescriptor::new(
+ schema_descriptor_arc.column(1).self_type_ptr(),
+ 1,
+ 1,
+ ColumnPath::new(vec!["col_string".to_string()]),
+ ));
+
+ let mut col_1_meta =
+
ColumnChunkMetaData::builder(column_1_desc_ptr).set_num_values(col_1_num_vals);
+ if let Some(stats1) = col_1_stats {
+ col_1_meta = col_1_meta.set_statistics(stats1)
+ }
+
+ let mut col_2_meta =
+
ColumnChunkMetaData::builder(column_2_desc_ptr).set_num_values(col_2_num_vals);
+ if let Some(stats2) = col_2_stats {
+ col_2_meta = col_2_meta.set_statistics(stats2)
+ }
+
+ let row_group_metadata =
RowGroupMetaData::builder(schema_descriptor_arc)
+ .set_num_rows(num_rows)
+ .set_column_metadata(vec![
+ col_1_meta.build()?,
+ // .set_statistics(Statistics::float(None, None, None, 1,
false))
+ col_2_meta.build()?,
+ ])
+ .build();
+
+ Ok(row_group_metadata?)
+ }
+}
diff --git a/crates/iceberg/src/scan.rs b/crates/iceberg/src/scan.rs
index 04aa1f5..45d7d4f 100644
--- a/crates/iceberg/src/scan.rs
+++ b/crates/iceberg/src/scan.rs
@@ -60,6 +60,7 @@ pub struct TableScanBuilder<'a> {
concurrency_limit_data_files: usize,
concurrency_limit_manifest_entries: usize,
concurrency_limit_manifest_files: usize,
+ row_group_filtering_enabled: bool,
}
impl<'a> TableScanBuilder<'a> {
@@ -76,6 +77,7 @@ impl<'a> TableScanBuilder<'a> {
concurrency_limit_data_files: num_cpus,
concurrency_limit_manifest_entries: num_cpus,
concurrency_limit_manifest_files: num_cpus,
+ row_group_filtering_enabled: true,
}
}
@@ -142,9 +144,16 @@ impl<'a> TableScanBuilder<'a> {
self
}
- /// Sets the manifest file concurrency limit for this scan
- pub fn with_manifest_file_concurrency_limit(mut self, limit: usize) ->
Self {
- self.concurrency_limit_manifest_files = limit;
+ /// Determines whether to enable row group filtering.
+ /// When enabled, if a read is performed with a filter predicate,
+ /// then the metadata for each row group in the parquet file is
+ /// evaluated against the filter predicate and row groups
+ /// that cant contain matching rows will be skipped entirely.
+ ///
+ /// Defaults to enabled, as it generally improves performance or
+ /// keeps it the same, with performance degradation unlikely.
+ pub fn with_row_group_filtering_enabled(mut self,
row_group_filtering_enabled: bool) -> Self {
+ self.row_group_filtering_enabled = row_group_filtering_enabled;
self
}
@@ -258,6 +267,7 @@ impl<'a> TableScanBuilder<'a> {
concurrency_limit_data_files: self.concurrency_limit_data_files,
concurrency_limit_manifest_entries:
self.concurrency_limit_manifest_entries,
concurrency_limit_manifest_files:
self.concurrency_limit_manifest_files,
+ row_group_filtering_enabled: self.row_group_filtering_enabled,
})
}
}
@@ -280,6 +290,8 @@ pub struct TableScan {
/// The maximum number of [`ManifestEntry`]s that will
/// be processed in parallel
concurrency_limit_data_files: usize,
+
+ row_group_filtering_enabled: bool,
}
/// PlanContext wraps a [`SnapshotRef`] alongside all the other
@@ -346,7 +358,7 @@ impl TableScan {
.try_for_each_concurrent(
concurrency_limit_manifest_entries,
|(manifest_entry_context, tx)| async move {
- crate::runtime::spawn(async move {
+ spawn(async move {
Self::process_manifest_entry(manifest_entry_context, tx).await
})
.await
@@ -365,7 +377,8 @@ impl TableScan {
/// Returns an [`ArrowRecordBatchStream`].
pub async fn to_arrow(&self) -> Result<ArrowRecordBatchStream> {
let mut arrow_reader_builder =
ArrowReaderBuilder::new(self.file_io.clone())
-
.with_data_file_concurrency_limit(self.concurrency_limit_data_files);
+
.with_data_file_concurrency_limit(self.concurrency_limit_data_files)
+
.with_row_group_filtering_enabled(self.row_group_filtering_enabled);
if let Some(batch_size) = self.batch_size {
arrow_reader_builder =
arrow_reader_builder.with_batch_size(batch_size);
diff --git a/crates/iceberg/src/writer/file_writer/parquet_writer.rs
b/crates/iceberg/src/writer/file_writer/parquet_writer.rs
index 11ba04f..3e2db58 100644
--- a/crates/iceberg/src/writer/file_writer/parquet_writer.rs
+++ b/crates/iceberg/src/writer/file_writer/parquet_writer.rs
@@ -27,23 +27,20 @@ use futures::future::BoxFuture;
use itertools::Itertools;
use parquet::arrow::async_writer::AsyncFileWriter as ArrowAsyncFileWriter;
use parquet::arrow::AsyncArrowWriter;
-use parquet::data_type::{
- BoolType, ByteArray, ByteArrayType, DataType as ParquetDataType,
DoubleType, FixedLenByteArray,
- FixedLenByteArrayType, FloatType, Int32Type, Int64Type,
-};
use parquet::file::properties::WriterProperties;
-use parquet::file::statistics::{from_thrift, Statistics, TypedStatistics};
+use parquet::file::statistics::{from_thrift, Statistics};
use parquet::format::FileMetaData;
-use uuid::Uuid;
use super::location_generator::{FileNameGenerator, LocationGenerator};
use super::track_writer::TrackWriter;
use super::{FileWriter, FileWriterBuilder};
-use crate::arrow::DEFAULT_MAP_FIELD_NAME;
+use crate::arrow::{
+ get_parquet_stat_max_as_datum, get_parquet_stat_min_as_datum,
DEFAULT_MAP_FIELD_NAME,
+};
use crate::io::{FileIO, FileWrite, OutputFile};
use crate::spec::{
visit_schema, DataFileBuilder, DataFileFormat, Datum, ListType, MapType,
NestedFieldRef,
- PrimitiveLiteral, PrimitiveType, Schema, SchemaRef, SchemaVisitor,
StructType, Type,
+ PrimitiveType, Schema, SchemaRef, SchemaVisitor, StructType, Type,
};
use crate::writer::CurrentFileStatus;
use crate::{Error, ErrorKind, Result};
@@ -237,34 +234,26 @@ impl MinMaxColAggregator {
}
}
- fn update_state<T: ParquetDataType>(
- &mut self,
- field_id: i32,
- state: &TypedStatistics<T>,
- convert_func: impl Fn(<T as ParquetDataType>::T) -> Result<Datum>,
- ) {
- if state.min_is_exact() {
- let val = convert_func(state.min().clone()).unwrap();
- self.lower_bounds
- .entry(field_id)
- .and_modify(|e| {
- if *e > val {
- *e = val.clone()
- }
- })
- .or_insert(val);
- }
- if state.max_is_exact() {
- let val = convert_func(state.max().clone()).unwrap();
- self.upper_bounds
- .entry(field_id)
- .and_modify(|e| {
- if *e < val {
- *e = val.clone()
- }
- })
- .or_insert(val);
- }
+ fn update_state_min(&mut self, field_id: i32, datum: Datum) {
+ self.lower_bounds
+ .entry(field_id)
+ .and_modify(|e| {
+ if *e > datum {
+ *e = datum.clone()
+ }
+ })
+ .or_insert(datum);
+ }
+
+ fn update_state_max(&mut self, field_id: i32, datum: Datum) {
+ self.upper_bounds
+ .entry(field_id)
+ .and_modify(|e| {
+ if *e > datum {
+ *e = datum.clone()
+ }
+ })
+ .or_insert(datum);
}
fn update(&mut self, field_id: i32, value: Statistics) -> Result<()> {
@@ -287,142 +276,28 @@ impl MinMaxColAggregator {
));
};
- match (&ty, value) {
- (PrimitiveType::Boolean, Statistics::Boolean(stat)) => {
- let convert_func = |v: bool|
Result::<Datum>::Ok(Datum::bool(v));
- self.update_state::<BoolType>(field_id, &stat, convert_func)
- }
- (PrimitiveType::Int, Statistics::Int32(stat)) => {
- let convert_func = |v: i32| Result::<Datum>::Ok(Datum::int(v));
- self.update_state::<Int32Type>(field_id, &stat, convert_func)
- }
- (PrimitiveType::Long, Statistics::Int64(stat)) => {
- let convert_func = |v: i64|
Result::<Datum>::Ok(Datum::long(v));
- self.update_state::<Int64Type>(field_id, &stat, convert_func)
- }
- (PrimitiveType::Float, Statistics::Float(stat)) => {
- let convert_func = |v: f32|
Result::<Datum>::Ok(Datum::float(v));
- self.update_state::<FloatType>(field_id, &stat, convert_func)
- }
- (PrimitiveType::Double, Statistics::Double(stat)) => {
- let convert_func = |v: f64|
Result::<Datum>::Ok(Datum::double(v));
- self.update_state::<DoubleType>(field_id, &stat, convert_func)
- }
- (PrimitiveType::String, Statistics::ByteArray(stat)) => {
- let convert_func = |v: ByteArray| {
- Result::<Datum>::Ok(Datum::string(
- String::from_utf8(v.data().to_vec()).unwrap(),
- ))
- };
- self.update_state::<ByteArrayType>(field_id, &stat,
convert_func)
- }
- (PrimitiveType::Binary, Statistics::ByteArray(stat)) => {
- let convert_func =
- |v: ByteArray|
Result::<Datum>::Ok(Datum::binary(v.data().to_vec()));
- self.update_state::<ByteArrayType>(field_id, &stat,
convert_func)
- }
- (PrimitiveType::Date, Statistics::Int32(stat)) => {
- let convert_func = |v: i32|
Result::<Datum>::Ok(Datum::date(v));
- self.update_state::<Int32Type>(field_id, &stat, convert_func)
- }
- (PrimitiveType::Time, Statistics::Int64(stat)) => {
- let convert_func = |v: i64| Datum::time_micros(v);
- self.update_state::<Int64Type>(field_id, &stat, convert_func)
- }
- (PrimitiveType::Timestamp, Statistics::Int64(stat)) => {
- let convert_func = |v: i64|
Result::<Datum>::Ok(Datum::timestamp_micros(v));
- self.update_state::<Int64Type>(field_id, &stat, convert_func)
- }
- (PrimitiveType::Timestamptz, Statistics::Int64(stat)) => {
- let convert_func = |v: i64|
Result::<Datum>::Ok(Datum::timestamptz_micros(v));
- self.update_state::<Int64Type>(field_id, &stat, convert_func)
- }
- (PrimitiveType::TimestampNs, Statistics::Int64(stat)) => {
- let convert_func = |v: i64|
Result::<Datum>::Ok(Datum::timestamp_nanos(v));
- self.update_state::<Int64Type>(field_id, &stat, convert_func)
- }
- (PrimitiveType::TimestamptzNs, Statistics::Int64(stat)) => {
- let convert_func = |v: i64|
Result::<Datum>::Ok(Datum::timestamptz_nanos(v));
- self.update_state::<Int64Type>(field_id, &stat, convert_func)
- }
- (
- PrimitiveType::Decimal {
- precision: _,
- scale: _,
- },
- Statistics::ByteArray(stat),
- ) => {
- let convert_func = |v: ByteArray| -> Result<Datum> {
- Result::<Datum>::Ok(Datum::new(
- ty.clone(),
-
PrimitiveLiteral::Int128(i128::from_le_bytes(v.data().try_into().unwrap())),
- ))
- };
- self.update_state::<ByteArrayType>(field_id, &stat,
convert_func)
- }
- (
- PrimitiveType::Decimal {
- precision: _,
- scale: _,
- },
- Statistics::Int32(stat),
- ) => {
- let convert_func = |v: i32| {
- Result::<Datum>::Ok(Datum::new(
- ty.clone(),
- PrimitiveLiteral::Int128(i128::from(v)),
- ))
- };
- self.update_state::<Int32Type>(field_id, &stat, convert_func)
- }
- (
- PrimitiveType::Decimal {
- precision: _,
- scale: _,
- },
- Statistics::Int64(stat),
- ) => {
- let convert_func = |v: i64| {
- Result::<Datum>::Ok(Datum::new(
- ty.clone(),
- PrimitiveLiteral::Int128(i128::from(v)),
- ))
- };
- self.update_state::<Int64Type>(field_id, &stat, convert_func)
- }
- (PrimitiveType::Uuid, Statistics::FixedLenByteArray(stat)) => {
- let convert_func = |v: FixedLenByteArray| {
- if v.len() != 16 {
- return Err(Error::new(
- ErrorKind::Unexpected,
- "Invalid length of uuid bytes.",
- ));
- }
- Ok(Datum::uuid(Uuid::from_bytes(
- v.data()[..16].try_into().unwrap(),
- )))
- };
- self.update_state::<FixedLenByteArrayType>(field_id, &stat,
convert_func)
- }
- (PrimitiveType::Fixed(len), Statistics::FixedLenByteArray(stat))
=> {
- let convert_func = |v: FixedLenByteArray| {
- if v.len() != *len as usize {
- return Err(Error::new(
- ErrorKind::Unexpected,
- "Invalid length of fixed bytes.",
- ));
- }
- Ok(Datum::fixed(v.data().to_vec()))
- };
- self.update_state::<FixedLenByteArrayType>(field_id, &stat,
convert_func)
- }
- (ty, value) => {
+ if value.min_is_exact() {
+ let Some(min_datum) = get_parquet_stat_min_as_datum(&ty, &value)?
else {
return Err(Error::new(
ErrorKind::Unexpected,
format!("Statistics {} is not match with field type {}.",
value, ty),
- ))
- }
+ ));
+ };
+
+ self.update_state_min(field_id, min_datum);
}
+
+ if value.max_is_exact() {
+ let Some(max_datum) = get_parquet_stat_max_as_datum(&ty, &value)?
else {
+ return Err(Error::new(
+ ErrorKind::Unexpected,
+ format!("Statistics {} is not match with field type {}.",
value, ty),
+ ));
+ };
+
+ self.update_state_max(field_id, max_datum);
+ }
+
Ok(())
}
@@ -609,6 +484,7 @@ mod tests {
use arrow_select::concat::concat_batches;
use parquet::arrow::PARQUET_FIELD_ID_META_KEY;
use tempfile::TempDir;
+ use uuid::Uuid;
use super::*;
use crate::io::FileIOBuilder;