1use std::collections::HashMap;
39
40use datafusion::sql::sqlparser::ast::{
41 Expr, Function, FunctionArg, FunctionArgExpr, FunctionArguments, Query, Select, SelectItem,
42 SetExpr, Statement, TableFactor, Value,
43};
44use datafusion::sql::sqlparser::dialect::DuckDbDialect;
45use datafusion::sql::sqlparser::parser::Parser;
46use krishiv_plan::window::{
47 AggFilterCompareOp, AggFilterValue, FloatLiteral, WindowAgg, WindowAggFilter, WindowAggKind,
48 WindowExecutionSpec, WindowKind,
49};
50
51use crate::streaming_tvf::{find_window_tvf, rewrite_window_tvfs};
52use crate::{SqlError, SqlResult};
53
54#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct StreamingWindowPlan {
58 pub spec: WindowExecutionSpec,
60 pub source: String,
62}
63
64fn unsupported(msg: impl Into<String>) -> SqlError {
65 SqlError::Unsupported {
66 feature: msg.into(),
67 }
68}
69
70fn parse_ms(raw: &str) -> SqlResult<u64> {
71 raw.trim().parse::<u64>().map_err(|_| {
72 unsupported(format!(
73 "window interval '{raw}' is not a millisecond count"
74 ))
75 })
76}
77
78pub fn is_windowed_streaming_sql(sql: &str) -> bool {
80 find_window_tvf(sql).is_some()
81}
82
83pub fn compile_streaming_window_sql(sql: &str) -> SqlResult<StreamingWindowPlan> {
94 let rewritten = rewrite_window_tvfs(sql);
95 let select = parse_single_select(&rewritten)?;
96
97 let (window, source) = extract_window(&select)?;
98 let (key_column, agg_exprs) = extract_key_and_aggs(&select)?;
99
100 let spec = WindowExecutionSpec {
101 key_column,
102 key_column_type: String::from("utf8"),
103 event_time_column: window.event_time_column,
104 watermark_lag_ms: 0,
105 window_kind: window.kind,
106 window_size_ms: window.window_size_ms,
107 slide_ms: window.slide_ms,
108 session_gap_ms: window.session_gap_ms,
109 agg_exprs,
110 state_ttl_ms: None,
111 allowed_lateness_ms: None,
112 source_watermark_lags: HashMap::new(),
113 source_id_column: None,
114 window_timezone: None,
115 };
116 Ok(StreamingWindowPlan { spec, source })
117}
118
119fn parse_single_select(sql: &str) -> SqlResult<Select> {
123 let dialect = DuckDbDialect {};
124 let stmts = Parser::parse_sql(&dialect, sql)
125 .map_err(|e| unsupported(format!("streaming window query parse error: {e}")))?;
126 let query = stmts
127 .into_iter()
128 .find_map(|s| match s {
129 Statement::Query(q) => Some(q),
130 _ => None,
131 })
132 .ok_or_else(|| unsupported("streaming window query must be a SELECT"))?;
133 match *query.body {
134 SetExpr::Select(select) => Ok(*select),
135 _ => Err(unsupported("streaming window query must be a plain SELECT")),
136 }
137}
138
139struct WindowParams {
141 kind: WindowKind,
142 event_time_column: String,
143 window_size_ms: u64,
144 slide_ms: Option<u64>,
145 session_gap_ms: Option<u64>,
146}
147
148fn extract_window(select: &Select) -> SqlResult<(WindowParams, String)> {
153 let subquery = find_tvf_window_subquery(select)
154 .ok_or_else(|| unsupported("query has no TUMBLE/HOP/SESSION window"))?;
155 let SetExpr::Select(inner) = subquery.body.as_ref() else {
156 return Err(unsupported("windowed source must be a plain SELECT"));
157 };
158 let boundary = projection_alias_expr(inner, "window_start")
159 .ok_or_else(|| unsupported("windowed source is missing its window_start boundary"))?;
160 let Expr::Function(func) = boundary else {
161 return Err(unsupported(
162 "window_start must be produced by a window boundary function",
163 ));
164 };
165 let params = window_params_from_udf(func)?;
166 let source = single_source_name(inner)?;
167 Ok((params, source))
168}
169
170fn find_tvf_window_subquery(select: &Select) -> Option<&Query> {
172 fn from_relation(relation: &TableFactor) -> Option<&Query> {
173 match relation {
174 TableFactor::Derived {
175 subquery, alias, ..
176 } if alias.as_ref().map(|a| a.name.value.as_str()) == Some("_tvf_window") => {
177 Some(subquery.as_ref())
178 }
179 _ => None,
180 }
181 }
182 for twj in &select.from {
183 if let Some(q) = from_relation(&twj.relation) {
184 return Some(q);
185 }
186 for join in &twj.joins {
187 if let Some(q) = from_relation(&join.relation) {
188 return Some(q);
189 }
190 }
191 }
192 None
193}
194
195fn projection_alias_expr<'a>(select: &'a Select, alias: &str) -> Option<&'a Expr> {
197 select.projection.iter().find_map(|item| match item {
198 SelectItem::ExprWithAlias { expr, alias: a } if a.value == alias => Some(expr),
199 _ => None,
200 })
201}
202
203fn window_params_from_udf(func: &Function) -> SqlResult<WindowParams> {
206 let name = func.name.to_string().to_ascii_lowercase();
207 let args = function_arg_exprs(func);
208 let event_time_column = args
209 .first()
210 .and_then(|e| ident_name(e))
211 .ok_or_else(|| unsupported("window function needs an event-time column argument"))?;
212 let ms = |slot: Option<&&Expr>, what: &str| -> SqlResult<u64> {
213 let expr = slot.ok_or_else(|| unsupported(format!("window function needs a {what}")))?;
214 let literal = number_literal(expr).ok_or_else(|| {
215 unsupported(format!(
216 "window {what} must be an integer millisecond literal"
217 ))
218 })?;
219 parse_ms(&literal)
220 };
221 match name.as_str() {
222 "tumble_start" => Ok(WindowParams {
223 kind: WindowKind::Tumbling,
224 event_time_column,
225 window_size_ms: ms(args.get(1), "size")?,
226 slide_ms: None,
227 session_gap_ms: None,
228 }),
229 "hop_start" => Ok(WindowParams {
230 kind: WindowKind::Sliding,
231 event_time_column,
232 slide_ms: Some(ms(args.get(1), "slide")?),
233 window_size_ms: ms(args.get(2), "size")?,
234 session_gap_ms: None,
235 }),
236 "session_start" => {
237 let gap = ms(args.get(1), "gap")?;
238 Ok(WindowParams {
239 kind: WindowKind::Session,
240 event_time_column,
241 window_size_ms: gap,
242 slide_ms: None,
243 session_gap_ms: Some(gap),
244 })
245 }
246 other => Err(unsupported(format!(
247 "unrecognised window boundary function '{other}'"
248 ))),
249 }
250}
251
252fn single_source_name(inner: &Select) -> SqlResult<String> {
254 let [only] = inner.from.as_slice() else {
255 return Err(unsupported(
256 "windowed source must read from exactly one table",
257 ));
258 };
259 if !only.joins.is_empty() {
260 return Err(unsupported(
261 "streaming windows do not support joins in the windowed source yet",
262 ));
263 }
264 match &only.relation {
265 TableFactor::Table { name, .. } => Ok(name.to_string()),
266 _ => Err(unsupported("windowed source must be a base table")),
267 }
268}
269
270fn function_arg_exprs(func: &Function) -> Vec<&Expr> {
272 let FunctionArguments::List(list) = &func.args else {
273 return Vec::new();
274 };
275 list.args
276 .iter()
277 .filter_map(|arg| match arg {
278 FunctionArg::Unnamed(FunctionArgExpr::Expr(e)) => Some(e),
279 FunctionArg::Named {
280 arg: FunctionArgExpr::Expr(e),
281 ..
282 } => Some(e),
283 _ => None,
284 })
285 .collect()
286}
287
288fn ident_name(expr: &Expr) -> Option<String> {
290 match expr {
291 Expr::Identifier(id) => Some(id.value.clone()),
292 Expr::CompoundIdentifier(parts) => parts.last().map(|p| p.value.clone()),
293 _ => None,
294 }
295}
296
297fn number_literal(expr: &Expr) -> Option<String> {
299 match expr {
300 Expr::Value(v) => match &v.value {
301 Value::Number(n, _) => Some(n.clone()),
302 _ => None,
303 },
304 _ => None,
305 }
306}
307
308const WINDOW_BOUNDARY_COLS: [&str; 2] = ["window_start", "window_end"];
309
310fn extract_key_and_aggs(select: &Select) -> SqlResult<(String, Vec<WindowAgg>)> {
311 let mut key_column: Option<String> = None;
312 let mut aggs: Vec<WindowAgg> = Vec::new();
313
314 for item in &select.projection {
315 let (expr, alias) = match item {
316 SelectItem::UnnamedExpr(e) => (e, None),
317 SelectItem::ExprWithAlias { expr, alias } => (expr, Some(alias.value.clone())),
318 _ => continue,
319 };
320 match expr {
321 Expr::Function(f) => aggs.push(function_to_agg(f, alias)?),
322 Expr::Identifier(id) => maybe_set_key(&mut key_column, &id.value),
323 Expr::CompoundIdentifier(parts) => {
324 if let Some(last) = parts.last() {
325 maybe_set_key(&mut key_column, &last.value);
326 }
327 }
328 _ => continue,
329 }
330 }
331
332 let key_column = key_column.ok_or_else(|| {
333 unsupported("streaming window query needs a grouping key column in the SELECT list")
334 })?;
335 if aggs.is_empty() {
336 aggs.push(WindowAgg::count("count"));
337 }
338 Ok((key_column, aggs))
339}
340
341fn maybe_set_key(key: &mut Option<String>, name: &str) {
342 if key.is_none() && !WINDOW_BOUNDARY_COLS.contains(&name) {
343 *key = Some(name.to_string());
344 }
345}
346
347fn function_to_agg(f: &Function, alias: Option<String>) -> SqlResult<WindowAgg> {
348 let fname = f.name.to_string().to_ascii_lowercase();
349 let mut kind = match fname.as_str() {
350 "count" => WindowAggKind::Count,
351 "sum" => WindowAggKind::Sum,
352 "min" => WindowAggKind::Min,
353 "max" => WindowAggKind::Max,
354 "avg" => WindowAggKind::Avg,
355 "stddev" | "stddev_samp" => WindowAggKind::Stddev,
356 other => {
357 return Err(unsupported(format!(
358 "aggregate '{other}' is not supported in streaming windows; \
359 use count/sum/min/max/avg/stddev"
360 )));
361 }
362 };
363
364 let mut filter = match &f.filter {
366 Some(predicate) => Some(lower_filter_expr(predicate)?),
367 None => None,
368 };
369
370 let mut input_column = None;
373 if let Some(arg) = first_arg_expr(f) {
374 match arg {
375 Expr::Identifier(id) => input_column = Some(id.value.clone()),
376 Expr::CompoundIdentifier(parts) => {
377 input_column = parts.last().map(|p| p.value.clone());
378 }
379 Expr::Case { .. } => {
380 let lowered = lower_case_arg(arg, kind, &fname)?;
381 kind = lowered.kind;
382 input_column = lowered.input_column;
383 filter = Some(match filter {
384 Some(existing) => {
385 WindowAggFilter::And(Box::new(existing), Box::new(lowered.filter))
386 }
387 None => lowered.filter,
388 });
389 }
390 _ => {}
392 }
393 }
394
395 let output_column = alias.unwrap_or_else(|| match &input_column {
396 Some(col) => format!("{fname}_{col}"),
397 None => fname.clone(),
398 });
399 Ok(WindowAgg {
400 kind,
401 input_column: input_column.unwrap_or_default(),
402 output_column,
403 filter,
404 })
405}
406
407struct LoweredCaseArg {
411 kind: WindowAggKind,
412 input_column: Option<String>,
413 filter: WindowAggFilter,
414}
415
416fn lower_case_arg(case: &Expr, kind: WindowAggKind, fname: &str) -> SqlResult<LoweredCaseArg> {
417 let Expr::Case {
418 operand,
419 conditions,
420 else_result,
421 ..
422 } = case
423 else {
424 return Err(unsupported("expected a CASE aggregate argument"));
425 };
426 if operand.is_some() {
427 return Err(unsupported(
428 "CASE <operand> WHEN … aggregate arguments are not supported in streaming \
429 windows; use a searched CASE WHEN <predicate> THEN …",
430 ));
431 }
432 let [when] = conditions.as_slice() else {
433 return Err(unsupported(
434 "streaming windows support exactly one WHEN branch in a CASE aggregate argument",
435 ));
436 };
437 let filter = lower_filter_expr(&when.condition)?;
438
439 match else_result.as_deref() {
441 None => {}
442 Some(Expr::Value(v)) if matches!(&v.value, Value::Null) => {}
443 Some(Expr::Value(v))
444 if matches!(kind, WindowAggKind::Sum | WindowAggKind::Count)
445 && matches!(&v.value, Value::Number(n, _) if n == "0") => {}
446 Some(other) => {
447 return Err(unsupported(format!(
448 "CASE aggregate argument ELSE branch '{other}' is not the {fname} identity; \
449 use ELSE NULL (or ELSE 0 for SUM/COUNT)"
450 )));
451 }
452 }
453
454 match &when.result {
455 Expr::Value(v) => match (&v.value, kind) {
458 (Value::Number(n, _), WindowAggKind::Sum) if n == "1" => Ok(LoweredCaseArg {
459 kind: WindowAggKind::Count,
460 input_column: None,
461 filter,
462 }),
463 (Value::Number(_, _), WindowAggKind::Count) => Ok(LoweredCaseArg {
464 kind: WindowAggKind::Count,
465 input_column: None,
466 filter,
467 }),
468 _ => Err(unsupported(format!(
469 "CASE aggregate argument THEN branch must be a column (or the literal 1 \
470 for a conditional count); got a literal under {fname}"
471 ))),
472 },
473 Expr::Identifier(_) | Expr::CompoundIdentifier(_) => {
476 let column = match &when.result {
477 Expr::Identifier(id) => id.value.clone(),
478 Expr::CompoundIdentifier(parts) => parts
479 .last()
480 .map(|p| p.value.clone())
481 .ok_or_else(|| unsupported("empty compound identifier in CASE THEN"))?,
482 _ => unreachable!("outer match restricts to identifiers"),
483 };
484 let filter = if kind == WindowAggKind::Count {
485 WindowAggFilter::And(
486 Box::new(filter),
487 Box::new(WindowAggFilter::IsNotNull {
488 column: column.clone(),
489 }),
490 )
491 } else {
492 filter
493 };
494 let input_column = (kind != WindowAggKind::Count).then_some(column);
495 Ok(LoweredCaseArg {
496 kind,
497 input_column,
498 filter,
499 })
500 }
501 other => Err(unsupported(format!(
502 "CASE aggregate argument THEN branch '{other}' is not supported in streaming \
503 windows; use a column or literal 1"
504 ))),
505 }
506}
507
508fn lower_filter_expr(expr: &Expr) -> SqlResult<WindowAggFilter> {
512 use datafusion::sql::sqlparser::ast::BinaryOperator;
513 match expr {
514 Expr::Nested(inner) => lower_filter_expr(inner),
515 Expr::BinaryOp { left, op, right } => match op {
516 BinaryOperator::And => Ok(WindowAggFilter::And(
517 Box::new(lower_filter_expr(left)?),
518 Box::new(lower_filter_expr(right)?),
519 )),
520 BinaryOperator::Or => Ok(WindowAggFilter::Or(
521 Box::new(lower_filter_expr(left)?),
522 Box::new(lower_filter_expr(right)?),
523 )),
524 _ => lower_comparison(left, op, right),
525 },
526 Expr::IsNull(inner) => Ok(WindowAggFilter::IsNull {
527 column: expr_column(inner)?,
528 }),
529 Expr::IsNotNull(inner) => Ok(WindowAggFilter::IsNotNull {
530 column: expr_column(inner)?,
531 }),
532 Expr::UnaryOp {
533 op: datafusion::sql::sqlparser::ast::UnaryOperator::Not,
534 expr,
535 } => Ok(WindowAggFilter::Not(Box::new(lower_filter_expr(expr)?))),
536 Expr::Identifier(_) | Expr::CompoundIdentifier(_) => Ok(WindowAggFilter::Compare {
538 column: expr_column(expr)?,
539 op: AggFilterCompareOp::Eq,
540 value: AggFilterValue::Bool(true),
541 }),
542 other => Err(unsupported(format!(
543 "aggregate filter predicate '{other}' is not supported in streaming windows; \
544 use column-vs-literal comparisons combined with AND/OR/NOT and IS [NOT] NULL"
545 ))),
546 }
547}
548
549fn lower_comparison(
550 left: &Expr,
551 op: &datafusion::sql::sqlparser::ast::BinaryOperator,
552 right: &Expr,
553) -> SqlResult<WindowAggFilter> {
554 use datafusion::sql::sqlparser::ast::BinaryOperator;
555 let mapped = match op {
556 BinaryOperator::Eq => AggFilterCompareOp::Eq,
557 BinaryOperator::NotEq => AggFilterCompareOp::NotEq,
558 BinaryOperator::Lt => AggFilterCompareOp::Lt,
559 BinaryOperator::LtEq => AggFilterCompareOp::LtEq,
560 BinaryOperator::Gt => AggFilterCompareOp::Gt,
561 BinaryOperator::GtEq => AggFilterCompareOp::GtEq,
562 other => {
563 return Err(unsupported(format!(
564 "aggregate filter operator '{other}' is not supported in streaming windows"
565 )));
566 }
567 };
568 if let (Ok(column), Some(value)) = (expr_column(left), expr_literal(right)) {
570 Ok(WindowAggFilter::Compare {
571 column,
572 op: mapped,
573 value,
574 })
575 } else if let (Some(value), Ok(column)) = (expr_literal(left), expr_column(right)) {
576 let mirrored = match mapped {
577 AggFilterCompareOp::Lt => AggFilterCompareOp::Gt,
578 AggFilterCompareOp::LtEq => AggFilterCompareOp::GtEq,
579 AggFilterCompareOp::Gt => AggFilterCompareOp::Lt,
580 AggFilterCompareOp::GtEq => AggFilterCompareOp::LtEq,
581 symmetric => symmetric,
582 };
583 Ok(WindowAggFilter::Compare {
584 column,
585 op: mirrored,
586 value,
587 })
588 } else {
589 Err(unsupported(
590 "aggregate filter comparisons must be column-vs-literal in streaming windows",
591 ))
592 }
593}
594
595fn expr_column(expr: &Expr) -> SqlResult<String> {
596 match expr {
597 Expr::Identifier(id) => Ok(id.value.clone()),
598 Expr::CompoundIdentifier(parts) => parts
599 .last()
600 .map(|p| p.value.clone())
601 .ok_or_else(|| unsupported("empty compound identifier in aggregate filter")),
602 Expr::Nested(inner) => expr_column(inner),
603 other => Err(unsupported(format!(
604 "aggregate filter expected a column, got '{other}'"
605 ))),
606 }
607}
608
609fn expr_literal(expr: &Expr) -> Option<AggFilterValue> {
610 let Expr::Value(v) = expr else {
611 return None;
612 };
613 match &v.value {
614 Value::Number(n, _) => {
615 if let Ok(i) = n.parse::<i64>() {
616 Some(AggFilterValue::Int(i))
617 } else {
618 n.parse::<f64>()
619 .ok()
620 .map(|f| AggFilterValue::Float(FloatLiteral(f)))
621 }
622 }
623 Value::SingleQuotedString(s) | Value::DoubleQuotedString(s) => {
624 Some(AggFilterValue::Utf8(s.clone()))
625 }
626 Value::Boolean(b) => Some(AggFilterValue::Bool(*b)),
627 _ => None,
628 }
629}
630
631fn first_arg_expr(f: &Function) -> Option<&Expr> {
632 let FunctionArguments::List(list) = &f.args else {
633 return None;
634 };
635 for fa in &list.args {
636 match fa {
637 FunctionArg::Unnamed(FunctionArgExpr::Expr(e)) => return Some(e),
638 FunctionArg::Named {
639 arg: FunctionArgExpr::Expr(e),
640 ..
641 } => return Some(e),
642 _ => {}
643 }
644 }
645 None
646}
647
648#[cfg(test)]
649mod tests {
650 #![allow(clippy::unwrap_used)]
651
652 use super::*;
653
654 #[test]
655 fn compiles_tumbling_window() {
656 let sql = "SELECT user_id, SUM(amount) AS total \
657 FROM TUMBLE(TABLE events, DESCRIPTOR(ts), 60000) \
658 GROUP BY user_id, window_start, window_end";
659 let plan = compile_streaming_window_sql(sql).unwrap();
660 assert_eq!(plan.source, "events");
661 assert_eq!(plan.spec.window_kind, WindowKind::Tumbling);
662 assert_eq!(plan.spec.window_size_ms, 60000);
663 assert_eq!(plan.spec.event_time_column, "ts");
664 assert_eq!(plan.spec.key_column, "user_id");
665 assert_eq!(plan.spec.agg_exprs.len(), 1);
666 assert_eq!(plan.spec.agg_exprs[0].kind, WindowAggKind::Sum);
667 assert_eq!(plan.spec.agg_exprs[0].input_column, "amount");
668 assert_eq!(plan.spec.agg_exprs[0].output_column, "total");
669 }
670
671 #[test]
672 fn compiles_tumbling_window_from_interval_string() {
673 let sql = "SELECT user_id, COUNT(*) AS c \
676 FROM TUMBLE(TABLE events, DESCRIPTOR(ts), '1 minute') \
677 GROUP BY user_id, window_start, window_end";
678 let plan = compile_streaming_window_sql(sql).unwrap();
679 assert_eq!(plan.spec.window_kind, WindowKind::Tumbling);
680 assert_eq!(plan.spec.window_size_ms, 60_000);
681 assert_eq!(plan.spec.event_time_column, "ts");
682 assert_eq!(plan.source, "events");
683 }
684
685 #[test]
686 fn compiles_with_schema_qualified_source() {
687 let sql = "SELECT user_id, SUM(amount) AS total \
690 FROM TUMBLE(TABLE analytics.events, DESCRIPTOR(ts), 60000) \
691 GROUP BY user_id, window_start, window_end";
692 let plan = compile_streaming_window_sql(sql).unwrap();
693 assert_eq!(plan.source, "analytics.events");
694 assert_eq!(plan.spec.key_column, "user_id");
695 }
696
697 #[test]
698 fn compiles_tumbling_window_with_stddev() {
699 let sql = "SELECT k, STDDEV(v) AS spread \
700 FROM TUMBLE(TABLE m, DESCRIPTOR(ts), 60000) \
701 GROUP BY k, window_start, window_end";
702 let plan = compile_streaming_window_sql(sql).unwrap();
703 assert_eq!(plan.spec.agg_exprs[0].kind, WindowAggKind::Stddev);
704 assert_eq!(plan.spec.agg_exprs[0].input_column, "v");
705 assert_eq!(plan.spec.agg_exprs[0].output_column, "spread");
706 }
707
708 #[test]
709 fn compiles_hop_window_with_slide() {
710 let sql = "SELECT k, COUNT(*) AS c \
711 FROM HOP(TABLE clicks, DESCRIPTOR(ts), 30000, 60000) \
712 GROUP BY k, window_start, window_end";
713 let plan = compile_streaming_window_sql(sql).unwrap();
714 assert_eq!(plan.spec.window_kind, WindowKind::Sliding);
715 assert_eq!(plan.spec.window_size_ms, 60000);
716 assert_eq!(plan.spec.slide_ms, Some(30000));
717 assert_eq!(plan.spec.agg_exprs[0].kind, WindowAggKind::Count);
718 }
719
720 #[test]
721 fn compiles_session_window_with_gap() {
722 let sql = "SELECT k, MAX(v) AS hi \
723 FROM SESSION(TABLE events, DESCRIPTOR(ts), 15000) \
724 GROUP BY k, window_start, window_end";
725 let plan = compile_streaming_window_sql(sql).unwrap();
726 assert_eq!(plan.spec.window_kind, WindowKind::Session);
727 assert_eq!(plan.spec.session_gap_ms, Some(15000));
728 assert_eq!(plan.spec.agg_exprs[0].kind, WindowAggKind::Max);
729 }
730
731 #[test]
732 fn non_windowed_query_is_unsupported() {
733 let err = compile_streaming_window_sql("SELECT a FROM t").unwrap_err();
734 assert!(matches!(err, SqlError::Unsupported { .. }));
735 }
736
737 #[test]
738 fn unsupported_aggregate_is_rejected() {
739 let sql = "SELECT k, MEDIAN(v) AS s \
740 FROM TUMBLE(TABLE events, DESCRIPTOR(ts), 60000) \
741 GROUP BY k, window_start, window_end";
742 let err = compile_streaming_window_sql(sql).unwrap_err();
743 assert!(matches!(err, SqlError::Unsupported { .. }));
744 }
745
746 #[test]
747 fn compiles_count_filter_where() {
748 let sql = "SELECT domain, COUNT(*) FILTER (WHERE kind = 'edit') AS edits \
749 FROM TUMBLE(TABLE events, DESCRIPTOR(ts), 60000) \
750 GROUP BY domain, window_start, window_end";
751 let plan = compile_streaming_window_sql(sql).unwrap();
752 let agg = &plan.spec.agg_exprs[0];
753 assert_eq!(agg.kind, WindowAggKind::Count);
754 assert_eq!(agg.output_column, "edits");
755 assert_eq!(
756 agg.filter,
757 Some(WindowAggFilter::Compare {
758 column: "kind".into(),
759 op: AggFilterCompareOp::Eq,
760 value: AggFilterValue::Utf8("edit".into()),
761 })
762 );
763 }
764
765 #[test]
766 fn compiles_sum_case_when_column() {
767 let sql = "SELECT domain, SUM(CASE WHEN kind = 'edit' THEN size END) AS edit_bytes \
768 FROM TUMBLE(TABLE events, DESCRIPTOR(ts), 60000) \
769 GROUP BY domain, window_start, window_end";
770 let plan = compile_streaming_window_sql(sql).unwrap();
771 let agg = &plan.spec.agg_exprs[0];
772 assert_eq!(agg.kind, WindowAggKind::Sum);
773 assert_eq!(agg.input_column, "size");
774 assert_eq!(agg.output_column, "edit_bytes");
775 assert!(agg.filter.is_some());
776 }
777
778 #[test]
779 fn sum_case_when_one_lowers_to_conditional_count() {
780 let sql = "SELECT domain, SUM(CASE WHEN is_bot = true THEN 1 ELSE 0 END) AS bots \
781 FROM TUMBLE(TABLE events, DESCRIPTOR(ts), 60000) \
782 GROUP BY domain, window_start, window_end";
783 let plan = compile_streaming_window_sql(sql).unwrap();
784 let agg = &plan.spec.agg_exprs[0];
785 assert_eq!(agg.kind, WindowAggKind::Count, "SUM of 1s is a count");
786 assert_eq!(
787 agg.filter,
788 Some(WindowAggFilter::Compare {
789 column: "is_bot".into(),
790 op: AggFilterCompareOp::Eq,
791 value: AggFilterValue::Bool(true),
792 })
793 );
794 }
795
796 #[test]
797 fn bare_boolean_filter_predicate_compiles() {
798 let sql = "SELECT domain, COUNT(*) FILTER (WHERE is_bot) AS bots \
799 FROM TUMBLE(TABLE events, DESCRIPTOR(ts), 60000) \
800 GROUP BY domain, window_start, window_end";
801 let plan = compile_streaming_window_sql(sql).unwrap();
802 assert_eq!(
803 plan.spec.agg_exprs[0].filter,
804 Some(WindowAggFilter::Compare {
805 column: "is_bot".into(),
806 op: AggFilterCompareOp::Eq,
807 value: AggFilterValue::Bool(true),
808 })
809 );
810 }
811
812 #[test]
813 fn filter_and_case_combine_with_and() {
814 let sql = "SELECT domain, \
815 SUM(CASE WHEN kind = 'edit' THEN size END) FILTER (WHERE size > 100) AS big \
816 FROM TUMBLE(TABLE events, DESCRIPTOR(ts), 60000) \
817 GROUP BY domain, window_start, window_end";
818 let plan = compile_streaming_window_sql(sql).unwrap();
819 let agg = &plan.spec.agg_exprs[0];
820 assert_eq!(agg.kind, WindowAggKind::Sum);
821 assert_eq!(agg.input_column, "size");
822 assert!(
823 matches!(agg.filter, Some(WindowAggFilter::And(_, _))),
824 "FILTER clause and CASE condition must both apply: {:?}",
825 agg.filter
826 );
827 }
828
829 #[test]
830 fn rejects_case_with_multiple_when_branches() {
831 let sql = "SELECT domain, \
832 SUM(CASE WHEN a = 1 THEN x WHEN b = 2 THEN y END) AS s \
833 FROM TUMBLE(TABLE events, DESCRIPTOR(ts), 60000) \
834 GROUP BY domain, window_start, window_end";
835 let err = compile_streaming_window_sql(sql).unwrap_err();
836 assert!(matches!(err, SqlError::Unsupported { .. }));
837 }
838
839 #[test]
840 fn rejects_non_identity_else_branch() {
841 let sql = "SELECT domain, MAX(CASE WHEN a = 1 THEN x ELSE 0 END) AS m \
842 FROM TUMBLE(TABLE events, DESCRIPTOR(ts), 60000) \
843 GROUP BY domain, window_start, window_end";
844 let err = compile_streaming_window_sql(sql).unwrap_err();
845 assert!(
846 matches!(err, SqlError::Unsupported { .. }),
847 "ELSE 0 under MAX changes semantics and must be rejected"
848 );
849 }
850
851 #[test]
852 fn window_boundary_columns_are_not_treated_as_key() {
853 let sql = "SELECT window_start, user_id, COUNT(*) AS c \
854 FROM TUMBLE(TABLE events, DESCRIPTOR(ts), 60000) \
855 GROUP BY user_id, window_start, window_end";
856 let plan = compile_streaming_window_sql(sql).unwrap();
857 assert_eq!(plan.spec.key_column, "user_id");
858 }
859
860 #[test]
861 fn detects_windowed_sql() {
862 assert!(is_windowed_streaming_sql(
863 "SELECT k FROM TUMBLE(TABLE t, DESCRIPTOR(ts), 1000) GROUP BY k"
864 ));
865 assert!(!is_windowed_streaming_sql("SELECT k FROM t"));
866 }
867}