Skip to main content

datafusion_datasource/
statistics.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! Use statistics to optimize physical planning.
19//!
20//! Currently, this module houses code to sort file groups if they are non-overlapping with
21//! respect to the required sort order. See [`MinMaxStatistics`]
22
23use std::sync::Arc;
24
25use crate::PartitionedFile;
26use crate::file_groups::FileGroup;
27
28use arrow::array::RecordBatch;
29use arrow::compute::SortColumn;
30use arrow::datatypes::SchemaRef;
31use arrow::row::{Row, Rows};
32use datafusion_common::stats::{NdvFallback, Precision};
33use datafusion_common::{
34    DataFusionError, Result, ScalarValue, plan_datafusion_err, plan_err,
35};
36use datafusion_physical_expr::expressions::Column;
37use datafusion_physical_expr_common::sort_expr::{LexOrdering, PhysicalSortExpr};
38use datafusion_physical_plan::{ColumnStatistics, Statistics};
39
40use futures::{Stream, StreamExt};
41
42/// A normalized representation of file min/max statistics that allows for efficient sorting & comparison.
43/// The min/max values are ordered by [`Self::sort_order`].
44/// Furthermore, any columns that are reversed in the sort order have their min/max values swapped.
45pub(crate) struct MinMaxStatistics {
46    min_by_sort_order: Rows,
47    max_by_sort_order: Rows,
48    sort_order: LexOrdering,
49}
50
51impl MinMaxStatistics {
52    /// Sort order used to sort the statistics
53    #[expect(unused)]
54    pub fn sort_order(&self) -> &LexOrdering {
55        &self.sort_order
56    }
57
58    /// Min value at index
59    #[expect(unused)]
60    pub fn min(&'_ self, idx: usize) -> Row<'_> {
61        self.min_by_sort_order.row(idx)
62    }
63
64    /// Max value at index
65    pub fn max(&'_ self, idx: usize) -> Row<'_> {
66        self.max_by_sort_order.row(idx)
67    }
68
69    pub fn new_from_files<'a>(
70        projected_sort_order: &LexOrdering, // Sort order with respect to projected schema
71        projected_schema: &SchemaRef,       // Projected schema
72        projection: Option<&[usize]>, // Indices of projection in full table schema (None = all columns)
73        files: impl IntoIterator<Item = &'a PartitionedFile>,
74    ) -> Result<Self> {
75        let Some(statistics_and_partition_values) = files
76            .into_iter()
77            .map(|file| {
78                file.statistics
79                    .as_ref()
80                    .zip(Some(file.partition_values.as_slice()))
81            })
82            .collect::<Option<Vec<_>>>()
83        else {
84            return plan_err!("Parquet file missing statistics");
85        };
86
87        // Helper function to get min/max statistics for a given column of projected_schema
88        let get_min_max = |i: usize| -> Result<(Vec<ScalarValue>, Vec<ScalarValue>)> {
89            Ok(statistics_and_partition_values
90                .iter()
91                .map(|(s, pv)| {
92                    if i < s.column_statistics.len() {
93                        s.column_statistics[i]
94                            .min_value
95                            .get_value()
96                            .cloned()
97                            .zip(s.column_statistics[i].max_value.get_value().cloned())
98                            .ok_or_else(|| plan_datafusion_err!("statistics not found"))
99                    } else {
100                        if let Some(partition_value) =
101                            pv.get(i - s.column_statistics.len())
102                        {
103                            Ok((partition_value.clone(), partition_value.clone()))
104                        } else {
105                            Err(plan_datafusion_err!(
106                                "statistics not found for partition, expected at most {}",
107                                s.column_statistics.len()
108                            ))
109                        }
110                    }
111                })
112                .collect::<Result<Vec<_>>>()?
113                .into_iter()
114                .unzip())
115        };
116
117        let Some(sort_columns) =
118            sort_columns_from_physical_sort_exprs(projected_sort_order)
119        else {
120            return plan_err!("sort expression must be on column");
121        };
122
123        // Project the schema & sort order down to just the relevant columns
124        let min_max_schema = Arc::new(
125            projected_schema
126                .project(&(sort_columns.iter().map(|c| c.index()).collect::<Vec<_>>()))?,
127        );
128
129        let min_max_sort_order = projected_sort_order
130            .iter()
131            .zip(sort_columns.iter())
132            .enumerate()
133            .map(|(idx, (sort_expr, col))| {
134                let expr = Arc::new(Column::new(col.name(), idx));
135                PhysicalSortExpr::new(expr, sort_expr.options)
136            });
137        // Safe to `unwrap` as we know that sort columns are non-empty:
138        let min_max_sort_order = LexOrdering::new(min_max_sort_order).unwrap();
139
140        let (min_values, max_values): (Vec<_>, Vec<_>) = sort_columns
141            .iter()
142            .map(|c| {
143                // Reverse the projection to get the index of the column in the full statistics
144                // The file statistics contains _every_ column , but the sort column's index()
145                // refers to the index in projected_schema
146                let i = projection
147                    .map(|p| p[c.index()])
148                    .unwrap_or_else(|| c.index());
149
150                let (min, max) = get_min_max(i).map_err(|e| {
151                    e.context(format!("get min/max for column: '{}'", c.name()))
152                })?;
153                Ok((
154                    ScalarValue::iter_to_array(min)?,
155                    ScalarValue::iter_to_array(max)?,
156                ))
157            })
158            .collect::<Result<Vec<_>>>()
159            .map_err(|e| e.context("collect min/max values"))?
160            .into_iter()
161            .unzip();
162
163        let min_batch = RecordBatch::try_new(Arc::clone(&min_max_schema), min_values)
164            .map_err(|e| {
165                DataFusionError::ArrowError(
166                    Box::new(e),
167                    Some("\ncreate min batch".to_string()),
168                )
169            })?;
170        let max_batch = RecordBatch::try_new(Arc::clone(&min_max_schema), max_values)
171            .map_err(|e| {
172                DataFusionError::ArrowError(
173                    Box::new(e),
174                    Some("\ncreate max batch".to_string()),
175                )
176            })?;
177
178        Self::new(&min_max_sort_order, &min_max_schema, min_batch, max_batch)
179    }
180
181    #[expect(clippy::needless_pass_by_value)]
182    pub fn new(
183        sort_order: &LexOrdering,
184        schema: &SchemaRef,
185        min_values: RecordBatch,
186        max_values: RecordBatch,
187    ) -> Result<Self> {
188        use arrow::row::*;
189
190        let sort_fields = sort_order
191            .iter()
192            .map(|expr| {
193                expr.expr
194                    .data_type(schema)
195                    .map(|data_type| SortField::new_with_options(data_type, expr.options))
196            })
197            .collect::<Result<Vec<_>>>()
198            .map_err(|e| e.context("create sort fields"))?;
199        let converter = RowConverter::new(sort_fields)?;
200
201        let Some(sort_columns) = sort_columns_from_physical_sort_exprs(sort_order) else {
202            return plan_err!("sort expression must be on column");
203        };
204
205        // swap min/max if they're reversed in the ordering
206        let (new_min_cols, new_max_cols): (Vec<_>, Vec<_>) = sort_order
207            .iter()
208            .zip(sort_columns.iter().copied())
209            .map(|(sort_expr, column)| {
210                let maxes = max_values.column_by_name(column.name());
211                let mins = min_values.column_by_name(column.name());
212                let opt_value = if sort_expr.options.descending {
213                    maxes.zip(mins)
214                } else {
215                    mins.zip(maxes)
216                };
217                opt_value.ok_or_else(|| {
218                    plan_datafusion_err!(
219                        "missing column in MinMaxStatistics::new: '{}'",
220                        column.name()
221                    )
222                })
223            })
224            .collect::<Result<Vec<_>>>()?
225            .into_iter()
226            .unzip();
227
228        let [min, max] = [new_min_cols, new_max_cols].map(|cols| {
229            let values = RecordBatch::try_new(
230                min_values.schema(),
231                cols.into_iter().cloned().collect(),
232            )?;
233            let sorting_columns = sort_order
234                .iter()
235                .zip(sort_columns.iter().copied())
236                .map(|(sort_expr, column)| {
237                    let schema = values.schema();
238                    let idx = schema.index_of(column.name())?;
239
240                    Ok(SortColumn {
241                        values: Arc::clone(values.column(idx)),
242                        options: Some(sort_expr.options),
243                    })
244                })
245                .collect::<Result<Vec<_>>>()
246                .map_err(|e| e.context("create sorting columns"))?;
247            converter
248                .convert_columns(
249                    &sorting_columns
250                        .into_iter()
251                        .map(|c| c.values)
252                        .collect::<Vec<_>>(),
253                )
254                .map_err(|e| {
255                    DataFusionError::ArrowError(
256                        Box::new(e),
257                        Some("convert columns".to_string()),
258                    )
259                })
260        });
261
262        Ok(Self {
263            min_by_sort_order: min.map_err(|e| e.context("build min rows"))?,
264            max_by_sort_order: max.map_err(|e| e.context("build max rows"))?,
265            sort_order: sort_order.clone(),
266        })
267    }
268
269    /// Return a sorted list of the min statistics together with the original indices
270    pub fn min_values_sorted(&self) -> Vec<(usize, Row<'_>)> {
271        let mut sort: Vec<_> = self.min_by_sort_order.iter().enumerate().collect();
272        sort.sort_unstable_by_key(|(_, row)| *row);
273        sort
274    }
275
276    /// Check if the min/max statistics are in order and non-overlapping
277    /// (or touching at boundaries)
278    pub fn is_sorted(&self) -> bool {
279        self.max_by_sort_order
280            .iter()
281            .zip(self.min_by_sort_order.iter().skip(1))
282            .all(|(max, next_min)| max <= next_min)
283    }
284}
285
286fn sort_columns_from_physical_sort_exprs(
287    sort_order: &LexOrdering,
288) -> Option<Vec<&Column>> {
289    sort_order
290        .iter()
291        .map(|expr| expr.expr.downcast_ref::<Column>())
292        .collect()
293}
294
295fn seed_summary_statistics(summary_statistics: &mut Statistics, file_stats: &Statistics) {
296    summary_statistics.num_rows = file_stats.num_rows;
297    summary_statistics.total_byte_size = file_stats.total_byte_size;
298
299    for (summary_col_stats, file_col_stats) in summary_statistics
300        .column_statistics
301        .iter_mut()
302        .zip(file_stats.column_statistics.iter())
303    {
304        summary_col_stats.null_count = file_col_stats.null_count;
305        summary_col_stats.max_value = file_col_stats.max_value.clone();
306        summary_col_stats.min_value = file_col_stats.min_value.clone();
307        summary_col_stats.sum_value = file_col_stats.sum_value.cast_to_sum_type();
308        summary_col_stats.byte_size = file_col_stats.byte_size;
309    }
310}
311
312fn merge_summary_statistics(
313    summary_statistics: &mut Statistics,
314    file_stats: &Statistics,
315) {
316    summary_statistics.num_rows = summary_statistics.num_rows.add(&file_stats.num_rows);
317    summary_statistics.total_byte_size = summary_statistics
318        .total_byte_size
319        .add(&file_stats.total_byte_size);
320
321    for (summary_col_stats, file_col_stats) in summary_statistics
322        .column_statistics
323        .iter_mut()
324        .zip(file_stats.column_statistics.iter())
325    {
326        let ColumnStatistics {
327            null_count: file_nc,
328            max_value: file_max,
329            min_value: file_min,
330            sum_value: file_sum,
331            distinct_count: _,
332            byte_size: file_sbs,
333        } = file_col_stats;
334
335        summary_col_stats.null_count = summary_col_stats.null_count.add(file_nc);
336        summary_col_stats.max_value = summary_col_stats.max_value.max(file_max);
337        summary_col_stats.min_value = summary_col_stats.min_value.min(file_min);
338        summary_col_stats.sum_value = summary_col_stats.sum_value.add_for_sum(file_sum);
339        summary_col_stats.byte_size = summary_col_stats.byte_size.add(file_sbs);
340    }
341}
342
343fn seed_first_file_statistics(
344    limit_num_rows: &mut Precision<usize>,
345    summary_statistics: &mut Statistics,
346    file_stats: &Statistics,
347    collect_stats: bool,
348) {
349    *limit_num_rows = file_stats.num_rows;
350
351    if collect_stats {
352        seed_summary_statistics(summary_statistics, file_stats);
353    }
354}
355
356fn merge_file_statistics(
357    limit_num_rows: &mut Precision<usize>,
358    summary_statistics: &mut Statistics,
359    file_stats: &Statistics,
360    collect_stats: bool,
361) {
362    *limit_num_rows = limit_num_rows.add(&file_stats.num_rows);
363
364    if collect_stats {
365        merge_summary_statistics(summary_statistics, file_stats);
366    }
367}
368
369/// Get all files as well as the file level summary statistics (no statistic for partition columns).
370/// If the optional `limit` is provided, includes only sufficient files. Needed to read up to
371/// `limit` number of rows. `collect_stats` is passed down from the configuration parameter on
372/// `ListingTable`. If it is false we only construct bare statistics and skip a potentially expensive
373///  call to `multiunzip` for constructing file level summary statistics.
374#[deprecated(
375    since = "47.0.0",
376    note = "Please use `get_files_with_limit` and  `compute_all_files_statistics` instead"
377)]
378#[cfg_attr(not(test), expect(unused))]
379pub async fn get_statistics_with_limit(
380    all_files: impl Stream<Item = Result<(PartitionedFile, Arc<Statistics>)>>,
381    file_schema: SchemaRef,
382    limit: Option<usize>,
383    collect_stats: bool,
384) -> Result<(FileGroup, Statistics)> {
385    let mut result_files = FileGroup::default();
386    // These statistics can be calculated as long as at least one file provides
387    // useful information. If none of the files provides any information, then
388    // they will end up having `Precision::Absent` values. Throughout calculations,
389    // missing values will be imputed as:
390    // - zero for summations, and
391    // - neutral element for extreme points.
392    let size = file_schema.fields().len();
393    let mut summary_statistics = Statistics {
394        num_rows: Precision::Absent,
395        total_byte_size: Precision::Absent,
396        column_statistics: vec![ColumnStatistics::default(); size],
397    };
398    // Keep limit pruning separate from the returned summary so `collect_stats=false`
399    // can still stop early using known file row counts.
400    let mut limit_num_rows = Precision::<usize>::Absent;
401
402    // Fusing the stream allows us to call next safely even once it is finished.
403    let mut all_files = Box::pin(all_files.fuse());
404
405    if let Some(first_file) = all_files.next().await {
406        let (mut file, file_stats) = first_file?;
407        file.statistics = Some(Arc::clone(&file_stats));
408        result_files.push(file);
409
410        seed_first_file_statistics(
411            &mut limit_num_rows,
412            &mut summary_statistics,
413            &file_stats,
414            collect_stats,
415        );
416
417        // If the number of rows exceeds the limit, we can stop processing
418        // files. This only applies when we know the number of rows. It also
419        // currently ignores tables that have no statistics regarding the
420        // number of rows.
421        let conservative_num_rows = match limit_num_rows {
422            Precision::Exact(nr) => nr,
423            _ => usize::MIN,
424        };
425        if conservative_num_rows <= limit.unwrap_or(usize::MAX) {
426            while let Some(current) = all_files.next().await {
427                let (mut file, file_stats) = current?;
428                file.statistics = Some(Arc::clone(&file_stats));
429                result_files.push(file);
430                merge_file_statistics(
431                    &mut limit_num_rows,
432                    &mut summary_statistics,
433                    &file_stats,
434                    collect_stats,
435                );
436
437                // If the number of rows exceeds the limit, we can stop processing
438                // files. This only applies when we know the number of rows. It also
439                // currently ignores tables that have no statistics regarding the
440                // number of rows.
441                if limit_num_rows.get_value().unwrap_or(&usize::MIN)
442                    > &limit.unwrap_or(usize::MAX)
443                {
444                    break;
445                }
446            }
447        }
448    };
449
450    let mut statistics = summary_statistics;
451    if all_files.next().await.is_some() {
452        // If we still have files in the stream, it means that the limit kicked
453        // in, and the statistic could have been different had we processed the
454        // files in a different order.
455        statistics = statistics.to_inexact()
456    }
457
458    Ok((result_files, statistics))
459}
460
461/// Computes the summary statistics for a group of files(`FileGroup` level's statistics).
462///
463/// This function combines statistics from all files in the file group to create
464/// summary statistics. It handles the following aspects:
465/// - Merges row counts and byte sizes across files
466/// - Computes column-level statistics like min/max values
467/// - Maintains appropriate precision information (exact, inexact, absent)
468///
469/// # Parameters
470/// * `file_group` - The group of files to process
471/// * `file_schema` - Schema of the files
472/// * `collect_stats` - Whether to collect statistics (if false, returns original file group)
473///
474/// # Returns
475/// A new file group with summary statistics attached
476#[expect(clippy::needless_pass_by_value)]
477pub fn compute_file_group_statistics(
478    file_group: FileGroup,
479    file_schema: SchemaRef,
480    collect_stats: bool,
481) -> Result<FileGroup> {
482    if !collect_stats {
483        return Ok(file_group);
484    }
485
486    let file_group_stats = file_group.iter().filter_map(|file| {
487        let stats = file.statistics.as_ref()?;
488        Some(stats.as_ref())
489    });
490    let statistics = Statistics::try_merge_iter_with_ndv_fallback(
491        file_group_stats,
492        &file_schema,
493        NdvFallback::Max,
494    )?;
495
496    Ok(file_group.with_statistics(Arc::new(statistics)))
497}
498
499/// Computes statistics for all files across multiple file groups.
500///
501/// This function:
502/// 1. Computes statistics for each individual file group
503/// 2. Summary statistics across all file groups
504/// 3. Optionally marks statistics as inexact
505///
506/// # Parameters
507/// * `file_groups` - Vector of file groups to process
508/// * `table_schema` - Schema of the table
509/// * `collect_stats` - Whether to collect statistics
510/// * `inexact_stats` - Whether to mark the resulting statistics as inexact
511///
512/// # Returns
513/// A tuple containing:
514/// * The processed file groups with their individual statistics attached
515/// * The summary statistics across all file groups, aka all files summary statistics
516#[expect(clippy::needless_pass_by_value)]
517pub fn compute_all_files_statistics(
518    file_groups: Vec<FileGroup>,
519    table_schema: SchemaRef,
520    collect_stats: bool,
521    inexact_stats: bool,
522) -> Result<(Vec<FileGroup>, Statistics)> {
523    let file_groups_with_stats = file_groups
524        .into_iter()
525        .map(|file_group| {
526            compute_file_group_statistics(
527                file_group,
528                Arc::clone(&table_schema),
529                collect_stats,
530            )
531        })
532        .collect::<Result<Vec<_>>>()?;
533
534    // Then summary statistics across all file groups
535    let file_groups_statistics = file_groups_with_stats
536        .iter()
537        .filter_map(|file_group| file_group.file_statistics(None));
538
539    let mut statistics = Statistics::try_merge_iter_with_ndv_fallback(
540        file_groups_statistics,
541        &table_schema,
542        NdvFallback::Max,
543    )?;
544
545    if inexact_stats {
546        statistics = statistics.to_inexact()
547    }
548
549    Ok((file_groups_with_stats, statistics))
550}
551
552#[deprecated(since = "47.0.0", note = "Use Statistics::add")]
553pub fn add_row_stats(
554    file_num_rows: Precision<usize>,
555    num_rows: Precision<usize>,
556) -> Precision<usize> {
557    file_num_rows.add(&num_rows)
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use crate::PartitionedFile;
564    use crate::file_groups::FileGroup;
565    use arrow::datatypes::{DataType, Field, Schema};
566    use futures::stream;
567
568    fn file_stats(sum: u32) -> Statistics {
569        Statistics {
570            num_rows: Precision::Exact(1),
571            total_byte_size: Precision::Exact(4),
572            column_statistics: vec![ColumnStatistics {
573                null_count: Precision::Exact(0),
574                max_value: Precision::Exact(ScalarValue::UInt32(Some(sum))),
575                min_value: Precision::Exact(ScalarValue::UInt32(Some(sum))),
576                sum_value: Precision::Exact(ScalarValue::UInt32(Some(sum))),
577                distinct_count: Precision::Exact(1),
578                byte_size: Precision::Exact(4),
579            }],
580        }
581    }
582
583    fn test_schema() -> SchemaRef {
584        Arc::new(Schema::new(vec![Field::new("a", DataType::Int64, true)]))
585    }
586
587    fn make_file_stats(
588        num_rows: usize,
589        total_byte_size: usize,
590        col_stats: ColumnStatistics,
591    ) -> Arc<Statistics> {
592        Arc::new(Statistics {
593            num_rows: Precision::Exact(num_rows),
594            total_byte_size: Precision::Exact(total_byte_size),
595            column_statistics: vec![col_stats],
596        })
597    }
598
599    fn rich_col_stats(
600        null_count: usize,
601        min: i64,
602        max: i64,
603        sum: i64,
604        byte_size: usize,
605    ) -> ColumnStatistics {
606        ColumnStatistics {
607            null_count: Precision::Exact(null_count),
608            max_value: Precision::Exact(ScalarValue::Int64(Some(max))),
609            min_value: Precision::Exact(ScalarValue::Int64(Some(min))),
610            distinct_count: Precision::Absent,
611            sum_value: Precision::Exact(ScalarValue::Int64(Some(sum))),
612            byte_size: Precision::Exact(byte_size),
613        }
614    }
615
616    fn utf8_file_stats(ndv: usize, min: &str, max: &str) -> Statistics {
617        Statistics {
618            num_rows: Precision::Exact(1),
619            total_byte_size: Precision::Exact(16),
620            column_statistics: vec![ColumnStatistics {
621                null_count: Precision::Exact(0),
622                max_value: Precision::Exact(ScalarValue::Utf8(Some(max.to_string()))),
623                min_value: Precision::Exact(ScalarValue::Utf8(Some(min.to_string()))),
624                sum_value: Precision::Absent,
625                distinct_count: Precision::Exact(ndv),
626                byte_size: Precision::Exact(16),
627            }],
628        }
629    }
630
631    fn file_with_stats(path: &str, stats: Statistics) -> PartitionedFile {
632        PartitionedFile::new(path, 1).with_statistics(Arc::new(stats))
633    }
634    #[tokio::test]
635    #[expect(deprecated)]
636    async fn test_get_statistics_with_limit_casts_first_file_sum_to_sum_type()
637    -> Result<()> {
638        let schema =
639            Arc::new(Schema::new(vec![Field::new("c1", DataType::UInt32, true)]));
640
641        let files = stream::iter(vec![Ok((
642            PartitionedFile::new("f1.parquet", 1),
643            Arc::new(file_stats(100)),
644        ))]);
645
646        let (_group, stats) =
647            get_statistics_with_limit(files, schema, None, true).await?;
648
649        assert_eq!(
650            stats.column_statistics[0].sum_value,
651            Precision::Exact(ScalarValue::UInt64(Some(100)))
652        );
653
654        Ok(())
655    }
656
657    #[tokio::test]
658    #[expect(deprecated)]
659    async fn test_get_statistics_with_limit_merges_sum_with_unsigned_widening()
660    -> Result<()> {
661        let schema =
662            Arc::new(Schema::new(vec![Field::new("c1", DataType::UInt32, true)]));
663
664        let files = stream::iter(vec![
665            Ok((
666                PartitionedFile::new("f1.parquet", 1),
667                Arc::new(file_stats(100)),
668            )),
669            Ok((
670                PartitionedFile::new("f2.parquet", 1),
671                Arc::new(file_stats(200)),
672            )),
673        ]);
674
675        let (_group, stats) =
676            get_statistics_with_limit(files, schema, None, true).await?;
677
678        assert_eq!(
679            stats.column_statistics[0].sum_value,
680            Precision::Exact(ScalarValue::UInt64(Some(300)))
681        );
682
683        Ok(())
684    }
685
686    #[tokio::test]
687    #[expect(deprecated)]
688    async fn get_statistics_with_limit_collect_stats_false_returns_bare_statistics() {
689        let all_files = stream::iter(vec![
690            Ok((
691                PartitionedFile::new("first.parquet", 10),
692                make_file_stats(0, 0, rich_col_stats(1, 1, 9, 15, 64)),
693            )),
694            Ok((
695                PartitionedFile::new("second.parquet", 20),
696                make_file_stats(10, 100, rich_col_stats(2, 10, 99, 300, 128)),
697            )),
698        ]);
699
700        let (_files, statistics) =
701            get_statistics_with_limit(all_files, test_schema(), None, false)
702                .await
703                .unwrap();
704
705        assert_eq!(statistics.num_rows, Precision::Absent);
706        assert_eq!(statistics.total_byte_size, Precision::Absent);
707        assert_eq!(statistics.column_statistics.len(), 1);
708        assert_eq!(
709            statistics.column_statistics[0].null_count,
710            Precision::Absent
711        );
712        assert_eq!(statistics.column_statistics[0].max_value, Precision::Absent);
713        assert_eq!(statistics.column_statistics[0].min_value, Precision::Absent);
714        assert_eq!(statistics.column_statistics[0].sum_value, Precision::Absent);
715        assert_eq!(statistics.column_statistics[0].byte_size, Precision::Absent);
716    }
717
718    #[tokio::test]
719    #[expect(deprecated)]
720    async fn get_statistics_with_limit_collect_stats_false_uses_row_counts_for_limit() {
721        let all_files = stream::iter(vec![
722            Ok((
723                PartitionedFile::new("first.parquet", 10),
724                make_file_stats(3, 30, rich_col_stats(1, 1, 9, 15, 64)),
725            )),
726            Ok((
727                PartitionedFile::new("second.parquet", 20),
728                make_file_stats(3, 30, rich_col_stats(2, 10, 99, 300, 128)),
729            )),
730            Ok((
731                PartitionedFile::new("third.parquet", 30),
732                make_file_stats(3, 30, rich_col_stats(0, 100, 199, 450, 256)),
733            )),
734        ]);
735
736        let (files, statistics) =
737            get_statistics_with_limit(all_files, test_schema(), Some(4), false)
738                .await
739                .unwrap();
740
741        assert_eq!(files.len(), 2);
742        assert_eq!(statistics.num_rows, Precision::Absent);
743        assert_eq!(statistics.total_byte_size, Precision::Absent);
744    }
745
746    #[tokio::test]
747    #[expect(deprecated)]
748    async fn get_statistics_with_limit_collect_stats_true_aggregates_statistics() {
749        let all_files = stream::iter(vec![
750            Ok((
751                PartitionedFile::new("first.parquet", 10),
752                make_file_stats(5, 50, rich_col_stats(1, 1, 9, 15, 64)),
753            )),
754            Ok((
755                PartitionedFile::new("second.parquet", 20),
756                make_file_stats(10, 100, rich_col_stats(2, 10, 99, 300, 128)),
757            )),
758        ]);
759
760        let (_files, statistics) =
761            get_statistics_with_limit(all_files, test_schema(), None, true)
762                .await
763                .unwrap();
764
765        assert_eq!(statistics.num_rows, Precision::Exact(15));
766        assert_eq!(statistics.total_byte_size, Precision::Exact(150));
767        assert_eq!(
768            statistics.column_statistics[0].null_count,
769            Precision::Exact(3)
770        );
771        assert_eq!(
772            statistics.column_statistics[0].min_value,
773            Precision::Exact(ScalarValue::Int64(Some(1)))
774        );
775        assert_eq!(
776            statistics.column_statistics[0].max_value,
777            Precision::Exact(ScalarValue::Int64(Some(99)))
778        );
779        assert_eq!(
780            statistics.column_statistics[0].sum_value,
781            Precision::Exact(ScalarValue::Int64(Some(315)))
782        );
783        assert_eq!(
784            statistics.column_statistics[0].byte_size,
785            Precision::Exact(192)
786        );
787    }
788
789    #[tokio::test]
790    #[expect(deprecated)]
791    async fn get_statistics_with_limit_collect_stats_true_limit_marks_inexact() {
792        let all_files = stream::iter(vec![
793            Ok((
794                PartitionedFile::new("first.parquet", 10),
795                make_file_stats(5, 50, rich_col_stats(0, 1, 5, 15, 64)),
796            )),
797            Ok((
798                PartitionedFile::new("second.parquet", 20),
799                make_file_stats(5, 50, rich_col_stats(1, 6, 10, 40, 64)),
800            )),
801            Ok((
802                PartitionedFile::new("third.parquet", 20),
803                make_file_stats(5, 50, rich_col_stats(2, 11, 15, 65, 64)),
804            )),
805        ]);
806
807        let (files, statistics) =
808            get_statistics_with_limit(all_files, test_schema(), Some(8), true)
809                .await
810                .unwrap();
811
812        assert_eq!(files.len(), 2);
813        assert_eq!(statistics.num_rows, Precision::Inexact(10));
814        assert_eq!(statistics.total_byte_size, Precision::Inexact(100));
815        assert_eq!(
816            statistics.column_statistics[0].min_value,
817            Precision::Inexact(ScalarValue::Int64(Some(1)))
818        );
819        assert_eq!(
820            statistics.column_statistics[0].max_value,
821            Precision::Inexact(ScalarValue::Int64(Some(10)))
822        );
823        assert_eq!(
824            statistics.column_statistics[0].sum_value,
825            Precision::Inexact(ScalarValue::Int64(Some(55)))
826        );
827        assert_eq!(
828            statistics.column_statistics[0].byte_size,
829            Precision::Inexact(128)
830        );
831    }
832
833    #[test]
834    fn test_compute_file_group_statistics_uses_max_ndv_fallback() -> Result<()> {
835        let schema = Arc::new(Schema::new(vec![Field::new("c1", DataType::Utf8, true)]));
836        let file_group = FileGroup::new(vec![
837            file_with_stats("f1.parquet", utf8_file_stats(5, "a", "x")),
838            file_with_stats("f2.parquet", utf8_file_stats(8, "b", "z")),
839        ]);
840
841        let file_group =
842            compute_file_group_statistics(file_group, Arc::clone(&schema), true)?;
843        let stats = file_group.file_statistics(None).unwrap();
844
845        assert_eq!(
846            stats.column_statistics[0].distinct_count,
847            Precision::Inexact(8)
848        );
849        assert_eq!(
850            stats.column_statistics[0].min_value,
851            Precision::Exact(ScalarValue::Utf8(Some("a".to_string())))
852        );
853        assert_eq!(
854            stats.column_statistics[0].max_value,
855            Precision::Exact(ScalarValue::Utf8(Some("z".to_string())))
856        );
857
858        Ok(())
859    }
860
861    #[test]
862    fn test_compute_all_files_statistics_uses_max_ndv_fallback() -> Result<()> {
863        let schema = Arc::new(Schema::new(vec![Field::new("c1", DataType::Utf8, true)]));
864        let file_groups = vec![
865            FileGroup::new(vec![
866                file_with_stats("f1.parquet", utf8_file_stats(5, "a", "x")),
867                file_with_stats("f2.parquet", utf8_file_stats(8, "b", "z")),
868            ]),
869            FileGroup::new(vec![
870                file_with_stats("f3.parquet", utf8_file_stats(3, "c", "w")),
871                file_with_stats("f4.parquet", utf8_file_stats(6, "d", "y")),
872            ]),
873        ];
874
875        let (file_groups, stats) =
876            compute_all_files_statistics(file_groups, schema, true, false)?;
877
878        assert_eq!(
879            file_groups[0]
880                .file_statistics(None)
881                .unwrap()
882                .column_statistics[0]
883                .distinct_count,
884            Precision::Inexact(8)
885        );
886        assert_eq!(
887            file_groups[1]
888                .file_statistics(None)
889                .unwrap()
890                .column_statistics[0]
891                .distinct_count,
892            Precision::Inexact(6)
893        );
894        assert_eq!(
895            stats.column_statistics[0].distinct_count,
896            Precision::Inexact(8)
897        );
898
899        Ok(())
900    }
901
902    #[test]
903    fn min_max_statistics_missing_column_stats_returns_error() {
904        let schema = test_schema();
905        let sort_order =
906            [PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)))].into();
907        let files = [
908            file_with_stats("f1.parquet", Statistics::default()),
909            file_with_stats("f2.parquet", Statistics::default()),
910        ];
911
912        let err = match MinMaxStatistics::new_from_files(
913            &sort_order,
914            &schema,
915            None,
916            files.iter(),
917        ) {
918            Ok(_) => panic!("expected missing statistics error"),
919            Err(err) => err,
920        };
921
922        assert!(
923            err.to_string()
924                .contains("statistics not found for partition"),
925            "unexpected error: {err:?}"
926        );
927    }
928}