Skip to main content

datafusion_pruning/
file_pruner.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//! File-level pruning based on partition values and file-level statistics
19
20use std::sync::Arc;
21
22use arrow::datatypes::{FieldRef, SchemaRef};
23use datafusion_common::{Result, internal_datafusion_err, pruning::PrunableStatistics};
24use datafusion_datasource::PartitionedFile;
25use datafusion_physical_expr::DynamicFilterTracking;
26use datafusion_physical_expr_common::physical_expr::PhysicalExpr;
27use datafusion_physical_plan::metrics::Count;
28use log::debug;
29
30use crate::build_pruning_predicate;
31
32/// Prune based on file-level statistics.
33///
34/// Note: Partition column pruning is handled earlier via `replace_columns_with_literals`
35/// which substitutes partition column references with their literal values before
36/// the predicate reaches this pruner.
37pub struct FilePruner {
38    predicate: Arc<dyn PhysicalExpr>,
39    /// Tracks the dynamic filters inside `predicate` so we only rebuild the
40    /// pruning predicate when one of them has actually moved.
41    tracking: DynamicFilterTracking,
42    /// Whether [`Self::should_prune`] has built+evaluated the pruning predicate
43    /// at least once. The first check always runs; subsequent checks only run
44    /// when a watched dynamic filter changed.
45    checked_once: bool,
46    /// Schema used for pruning (the logical file schema).
47    file_schema: SchemaRef,
48    file_stats_pruning: PrunableStatistics,
49    predicate_creation_errors: Count,
50}
51
52impl FilePruner {
53    #[deprecated(
54        since = "52.0.0",
55        note = "Use `try_new` instead which returns None if no statistics are available"
56    )]
57    #[expect(clippy::needless_pass_by_value)]
58    pub fn new(
59        predicate: Arc<dyn PhysicalExpr>,
60        logical_file_schema: &SchemaRef,
61        _partition_fields: Vec<FieldRef>,
62        partitioned_file: PartitionedFile,
63        predicate_creation_errors: Count,
64    ) -> Result<Self> {
65        Self::try_new(
66            predicate,
67            logical_file_schema,
68            &partitioned_file,
69            predicate_creation_errors,
70        )
71        .ok_or_else(|| {
72            internal_datafusion_err!(
73                "FilePruner::new called on a file without statistics: {:?}",
74                partitioned_file
75            )
76        })
77    }
78
79    /// Create a file pruner for this file, or `None` when pruning it cannot
80    /// help.
81    ///
82    /// Returns `None` when the file has no statistics struct to evaluate a
83    /// pruning predicate against, or when the predicate is purely static and the
84    /// file has no usable column statistics — in that case planning already did
85    /// everything such a pruner could. A predicate carrying a dynamic filter is
86    /// always accepted (given a statistics struct), since it may prune via
87    /// partition-value folding even without column statistics.
88    pub fn try_new(
89        predicate: Arc<dyn PhysicalExpr>,
90        file_schema: &SchemaRef,
91        partitioned_file: &PartitionedFile,
92        predicate_creation_errors: Count,
93    ) -> Option<Self> {
94        // A pruning predicate is evaluated against a statistics struct, so one
95        // must exist (its columns may all be `Absent`).
96        let file_stats = partitioned_file.statistics.as_ref()?;
97        let tracking = DynamicFilterTracking::classify(&predicate);
98        // Only build a pruner when it could prune something planning didn't
99        // already: the file has real column statistics, or the predicate carries
100        // a dynamic filter (whose value, or folded partition columns, can prune
101        // even without column statistics). For a purely static predicate with no
102        // usable stats there is nothing to gain.
103        if !partitioned_file.has_statistics() && !tracking.contains_dynamic_filter() {
104            return None;
105        }
106        let file_stats_pruning =
107            PrunableStatistics::new(vec![file_stats.clone()], Arc::clone(file_schema));
108        Some(Self {
109            predicate,
110            tracking,
111            checked_once: false,
112            file_schema: Arc::clone(file_schema),
113            file_stats_pruning,
114            predicate_creation_errors,
115        })
116    }
117
118    /// Returns `true` if this pruner watches a dynamic filter that can still
119    /// change, meaning [`Self::should_prune`] is worth re-checking as the scan
120    /// progresses. When `false`, the predicate is effectively static for the
121    /// remainder of the scan and the caller can avoid wrapping the stream in a
122    /// per-batch re-pruning adapter.
123    pub fn is_watching(&self) -> bool {
124        matches!(self.tracking, DynamicFilterTracking::Watching(_))
125    }
126
127    pub fn should_prune(&mut self) -> Result<bool> {
128        // Building the pruning predicate is expensive (it involves expression
129        // analysis), so we only do it on the first check and whenever a dynamic
130        // filter inside the predicate has actually moved.
131        //
132        // Dynamic filter expressions can change their values during query
133        // execution; `DynamicFilterTracking` watches the still-incomplete
134        // filters and reports a change at most once per update. A purely static
135        // predicate (or one whose dynamic filters have all completed) is checked
136        // exactly once.
137        let should_build = if self.checked_once {
138            self.tracking.watcher().is_some_and(|w| w.changed())
139        } else {
140            self.checked_once = true;
141            true
142        };
143        if !should_build {
144            return Ok(false);
145        }
146        let pruning_predicate = build_pruning_predicate(
147            Arc::clone(&self.predicate),
148            &self.file_schema,
149            &self.predicate_creation_errors,
150        );
151        let Some(pruning_predicate) = pruning_predicate else {
152            return Ok(false);
153        };
154        match pruning_predicate.prune(&self.file_stats_pruning) {
155            Ok(values) => {
156                assert!(values.len() == 1);
157                // We expect a single container -> if all containers are false skip this file
158                if values.into_iter().all(|v| !v) {
159                    return Ok(true);
160                }
161            }
162            // Stats filter array could not be built, so we can't prune
163            Err(e) => {
164                debug!("Ignoring error building pruning predicate for file: {e}");
165                self.predicate_creation_errors.add(1);
166            }
167        }
168
169        Ok(false)
170    }
171}