jayzhan211 commented on code in PR #26441:
URL: https://github.com/apache/datafusion/pull/26441#discussion_r4504870290
##########
datafusion/catalog-listing/src/table.rs:
##########
@@ -1431,4 +1534,137 @@ mod tests {
let result = derive_common_ordering_from_files(&file_groups);
assert_eq!(result, Some(ordering));
}
+
+ fn prune_test_files(expr: Expr, statistics: Vec<Option<Statistics>>) ->
Vec<String> {
+ use arrow::datatypes::DataType;
+ let schema = Arc::new(Schema::new(vec![Field::new("d",
DataType::Int64, true)]));
+ let physical = datafusion_physical_expr::create_physical_expr(
+ &expr,
+ &DFSchema::try_from(schema.as_ref().clone()).unwrap(),
+ &ExecutionProps::new(),
+ &PhysicalPlanningContext::default(),
+ )
+ .unwrap();
+ let predicate = PruningPredicateBuilder::new()
+ .with_file_schema(Arc::clone(&schema))
+ .try_build(physical)
+ .unwrap();
+ let files = FileGroup::new(
+ statistics
+ .into_iter()
+ .enumerate()
+ .map(|(i, stats)| {
+ let mut file = PartitionedFile::new(format!("f{i}"), 10);
+ file.statistics = stats.map(Arc::new);
+ file
+ })
+ .collect(),
+ );
+ prune_collected_files(&predicate, &schema, files)
+ .iter()
+ .map(|f| f.object_meta.location.to_string())
+ .collect()
+ }
+
+ fn bounds(
+ min: Precision<datafusion_common::ScalarValue>,
+ max: Precision<datafusion_common::ScalarValue>,
+ ) -> Statistics {
+ Statistics {
+ num_rows: Precision::Exact(10),
+ column_statistics: vec![datafusion_common::ColumnStatistics {
+ min_value: min,
+ max_value: max,
+ ..Default::default()
+ }],
+ ..Default::default()
+ }
+ }
+
+ #[test]
+ fn statistics_pruning_keeps_missing_and_inexact_bounds() {
+ use datafusion_expr::{col, lit};
+ let exact = |v: i64| Precision::Exact(v.into());
+ let inexact = |v: i64| Precision::Inexact(v.into());
+ assert_eq!(
+ prune_test_files(
+ col("d").gt_eq(lit(20_i64)),
+ vec![
+ None,
+ Some(bounds(Precision::Absent, Precision::Absent)),
+ Some(bounds(inexact(0), inexact(9))),
+ Some(bounds(exact(0), exact(9))),
+ Some(bounds(exact(20), exact(29)))
+ ]
+ ),
+ ["f0", "f1", "f2", "f4"]
+ );
+ }
+
+ #[test]
+ fn statistics_pruning_uses_independent_bounds() {
+ use datafusion_expr::{col, lit};
+ assert_eq!(
+ prune_test_files(
+ col("d").lt(lit(20_i64)),
+ vec![
+ Some(bounds(Precision::Exact(30_i64.into()),
Precision::Absent)),
+ Some(bounds(Precision::Absent,
Precision::Exact(10_i64.into())))
+ ]
+ ),
+ ["f1"]
+ );
+ }
+
+ #[test]
+ fn statistics_pruning_preserves_null_semantics() {
+ use datafusion_expr::{col, lit};
+ let mut all_null = bounds(Precision::Absent, Precision::Absent);
+ all_null.column_statistics[0].null_count = Precision::Exact(10);
+ let mut no_null = bounds(
+ Precision::Exact(0_i64.into()),
+ Precision::Exact(9_i64.into()),
+ );
+ no_null.column_statistics[0].null_count = Precision::Exact(0);
+ assert_eq!(
+ prune_test_files(
+ col("d").is_null(),
+ vec![Some(all_null.clone()), Some(no_null.clone())]
+ ),
+ ["f0"]
+ );
+ assert_eq!(
+ prune_test_files(
+ col("d").is_not_null(),
+ vec![Some(all_null.clone()), Some(no_null.clone())]
+ ),
+ ["f1"]
+ );
+ assert!(
+ prune_test_files(col("d").eq(lit(5_i64)),
vec![Some(all_null)]).is_empty()
+ );
+ assert_eq!(
+ prune_test_files(
+ col("d").eq(lit(5_i64)).or(col("d").is_null()),
+ vec![Some(no_null)]
+ ),
+ ["f0"]
+ );
+ }
+
+ #[test]
+ fn statistics_pruning_keeps_file_on_evaluation_error() {
+ use datafusion_expr::{col, lit};
+ // A malformed statistics type cannot establish that the file is empty.
+ assert_eq!(
+ prune_test_files(
+ col("d").gt(lit(10_i64)),
+ vec![Some(bounds(
+ Precision::Exact("invalid".into()),
+ Precision::Exact("invalid".into())
+ ))]
+ ),
Review Comment:
Optional cleanup: this test doesn't reach the error branch yet. A Utf8 bound
doesn't make `prune` fail, so the test still passes if `.unwrap_or(true)`
becomes `.unwrap_or(false)`. Binary bounds can't be cast to Int64, so they do
error, and the test then fails with `unwrap_or(false)`:
```suggestion
use datafusion_common::ScalarValue;
use datafusion_expr::{col, lit};
// Binary bounds cannot be cast to Int64, so `prune` returns an
error.
// A Utf8 bound does not error, so it would not reach that branch.
let binary = || Precision::Exact(ScalarValue::Binary(Some(vec![1])));
assert_eq!(
prune_test_files(
col("d").gt(lit(10_i64)),
vec![Some(bounds(binary(), binary()))]
),
```
##########
datafusion/catalog-listing/src/table.rs:
##########
@@ -1028,17 +1090,30 @@ impl ListingTable {
let (grouped, split_points) = file_group
.group_by_partition_values_with_split_points(file_group_count);
if grouped.len() >= threshold {
+ // Preserve the groups and split points derived from all
files so
+ // pruning cannot change the declared range partitioning.
+ let grouped = grouped
+ .into_iter()
+ .map(|group| {
+ self.prune_files_by_statistics(
+ pruning_predicate.as_deref(),
+ group,
+ )
+ })
+ .collect();
(grouped, true, split_points)
} else {
let all_files: Vec<_> =
grouped.into_iter().flat_map(|g|
g.into_inner()).collect();
- (
-
FileGroup::new(all_files).split_files(file_group_count),
- false,
- vec![],
- )
+ let file_group = self.prune_files_by_statistics(
+ pruning_predicate.as_deref(),
+ FileGroup::new(all_files),
+ );
Review Comment:
Optional: no test covers this branch, where `preserve_file_partitions` is
above the number of distinct partition values. If the prune here is removed,
every test still passes. With two `p` values and a threshold of 3, the test
below expects `[[f2], [f3]]`. It passes on this PR and fails when the prune is
removed.
<details><summary>Test for <code>file_statistics_pruning.rs</code></summary>
```rs
#[tokio::test]
async fn preserve_partitions_below_threshold_still_prunes() -> Result<()> {
let dir = fixture(
(0..8)
.map(|i| (i * 10..i * 10 + 10).map(Some).collect())
.collect(),
true,
true,
);
let mut config = SessionConfig::new()
.with_target_partitions(4)
.with_collect_statistics(true);
// Two distinct `p` values are below the threshold, so files are split
normally.
config.options_mut().optimizer.preserve_file_partitions = 3;
let ctx = SessionContext::new_with_config(config);
let table = table(&dir, None, true, false);
let plan = table.scan(&ctx.state(), None, &[predicate()], None).await?;
assert_eq!(
names(scan_config(&plan)),
vec![vec!["f2.parquet"], vec!["f3.parquet"]]
);
Ok(())
}
```
</details>
##########
datafusion/catalog-listing/src/table.rs:
##########
@@ -1270,6 +1345,34 @@ async fn get_files_with_limit(
Ok((file_group, inexact_stats))
}
+/// Keep a file unless the pruning predicate proves it cannot contain a match.
+fn prune_collected_files(
+ predicate: &datafusion_pruning::PruningPredicate,
+ file_schema: &SchemaRef,
+ files: FileGroup,
+) -> FileGroup {
+ FileGroup::new(
+ files
+ .into_inner()
+ .into_iter()
+ .filter(|file| {
+ let Some(statistics) = &file.statistics else {
+ return true;
+ };
Review Comment:
Optional: files whose statistics are all `Absent` can't be pruned, but each
one still gets a `PrunableStatistics` and a `prune` call. That covers every
file of a CSV or JSON table, whose `infer_stats` returns unknown statistics.
`FilePruner::try_new` skips these files with the same check:
```suggestion
// Same check as `FilePruner::try_new`: all-`Absent`
statistics
// cannot prune, so skip building `PrunableStatistics` for
them.
if !file.has_statistics() {
return true;
}
let Some(statistics) = &file.statistics else {
return true;
};
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]