Repository navigation
feat(physical-optimizer): support FilterExec with embedded projection in WindowTopN #23599
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fe2688b
26a9e9c
e4ca432
6a3ebc3
7c3bc01
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,6 +56,7 @@ use datafusion_common::config::ConfigOptions; | |
| use datafusion_common::tree_node::{Transformed, TransformedResult, TreeNode}; | ||
| use datafusion_common::{Result, ScalarValue}; | ||
| use datafusion_expr::Operator; | ||
| use datafusion_physical_expr::PhysicalExpr; | ||
| use datafusion_physical_expr::expressions::{BinaryExpr, Column, Literal}; | ||
| use datafusion_physical_expr::window::StandardWindowExpr; | ||
| use datafusion_physical_expr::{LexOrdering, PhysicalSortExpr}; | ||
|
|
@@ -142,11 +143,29 @@ impl WindowTopN { | |
| // Step 1: Match FilterExec at the top | ||
| let filter = plan.downcast_ref::<FilterExec>()?; | ||
|
|
||
| // Don't handle filters with projections | ||
| if filter.projection().is_some() { | ||
| // The rewrite replaces the FilterExec entirely, so anything it carries | ||
| // beyond the predicate has to be reproduced or declined. `fetch` is | ||
| // applied by `FilterExec::execute` *after* the predicate, and the | ||
| // rewritten plan has nowhere to put it: a `PartitionedTopKExec` bounds | ||
| // rows per partition, which is not the same as a row limit over the | ||
| // filtered output. Decline rather than silently widen the result. | ||
| // | ||
| // Note this also guards a case that predates this rule's projection | ||
| // support: a FilterExec with `fetch` but no projection was already | ||
| // rewritten with its fetch dropped. | ||
| if filter.fetch().is_some() { | ||
| return None; | ||
| } | ||
|
|
||
| // A projection embedded in the FilterExec (from an earlier | ||
| // filter/projection pushdown pass) is captured here and re-applied | ||
| // via a wrapping ProjectionExec at the end so the rewrite preserves | ||
| // the original output schema. | ||
| let filter_projection: Option<Vec<usize>> = filter | ||
| .projection() | ||
| .as_ref() | ||
| .map(|p| p.iter().copied().collect()); | ||
|
|
||
| // Step 2: Extract limit from predicate (rn <= K, rn < K, etc.) | ||
| let (col_idx, limit_n) = extract_window_limit(filter.predicate())?; | ||
|
|
||
|
|
@@ -252,6 +271,34 @@ impl WindowTopN { | |
| result = replace_children_if_necessary(node, vec![result]).ok()?; | ||
| } | ||
|
|
||
| // Step 10: Re-apply the FilterExec's embedded projection (if any) | ||
| // as an outer ProjectionExec. The projection indices refer to | ||
| // columns in `filter.input().schema()`, which equals `result`'s | ||
| // schema at this point (Steps 8-9 preserve schema), so the | ||
| // indices remain valid. | ||
| if let Some(indices) = filter_projection { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice improvement capturing and restoring the embedded projection. One thing I noticed is that the rewrite removes the For example, if a matching projected filter has Could we either preserve the fetch with an equivalent outer limit/fetch operator, or skip this rewrite when It would also be great to add a regression test covering the projection plus fetch case that executes the plan, or otherwise verifies that the row limit is preserved.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch — fixed in f6bd9dd, and it turned out to be wider than the projection path. I went with the second option: Worth flagging on reachability: Two regression tests, both asserting the plan comes back unchanged: Also rebased onto main and resolved the conflicts — |
||
| let input_schema = result.schema(); | ||
| let field_count = input_schema.fields().len(); | ||
| // Validate before indexing: an out-of-range index would panic | ||
| // in `input_schema.field(idx)`. Bail out of the rewrite instead | ||
| // so a malformed FilterExec projection can never crash the | ||
| // optimizer. | ||
| if indices.iter().any(|&idx| idx >= field_count) { | ||
| return None; | ||
| } | ||
| let projection_exprs: Vec<(Arc<dyn PhysicalExpr>, String)> = indices | ||
| .iter() | ||
| .map(|&idx| { | ||
| let field = input_schema.field(idx); | ||
| ( | ||
| Arc::new(Column::new(field.name(), idx)) as Arc<dyn PhysicalExpr>, | ||
| field.name().clone(), | ||
| ) | ||
| }) | ||
| .collect(); | ||
| result = Arc::new(ProjectionExec::try_new(projection_exprs, result).ok()?); | ||
| } | ||
|
|
||
| Some(result) | ||
| } | ||
| } | ||
|
|
@@ -308,9 +355,7 @@ impl PhysicalOptimizerRule for WindowTopN { | |
| /// - `10 >= rn` → `Some((2, 10))` | ||
| /// - `rn = 1` → `None` (equality not supported) | ||
| /// - `val <= 5` → `Some((1, 5))` (caller must verify it's a window column) | ||
| fn extract_window_limit( | ||
| predicate: &Arc<dyn datafusion_physical_expr::PhysicalExpr>, | ||
| ) -> Option<(usize, usize)> { | ||
| fn extract_window_limit(predicate: &Arc<dyn PhysicalExpr>) -> Option<(usize, usize)> { | ||
| let binary = predicate.downcast_ref::<BinaryExpr>()?; | ||
| let op = binary.op(); | ||
| let left = binary.left(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Optional: could we add a reordered or duplicate projection case such as
[1, 0, 1]and assert that the optimized schema matches the original filter schema, including field and schema metadata? The current[0, 1]snapshot covers column removal, but this would strengthen coverage for ordering, duplicates, and metadata preservation.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @kosiew — added in 7c3bc01 as
reordered_duplicated_projection_preserves_filter_schema: a[1, 0, 1]projection (reorder + duplicate) over a schema with both field-level and schema-level metadata, assertingoptimized.schema() == filter.schema().It passes — the metadata does survive. Two guards so it can't pass for the wrong reason: it checks the rewrite actually fired (rather than the rule declining and handing back the input), and that
FilterExecitself carries the metadata being compared (otherwise both sides would be empty).