Skip to main content

krishiv_sql/
streaming_window_plan.rs

1//! Compile a windowed streaming SQL query into a [`WindowExecutionSpec`].
2//!
3//! Supports the canonical keyed windowed-aggregation shape:
4//!
5//! ```sql
6//! SELECT key, AGG(col) AS out [, ...]
7//! FROM TUMBLE(TABLE src, DESCRIPTOR(ts), <size>)   -- or HOP / SESSION
8//! GROUP BY key, window_start, window_end
9//! ```
10//!
11//! # One SQL front door (Phase 60)
12//!
13//! Streaming and batch share **one** front door for the window TVF and **one**
14//! parse of the query:
15//!
16//! 1. The window TVF is rewritten to a subquery exactly once by
17//!    [`rewrite_window_tvfs`] (`streaming_tvf.rs`) — the *same* rewrite the batch
18//!    planner consumes, so `TUMBLE`/`HOP`/`SESSION` has a single canonical
19//!    lowering, not one for batch and another hand-rolled for streaming.
20//! 2. The rewritten SQL is parsed **once** with the front-door dialect
21//!    ([`DuckDbDialect`], matching `SqlEngine`'s `sql_parser.dialect`). Parsing
22//!    streaming SQL with a *different* dialect than batch was the divergence
23//!    class behind the `SUM(CASE WHEN …)` 409 in prod; a single dialect closes
24//!    it structurally.
25//! 3. Everything the operator needs — the window kind/size/slide/gap, the event
26//!    time column, the grouping key, and the aggregate list — is derived from
27//!    that one parsed plan. Window recognition is now *structural*: "does the
28//!    parsed plan carry a `window_start` boundary projection over a recognised
29//!    window function?", so [`SqlError::Unsupported`] means "the planner cannot
30//!    lower this shape to a continuous plan", not "a text matcher failed to
31//!    recognise the SQL".
32//!
33//! The dataflow `ContinuousWindowExecutor` still computes the aggregation from
34//! the resulting [`WindowExecutionSpec`]; consuming DataFusion's own
35//! `LogicalPlan` (rather than the shared sqlparser AST) is the deeper
36//! unification that grows with Phase 55's operator coverage.
37
38use 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/// A compiled windowed streaming plan: the operator spec plus the name of the
55/// source table the window reads from.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct StreamingWindowPlan {
58    /// The keyed-window operator specification.
59    pub spec: WindowExecutionSpec,
60    /// The source table the window TVF reads from.
61    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
78/// Returns `true` when `sql` contains a TUMBLE/HOP/SESSION window TVF.
79pub fn is_windowed_streaming_sql(sql: &str) -> bool {
80    find_window_tvf(sql).is_some()
81}
82
83/// Compile a windowed streaming SQL query into a [`StreamingWindowPlan`].
84///
85/// Returns [`SqlError::Unsupported`] when the query is not a recognised keyed
86/// windowed aggregation.
87///
88/// The compile is one shared TVF rewrite + one parse: [`rewrite_window_tvfs`]
89/// (the same rewrite batch uses) turns the window TVF into a `_tvf_window`
90/// subquery, then the rewritten SQL is parsed once with the front-door dialect
91/// and both the window parameters and the key/aggregates are derived from that
92/// single parsed plan.
93pub 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
119/// Parse the (already TVF-rewritten) SQL once with the front-door dialect and
120/// return its single top-level `SELECT`. This is the *only* parse in the
121/// streaming compile path, and it uses the same dialect as the batch front door.
122fn 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
139/// The window parameters recovered structurally from the parsed plan.
140struct 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
148/// Recover the window parameters and source table from the parsed plan. The TVF
149/// rewrite wraps the source in a derived table aliased `_tvf_window` whose
150/// projection carries `window_start = <window_fn>(ts, …)`, so recognising the
151/// window is a structural check over the parsed plan rather than a text scan.
152fn 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
170/// Find the `_tvf_window` derived table emitted by the TVF rewrite.
171fn 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
195/// The expression of the projection item with the given output alias, if any.
196fn 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
203/// Translate a recognised window boundary function call
204/// (`tumble_start`/`hop_start`/`session_start`) into [`WindowParams`].
205fn 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
252/// The single base-table name the windowed source reads from.
253fn 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
270/// Collect the positional expression arguments of a function call.
271fn 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
288/// The (last) identifier name of an expression, if it is a plain/compound column.
289fn 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
297/// The numeric literal text of an expression, if it is a number value.
298fn 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    // `AGG(x) FILTER (WHERE …)`.
365    let mut filter = match &f.filter {
366        Some(predicate) => Some(lower_filter_expr(predicate)?),
367        None => None,
368    };
369
370    // The aggregate argument: a bare column, or the `CASE WHEN cond THEN x
371    // [ELSE 0|NULL] END` conditional idiom, which lowers to a row filter.
372    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            // COUNT(*) and other wildcard forms fall through with no column.
391            _ => {}
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
407/// The lowering of a `CASE WHEN cond THEN value [ELSE …] END` aggregate
408/// argument: the effective aggregate kind (SUM-of-1 collapses to COUNT), the
409/// value column when the branch yields one, and the row filter.
410struct 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    // ELSE must be the aggregate's identity (absent, NULL, or 0 for SUM/COUNT).
440    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        // SUM(CASE WHEN c THEN 1 …) / COUNT(CASE WHEN c THEN <literal> …):
456        // a conditional row count.
457        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        // AGG(CASE WHEN c THEN col …): filter + plain column aggregate. For
474        // COUNT, SQL counts non-null results, so add the column null-check.
475        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
508/// Lower a SQL predicate to the typed [`WindowAggFilter`] AST the dataflow
509/// operators evaluate. Supports column-vs-literal comparisons, AND/OR/NOT,
510/// and IS [NOT] NULL — the shapes `FILTER (WHERE …)` clauses use in practice.
511fn 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        // A bare boolean column used as the predicate (`WHERE is_bot`).
537        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    // `column <op> literal` or the mirrored `literal <op> column`.
569    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        // The shared TVF rewrite normalises `'1 minute'` → 60000 ms, and the
674        // unified parse recovers it structurally from the rewritten plan.
675        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        // Source names flow through the single parse; a schema-qualified table
688        // is recovered from the parsed plan, not a text slice.
689        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}