datafusion_physical_optimizer/window_topn.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//! [`WindowTopN`] optimizer rule for per-partition top-K window queries.
19//!
20//! Detects queries of the form:
21//!
22//! ```sql
23//! SELECT * FROM (
24//! SELECT *, ROW_NUMBER() OVER (PARTITION BY pk ORDER BY val) as rn
25//! FROM t
26//! ) WHERE rn <= K;
27//! ```
28//!
29//! or with `RANK()` in place of `ROW_NUMBER()`:
30//!
31//! ```sql
32//! SELECT * FROM (
33//! SELECT *, RANK() OVER (PARTITION BY pk ORDER BY val) as rk
34//! FROM t
35//! ) WHERE rk <= K;
36//! ```
37//!
38//! And replaces the `FilterExec → BoundedWindowAggExec` pipeline with
39//! `BoundedWindowAggExec → PartitionedTopKExec(fetch=K)`, removing the
40//! `FilterExec` and inserting `PartitionedTopKExec` under the window.
41//!
42//! The appropriate [`WindowFnKind`] is forwarded to `PartitionedTopKExec`.
43//! RANK requires a non-empty `ORDER BY` clause (otherwise all rows tie at
44//! rank 1 and the optimization is degenerate).
45//!
46//! See [`PartitionedTopKExec`] for details on the replacement operator.
47//!
48//! [`PartitionedTopKExec`]: datafusion_physical_plan::sorts::partitioned_topk::PartitionedTopKExec
49//! [`WindowFnKind`]: datafusion_physical_plan::sorts::partitioned_topk::WindowFnKind
50
51use std::sync::Arc;
52
53use crate::PhysicalOptimizerRule;
54use arrow::datatypes::DataType;
55use datafusion_common::config::ConfigOptions;
56use datafusion_common::tree_node::{Transformed, TransformedResult, TreeNode};
57use datafusion_common::{Result, ScalarValue};
58use datafusion_expr::Operator;
59use datafusion_physical_expr::expressions::{BinaryExpr, Column, Literal};
60use datafusion_physical_expr::window::StandardWindowExpr;
61use datafusion_physical_expr::{LexOrdering, PhysicalSortExpr};
62use datafusion_physical_plan::ExecutionPlan;
63use datafusion_physical_plan::execution_plan::replace_children_if_necessary;
64use datafusion_physical_plan::filter::FilterExec;
65use datafusion_physical_plan::projection::ProjectionExec;
66use datafusion_physical_plan::repartition::RepartitionExec;
67use datafusion_physical_plan::sorts::partitioned_topk::{
68 PartitionedTopKExec, WindowFnKind,
69};
70use datafusion_physical_plan::windows::{BoundedWindowAggExec, WindowUDFExpr};
71
72/// Physical optimizer rule that converts per-partition `ROW_NUMBER` and
73/// `RANK` top-K queries into a more efficient plan using
74/// [`PartitionedTopKExec`].
75///
76/// # Pattern Detected
77///
78/// ```text
79/// FilterExec(<ranking fn output> <= K)
80/// [optional ProjectionExec]
81/// BoundedWindowAggExec(<ranking fn> PARTITION BY ... ORDER BY ...)
82/// ```
83///
84/// # Replacement
85///
86/// ```text
87/// [optional ProjectionExec]
88/// BoundedWindowAggExec(<ranking fn> PARTITION BY ... ORDER BY ...)
89/// PartitionedTopKExec(fn=<row_number|rank>, partition_keys, order_keys, fetch=K)
90/// ```
91///
92/// The `FilterExec` is removed entirely. The child of `BoundedWindowAggExec` is now
93/// `PartitionedTopKExec`, which maintains a per-partition top-K heap (and,
94/// for `RANK`, a sibling ties `Vec`) instead of sorting the whole dataset.
95///
96/// # Supported Predicates
97///
98/// - `rn <= K` → fetch = K
99/// - `rn < K` → fetch = K - 1
100/// - `K >= rn` (flipped) → fetch = K
101/// - `K > rn` (flipped) → fetch = K - 1
102///
103/// # When the Rule Fires
104///
105/// All of the following must be true:
106/// - Config flag `enable_window_topn` is `true`
107/// - The plan matches `FilterExec → [ProjectionExec] → BoundedWindowAggExec`
108/// - The window function is `ROW_NUMBER` or `RANK` (not `DENSE_RANK`)
109/// - The window function has a `PARTITION BY` clause (global top-K is
110/// already handled by `SortExec` with `fetch`)
111/// - For `RANK`: a non-empty `ORDER BY` clause (otherwise all rows tie
112/// at rank 1 — the optimization is useless and the boundary-tie storage
113/// would be unbounded)
114/// - The filter predicate compares the window output column to an integer
115/// literal using `<=`, `<`, `>=`, or `>`
116///
117/// [`PartitionedTopKExec`]: datafusion_physical_plan::sorts::partitioned_topk::PartitionedTopKExec
118#[derive(Default, Clone, Debug)]
119pub struct WindowTopN;
120
121impl WindowTopN {
122 pub fn new() -> Self {
123 Self
124 }
125
126 /// Attempt to transform a single plan node.
127 ///
128 /// Returns `Some(new_plan)` if the node matches the
129 /// `FilterExec → [ProjectionExec] → BoundedWindowAggExec`
130 /// pattern and can be rewritten, or `None` if the node should be
131 /// left unchanged.
132 fn try_transform(plan: &Arc<dyn ExecutionPlan>) -> Option<Arc<dyn ExecutionPlan>> {
133 // Step 1: Match FilterExec at the top
134 let filter = plan.downcast_ref::<FilterExec>()?;
135
136 // Don't handle filters with projections
137 if filter.projection().is_some() {
138 return None;
139 }
140
141 // Step 2: Extract limit from predicate (rn <= K, rn < K, etc.)
142 let (col_idx, limit_n) = extract_window_limit(filter.predicate())?;
143
144 // Step 3: Walk through optional ProjectionExec and RepartitionExec to find BoundedWindowAggExec
145 let child = filter.input();
146 let (window_exec, intermediates) = find_window_below(child)?;
147
148 // Step 4: Verify col_idx references a supported window function output column
149 let window_exec_typed = window_exec.downcast_ref::<BoundedWindowAggExec>()?;
150 let input_field_count = window_exec_typed.input().schema().fields().len();
151 if col_idx < input_field_count {
152 return None; // Filter is on an input column, not a window column
153 }
154 let window_expr_idx = col_idx - input_field_count;
155 let window_exprs = window_exec_typed.window_expr();
156 if window_expr_idx >= window_exprs.len() {
157 return None;
158 }
159 let fn_kind = supported_window_fn(&window_exprs[window_expr_idx])?;
160
161 // Step 5: Validate PARTITION BY / ORDER BY and collect sort keys from the window expr
162 let partition_by = window_exprs[window_expr_idx].partition_by();
163 let partition_prefix_len = partition_by.len();
164
165 // Without PARTITION BY, this is just a global top-K which
166 // SortExec with fetch already handles efficiently.
167 if partition_prefix_len == 0 {
168 return None;
169 }
170
171 // For RANK: an empty ORDER BY makes every row tie at rank 1 —
172 // the optimization is degenerate (we'd retain the entire input)
173 // and tie storage would be unbounded.
174 let order_by = window_exprs[window_expr_idx].order_by();
175 if matches!(fn_kind, WindowFnKind::Rank) && order_by.is_empty() {
176 return None;
177 }
178
179 // Step 6: Build PartitionedTopKExec from the window's partition/order keys
180 let expr_iterator = partition_by
181 .iter()
182 .map(|e| PhysicalSortExpr::new_default(Arc::clone(e)))
183 .chain(order_by.iter().cloned());
184 let expr = LexOrdering::new(expr_iterator)?;
185
186 let partitioned_topk = PartitionedTopKExec::try_new(
187 Arc::clone(window_exec_typed.input()),
188 expr,
189 partition_prefix_len,
190 limit_n,
191 fn_kind,
192 )
193 .ok()?;
194
195 // Step 7: Rebuild window with PartitionedTopKExec as its child
196 let mut result =
197 replace_children_if_necessary(window_exec, vec![Arc::new(partitioned_topk)])
198 .ok()?;
199
200 // Step 8: Rebuild intermediate nodes (ProjectionExec/RepartitionExec)
201 for node in intermediates.into_iter().rev() {
202 result = replace_children_if_necessary(node, vec![result]).ok()?;
203 }
204
205 Some(result)
206 }
207}
208
209impl PhysicalOptimizerRule for WindowTopN {
210 fn optimize(
211 &self,
212 plan: Arc<dyn ExecutionPlan>,
213 config: &ConfigOptions,
214 ) -> Result<Arc<dyn ExecutionPlan>> {
215 if !config.optimizer.enable_window_topn {
216 return Ok(plan);
217 }
218
219 plan.transform_down(|node| {
220 Ok(
221 if let Some(transformed) = WindowTopN::try_transform(&node) {
222 Transformed::yes(transformed)
223 } else {
224 Transformed::no(node)
225 },
226 )
227 })
228 .data()
229 }
230
231 fn name(&self) -> &str {
232 "WindowTopN"
233 }
234
235 fn schema_check(&self) -> bool {
236 true
237 }
238}
239
240/// Extract a window limit from a predicate expression.
241///
242/// Returns `(column_index, fetch)` if the predicate constrains a column
243/// to at most N rows.
244///
245/// # Supported Patterns
246///
247/// | Predicate | Returns |
248/// |-----------|---------|
249/// | `Column(idx) <= Literal(N)` | `(idx, N)` |
250/// | `Column(idx) < Literal(N)` | `(idx, N-1)` |
251/// | `Literal(N) >= Column(idx)` | `(idx, N)` |
252/// | `Literal(N) > Column(idx)` | `(idx, N-1)` |
253///
254/// # Examples
255///
256/// - `rn <= 5` → `Some((2, 5))` (assuming rn is column index 2)
257/// - `rn < 3` → `Some((2, 2))`
258/// - `10 >= rn` → `Some((2, 10))`
259/// - `rn = 1` → `None` (equality not supported)
260/// - `val <= 5` → `Some((1, 5))` (caller must verify it's a window column)
261fn extract_window_limit(
262 predicate: &Arc<dyn datafusion_physical_expr::PhysicalExpr>,
263) -> Option<(usize, usize)> {
264 let binary = predicate.downcast_ref::<BinaryExpr>()?;
265 let op = binary.op();
266 let left = binary.left();
267 let right = binary.right();
268
269 // Try Column op Literal
270 if let (Some(col), Some(lit_val)) = (
271 left.downcast_ref::<Column>(),
272 right.downcast_ref::<Literal>(),
273 ) {
274 let n = scalar_to_usize(lit_val.value())?;
275 return match *op {
276 Operator::LtEq => Some((col.index(), n)),
277 Operator::Lt => Some((col.index(), n - 1)),
278 _ => None,
279 };
280 }
281
282 // Try Literal op Column (flipped)
283 if let (Some(lit_val), Some(col)) = (
284 left.downcast_ref::<Literal>(),
285 right.downcast_ref::<Column>(),
286 ) {
287 let n = scalar_to_usize(lit_val.value())?;
288 return match *op {
289 Operator::GtEq => Some((col.index(), n)),
290 Operator::Gt => Some((col.index(), n - 1)),
291 _ => None,
292 };
293 }
294
295 None
296}
297
298/// Convert a [`ScalarValue`] to `usize` if it's a positive integer.
299///
300/// Returns `None` for null values, zero, negative integers, and
301/// non-integer types (floats, strings, decimals, etc.).
302fn scalar_to_usize(value: &ScalarValue) -> Option<usize> {
303 if !value.data_type().is_integer() {
304 return None;
305 }
306 let casted = value.cast_to(&DataType::UInt64).ok()?;
307 match casted {
308 ScalarValue::UInt64(Some(v)) if v > 0 => usize::try_from(v).ok(),
309 _ => None,
310 }
311}
312
313/// Identify which supported ranking window function `expr` is.
314///
315/// Downcasts through `StandardWindowExpr` → `WindowUDFExpr` and checks
316/// the UDF name. Returns:
317/// - `Some(WindowFnKind::RowNumber)` for `"row_number"`
318/// - `Some(WindowFnKind::Rank)` for `"rank"`
319/// - `None` for everything else (e.g. `dense_rank`)
320fn supported_window_fn(
321 expr: &Arc<dyn datafusion_physical_expr::window::WindowExpr>,
322) -> Option<WindowFnKind> {
323 let swe = expr.as_any().downcast_ref::<StandardWindowExpr>()?;
324 let swfe = swe.get_standard_func_expr();
325 let udf = swfe.as_any().downcast_ref::<WindowUDFExpr>()?;
326 match udf.fun().name() {
327 "row_number" => Some(WindowFnKind::RowNumber),
328 "rank" => Some(WindowFnKind::Rank),
329 _ => None,
330 }
331}
332
333type PlanAndIntermediates = (Arc<dyn ExecutionPlan>, Vec<Arc<dyn ExecutionPlan>>);
334
335/// Walk below a plan node looking for a [`BoundedWindowAggExec`].
336///
337/// Handles sequences of `ProjectionExec` and `RepartitionExec`.
338/// This is safe because `PartitionedTopKExec` can be pushed below them:
339/// projections only provide aliases, and pushing the limit below repartitions
340/// is safe because the limit is computed per-partition.
341///
342/// Returns the window exec and a list of intermediate nodes to rebuild,
343/// or `None` if no `BoundedWindowAggExec` is found.
344fn find_window_below(plan: &Arc<dyn ExecutionPlan>) -> Option<PlanAndIntermediates> {
345 let mut current = Arc::clone(plan);
346 let mut intermediates = Vec::new();
347
348 loop {
349 if current.downcast_ref::<BoundedWindowAggExec>().is_some() {
350 return Some((current, intermediates));
351 } else if current.downcast_ref::<ProjectionExec>().is_some()
352 || current.downcast_ref::<RepartitionExec>().is_some()
353 {
354 let next = Arc::clone(current.children().first()?);
355 intermediates.push(current);
356 current = next;
357 } else {
358 return None;
359 }
360 }
361}