Skip to main content

mdql_core/
query_engine.rs

1//! Execute parsed queries over in-memory rows.
2
3use std::cmp::Ordering;
4use std::collections::HashMap;
5
6use regex::Regex;
7
8use crate::errors::MdqlError;
9use crate::model::{Row, Value};
10use crate::query_parser::*;
11use crate::schema::Schema;
12
13pub fn execute_query(
14    query: &SelectQuery,
15    rows: &[Row],
16    _schema: &Schema,
17) -> crate::errors::Result<(Vec<Row>, Vec<String>)> {
18    execute_inner(query, rows, None)
19}
20
21#[allow(dead_code)]
22pub(crate) fn execute_query_indexed(
23    query: &SelectQuery,
24    rows: &[Row],
25    schema: &Schema,
26    index: Option<&crate::index::TableIndex>,
27    searcher: Option<&crate::search::TableSearcher>,
28) -> crate::errors::Result<(Vec<Row>, Vec<String>)> {
29    // Pre-compute FTS results for any LIKE clauses on section columns
30    let fts_results = if let (Some(ref wc), Some(searcher)) = (&query.where_clause, searcher) {
31        collect_fts_results(wc, schema, searcher)
32    } else {
33        HashMap::new()
34    };
35
36    execute_with_fts(query, rows, index, &fts_results)
37}
38
39#[allow(dead_code)]
40fn collect_fts_results(
41    clause: &WhereClause,
42    schema: &Schema,
43    searcher: &crate::search::TableSearcher,
44) -> HashMap<(String, String), std::collections::HashSet<String>> {
45    let mut results = HashMap::new();
46    collect_fts_results_inner(clause, schema, searcher, &mut results);
47    results
48}
49
50#[allow(dead_code)]
51fn collect_fts_results_inner(
52    clause: &WhereClause,
53    schema: &Schema,
54    searcher: &crate::search::TableSearcher,
55    results: &mut HashMap<(String, String), std::collections::HashSet<String>>,
56) {
57    match clause {
58        WhereClause::Comparison(cmp) => {
59            if (cmp.op == "LIKE" || cmp.op == "NOT LIKE") && schema.sections.contains_key(&cmp.column) {
60                if let Some(SqlValue::String(pattern)) = &cmp.value {
61                    // Strip SQL wildcards for Tantivy query
62                    let search_term = pattern.replace('%', " ").replace('_', " ").trim().to_string();
63                    if !search_term.is_empty() {
64                        if let Ok(paths) = searcher.search(&search_term, Some(&cmp.column)) {
65                            let key = (cmp.column.clone(), pattern.clone());
66                            results.insert(key, paths.into_iter().collect());
67                        }
68                    }
69                }
70            }
71        }
72        WhereClause::BoolOp(bop) => {
73            collect_fts_results_inner(&bop.left, schema, searcher, results);
74            collect_fts_results_inner(&bop.right, schema, searcher, results);
75        }
76    }
77}
78
79type FtsResults = HashMap<(String, String), std::collections::HashSet<String>>;
80
81fn execute_with_fts(
82    query: &SelectQuery,
83    rows: &[Row],
84    index: Option<&crate::index::TableIndex>,
85    fts: &FtsResults,
86) -> crate::errors::Result<(Vec<Row>, Vec<String>)> {
87    // Determine available columns
88    let mut all_columns: Vec<String> = Vec::new();
89    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
90    for r in rows {
91        for k in r.keys() {
92            if seen.insert(k.clone()) {
93                all_columns.push(k.clone());
94            }
95        }
96    }
97
98    // Check if query has aggregates
99    let has_aggregates = match &query.columns {
100        ColumnList::Named(exprs) => exprs.iter().any(|e| e.is_aggregate()),
101        _ => false,
102    };
103
104    // Output column names
105    let columns: Vec<String> = match &query.columns {
106        ColumnList::All => all_columns,
107        ColumnList::Named(exprs) => exprs.iter().map(|e| e.output_name()).collect(),
108    };
109
110    // Filter — try index first, fall back to full scan
111    let filtered: Vec<Row> = if let Some(ref wc) = query.where_clause {
112        let candidate_paths = index.and_then(|idx| try_index_filter(wc, idx));
113        if let Some(paths) = candidate_paths {
114            rows.iter()
115                .filter(|r| {
116                    r.get("path")
117                        .and_then(|v| v.as_str())
118                        .map_or(false, |p| paths.contains(p))
119                })
120                .filter(|r| evaluate_with_fts(wc, r, fts))
121                .cloned()
122                .collect()
123        } else {
124            rows.iter()
125                .filter(|r| evaluate_with_fts(wc, r, fts))
126                .cloned()
127                .collect()
128        }
129    } else {
130        rows.to_vec()
131    };
132
133    // Aggregate if needed
134    let mut result = if has_aggregates || query.group_by.is_some() {
135        let exprs = match &query.columns {
136            ColumnList::Named(exprs) => exprs.clone(),
137            _ => return Err(MdqlError::QueryExecution(
138                "SELECT * with GROUP BY is not supported".into(),
139            )),
140        };
141        let group_keys = query.group_by.as_deref().unwrap_or(&[]);
142        aggregate_rows(&filtered, &exprs, group_keys)?
143    } else {
144        filtered
145    };
146
147    // HAVING filter — apply after aggregation
148    if let Some(ref having) = query.having {
149        result.retain(|row| evaluate(having, row));
150    }
151
152    // Sort — resolve ORDER BY aliases against SELECT list
153    if let Some(ref order_by) = query.order_by {
154        let resolved = resolve_order_aliases(order_by, &query.columns);
155        sort_rows(&mut result, &resolved);
156    }
157
158    // Limit
159    if let Some(limit) = query.limit {
160        result.truncate(limit as usize);
161    }
162
163    // Project — evaluate expressions and strip to requested columns
164    if !matches!(query.columns, ColumnList::All) {
165        let named_exprs = match &query.columns {
166            ColumnList::Named(exprs) => exprs,
167            _ => unreachable!(),
168        };
169
170        // Compute expression columns first, then retain only requested columns.
171        // Skip if aggregation already computed them (re-evaluating would lose
172        // columns that only existed in pre-aggregation rows, e.g. dict fields).
173        let has_expr_cols = named_exprs.iter().any(|e| matches!(e, SelectExpr::Expr { .. }));
174        let already_aggregated = has_aggregates || query.group_by.is_some();
175        if has_expr_cols && !already_aggregated {
176            for row in &mut result {
177                for expr in named_exprs {
178                    if let SelectExpr::Expr { expr: e, alias } = expr {
179                        let name = alias.clone().unwrap_or_else(|| e.display_name());
180                        let val = evaluate_expr(e, row);
181                        row.insert(name, val);
182                    }
183                }
184            }
185        }
186
187        let col_set: std::collections::HashSet<&str> =
188            columns.iter().map(|s| s.as_str()).collect();
189        for row in &mut result {
190            row.retain(|k, _| col_set.contains(k.as_str()));
191        }
192    }
193
194    Ok((result, columns))
195}
196
197fn aggregate_rows(
198    rows: &[Row],
199    exprs: &[SelectExpr],
200    group_keys: &[String],
201) -> crate::errors::Result<Vec<Row>> {
202    // Group rows by group_keys
203    let mut groups: Vec<(Vec<Value>, Vec<&Row>)> = Vec::new();
204    let mut key_index: HashMap<Vec<String>, usize> = HashMap::new();
205
206    if group_keys.is_empty() {
207        // No GROUP BY — all rows are one group
208        let all_refs: Vec<&Row> = rows.iter().collect();
209        groups.push((vec![], all_refs));
210    } else {
211        for row in rows {
212            let key: Vec<String> = group_keys
213                .iter()
214                .map(|k| {
215                    row.get(k)
216                        .map(|v| v.to_display_string())
217                        .unwrap_or_default()
218                })
219                .collect();
220            let key_vals: Vec<Value> = group_keys
221                .iter()
222                .map(|k| row.get(k).cloned().unwrap_or(Value::Null))
223                .collect();
224            if let Some(&idx) = key_index.get(&key) {
225                groups[idx].1.push(row);
226            } else {
227                let idx = groups.len();
228                key_index.insert(key, idx);
229                groups.push((key_vals, vec![row]));
230            }
231        }
232    }
233
234    // Compute aggregates per group
235    let mut result = Vec::new();
236    for (key_vals, group_rows) in &groups {
237        let mut out = Row::new();
238
239        // Fill in group key values
240        for (i, k) in group_keys.iter().enumerate() {
241            out.insert(k.clone(), key_vals[i].clone());
242        }
243
244        // Compute each expression
245        for expr in exprs {
246            match expr {
247                SelectExpr::Column(name) => {
248                    // Already filled if it's a group key; otherwise take first row's value
249                    if !out.contains_key(name) {
250                        if let Some(first) = group_rows.first() {
251                            out.insert(
252                                name.clone(),
253                                first.get(name).cloned().unwrap_or(Value::Null),
254                            );
255                        }
256                    }
257                }
258                SelectExpr::Aggregate { func, arg, arg_expr, alias } => {
259                    let out_name = alias
260                        .clone()
261                        .unwrap_or_else(|| expr.output_name());
262                    let val = compute_aggregate(func, arg, arg_expr.as_ref(), group_rows);
263                    out.insert(out_name, val);
264                }
265                SelectExpr::Expr { expr: e, alias } => {
266                    let out_name = alias.clone().unwrap_or_else(|| e.display_name());
267                    if let Some(first) = group_rows.first() {
268                        let val = evaluate_expr(e, first);
269                        out.insert(out_name, val);
270                    }
271                }
272            }
273        }
274
275        result.push(out);
276    }
277
278    Ok(result)
279}
280
281/// Resolve a per-row value for an aggregate argument.
282/// If `arg_expr` is set, evaluate it; otherwise look up `arg` as a column name.
283fn resolve_agg_value<'a>(arg: &str, arg_expr: Option<&Expr>, row: &'a Row) -> Value {
284    if let Some(expr) = arg_expr {
285        evaluate_expr(expr, row)
286    } else {
287        row.get(arg).cloned().unwrap_or(Value::Null)
288    }
289}
290
291fn compute_aggregate(func: &AggFunc, arg: &str, arg_expr: Option<&Expr>, rows: &[&Row]) -> Value {
292    match func {
293        AggFunc::Count => {
294            if arg == "*" && arg_expr.is_none() {
295                Value::Int(rows.len() as i64)
296            } else {
297                let count = rows
298                    .iter()
299                    .filter(|r| {
300                        let v = resolve_agg_value(arg, arg_expr, r);
301                        !v.is_null()
302                    })
303                    .count();
304                Value::Int(count as i64)
305            }
306        }
307        AggFunc::Sum => {
308            let mut total = 0.0f64;
309            let mut has_any = false;
310            for r in rows {
311                let v = resolve_agg_value(arg, arg_expr, r);
312                match v {
313                    Value::Int(n) => { total += n as f64; has_any = true; }
314                    Value::Float(f) => { total += f; has_any = true; }
315                    _ => {}
316                }
317            }
318            if has_any { Value::Float(total) } else { Value::Null }
319        }
320        AggFunc::Avg => {
321            let mut total = 0.0f64;
322            let mut count = 0usize;
323            for r in rows {
324                let v = resolve_agg_value(arg, arg_expr, r);
325                match v {
326                    Value::Int(n) => { total += n as f64; count += 1; }
327                    Value::Float(f) => { total += f; count += 1; }
328                    _ => {}
329                }
330            }
331            if count > 0 { Value::Float(total / count as f64) } else { Value::Null }
332        }
333        AggFunc::Min => {
334            let mut min_val: Option<Value> = None;
335            for r in rows {
336                let v = resolve_agg_value(arg, arg_expr, r);
337                if v.is_null() { continue; }
338                min_val = Some(match min_val {
339                    None => v,
340                    Some(ref current) => {
341                        if v.partial_cmp(current) == Some(std::cmp::Ordering::Less) {
342                            v
343                        } else {
344                            current.clone()
345                        }
346                    }
347                });
348            }
349            min_val.unwrap_or(Value::Null)
350        }
351        AggFunc::Max => {
352            let mut max_val: Option<Value> = None;
353            for r in rows {
354                let v = resolve_agg_value(arg, arg_expr, r);
355                if v.is_null() { continue; }
356                max_val = Some(match max_val {
357                    None => v,
358                    Some(ref current) => {
359                        if v.partial_cmp(current) == Some(std::cmp::Ordering::Greater) {
360                            v
361                        } else {
362                            current.clone()
363                        }
364                    }
365                });
366            }
367            max_val.unwrap_or(Value::Null)
368        }
369    }
370}
371
372fn evaluate_with_fts(clause: &WhereClause, row: &Row, fts: &FtsResults) -> bool {
373    match clause {
374        WhereClause::BoolOp(bop) => {
375            let left = evaluate_with_fts(&bop.left, row, fts);
376            match bop.op.as_str() {
377                "AND" => left && evaluate_with_fts(&bop.right, row, fts),
378                "OR" => left || evaluate_with_fts(&bop.right, row, fts),
379                _ => false,
380            }
381        }
382        WhereClause::Comparison(cmp) => {
383            // Check if we have FTS results for this comparison
384            if cmp.op == "LIKE" || cmp.op == "NOT LIKE" {
385                if let Some(SqlValue::String(pattern)) = &cmp.value {
386                    let key = (cmp.column.clone(), pattern.clone());
387                    if let Some(matching_paths) = fts.get(&key) {
388                        let row_path = row.get("path").and_then(|v| v.as_str()).unwrap_or("");
389                        let matched = matching_paths.contains(row_path);
390                        return if cmp.op == "LIKE" { matched } else { !matched };
391                    }
392                }
393            }
394            evaluate_comparison(cmp, row)
395        }
396    }
397}
398
399pub use crate::query_join::execute_join_query;
400
401pub(crate) fn execute_inner(
402    query: &SelectQuery,
403    rows: &[Row],
404    index: Option<&crate::index::TableIndex>,
405) -> crate::errors::Result<(Vec<Row>, Vec<String>)> {
406    let empty_fts = HashMap::new();
407    execute_with_fts(query, rows, index, &empty_fts)
408}
409
410pub fn evaluate(clause: &WhereClause, row: &Row) -> bool {
411    match clause {
412        WhereClause::BoolOp(bop) => {
413            let left = evaluate(&bop.left, row);
414            match bop.op.as_str() {
415                "AND" => left && evaluate(&bop.right, row),
416                "OR" => left || evaluate(&bop.right, row),
417                _ => false,
418            }
419        }
420        WhereClause::Comparison(cmp) => evaluate_comparison(cmp, row),
421    }
422}
423
424/// Evaluate an Expr against a row, returning a Value.
425pub fn evaluate_expr(expr: &Expr, row: &Row) -> Value {
426    match expr {
427        Expr::Literal(SqlValue::Int(n)) => Value::Int(*n),
428        Expr::Literal(SqlValue::Float(f)) => Value::Float(*f),
429        Expr::Literal(SqlValue::String(s)) => Value::String(s.clone()),
430        Expr::Literal(SqlValue::Null) => Value::Null,
431        Expr::Literal(SqlValue::List(_)) => Value::Null,
432        Expr::Column(name) => {
433            if let Some(val) = row.get(name) {
434                return val.clone();
435            }
436            // Try all possible dot splits for dict access (e.g. "s.params.key")
437            for (i, _) in name.match_indices('.') {
438                let dict_col = &name[..i];
439                let dict_key = &name[i + 1..];
440                if let Some(Value::Dict(map)) = row.get(dict_col) {
441                    return map.get(dict_key).cloned().unwrap_or(Value::Null);
442                }
443            }
444            Value::Null
445        }
446        Expr::UnaryMinus(inner) => {
447            match evaluate_expr(inner, row) {
448                Value::Int(n) => Value::Int(-n),
449                Value::Float(f) => Value::Float(-f),
450                Value::Null => Value::Null,
451                _ => Value::Null, // non-numeric → NULL
452            }
453        }
454        Expr::BinaryOp { left, op, right } => {
455            let lv = evaluate_expr(left, row);
456            let rv = evaluate_expr(right, row);
457
458            // NULL propagation: any NULL operand → NULL
459            if lv.is_null() || rv.is_null() {
460                return Value::Null;
461            }
462
463            // Extract numeric values with int→float coercion
464            match (&lv, &rv) {
465                (Value::Int(a), Value::Int(b)) => {
466                    match op {
467                        ArithOp::Add => Value::Int(a.wrapping_add(*b)),
468                        ArithOp::Sub => Value::Int(a.wrapping_sub(*b)),
469                        ArithOp::Mul => Value::Int(a.wrapping_mul(*b)),
470                        ArithOp::Div => {
471                            if *b == 0 { Value::Null } else { Value::Int(a / b) }
472                        }
473                        ArithOp::Mod => {
474                            if *b == 0 { Value::Null } else { Value::Int(a % b) }
475                        }
476                    }
477                }
478                _ => {
479                    // Coerce to float
480                    let a = match &lv {
481                        Value::Int(n) => *n as f64,
482                        Value::Float(f) => *f,
483                        _ => return Value::Null,
484                    };
485                    let b = match &rv {
486                        Value::Int(n) => *n as f64,
487                        Value::Float(f) => *f,
488                        _ => return Value::Null,
489                    };
490                    match op {
491                        ArithOp::Add => Value::Float(a + b),
492                        ArithOp::Sub => Value::Float(a - b),
493                        ArithOp::Mul => Value::Float(a * b),
494                        ArithOp::Div => {
495                            if b == 0.0 { Value::Null } else { Value::Float(a / b) }
496                        }
497                        ArithOp::Mod => {
498                            if b == 0.0 { Value::Null } else { Value::Float(a % b) }
499                        }
500                    }
501                }
502            }
503        }
504        Expr::Case { whens, else_expr } => {
505            for (condition, result) in whens {
506                if evaluate(condition, row) {
507                    return evaluate_expr(result, row);
508                }
509            }
510            match else_expr {
511                Some(e) => evaluate_expr(e, row),
512                None => Value::Null,
513            }
514        }
515        Expr::CurrentDate => {
516            Value::Date(chrono::Local::now().naive_local().date())
517        }
518        Expr::CurrentTimestamp => {
519            Value::DateTime(chrono::Local::now().naive_local())
520        }
521        Expr::DateAdd { date, days } => {
522            let date_val = evaluate_expr(date, row);
523            let days_val = evaluate_expr(days, row);
524            let n = match &days_val {
525                Value::Int(n) => *n,
526                Value::Float(f) => *f as i64,
527                _ => return Value::Null,
528            };
529            let duration = chrono::Duration::days(n);
530            match date_val {
531                Value::Date(d) => {
532                    match d.checked_add_signed(duration) {
533                        Some(result) => Value::Date(result),
534                        None => Value::Null,
535                    }
536                }
537                Value::DateTime(dt) => {
538                    match dt.checked_add_signed(duration) {
539                        Some(result) => Value::DateTime(result),
540                        None => Value::Null,
541                    }
542                }
543                _ => Value::Null,
544            }
545        }
546        Expr::DateDiff { left, right } => {
547            let lv = evaluate_expr(left, row);
548            let rv = evaluate_expr(right, row);
549            let left_date = match &lv {
550                Value::Date(d) => d.and_hms_opt(0, 0, 0).unwrap(),
551                Value::DateTime(dt) => *dt,
552                _ => return Value::Null,
553            };
554            let right_date = match &rv {
555                Value::Date(d) => d.and_hms_opt(0, 0, 0).unwrap(),
556                Value::DateTime(dt) => *dt,
557                _ => return Value::Null,
558            };
559            Value::Int((left_date - right_date).num_days())
560        }
561    }
562}
563
564fn evaluate_comparison(cmp: &Comparison, row: &Row) -> bool {
565    // If we have expression-based comparison (new path), use it for standard ops
566    if let (Some(left_expr), Some(right_expr)) = (&cmp.left_expr, &cmp.right_expr) {
567        if ["=", "!=", "<", ">", "<=", ">="].contains(&cmp.op.as_str()) {
568            let left_val = evaluate_expr(left_expr, row);
569            let right_val = evaluate_expr(right_expr, row);
570
571            // NULL comparison: always false (except IS NULL handled below)
572            if left_val.is_null() || right_val.is_null() {
573                return false;
574            }
575
576            // Coerce for comparison: if types differ, try int→float
577            let ord = compare_model_values(&left_val, &right_val);
578
579            return match cmp.op.as_str() {
580                "=" => ord == Some(Ordering::Equal),
581                "!=" => ord != Some(Ordering::Equal),
582                "<" => ord == Some(Ordering::Less),
583                ">" => ord == Some(Ordering::Greater),
584                "<=" => matches!(ord, Some(Ordering::Less | Ordering::Equal)),
585                ">=" => matches!(ord, Some(Ordering::Greater | Ordering::Equal)),
586                _ => false,
587            };
588        }
589    }
590
591    // Fall back to legacy column-based comparison for IS NULL, IN, LIKE, etc.
592    let actual = row.get(&cmp.column);
593
594    if cmp.op == "IS NULL" {
595        return actual.map_or(true, |v| v.is_null());
596    }
597    if cmp.op == "IS NOT NULL" {
598        return actual.map_or(false, |v| !v.is_null());
599    }
600
601    let actual = match actual {
602        Some(v) if !v.is_null() => v,
603        _ => return false,
604    };
605
606    let expected = match &cmp.value {
607        Some(v) => v,
608        None => return false,
609    };
610
611    match cmp.op.as_str() {
612        "=" => eq_match(actual, expected),
613        "!=" => !eq_match(actual, expected),
614        "<" => compare_values(actual, expected) == Some(Ordering::Less),
615        ">" => compare_values(actual, expected) == Some(Ordering::Greater),
616        "<=" => matches!(compare_values(actual, expected), Some(Ordering::Less | Ordering::Equal)),
617        ">=" => matches!(compare_values(actual, expected), Some(Ordering::Greater | Ordering::Equal)),
618        "LIKE" => like_match(actual, expected),
619        "NOT LIKE" => !like_match(actual, expected),
620        "IN" => {
621            if let SqlValue::List(items) = expected {
622                items.iter().any(|v| eq_match(actual, v))
623            } else {
624                eq_match(actual, expected)
625            }
626        }
627        _ => false,
628    }
629}
630
631/// Compare two model::Value instances, with int↔float coercion.
632fn compare_model_values(a: &Value, b: &Value) -> Option<Ordering> {
633    match (a, b) {
634        (Value::Int(x), Value::Float(y)) => (*x as f64).partial_cmp(y),
635        (Value::Float(x), Value::Int(y)) => x.partial_cmp(&(*y as f64)),
636        _ => a.partial_cmp(b),
637    }
638}
639
640fn coerce_sql_to_value(sql_val: &SqlValue, target: &Value) -> Value {
641    match sql_val {
642        SqlValue::Null => Value::Null,
643        SqlValue::String(s) => {
644            match target {
645                Value::Int(_) => s.parse::<i64>().map(Value::Int).unwrap_or(Value::String(s.clone())),
646                Value::Float(_) => s.parse::<f64>().map(Value::Float).unwrap_or(Value::String(s.clone())),
647                Value::Date(_) => {
648                    chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
649                        .map(Value::Date)
650                        .unwrap_or(Value::String(s.clone()))
651                }
652                Value::DateTime(_) => {
653                    chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
654                        .or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f"))
655                        .map(Value::DateTime)
656                        .unwrap_or(Value::String(s.clone()))
657                }
658                _ => Value::String(s.clone()),
659            }
660        }
661        SqlValue::Int(n) => {
662            match target {
663                Value::Float(_) => Value::Float(*n as f64),
664                _ => Value::Int(*n),
665            }
666        }
667        SqlValue::Float(f) => Value::Float(*f),
668        SqlValue::List(_) => Value::Null, // Lists handled separately
669    }
670}
671
672fn eq_match(actual: &Value, expected: &SqlValue) -> bool {
673    // Special handling for lists (e.g., categories)
674    if let Value::List(items) = actual {
675        if let SqlValue::String(s) = expected {
676            return items.contains(s);
677        }
678    }
679
680    let coerced = coerce_sql_to_value(expected, actual);
681    actual == &coerced
682}
683
684fn like_match(actual: &Value, pattern: &SqlValue) -> bool {
685    let pattern_str = match pattern {
686        SqlValue::String(s) => s,
687        _ => return false,
688    };
689
690    // Convert SQL LIKE to regex
691    let mut regex_str = String::from("(?is)^");
692    for ch in pattern_str.chars() {
693        match ch {
694            '%' => regex_str.push_str(".*"),
695            '_' => regex_str.push('.'),
696            c => {
697                if regex::escape(&c.to_string()) != c.to_string() {
698                    regex_str.push_str(&regex::escape(&c.to_string()));
699                } else {
700                    regex_str.push(c);
701                }
702            }
703        }
704    }
705    regex_str.push('$');
706
707    let re = match Regex::new(&regex_str) {
708        Ok(r) => r,
709        Err(_) => return false,
710    };
711
712    match actual {
713        Value::List(items) => items.iter().any(|item| re.is_match(item)),
714        _ => re.is_match(&actual.to_display_string()),
715    }
716}
717
718fn compare_values(actual: &Value, expected: &SqlValue) -> Option<Ordering> {
719    let coerced = coerce_sql_to_value(expected, actual);
720    actual.partial_cmp(&coerced).map(|o| o)
721}
722
723/// Convert a SqlValue to a Value for index lookups (without a target type for coercion).
724fn sql_value_to_index_value(sv: &SqlValue) -> Value {
725    match sv {
726        SqlValue::String(s) => {
727            // Try datetime first (more specific)
728            if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
729                return Value::DateTime(dt);
730            }
731            if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") {
732                return Value::DateTime(dt);
733            }
734            // Try date
735            if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
736                return Value::Date(d);
737            }
738            Value::String(s.clone())
739        }
740        SqlValue::Int(n) => Value::Int(*n),
741        SqlValue::Float(f) => Value::Float(*f),
742        SqlValue::Null => Value::Null,
743        SqlValue::List(_) => Value::Null,
744    }
745}
746
747/// Try to use B-tree indexes to narrow the candidate row set.
748/// Returns Some(paths) if the entire WHERE clause could be resolved via index,
749/// or None if a full scan is needed.
750fn try_index_filter(
751    clause: &WhereClause,
752    index: &crate::index::TableIndex,
753) -> Option<std::collections::HashSet<String>> {
754    match clause {
755        WhereClause::Comparison(cmp) => {
756            if !index.has_index(&cmp.column) {
757                return None;
758            }
759            match cmp.op.as_str() {
760                "=" => {
761                    let val = sql_value_to_index_value(cmp.value.as_ref()?);
762                    let paths = index.lookup_eq(&cmp.column, &val);
763                    Some(paths.into_iter().map(|s| s.to_string()).collect())
764                }
765                "<" => {
766                    let val = sql_value_to_index_value(cmp.value.as_ref()?);
767                    // exclusive upper bound: use range with max < val
768                    // lookup_range is inclusive, so we get all <= val then remove exact matches
769                    let range_paths = index.lookup_range(&cmp.column, None, Some(&val));
770                    let eq_paths: std::collections::HashSet<&str> = index.lookup_eq(&cmp.column, &val).into_iter().collect();
771                    Some(range_paths.into_iter().filter(|p| !eq_paths.contains(p)).map(|s| s.to_string()).collect())
772                }
773                ">" => {
774                    let val = sql_value_to_index_value(cmp.value.as_ref()?);
775                    let range_paths = index.lookup_range(&cmp.column, Some(&val), None);
776                    let eq_paths: std::collections::HashSet<&str> = index.lookup_eq(&cmp.column, &val).into_iter().collect();
777                    Some(range_paths.into_iter().filter(|p| !eq_paths.contains(p)).map(|s| s.to_string()).collect())
778                }
779                "<=" => {
780                    let val = sql_value_to_index_value(cmp.value.as_ref()?);
781                    let paths = index.lookup_range(&cmp.column, None, Some(&val));
782                    Some(paths.into_iter().map(|s| s.to_string()).collect())
783                }
784                ">=" => {
785                    let val = sql_value_to_index_value(cmp.value.as_ref()?);
786                    let paths = index.lookup_range(&cmp.column, Some(&val), None);
787                    Some(paths.into_iter().map(|s| s.to_string()).collect())
788                }
789                "IN" => {
790                    if let Some(SqlValue::List(items)) = &cmp.value {
791                        let vals: Vec<Value> = items.iter().map(sql_value_to_index_value).collect();
792                        let paths = index.lookup_in(&cmp.column, &vals);
793                        Some(paths.into_iter().map(|s| s.to_string()).collect())
794                    } else {
795                        None
796                    }
797                }
798                _ => None, // LIKE, IS NULL, etc. can't use index
799            }
800        }
801        WhereClause::BoolOp(bop) => {
802            let left = try_index_filter(&bop.left, index);
803            let right = try_index_filter(&bop.right, index);
804            match bop.op.as_str() {
805                "AND" => {
806                    match (left, right) {
807                        (Some(l), Some(r)) => Some(l.intersection(&r).cloned().collect()),
808                        (Some(l), None) => Some(l), // narrow with left, scan-verify right
809                        (None, Some(r)) => Some(r),
810                        (None, None) => None,
811                    }
812                }
813                "OR" => {
814                    match (left, right) {
815                        (Some(l), Some(r)) => Some(l.union(&r).cloned().collect()),
816                        _ => None, // Can't use index if either side needs full scan
817                    }
818                }
819                _ => None,
820            }
821        }
822    }
823}
824
825/// If an ORDER BY column matches a SELECT alias, replace its expr with the
826/// aliased expression so sorting uses the computed value.
827fn resolve_order_aliases(specs: &[OrderSpec], columns: &ColumnList) -> Vec<OrderSpec> {
828    let named = match columns {
829        ColumnList::Named(exprs) => exprs,
830        _ => return specs.to_vec(),
831    };
832
833    // Build alias → expr map
834    let alias_map: HashMap<String, &Expr> = named
835        .iter()
836        .filter_map(|se| match se {
837            SelectExpr::Expr { expr, alias: Some(a) } => Some((a.clone(), expr)),
838            _ => None,
839        })
840        .collect();
841
842    specs
843        .iter()
844        .map(|spec| {
845            // If the ORDER BY column name matches a SELECT alias, use that expression
846            if let Some(expr) = alias_map.get(&spec.column) {
847                OrderSpec {
848                    column: spec.column.clone(),
849                    expr: Some((*expr).clone()),
850                    descending: spec.descending,
851                }
852            } else {
853                spec.clone()
854            }
855        })
856        .collect()
857}
858
859fn sort_rows(rows: &mut Vec<Row>, specs: &[OrderSpec]) {
860    rows.sort_by(|a, b| {
861        for spec in specs {
862            let (va, vb) = if let Some(ref expr) = spec.expr {
863                (evaluate_expr(expr, a), evaluate_expr(expr, b))
864            } else {
865                (
866                    a.get(&spec.column).cloned().unwrap_or(Value::Null),
867                    b.get(&spec.column).cloned().unwrap_or(Value::Null),
868                )
869            };
870
871            // NULLs sort last
872            let ordering = match (&va, &vb) {
873                (Value::Null, Value::Null) => Ordering::Equal,
874                (Value::Null, _) => Ordering::Greater,
875                (_, Value::Null) => Ordering::Less,
876                (a_val, b_val) => {
877                    compare_model_values(a_val, b_val).unwrap_or(Ordering::Equal)
878                }
879            };
880
881            let ordering = if spec.descending {
882                ordering.reverse()
883            } else {
884                ordering
885            };
886
887            if ordering != Ordering::Equal {
888                return ordering;
889            }
890        }
891        Ordering::Equal
892    });
893}
894
895/// Convert a SqlValue to our model Value (for use in insert/update).
896pub fn sql_value_to_value(sql_val: &SqlValue) -> Value {
897    match sql_val {
898        SqlValue::Null => Value::Null,
899        SqlValue::String(s) => Value::String(s.clone()),
900        SqlValue::Int(n) => Value::Int(*n),
901        SqlValue::Float(f) => Value::Float(*f),
902        SqlValue::List(items) => {
903            let strings: Vec<String> = items
904                .iter()
905                .filter_map(|v| match v {
906                    SqlValue::String(s) => Some(s.clone()),
907                    _ => None,
908                })
909                .collect();
910            Value::List(strings)
911        }
912    }
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918
919    fn make_rows() -> Vec<Row> {
920        vec![
921            Row::from([
922                ("path".into(), Value::String("a.md".into())),
923                ("title".into(), Value::String("Alpha".into())),
924                ("count".into(), Value::Int(10)),
925            ]),
926            Row::from([
927                ("path".into(), Value::String("b.md".into())),
928                ("title".into(), Value::String("Beta".into())),
929                ("count".into(), Value::Int(5)),
930            ]),
931            Row::from([
932                ("path".into(), Value::String("c.md".into())),
933                ("title".into(), Value::String("Gamma".into())),
934                ("count".into(), Value::Int(20)),
935            ]),
936        ]
937    }
938
939    #[test]
940    fn test_select_all() {
941        let q = SelectQuery {
942            columns: ColumnList::All,
943            table: "test".into(),
944            table_alias: None,
945            joins: vec![],
946            where_clause: None,
947            group_by: None,
948            having: None,
949            order_by: None,
950            limit: None,
951        };
952        let (rows, _cols) = execute_inner(&q, &make_rows(), None).unwrap();
953        assert_eq!(rows.len(), 3);
954    }
955
956    #[test]
957    fn test_where_gt() {
958        let q = SelectQuery {
959            columns: ColumnList::All,
960            table: "test".into(),
961            table_alias: None,
962            joins: vec![],
963            where_clause: Some(WhereClause::Comparison(Comparison {
964                column: "count".into(),
965                op: ">".into(),
966                value: Some(SqlValue::Int(5)),
967                left_expr: Some(Expr::Column("count".into())),
968                right_expr: Some(Expr::Literal(SqlValue::Int(5))),
969            })),
970            group_by: None,
971            having: None,
972            order_by: None,
973            limit: None,
974        };
975        let (rows, _) = execute_inner(&q, &make_rows(), None).unwrap();
976        assert_eq!(rows.len(), 2);
977    }
978
979    #[test]
980    fn test_order_by_desc() {
981        let q = SelectQuery {
982            columns: ColumnList::All,
983            table: "test".into(),
984            table_alias: None,
985            joins: vec![],
986            where_clause: None,
987            group_by: None,
988            having: None,
989            order_by: Some(vec![OrderSpec {
990                column: "count".into(),
991                expr: Some(Expr::Column("count".into())),
992                descending: true,
993            }]),
994            limit: None,
995        };
996        let (rows, _) = execute_inner(&q, &make_rows(), None).unwrap();
997        assert_eq!(rows[0]["count"], Value::Int(20));
998        assert_eq!(rows[2]["count"], Value::Int(5));
999    }
1000
1001    #[test]
1002    fn test_limit() {
1003        let q = SelectQuery {
1004            columns: ColumnList::All,
1005            table: "test".into(),
1006            table_alias: None,
1007            joins: vec![],
1008            where_clause: None,
1009            group_by: None,
1010            having: None,
1011            order_by: None,
1012            limit: Some(2),
1013        };
1014        let (rows, _) = execute_inner(&q, &make_rows(), None).unwrap();
1015        assert_eq!(rows.len(), 2);
1016    }
1017
1018    #[test]
1019    fn test_like() {
1020        let q = SelectQuery {
1021            columns: ColumnList::All,
1022            table: "test".into(),
1023            table_alias: None,
1024            joins: vec![],
1025            where_clause: Some(WhereClause::Comparison(Comparison {
1026                column: "title".into(),
1027                op: "LIKE".into(),
1028                value: Some(SqlValue::String("%lph%".into())),
1029                left_expr: Some(Expr::Column("title".into())),
1030                right_expr: None,
1031            })),
1032            group_by: None,
1033            having: None,
1034            order_by: None,
1035            limit: None,
1036        };
1037        let (rows, _) = execute_inner(&q, &make_rows(), None).unwrap();
1038        assert_eq!(rows.len(), 1);
1039        assert_eq!(rows[0]["title"], Value::String("Alpha".into()));
1040    }
1041
1042    #[test]
1043    fn test_is_null() {
1044        let mut rows = make_rows();
1045        rows[1].insert("optional".into(), Value::Null);
1046
1047        let q = SelectQuery {
1048            columns: ColumnList::All,
1049            table: "test".into(),
1050            table_alias: None,
1051            joins: vec![],
1052            where_clause: Some(WhereClause::Comparison(Comparison {
1053                column: "optional".into(),
1054                op: "IS NULL".into(),
1055                value: None,
1056                left_expr: Some(Expr::Column("optional".into())),
1057                right_expr: None,
1058            })),
1059            group_by: None,
1060            having: None,
1061            order_by: None,
1062            limit: None,
1063        };
1064        let (result, _) = execute_inner(&q, &rows, None).unwrap();
1065        // All rows where optional is NULL or missing
1066        assert_eq!(result.len(), 3);
1067    }
1068
1069    // ── Expression evaluation tests ─────────────────────────��─────
1070
1071    #[test]
1072    fn test_evaluate_expr_literal() {
1073        let row = Row::new();
1074        assert_eq!(evaluate_expr(&Expr::Literal(SqlValue::Int(42)), &row), Value::Int(42));
1075        assert_eq!(evaluate_expr(&Expr::Literal(SqlValue::Float(3.14)), &row), Value::Float(3.14));
1076        assert_eq!(evaluate_expr(&Expr::Literal(SqlValue::Null), &row), Value::Null);
1077    }
1078
1079    #[test]
1080    fn test_evaluate_expr_column() {
1081        let row = Row::from([("x".into(), Value::Int(10))]);
1082        assert_eq!(evaluate_expr(&Expr::Column("x".into()), &row), Value::Int(10));
1083        assert_eq!(evaluate_expr(&Expr::Column("missing".into()), &row), Value::Null);
1084    }
1085
1086    #[test]
1087    fn test_evaluate_expr_int_arithmetic() {
1088        let row = Row::from([("a".into(), Value::Int(10)), ("b".into(), Value::Int(3))]);
1089        let add = Expr::BinaryOp {
1090            left: Box::new(Expr::Column("a".into())),
1091            op: ArithOp::Add,
1092            right: Box::new(Expr::Column("b".into())),
1093        };
1094        assert_eq!(evaluate_expr(&add, &row), Value::Int(13));
1095
1096        let sub = Expr::BinaryOp {
1097            left: Box::new(Expr::Column("a".into())),
1098            op: ArithOp::Sub,
1099            right: Box::new(Expr::Column("b".into())),
1100        };
1101        assert_eq!(evaluate_expr(&sub, &row), Value::Int(7));
1102
1103        let mul = Expr::BinaryOp {
1104            left: Box::new(Expr::Column("a".into())),
1105            op: ArithOp::Mul,
1106            right: Box::new(Expr::Column("b".into())),
1107        };
1108        assert_eq!(evaluate_expr(&mul, &row), Value::Int(30));
1109
1110        let div = Expr::BinaryOp {
1111            left: Box::new(Expr::Column("a".into())),
1112            op: ArithOp::Div,
1113            right: Box::new(Expr::Column("b".into())),
1114        };
1115        assert_eq!(evaluate_expr(&div, &row), Value::Int(3)); // integer division
1116
1117        let modulo = Expr::BinaryOp {
1118            left: Box::new(Expr::Column("a".into())),
1119            op: ArithOp::Mod,
1120            right: Box::new(Expr::Column("b".into())),
1121        };
1122        assert_eq!(evaluate_expr(&modulo, &row), Value::Int(1));
1123    }
1124
1125    #[test]
1126    fn test_evaluate_expr_float_coercion() {
1127        let row = Row::from([("a".into(), Value::Int(10)), ("b".into(), Value::Float(3.0))]);
1128        let add = Expr::BinaryOp {
1129            left: Box::new(Expr::Column("a".into())),
1130            op: ArithOp::Add,
1131            right: Box::new(Expr::Column("b".into())),
1132        };
1133        assert_eq!(evaluate_expr(&add, &row), Value::Float(13.0));
1134    }
1135
1136    #[test]
1137    fn test_evaluate_expr_null_propagation() {
1138        let row = Row::from([("a".into(), Value::Int(10))]);
1139        let add = Expr::BinaryOp {
1140            left: Box::new(Expr::Column("a".into())),
1141            op: ArithOp::Add,
1142            right: Box::new(Expr::Column("missing".into())),
1143        };
1144        assert_eq!(evaluate_expr(&add, &row), Value::Null);
1145    }
1146
1147    #[test]
1148    fn test_evaluate_expr_div_by_zero() {
1149        let row = Row::from([("a".into(), Value::Int(10)), ("b".into(), Value::Int(0))]);
1150        let div = Expr::BinaryOp {
1151            left: Box::new(Expr::Column("a".into())),
1152            op: ArithOp::Div,
1153            right: Box::new(Expr::Column("b".into())),
1154        };
1155        assert_eq!(evaluate_expr(&div, &row), Value::Null);
1156    }
1157
1158    #[test]
1159    fn test_evaluate_expr_unary_minus() {
1160        let row = Row::from([("x".into(), Value::Int(5))]);
1161        let neg = Expr::UnaryMinus(Box::new(Expr::Column("x".into())));
1162        assert_eq!(evaluate_expr(&neg, &row), Value::Int(-5));
1163    }
1164
1165    #[test]
1166    fn test_select_with_expression() {
1167        // Integration test: SELECT count * 2 AS doubled FROM test
1168        let stmt = crate::query_parser::parse_query(
1169            "SELECT count * 2 AS doubled FROM test"
1170        ).unwrap();
1171        if let crate::query_parser::Statement::Select(q) = stmt {
1172            let (rows, cols) = execute_inner(&q, &make_rows(), None).unwrap();
1173            assert_eq!(cols, vec!["doubled"]);
1174            assert_eq!(rows.len(), 3);
1175            // Rows are: count=10, count=5, count=20
1176            let values: Vec<Value> = rows.iter().map(|r| r["doubled"].clone()).collect();
1177            assert!(values.contains(&Value::Int(20)));
1178            assert!(values.contains(&Value::Int(10)));
1179            assert!(values.contains(&Value::Int(40)));
1180        } else {
1181            panic!("Expected Select");
1182        }
1183    }
1184
1185    #[test]
1186    fn test_where_with_expression() {
1187        // SELECT * FROM test WHERE count * 2 > 15
1188        let stmt = crate::query_parser::parse_query(
1189            "SELECT * FROM test WHERE count * 2 > 15"
1190        ).unwrap();
1191        if let crate::query_parser::Statement::Select(q) = stmt {
1192            let (rows, _) = execute_inner(&q, &make_rows(), None).unwrap();
1193            // count=10 → 20 > 15 ✓, count=5 → 10 > 15 ✗, count=20 → 40 > 15 ✓
1194            assert_eq!(rows.len(), 2);
1195        } else {
1196            panic!("Expected Select");
1197        }
1198    }
1199
1200    #[test]
1201    fn test_order_by_expression() {
1202        // SELECT * FROM test ORDER BY count * -1 ASC (effectively DESC by count)
1203        let stmt = crate::query_parser::parse_query(
1204            "SELECT title, count FROM test ORDER BY count * -1 ASC"
1205        ).unwrap();
1206        if let crate::query_parser::Statement::Select(q) = stmt {
1207            let (rows, _) = execute_inner(&q, &make_rows(), None).unwrap();
1208            // count: 20 → -20, 10 → -10, 5 → -5, ASC means -20, -10, -5
1209            assert_eq!(rows[0]["count"], Value::Int(20));
1210            assert_eq!(rows[1]["count"], Value::Int(10));
1211            assert_eq!(rows[2]["count"], Value::Int(5));
1212        } else {
1213            panic!("Expected Select");
1214        }
1215    }
1216
1217    // ── CASE WHEN evaluation tests ────────────────────────────────
1218
1219    #[test]
1220    fn test_case_when_eval_basic() {
1221        let row = Row::from([("status".into(), Value::String("ACTIVE".into()))]);
1222        let expr = Expr::Case {
1223            whens: vec![(
1224                WhereClause::Comparison(Comparison {
1225                    column: "status".into(),
1226                    op: "=".into(),
1227                    value: Some(SqlValue::String("ACTIVE".into())),
1228                    left_expr: Some(Expr::Column("status".into())),
1229                    right_expr: Some(Expr::Literal(SqlValue::String("ACTIVE".into()))),
1230                }),
1231                Box::new(Expr::Literal(SqlValue::Int(1))),
1232            )],
1233            else_expr: Some(Box::new(Expr::Literal(SqlValue::Int(0)))),
1234        };
1235        assert_eq!(evaluate_expr(&expr, &row), Value::Int(1));
1236    }
1237
1238    #[test]
1239    fn test_case_when_eval_else() {
1240        let row = Row::from([("status".into(), Value::String("KILLED".into()))]);
1241        let expr = Expr::Case {
1242            whens: vec![(
1243                WhereClause::Comparison(Comparison {
1244                    column: "status".into(),
1245                    op: "=".into(),
1246                    value: Some(SqlValue::String("ACTIVE".into())),
1247                    left_expr: Some(Expr::Column("status".into())),
1248                    right_expr: Some(Expr::Literal(SqlValue::String("ACTIVE".into()))),
1249                }),
1250                Box::new(Expr::Literal(SqlValue::Int(1))),
1251            )],
1252            else_expr: Some(Box::new(Expr::Literal(SqlValue::Int(0)))),
1253        };
1254        assert_eq!(evaluate_expr(&expr, &row), Value::Int(0));
1255    }
1256
1257    #[test]
1258    fn test_case_when_eval_no_else_null() {
1259        let row = Row::from([("x".into(), Value::Int(99))]);
1260        let expr = Expr::Case {
1261            whens: vec![(
1262                WhereClause::Comparison(Comparison {
1263                    column: "x".into(),
1264                    op: "=".into(),
1265                    value: Some(SqlValue::Int(1)),
1266                    left_expr: Some(Expr::Column("x".into())),
1267                    right_expr: Some(Expr::Literal(SqlValue::Int(1))),
1268                }),
1269                Box::new(Expr::Literal(SqlValue::String("one".into()))),
1270            )],
1271            else_expr: None,
1272        };
1273        assert_eq!(evaluate_expr(&expr, &row), Value::Null);
1274    }
1275
1276    #[test]
1277    fn test_case_when_in_aggregate_query() {
1278        // SUM(CASE WHEN count > 5 THEN count ELSE 0 END)
1279        // Rows: count=10, count=5, count=20 → should sum 10 + 0 + 20 = 30
1280        let stmt = crate::query_parser::parse_query(
1281            "SELECT SUM(CASE WHEN count > 5 THEN count ELSE 0 END) AS total FROM test"
1282        ).unwrap();
1283        if let crate::query_parser::Statement::Select(q) = stmt {
1284            let (rows, cols) = execute_inner(&q, &make_rows(), None).unwrap();
1285            assert_eq!(cols, vec!["total"]);
1286            assert_eq!(rows.len(), 1);
1287            assert_eq!(rows[0]["total"], Value::Float(30.0));
1288        } else {
1289            panic!("Expected Select");
1290        }
1291    }
1292
1293    #[test]
1294    fn test_case_when_with_unary_minus_in_aggregate() {
1295        // SUM(CASE WHEN title = 'Alpha' THEN count ELSE -count END)
1296        // Alpha: 10, Beta: -5, Gamma: -20 → 10 - 5 - 20 = -15
1297        let stmt = crate::query_parser::parse_query(
1298            "SELECT SUM(CASE WHEN title = 'Alpha' THEN count ELSE -count END) AS net FROM test"
1299        ).unwrap();
1300        if let crate::query_parser::Statement::Select(q) = stmt {
1301            let (rows, _) = execute_inner(&q, &make_rows(), None).unwrap();
1302            assert_eq!(rows.len(), 1);
1303            assert_eq!(rows[0]["net"], Value::Float(-15.0));
1304        } else {
1305            panic!("Expected Select");
1306        }
1307    }
1308
1309    #[test]
1310    fn test_dateadd_with_dict_in_group_by() {
1311        // Simulate a joined row with a dict field, then GROUP BY + DateAdd expr
1312        use indexmap::IndexMap;
1313        let mut params = IndexMap::new();
1314        params.insert("exit_days".to_string(), Value::Int(21));
1315
1316        let rows = vec![
1317            Row::from([
1318                ("o.token".into(), Value::String("BTC".into())),
1319                ("o.event_date".into(), Value::Date(
1320                    chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()
1321                )),
1322                ("o.size".into(), Value::Int(100)),
1323                ("s.params".into(), Value::Dict(params.clone())),
1324            ]),
1325            Row::from([
1326                ("o.token".into(), Value::String("BTC".into())),
1327                ("o.event_date".into(), Value::Date(
1328                    chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()
1329                )),
1330                ("o.size".into(), Value::Int(50)),
1331                ("s.params".into(), Value::Dict(params.clone())),
1332            ]),
1333        ];
1334
1335        let q = SelectQuery {
1336            columns: ColumnList::Named(vec![
1337                SelectExpr::Column("o.token".into()),
1338                SelectExpr::Column("o.event_date".into()),
1339                SelectExpr::Expr {
1340                    expr: Expr::DateAdd {
1341                        date: Box::new(Expr::Column("o.event_date".into())),
1342                        days: Box::new(Expr::Column("s.params.exit_days".into())),
1343                    },
1344                    alias: Some("exit_date".into()),
1345                },
1346                SelectExpr::Aggregate {
1347                    func: AggFunc::Sum,
1348                    arg: "o.size".into(),
1349                    arg_expr: Some(Expr::Column("o.size".into())),
1350                    alias: Some("total".into()),
1351                },
1352            ]),
1353            table: "orders".into(),
1354            table_alias: None,
1355            joins: vec![],
1356            where_clause: None,
1357            group_by: Some(vec!["o.token".into(), "o.event_date".into()]),
1358            having: None,
1359            order_by: None,
1360            limit: None,
1361        };
1362
1363        let (rows, cols) = execute_inner(&q, &rows, None).unwrap();
1364        assert_eq!(rows.len(), 1);
1365        assert!(cols.contains(&"exit_date".to_string()));
1366        assert_eq!(rows[0]["total"], Value::Float(150.0));
1367        // The key test: exit_date should be 2026-01-22, not Null
1368        assert_eq!(
1369            rows[0]["exit_date"],
1370            Value::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 22).unwrap())
1371        );
1372    }
1373}