Skip to content

Commit 2f25454

Browse files
authored
fix: preserve aggregate filter pushdown order (#22926)
## Which issue does this PR close? - Closes #22925. ## Rationale for this change `FilterPushdown` maps each child pushdown result back to its original parent filter by position. `AggregateExec::gather_filters_for_pushdown` previously split parent filters into safe and unsafe buckets and then concatenated the results, which changed their order. For example, in `cnt@2 = 1 AND b@1 = bar`, the grouping-column predicate on `b` can cross the aggregate, while the predicate on aggregate output `cnt` must remain above it. Reordering the returned results could make the optimizer associate the pushed-down `b` result with the `cnt` predicate and incorrectly remove the latter. Aggregate pushdown also needs to account for empty-input semantics. A global aggregate, or a grouping-sets aggregate containing `()`, can emit a row even when its input is empty. Moving any parent predicate below such an aggregate — including a column-free predicate such as `false` — can therefore change the result. ## What changes are included in this PR? - Preserve parent-filter order by constructing each child description once with `ChildFilterDescription::from_child_with_allowed_indices`. - Allow only grouping-output columns that are present in every grouping set; aggregate-result columns remain above the aggregate. - Mark all parent filters unsupported for global aggregates and grouping sets containing an empty grouping set, while retaining aggregate-generated dynamic filters. - Validate that every child returns one parent-filter result per input filter before positional remapping. - Add physical optimizer and SQL regression coverage for mixed filter order, global-aggregate name collisions, constant predicates, and grouping sets. ## Are these changes tested? Yes: - `cargo test -p datafusion --test core_integration physical_optimizer::filter_pushdown` - `cargo fmt --all -- --check` - `cargo check -p datafusion-physical-plan -p datafusion-physical-optimizer` - `git diff --check upstream/main` - Full GitHub CI, including Rust tests, clippy, and sqllogictests ## Are there any user-facing changes? There are no public API changes. Filter pushdown now preserves mixed aggregate predicates correctly and avoids moving predicates across aggregates when doing so could change empty-input results.
1 parent 2fb472a commit 2f25454

6 files changed

Lines changed: 253 additions & 91 deletions

File tree

datafusion/core/tests/physical_optimizer/filter_pushdown.rs

Lines changed: 171 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,13 @@ use datafusion_datasource::{
4141
use datafusion_execution::object_store::ObjectStoreUrl;
4242
use datafusion_expr::ScalarUDF;
4343
use datafusion_functions::math::random::RandomFunc;
44-
use datafusion_functions_aggregate::{count::count_udaf, min_max::min_udaf};
45-
use datafusion_physical_expr::{LexOrdering, PhysicalSortExpr, expressions::col};
44+
use datafusion_functions_aggregate::{
45+
count::count_udaf,
46+
min_max::{max_udaf, min_udaf},
47+
};
48+
use datafusion_physical_expr::{
49+
LexOrdering, PhysicalSortExpr, expressions::col, utils::conjunction,
50+
};
4651
use datafusion_physical_expr::{
4752
Partitioning, ScalarFunctionExpr, aggregate::AggregateExprBuilder,
4853
};
@@ -738,6 +743,65 @@ fn test_pushdown_through_aggregates_on_grouping_columns() {
738743
);
739744
}
740745

746+
#[test]
747+
fn test_pushdown_through_aggregates_preserves_parent_filter_order() {
748+
// AggregateExec may push filters on grouping columns to its input, but must
749+
// keep filters on aggregate outputs above itself. The parent-filter result
750+
// order must match the incoming filter order, otherwise an unsupported
751+
// aggregate-output filter can be reported as pushed down and removed.
752+
let scan = TestScanBuilder::new(schema()).with_support(true).build();
753+
754+
let aggregate_expr = vec![
755+
AggregateExprBuilder::new(count_udaf(), vec![col("a", &schema()).unwrap()])
756+
.schema(schema())
757+
.alias("cnt")
758+
.build()
759+
.map(Arc::new)
760+
.unwrap(),
761+
];
762+
let group_by = PhysicalGroupBy::new_single(vec![
763+
(col("a", &schema()).unwrap(), "a".to_string()),
764+
(col("b", &schema()).unwrap(), "b".to_string()),
765+
]);
766+
let aggregate = Arc::new(
767+
AggregateExec::try_new(
768+
AggregateMode::Final,
769+
group_by,
770+
aggregate_expr,
771+
vec![None],
772+
scan,
773+
schema(),
774+
)
775+
.unwrap(),
776+
);
777+
778+
let aggregate_schema = aggregate.schema();
779+
let aggregate_output_filter = col_lit_predicate(
780+
"cnt",
781+
ScalarValue::Int64(Some(1)),
782+
aggregate_schema.as_ref(),
783+
);
784+
let grouping_key_filter = col_lit_predicate("b", "bar", aggregate_schema.as_ref());
785+
let predicate = conjunction(vec![aggregate_output_filter, grouping_key_filter]);
786+
let plan = Arc::new(FilterExec::try_new(predicate, aggregate).unwrap());
787+
788+
insta::assert_snapshot!(
789+
OptimizationTest::new(plan, FilterPushdown::new(), true),
790+
@r"
791+
OptimizationTest:
792+
input:
793+
- FilterExec: cnt@2 = 1 AND b@1 = bar
794+
- AggregateExec: mode=Final, gby=[a@0 as a, b@1 as b], aggr=[cnt]
795+
- DataSourceExec: file_groups={1 group: [[test.parquet]]}, projection=[a, b, c], file_type=test, pushdown_supported=true
796+
output:
797+
Ok:
798+
- FilterExec: cnt@2 = 1
799+
- AggregateExec: mode=Final, gby=[a@0 as a, b@1 as b], aggr=[cnt], ordering_mode=PartiallySorted([1])
800+
- DataSourceExec: file_groups={1 group: [[test.parquet]]}, projection=[a, b, c], file_type=test, pushdown_supported=true, predicate=b@1 = bar
801+
"
802+
);
803+
}
804+
741805
/// Test various combinations of handling of child pushdown results
742806
/// in an ExecutionPlan in combination with support/not support in a DataSource.
743807
#[test]
@@ -1753,6 +1817,16 @@ fn col_lit_predicate(
17531817
))
17541818
}
17551819

1820+
fn assert_parent_filter_remains_above_aggregate(plan: Arc<dyn ExecutionPlan>) {
1821+
let mut config = ConfigOptions::default();
1822+
config.execution.parquet.pushdown_filters = true;
1823+
let optimized = FilterPushdown::new().optimize(plan, &config).unwrap();
1824+
assert!(
1825+
optimized.downcast_ref::<FilterExec>().is_some(),
1826+
"parent filter must remain above aggregate"
1827+
);
1828+
}
1829+
17561830
// ==== Aggregate Dynamic Filter tests ====
17571831
//
17581832
// The end-to-end min/max dynamic filter cases (simple/min/max/mixed/all-nulls)
@@ -1997,13 +2071,65 @@ fn test_pushdown_grouping_sets_filter_on_common_column() {
19972071
);
19982072
}
19992073

2074+
#[tokio::test]
2075+
async fn test_no_pushdown_through_global_aggregate_with_name_collision() {
2076+
let input_schema =
2077+
Arc::new(Schema::new(vec![Field::new("a", DataType::Int64, false)]));
2078+
let scan = TestScanBuilder::new(Arc::clone(&input_schema))
2079+
.with_support(true)
2080+
.with_batches(vec![record_batch!(("a", Int64, [1, 20])).unwrap()])
2081+
.build();
2082+
let aggregate_expr = vec![
2083+
AggregateExprBuilder::new(max_udaf(), vec![col("a", &input_schema).unwrap()])
2084+
.schema(Arc::clone(&input_schema))
2085+
.alias("a")
2086+
.build()
2087+
.map(Arc::new)
2088+
.unwrap(),
2089+
];
2090+
let aggregate = Arc::new(
2091+
AggregateExec::try_new(
2092+
AggregateMode::Single,
2093+
PhysicalGroupBy::new_single(vec![]),
2094+
aggregate_expr,
2095+
vec![None],
2096+
scan,
2097+
input_schema,
2098+
)
2099+
.unwrap(),
2100+
);
2101+
2102+
// This is a physical filter above the aggregate, not a SQL WHERE clause.
2103+
// Pushing it through would evaluate input `a` instead of MAX(a).
2104+
let predicate = Arc::new(BinaryExpr::new(
2105+
col("a", aggregate.schema().as_ref()).unwrap(),
2106+
Operator::Lt,
2107+
Arc::new(Literal::new(ScalarValue::Int64(Some(10)))),
2108+
));
2109+
let plan = Arc::new(FilterExec::try_new(predicate, aggregate).unwrap());
2110+
2111+
let mut config = ConfigOptions::default();
2112+
config.execution.parquet.pushdown_filters = true;
2113+
let optimized = FilterPushdown::new().optimize(plan, &config).unwrap();
2114+
assert!(optimized.downcast_ref::<FilterExec>().is_some());
2115+
2116+
let session_ctx = SessionContext::new();
2117+
session_ctx.register_object_store(
2118+
ObjectStoreUrl::parse("test://").unwrap().as_ref(),
2119+
Arc::new(InMemory::new()),
2120+
);
2121+
let batches = collect(optimized, session_ctx.state().task_ctx())
2122+
.await
2123+
.unwrap();
2124+
assert!(
2125+
batches.is_empty(),
2126+
"MAX(a) = 20 must be filtered out instead of applying a < 10 to input rows"
2127+
);
2128+
}
2129+
20002130
#[test]
2001-
fn test_pushdown_with_empty_group_by() {
2002-
// Test that filters can be pushed down when GROUP BY is empty (no grouping columns)
2003-
// SELECT count(*) as cnt FROM table WHERE a = 'foo'
2004-
// There are no grouping columns, so the filter should still push down
2131+
fn test_no_pushdown_constant_false_through_global_aggregate() {
20052132
let scan = TestScanBuilder::new(schema()).with_support(true).build();
2006-
20072133
let aggregate_expr = vec![
20082134
AggregateExprBuilder::new(count_udaf(), vec![col("c", &schema()).unwrap()])
20092135
.schema(schema())
@@ -2012,41 +2138,58 @@ fn test_pushdown_with_empty_group_by() {
20122138
.map(Arc::new)
20132139
.unwrap(),
20142140
];
2141+
let aggregate = Arc::new(
2142+
AggregateExec::try_new(
2143+
AggregateMode::Final,
2144+
PhysicalGroupBy::new_single(vec![]),
2145+
aggregate_expr,
2146+
vec![None],
2147+
scan,
2148+
schema(),
2149+
)
2150+
.unwrap(),
2151+
);
2152+
let predicate = Arc::new(Literal::new(ScalarValue::Boolean(Some(false))));
2153+
let plan = Arc::new(FilterExec::try_new(predicate, aggregate).unwrap());
20152154

2016-
// Empty GROUP BY - no grouping columns
2017-
let group_by = PhysicalGroupBy::new_single(vec![]);
2155+
assert_parent_filter_remains_above_aggregate(plan);
2156+
}
20182157

2158+
#[test]
2159+
fn test_no_pushdown_constant_false_through_empty_grouping_set() {
2160+
let scan = TestScanBuilder::new(schema()).with_support(true).build();
2161+
let group_by = PhysicalGroupBy::new(
2162+
vec![(col("a", &schema()).unwrap(), "a".to_string())],
2163+
vec![(
2164+
Arc::new(Literal::new(ScalarValue::Utf8(None))),
2165+
"a".to_string(),
2166+
)],
2167+
vec![vec![true]],
2168+
true,
2169+
);
2170+
let aggregate_expr = vec![
2171+
AggregateExprBuilder::new(count_udaf(), vec![col("c", &schema()).unwrap()])
2172+
.schema(schema())
2173+
.alias("cnt")
2174+
.build()
2175+
.map(Arc::new)
2176+
.unwrap(),
2177+
];
20192178
let aggregate = Arc::new(
20202179
AggregateExec::try_new(
20212180
AggregateMode::Final,
20222181
group_by,
2023-
aggregate_expr.clone(),
2182+
aggregate_expr,
20242183
vec![None],
20252184
scan,
20262185
schema(),
20272186
)
20282187
.unwrap(),
20292188
);
2030-
2031-
// Filter on 'a'
2032-
let predicate = col_lit_predicate("a", "foo", &schema());
2189+
let predicate = Arc::new(Literal::new(ScalarValue::Boolean(Some(false))));
20332190
let plan = Arc::new(FilterExec::try_new(predicate, aggregate).unwrap());
20342191

2035-
// The filter should be pushed down even with empty GROUP BY
2036-
insta::assert_snapshot!(
2037-
OptimizationTest::new(plan, FilterPushdown::new(), true),
2038-
@r"
2039-
OptimizationTest:
2040-
input:
2041-
- FilterExec: a@0 = foo
2042-
- AggregateExec: mode=Final, gby=[], aggr=[cnt]
2043-
- DataSourceExec: file_groups={1 group: [[test.parquet]]}, projection=[a, b, c], file_type=test, pushdown_supported=true
2044-
output:
2045-
Ok:
2046-
- AggregateExec: mode=Final, gby=[], aggr=[cnt]
2047-
- DataSourceExec: file_groups={1 group: [[test.parquet]]}, projection=[a, b, c], file_type=test, pushdown_supported=true, predicate=a@0 = foo
2048-
"
2049-
);
2192+
assert_parent_filter_remains_above_aggregate(plan);
20502193
}
20512194

20522195
#[test]

datafusion/physical-optimizer/src/filter_pushdown.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,14 @@ fn push_down_filters(
486486
// currently. `self_filters` are the predicates which are provided by the current node,
487487
// and tried to be pushed down over the child similarly.
488488

489+
assert_eq_or_internal_err!(
490+
parent_filters.len(),
491+
parent_filtered.len(),
492+
"Filter pushdown expected {} to return one parent filter result per input filter for child {}",
493+
node.name(),
494+
child_idx
495+
);
496+
489497
// Filter out self_filters that contain volatile expressions and track indices
490498
let self_filtered = FilteredVec::new(&self_filters, allow_pushdown_for_expr);
491499

datafusion/physical-plan/src/aggregates/mod.rs

Lines changed: 27 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ use crate::aggregates::{
159159
use crate::execution_plan::{CardinalityEffect, EmissionType};
160160
use crate::filter_pushdown::{
161161
ChildFilterDescription, ChildPushdownResult, FilterDescription, FilterPushdownPhase,
162-
FilterPushdownPropagation, PushedDownPredicate,
162+
FilterPushdownPropagation,
163163
};
164164
use crate::metrics::{ExecutionPlanMetricsSet, MetricsSet};
165165
use crate::statistics::{ChildStats, StatisticsArgs};
@@ -168,7 +168,6 @@ use crate::{
168168
InputOrderMode, SendableRecordBatchStream, Statistics, check_if_same_properties,
169169
};
170170
use datafusion_common::config::ConfigOptions;
171-
use datafusion_physical_expr::utils::collect_columns;
172171
use parking_lot::Mutex;
173172
use std::collections::{HashMap, HashSet};
174173

@@ -2041,70 +2040,35 @@ impl ExecutionPlan for AggregateExec {
20412040
// This optimization is NOT safe for filters on aggregated columns (like filtering on
20422041
// the result of SUM or COUNT), as those require computing all groups first.
20432042

2044-
// Build grouping columns using output indices because parent filters reference the
2045-
// AggregateExec's output schema where grouping columns in the output schema. The
2046-
// grouping expressions reference input columns which may not match the output schema.
2047-
//
2048-
// It is safe to assume that the output_schema contains group by columns in the same order
2049-
// as the group by expression. See [`create_schema`] and [`AggregateExec`].
2050-
let output_schema = self.schema();
2051-
let grouping_columns: HashSet<_> = (0..self.group_by.expr().len())
2052-
.map(|i| Column::new(output_schema.field(i).name(), i))
2053-
.collect();
2054-
2055-
// Analyze each filter separately to determine if it can be pushed down
2056-
let mut safe_filters = Vec::new();
2057-
let mut unsafe_filters = Vec::new();
2058-
2059-
for filter in parent_filters {
2060-
let filter_columns: HashSet<_> =
2061-
collect_columns(&filter).into_iter().collect();
2062-
2063-
// Check if this filter references non-grouping columns
2064-
let references_non_grouping = !grouping_columns.is_empty()
2065-
&& !filter_columns.is_subset(&grouping_columns);
2066-
2067-
if references_non_grouping {
2068-
unsafe_filters.push(filter);
2069-
continue;
2070-
}
2071-
2072-
// For GROUPING SETS, verify this filter's columns appear in all grouping sets
2073-
if self.group_by.groups().len() > 1 {
2074-
let filter_column_indices: Vec<usize> = filter_columns
2075-
.iter()
2076-
.filter_map(|filter_col| {
2077-
grouping_columns.get(filter_col).map(|col| col.index())
2078-
})
2079-
.collect();
2080-
2081-
// Check if any of this filter's columns are missing from any grouping set
2082-
let has_missing_column = self.group_by.groups().iter().any(|null_mask| {
2083-
filter_column_indices
2084-
.iter()
2085-
.any(|&idx| null_mask.get(idx) == Some(&true))
2086-
});
2087-
2088-
if has_missing_column {
2089-
unsafe_filters.push(filter);
2090-
continue;
2091-
}
2092-
}
2093-
2094-
// This filter is safe to push down
2095-
safe_filters.push(filter);
2043+
// Grouping columns are output before aggregate columns, in the same order
2044+
// as the grouping expressions. A grouping-set null mask marks grouping
2045+
// columns that are not available in that set.
2046+
let mut allowed_indices: HashSet<usize> =
2047+
(0..self.group_by.expr().len()).collect();
2048+
for null_mask in self.group_by.groups() {
2049+
allowed_indices.retain(|idx| null_mask.get(*idx) != Some(&true));
20962050
}
20972051

2098-
// Build child filter description with both safe and unsafe filters
20992052
let child = self.children()[0];
2100-
let mut child_desc = ChildFilterDescription::from_child(&safe_filters, child)?;
2101-
2102-
// Add unsafe filters as unsupported
2103-
child_desc.parent_filters.extend(
2104-
unsafe_filters
2105-
.into_iter()
2106-
.map(PushedDownPredicate::unsupported),
2107-
);
2053+
// Global aggregates and grouping sets containing an empty grouping set
2054+
// emit a row even when their input is empty. Parent filters therefore
2055+
// cannot be pushed below them, including filters without column
2056+
// references.
2057+
let may_emit_on_empty_input = self.group_by.is_true_no_grouping()
2058+
|| self
2059+
.group_by
2060+
.groups()
2061+
.iter()
2062+
.any(|null_mask| null_mask.iter().all(|is_null| *is_null));
2063+
let mut child_desc = if may_emit_on_empty_input {
2064+
ChildFilterDescription::all_unsupported(&parent_filters)
2065+
} else {
2066+
ChildFilterDescription::from_child_with_allowed_indices(
2067+
&parent_filters,
2068+
allowed_indices,
2069+
child,
2070+
)?
2071+
};
21082072

21092073
// Include self dynamic filter when it's possible
21102074
if phase == FilterPushdownPhase::Post

datafusion/physical-plan/src/execution_plan.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,12 @@ pub trait ExecutionPlan: Any + Debug + DisplayAs + Send + Sync {
664664
/// There are two different phases in filter pushdown, which some operators may handle the same and some differently.
665665
/// Depending on the phase the operator may or may not be allowed to modify the plan.
666666
/// See [`FilterPushdownPhase`] for more details.
667+
///
668+
/// Implementations must preserve the order of `parent_filters` in the
669+
/// returned child [`FilterDescription`]: each child parent-filter result is
670+
/// matched back to the corresponding input parent filter by position.
671+
/// Unsupported filters should therefore be marked unsupported in place,
672+
/// rather than removed or appended after supported filters.
667673
fn gather_filters_for_pushdown(
668674
&self,
669675
_phase: FilterPushdownPhase,

0 commit comments

Comments
 (0)