Skip to main content

nodedb_sql/planner/window/
extract.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Window function extraction from a SELECT's projection — the module entry
4//! point.
5//!
6//! Resolves `OVER w` / `OVER (w ORDER BY ...)` references against the query's
7//! `WINDOW` clause, validates each `<func>() OVER (...)` against the function
8//! registry (PostgreSQL allows aggregates as windows), and produces the
9//! `WindowSpec`s the Data-Plane evaluator consumes. Names that are neither
10//! registered window functions nor aggregates are rejected here so the
11//! evaluator never receives an unrecognised verb.
12
13use std::collections::HashMap;
14
15use sqlparser::ast;
16
17use crate::error::{Result, SqlError};
18use crate::functions::registry::{FunctionCategory, FunctionRegistry};
19use crate::parser::normalize::{SCHEMA_QUALIFIED_MSG, normalize_ident};
20use crate::planner::sort::{sort_ascending, sort_nulls_first};
21use crate::resolver::ColumnScope;
22use crate::resolver::columns::TableScope;
23use crate::resolver::expr::convert_expr;
24use crate::types::{SortKey, SqlExpr, WindowSpec};
25use nodedb_query::{FrameBound, WindowFrame};
26
27use super::frame::convert_window_frame;
28use super::named::{collect_named_windows, flatten_window_spec, resolve_named_def};
29
30/// Extract window function specifications from a SELECT's projection.
31pub fn extract_window_functions(
32    select: &ast::Select,
33    functions: &FunctionRegistry,
34    scope: &TableScope,
35) -> Result<Vec<WindowSpec>> {
36    let named = collect_named_windows(&select.named_window)?;
37    let mut specs = Vec::new();
38    for item in &select.projection {
39        let (expr, alias) = match item {
40            ast::SelectItem::UnnamedExpr(e) => (e, format!("{e}")),
41            ast::SelectItem::ExprWithAlias { expr, alias } => (expr, normalize_ident(alias)),
42            _ => continue,
43        };
44        if let ast::Expr::Function(func) = expr
45            && func.over.is_some()
46        {
47            specs.push(convert_window_spec(func, &alias, functions, &named, scope)?);
48        }
49    }
50    Ok(specs)
51}
52
53fn convert_window_spec(
54    func: &ast::Function,
55    alias: &str,
56    functions: &FunctionRegistry,
57    named: &HashMap<String, &ast::NamedWindowExpr>,
58    scope: &TableScope,
59) -> Result<WindowSpec> {
60    if func.name.0.len() > 1 {
61        let qualified: String = func
62            .name
63            .0
64            .iter()
65            .map(|p| match p {
66                ast::ObjectNamePart::Identifier(ident) => ident.value.clone(),
67                _ => String::new(),
68            })
69            .collect::<Vec<_>>()
70            .join(".");
71        return Err(SqlError::Unsupported {
72            detail: format!(
73                "schema-qualified window function name '{qualified}': {SCHEMA_QUALIFIED_MSG}"
74            ),
75        });
76    }
77    let name = func
78        .name
79        .0
80        .iter()
81        .map(|p| match p {
82            ast::ObjectNamePart::Identifier(ident) => normalize_ident(ident),
83            _ => String::new(),
84        })
85        .collect::<Vec<_>>()
86        .join(".");
87
88    // Reject unknown names at plan time. PostgreSQL permits aggregates as
89    // windows, so accept either Window or Aggregate categories.
90    match functions.lookup(&name).map(|m| m.category) {
91        Some(FunctionCategory::Window) | Some(FunctionCategory::Aggregate) => {}
92        Some(FunctionCategory::Scalar) => {
93            return Err(SqlError::InvalidFunction {
94                detail: format!(
95                    "function '{name}() OVER ()' does not exist as a window function \
96                     (it is a scalar function)"
97                ),
98            });
99        }
100        None => {
101            return Err(SqlError::InvalidFunction {
102                detail: format!("function '{name}() OVER ()' does not exist"),
103            });
104        }
105    }
106
107    let args = convert_window_args(func, &name, scope)?;
108    validate_constant_args(&name, &args)?;
109
110    // Resolve the OVER target into a flattened partition/order/frame.
111    let flat = match &func.over {
112        Some(ast::WindowType::WindowSpec(spec)) => {
113            Some(flatten_window_spec(spec, named, &mut Vec::new())?)
114        }
115        Some(ast::WindowType::NamedWindow(ident)) => {
116            let n = normalize_ident(ident);
117            let mut seen = vec![n.clone()];
118            let base = resolve_named_def(&n, named, &mut seen)?;
119            Some(flatten_window_spec(base, named, &mut seen)?)
120        }
121        None => None,
122    };
123
124    let (partition_by, order_by, frame) = match flat {
125        Some(flat) => {
126            let pb = flat
127                .partition_by
128                .iter()
129                .map(|e| convert_expr(e, &ColumnScope::Relations(scope)))
130                .collect::<Result<Vec<_>>>()?;
131            let ob = flat
132                .order_by
133                .iter()
134                .map(|o| {
135                    Ok(SortKey {
136                        expr: convert_expr(&o.expr, &ColumnScope::Relations(scope))?,
137                        ascending: sort_ascending(&o.options)?,
138                        nulls_first: sort_nulls_first(&o.options)?,
139                    })
140                })
141                .collect::<Result<Vec<_>>>()?;
142            let frame = match &flat.frame {
143                Some(f) => convert_window_frame(f, &ob)?,
144                // PostgreSQL default: when ORDER BY is present, RANGE UNBOUNDED
145                // PRECEDING TO CURRENT ROW; when no ORDER BY, the window covers
146                // the whole partition (RANGE UNBOUNDED PRECEDING TO UNBOUNDED
147                // FOLLOWING).
148                None => {
149                    if ob.is_empty() {
150                        WindowFrame {
151                            mode: "range".into(),
152                            start: FrameBound::UnboundedPreceding,
153                            end: FrameBound::UnboundedFollowing,
154                        }
155                    } else {
156                        WindowFrame::default()
157                    }
158                }
159            };
160            (pb, ob, frame)
161        }
162        // Bare `OVER ()` with no spec — whole input is one window.
163        None => (
164            Vec::new(),
165            Vec::new(),
166            WindowFrame {
167                mode: "range".into(),
168                start: FrameBound::UnboundedPreceding,
169                end: FrameBound::UnboundedFollowing,
170            },
171        ),
172    };
173
174    Ok(WindowSpec {
175        function: name,
176        args,
177        partition_by,
178        order_by,
179        alias: alias.into(),
180        frame,
181    })
182}
183
184/// Convert a window function's argument list.
185///
186/// Each argument is a full expression that the evaluator evaluates per row.
187/// An argument the converter cannot represent is an error, never a dropped
188/// argument: discarding one silently turns `SUM(price * qty) OVER (...)` into
189/// a windowed column of NULLs that still reports success.
190fn convert_window_args(
191    func: &ast::Function,
192    name: &str,
193    scope: &TableScope,
194) -> Result<Vec<SqlExpr>> {
195    let ast::FunctionArguments::List(list) = &func.args else {
196        return Ok(Vec::new());
197    };
198
199    let mut args = Vec::with_capacity(list.args.len());
200    for arg in &list.args {
201        match arg {
202            ast::FunctionArg::Unnamed(ast::FunctionArgExpr::Expr(e)) => {
203                args.push(convert_expr(e, &ColumnScope::Relations(scope))?)
204            }
205            // `COUNT(*) OVER (...)` — a wildcard carries no value to evaluate.
206            // The evaluator counts frame rows when no argument is present.
207            ast::FunctionArg::Unnamed(ast::FunctionArgExpr::Wildcard) => {}
208            other => {
209                return Err(SqlError::Unsupported {
210                    detail: format!("argument form in '{name}() OVER (...)': {other:?}"),
211                });
212            }
213        }
214    }
215    Ok(args)
216}
217
218/// Reject a non-constant in the argument positions PostgreSQL requires to be
219/// constant: the `LAG`/`LEAD` offset and default, the `NTILE` bucket count,
220/// and the `NTH_VALUE` position.
221///
222/// The evaluator resolves these once per partition rather than per row. Left
223/// unvalidated, a non-constant there falls back to the position's default and
224/// silently answers a different query than the one asked.
225fn validate_constant_args(name: &str, args: &[SqlExpr]) -> Result<()> {
226    let constant_positions: &[usize] = match name {
227        "lag" | "lead" => &[1, 2],
228        "ntile" => &[0],
229        "nth_value" => &[1],
230        _ => return Ok(()),
231    };
232
233    for &pos in constant_positions {
234        if let Some(arg) = args.get(pos)
235            && !matches!(arg, SqlExpr::Literal(_))
236        {
237            return Err(SqlError::Unsupported {
238                detail: format!(
239                    "argument {} of '{name}() OVER (...)' must be a constant",
240                    pos + 1
241                ),
242            });
243        }
244    }
245    Ok(())
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::functions::registry::FunctionRegistry;
252    use crate::parser::statement::parse_sql;
253    use crate::resolver::columns::test_support::open_scope;
254
255    fn select_of(sql: &str) -> Box<ast::Select> {
256        match parse_sql(sql).unwrap().into_iter().next().unwrap() {
257            ast::Statement::Query(q) => match *q.body {
258                ast::SetExpr::Select(s) => s,
259                _ => panic!("not a SELECT"),
260            },
261            _ => panic!("not a query"),
262        }
263    }
264
265    #[test]
266    fn named_window_referenced_by_multiple_functions() {
267        let reg = FunctionRegistry::new();
268        let select = select_of(
269            "SELECT first_value(price) OVER w AS o, last_value(price) OVER w AS c, sum(volume) OVER w AS v
270             FROM ticks
271             WINDOW w AS (PARTITION BY bucket ORDER BY ts ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)",
272        );
273        let specs = extract_window_functions(&select, &reg, &open_scope("ticks")).unwrap();
274        assert_eq!(specs.len(), 3);
275        for s in &specs {
276            assert_eq!(
277                s.partition_by.len(),
278                1,
279                "partition by must be resolved from WINDOW clause"
280            );
281            assert_eq!(
282                s.order_by.len(),
283                1,
284                "order by must be resolved from WINDOW clause"
285            );
286            assert_eq!(s.frame.mode, "rows");
287            assert!(matches!(s.frame.start, FrameBound::UnboundedPreceding));
288            assert!(matches!(s.frame.end, FrameBound::UnboundedFollowing));
289        }
290    }
291
292    #[test]
293    fn undefined_named_window_is_rejected() {
294        let reg = FunctionRegistry::new();
295        let select = select_of("SELECT row_number() OVER missing AS r FROM t");
296        let err = extract_window_functions(&select, &reg, &open_scope("ticks")).unwrap_err();
297        assert!(
298            format!("{err}").contains("missing"),
299            "error must name the missing window: {err}"
300        );
301    }
302
303    #[test]
304    fn window_definition_referencing_another_resolves() {
305        let reg = FunctionRegistry::new();
306        let select = select_of(
307            "SELECT sum(x) OVER w2 AS s FROM t WINDOW w1 AS (PARTITION BY a), w2 AS (w1 ORDER BY ts)",
308        );
309        let specs = extract_window_functions(&select, &reg, &open_scope("ticks")).unwrap();
310        assert_eq!(specs.len(), 1);
311        assert_eq!(
312            specs[0].partition_by.len(),
313            1,
314            "PARTITION BY inherited from w1"
315        );
316        assert_eq!(specs[0].order_by.len(), 1, "ORDER BY added by w2");
317    }
318
319    #[test]
320    fn circular_named_window_is_rejected() {
321        let reg = FunctionRegistry::new();
322        let select = select_of("SELECT sum(x) OVER w1 AS s FROM t WINDOW w1 AS (w2), w2 AS (w1)");
323        let err = extract_window_functions(&select, &reg, &open_scope("ticks")).unwrap_err();
324        assert!(
325            format!("{err}").to_lowercase().contains("circular"),
326            "got: {err}"
327        );
328    }
329
330    #[test]
331    fn ohlcv_shape_base_window_plus_derived_ordered_window() {
332        // Mirrors nodedb-docs/use-cases/fintech-trading.rdx OHLCV bars:
333        // a base `w` (PARTITION BY only) used by max/min/sum, and a derived
334        // `w_ord` (= w + ORDER BY + frame) used by first_value/last_value.
335        let reg = FunctionRegistry::new();
336        let select = select_of(
337            "SELECT first_value(price) OVER w_ord AS o, max(price) OVER w AS h,
338                    min(price) OVER w AS l, last_value(price) OVER w_ord AS c, sum(volume) OVER w AS v
339             FROM ticks
340             WINDOW w     AS (PARTITION BY time_bucket('1m', ts), symbol),
341                    w_ord AS (w ORDER BY ts ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)",
342        );
343        let specs = extract_window_functions(&select, &reg, &open_scope("ticks")).unwrap();
344        assert_eq!(specs.len(), 5);
345        for s in &specs {
346            assert_eq!(
347                s.partition_by.len(),
348                2,
349                "{}: partition inherited from w",
350                s.function
351            );
352        }
353        // first_value / last_value carry the ORDER BY + explicit frame from w_ord.
354        for f in ["first_value", "last_value"] {
355            let s = specs.iter().find(|s| s.function == f).unwrap();
356            assert_eq!(s.order_by.len(), 1, "{f}: order by from w_ord");
357            assert_eq!(s.frame.mode, "rows", "{f}: frame from w_ord");
358            assert!(matches!(s.frame.start, FrameBound::UnboundedPreceding));
359            assert!(matches!(s.frame.end, FrameBound::UnboundedFollowing));
360        }
361        // max / min / sum use the base w: no order, whole-partition frame.
362        for f in ["max", "min", "sum"] {
363            let s = specs.iter().find(|s| s.function == f).unwrap();
364            assert!(s.order_by.is_empty(), "{f}: no order by");
365            assert_eq!(s.frame.mode, "range");
366            assert!(matches!(s.frame.start, FrameBound::UnboundedPreceding));
367            assert!(matches!(s.frame.end, FrameBound::UnboundedFollowing));
368        }
369    }
370
371    #[test]
372    fn inline_window_referencing_named_inherits_partition() {
373        let reg = FunctionRegistry::new();
374        let select = select_of(
375            "SELECT sum(x) OVER (w ORDER BY ts) AS s FROM t WINDOW w AS (PARTITION BY a)",
376        );
377        let specs = extract_window_functions(&select, &reg, &open_scope("ticks")).unwrap();
378        assert_eq!(specs[0].partition_by.len(), 1);
379        assert_eq!(specs[0].order_by.len(), 1);
380    }
381}