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 6e8487bc perf(datafusion): align Paimon scan statistics with Parquet
(#548)
6e8487bc is described below
commit 6e8487bcd4aa8e1f34a8163fe31d702e6a53d48c
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Jul 19 23:38:05 2026 +0800
perf(datafusion): align Paimon scan statistics with Parquet (#548)
---
.../datafusion/src/physical_plan/scan.rs | 481 ++++++++++++++++++++-
crates/paimon/src/spec/data_file.rs | 153 ++++++-
2 files changed, 621 insertions(+), 13 deletions(-)
diff --git a/crates/integrations/datafusion/src/physical_plan/scan.rs
b/crates/integrations/datafusion/src/physical_plan/scan.rs
index 25709034..f374c034 100644
--- a/crates/integrations/datafusion/src/physical_plan/scan.rs
+++ b/crates/integrations/datafusion/src/physical_plan/scan.rs
@@ -18,10 +18,12 @@
use std::sync::Arc;
use datafusion::arrow::compute::cast;
-use datafusion::arrow::datatypes::SchemaRef as ArrowSchemaRef;
+use datafusion::arrow::datatypes::{
+ DataType as ArrowDataType, SchemaRef as ArrowSchemaRef, TimeUnit,
+};
use datafusion::arrow::record_batch::{RecordBatch, RecordBatchOptions};
use datafusion::common::stats::Precision;
-use datafusion::common::Statistics;
+use datafusion::common::{ColumnStatistics, ScalarValue, Statistics};
use datafusion::error::Result as DFResult;
use datafusion::execution::{SendableRecordBatchStream, TaskContext};
use datafusion::physical_expr::EquivalenceProperties;
@@ -29,7 +31,7 @@ use datafusion::physical_plan::execution_plan::{Boundedness,
EmissionType};
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
use datafusion::physical_plan::{DisplayAs, ExecutionPlan, Partitioning,
PlanProperties};
use futures::{StreamExt, TryStreamExt};
-use paimon::spec::{DataField, Predicate};
+use paimon::spec::{DataField, Datum, MergeEngine, Predicate};
use paimon::table::{ScanTrace, Table};
use paimon::DataSplit;
@@ -62,6 +64,186 @@ fn to_datafusion_batch(batch: RecordBatch, schema:
&ArrowSchemaRef) -> DFResult<
RecordBatch::try_new_with_options(Arc::clone(schema), columns,
&options).map_err(Into::into)
}
+#[derive(Debug)]
+struct ColumnStatsAccumulator {
+ min_value: Option<Datum>,
+ max_value: Option<Datum>,
+ null_count: usize,
+ min_valid: bool,
+ max_valid: bool,
+ null_valid: bool,
+}
+
+impl Default for ColumnStatsAccumulator {
+ fn default() -> Self {
+ Self {
+ min_value: None,
+ max_value: None,
+ null_count: 0,
+ min_valid: true,
+ max_valid: true,
+ null_valid: true,
+ }
+ }
+}
+
+impl ColumnStatsAccumulator {
+ fn add_file(&mut self, file: &paimon::spec::DataFileMeta, field:
&DataField, table: &Table) {
+ if file.row_count == 0 {
+ return;
+ }
+ let Some(stats) =
+ file.value_stats_for_field(table.schema().id(),
table.schema().fields(), field)
+ else {
+ self.min_valid = false;
+ self.max_valid = false;
+ self.null_valid = false;
+ return;
+ };
+
+ let all_null = stats.null_count == Some(file.row_count);
+ match stats.min_value {
+ Some(value) => match &self.min_value {
+ Some(current) => match value.partial_cmp(current) {
+ Some(std::cmp::Ordering::Less) => self.min_value =
Some(value),
+ Some(_) => {}
+ None => self.min_valid = false,
+ },
+ None => self.min_value = Some(value),
+ },
+ None if !all_null => self.min_valid = false,
+ None => {}
+ }
+ match stats.max_value {
+ Some(value) => match &self.max_value {
+ Some(current) => match value.partial_cmp(current) {
+ Some(std::cmp::Ordering::Greater) => self.max_value =
Some(value),
+ Some(_) => {}
+ None => self.max_valid = false,
+ },
+ None => self.max_value = Some(value),
+ },
+ None if !all_null => self.max_valid = false,
+ None => {}
+ }
+ match stats
+ .null_count
+ .and_then(|count| usize::try_from(count).ok())
+ .and_then(|count| self.null_count.checked_add(count))
+ {
+ Some(count) => self.null_count = count,
+ None => self.null_valid = false,
+ }
+ }
+
+ fn finish(self, data_type: &ArrowDataType, exact_null_count: bool) ->
ColumnStatistics {
+ let min_value = if self.min_valid {
+ self.min_value
+ .and_then(|value| datum_to_scalar(value, data_type))
+ .map(Precision::Inexact)
+ .unwrap_or(Precision::Absent)
+ } else {
+ Precision::Absent
+ };
+ let max_value = if self.max_valid {
+ self.max_value
+ .and_then(|value| datum_to_scalar(value, data_type))
+ .map(Precision::Inexact)
+ .unwrap_or(Precision::Absent)
+ } else {
+ Precision::Absent
+ };
+ let null_count = if exact_null_count && self.null_valid {
+ Precision::Exact(self.null_count)
+ } else {
+ Precision::Absent
+ };
+
+ ColumnStatistics {
+ null_count,
+ min_value,
+ max_value,
+ distinct_count: Precision::Absent,
+ sum_value: Precision::Absent,
+ byte_size: Precision::Absent,
+ }
+ }
+}
+
+fn datum_to_scalar(value: Datum, data_type: &ArrowDataType) ->
Option<ScalarValue> {
+ match (value, data_type) {
+ (Datum::Bool(value), ArrowDataType::Boolean) =>
Some(ScalarValue::Boolean(Some(value))),
+ (Datum::TinyInt(value), ArrowDataType::Int8) =>
Some(ScalarValue::Int8(Some(value))),
+ (Datum::SmallInt(value), ArrowDataType::Int16) =>
Some(ScalarValue::Int16(Some(value))),
+ (Datum::Int(value), ArrowDataType::Int32) =>
Some(ScalarValue::Int32(Some(value))),
+ (Datum::Long(value), ArrowDataType::Int64) =>
Some(ScalarValue::Int64(Some(value))),
+ (Datum::Float(value), ArrowDataType::Float32) =>
Some(ScalarValue::Float32(Some(value))),
+ (Datum::Double(value), ArrowDataType::Float64) =>
Some(ScalarValue::Float64(Some(value))),
+ (Datum::String(value), ArrowDataType::Utf8) =>
Some(ScalarValue::Utf8(Some(value))),
+ (Datum::String(value), ArrowDataType::Utf8View) =>
Some(ScalarValue::Utf8View(Some(value))),
+ (Datum::String(value), ArrowDataType::LargeUtf8) => {
+ Some(ScalarValue::LargeUtf8(Some(value)))
+ }
+ (Datum::Date(value), ArrowDataType::Date32) =>
Some(ScalarValue::Date32(Some(value))),
+ (Datum::Time(value), ArrowDataType::Time32(TimeUnit::Millisecond)) => {
+ Some(ScalarValue::Time32Millisecond(Some(value)))
+ }
+ (Datum::Timestamp { millis, nanos }, ArrowDataType::Timestamp(unit,
timezone))
+ | (
+ Datum::LocalZonedTimestamp { millis, nanos },
+ ArrowDataType::Timestamp(unit, timezone),
+ ) => {
+ let value = match unit {
+ TimeUnit::Second => millis.checked_div(1_000)?,
+ TimeUnit::Millisecond => millis,
+ TimeUnit::Microsecond => millis
+ .checked_mul(1_000)?
+ .checked_add(i64::from(nanos / 1_000))?,
+ TimeUnit::Nanosecond => millis
+ .checked_mul(1_000_000)?
+ .checked_add(i64::from(nanos))?,
+ };
+ match unit {
+ TimeUnit::Second => {
+ Some(ScalarValue::TimestampSecond(Some(value),
timezone.clone()))
+ }
+ TimeUnit::Millisecond =>
Some(ScalarValue::TimestampMillisecond(
+ Some(value),
+ timezone.clone(),
+ )),
+ TimeUnit::Microsecond =>
Some(ScalarValue::TimestampMicrosecond(
+ Some(value),
+ timezone.clone(),
+ )),
+ TimeUnit::Nanosecond => Some(ScalarValue::TimestampNanosecond(
+ Some(value),
+ timezone.clone(),
+ )),
+ }
+ }
+ (
+ Datum::Decimal {
+ unscaled,
+ precision,
+ scale,
+ },
+ ArrowDataType::Decimal128(_, _),
+ ) => Some(ScalarValue::Decimal128(
+ Some(unscaled),
+ u8::try_from(precision).ok()?,
+ i8::try_from(scale).ok()?,
+ )),
+ // Paimon compares bytes using Java's signed-byte ordering, while
Arrow compares
+ // binary values using unsigned lexicographic ordering. Publishing the
manifest
+ // bounds would therefore be unsound for values crossing 0x7f/0x80.
+ (
+ Datum::Bytes(_),
+ ArrowDataType::Binary | ArrowDataType::BinaryView |
ArrowDataType::LargeBinary,
+ ) => None,
+ _ => None,
+ }
+}
+
/// Execution plan that scans a Paimon table with optional column projection.
///
/// Planning is performed eagerly in
[`super::super::table::PaimonTableProvider::scan`],
@@ -150,6 +332,55 @@ impl PaimonTableScan {
pub fn limit(&self) -> Option<usize> {
self.limit
}
+
+ fn manifest_column_statistics(&self, partitions: &[Arc<[DataSplit]>]) ->
Vec<ColumnStatistics> {
+ if self.read_type.len() != self.schema().fields().len() {
+ return Statistics::unknown_column(&self.schema());
+ }
+
+ let Ok(merge_engine) =
self.table.schema().core_options().merge_engine() else {
+ return Statistics::unknown_column(&self.schema());
+ };
+ if merge_engine == MergeEngine::Aggregation {
+ // Aggregate functions such as SUM can produce logical values
outside every
+ // physical file's min/max bounds.
+ return Statistics::unknown_column(&self.schema());
+ }
+
+ let exact_null_counts = (self.table.schema().primary_keys().is_empty()
+ || merge_engine == MergeEngine::Deduplicate)
+ && self.pushed_predicate.is_none()
+ && self.limit.is_none()
+ && partitions
+ .iter()
+ .flat_map(|splits| splits.iter())
+ .all(|split| {
+ split.raw_convertible()
+ && split.row_ranges().is_none()
+ && split
+ .data_deletion_files()
+ .is_none_or(|files|
files.iter().all(Option::is_none))
+ });
+ let mut accumulators = (0..self.read_type.len())
+ .map(|_| ColumnStatsAccumulator::default())
+ .collect::<Vec<_>>();
+
+ for file in partitions
+ .iter()
+ .flat_map(|splits| splits.iter())
+ .flat_map(|split| split.data_files())
+ {
+ for (accumulator, field) in
accumulators.iter_mut().zip(&self.read_type) {
+ accumulator.add_file(file, field, &self.table);
+ }
+ }
+
+ accumulators
+ .into_iter()
+ .zip(self.schema().fields())
+ .map(|(accumulator, field)| accumulator.finish(field.data_type(),
exact_null_counts))
+ .collect()
+ }
}
impl ExecutionPlan for PaimonTableScan {
@@ -227,7 +458,6 @@ impl ExecutionPlan for PaimonTableScan {
};
let mut total_rows: usize = 0;
- let mut total_bytes: usize = 0;
let mut all_row_counts_known = true;
for splits in partitions {
for split in splits.iter() {
@@ -237,9 +467,6 @@ impl ExecutionPlan for PaimonTableScan {
all_row_counts_known = false;
total_rows += split.row_count() as usize;
}
- for file in split.data_files() {
- total_bytes += file.file_size as usize;
- }
}
}
@@ -256,8 +483,8 @@ impl ExecutionPlan for PaimonTableScan {
Ok(Arc::new(Statistics {
num_rows: num_rows_precision,
- total_byte_size: Precision::Inexact(total_bytes),
- column_statistics: Statistics::unknown_column(&self.schema()),
+ total_byte_size: Precision::Absent,
+ column_statistics: self.manifest_column_statistics(partitions),
}))
}
}
@@ -320,9 +547,10 @@ mod tests {
use paimon::catalog::Identifier;
use paimon::io::FileIOBuilder;
use paimon::spec::{
- BinaryRow, DataType, Datum, IntType, PredicateBuilder, Schema as
PaimonSchema, TableSchema,
+ BinaryRow, DataFileMeta, DataType, Datum, IntType, PredicateBuilder,
+ Schema as PaimonSchema, TableSchema,
};
- use paimon::table::Table;
+ use paimon::table::{DeletionFile, RowRange, Table};
use std::fs;
use tempfile::tempdir;
use test_utils::{local_file_path, test_data_file, write_int_parquet_file};
@@ -343,6 +571,20 @@ mod tests {
)]
}
+ #[test]
+ fn test_binary_manifest_bounds_are_not_exposed() {
+ for data_type in [
+ ArrowDataType::Binary,
+ ArrowDataType::BinaryView,
+ ArrowDataType::LargeBinary,
+ ] {
+ assert_eq!(
+ datum_to_scalar(Datum::Bytes(vec![0x7f, 0x80]), &data_type),
+ None
+ );
+ }
+ }
+
#[test]
fn test_partition_count_empty_plan() {
let schema = test_schema();
@@ -388,7 +630,10 @@ mod tests {
/// only test PlanProperties, not actual reads).
fn dummy_table() -> Table {
let file_io = FileIOBuilder::new("file").build().unwrap();
- let schema = PaimonSchema::builder().build().unwrap();
+ let schema = PaimonSchema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .build()
+ .unwrap();
let table_schema = TableSchema::new(0, &schema);
Table::new(
file_io,
@@ -399,6 +644,218 @@ mod tests {
)
}
+ fn data_file_with_int_stats(
+ file_name: &str,
+ row_count: i64,
+ min: i32,
+ max: i32,
+ null_count: i64,
+ ) -> DataFileMeta {
+ let data_type = DataType::Int(IntType::new());
+ let min = Datum::Int(min);
+ let max = Datum::Int(max);
+ let mut file =
+ serde_json::to_value(test_data_file::<DataFileMeta>(file_name,
row_count, 100))
+ .unwrap();
+ file["_VALUE_STATS"] = serde_json::json!({
+ "_MIN_VALUES": BinaryRow::from_datums(&[(Some(&min),
&data_type)]).to_serialized_bytes(),
+ "_MAX_VALUES": BinaryRow::from_datums(&[(Some(&max),
&data_type)]).to_serialized_bytes(),
+ "_NULL_COUNTS": [null_count],
+ });
+ serde_json::from_value(file).unwrap()
+ }
+
+ fn split_with_int_stats(
+ row_ranges: Option<Vec<RowRange>>,
+ deletion_file: Option<DeletionFile>,
+ ) -> DataSplit {
+ let mut builder = paimon::DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("file:/tmp/test-table/bucket-0".to_string())
+ .with_total_buckets(1)
+ .with_data_files(vec![data_file_with_int_stats("data.parquet", 4,
1, 4, 1)]);
+ if let Some(row_ranges) = row_ranges {
+ builder = builder.with_row_ranges(row_ranges);
+ }
+ if let Some(deletion_file) = deletion_file {
+ builder =
builder.with_data_deletion_files(vec![Some(deletion_file)]);
+ }
+ builder.build().unwrap()
+ }
+
+ #[test]
+ fn test_partition_statistics_include_manifest_column_bounds() {
+ let split = paimon::DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("file:/tmp/test-table/bucket-0".to_string())
+ .with_total_buckets(1)
+ .with_data_files(vec![
+ data_file_with_int_stats("a.parquet", 4, 2, 8, 1),
+ data_file_with_int_stats("b.parquet", 6, 1, 10, 2),
+ ])
+ .build()
+ .unwrap();
+ let scan = PaimonTableScan::new(
+ test_schema(),
+ dummy_table(),
+ test_read_type(),
+ None,
+ vec![Arc::from(vec![split])],
+ None,
+ true,
+ None,
+ None,
+ true,
+ );
+
+ let statistics = scan.partition_statistics(None).unwrap();
+ let id = &statistics.column_statistics[0];
+
+ assert_eq!(
+ id.min_value,
+ Precision::Inexact(ScalarValue::Int32(Some(1)))
+ );
+ assert_eq!(
+ id.max_value,
+ Precision::Inexact(ScalarValue::Int32(Some(10)))
+ );
+ assert_eq!(id.null_count, Precision::Exact(3));
+ }
+
+ #[test]
+ fn test_partition_statistics_omit_compressed_file_size() {
+ let split = paimon::DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("file:/tmp/test-table/bucket-0".to_string())
+ .with_total_buckets(1)
+ .with_data_files(vec![data_file_with_int_stats("data.parquet", 4,
1, 4, 0)])
+ .build()
+ .unwrap();
+ let scan = PaimonTableScan::new(
+ test_schema(),
+ dummy_table(),
+ test_read_type(),
+ None,
+ vec![Arc::from(vec![split])],
+ None,
+ true,
+ None,
+ None,
+ true,
+ );
+
+ let statistics = scan.partition_statistics(None).unwrap();
+
+ assert_eq!(statistics.num_rows, Precision::Exact(4));
+ assert_eq!(statistics.total_byte_size, Precision::Absent);
+ }
+
+ #[test]
+ fn test_partition_statistics_downgrade_unsafe_null_counts() {
+ let table = dummy_table();
+ let predicate = PredicateBuilder::new(table.schema().fields())
+ .greater_than("id", Datum::Int(1))
+ .unwrap();
+ let cases = vec![
+ (split_with_int_stats(None, None), Some(predicate), None),
+ (split_with_int_stats(None, None), None, Some(2)),
+ (
+ split_with_int_stats(Some(vec![RowRange::new(1, 2)]), None),
+ None,
+ None,
+ ),
+ (
+ split_with_int_stats(
+ None,
+ Some(DeletionFile::new("dv.bin".to_string(), 0, 16,
Some(1))),
+ ),
+ None,
+ None,
+ ),
+ ];
+
+ for (split, predicate, limit) in cases {
+ let scan = PaimonTableScan::new(
+ test_schema(),
+ table.clone(),
+ test_read_type(),
+ predicate,
+ vec![Arc::from(vec![split])],
+ limit,
+ true,
+ None,
+ None,
+ true,
+ );
+ assert_eq!(
+
scan.partition_statistics(None).unwrap().column_statistics[0].null_count,
+ Precision::Absent
+ );
+ }
+ }
+
+ #[test]
+ fn test_partition_statistics_hide_aggregation_value_bounds() {
+ let file_io = FileIOBuilder::new("file").build().unwrap();
+ let table_schema = TableSchema::new(
+ 0,
+ &PaimonSchema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("merge-engine", "aggregation")
+ .option("fields.value.aggregate-function", "sum")
+ .build()
+ .unwrap(),
+ );
+ let value_field = table_schema.fields()[1].clone();
+ let table = Table::new(
+ file_io,
+ Identifier::new("test_db", "aggregation_table"),
+ "/tmp/aggregation-table".to_string(),
+ table_schema,
+ None,
+ );
+ let mut file = data_file_with_int_stats("data.parquet", 2, 1, 2, 0);
+ file.value_stats_cols = Some(vec!["value".to_string()]);
+ let split = paimon::DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+
.with_bucket_path("file:/tmp/aggregation-table/bucket-0".to_string())
+ .with_total_buckets(1)
+ .with_data_files(vec![file])
+ .build()
+ .unwrap();
+ let scan = PaimonTableScan::new(
+ Arc::new(ArrowSchema::new(vec![Field::new(
+ "value",
+ ArrowDataType::Int32,
+ true,
+ )])),
+ table,
+ vec![value_field],
+ None,
+ vec![Arc::from(vec![split])],
+ None,
+ true,
+ None,
+ None,
+ true,
+ );
+
+ let value_stats =
&scan.partition_statistics(None).unwrap().column_statistics[0];
+ assert_eq!(value_stats.min_value, Precision::Absent);
+ assert_eq!(value_stats.max_value, Precision::Absent);
+ assert_eq!(value_stats.null_count, Precision::Absent);
+ }
+
#[tokio::test]
async fn test_execute_applies_pushed_filter_during_read() {
let tempdir = tempdir().unwrap();
diff --git a/crates/paimon/src/spec/data_file.rs
b/crates/paimon/src/spec/data_file.rs
index cc90824e..c6745ad3 100644
--- a/crates/paimon/src/spec/data_file.rs
+++ b/crates/paimon/src/spec/data_file.rs
@@ -16,7 +16,9 @@
// under the License.
use crate::spec::stats::BinaryTableStats;
-use crate::spec::{serialize_binary_array_str, BinaryRowBuilder};
+use crate::spec::{
+ extract_datum, serialize_binary_array_str, BinaryRow, BinaryRowBuilder,
DataField, Datum,
+};
use chrono::serde::ts_milliseconds_option::deserialize as from_millis_opt;
use chrono::serde::ts_milliseconds_option::serialize as to_millis_opt;
use chrono::{DateTime, Utc};
@@ -26,6 +28,14 @@ use std::fmt::{Display, Formatter};
/// Suffix for sidecar file-index files, matching Java
`DataFilePathFactory.INDEX_PATH_SUFFIX`.
pub const DATA_FILE_INDEX_SUFFIX: &str = ".index";
+/// Manifest value statistics for one column in one data file.
+#[derive(Debug, Clone, PartialEq)]
+pub struct DataFileColumnStats {
+ pub min_value: Option<Datum>,
+ pub max_value: Option<Datum>,
+ pub null_count: Option<i64>,
+}
+
/// Metadata of a data file.
///
/// Impl References:
<https://github.com/apache/paimon/blob/release-0.8.2/paimon-core/src/main/java/org/apache/paimon/io/DataFileMeta.java>
@@ -160,6 +170,73 @@ fn opt_str_array(b: &mut BinaryRowBuilder, pos: usize, v:
&Option<Vec<String>>)
}
impl DataFileMeta {
+ /// Decode this file's manifest value statistics for a field in the
current schema.
+ ///
+ /// Returns `None` when statistics are missing, malformed, or belong to a
different
+ /// schema version. Callers must treat `None` as unknown rather than
excluding data.
+ pub fn value_stats_for_field(
+ &self,
+ current_schema_id: i64,
+ current_fields: &[DataField],
+ field: &DataField,
+ ) -> Option<DataFileColumnStats> {
+ if self.schema_id != current_schema_id {
+ return None;
+ }
+
+ let (schema_index, schema_field) = current_fields
+ .iter()
+ .enumerate()
+ .find(|(_, candidate)| candidate.id() == field.id())?;
+ if schema_field.name() != field.name() || schema_field.data_type() !=
field.data_type() {
+ return None;
+ }
+ let stats_index = if let Some(columns) = &self.value_stats_cols {
+ columns.iter().position(|name| name == field.name())?
+ } else if let Some(columns) = &self.write_cols {
+ columns.iter().position(|name| name == field.name())?
+ } else {
+ schema_index
+ };
+
+ let min_values =
BinaryRow::from_serialized_bytes(self.value_stats.min_values()).ok()?;
+ let max_values =
BinaryRow::from_serialized_bytes(self.value_stats.max_values()).ok()?;
+ if stats_index >= usize::try_from(min_values.arity()).ok()?
+ || stats_index >= usize::try_from(max_values.arity()).ok()?
+ {
+ return None;
+ }
+ let min_value = extract_datum(&min_values, stats_index,
field.data_type()).ok()?;
+ let max_value = extract_datum(&max_values, stats_index,
field.data_type()).ok()?;
+ let null_count = self
+ .value_stats
+ .null_counts()
+ .get(stats_index)
+ .copied()
+ .flatten();
+
+ if self.row_count < 0 || null_count.is_some_and(|count| count < 0 ||
count > self.row_count)
+ {
+ return None;
+ }
+ let all_null = null_count == Some(self.row_count);
+ if all_null && (min_value.is_some() || max_value.is_some()) {
+ return None;
+ }
+ if let (Some(min), Some(max)) = (&min_value, &max_value) {
+ match min.partial_cmp(max) {
+ Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal) =>
{}
+ Some(std::cmp::Ordering::Greater) | None => return None,
+ }
+ }
+
+ Some(DataFileColumnStats {
+ min_value,
+ max_value,
+ null_count,
+ })
+ }
+
/// Returns the row ID range `[first_row_id, first_row_id + row_count -
1]` if `first_row_id` is set.
pub fn row_id_range(&self) -> Option<(i64, i64)> {
self.first_row_id.map(|fid| (fid, fid + self.row_count - 1))
@@ -295,6 +372,7 @@ fn parent_path(path: &str) -> Option<&str> {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::spec::{DataType, IntType};
fn data_file(file_name: &str) -> DataFileMeta {
let stats = BinaryTableStats::empty();
@@ -322,6 +400,79 @@ mod tests {
}
}
+ fn int_stats(min: Option<i32>, max: Option<i32>, null_count: Option<i64>)
-> BinaryTableStats {
+ let data_type = DataType::Int(IntType::new());
+ let min = min.map(Datum::Int);
+ let max = max.map(Datum::Int);
+ BinaryTableStats::new(
+ BinaryRow::from_datums(&[(min.as_ref(),
&data_type)]).to_serialized_bytes(),
+ BinaryRow::from_datums(&[(max.as_ref(),
&data_type)]).to_serialized_bytes(),
+ vec![null_count],
+ )
+ }
+
+ fn int_field(id: i32, name: &str) -> DataField {
+ DataField::new(id, name.to_string(), DataType::Int(IntType::new()))
+ }
+
+ #[test]
+ fn value_stats_for_field_resolves_dense_columns_and_schema() {
+ let fields = vec![int_field(0, "id"), int_field(1, "v")];
+ let mut file = data_file("data.parquet");
+ file.value_stats = int_stats(Some(3), Some(9), Some(2));
+
+ let stats = file.value_stats_for_field(11, &fields,
&fields[1]).unwrap();
+ assert_eq!(stats.min_value, Some(Datum::Int(3)));
+ assert_eq!(stats.max_value, Some(Datum::Int(9)));
+ assert_eq!(stats.null_count, Some(2));
+
+ assert!(file
+ .value_stats_for_field(11, &fields, &fields[0])
+ .is_none());
+ assert!(file
+ .value_stats_for_field(12, &fields, &fields[1])
+ .is_none());
+ assert!(file
+ .value_stats_for_field(11, &fields, &int_field(1, "other"))
+ .is_none());
+ }
+
+ #[test]
+ fn value_stats_for_field_rejects_inconsistent_or_malformed_stats() {
+ let fields = vec![int_field(1, "v")];
+ let mut file = data_file("data.parquet");
+ file.row_count = 4;
+
+ file.value_stats = int_stats(Some(1), Some(4), Some(5));
+ assert!(file
+ .value_stats_for_field(11, &fields, &fields[0])
+ .is_none());
+
+ file.value_stats = int_stats(Some(4), Some(1), Some(0));
+ assert!(file
+ .value_stats_for_field(11, &fields, &fields[0])
+ .is_none());
+
+ file.value_stats = int_stats(Some(1), Some(4), Some(4));
+ assert!(file
+ .value_stats_for_field(11, &fields, &fields[0])
+ .is_none());
+
+ file.value_stats = BinaryTableStats::new(vec![0], vec![0],
vec![Some(0)]);
+ assert!(file
+ .value_stats_for_field(11, &fields, &fields[0])
+ .is_none());
+
+ file.value_stats = BinaryTableStats::new(
+ BinaryRow::new(0).to_serialized_bytes(),
+ BinaryRow::new(0).to_serialized_bytes(),
+ vec![Some(4)],
+ );
+ assert!(file
+ .value_stats_for_field(11, &fields, &fields[0])
+ .is_none());
+ }
+
#[test]
fn display_includes_all_data_file_meta_fields() {
let file = DataFileMeta {