Skip to content

Commit b716c09

Browse files
NGA-TRANalamb
andauthored
feat: API for collecting statistics/index for metadata of a parquet file + tests (#10537)
* test: some tests to write data to a parquet file and read its metadata * feat: API to convert parquet stats to arrow stats * Refine statistics extraction API and tests * Implement null counts * port test * test: add more tests for the arrow statistics * chore: fix format and test output * chore: rename test helpers * chore: Apply suggestions from code review Co-authored-by: Andrew Lamb <andrew@nerdnetworks.org> * Apply suggestions from code review * Apply suggestions from code review --------- Co-authored-by: Andrew Lamb <andrew@nerdnetworks.org>
1 parent 439726f commit b716c09

4 files changed

Lines changed: 812 additions & 4 deletions

File tree

datafusion/core/src/datasource/physical_plan/parquet/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ mod statistics;
7272

7373
pub use metrics::ParquetFileMetrics;
7474
pub use schema_adapter::{SchemaAdapter, SchemaAdapterFactory, SchemaMapper};
75+
pub use statistics::{RequestedStatistics, StatisticsConverter};
7576

7677
/// Execution plan for scanning one or more Parquet partitions
7778
#[derive(Debug, Clone)]
@@ -482,7 +483,6 @@ struct ParquetOpener {
482483
impl FileOpener for ParquetOpener {
483484
fn open(&self, file_meta: FileMeta) -> Result<FileOpenFuture> {
484485
let file_range = file_meta.range.clone();
485-
486486
let file_metrics = ParquetFileMetrics::new(
487487
self.partition_index,
488488
file_meta.location().as_ref(),

datafusion/core/src/datasource/physical_plan/parquet/statistics.rs

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,15 @@
2020
// TODO: potentially move this to arrow-rs: https://github.com/apache/arrow-rs/issues/4328
2121

2222
use arrow::{array::ArrayRef, datatypes::DataType};
23-
use arrow_array::new_empty_array;
24-
use arrow_schema::{FieldRef, Schema};
25-
use datafusion_common::{Result, ScalarValue};
23+
use arrow_array::{new_empty_array, new_null_array, UInt64Array};
24+
use arrow_schema::{Field, FieldRef, Schema};
25+
use datafusion_common::{
26+
internal_datafusion_err, internal_err, plan_err, Result, ScalarValue,
27+
};
28+
use parquet::file::metadata::ParquetMetaData;
2629
use parquet::file::statistics::Statistics as ParquetStatistics;
2730
use parquet::schema::types::SchemaDescriptor;
31+
use std::sync::Arc;
2832

2933
// Convert the bytes array to i128.
3034
// The endian of the input bytes array must be big-endian.
@@ -210,6 +214,152 @@ fn collect_scalars<I: Iterator<Item = Option<ScalarValue>>>(
210214
}
211215
}
212216

217+
/// What type of statistics should be extracted?
218+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
219+
pub enum RequestedStatistics {
220+
/// Minimum Value
221+
Min,
222+
/// Maximum Value
223+
Max,
224+
/// Null Count, returned as a [`UInt64Array`])
225+
NullCount,
226+
}
227+
228+
/// Extracts Parquet statistics as Arrow arrays
229+
///
230+
/// This is used to convert Parquet statistics to Arrow arrays, with proper type
231+
/// conversions. This information can be used for pruning parquet files or row
232+
/// groups based on the statistics embedded in parquet files
233+
///
234+
/// # Schemas
235+
///
236+
/// The schema of the parquet file and the arrow schema are used to convert the
237+
/// underlying statistics value (stored as a parquet value) into the
238+
/// corresponding Arrow value. For example, Decimals are stored as binary in
239+
/// parquet files.
240+
///
241+
/// The parquet_schema and arrow _schema do not have to be identical (for
242+
/// example, the columns may be in different orders and one or the other schemas
243+
/// may have additional columns). The function [`parquet_column`] is used to
244+
/// match the column in the parquet file to the column in the arrow schema.
245+
///
246+
/// # Multiple parquet files
247+
///
248+
/// This API is designed to support efficiently extracting statistics from
249+
/// multiple parquet files (hence why the parquet schema is passed in as an
250+
/// argument). This is useful when building an index for a directory of parquet
251+
/// files.
252+
///
253+
#[derive(Debug)]
254+
pub struct StatisticsConverter<'a> {
255+
/// The name of the column to extract statistics for
256+
column_name: &'a str,
257+
/// The type of statistics to extract
258+
statistics_type: RequestedStatistics,
259+
/// The arrow schema of the query
260+
arrow_schema: &'a Schema,
261+
/// The field (with data type) of the column in the arrow schema
262+
arrow_field: &'a Field,
263+
}
264+
265+
impl<'a> StatisticsConverter<'a> {
266+
/// Returns a [`UInt64Array`] with counts for each row group
267+
///
268+
/// The returned array has no nulls, and has one value for each row group.
269+
/// Each value is the number of rows in the row group.
270+
pub fn row_counts(metadata: &ParquetMetaData) -> Result<UInt64Array> {
271+
let row_groups = metadata.row_groups();
272+
let mut builder = UInt64Array::builder(row_groups.len());
273+
for row_group in row_groups {
274+
let row_count = row_group.num_rows();
275+
let row_count: u64 = row_count.try_into().map_err(|e| {
276+
internal_datafusion_err!(
277+
"Parquet row count {row_count} too large to convert to u64: {e}"
278+
)
279+
})?;
280+
builder.append_value(row_count);
281+
}
282+
Ok(builder.finish())
283+
}
284+
285+
/// create an new statistics converter
286+
pub fn try_new(
287+
column_name: &'a str,
288+
statistics_type: RequestedStatistics,
289+
arrow_schema: &'a Schema,
290+
) -> Result<Self> {
291+
// ensure the requested column is in the arrow schema
292+
let Some((_idx, arrow_field)) = arrow_schema.column_with_name(column_name) else {
293+
return plan_err!(
294+
"Column '{}' not found in schema for statistics conversion",
295+
column_name
296+
);
297+
};
298+
299+
Ok(Self {
300+
column_name,
301+
statistics_type,
302+
arrow_schema,
303+
arrow_field,
304+
})
305+
}
306+
307+
/// extract the statistics from a parquet file, given the parquet file's metadata
308+
///
309+
/// The returned array contains 1 value for each row group in the parquet
310+
/// file in order
311+
///
312+
/// Each value is either
313+
/// * the requested statistics type for the column
314+
/// * a null value, if the statistics can not be extracted
315+
///
316+
/// Note that a null value does NOT mean the min or max value was actually
317+
/// `null` it means it the requested statistic is unknown
318+
///
319+
/// Reasons for not being able to extract the statistics include:
320+
/// * the column is not present in the parquet file
321+
/// * statistics for the column are not present in the row group
322+
/// * the stored statistic value can not be converted to the requested type
323+
pub fn extract(&self, metadata: &ParquetMetaData) -> Result<ArrayRef> {
324+
let data_type = self.arrow_field.data_type();
325+
let num_row_groups = metadata.row_groups().len();
326+
327+
let parquet_schema = metadata.file_metadata().schema_descr();
328+
let row_groups = metadata.row_groups();
329+
330+
// find the column in the parquet schema, if not, return a null array
331+
let Some((parquet_idx, matched_field)) =
332+
parquet_column(parquet_schema, self.arrow_schema, self.column_name)
333+
else {
334+
// column was in the arrow schema but not in the parquet schema, so return a null array
335+
return Ok(new_null_array(data_type, num_row_groups));
336+
};
337+
338+
// sanity check that matching field matches the arrow field
339+
if matched_field.as_ref() != self.arrow_field {
340+
return internal_err!(
341+
"Matched column '{:?}' does not match original matched column '{:?}'",
342+
matched_field,
343+
self.arrow_field
344+
);
345+
}
346+
347+
// Get an iterator over the column statistics
348+
let iter = row_groups
349+
.iter()
350+
.map(|x| x.column(parquet_idx).statistics());
351+
352+
match self.statistics_type {
353+
RequestedStatistics::Min => min_statistics(data_type, iter),
354+
RequestedStatistics::Max => max_statistics(data_type, iter),
355+
RequestedStatistics::NullCount => {
356+
let null_counts = iter.map(|stats| stats.map(|s| s.null_count()));
357+
Ok(Arc::new(UInt64Array::from_iter(null_counts)))
358+
}
359+
}
360+
}
361+
}
362+
213363
#[cfg(test)]
214364
mod test {
215365
use super::*;

0 commit comments

Comments
 (0)