Skip to content

Commit 74f11ca

Browse files
adriangbclaude
andcommitted
test(pruning): per-conjunct pruning statistics
Cover conjunct attribution for an `AND` of several conjuncts, a conjunct that statistics can not prune, a dynamic filter conjunct, an `OR` and a `NOT` that stay one conjunct, literal guarantees, and conjunct order. Each test also checks that the pruning result is the same as the result of a predicate built without conjunct stats. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ca24412 commit 74f11ca

1 file changed

Lines changed: 264 additions & 0 deletions

File tree

‎datafusion/pruning/src/pruning_predicate/conjunct_stats.rs‎

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,267 @@ impl<S: PruningStatistics + ?Sized> LazyStatisticsBatch<'_, S> {
187187
})
188188
}
189189
}
190+
191+
#[cfg(test)]
192+
mod tests {
193+
use std::collections::{HashMap, HashSet};
194+
195+
use arrow::array::{ArrayRef, BooleanArray, Int32Array};
196+
use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
197+
use datafusion_common::{Column, ScalarValue};
198+
use datafusion_expr::{Expr, col, lit};
199+
use datafusion_physical_expr::expressions::DynamicFilterPhysicalExpr;
200+
use datafusion_physical_expr::planner::logical2physical;
201+
use datafusion_physical_expr::utils::collect_columns;
202+
203+
use super::*;
204+
use crate::PruningPredicateBuilder;
205+
206+
/// Int32 min/max statistics and `contained` results per column.
207+
#[derive(Default)]
208+
struct TestStats {
209+
num_containers: usize,
210+
min_max: HashMap<String, (ArrayRef, ArrayRef)>,
211+
/// Returned by `contained` for any set of values
212+
contained: HashMap<String, BooleanArray>,
213+
}
214+
215+
impl TestStats {
216+
fn new(num_containers: usize) -> Self {
217+
Self {
218+
num_containers,
219+
..Default::default()
220+
}
221+
}
222+
223+
fn with_min_max(mut self, column: &str, min: &[i32], max: &[i32]) -> Self {
224+
assert_eq!(min.len(), self.num_containers);
225+
assert_eq!(max.len(), self.num_containers);
226+
let min = Arc::new(Int32Array::from(min.to_vec())) as ArrayRef;
227+
let max = Arc::new(Int32Array::from(max.to_vec())) as ArrayRef;
228+
self.min_max.insert(column.to_string(), (min, max));
229+
self
230+
}
231+
232+
fn with_contained(mut self, column: &str, contained: &[bool]) -> Self {
233+
assert_eq!(contained.len(), self.num_containers);
234+
self.contained
235+
.insert(column.to_string(), BooleanArray::from(contained.to_vec()));
236+
self
237+
}
238+
}
239+
240+
impl PruningStatistics for TestStats {
241+
fn min_values(&self, column: &Column) -> Option<ArrayRef> {
242+
self.min_max
243+
.get(column.name())
244+
.map(|(min, _)| Arc::clone(min))
245+
}
246+
247+
fn max_values(&self, column: &Column) -> Option<ArrayRef> {
248+
self.min_max
249+
.get(column.name())
250+
.map(|(_, max)| Arc::clone(max))
251+
}
252+
253+
fn num_containers(&self) -> usize {
254+
self.num_containers
255+
}
256+
257+
fn null_counts(&self, _column: &Column) -> Option<ArrayRef> {
258+
None
259+
}
260+
261+
fn row_counts(&self) -> Option<ArrayRef> {
262+
None
263+
}
264+
265+
fn contained(
266+
&self,
267+
column: &Column,
268+
_values: &HashSet<ScalarValue>,
269+
) -> Option<BooleanArray> {
270+
self.contained.get(column.name()).cloned()
271+
}
272+
}
273+
274+
fn schema() -> SchemaRef {
275+
Arc::new(Schema::new(vec![
276+
Field::new("a", DataType::Int32, true),
277+
Field::new("b", DataType::Int32, true),
278+
]))
279+
}
280+
281+
/// Three containers:
282+
///
283+
/// | container | a | b |
284+
/// |-----------|------------|----------|
285+
/// | 0 | 0..=3 | 0..=1 |
286+
/// | 1 | 6..=10 | 20..=30 |
287+
/// | 2 | 200..=300 | 0..=1 |
288+
fn stats() -> TestStats {
289+
TestStats::new(3)
290+
.with_min_max("a", &[0, 6, 200], &[3, 10, 300])
291+
.with_min_max("b", &[0, 20, 0], &[1, 30, 1])
292+
}
293+
294+
fn stats_of(pruned: usize, kept: usize) -> ConjunctPruningStats {
295+
ConjunctPruningStats {
296+
containers_pruned: pruned,
297+
containers_kept: kept,
298+
}
299+
}
300+
301+
fn physical(expr: &Expr) -> Arc<dyn PhysicalExpr> {
302+
logical2physical(expr, &schema())
303+
}
304+
305+
/// Prunes with per-conjunct stats, and checks that the pruning result is
306+
/// the same as the result of a predicate built without conjunct stats.
307+
fn prune_with_conjunct_stats(
308+
predicate: Arc<dyn PhysicalExpr>,
309+
statistics: &TestStats,
310+
) -> (Vec<bool>, Vec<ConjunctPruningStats>) {
311+
let plain = PruningPredicateBuilder::new()
312+
.with_file_schema(schema())
313+
.try_build(Arc::clone(&predicate))
314+
.unwrap();
315+
let with_stats = PruningPredicateBuilder::new()
316+
.with_file_schema(schema())
317+
.with_conjunct_stats(true)
318+
.try_build(predicate)
319+
.unwrap();
320+
321+
let (result, conjunct_stats) =
322+
with_stats.prune_with_conjunct_stats(statistics).unwrap();
323+
let expected = plain.prune(statistics).unwrap();
324+
assert_eq!(
325+
result, expected,
326+
"result differs from PruningPredicate::prune"
327+
);
328+
assert_eq!(with_stats.prune(statistics).unwrap(), expected);
329+
for stats in &conjunct_stats {
330+
assert_eq!(
331+
stats.containers_pruned + stats.containers_kept,
332+
statistics.num_containers()
333+
);
334+
}
335+
(result, conjunct_stats)
336+
}
337+
338+
#[test]
339+
fn and_of_conjuncts() {
340+
// Each conjunct prunes a different container.
341+
let predicate = col("a")
342+
.gt(lit(5))
343+
.and(col("b").lt(lit(10)))
344+
.and(col("a").lt(lit(100)));
345+
let (result, conjunct_stats) =
346+
prune_with_conjunct_stats(physical(&predicate), &stats());
347+
assert_eq!(result, vec![false, false, false]);
348+
assert_eq!(
349+
conjunct_stats,
350+
vec![stats_of(1, 2), stats_of(1, 2), stats_of(1, 2)]
351+
);
352+
}
353+
354+
#[test]
355+
fn stats_do_not_depend_on_conjunct_order() {
356+
// `a > 1000` prunes all containers. The other conjuncts are still
357+
// evaluated on all containers.
358+
let predicate = col("a")
359+
.gt(lit(1000))
360+
.and(col("b").lt(lit(10)))
361+
.and(col("a").gt(lit(5)));
362+
let (result, conjunct_stats) =
363+
prune_with_conjunct_stats(physical(&predicate), &stats());
364+
assert_eq!(result, vec![false, false, false]);
365+
assert_eq!(
366+
conjunct_stats,
367+
vec![stats_of(3, 0), stats_of(1, 2), stats_of(1, 2)]
368+
);
369+
}
370+
371+
#[test]
372+
fn single_conjunct() {
373+
let (result, conjunct_stats) =
374+
prune_with_conjunct_stats(physical(&col("a").gt(lit(5))), &stats());
375+
assert_eq!(result, vec![false, true, true]);
376+
assert_eq!(conjunct_stats, vec![stats_of(1, 2)]);
377+
}
378+
379+
#[test]
380+
fn conjunct_that_can_not_be_rewritten_keeps_all_containers() {
381+
// `a + b = 3` refers to two columns, so statistics can not prune it.
382+
let predicate = (col("a") + col("b")).eq(lit(3)).and(col("a").gt(lit(5)));
383+
let (result, conjunct_stats) =
384+
prune_with_conjunct_stats(physical(&predicate), &stats());
385+
assert_eq!(result, vec![false, true, true]);
386+
assert_eq!(conjunct_stats, vec![stats_of(0, 3), stats_of(1, 2)]);
387+
}
388+
389+
#[test]
390+
fn or_and_not_are_one_conjunct() {
391+
// The `OR` is not split, and the `AND` inside the `NOT` is not split.
392+
let predicate =
393+
col("a")
394+
.lt(lit(3))
395+
.or(col("a").gt(lit(250)))
396+
.and(Expr::Not(Box::new(
397+
col("a").eq(lit(1)).and(col("b").eq(lit(1))),
398+
)));
399+
let (result, conjunct_stats) =
400+
prune_with_conjunct_stats(physical(&predicate), &stats());
401+
assert_eq!(result, vec![true, false, true]);
402+
assert_eq!(conjunct_stats, vec![stats_of(1, 2), stats_of(0, 3)]);
403+
}
404+
405+
#[test]
406+
fn dynamic_filter_is_one_conjunct() {
407+
// The dynamic filter contains an `AND`, but it is one conjunct.
408+
let inner = physical(&col("a").gt(lit(5)).and(col("b").lt(lit(10))));
409+
let children = collect_columns(&inner)
410+
.into_iter()
411+
.map(|c| Arc::new(c) as Arc<dyn PhysicalExpr>)
412+
.collect();
413+
let dynamic_filter = Arc::new(DynamicFilterPhysicalExpr::new(children, inner))
414+
as Arc<dyn PhysicalExpr>;
415+
let predicate = datafusion_physical_expr::conjunction([
416+
physical(&col("a").lt(lit(100))),
417+
dynamic_filter,
418+
]);
419+
420+
let (result, conjunct_stats) = prune_with_conjunct_stats(predicate, &stats());
421+
assert_eq!(result, vec![false, false, false]);
422+
// Container 0 fails `a > 5` and container 1 fails `b < 10`.
423+
assert_eq!(conjunct_stats, vec![stats_of(1, 2), stats_of(2, 1)]);
424+
}
425+
426+
#[test]
427+
fn literal_guarantees_are_attributed_per_conjunct() {
428+
// No min/max statistics, only `contained` (for example Bloom filters).
429+
let statistics = TestStats::new(3)
430+
.with_contained("a", &[false, true, true])
431+
.with_contained("b", &[true, true, false]);
432+
let predicate = col("a")
433+
.in_list(vec![lit(7), lit(8)], false)
434+
.and(col("b").eq(lit(1)));
435+
let (result, conjunct_stats) =
436+
prune_with_conjunct_stats(physical(&predicate), &statistics);
437+
assert_eq!(result, vec![false, true, false]);
438+
assert_eq!(conjunct_stats, vec![stats_of(1, 2), stats_of(1, 2)]);
439+
}
440+
441+
#[test]
442+
fn error_without_conjunct_stats() {
443+
let predicate = PruningPredicateBuilder::new()
444+
.with_file_schema(schema())
445+
.try_build(physical(&col("a").gt(lit(5))))
446+
.unwrap();
447+
let err = predicate.prune_with_conjunct_stats(&stats()).unwrap_err();
448+
assert!(
449+
err.to_string().contains("with_conjunct_stats"),
450+
"unexpected error: {err}"
451+
);
452+
}
453+
}

0 commit comments

Comments
 (0)