nodedb_sql/planner/window/
extract.rs1use 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
30pub 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 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 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 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 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
184fn 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 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
218fn 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, ®, &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, ®, &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, ®, &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, ®, &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 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, ®, &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 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 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, ®, &open_scope("ticks")).unwrap();
378 assert_eq!(specs[0].partition_by.len(), 1);
379 assert_eq!(specs[0].order_by.len(), 1);
380 }
381}