Skip to main content

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}