Skip to main content

mq_db/
sql.rs

1//! Custom SQL execution engine for mq-db.
2//!
3//! Executes SQL queries directly against the in-memory [`DocumentStore`]
4//! without copying data into an external database. Uses [`sqlparser`] to parse
5//! SQL and evaluates predicates natively against [`Block`] data — including the
6//! O(1) `under(pre, post, anc_pre, anc_post)` interval-index function.
7//!
8//! # Virtual Schema
9//!
10//! ```sql
11//! -- documents table
12//! SELECT id, path, title, tags FROM documents;
13//!
14//! -- blocks table
15//! SELECT id, document_id, block_type, content, pre, post, depth, lang,
16//!        properties FROM blocks;
17//! ```
18//!
19//! # Built-in Functions
20//!
21//! | Function | Description |
22//! |---|---|
23//! | `under(pre, post, anc_pre, anc_post)` | O(1) interval ancestor check |
24//! | `json_extract(json, path)` | Extract value from JSON string |
25//! | `mq(program, content)` | Run an mq program against Markdown content |
26//! | `count`/`min`/`max`/`sum`/`avg`/`group_concat`/`string_agg` | Aggregates (`count` and `group_concat`/`string_agg` support `DISTINCT`) |
27//! | `lower`/`upper`/`length`/`trim`/`ltrim`/`rtrim`/`concat`/`concat_ws`/`replace`/`left`/`right`/`lpad`/`rpad`/`reverse`/`repeat`/`initcap`/`ascii`/`chr`/`instr`/`split_part`/`substring`/`substr`/`position` | String functions |
28//! | `abs`/`round`/`ceil`/`floor`/`trunc`/`mod`/`power`/`sqrt`/`sign`/`exp`/`ln`/`log`/`log10`/`log2`/`pi`/`greatest`/`least` | Numeric functions |
29//! | `coalesce`/`ifnull`/`nullif` | Null handling |
30//! | `typeof`/`now`/`current_timestamp`/`current_date`/`current_time`/`CASE WHEN` | Misc |
31//!
32//! # Example
33//!
34//! ```rust,no_run
35//! use mq_db::{DocumentStore, SqlEngine};
36//!
37//! let mut store = DocumentStore::new();
38//! store.add_str("# Hello\n\n## Architecture\n\nDetails\n\n```rust\ncode\n```\n").unwrap();
39//!
40//! let engine = SqlEngine::new(&store).unwrap();
41//! let out = engine.execute(
42//!     "SELECT block_type, content FROM blocks WHERE block_type = 'heading'"
43//! ).unwrap();
44//! assert!(!out.rows.is_empty());
45//! ```
46
47use rustc_hash::FxHashMap;
48use sqlparser::{
49    ast::{
50        AssignmentTarget, BinaryOperator, CaseWhen, CeilFloorKind, CreateTable, CreateView,
51        DateTimeField, DuplicateTreatment, Expr, FromTable, Function, FunctionArg, FunctionArgExpr,
52        FunctionArguments, GroupByExpr, Insert, JoinConstraint, JoinOperator, LimitClause,
53        ObjectName, ObjectNamePart, ObjectType, OrderByExpr, OrderByKind, Query, Select,
54        SelectItem, SetExpr, SetOperator, SetQuantifier, Statement, TableFactor, TableFunctionArgs,
55        TableObject, TableWithJoins, TrimWhereField, UnaryOperator, Value as SqlValue, Values,
56    },
57    dialect::GenericDialect,
58    parser::Parser,
59};
60
61use mq_lang::{DefaultEngine, parse_markdown_input};
62
63use crate::{
64    DocumentStore, MqdbError,
65    block::{Block, BlockType, Properties, PropertyValue},
66    document::{Document, ZoneMaps},
67    indexes::{DocumentIndex, IndexHint, tokenize},
68    store::{CustomTableState, DatabaseAlias},
69};
70
71#[derive(Debug, Clone, PartialEq)]
72pub enum Value {
73    Str(String),
74    Int(i64),
75    Float(f64),
76    Bool(bool),
77    Null,
78}
79
80impl Value {
81    fn as_str(&self) -> Option<&str> {
82        if let Value::Str(s) = self {
83            Some(s)
84        } else {
85            None
86        }
87    }
88    fn as_i64(&self) -> Option<i64> {
89        match self {
90            Value::Int(n) => Some(*n),
91            Value::Float(f) => Some(*f as i64),
92            _ => None,
93        }
94    }
95    fn as_f64(&self) -> Option<f64> {
96        match self {
97            Value::Float(f) => Some(*f),
98            Value::Int(n) => Some(*n as f64),
99            _ => None,
100        }
101    }
102    fn is_truthy(&self) -> bool {
103        match self {
104            Value::Bool(b) => *b,
105            Value::Int(n) => *n != 0,
106            Value::Float(f) => *f != 0.0,
107            Value::Str(s) => !s.is_empty(),
108            Value::Null => false,
109        }
110    }
111    fn display(&self) -> String {
112        match self {
113            Value::Str(s) => s.clone(),
114            Value::Int(n) => n.to_string(),
115            Value::Float(f) => f.to_string(),
116            Value::Bool(b) => b.to_string(),
117            Value::Null => "NULL".to_string(),
118        }
119    }
120    fn cmp_val(&self, other: &Value) -> Option<std::cmp::Ordering> {
121        match (self, other) {
122            (Value::Int(a), Value::Int(b)) => Some(a.cmp(b)),
123            (Value::Float(a), Value::Float(b)) => a.partial_cmp(b),
124            (Value::Int(a), Value::Float(b)) => (*a as f64).partial_cmp(b),
125            (Value::Float(a), Value::Int(b)) => a.partial_cmp(&(*b as f64)),
126            (Value::Str(a), Value::Str(b)) => Some(a.cmp(b)),
127            (Value::Null, Value::Null) => Some(std::cmp::Ordering::Equal),
128            _ => None,
129        }
130    }
131}
132
133/// Hashable projection of [`Value`], mirroring its derived `PartialEq` (no
134/// cross-variant coercion, `NULL` equals `NULL`, `NaN` matches nothing).
135#[derive(PartialEq, Eq, Hash)]
136enum JoinKey {
137    Str(String),
138    Int(i64),
139    Bool(bool),
140    FloatBits(u64),
141    Null,
142}
143
144fn value_join_key(v: &Value) -> Option<JoinKey> {
145    match v {
146        Value::Str(s) => Some(JoinKey::Str(s.clone())),
147        Value::Int(i) => Some(JoinKey::Int(*i)),
148        Value::Bool(b) => Some(JoinKey::Bool(*b)),
149        Value::Null => Some(JoinKey::Null),
150        Value::Float(f) if f.is_nan() => None, // NaN matches nothing
151        Value::Float(f) => {
152            let normalized = if *f == 0.0 { 0.0 } else { *f };
153            Some(JoinKey::FloatBits(normalized.to_bits()))
154        }
155    }
156}
157
158#[derive(Debug, Clone)]
159struct Row {
160    columns: Vec<String>,
161    values: Vec<Value>,
162}
163
164impl Row {
165    fn get(&self, col: &str) -> Option<&Value> {
166        let col_lower = col.to_lowercase();
167        if let Some(i) = self
168            .columns
169            .iter()
170            .position(|c| c.to_lowercase() == col_lower)
171        {
172            return self.values.get(i);
173        }
174        // Try short name (strip "table." prefix from query)
175        let short = col_lower.split('.').next_back().unwrap_or(&col_lower);
176        // Match "alias.col" columns
177        self.columns
178            .iter()
179            .position(|c| {
180                let cl = c.to_lowercase();
181                cl == col_lower || cl.split('.').next_back().unwrap_or(&cl) == short
182            })
183            .and_then(|i| self.values.get(i))
184    }
185}
186
187fn json_value_str(s: &str) -> String {
188    if let Ok(n) = s.parse::<i64>() {
189        return n.to_string();
190    }
191    if let Ok(f) = s.parse::<f64>() {
192        return f.to_string();
193    }
194    if s == "true" || s == "false" || s == "null" || s == "NULL" {
195        return s.to_lowercase();
196    }
197    // Treat as JSON string — escape quotes and backslashes
198    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
199}
200
201fn csv_cell(s: &str) -> String {
202    if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') {
203        format!("\"{}\"", s.replace('"', "\"\""))
204    } else {
205        s.to_string()
206    }
207}
208
209fn csv_row(fields: &[String]) -> String {
210    let mut row = fields
211        .iter()
212        .map(|f| csv_cell(f))
213        .collect::<Vec<_>>()
214        .join(",");
215    row.push('\n');
216    row
217}
218
219pub fn html_escape(s: &str) -> String {
220    s.replace('&', "&amp;")
221        .replace('<', "&lt;")
222        .replace('>', "&gt;")
223        .replace('"', "&quot;")
224}
225
226/// The tabular output of a SQL query.
227#[derive(Debug)]
228pub struct QueryOutput {
229    pub columns: Vec<String>,
230    pub rows: Vec<Vec<String>>,
231}
232
233impl QueryOutput {
234    /// Render as a JSON array of objects, one object per row.
235    pub fn to_json(&self) -> String {
236        if self.rows.is_empty() {
237            return "[]\n".to_string();
238        }
239        let objects: Vec<String> = self
240            .rows
241            .iter()
242            .map(|row| {
243                let pairs: Vec<String> = self
244                    .columns
245                    .iter()
246                    .zip(row.iter())
247                    .map(|(col, val)| {
248                        format!(
249                            "\"{}\":{}",
250                            col.replace('\\', "\\\\").replace('"', "\\\""),
251                            json_value_str(val)
252                        )
253                    })
254                    .collect();
255                format!("{{{}}}", pairs.join(","))
256            })
257            .collect();
258        format!("[{}]\n", objects.join(","))
259    }
260
261    /// Render as RFC 4180 CSV with a header row.
262    pub fn to_csv(&self) -> String {
263        let mut out = String::new();
264        if !self.columns.is_empty() {
265            out.push_str(&csv_row(&self.columns));
266        }
267        for row in &self.rows {
268            out.push_str(&csv_row(row));
269        }
270        out
271    }
272
273    /// Render as tab-separated values with a header row.
274    pub fn to_tsv(&self) -> String {
275        let mut out = String::new();
276        if !self.columns.is_empty() {
277            out.push_str(&self.columns.join("\t"));
278            out.push('\n');
279        }
280        for row in &self.rows {
281            out.push_str(&row.join("\t"));
282            out.push('\n');
283        }
284        out
285    }
286
287    /// Render as a GFM Markdown table.
288    pub fn to_markdown_table(&self) -> String {
289        if self.columns.is_empty() {
290            return String::new();
291        }
292        let mut widths: Vec<usize> = self.columns.iter().map(|h| h.len().max(3)).collect();
293        for row in &self.rows {
294            for (i, cell) in row.iter().enumerate() {
295                if i < widths.len() {
296                    widths[i] = widths[i].max(cell.len());
297                }
298            }
299        }
300
301        let mut out = String::new();
302        out.push('|');
303        for (i, h) in self.columns.iter().enumerate() {
304            out.push_str(&format!(" {:<w$} |", h, w = widths[i]));
305        }
306        out.push('\n');
307
308        out.push('|');
309        for &w in &widths {
310            out.push_str(&format!(" {} |", "-".repeat(w)));
311        }
312        out.push('\n');
313
314        for row in &self.rows {
315            out.push('|');
316            for (i, &w) in widths.iter().enumerate() {
317                let cell = row.get(i).map(String::as_str).unwrap_or("");
318                let escaped = cell
319                    .replace('|', "\\|")
320                    .replace('\n', " ")
321                    .replace('\r', "");
322                out.push_str(&format!(" {:<w$} |", escaped, w = w));
323            }
324            out.push('\n');
325        }
326        out
327    }
328
329    /// Render as an HTML `<table>`.
330    pub fn to_html_table(&self) -> String {
331        let mut out = String::from("<table>\n");
332        if !self.columns.is_empty() {
333            out.push_str("<thead><tr>");
334            for h in &self.columns {
335                out.push_str(&format!("<th>{}</th>", html_escape(h)));
336            }
337            out.push_str("</tr></thead>\n");
338        }
339        out.push_str("<tbody>\n");
340        for row in &self.rows {
341            out.push_str("<tr>");
342            for (i, _) in self.columns.iter().enumerate() {
343                let cell = row.get(i).map(String::as_str).unwrap_or("");
344                out.push_str(&format!("<td>{}</td>", html_escape(cell)));
345            }
346            out.push_str("</tr>\n");
347        }
348        out.push_str("</tbody>\n</table>\n");
349        out
350    }
351
352    /// Render as a Unicode box-drawing table. Cells > 60 chars are truncated.
353    pub fn to_table(&self) -> String {
354        const MAX_CELL: usize = 60;
355
356        if self.columns.is_empty() {
357            return "(no columns)\n".to_string();
358        }
359        if self.rows.is_empty() {
360            return "(0 rows)\n".to_string();
361        }
362
363        let mut widths: Vec<usize> = self.columns.iter().map(|h| h.len()).collect();
364        for row in &self.rows {
365            for (i, cell) in row.iter().enumerate() {
366                if i < widths.len() {
367                    let display_len = cell.replace('\r', "").replace('\n', " ").chars().count();
368                    widths[i] = widths[i].max(display_len.min(MAX_CELL));
369                }
370            }
371        }
372
373        let col_count = self.columns.len();
374        let mut out = String::new();
375
376        out.push('┌');
377        for (i, &w) in widths.iter().enumerate() {
378            out.push_str(&"─".repeat(w + 2));
379            out.push(if i + 1 < col_count { '┬' } else { '┐' });
380        }
381        out.push('\n');
382
383        out.push('│');
384        for (i, h) in self.columns.iter().enumerate() {
385            out.push_str(&format!(" {:<width$} │", h, width = widths[i]));
386        }
387        out.push('\n');
388
389        out.push('├');
390        for (i, &w) in widths.iter().enumerate() {
391            out.push_str(&"─".repeat(w + 2));
392            out.push(if i + 1 < col_count { '┼' } else { '┤' });
393        }
394        out.push('\n');
395
396        for row in &self.rows {
397            out.push('│');
398            for (i, &w) in widths.iter().enumerate() {
399                let cell = row.get(i).map(String::as_str).unwrap_or("");
400                let cell = cell.replace('\r', "").replace('\n', " ");
401                let truncated: String = if cell.chars().count() > MAX_CELL {
402                    let mut s: String = cell.chars().take(MAX_CELL - 1).collect();
403                    s.push('…');
404                    s
405                } else {
406                    cell
407                };
408                out.push_str(&format!(" {:<width$} │", truncated, width = w));
409            }
410            out.push('\n');
411        }
412
413        out.push('└');
414        for (i, &w) in widths.iter().enumerate() {
415            out.push_str(&"─".repeat(w + 2));
416            out.push(if i + 1 < col_count { '┴' } else { '┘' });
417        }
418        out.push('\n');
419        out.push_str(&format!(
420            "({} row{})\n",
421            self.rows.len(),
422            if self.rows.len() == 1 { "" } else { "s" }
423        ));
424        out
425    }
426}
427
428fn pv_to_json(pv: &PropertyValue) -> String {
429    match pv {
430        PropertyValue::String(s) => {
431            format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
432        }
433        PropertyValue::Int(n) => n.to_string(),
434        PropertyValue::Float(f) => f.to_string(),
435        PropertyValue::Bool(b) => b.to_string(),
436        PropertyValue::Array(arr) => {
437            format!(
438                "[{}]",
439                arr.iter().map(pv_to_json).collect::<Vec<_>>().join(",")
440            )
441        }
442        PropertyValue::Null => "null".to_string(),
443    }
444}
445
446fn properties_to_json(props: &Properties) -> String {
447    let pairs: Vec<String> = props
448        .iter()
449        .map(|(k, v)| {
450            format!(
451                "\"{}\":{}",
452                k.replace('\\', "\\\\").replace('"', "\\\""),
453                pv_to_json(v)
454            )
455        })
456        .collect();
457    format!("{{{}}}", pairs.join(","))
458}
459
460fn block_to_row(doc_id: u32, block: &Block, block_idx: u32) -> Row {
461    Row {
462        columns: vec![
463            "id".into(),
464            "document_id".into(),
465            "block_type".into(),
466            "content".into(),
467            "pre".into(),
468            "post".into(),
469            "depth".into(),
470            "lang".into(),
471            "properties".into(),
472        ],
473        values: vec![
474            Value::Int(block_idx as i64),
475            Value::Int(doc_id as i64),
476            Value::Str(block.block_type.as_str().to_string()),
477            Value::Str(block.content.clone()),
478            Value::Int(block.pre as i64),
479            Value::Int(block.post as i64),
480            Value::Int(block.heading_depth().unwrap_or(0) as i64),
481            Value::Str(block.code_lang().unwrap_or("").to_string()),
482            Value::Str(properties_to_json(&block.properties)),
483        ],
484    }
485}
486
487fn doc_to_row(doc: &Document) -> Row {
488    let tags_json = {
489        let items: Vec<String> = doc
490            .zone_maps
491            .tags
492            .iter()
493            .map(|t| format!("\"{}\"", t.replace('"', "\\\"")))
494            .collect();
495        format!("[{}]", items.join(","))
496    };
497    Row {
498        columns: vec!["id".into(), "path".into(), "title".into(), "tags".into()],
499        values: vec![
500            Value::Int(doc.id as i64),
501            Value::Str(
502                doc.path
503                    .as_ref()
504                    .and_then(|p| p.to_str())
505                    .unwrap_or("")
506                    .to_string(),
507            ),
508            Value::Str(doc.zone_maps.title.clone().unwrap_or_default()),
509            Value::Str(tags_json),
510        ],
511    }
512}
513
514fn qualify_row(row: Row, prefix: &str) -> Row {
515    Row {
516        columns: row
517            .columns
518            .iter()
519            .map(|c| format!("{}.{}", prefix, c))
520            .collect(),
521        values: row.values,
522    }
523}
524
525fn parse_display_value(s: &str) -> Value {
526    if let Ok(n) = s.parse::<i64>() {
527        Value::Int(n)
528    } else if let Ok(f) = s.parse::<f64>() {
529        Value::Float(f)
530    } else {
531        Value::Str(s.to_string())
532    }
533}
534
535fn output_to_rows(out: &QueryOutput, prefix: &str) -> Vec<Row> {
536    out.rows
537        .iter()
538        .map(|r| {
539            qualify_row(
540                Row {
541                    columns: out.columns.clone(),
542                    values: r.iter().map(|v| parse_display_value(v)).collect(),
543                },
544                prefix,
545            )
546        })
547        .collect()
548}
549
550fn cross_join(left: Vec<Row>, right: Vec<Row>) -> Vec<Row> {
551    let mut out = Vec::with_capacity(left.len() * right.len());
552    for l in &left {
553        for r in &right {
554            let mut cols = l.columns.clone();
555            cols.extend(r.columns.iter().cloned());
556            let mut vals = l.values.clone();
557            vals.extend(r.values.iter().cloned());
558            out.push(Row {
559                columns: cols,
560                values: vals,
561            });
562        }
563    }
564    out
565}
566
567/// Equi-join fast path: hashes `right` by `right_key_expr` and probes it with
568/// `left_key_expr` per left row instead of the full `left * right` cross
569/// product. `full_predicate` is still checked per candidate pair, so results
570/// match `cross_join` + `.retain(full_predicate)` exactly.
571fn hash_equi_join(
572    left: Vec<Row>,
573    right: Vec<Row>,
574    left_key_expr: &Expr,
575    right_key_expr: &Expr,
576    full_predicate: &Expr,
577) -> Vec<Row> {
578    let mut buckets: FxHashMap<JoinKey, Vec<usize>> = FxHashMap::default();
579    for (i, r) in right.iter().enumerate() {
580        if let Some(key) = value_join_key(&eval_expr(right_key_expr, r)) {
581            buckets.entry(key).or_default().push(i);
582        }
583    }
584
585    let mut out = Vec::new();
586    for l in &left {
587        let Some(key) = value_join_key(&eval_expr(left_key_expr, l)) else {
588            continue;
589        };
590        let Some(candidates) = buckets.get(&key) else {
591            continue;
592        };
593        for &i in candidates {
594            let r = &right[i];
595            let mut cols = l.columns.clone();
596            cols.extend(r.columns.iter().cloned());
597            let mut vals = l.values.clone();
598            vals.extend(r.values.iter().cloned());
599            let combined = Row {
600                columns: cols,
601                values: vals,
602            };
603            if eval_expr(full_predicate, &combined).is_truthy() {
604                out.push(combined);
605            }
606        }
607    }
608    out
609}
610
611fn eval_sql_value(v: &SqlValue) -> Value {
612    match v {
613        SqlValue::Number(n, _) => {
614            if let Ok(i) = n.parse::<i64>() {
615                Value::Int(i)
616            } else if let Ok(f) = n.parse::<f64>() {
617                Value::Float(f)
618            } else {
619                Value::Null
620            }
621        }
622        SqlValue::SingleQuotedString(s) | SqlValue::DoubleQuotedString(s) => Value::Str(s.clone()),
623        SqlValue::Boolean(b) => Value::Bool(*b),
624        SqlValue::Null => Value::Null,
625        _ => Value::Null,
626    }
627}
628
629fn ident_value(part: &ObjectNamePart) -> &str {
630    match part {
631        ObjectNamePart::Identifier(i) => &i.value,
632        ObjectNamePart::Function(_) => "",
633    }
634}
635
636/// Table name for DDL/DML targets, which must be unqualified — writes
637/// through an ATTACHed database's `<alias>.<table>` are not supported.
638fn require_unqualified(name: &ObjectName) -> Result<String, MqdbError> {
639    if name.0.len() > 1 {
640        return Err(MqdbError::SqlExec(format!(
641            "'{}': writes to an attached database are not supported — only SELECT/JOIN may use <alias>.<table>",
642            name.0.iter().map(ident_value).collect::<Vec<_>>().join(".")
643        )));
644    }
645    Ok(name.0.last().map(ident_value).unwrap_or("").to_lowercase())
646}
647
648/// Single-row `("ok")` result for statements with no natural row output
649/// (`ATTACH`/`DETACH`).
650fn ok_result() -> QueryOutput {
651    QueryOutput {
652        columns: vec!["result".to_string()],
653        rows: vec![vec!["ok".to_string()]],
654    }
655}
656
657fn eval_expr(expr: &Expr, row: &Row) -> Value {
658    match expr {
659        Expr::Value(v) => eval_sql_value(&v.value),
660        Expr::Identifier(i) => row.get(&i.value).cloned().unwrap_or(Value::Null),
661        Expr::CompoundIdentifier(parts) => {
662            // CompoundIdentifier holds Vec<Ident> (not Vec<ObjectNamePart>)
663            let full = parts
664                .iter()
665                .map(|i| i.value.as_str())
666                .collect::<Vec<_>>()
667                .join(".");
668            let short = parts.last().map(|i| i.value.as_str()).unwrap_or("");
669            row.get(&full)
670                .or_else(|| row.get(short))
671                .cloned()
672                .unwrap_or(Value::Null)
673        }
674        Expr::BinaryOp { left, op, right } => eval_binary(left, op, right, row),
675        Expr::UnaryOp { op, expr } => match op {
676            UnaryOperator::Not => Value::Bool(!eval_expr(expr, row).is_truthy()),
677            UnaryOperator::Minus => match eval_expr(expr, row) {
678                Value::Int(n) => Value::Int(-n),
679                Value::Float(f) => Value::Float(-f),
680                _ => Value::Null,
681            },
682            _ => Value::Null,
683        },
684        Expr::IsNull(inner) => Value::Bool(matches!(eval_expr(inner, row), Value::Null)),
685        Expr::IsNotNull(inner) => Value::Bool(!matches!(eval_expr(inner, row), Value::Null)),
686        Expr::InList {
687            expr,
688            list,
689            negated,
690        } => {
691            let val = eval_expr(expr, row);
692            let found = list.iter().any(|e| eval_expr(e, row) == val);
693            Value::Bool(if *negated { !found } else { found })
694        }
695        Expr::Between {
696            expr,
697            negated,
698            low,
699            high,
700        } => {
701            let val = eval_expr(expr, row);
702            let lo = eval_expr(low, row);
703            let hi = eval_expr(high, row);
704            let in_range = lo.cmp_val(&val).map(|o| o.is_le()).unwrap_or(false)
705                && val.cmp_val(&hi).map(|o| o.is_le()).unwrap_or(false);
706            Value::Bool(if *negated { !in_range } else { in_range })
707        }
708        Expr::Like {
709            expr,
710            negated,
711            pattern,
712            ..
713        } => {
714            let val = eval_expr(expr, row);
715            let pat = eval_expr(pattern, row);
716            if let (Value::Str(s), Value::Str(p)) = (val, pat) {
717                let matched = like_match_str(&s, &p);
718                Value::Bool(if *negated { !matched } else { matched })
719            } else {
720                Value::Bool(false)
721            }
722        }
723        Expr::Function(f) => eval_function_call(f, row),
724        Expr::Nested(inner) => eval_expr(inner, row),
725        Expr::Cast { expr, .. } => eval_expr(expr, row),
726        Expr::Case {
727            operand,
728            conditions,
729            else_result,
730            ..
731        } => eval_case(operand.as_deref(), conditions, else_result.as_deref(), row),
732        Expr::Trim {
733            expr,
734            trim_where,
735            trim_what,
736            trim_characters,
737        } => eval_trim(expr, trim_where, trim_what, trim_characters, row),
738        Expr::Substring {
739            expr,
740            substring_from,
741            substring_for,
742            ..
743        } => eval_substring(expr, substring_from, substring_for, row),
744        Expr::Position { expr, r#in } => eval_position(expr, r#in, row),
745        Expr::Ceil { expr, field } => eval_ceil_floor(expr, field, row, true),
746        Expr::Floor { expr, field } => eval_ceil_floor(expr, field, row, false),
747        // Subqueries are pre-resolved by resolve_subqueries before eval
748        _ => Value::Null,
749    }
750}
751
752fn eval_case(
753    operand: Option<&Expr>,
754    conditions: &[CaseWhen],
755    else_result: Option<&Expr>,
756    row: &Row,
757) -> Value {
758    let operand_val = operand.map(|o| eval_expr(o, row));
759    for when in conditions {
760        let matched = match &operand_val {
761            Some(ov) => *ov == eval_expr(&when.condition, row),
762            None => eval_expr(&when.condition, row).is_truthy(),
763        };
764        if matched {
765            return eval_expr(&when.result, row);
766        }
767    }
768    else_result
769        .map(|e| eval_expr(e, row))
770        .unwrap_or(Value::Null)
771}
772
773fn eval_trim(
774    expr: &Expr,
775    trim_where: &Option<TrimWhereField>,
776    trim_what: &Option<Box<Expr>>,
777    trim_characters: &Option<Vec<Expr>>,
778    row: &Row,
779) -> Value {
780    let s = match eval_expr(expr, row).as_str() {
781        Some(s) => s.to_string(),
782        None => return Value::Null,
783    };
784    let chars: Vec<char> = if let Some(w) = trim_what {
785        eval_expr(w, row)
786            .as_str()
787            .map(|s| s.chars().collect())
788            .unwrap_or_default()
789    } else if let Some(cs) = trim_characters {
790        cs.iter()
791            .filter_map(|e| eval_expr(e, row).as_str().map(|s| s.to_string()))
792            .collect::<String>()
793            .chars()
794            .collect()
795    } else {
796        vec![' ', '\t', '\n', '\r']
797    };
798    let is_trim_char = |c: char| chars.contains(&c);
799    let trimmed = match trim_where {
800        Some(TrimWhereField::Leading) => s.trim_start_matches(is_trim_char).to_string(),
801        Some(TrimWhereField::Trailing) => s.trim_end_matches(is_trim_char).to_string(),
802        _ => s.trim_matches(is_trim_char).to_string(),
803    };
804    Value::Str(trimmed)
805}
806
807fn eval_substring(
808    expr: &Expr,
809    substring_from: &Option<Box<Expr>>,
810    substring_for: &Option<Box<Expr>>,
811    row: &Row,
812) -> Value {
813    let s = match eval_expr(expr, row).as_str() {
814        Some(s) => s.to_string(),
815        None => return Value::Null,
816    };
817    let chars: Vec<char> = s.chars().collect();
818    let len = chars.len() as i64;
819    let start_1based = substring_from
820        .as_ref()
821        .map(|e| eval_expr(e, row).as_i64().unwrap_or(1))
822        .unwrap_or(1);
823    let take = substring_for
824        .as_ref()
825        .map(|e| eval_expr(e, row).as_i64().unwrap_or(len));
826    // SQL substring is 1-based; positions before 1 are clamped, consuming from
827    // the requested length as if the string started earlier.
828    let start_0based = (start_1based - 1).max(0) as usize;
829    let end_0based = match take {
830        Some(n) => {
831            let end = start_1based - 1 + n.max(0);
832            end.clamp(0, len) as usize
833        }
834        None => len as usize,
835    };
836    if start_0based >= chars.len() || end_0based <= start_0based {
837        return Value::Str(String::new());
838    }
839    Value::Str(chars[start_0based..end_0based].iter().collect())
840}
841
842fn eval_position(expr: &Expr, r#in: &Expr, row: &Row) -> Value {
843    let needle = eval_expr(expr, row);
844    let haystack = eval_expr(r#in, row);
845    match (needle.as_str(), haystack.as_str()) {
846        (Some(needle), Some(haystack)) => {
847            let hay_chars: Vec<char> = haystack.chars().collect();
848            let needle_chars: Vec<char> = needle.chars().collect();
849            if needle_chars.is_empty() {
850                return Value::Int(0);
851            }
852            for i in 0..=hay_chars.len().saturating_sub(needle_chars.len()) {
853                if hay_chars[i..i + needle_chars.len()] == needle_chars[..] {
854                    return Value::Int(i as i64 + 1);
855                }
856            }
857            Value::Int(0)
858        }
859        _ => Value::Null,
860    }
861}
862
863fn eval_ceil_floor(expr: &Expr, field: &CeilFloorKind, row: &Row, is_ceil: bool) -> Value {
864    let n = match eval_expr(expr, row).as_f64() {
865        Some(n) => n,
866        None => return Value::Null,
867    };
868    let scale = match field {
869        CeilFloorKind::Scale(v) => match &v.value {
870            SqlValue::Number(s, _) => s.parse::<i32>().unwrap_or(0),
871            _ => 0,
872        },
873        CeilFloorKind::DateTimeField(DateTimeField::NoDateTime) => 0,
874        // Date-truncation forms (`CEIL(x TO DAY)`) need calendar data we don't track.
875        _ => return Value::Null,
876    };
877    let factor = 10f64.powi(scale);
878    let scaled = n * factor;
879    let rounded = if is_ceil {
880        scaled.ceil()
881    } else {
882        scaled.floor()
883    };
884    let result = rounded / factor;
885    if scale <= 0 && result.fract() == 0.0 {
886        Value::Int(result as i64)
887    } else {
888        Value::Float(result)
889    }
890}
891
892fn eval_binary(left: &Expr, op: &BinaryOperator, right: &Expr, row: &Row) -> Value {
893    match op {
894        BinaryOperator::And => {
895            if !eval_expr(left, row).is_truthy() {
896                return Value::Bool(false);
897            }
898            Value::Bool(eval_expr(right, row).is_truthy())
899        }
900        BinaryOperator::Or => {
901            if eval_expr(left, row).is_truthy() {
902                return Value::Bool(true);
903            }
904            Value::Bool(eval_expr(right, row).is_truthy())
905        }
906        BinaryOperator::Eq => Value::Bool(eval_expr(left, row) == eval_expr(right, row)),
907        BinaryOperator::NotEq => Value::Bool(eval_expr(left, row) != eval_expr(right, row)),
908        BinaryOperator::Lt => cmp_op(left, right, row, |o| o.is_lt()),
909        BinaryOperator::LtEq => cmp_op(left, right, row, |o| o.is_le()),
910        BinaryOperator::Gt => cmp_op(left, right, row, |o| o.is_gt()),
911        BinaryOperator::GtEq => cmp_op(left, right, row, |o| o.is_ge()),
912        BinaryOperator::Plus => arith_op(left, right, row, |a, b| a + b, |a, b| a + b),
913        BinaryOperator::Minus => arith_op(left, right, row, |a, b| a - b, |a, b| a - b),
914        BinaryOperator::Multiply => arith_op(left, right, row, |a, b| a * b, |a, b| a * b),
915        BinaryOperator::Divide => {
916            let (l, r) = (eval_expr(left, row), eval_expr(right, row));
917            match (&l, &r) {
918                (Value::Int(a), Value::Int(b)) if *b != 0 => Value::Int(a / b),
919                _ => match (l.as_f64(), r.as_f64()) {
920                    (Some(a), Some(b)) if b != 0.0 => Value::Float(a / b),
921                    _ => Value::Null,
922                },
923            }
924        }
925        BinaryOperator::StringConcat => {
926            let l = eval_expr(left, row);
927            let r = eval_expr(right, row);
928            Value::Str(format!("{}{}", l.display(), r.display()))
929        }
930        _ => Value::Null,
931    }
932}
933
934fn cmp_op(l: &Expr, r: &Expr, row: &Row, f: impl Fn(std::cmp::Ordering) -> bool) -> Value {
935    Value::Bool(
936        eval_expr(l, row)
937            .cmp_val(&eval_expr(r, row))
938            .map(f)
939            .unwrap_or(false),
940    )
941}
942
943fn arith_op(
944    l: &Expr,
945    r: &Expr,
946    row: &Row,
947    int_f: impl Fn(i64, i64) -> i64,
948    flt_f: impl Fn(f64, f64) -> f64,
949) -> Value {
950    let (lv, rv) = (eval_expr(l, row), eval_expr(r, row));
951    match (&lv, &rv) {
952        (Value::Int(a), Value::Int(b)) => Value::Int(int_f(*a, *b)),
953        _ => match (lv.as_f64(), rv.as_f64()) {
954            (Some(a), Some(b)) => Value::Float(flt_f(a, b)),
955            _ => Value::Null,
956        },
957    }
958}
959
960fn eval_function_call(f: &Function, row: &Row) -> Value {
961    let name = f.name.0.last().map(ident_value).unwrap_or("");
962    // Aggregates return placeholder; resolved later
963    if is_aggregate_name(&name.to_lowercase()) {
964        return Value::Int(1);
965    }
966    let args: Vec<Value> = match &f.args {
967        FunctionArguments::List(al) => al
968            .args
969            .iter()
970            .filter_map(|a| match a {
971                FunctionArg::Unnamed(FunctionArgExpr::Expr(e)) => Some(eval_expr(e, row)),
972                _ => None,
973            })
974            .collect(),
975        _ => vec![],
976    };
977    eval_scalar_function(name, &args)
978}
979
980fn eval_scalar_function(name: &str, args: &[Value]) -> Value {
981    match name.to_lowercase().as_str() {
982        "under" => {
983            if args.len() < 4 {
984                return Value::Bool(false);
985            }
986            let (pre, post) = (args[0].as_i64().unwrap_or(0), args[1].as_i64().unwrap_or(0));
987            let (ap, aq) = (args[2].as_i64().unwrap_or(0), args[3].as_i64().unwrap_or(0));
988            Value::Bool(pre > ap && post < aq)
989        }
990        "json_extract" => {
991            if args.len() < 2 {
992                return Value::Null;
993            }
994            let json = args[0].as_str().unwrap_or("");
995            let path = args[1].as_str().unwrap_or("");
996            let key = path.trim_start_matches("$.").trim_matches('"');
997            extract_json_key(json, key)
998        }
999        "mq" => {
1000            if args.len() < 2 {
1001                return Value::Null;
1002            }
1003            let program = match args[0].as_str() {
1004                Some(s) => s.to_string(),
1005                None => return Value::Null,
1006            };
1007            let content = match args[1].as_str() {
1008                Some(s) => s.to_string(),
1009                None => return Value::Null,
1010            };
1011            eval_mq_scalar(&program, &content)
1012        }
1013        "match" => {
1014            let (Some(content), Some(query)) = (
1015                args.first().and_then(Value::as_str),
1016                args.get(1).and_then(Value::as_str),
1017            ) else {
1018                return Value::Bool(false);
1019            };
1020            let content_terms: std::collections::HashSet<String> =
1021                tokenize(content).into_iter().collect();
1022            let query_terms = tokenize(query);
1023            Value::Bool(
1024                !query_terms.is_empty() && query_terms.iter().all(|t| content_terms.contains(t)),
1025            )
1026        }
1027        "score" => {
1028            let (Some(content), Some(query)) = (
1029                args.first().and_then(Value::as_str),
1030                args.get(1).and_then(Value::as_str),
1031            ) else {
1032                return Value::Float(0.0);
1033            };
1034            let content_terms = tokenize(content);
1035            let query_terms = tokenize(query);
1036            if content_terms.is_empty() || query_terms.is_empty() {
1037                return Value::Float(0.0);
1038            }
1039            // Simple term-frequency score, normalised by content length —
1040            // deliberately not BM25 (no IDF/corpus-wide stats): `eval_expr`
1041            // only ever sees one `Row` at a time with no back-reference to
1042            // the corpus, so a real IDF term would need a much larger
1043            // signature change (see `TermIndex`'s doc comment for the same
1044            // constraint on the index side). Good enough to rank matches
1045            // within a single query; a document that repeats a common word
1046            // many times can outrank one with a rarer, more specific match.
1047            let mut freq: FxHashMap<&str, u32> = FxHashMap::default();
1048            for t in &content_terms {
1049                *freq.entry(t.as_str()).or_default() += 1;
1050            }
1051            let hits: f64 = query_terms
1052                .iter()
1053                .map(|q| *freq.get(q.as_str()).unwrap_or(&0) as f64)
1054                .sum();
1055            Value::Float(hits / content_terms.len() as f64)
1056        }
1057
1058        "lower" => str_fn(args, |s| s.to_lowercase()),
1059        "upper" => str_fn(args, |s| s.to_uppercase()),
1060        "length" | "len" | "char_length" | "character_length" => args
1061            .first()
1062            .and_then(|v| v.as_str())
1063            .map(|s| Value::Int(s.chars().count() as i64))
1064            .unwrap_or(Value::Null),
1065        "trim" => str_fn(args, |s| s.trim().to_string()),
1066        "ltrim" => {
1067            let chars = trim_char_set(args, 1);
1068            str_fn(args, |s| {
1069                s.trim_start_matches(|c| chars.contains(&c)).to_string()
1070            })
1071        }
1072        "rtrim" => {
1073            let chars = trim_char_set(args, 1);
1074            str_fn(args, |s| {
1075                s.trim_end_matches(|c| chars.contains(&c)).to_string()
1076            })
1077        }
1078        "concat" => Value::Str(
1079            args.iter()
1080                .map(|v| v.display())
1081                .collect::<Vec<_>>()
1082                .join(""),
1083        ),
1084        "concat_ws" => {
1085            let sep = match args.first().and_then(|v| v.as_str()) {
1086                Some(s) => s,
1087                None => return Value::Null,
1088            };
1089            Value::Str(
1090                args[1..]
1091                    .iter()
1092                    .filter(|v| !matches!(v, Value::Null))
1093                    .map(|v| v.display())
1094                    .collect::<Vec<_>>()
1095                    .join(sep),
1096            )
1097        }
1098        "replace" => {
1099            if args.len() < 3 {
1100                return Value::Null;
1101            }
1102            match (args[0].as_str(), args[1].as_str(), args[2].as_str()) {
1103                (Some(s), Some(from), Some(to)) => Value::Str(s.replace(from, to)),
1104                _ => Value::Null,
1105            }
1106        }
1107        "left" => str_int_fn(args, |chars, n| {
1108            chars[..(n.max(0) as usize).min(chars.len())]
1109                .iter()
1110                .collect()
1111        }),
1112        "right" => str_int_fn(args, |chars, n| {
1113            let n = (n.max(0) as usize).min(chars.len());
1114            chars[chars.len() - n..].iter().collect()
1115        }),
1116        "lpad" => pad_fn(args, true),
1117        "rpad" => pad_fn(args, false),
1118        "reverse" => str_fn(args, |s| s.chars().rev().collect()),
1119        "repeat" => {
1120            if args.len() < 2 {
1121                return Value::Null;
1122            }
1123            match (args[0].as_str(), args[1].as_i64()) {
1124                (Some(s), Some(n)) => Value::Str(s.repeat(n.max(0) as usize)),
1125                _ => Value::Null,
1126            }
1127        }
1128        "initcap" => str_fn(args, |s| {
1129            s.split(' ')
1130                .map(|word| {
1131                    let mut c = word.chars();
1132                    match c.next() {
1133                        Some(first) => {
1134                            first.to_uppercase().collect::<String>() + &c.as_str().to_lowercase()
1135                        }
1136                        None => String::new(),
1137                    }
1138                })
1139                .collect::<Vec<_>>()
1140                .join(" ")
1141        }),
1142        "ascii" => args
1143            .first()
1144            .and_then(|v| v.as_str())
1145            .and_then(|s| s.chars().next())
1146            .map(|c| Value::Int(c as i64))
1147            .unwrap_or(Value::Null),
1148        "chr" => args
1149            .first()
1150            .and_then(|v| v.as_i64())
1151            .and_then(|n| u32::try_from(n).ok())
1152            .and_then(char::from_u32)
1153            .map(|c| Value::Str(c.to_string()))
1154            .unwrap_or(Value::Null),
1155        "instr" => {
1156            if args.len() < 2 {
1157                return Value::Null;
1158            }
1159            match (args[0].as_str(), args[1].as_str()) {
1160                (Some(haystack), Some(needle)) => {
1161                    let hay_chars: Vec<char> = haystack.chars().collect();
1162                    let needle_chars: Vec<char> = needle.chars().collect();
1163                    if needle_chars.is_empty() {
1164                        return Value::Int(0);
1165                    }
1166                    for i in 0..=hay_chars.len().saturating_sub(needle_chars.len()) {
1167                        if hay_chars[i..i + needle_chars.len()] == needle_chars[..] {
1168                            return Value::Int(i as i64 + 1);
1169                        }
1170                    }
1171                    Value::Int(0)
1172                }
1173                _ => Value::Null,
1174            }
1175        }
1176        "split_part" => {
1177            if args.len() < 3 {
1178                return Value::Null;
1179            }
1180            match (args[0].as_str(), args[1].as_str(), args[2].as_i64()) {
1181                (Some(s), Some(delim), Some(n)) if n > 0 => s
1182                    .split(delim)
1183                    .nth((n - 1) as usize)
1184                    .map(|p| Value::Str(p.to_string()))
1185                    .unwrap_or(Value::Null),
1186                _ => Value::Null,
1187            }
1188        }
1189
1190        "abs" => num_fn(args, |n| n.abs(), |n| n.abs()),
1191        "round" => {
1192            let n = match args.first().and_then(|v| v.as_f64()) {
1193                Some(n) => n,
1194                None => return Value::Null,
1195            };
1196            let scale = args.get(1).and_then(|v| v.as_i64()).unwrap_or(0);
1197            let factor = 10f64.powi(scale as i32);
1198            let result = (n * factor).round() / factor;
1199            if scale <= 0 {
1200                Value::Int(result as i64)
1201            } else {
1202                Value::Float(result)
1203            }
1204        }
1205        "ceil" | "ceiling" => float_fn(args, |n| n.ceil()),
1206        "floor" => float_fn(args, |n| n.floor()),
1207        "trunc" | "truncate" => {
1208            let n = match args.first().and_then(|v| v.as_f64()) {
1209                Some(n) => n,
1210                None => return Value::Null,
1211            };
1212            let scale = args.get(1).and_then(|v| v.as_i64()).unwrap_or(0);
1213            let factor = 10f64.powi(scale as i32);
1214            let result = (n * factor).trunc() / factor;
1215            if scale <= 0 {
1216                Value::Int(result as i64)
1217            } else {
1218                Value::Float(result)
1219            }
1220        }
1221        "mod" => {
1222            if args.len() < 2 {
1223                return Value::Null;
1224            }
1225            match (&args[0], &args[1]) {
1226                (Value::Int(a), Value::Int(b)) if *b != 0 => Value::Int(a % b),
1227                _ => match (args[0].as_f64(), args[1].as_f64()) {
1228                    (Some(a), Some(b)) if b != 0.0 => Value::Float(a % b),
1229                    _ => Value::Null,
1230                },
1231            }
1232        }
1233        "power" | "pow" => {
1234            if args.len() < 2 {
1235                return Value::Null;
1236            }
1237            match (args[0].as_f64(), args[1].as_f64()) {
1238                (Some(a), Some(b)) => Value::Float(a.powf(b)),
1239                _ => Value::Null,
1240            }
1241        }
1242        "sqrt" => float_fn(args, |n| n.sqrt()),
1243        "sign" => float_fn(args, |n| {
1244            if n > 0.0 {
1245                1.0
1246            } else if n < 0.0 {
1247                -1.0
1248            } else {
1249                0.0
1250            }
1251        }),
1252        "exp" => float_fn(args, |n| n.exp()),
1253        "ln" => float_fn(args, |n| n.ln()),
1254        "log10" => float_fn(args, |n| n.log10()),
1255        "log2" => float_fn(args, |n| n.log2()),
1256        "log" => {
1257            let n = match args.first().and_then(|v| v.as_f64()) {
1258                Some(n) => n,
1259                None => return Value::Null,
1260            };
1261            match args.get(1).and_then(|v| v.as_f64()) {
1262                Some(base) => Value::Float(n.log(base)),
1263                None => Value::Float(n.log10()),
1264            }
1265        }
1266        "pi" => Value::Float(std::f64::consts::PI),
1267        "greatest" => args
1268            .iter()
1269            .filter(|v| !matches!(v, Value::Null))
1270            .cloned()
1271            .max_by(|a, b| a.cmp_val(b).unwrap_or(std::cmp::Ordering::Equal))
1272            .unwrap_or(Value::Null),
1273        "least" => args
1274            .iter()
1275            .filter(|v| !matches!(v, Value::Null))
1276            .cloned()
1277            .min_by(|a, b| a.cmp_val(b).unwrap_or(std::cmp::Ordering::Equal))
1278            .unwrap_or(Value::Null),
1279
1280        "coalesce" | "ifnull" => args
1281            .iter()
1282            .find(|v| !matches!(v, Value::Null))
1283            .cloned()
1284            .unwrap_or(Value::Null),
1285        "nullif" => {
1286            if args.len() < 2 {
1287                return Value::Null;
1288            }
1289            if args[0] == args[1] {
1290                Value::Null
1291            } else {
1292                args[0].clone()
1293            }
1294        }
1295
1296        "typeof" => Value::Str(
1297            match args.first() {
1298                Some(Value::Str(_)) => "text",
1299                Some(Value::Int(_)) => "integer",
1300                Some(Value::Float(_)) => "float",
1301                Some(Value::Bool(_)) => "boolean",
1302                Some(Value::Null) | None => "null",
1303            }
1304            .to_string(),
1305        ),
1306        "now" | "current_timestamp" => Value::Str(current_datetime_utc(true, true)),
1307        "current_date" => Value::Str(current_datetime_utc(true, false)),
1308        "current_time" => Value::Str(current_datetime_utc(false, true)),
1309        _ => Value::Null,
1310    }
1311}
1312
1313fn str_fn(args: &[Value], f: impl Fn(&str) -> String) -> Value {
1314    args.first()
1315        .and_then(|v| v.as_str())
1316        .map(|s| Value::Str(f(s)))
1317        .unwrap_or(Value::Null)
1318}
1319
1320fn str_int_fn(args: &[Value], f: impl Fn(&[char], i64) -> String) -> Value {
1321    if args.len() < 2 {
1322        return Value::Null;
1323    }
1324    match (args[0].as_str(), args[1].as_i64()) {
1325        (Some(s), Some(n)) => {
1326            let chars: Vec<char> = s.chars().collect();
1327            Value::Str(f(&chars, n))
1328        }
1329        _ => Value::Null,
1330    }
1331}
1332
1333fn num_fn(args: &[Value], int_f: impl Fn(i64) -> i64, flt_f: impl Fn(f64) -> f64) -> Value {
1334    match args.first() {
1335        Some(Value::Int(n)) => Value::Int(int_f(*n)),
1336        Some(v) => v
1337            .as_f64()
1338            .map(|n| Value::Float(flt_f(n)))
1339            .unwrap_or(Value::Null),
1340        None => Value::Null,
1341    }
1342}
1343
1344fn float_fn(args: &[Value], f: impl Fn(f64) -> f64) -> Value {
1345    args.first()
1346        .and_then(|v| v.as_f64())
1347        .map(|n| Value::Float(f(n)))
1348        .unwrap_or(Value::Null)
1349}
1350
1351/// Builds the set of characters TRIM/LTRIM/RTRIM should strip, defaulting to
1352/// whitespace when no explicit character argument is given.
1353fn trim_char_set(args: &[Value], chars_idx: usize) -> Vec<char> {
1354    args.get(chars_idx)
1355        .and_then(|v| v.as_str())
1356        .map(|s| s.chars().collect())
1357        .unwrap_or_else(|| vec![' ', '\t', '\n', '\r'])
1358}
1359
1360fn pad_fn(args: &[Value], left: bool) -> Value {
1361    if args.len() < 2 {
1362        return Value::Null;
1363    }
1364    let s = match args[0].as_str() {
1365        Some(s) => s,
1366        None => return Value::Null,
1367    };
1368    let target_len = match args[1].as_i64() {
1369        Some(n) => n.max(0) as usize,
1370        None => return Value::Null,
1371    };
1372    let pad_str = args.get(2).and_then(|v| v.as_str()).unwrap_or(" ");
1373    let mut chars: Vec<char> = s.chars().collect();
1374    if chars.len() >= target_len {
1375        chars.truncate(target_len);
1376        return Value::Str(chars.into_iter().collect());
1377    }
1378    if pad_str.is_empty() {
1379        return Value::Str(s.to_string());
1380    }
1381    let pad_chars: Vec<char> = pad_str.chars().collect();
1382    let needed = target_len - chars.len();
1383    let padding: Vec<char> = pad_chars.iter().cycle().take(needed).copied().collect();
1384    if left {
1385        Value::Str(padding.into_iter().chain(chars).collect())
1386    } else {
1387        chars.extend(padding);
1388        Value::Str(chars.into_iter().collect())
1389    }
1390}
1391
1392/// Returns the current UTC time formatted for `now()`/`current_timestamp`/
1393/// `current_date`/`current_time`. No date columns exist in the schema, so
1394/// this only needs to support clock-style scalar lookups, not arithmetic.
1395fn current_datetime_utc(with_date: bool, with_time: bool) -> String {
1396    let secs = std::time::SystemTime::now()
1397        .duration_since(std::time::UNIX_EPOCH)
1398        .map(|d| d.as_secs())
1399        .unwrap_or(0);
1400    let days = (secs / 86400) as i64;
1401    let time_of_day = secs % 86400;
1402    let (y, m, d) = civil_from_days(days);
1403    let (h, mi, s) = (
1404        time_of_day / 3600,
1405        (time_of_day / 60) % 60,
1406        time_of_day % 60,
1407    );
1408    match (with_date, with_time) {
1409        (true, true) => format!("{y:04}-{m:02}-{d:02} {h:02}:{mi:02}:{s:02}"),
1410        (true, false) => format!("{y:04}-{m:02}-{d:02}"),
1411        _ => format!("{h:02}:{mi:02}:{s:02}"),
1412    }
1413}
1414
1415/// Howard Hinnant's `civil_from_days` algorithm: converts a day count
1416/// since the Unix epoch (1970-01-01) into a proleptic-Gregorian (year, month, day).
1417fn civil_from_days(z: i64) -> (i64, u32, u32) {
1418    let z = z + 719468;
1419    let era = if z >= 0 { z } else { z - 146096 } / 146097;
1420    let doe = (z - era * 146097) as u64;
1421    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1422    let y = yoe as i64 + era * 400;
1423    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1424    let mp = (5 * doy + 2) / 153;
1425    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
1426    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
1427    let y = if m <= 2 { y + 1 } else { y };
1428    (y, m, d)
1429}
1430
1431fn eval_mq_scalar(program: &str, content: &str) -> Value {
1432    let mut engine = DefaultEngine::default();
1433    engine.load_builtin_module();
1434    let input = match parse_markdown_input(content) {
1435        Ok(i) => i,
1436        Err(_) => return Value::Null,
1437    };
1438    match engine.eval(program, input.into_iter()) {
1439        Ok(output) => {
1440            let parts: Vec<String> = output
1441                .compact()
1442                .into_iter()
1443                .map(|v| v.to_string())
1444                .collect();
1445            if parts.is_empty() {
1446                Value::Null
1447            } else {
1448                Value::Str(parts.join("\n"))
1449            }
1450        }
1451        Err(_) => Value::Null,
1452    }
1453}
1454
1455fn extract_json_key(json: &str, key: &str) -> Value {
1456    let s = json.trim();
1457    if !s.starts_with('{') {
1458        return Value::Null;
1459    }
1460    let target = format!("\"{}\":", key);
1461    if let Some(pos) = s.find(&target) {
1462        let after = s[pos + target.len()..].trim_start();
1463        if let Some(inner) = after.strip_prefix('"') {
1464            if let Some(end) = inner.find('"') {
1465                return Value::Str(inner[..end].to_string());
1466            }
1467        } else if let Some(end) = after.find([',', '}']) {
1468            let raw = after[..end].trim();
1469            if let Ok(n) = raw.parse::<i64>() {
1470                return Value::Int(n);
1471            }
1472            if let Ok(f) = raw.parse::<f64>() {
1473                return Value::Float(f);
1474            }
1475            if raw == "true" {
1476                return Value::Bool(true);
1477            }
1478            if raw == "false" {
1479                return Value::Bool(false);
1480            }
1481            if raw == "null" {
1482                return Value::Null;
1483            }
1484        }
1485    }
1486    Value::Null
1487}
1488
1489// LIKE pattern matching (% = .*, _ = any char)
1490fn like_match_str(s: &str, pattern: &str) -> bool {
1491    let s: Vec<char> = s.to_lowercase().chars().collect();
1492    let p: Vec<char> = pattern.to_lowercase().chars().collect();
1493    like_dp(&s, &p, 0, 0)
1494}
1495
1496fn like_dp(s: &[char], p: &[char], si: usize, pi: usize) -> bool {
1497    if pi == p.len() {
1498        return si == s.len();
1499    }
1500    if p[pi] == '%' {
1501        // skip consecutive %
1502        let mut npi = pi + 1;
1503        while npi < p.len() && p[npi] == '%' {
1504            npi += 1;
1505        }
1506        for k in si..=s.len() {
1507            if like_dp(s, p, k, npi) {
1508                return true;
1509            }
1510        }
1511        return false;
1512    }
1513    if si >= s.len() {
1514        return false;
1515    }
1516    let matches = p[pi] == '_' || p[pi] == s[si];
1517    matches && like_dp(s, p, si + 1, pi + 1)
1518}
1519
1520/// Custom SQL execution engine backed by a [`DocumentStore`] reference.
1521///
1522/// Secondary indexes are built once on construction (O(n) in total block count)
1523/// and reused for every query. Commands that do not create a `SqlEngine`
1524/// (mq, list, show, stats …) pay no index-construction cost.
1525pub struct SqlEngine<'a> {
1526    store: &'a DocumentStore,
1527    /// One `DocumentIndex` per document, in the same order as `store.documents()`.
1528    indexes: Vec<DocumentIndex>,
1529    /// Stack of CTE scopes from `WITH` clauses, one frame per nested
1530    /// `exec_query` call. Looked up innermost-first so a nested subquery's
1531    /// own `WITH` shadows an outer CTE of the same name.
1532    cte_scopes: std::cell::RefCell<Vec<FxHashMap<String, std::rc::Rc<QueryOutput>>>>,
1533    view_stack: std::cell::RefCell<Vec<String>>,
1534}
1535
1536impl<'a> SqlEngine<'a> {
1537    /// Build the engine and its secondary indexes.
1538    ///
1539    /// Uses cached indexes from [`DocumentStore::load_all_indexes`] when
1540    /// available (O(1) per document); otherwise rebuilds from blocks (O(n)).
1541    pub fn new(store: &'a DocumentStore) -> Result<Self, MqdbError> {
1542        let indexes = store
1543            .documents()
1544            .iter()
1545            .enumerate()
1546            .map(|(i, doc)| {
1547                if let Some(idx) = store.get_doc_index(i) {
1548                    idx.clone()
1549                } else {
1550                    DocumentIndex::build(&doc.blocks)
1551                }
1552            })
1553            .collect();
1554        Ok(Self {
1555            store,
1556            indexes,
1557            cte_scopes: std::cell::RefCell::new(Vec::new()),
1558            view_stack: std::cell::RefCell::new(Vec::new()),
1559        })
1560    }
1561
1562    fn documents_with_indexes(&self) -> impl Iterator<Item = (&Document, &DocumentIndex)> {
1563        self.store.documents().iter().zip(self.indexes.iter())
1564    }
1565
1566    /// Sum of `hint`'s predicted matching-block count across every document,
1567    /// read directly from each document's already-built secondary index —
1568    /// no scanning. Shared by [`Self::choose_best_hint`] and `EXPLAIN`'s plan
1569    /// describer, so both report the same numbers.
1570    fn estimate_hint_cost(&self, hint: &IndexHint) -> u64 {
1571        self.documents_with_indexes()
1572            .map(|(doc, idx)| {
1573                hint.resolve(idx)
1574                    .map(|v| v.len() as u64)
1575                    .unwrap_or(doc.blocks.len() as u64)
1576            })
1577            .sum()
1578    }
1579
1580    /// Cheapest of several [`IndexHint`] candidates for the same WHERE
1581    /// clause, by [`Self::estimate_hint_cost`] (ties keep the first
1582    /// candidate, for deterministic output). `FullScan` if there are none.
1583    fn choose_best_hint(&self, candidates: Vec<IndexHint>) -> IndexHint {
1584        candidates
1585            .into_iter()
1586            .min_by_key(|h| self.estimate_hint_cost(h))
1587            .unwrap_or(IndexHint::FullScan)
1588    }
1589
1590    /// Execute a SQL statement against the store.
1591    ///
1592    /// Supports `SELECT`, `CREATE TABLE`, `INSERT INTO`, `DROP TABLE`,
1593    /// `DESC`/`DESCRIBE`, and `SHOW TABLES`.
1594    pub fn execute(&self, sql: &str) -> Result<QueryOutput, MqdbError> {
1595        // Pre-process non-standard commands (DESC / SHOW TABLES).
1596        let trimmed = sql.trim().trim_end_matches(';');
1597        let upper = trimmed.to_ascii_uppercase();
1598        if upper.starts_with("DESC ") || upper.starts_with("DESCRIBE ") {
1599            let name = trimmed
1600                .split_whitespace()
1601                .nth(1)
1602                .unwrap_or("")
1603                .to_lowercase();
1604            return self.exec_desc(&name);
1605        }
1606        if upper == "SHOW TABLES" {
1607            return self.exec_show_tables();
1608        }
1609        if upper.starts_with("DETACH") {
1610            return self.exec_detach(trimmed);
1611        }
1612
1613        let stmts = Parser::parse_sql(&GenericDialect {}, sql)
1614            .map_err(|e| MqdbError::SqlParse(e.to_string()))?;
1615        let stmt = stmts
1616            .into_iter()
1617            .next()
1618            .ok_or_else(|| MqdbError::SqlParse("empty query".into()))?;
1619        match stmt {
1620            Statement::Query(q) => self.exec_query(&q),
1621            Statement::CreateTable(ct) => self.exec_create_table(&ct),
1622            Statement::Insert(ins) => self.exec_insert(&ins),
1623            Statement::Drop {
1624                object_type: ObjectType::Table,
1625                names,
1626                if_exists,
1627                ..
1628            } => self.exec_drop_tables(&names, if_exists),
1629            Statement::Drop {
1630                object_type: ObjectType::View,
1631                names,
1632                if_exists,
1633                ..
1634            } => self.exec_drop_views(&names, if_exists),
1635            Statement::CreateView(cv) => self.exec_create_view(&cv),
1636            Statement::Explain {
1637                analyze, statement, ..
1638            } => self.exec_explain(analyze, &statement),
1639            Statement::Vacuum(_) => Err(MqdbError::SqlExec(
1640                "VACUUM is a CLI command, not a SQL statement here — run `mq-db vacuum --db <path>`".into(),
1641            )),
1642            Statement::AttachDatabase {
1643                schema_name,
1644                database_file_name,
1645                ..
1646            } => {
1647                let path = expr_str_val(&database_file_name).ok_or_else(|| {
1648                    MqdbError::SqlExec(
1649                        "ATTACH DATABASE: file path must be a string literal".into(),
1650                    )
1651                })?;
1652                let alias = DatabaseAlias::parse(&schema_name.value)?;
1653                self.store.attach(alias, std::path::Path::new(&path))?;
1654                Ok(ok_result())
1655            }
1656            _ => Err(MqdbError::SqlExec(
1657                "unsupported statement; supported: SELECT, CREATE TABLE, INSERT INTO, DROP TABLE, CREATE VIEW, DROP VIEW, ATTACH DATABASE, DETACH, DESC, SHOW TABLES, EXPLAIN".into(),
1658            )),
1659        }
1660    }
1661
1662    /// `DETACH [DATABASE] <alias>` — not parsed generically by `sqlparser`
1663    /// under `GenericDialect`, so handled here like `DESC`/`SHOW TABLES`.
1664    fn exec_detach(&self, trimmed: &str) -> Result<QueryOutput, MqdbError> {
1665        let mut tokens = trimmed.split_whitespace().skip(1); // skip "DETACH"
1666        let mut tok = tokens.next();
1667        if let Some(t) = tok
1668            && t.eq_ignore_ascii_case("DATABASE")
1669        {
1670            tok = tokens.next();
1671        }
1672        let alias = tok.ok_or_else(|| {
1673            MqdbError::SqlParse("malformed DETACH statement: expected a database alias".into())
1674        })?;
1675        if tokens.next().is_some() {
1676            return Err(MqdbError::SqlParse(
1677                "malformed DETACH statement: unexpected trailing tokens".into(),
1678            ));
1679        }
1680        if !self.store.detach(alias) {
1681            return Err(MqdbError::SqlExec(format!(
1682                "database alias '{alias}' is not attached"
1683            )));
1684        }
1685        Ok(ok_result())
1686    }
1687
1688    fn exec_explain(&self, analyze: bool, inner: &Statement) -> Result<QueryOutput, MqdbError> {
1689        let Statement::Query(query) = inner else {
1690            return Err(MqdbError::SqlExec(
1691                "EXPLAIN only supports SELECT queries".into(),
1692            ));
1693        };
1694
1695        let mut rows: Vec<(String, String)> = Vec::new();
1696        self.describe_query(query, "query", &mut Vec::new(), &mut rows);
1697
1698        if analyze {
1699            let start = std::time::Instant::now();
1700            let out = self.exec_query(query)?;
1701            let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
1702            rows.push(("actual:elapsed".to_string(), format!("{elapsed_ms:.3}ms")));
1703            rows.push((
1704                "actual:rows".to_string(),
1705                format!("{} row(s) returned", out.rows.len()),
1706            ));
1707            self.explain_analyze_scan_stats(query, &mut rows);
1708        }
1709
1710        Ok(QueryOutput {
1711            columns: vec!["step".to_string(), "detail".to_string()],
1712            rows: rows.into_iter().map(|(s, d)| vec![s, d]).collect(),
1713        })
1714    }
1715
1716    fn describe_query(
1717        &self,
1718        query: &Query,
1719        label: &str,
1720        cte_names: &mut Vec<String>,
1721        out: &mut Vec<(String, String)>,
1722    ) {
1723        let mut local_ctes = 0;
1724        if let Some(with) = &query.with {
1725            for cte in &with.cte_tables {
1726                let name = cte.alias.name.value.to_lowercase();
1727                self.describe_query(&cte.query, &format!("cte:{name}"), cte_names, out);
1728                cte_names.push(name);
1729                local_ctes += 1;
1730            }
1731        }
1732        match query.body.as_ref() {
1733            SetExpr::Select(select) => {
1734                let limit = limit_expr_of(query);
1735                self.describe_select(
1736                    select,
1737                    &query.order_by,
1738                    limit.as_ref(),
1739                    label,
1740                    cte_names,
1741                    out,
1742                );
1743            }
1744            _ => {
1745                out.push((
1746                    label.to_string(),
1747                    "non-SELECT set expression (VALUES/UNION/...) — no index plan".to_string(),
1748                ));
1749            }
1750        }
1751        cte_names.truncate(cte_names.len() - local_ctes);
1752    }
1753
1754    fn describe_select(
1755        &self,
1756        select: &Select,
1757        order_by: &Option<sqlparser::ast::OrderBy>,
1758        limit: Option<&Expr>,
1759        label: &str,
1760        cte_names: &[String],
1761        out: &mut Vec<(String, String)>,
1762    ) {
1763        if select.from.is_empty() {
1764            out.push((
1765                format!("{label}:from"),
1766                "no FROM clause (constant SELECT)".to_string(),
1767            ));
1768        } else {
1769            let twj = &select.from[0];
1770            match table_factor_ident(&twj.relation) {
1771                Some(table_name) => {
1772                    let is_cte = cte_names.contains(&table_name);
1773                    let kind = if is_cte {
1774                        "cte"
1775                    } else {
1776                        match table_name.as_str() {
1777                            "blocks" => "blocks",
1778                            "documents" => "documents",
1779                            other
1780                                if self.store.custom_tables.read().unwrap().contains_key(other) =>
1781                            {
1782                                "custom table"
1783                            }
1784                            _ => "unknown",
1785                        }
1786                    };
1787                    out.push((format!("{label}:from"), format!("{table_name} ({kind})")));
1788
1789                    let single_unjoined_from = select.from.len() == 1 && twj.joins.is_empty();
1790                    let is_plain_blocks = kind == "blocks";
1791
1792                    match select.selection.as_ref() {
1793                        Some(we) if is_plain_blocks => {
1794                            let candidates = candidate_hints_for_where(we);
1795                            let hint = self.choose_best_hint(candidates.clone());
1796                            if candidates.len() <= 1 {
1797                                out.push((
1798                                    format!("{label}:where"),
1799                                    format!("{} used", describe_hint(&hint)),
1800                                ));
1801                            } else {
1802                                let mut costed: Vec<(u64, &IndexHint)> = candidates
1803                                    .iter()
1804                                    .map(|h| (self.estimate_hint_cost(h), h))
1805                                    .collect();
1806                                costed.sort_by_key(|(cost, _)| *cost);
1807                                let others: Vec<String> = costed
1808                                    .iter()
1809                                    .filter(|(_, h)| **h != hint)
1810                                    .map(|(cost, h)| format!("{} [est. {cost}]", describe_hint(h)))
1811                                    .collect();
1812                                out.push((
1813                                    format!("{label}:where"),
1814                                    format!(
1815                                        "{} used (est. {} row(s); also considered: {})",
1816                                        describe_hint(&hint),
1817                                        self.estimate_hint_cost(&hint),
1818                                        others.join(", ")
1819                                    ),
1820                                ));
1821                            }
1822
1823                            if single_unjoined_from {
1824                                let fields = zone_map_candidate_fields(we);
1825                                if fields.is_empty() {
1826                                    out.push((
1827                                        format!("{label}:zone-map"),
1828                                        "not eligible (no lang=/depth=/heading-content= conjunct)"
1829                                            .to_string(),
1830                                    ));
1831                                } else {
1832                                    out.push((
1833                                        format!("{label}:zone-map"),
1834                                        format!("eligible via {}", fields.join(", ")),
1835                                    ));
1836                                }
1837                            } else {
1838                                out.push((
1839                                    format!("{label}:zone-map"),
1840                                    "disabled (JOIN or multiple FROM tables)".to_string(),
1841                                ));
1842                            }
1843
1844                            let where_fully_indexed = matches!(hint, IndexHint::TermMatch(_))
1845                                && single_unjoined_from
1846                                && matches!(unwrap_nested(we), Expr::Function(_));
1847                            out.push((
1848                                format!("{label}:where-recheck"),
1849                                if where_fully_indexed {
1850                                    "skipped (fully covered by TermIndex match())".to_string()
1851                                } else {
1852                                    "row-by-row (full predicate re-evaluated after scan)"
1853                                        .to_string()
1854                                },
1855                            ));
1856                        }
1857                        Some(_) => {
1858                            out.push((
1859                                format!("{label}:where"),
1860                                "row-by-row (no secondary index for this table)".to_string(),
1861                            ));
1862                        }
1863                        None => {
1864                            out.push((format!("{label}:where"), "none — full scan".to_string()));
1865                        }
1866                    }
1867                }
1868                None => {
1869                    out.push((
1870                        format!("{label}:from"),
1871                        "unsupported FROM clause (subquery/derived table)".to_string(),
1872                    ));
1873                }
1874            }
1875
1876            for (i, join) in twj.joins.iter().enumerate() {
1877                let jname = table_factor_ident(&join.relation).unwrap_or_else(|| "?".to_string());
1878                let strategy = match &join.join_operator {
1879                    JoinOperator::Inner(JoinConstraint::On(on))
1880                    | JoinOperator::Join(JoinConstraint::On(on))
1881                    | JoinOperator::Left(JoinConstraint::On(on))
1882                    | JoinOperator::LeftOuter(JoinConstraint::On(on)) => describe_join_strategy(on),
1883                    _ => "cross join (no ON, or unsupported join type)".to_string(),
1884                };
1885                out.push((
1886                    format!("{label}:join[{i}]"),
1887                    format!("{jname}: {strategy} — join partner always full-scanned"),
1888                ));
1889            }
1890
1891            for twj_extra in select.from.iter().skip(1) {
1892                let n = table_factor_ident(&twj_extra.relation).unwrap_or_else(|| "?".to_string());
1893                out.push((
1894                    format!("{label}:from+"),
1895                    format!("{n}: cross join (comma-separated FROM)"),
1896                ));
1897            }
1898        }
1899
1900        let group_by_exprs: Vec<Expr> = match &select.group_by {
1901            GroupByExpr::Expressions(exprs, _) => exprs.clone(),
1902            _ => vec![],
1903        };
1904        if !group_by_exprs.is_empty() {
1905            out.push((
1906                format!("{label}:group-by"),
1907                format!("{} key(s)", group_by_exprs.len()),
1908            ));
1909        }
1910
1911        if let Some(ob) = order_by
1912            && let OrderByKind::Expressions(exprs) = &ob.kind
1913        {
1914            let desc = exprs
1915                .iter()
1916                .map(|e| {
1917                    format!(
1918                        "{} {}",
1919                        e.expr,
1920                        if e.options.asc == Some(false) {
1921                            "DESC"
1922                        } else {
1923                            "ASC"
1924                        }
1925                    )
1926                })
1927                .collect::<Vec<_>>()
1928                .join(", ");
1929            out.push((format!("{label}:order-by"), desc));
1930        }
1931
1932        if let Some(lim) = limit {
1933            out.push((format!("{label}:limit"), format!("{lim}")));
1934        }
1935    }
1936
1937    fn explain_analyze_scan_stats(&self, query: &Query, out: &mut Vec<(String, String)>) {
1938        let SetExpr::Select(select) = query.body.as_ref() else {
1939            return;
1940        };
1941        if select.from.len() != 1 || !select.from[0].joins.is_empty() {
1942            return;
1943        }
1944        let Some(table_name) = table_factor_ident(&select.from[0].relation) else {
1945            return;
1946        };
1947        let shadowed_by_cte = query.with.as_ref().is_some_and(|w| {
1948            w.cte_tables
1949                .iter()
1950                .any(|c| c.alias.name.value.eq_ignore_ascii_case("blocks"))
1951        });
1952        if table_name != "blocks" || shadowed_by_cte {
1953            return;
1954        }
1955
1956        let where_expr = select.selection.as_ref();
1957        let hint = where_expr
1958            .map(|we| self.choose_best_hint(candidate_hints_for_where(we)))
1959            .unwrap_or(IndexHint::FullScan);
1960
1961        let (mut docs_total, mut docs_skipped, mut candidate_rows, mut total_rows) =
1962            (0u32, 0u32, 0u32, 0u32);
1963        for (doc, doc_idx) in self.documents_with_indexes() {
1964            docs_total += 1;
1965            total_rows += doc.blocks.len() as u32;
1966            if let Some(we) = where_expr
1967                && zone_map_skip(&doc.zone_maps, we)
1968            {
1969                docs_skipped += 1;
1970                continue;
1971            }
1972            candidate_rows += hint
1973                .resolve(doc_idx)
1974                .map(|v| v.len() as u32)
1975                .unwrap_or(doc.blocks.len() as u32);
1976        }
1977        out.push((
1978            "actual".to_string(),
1979            format!("{docs_skipped}/{docs_total} document(s) skipped by zone map"),
1980        ));
1981        out.push((
1982            "actual".to_string(),
1983            format!(
1984                "{candidate_rows} candidate row(s) from index/scan (of {total_rows} total blocks)"
1985            ),
1986        ));
1987    }
1988
1989    fn exec_desc(&self, table_name: &str) -> Result<QueryOutput, MqdbError> {
1990        let schema: Option<Vec<(&str, &str)>> = match table_name {
1991            "blocks" => Some(vec![
1992                ("id", "integer"),
1993                ("document_id", "integer"),
1994                ("block_type", "text"),
1995                ("content", "text"),
1996                ("pre", "integer"),
1997                ("post", "integer"),
1998                ("depth", "integer"),
1999                ("lang", "text"),
2000                ("properties", "text"),
2001            ]),
2002            "documents" => Some(vec![
2003                ("id", "integer"),
2004                ("path", "text"),
2005                ("title", "text"),
2006                ("tags", "text"),
2007            ]),
2008            _ => None,
2009        };
2010        if let Some(rows) = schema {
2011            return Ok(QueryOutput {
2012                columns: vec!["column".to_string(), "type".to_string()],
2013                rows: rows
2014                    .iter()
2015                    .map(|(c, t)| vec![c.to_string(), t.to_string()])
2016                    .collect(),
2017            });
2018        }
2019        let guard = self.store.custom_tables.read().unwrap();
2020        if let Some(state) = guard.get(table_name) {
2021            let rows = state
2022                .columns
2023                .iter()
2024                .map(|c| vec![c.clone(), "text".to_string()])
2025                .collect();
2026            return Ok(QueryOutput {
2027                columns: vec!["column".to_string(), "type".to_string()],
2028                rows,
2029            });
2030        }
2031        drop(guard);
2032        if let Some(sql_text) = self.store.views.read().unwrap().get(table_name).cloned() {
2033            let out = self.exec_view_query(table_name, &sql_text)?;
2034            let rows = out
2035                .columns
2036                .iter()
2037                .map(|c| vec![c.clone(), "text".to_string()])
2038                .collect();
2039            return Ok(QueryOutput {
2040                columns: vec!["column".to_string(), "type".to_string()],
2041                rows,
2042            });
2043        }
2044        Err(MqdbError::SqlExec(format!("unknown table: {table_name}")))
2045    }
2046
2047    fn exec_show_tables(&self) -> Result<QueryOutput, MqdbError> {
2048        let mut rows = vec![
2049            vec!["blocks".to_string(), "built-in".to_string()],
2050            vec!["documents".to_string(), "built-in".to_string()],
2051        ];
2052        let guard = self.store.custom_tables.read().unwrap();
2053        let mut custom: Vec<String> = guard.keys().cloned().collect();
2054        drop(guard);
2055        custom.sort();
2056        rows.extend(custom.into_iter().map(|n| vec![n, "custom".to_string()]));
2057
2058        let guard = self.store.views.read().unwrap();
2059        let mut views: Vec<String> = guard.keys().cloned().collect();
2060        drop(guard);
2061        views.sort();
2062        rows.extend(views.into_iter().map(|n| vec![n, "view".to_string()]));
2063
2064        Ok(QueryOutput {
2065            columns: vec!["table".to_string(), "kind".to_string()],
2066            rows,
2067        })
2068    }
2069
2070    fn exec_create_table(&self, ct: &CreateTable) -> Result<QueryOutput, MqdbError> {
2071        let table_name = require_unqualified(&ct.name)?;
2072        if matches!(table_name.as_str(), "blocks" | "documents") {
2073            return Err(MqdbError::SqlExec(format!(
2074                "cannot override built-in table '{table_name}'"
2075            )));
2076        }
2077        if self.store.views.read().unwrap().contains_key(&table_name) {
2078            return Err(MqdbError::SqlExec(format!(
2079                "'{table_name}' is already defined as a view"
2080            )));
2081        }
2082
2083        if let Some(query) = &ct.query {
2084            // CREATE TABLE name AS SELECT ...
2085            let result = self.exec_query(query)?;
2086            let n = result.rows.len();
2087            self.store.custom_tables.write().unwrap().insert(
2088                table_name,
2089                CustomTableState {
2090                    columns: result.columns,
2091                    rows: result.rows,
2092                    first_row_page: 0,
2093                    last_row_page: 0,
2094                },
2095            );
2096            self.store.try_flush_catalog_to_storage();
2097            return Ok(QueryOutput {
2098                columns: vec!["rows".to_string()],
2099                rows: vec![vec![n.to_string()]],
2100            });
2101        }
2102
2103        // CREATE TABLE name (col1 TYPE, ...)
2104        let columns: Vec<String> = ct.columns.iter().map(|c| c.name.value.clone()).collect();
2105        if columns.is_empty() {
2106            return Err(MqdbError::SqlExec(
2107                "CREATE TABLE requires at least one column or AS SELECT".into(),
2108            ));
2109        }
2110        let already_exists = self
2111            .store
2112            .custom_tables
2113            .read()
2114            .unwrap()
2115            .contains_key(&table_name);
2116        if already_exists {
2117            if ct.if_not_exists {
2118                return Ok(QueryOutput {
2119                    columns: vec!["result".to_string()],
2120                    rows: vec![vec!["already exists".to_string()]],
2121                });
2122            }
2123            return Err(MqdbError::SqlExec(format!(
2124                "table '{table_name}' already exists"
2125            )));
2126        }
2127        self.store.custom_tables.write().unwrap().insert(
2128            table_name,
2129            CustomTableState {
2130                columns,
2131                rows: vec![],
2132                first_row_page: 0,
2133                last_row_page: 0,
2134            },
2135        );
2136        self.store.try_flush_catalog_to_storage();
2137        Ok(QueryOutput {
2138            columns: vec!["result".to_string()],
2139            rows: vec![vec!["ok".to_string()]],
2140        })
2141    }
2142
2143    fn exec_create_view(&self, cv: &CreateView) -> Result<QueryOutput, MqdbError> {
2144        let view_name = require_unqualified(&cv.name)?;
2145        if matches!(view_name.as_str(), "blocks" | "documents") {
2146            return Err(MqdbError::SqlExec(format!(
2147                "cannot override built-in table '{view_name}'"
2148            )));
2149        }
2150        if self
2151            .store
2152            .custom_tables
2153            .read()
2154            .unwrap()
2155            .contains_key(&view_name)
2156        {
2157            return Err(MqdbError::SqlExec(format!(
2158                "'{view_name}' is already defined as a table"
2159            )));
2160        }
2161        if !cv.columns.is_empty() {
2162            return Err(MqdbError::SqlExec(
2163                "explicit view columns (CREATE VIEW v (a, b) AS ...) are not supported".into(),
2164            ));
2165        }
2166
2167        let already_exists = self.store.views.read().unwrap().contains_key(&view_name);
2168        if already_exists && !cv.or_replace {
2169            if cv.if_not_exists {
2170                return Ok(QueryOutput {
2171                    columns: vec!["result".to_string()],
2172                    rows: vec![vec!["already exists".to_string()]],
2173                });
2174            }
2175            return Err(MqdbError::SqlExec(format!(
2176                "view '{view_name}' already exists"
2177            )));
2178        }
2179
2180        // Validate the query now so a bad CREATE VIEW fails immediately
2181        // rather than on first use.
2182        self.exec_query(&cv.query)?;
2183        let sql_text = cv.query.to_string();
2184        if sql_text.len() > u16::MAX as usize {
2185            return Err(MqdbError::SqlExec(
2186                "view query is too long to persist (max 65535 bytes)".into(),
2187            ));
2188        }
2189
2190        self.store
2191            .views
2192            .write()
2193            .unwrap()
2194            .insert(view_name, sql_text);
2195        self.store.try_flush_catalog_to_storage();
2196        Ok(QueryOutput {
2197            columns: vec!["result".to_string()],
2198            rows: vec![vec!["ok".to_string()]],
2199        })
2200    }
2201
2202    fn exec_drop_views(
2203        &self,
2204        names: &[ObjectName],
2205        if_exists: bool,
2206    ) -> Result<QueryOutput, MqdbError> {
2207        let dropped = {
2208            let mut guard = self.store.views.write().unwrap();
2209            let mut dropped = 0usize;
2210            for name in names {
2211                let view_name = require_unqualified(name)?;
2212                if matches!(view_name.as_str(), "blocks" | "documents") {
2213                    return Err(MqdbError::SqlExec(format!(
2214                        "cannot drop built-in table '{view_name}'"
2215                    )));
2216                }
2217                if guard.remove(&view_name).is_some() {
2218                    dropped += 1;
2219                } else if !if_exists {
2220                    return Err(MqdbError::SqlExec(format!(
2221                        "view '{view_name}' does not exist"
2222                    )));
2223                }
2224            }
2225            dropped
2226        };
2227        self.store.try_flush_catalog_to_storage();
2228        Ok(QueryOutput {
2229            columns: vec!["result".to_string()],
2230            rows: vec![vec![format!("{dropped} view(s) dropped")]],
2231        })
2232    }
2233
2234    fn exec_insert(&self, ins: &Insert) -> Result<QueryOutput, MqdbError> {
2235        let table_name = match &ins.table {
2236            TableObject::TableName(name) => require_unqualified(name)?,
2237            _ => return Err(MqdbError::SqlExec("unsupported INSERT target".into())),
2238        };
2239
2240        let source = ins
2241            .source
2242            .as_ref()
2243            .ok_or_else(|| MqdbError::SqlExec("INSERT requires VALUES or SELECT".into()))?;
2244        let values_out = self.exec_query(source)?;
2245
2246        // Determine column mapping
2247        let col_indices: Option<Vec<usize>> = if ins.columns.is_empty() {
2248            None // positional
2249        } else {
2250            let guard = self.store.custom_tables.read().unwrap();
2251            let table_cols = guard
2252                .get(&table_name)
2253                .map(|state| state.columns.clone())
2254                .ok_or_else(|| MqdbError::SqlExec(format!("unknown table: {table_name}")))?;
2255            drop(guard);
2256            let indices: Result<Vec<usize>, _> = ins
2257                .columns
2258                .iter()
2259                .map(|col_name| {
2260                    let name = col_name.0.last().map(ident_value).unwrap_or("");
2261                    table_cols
2262                        .iter()
2263                        .position(|c| c.eq_ignore_ascii_case(name))
2264                        .ok_or_else(|| MqdbError::SqlExec(format!("unknown column '{name}'")))
2265                })
2266                .collect();
2267            Some(indices?)
2268        };
2269
2270        let new_rows = {
2271            let mut guard = self.store.custom_tables.write().unwrap();
2272            let state = guard
2273                .get_mut(&table_name)
2274                .ok_or_else(|| MqdbError::SqlExec(format!("unknown table: {table_name}")))?;
2275            let ncols = state.columns.len();
2276
2277            let mut new_rows = Vec::with_capacity(values_out.rows.len());
2278            for src_row in &values_out.rows {
2279                let mut row = vec![String::new(); ncols];
2280                match &col_indices {
2281                    None => {
2282                        if src_row.len() != ncols {
2283                            return Err(MqdbError::SqlExec(format!(
2284                                "expected {ncols} columns, got {}",
2285                                src_row.len()
2286                            )));
2287                        }
2288                        row = src_row.clone();
2289                    }
2290                    Some(idx_map) => {
2291                        for (dst_idx, &src_idx) in idx_map.iter().enumerate() {
2292                            if let Some(v) = src_row.get(dst_idx) {
2293                                row[src_idx] = v.clone();
2294                            }
2295                        }
2296                    }
2297                }
2298                state.rows.push(row.clone());
2299                new_rows.push(row);
2300            }
2301            new_rows
2302        }; // write lock released before flush
2303        let inserted = new_rows.len();
2304        // Append only the new rows to the on-disk chain instead of rewriting
2305        // the whole table, so INSERT cost stays proportional to the rows
2306        // being added rather than the table's total size.
2307        self.store
2308            .try_append_table_rows_to_storage(&table_name, &new_rows);
2309        Ok(QueryOutput {
2310            columns: vec!["rows_affected".to_string()],
2311            rows: vec![vec![inserted.to_string()]],
2312        })
2313    }
2314
2315    fn exec_drop_tables(
2316        &self,
2317        names: &[ObjectName],
2318        if_exists: bool,
2319    ) -> Result<QueryOutput, MqdbError> {
2320        let dropped = {
2321            let mut guard = self.store.custom_tables.write().unwrap();
2322            let mut dropped = 0usize;
2323            for name in names {
2324                let table_name = require_unqualified(name)?;
2325                if matches!(table_name.as_str(), "blocks" | "documents") {
2326                    return Err(MqdbError::SqlExec(format!(
2327                        "cannot drop built-in table '{table_name}'"
2328                    )));
2329                }
2330                if guard.remove(&table_name).is_some() {
2331                    dropped += 1;
2332                } else if !if_exists {
2333                    return Err(MqdbError::SqlExec(format!(
2334                        "table '{table_name}' does not exist"
2335                    )));
2336                }
2337            }
2338            dropped
2339        }; // write lock released before flush
2340        self.store.try_flush_catalog_to_storage();
2341        Ok(QueryOutput {
2342            columns: vec!["result".to_string()],
2343            rows: vec![vec![format!("{dropped} table(s) dropped")]],
2344        })
2345    }
2346
2347    /// Materialises any `WITH` clause's CTEs into a new scope frame, then
2348    /// delegates to [`Self::exec_query_body`].
2349    fn exec_query(&self, query: &Query) -> Result<QueryOutput, MqdbError> {
2350        let Some(with) = &query.with else {
2351            return self.exec_query_body(query);
2352        };
2353
2354        self.cte_scopes.borrow_mut().push(FxHashMap::default());
2355        let result = (|| {
2356            for cte in &with.cte_tables {
2357                if !cte.alias.columns.is_empty() {
2358                    return Err(MqdbError::SqlExec(
2359                        "CTE column aliases (WITH x(a, b) AS ...) are not supported".into(),
2360                    ));
2361                }
2362                let name = cte.alias.name.value.to_lowercase();
2363                // `name` isn't in scope yet, so no self-reference outside
2364                // `exec_cte_body`'s own recursive-CTE handling.
2365                let out = self.exec_cte_body(&name, &cte.query, with.recursive)?;
2366                self.cte_scopes
2367                    .borrow_mut()
2368                    .last_mut()
2369                    .expect("scope frame just pushed above")
2370                    .insert(name, std::rc::Rc::new(out));
2371            }
2372            self.exec_query_body(query)
2373        })();
2374        self.cte_scopes.borrow_mut().pop();
2375        result
2376    }
2377
2378    /// Dispatches a `WITH [RECURSIVE]` CTE body: the recursive fixed-point
2379    /// driver ([`Self::exec_recursive_cte`]) if `recursive` is set and
2380    /// `query.body` has the standard `<anchor> UNION [ALL] <term
2381    /// referencing name>` shape, otherwise the plain evaluate-once path
2382    /// (also covers a `WITH RECURSIVE` block containing a CTE that doesn't
2383    /// actually self-reference).
2384    fn exec_cte_body(
2385        &self,
2386        name: &str,
2387        query: &Query,
2388        recursive: bool,
2389    ) -> Result<QueryOutput, MqdbError> {
2390        if recursive
2391            && let SetExpr::SetOperation {
2392                left,
2393                op: SetOperator::Union,
2394                set_quantifier,
2395                right,
2396            } = query.body.as_ref()
2397            && let (SetExpr::Select(anchor), SetExpr::Select(term)) =
2398                (left.as_ref(), right.as_ref())
2399            && select_references_table(term, name)
2400        {
2401            if select_references_table(anchor, name) {
2402                return Err(MqdbError::SqlExec(format!(
2403                    "recursive CTE '{name}': the anchor (first) branch must not reference '{name}' itself"
2404                )));
2405            }
2406            let all = matches!(set_quantifier, SetQuantifier::All);
2407            return self.exec_recursive_cte(name, anchor, term, all);
2408        }
2409        self.exec_query(query)
2410    }
2411
2412    /// Iterative fixed-point evaluation of a recursive CTE: run `anchor`
2413    /// once to seed the result, then repeatedly bind `name` to only the
2414    /// *previous iteration's new rows* (not the whole accumulated result —
2415    /// standard recursive-CTE semantics) and run `recursive` again, until it
2416    /// produces no new rows. `all` selects `UNION ALL` (no dedup) vs. `UNION`
2417    /// (dedup against everything produced so far, which is also what drives
2418    /// termination for a query that would otherwise cycle).
2419    fn exec_recursive_cte(
2420        &self,
2421        name: &str,
2422        anchor: &Select,
2423        recursive: &Select,
2424        all: bool,
2425    ) -> Result<QueryOutput, MqdbError> {
2426        let anchor_out = self.exec_select(anchor, &None, None)?;
2427        let columns = anchor_out.columns.clone();
2428        let mut result_rows = anchor_out.rows.clone();
2429        let mut working = anchor_out.rows;
2430        let mut seen: std::collections::HashSet<Vec<String>> = if all {
2431            std::collections::HashSet::new()
2432        } else {
2433            result_rows.iter().cloned().collect()
2434        };
2435
2436        let mut iterations = 0usize;
2437        while !working.is_empty() {
2438            iterations += 1;
2439            if iterations > MAX_RECURSIVE_CTE_ITERATIONS {
2440                return Err(MqdbError::SqlExec(format!(
2441                    "recursive CTE '{name}' exceeded {MAX_RECURSIVE_CTE_ITERATIONS} iterations \
2442                     — check that the recursive branch's WHERE clause terminates"
2443                )));
2444            }
2445
2446            self.cte_scopes.borrow_mut().push(
2447                [(
2448                    name.to_string(),
2449                    std::rc::Rc::new(QueryOutput {
2450                        columns: columns.clone(),
2451                        rows: working.clone(),
2452                    }),
2453                )]
2454                .into_iter()
2455                .collect(),
2456            );
2457            let step = self.exec_select(recursive, &None, None);
2458            self.cte_scopes.borrow_mut().pop();
2459            let step_out = step?;
2460
2461            if step_out.columns.len() != columns.len() {
2462                return Err(MqdbError::SqlExec(format!(
2463                    "recursive CTE '{name}': anchor and recursive branch select \
2464                     different numbers of columns"
2465                )));
2466            }
2467
2468            working = step_out
2469                .rows
2470                .into_iter()
2471                .filter(|row| all || seen.insert(row.clone()))
2472                .collect();
2473            result_rows.extend(working.iter().cloned());
2474        }
2475
2476        Ok(QueryOutput {
2477            columns,
2478            rows: result_rows,
2479        })
2480    }
2481
2482    fn exec_query_body(&self, query: &Query) -> Result<QueryOutput, MqdbError> {
2483        let select = match query.body.as_ref() {
2484            SetExpr::Select(s) => s,
2485            SetExpr::Values(Values { rows, .. }) => {
2486                let empty = Row {
2487                    columns: vec![],
2488                    values: vec![],
2489                };
2490                let out: Vec<Vec<String>> = rows
2491                    .iter()
2492                    .map(|row| row.iter().map(|e| eval_expr(e, &empty).display()).collect())
2493                    .collect();
2494                return Ok(QueryOutput {
2495                    columns: vec![],
2496                    rows: out,
2497                });
2498            }
2499            _ => return Err(MqdbError::SqlExec("unsupported query type".into())),
2500        };
2501        let limit_expr = limit_expr_of(query);
2502        self.exec_select(select, &query.order_by, limit_expr.as_ref())
2503    }
2504
2505    fn exec_select(
2506        &self,
2507        select: &Select,
2508        order_by: &Option<sqlparser::ast::OrderBy>,
2509        limit: Option<&Expr>,
2510    ) -> Result<QueryOutput, MqdbError> {
2511        // 1. Materialise FROM — with cost-based index predicate pushdown
2512        let where_expr = select.selection.as_ref();
2513        let hint = where_expr
2514            .map(|we| self.choose_best_hint(candidate_hints_for_where(we)))
2515            .unwrap_or(IndexHint::FullScan);
2516        // Unlike `hint`, a skip has no later row-by-row recheck, so only
2517        // allow it for a single un-joined FROM table (no alias ambiguity).
2518        let single_unjoined_from = select.from.len() == 1 && select.from[0].joins.is_empty();
2519        let zone_filter = where_expr.filter(|_| single_unjoined_from);
2520        let mut rows = self.materialise_from_with_hint(&select.from, &hint, zone_filter)?;
2521
2522        // 2. WHERE (full predicate evaluation; index only pre-filtered)
2523        //
2524        // Exception: `WHERE match(content, 'q')` with nothing else ANDed in,
2525        // against a plain (un-shadowed, un-joined) `blocks` table, is already
2526        // an *exact* result — `TermIndex::intersect` and `match()` share the
2527        // same `tokenize()` (see its doc comment), so there's no false
2528        // positive to filter out. Re-tokenizing every matched block's
2529        // content here would be pure waste, and for a common query term that
2530        // can mean re-scanning most of the table.
2531        let where_fully_indexed = matches!(hint, IndexHint::TermMatch(_))
2532            && single_unjoined_from
2533            && matches!(where_expr.map(unwrap_nested), Some(Expr::Function(_)))
2534            && from_names_unshadowed_blocks(&select.from[0], &self.cte_scopes.borrow());
2535        if !where_fully_indexed && let Some(where_expr) = &select.selection {
2536            let resolved = self.resolve_subqueries(where_expr)?;
2537            rows.retain(|row| eval_expr(&resolved, row).is_truthy());
2538        }
2539
2540        // 3. PROJECT / GROUP / ORDER / LIMIT
2541        self.project_and_aggregate(select, rows, order_by, limit)
2542    }
2543
2544    fn resolve_subqueries(&self, expr: &Expr) -> Result<Expr, MqdbError> {
2545        match expr {
2546            Expr::BinaryOp { left, op, right } => Ok(Expr::BinaryOp {
2547                left: Box::new(self.resolve_subqueries(left)?),
2548                op: op.clone(),
2549                right: Box::new(self.resolve_subqueries(right)?),
2550            }),
2551            Expr::Subquery(q) => {
2552                let out = self.exec_query(q)?;
2553                let val = out
2554                    .rows
2555                    .first()
2556                    .and_then(|r| r.first())
2557                    .map(|s| {
2558                        if let Ok(n) = s.parse::<i64>() {
2559                            Expr::Value(SqlValue::Number(n.to_string(), false).with_empty_span())
2560                        } else {
2561                            Expr::Value(SqlValue::SingleQuotedString(s.clone()).with_empty_span())
2562                        }
2563                    })
2564                    .unwrap_or(Expr::Value(SqlValue::Null.with_empty_span()));
2565                Ok(val)
2566            }
2567            Expr::Nested(inner) => Ok(Expr::Nested(Box::new(self.resolve_subqueries(inner)?))),
2568            Expr::Function(f) => {
2569                let new_args = match &f.args {
2570                    FunctionArguments::List(al) => {
2571                        let resolved: Result<Vec<_>, _> = al
2572                            .args
2573                            .iter()
2574                            .map(|a| match a {
2575                                FunctionArg::Unnamed(FunctionArgExpr::Expr(e)) => {
2576                                    Ok::<FunctionArg, MqdbError>(FunctionArg::Unnamed(
2577                                        FunctionArgExpr::Expr(self.resolve_subqueries(e)?),
2578                                    ))
2579                                }
2580                                _ => Ok(a.clone()),
2581                            })
2582                            .collect();
2583                        FunctionArguments::List(sqlparser::ast::FunctionArgumentList {
2584                            args: resolved?,
2585                            ..al.clone()
2586                        })
2587                    }
2588                    other => other.clone(),
2589                };
2590                Ok(Expr::Function(Function {
2591                    args: new_args,
2592                    ..f.clone()
2593                }))
2594            }
2595            other => Ok(other.clone()),
2596        }
2597    }
2598
2599    fn materialise_from_with_hint(
2600        &self,
2601        from: &[sqlparser::ast::TableWithJoins],
2602        hint: &IndexHint,
2603        zone_filter: Option<&Expr>,
2604    ) -> Result<Vec<Row>, MqdbError> {
2605        if from.is_empty() {
2606            return Ok(vec![Row {
2607                columns: vec![],
2608                values: vec![],
2609            }]);
2610        }
2611        let mut rows = self.table_rows_with_hint(&from[0].relation, hint, zone_filter)?;
2612        for join in &from[0].joins {
2613            // Joined tables always full-scan (join partner)
2614            let right = self.table_rows_with_hint(&join.relation, &IndexHint::FullScan, None)?;
2615            match &join.join_operator {
2616                JoinOperator::Inner(JoinConstraint::On(on))
2617                | JoinOperator::Join(JoinConstraint::On(on))
2618                | JoinOperator::Left(JoinConstraint::On(on))
2619                | JoinOperator::LeftOuter(JoinConstraint::On(on)) => {
2620                    let resolved = self.resolve_subqueries(on)?;
2621                    let left_cols = rows.first().map(|r| r.columns.clone()).unwrap_or_default();
2622                    let right_cols = right.first().map(|r| r.columns.clone()).unwrap_or_default();
2623                    rows = match find_equi_join_exprs(&resolved, &left_cols, &right_cols) {
2624                        Some((left_key, right_key)) => {
2625                            hash_equi_join(rows, right, left_key, right_key, &resolved)
2626                        }
2627                        None => {
2628                            let mut combined = cross_join(rows, right);
2629                            combined.retain(|row| eval_expr(&resolved, row).is_truthy());
2630                            combined
2631                        }
2632                    };
2633                }
2634                _ => {
2635                    rows = cross_join(rows, right);
2636                }
2637            }
2638        }
2639        for twj in from.iter().skip(1) {
2640            let right = self.table_rows_with_hint(&twj.relation, &IndexHint::FullScan, None)?;
2641            rows = cross_join(rows, right);
2642            for join in &twj.joins {
2643                let right2 =
2644                    self.table_rows_with_hint(&join.relation, &IndexHint::FullScan, None)?;
2645                rows = cross_join(rows, right2);
2646            }
2647        }
2648        Ok(rows)
2649    }
2650
2651    fn table_rows_with_hint(
2652        &self,
2653        factor: &TableFactor,
2654        hint: &IndexHint,
2655        zone_filter: Option<&Expr>,
2656    ) -> Result<Vec<Row>, MqdbError> {
2657        let (schema, table_name, alias, func_args) = match factor {
2658            TableFactor::Table {
2659                name, alias, args, ..
2660            } => {
2661                let parts: Vec<&str> = name.0.iter().map(ident_value).collect();
2662                let (schema, n) = if parts.len() >= 2 {
2663                    (
2664                        Some(parts[parts.len() - 2].to_lowercase()),
2665                        parts[parts.len() - 1].to_lowercase(),
2666                    )
2667                } else {
2668                    (None, parts.last().unwrap_or(&"").to_lowercase())
2669                };
2670                let a = alias.as_ref().map(|a| a.name.value.clone());
2671                (schema, n, a, args.clone())
2672            }
2673            _ => return Err(MqdbError::SqlExec("unsupported FROM clause".into())),
2674        };
2675
2676        if let Some(func_args) = &func_args {
2677            let prefix = alias.as_deref().unwrap_or(&table_name).to_string();
2678            return resolve_table_function(&table_name, func_args, &prefix);
2679        }
2680
2681        // `<alias>.<table>` — resolve against an ATTACHed store instead of
2682        // this one. No CTE shadowing or transitive attach across it.
2683        if let Some(schema) = schema {
2684            let guard = self.store.attached.read().unwrap();
2685            let other = guard.get(schema.as_str()).ok_or_else(|| {
2686                MqdbError::SqlExec(format!(
2687                    "unknown database '{schema}' (attach it first with ATTACH DATABASE '<path>' AS {schema})"
2688                ))
2689            })?;
2690            let engine = SqlEngine::new(other)?;
2691            return engine.table_rows_unqualified(&table_name, alias.as_deref(), hint, zone_filter);
2692        }
2693
2694        // A `WITH x AS (...)` shadows a real table named `x`; search
2695        // innermost-to-outermost so nested `WITH`s shadow outer ones.
2696        for scope in self.cte_scopes.borrow().iter().rev() {
2697            if let Some(out) = scope.get(&table_name) {
2698                let prefix = alias.as_deref().unwrap_or(&table_name);
2699                return Ok(output_to_rows(out, prefix));
2700            }
2701        }
2702
2703        self.table_rows_unqualified(&table_name, alias.as_deref(), hint, zone_filter)
2704    }
2705
2706    /// Resolves `blocks`/`documents`/a view/a custom table by name, with no
2707    /// schema qualifier or CTE shadowing — shared by local and
2708    /// `<alias>.<table>` (attached-store) resolution.
2709    fn table_rows_unqualified(
2710        &self,
2711        table_name: &str,
2712        alias: Option<&str>,
2713        hint: &IndexHint,
2714        zone_filter: Option<&Expr>,
2715    ) -> Result<Vec<Row>, MqdbError> {
2716        match table_name {
2717            "blocks" => {
2718                let prefix = alias.unwrap_or("blocks");
2719                let mut rows = Vec::new();
2720                let mut global_idx: u32 = 0;
2721
2722                for (doc, doc_idx) in self.documents_with_indexes() {
2723                    // Zone-map document skip: prove no block in this document
2724                    // can match before reading any of them.
2725                    if let Some(we) = zone_filter
2726                        && zone_map_skip(&doc.zone_maps, we)
2727                    {
2728                        global_idx += doc.blocks.len() as u32;
2729                        continue;
2730                    }
2731                    // Try index-based access first
2732                    if let Some(local_indices) = hint.resolve(doc_idx) {
2733                        // Only materialise the pre-filtered blocks
2734                        for local_i in local_indices {
2735                            if let Some(block) = doc.blocks.get(local_i as usize) {
2736                                let block_global_idx = global_idx + local_i;
2737                                rows.push(qualify_row(
2738                                    block_to_row(doc.id, block, block_global_idx),
2739                                    prefix,
2740                                ));
2741                            }
2742                        }
2743                    } else {
2744                        // FullScan
2745                        for (i, block) in doc.blocks.iter().enumerate() {
2746                            rows.push(qualify_row(
2747                                block_to_row(doc.id, block, global_idx + i as u32),
2748                                prefix,
2749                            ));
2750                        }
2751                    }
2752                    global_idx += doc.blocks.len() as u32;
2753                }
2754                Ok(rows)
2755            }
2756            "documents" => {
2757                let prefix = alias.unwrap_or("documents");
2758                Ok(self
2759                    .store
2760                    .documents()
2761                    .iter()
2762                    .map(|doc| qualify_row(doc_to_row(doc), prefix))
2763                    .collect())
2764            }
2765            other => {
2766                if let Some(sql_text) = self.store.views.read().unwrap().get(other).cloned() {
2767                    let prefix = alias.unwrap_or(other).to_string();
2768                    return self.resolve_view(other, &sql_text, &prefix);
2769                }
2770                let guard = self.store.custom_tables.read().unwrap();
2771                if let Some(state) = guard.get(other) {
2772                    let prefix = alias.unwrap_or(other);
2773                    let rows = state
2774                        .rows
2775                        .iter()
2776                        .map(|row_vals| {
2777                            qualify_row(
2778                                Row {
2779                                    columns: state.columns.clone(),
2780                                    values: row_vals
2781                                        .iter()
2782                                        .map(|v| Value::Str(v.clone()))
2783                                        .collect(),
2784                                },
2785                                prefix,
2786                            )
2787                        })
2788                        .collect();
2789                    return Ok(rows);
2790                }
2791                drop(guard);
2792                Err(MqdbError::SqlExec(format!("unknown table: {other}")))
2793            }
2794        }
2795    }
2796
2797    fn exec_view_query(&self, name: &str, sql_text: &str) -> Result<QueryOutput, MqdbError> {
2798        if self.view_stack.borrow().iter().any(|n| n == name) {
2799            let mut cycle = self.view_stack.borrow().clone();
2800            cycle.push(name.to_string());
2801            return Err(MqdbError::SqlExec(format!(
2802                "circular view reference: {}",
2803                cycle.join(" -> ")
2804            )));
2805        }
2806        self.view_stack.borrow_mut().push(name.to_string());
2807        let result = (|| {
2808            let stmts = Parser::parse_sql(&GenericDialect {}, sql_text)
2809                .map_err(|e| MqdbError::SqlParse(e.to_string()))?;
2810            let stmt = stmts
2811                .into_iter()
2812                .next()
2813                .ok_or_else(|| MqdbError::SqlParse("empty view query".into()))?;
2814            let Statement::Query(query) = stmt else {
2815                return Err(MqdbError::SqlExec("view query is not a SELECT".into()));
2816            };
2817            self.exec_query(&query)
2818        })();
2819        self.view_stack.borrow_mut().pop();
2820        result
2821    }
2822
2823    fn resolve_view(
2824        &self,
2825        name: &str,
2826        sql_text: &str,
2827        prefix: &str,
2828    ) -> Result<Vec<Row>, MqdbError> {
2829        Ok(output_to_rows(
2830            &self.exec_view_query(name, sql_text)?,
2831            prefix,
2832        ))
2833    }
2834
2835    fn project_and_aggregate(
2836        &self,
2837        select: &Select,
2838        rows: Vec<Row>,
2839        order_by: &Option<sqlparser::ast::OrderBy>,
2840        limit: Option<&Expr>,
2841    ) -> Result<QueryOutput, MqdbError> {
2842        let group_by_exprs: Vec<Expr> = match &select.group_by {
2843            GroupByExpr::Expressions(exprs, _) => exprs.clone(),
2844            _ => vec![],
2845        };
2846        let is_agg = has_aggregate(&select.projection);
2847
2848        if is_agg || !group_by_exprs.is_empty() {
2849            return self.aggregate(select, rows, limit, &group_by_exprs);
2850        }
2851
2852        // Plain SELECT
2853        let columns = projection_columns(&select.projection, rows.first());
2854        let mut result: Vec<(Row, Vec<String>)> = rows
2855            .into_iter()
2856            .map(|row| {
2857                let cells = project_row(&select.projection, &row);
2858                (row, cells)
2859            })
2860            .collect();
2861
2862        // ORDER BY
2863        if let Some(ob) = order_by {
2864            apply_order_by(&mut result, &ob.kind);
2865        }
2866
2867        // DISTINCT
2868        let result: Vec<Vec<String>> = if select.distinct.is_some() {
2869            let mut seen = std::collections::HashSet::new();
2870            result
2871                .into_iter()
2872                .filter_map(|(_, cells)| {
2873                    if seen.insert(cells.clone()) {
2874                        Some(cells)
2875                    } else {
2876                        None
2877                    }
2878                })
2879                .collect()
2880        } else {
2881            result.into_iter().map(|(_, cells)| cells).collect()
2882        };
2883
2884        Ok(QueryOutput {
2885            columns,
2886            rows: apply_limit(result, limit),
2887        })
2888    }
2889
2890    fn aggregate(
2891        &self,
2892        select: &Select,
2893        rows: Vec<Row>,
2894        limit: Option<&Expr>,
2895        group_by_exprs: &[Expr],
2896    ) -> Result<QueryOutput, MqdbError> {
2897        validate_agg_projection(&select.projection, group_by_exprs)?;
2898
2899        let columns: Vec<String> = select
2900            .projection
2901            .iter()
2902            .enumerate()
2903            .map(|(i, item)| projection_col_name(item, i))
2904            .collect();
2905
2906        // Group
2907        let mut groups: Vec<(Vec<Value>, Vec<&Row>)> = Vec::new();
2908        let mut key_index: FxHashMap<Vec<String>, usize> = FxHashMap::default();
2909
2910        // We need owned rows to reference; collect first
2911        let owned: Vec<Row> = rows;
2912
2913        if group_by_exprs.is_empty() {
2914            // Single group
2915            let all: Vec<&Row> = owned.iter().collect();
2916            let out_row = eval_agg_row(&select.projection, group_by_exprs, &[], &all);
2917            return Ok(QueryOutput {
2918                columns,
2919                rows: apply_limit(vec![out_row], limit),
2920            });
2921        }
2922
2923        for row in &owned {
2924            let key: Vec<Value> = group_by_exprs.iter().map(|e| eval_expr(e, row)).collect();
2925            let key_str: Vec<String> = key.iter().map(|v| v.display()).collect();
2926            let idx = key_index.entry(key_str.clone()).or_insert_with(|| {
2927                groups.push((key, Vec::new()));
2928                groups.len() - 1
2929            });
2930            groups[*idx].1.push(row);
2931        }
2932
2933        let out_rows: Vec<Vec<String>> = groups
2934            .iter()
2935            .map(|(key_vals, group_rows)| {
2936                eval_agg_row(&select.projection, group_by_exprs, key_vals, group_rows)
2937            })
2938            .collect();
2939
2940        Ok(QueryOutput {
2941            columns,
2942            rows: apply_limit(out_rows, limit),
2943        })
2944    }
2945}
2946
2947/// A single matched `blocks` row targeted by `UPDATE`/`DELETE`, identified
2948/// by `(document_id, pre)` — `pre` is a unique per-document DFS number, so
2949/// this is stable even though the SQL-visible `id` column is a store-wide
2950/// running index that doesn't correspond to any field on [`Block`].
2951struct MatchedBlockEdit {
2952    document_id: u32,
2953    pre: u32,
2954    /// `Some(rendered content)` for `UPDATE`, `None` for `DELETE`.
2955    new_content: Option<String>,
2956}
2957
2958fn single_table_name(twj: &TableWithJoins) -> Result<String, MqdbError> {
2959    if !twj.joins.is_empty() {
2960        return Err(MqdbError::SqlExec(
2961            "UPDATE/DELETE with write-back do not support joins".into(),
2962        ));
2963    }
2964    match &twj.relation {
2965        TableFactor::Table { name, .. } => require_unqualified(name),
2966        _ => Err(MqdbError::SqlExec(
2967            "unsupported UPDATE/DELETE target".into(),
2968        )),
2969    }
2970}
2971
2972/// Materialises the rows matched by `target`/`selection`, optionally
2973/// evaluating `set_value` (the `UPDATE ... SET content = <expr>` value,
2974/// per matched row) into `MatchedBlockEdit`s. `set_value` is `None` for
2975/// `DELETE`.
2976fn collect_matched_edits(
2977    store: &DocumentStore,
2978    target: &TableWithJoins,
2979    selection: Option<&Expr>,
2980    set_value: Option<&Expr>,
2981) -> Result<Vec<MatchedBlockEdit>, MqdbError> {
2982    let table_name = single_table_name(target)?;
2983    if table_name != "blocks" {
2984        return Err(MqdbError::SqlExec(format!(
2985            "UPDATE/DELETE with write-back is only supported on 'blocks' (got '{table_name}')"
2986        )));
2987    }
2988
2989    let engine = SqlEngine::new(store)?;
2990    let mut rows = engine.materialise_from_with_hint(
2991        std::slice::from_ref(target),
2992        &IndexHint::FullScan,
2993        None,
2994    )?;
2995    if let Some(sel) = selection {
2996        let resolved = engine.resolve_subqueries(sel)?;
2997        rows.retain(|row| eval_expr(&resolved, row).is_truthy());
2998    }
2999
3000    rows.iter()
3001        .map(|row| {
3002            let document_id = row
3003                .get("document_id")
3004                .and_then(Value::as_i64)
3005                .ok_or_else(|| MqdbError::SqlExec("matched row missing document_id".into()))?
3006                as u32;
3007            let pre = row
3008                .get("pre")
3009                .and_then(Value::as_i64)
3010                .ok_or_else(|| MqdbError::SqlExec("matched row missing pre".into()))?
3011                as u32;
3012            let new_content = set_value.map(|expr| eval_expr(expr, row).display());
3013            Ok(MatchedBlockEdit {
3014                document_id,
3015                pre,
3016                new_content,
3017            })
3018        })
3019        .collect()
3020}
3021
3022/// Renders Markdown source text for a `Heading`/`Paragraph` block. Shared by
3023/// `UPDATE`/`INSERT INTO blocks` write-back. Other block types (tables,
3024/// code, lists, ...) aren't supported.
3025fn render_markdown_for(
3026    block_type: &BlockType,
3027    depth: Option<u8>,
3028    content: &str,
3029) -> Result<String, MqdbError> {
3030    match block_type {
3031        BlockType::Heading => Ok(format!(
3032            "{} {}",
3033            "#".repeat(depth.unwrap_or(1).max(1) as usize),
3034            content
3035        )),
3036        BlockType::Paragraph => Ok(content.to_string()),
3037        other => Err(MqdbError::SqlExec(format!(
3038            "write-back is only supported for heading/paragraph blocks (found {})",
3039            other.as_str()
3040        ))),
3041    }
3042}
3043
3044/// Renders `edit`'s replacement text for an existing matched block.
3045fn render_replacement(block: &Block, new_content: &str) -> Result<String, MqdbError> {
3046    render_markdown_for(&block.block_type, block.heading_depth(), new_content)
3047}
3048
3049/// Applies `edits` (grouped by document) as a source-text patch + reparse:
3050/// for each affected document, reads the *current* file off disk, splices
3051/// in the rendered replacement (or removes the lines entirely for a
3052/// `DELETE`) at each matched block's `Span`, writes the patched text back to
3053/// the file, then calls [`DocumentStore::replace_document`] to re-parse it
3054/// in place (same `DocumentId`, fresh blocks/index/catalog entry).
3055///
3056/// Returns the number of blocks affected.
3057fn apply_matched_edits(
3058    store: &mut DocumentStore,
3059    edits: Vec<MatchedBlockEdit>,
3060) -> Result<usize, MqdbError> {
3061    let mut by_doc: FxHashMap<u32, Vec<MatchedBlockEdit>> = FxHashMap::default();
3062    for edit in edits {
3063        by_doc.entry(edit.document_id).or_default().push(edit);
3064    }
3065
3066    let mut affected = 0usize;
3067    for (doc_id, doc_edits) in by_doc {
3068        struct LineEdit {
3069            start_line: usize,
3070            end_line: usize,
3071            replacement: Option<String>,
3072        }
3073
3074        let (path, mut line_edits) = {
3075            let doc = store
3076                .get_document(doc_id)
3077                .ok_or_else(|| MqdbError::SqlExec(format!("no such document: {doc_id}")))?;
3078            let path = doc.path.clone().ok_or_else(|| {
3079                MqdbError::SqlExec(
3080                    "cannot write back: document has no source file (added via add_str)".into(),
3081                )
3082            })?;
3083
3084            let mut line_edits = Vec::with_capacity(doc_edits.len());
3085            for edit in &doc_edits {
3086                let block = doc
3087                    .blocks
3088                    .iter()
3089                    .find(|b| b.pre == edit.pre)
3090                    .ok_or_else(|| MqdbError::SqlExec("matched block no longer exists".into()))?;
3091                let span = block.span.as_ref().ok_or_else(|| {
3092                    MqdbError::SqlExec(
3093                        "write-back requires source spans; reindex without --no-spans".into(),
3094                    )
3095                })?;
3096                let replacement = edit
3097                    .new_content
3098                    .as_deref()
3099                    .map(|c| render_replacement(block, c))
3100                    .transpose()?;
3101                line_edits.push(LineEdit {
3102                    start_line: span.start_line,
3103                    end_line: span.end_line,
3104                    replacement,
3105                });
3106            }
3107            (path, line_edits)
3108        };
3109
3110        let original = std::fs::read_to_string(&path)?;
3111        let had_trailing_newline = original.ends_with('\n');
3112        let mut lines: Vec<String> = original.lines().map(str::to_string).collect();
3113
3114        // Apply from the bottom up so earlier edits don't shift later
3115        // (already-resolved) line numbers.
3116        line_edits.sort_by_key(|edit| std::cmp::Reverse(edit.start_line));
3117        for edit in &line_edits {
3118            let start = edit.start_line.saturating_sub(1);
3119            let end = edit.end_line.min(lines.len());
3120            if start >= end || start >= lines.len() {
3121                continue;
3122            }
3123            match &edit.replacement {
3124                Some(text) => {
3125                    lines.splice(start..end, std::iter::once(text.clone()));
3126                }
3127                None => {
3128                    let mut remove_start = start;
3129                    let mut remove_end = end;
3130                    if remove_end < lines.len() && lines[remove_end].trim().is_empty() {
3131                        // Blank line after (the common case: an interior or
3132                        // first block) — swallow it.
3133                        remove_end += 1;
3134                    } else if remove_start > 0 && lines[remove_start - 1].trim().is_empty() {
3135                        // No blank line after (block was the last one in the
3136                        // file) — swallow the blank line before it instead.
3137                        remove_start -= 1;
3138                    }
3139                    lines.splice(remove_start..remove_end, std::iter::empty());
3140                }
3141            }
3142        }
3143
3144        let mut patched = lines.join("\n");
3145        if had_trailing_newline {
3146            patched.push('\n');
3147        }
3148
3149        std::fs::write(&path, &patched)?;
3150        affected += doc_edits.len();
3151        store.replace_document(doc_id, &patched, Some(path))?;
3152    }
3153
3154    Ok(affected)
3155}
3156
3157/// A new block to insert via `INSERT INTO blocks (...) VALUES (...)`.
3158/// Mirrors [`MatchedBlockEdit`] but for insertion.
3159struct NewBlockSpec {
3160    document_id: u32,
3161    block_type: BlockType,
3162    content: String,
3163    /// Required (1-6) iff `block_type` is `Heading`.
3164    depth: Option<u8>,
3165    /// `pre` of the block to insert after; `None` appends at document end.
3166    after_pre: Option<u32>,
3167    /// Position within `VALUES`, to preserve order among same-anchor rows.
3168    row_index: usize,
3169}
3170
3171const INSERT_BLOCKS_COLUMNS: [&str; 5] =
3172    ["document_id", "block_type", "content", "depth", "after_pre"];
3173
3174/// Parses an `INSERT INTO blocks (...) VALUES (...)` statement into
3175/// [`NewBlockSpec`]s. Only an explicit column list and a literal `VALUES`
3176/// source are supported (no `INSERT ... SELECT`).
3177fn collect_new_blocks(ins: &Insert) -> Result<Vec<NewBlockSpec>, MqdbError> {
3178    if ins.columns.is_empty() {
3179        return Err(MqdbError::SqlExec(
3180            "write-back INSERT INTO blocks requires an explicit column list".into(),
3181        ));
3182    }
3183    let col_names: Vec<String> = ins
3184        .columns
3185        .iter()
3186        .map(|c| c.0.last().map(ident_value).unwrap_or("").to_lowercase())
3187        .collect();
3188    for name in &col_names {
3189        if !INSERT_BLOCKS_COLUMNS.contains(&name.as_str()) {
3190            return Err(MqdbError::SqlExec(format!(
3191                "write-back INSERT INTO blocks does not support column '{name}'"
3192            )));
3193        }
3194    }
3195
3196    let source = ins
3197        .source
3198        .as_ref()
3199        .ok_or_else(|| MqdbError::SqlExec("INSERT requires VALUES".into()))?;
3200    let SetExpr::Values(Values { rows, .. }) = source.body.as_ref() else {
3201        return Err(MqdbError::SqlExec(
3202            "write-back INSERT INTO blocks only supports VALUES, not INSERT ... SELECT".into(),
3203        ));
3204    };
3205
3206    let empty = Row {
3207        columns: vec![],
3208        values: vec![],
3209    };
3210    rows.iter()
3211        .enumerate()
3212        .map(|(row_index, row)| {
3213            if row.len() != col_names.len() {
3214                return Err(MqdbError::SqlExec(format!(
3215                    "expected {} values, got {}",
3216                    col_names.len(),
3217                    row.len()
3218                )));
3219            }
3220
3221            let mut document_id: Option<i64> = None;
3222            let mut block_type: Option<BlockType> = None;
3223            let mut content: Option<String> = None;
3224            let mut depth: Option<u8> = None;
3225            let mut after_pre: Option<u32> = None;
3226
3227            for (name, expr) in col_names.iter().zip(row.iter()) {
3228                let value = eval_expr(expr, &empty);
3229                match name.as_str() {
3230                    "document_id" => {
3231                        document_id = Some(value.as_i64().ok_or_else(|| {
3232                            MqdbError::SqlExec("document_id must be an integer".into())
3233                        })?);
3234                    }
3235                    "block_type" => {
3236                        let s = value.as_str().ok_or_else(|| {
3237                            MqdbError::SqlExec("block_type must be a string".into())
3238                        })?;
3239                        let bt = BlockType::from_str(&s.to_lowercase())
3240                            .filter(|bt| matches!(bt, BlockType::Heading | BlockType::Paragraph))
3241                            .ok_or_else(|| {
3242                                MqdbError::SqlExec(format!(
3243                                    "write-back is only supported for heading/paragraph blocks (found {s})"
3244                                ))
3245                            })?;
3246                        block_type = Some(bt);
3247                    }
3248                    "content" => {
3249                        content = match value {
3250                            Value::Null => None,
3251                            other => Some(other.display()),
3252                        };
3253                    }
3254                    "depth" => {
3255                        depth = match value {
3256                            Value::Null => None,
3257                            other => Some(other.as_i64().ok_or_else(|| {
3258                                MqdbError::SqlExec("depth must be an integer".into())
3259                            })? as u8),
3260                        };
3261                    }
3262                    "after_pre" => {
3263                        after_pre = match value {
3264                            Value::Null => None,
3265                            other => Some(other.as_i64().ok_or_else(|| {
3266                                MqdbError::SqlExec("after_pre must be an integer".into())
3267                            })? as u32),
3268                        };
3269                    }
3270                    _ => unreachable!("column names validated above"),
3271                }
3272            }
3273
3274            let document_id = document_id
3275                .ok_or_else(|| MqdbError::SqlExec("INSERT INTO blocks requires document_id".into()))?
3276                as u32;
3277            let block_type = block_type
3278                .ok_or_else(|| MqdbError::SqlExec("INSERT INTO blocks requires block_type".into()))?;
3279            let content = content.ok_or_else(|| {
3280                MqdbError::SqlExec("INSERT INTO blocks requires non-NULL content".into())
3281            })?;
3282
3283            match block_type {
3284                BlockType::Heading => match depth {
3285                    None => {
3286                        return Err(MqdbError::SqlExec(
3287                            "INSERT INTO blocks requires depth (1-6) for block_type 'heading'"
3288                                .into(),
3289                        ));
3290                    }
3291                    Some(d) if !(1..=6).contains(&d) => {
3292                        return Err(MqdbError::SqlExec(
3293                            "depth must be between 1 and 6 for block_type 'heading'".into(),
3294                        ));
3295                    }
3296                    Some(_) => {}
3297                },
3298                BlockType::Paragraph if depth.is_some() => {
3299                    return Err(MqdbError::SqlExec(
3300                        "depth is only valid for block_type 'heading'".into(),
3301                    ));
3302                }
3303                _ => {}
3304            }
3305
3306            Ok(NewBlockSpec {
3307                document_id,
3308                block_type,
3309                content,
3310                depth,
3311                after_pre,
3312                row_index,
3313            })
3314        })
3315        .collect()
3316}
3317
3318/// Applies `specs` (grouped by document) by splicing rendered Markdown text
3319/// into the source file at each spec's anchor position, then reparsing via
3320/// [`DocumentStore::replace_document`], same as [`apply_matched_edits`].
3321///
3322/// Returns the number of blocks inserted.
3323fn apply_new_blocks(
3324    store: &mut DocumentStore,
3325    specs: Vec<NewBlockSpec>,
3326) -> Result<usize, MqdbError> {
3327    let mut by_doc: FxHashMap<u32, Vec<NewBlockSpec>> = FxHashMap::default();
3328    for spec in specs {
3329        by_doc.entry(spec.document_id).or_default().push(spec);
3330    }
3331
3332    let mut inserted = 0usize;
3333    for (doc_id, doc_specs) in by_doc {
3334        struct Insertion {
3335            /// 0-indexed line to insert before. `usize::MAX` means "end of
3336            /// file", resolved once the line count is known, below.
3337            at: usize,
3338            row_index: usize,
3339            rendered: String,
3340        }
3341
3342        let (path, mut insertions) = {
3343            let doc = store
3344                .get_document(doc_id)
3345                .ok_or_else(|| MqdbError::SqlExec(format!("no such document: {doc_id}")))?;
3346            let path = doc.path.clone().ok_or_else(|| {
3347                MqdbError::SqlExec(
3348                    "cannot write back: document has no source file (added via add_str)".into(),
3349                )
3350            })?;
3351
3352            let mut insertions = Vec::with_capacity(doc_specs.len());
3353            for spec in &doc_specs {
3354                let at = match spec.after_pre {
3355                    Some(pre) => {
3356                        let block = doc.blocks.iter().find(|b| b.pre == pre).ok_or_else(|| {
3357                            MqdbError::SqlExec(format!(
3358                                "after_pre {pre} does not match any block in document {doc_id}"
3359                            ))
3360                        })?;
3361                        let span = block.span.as_ref().ok_or_else(|| {
3362                            MqdbError::SqlExec(
3363                                "write-back requires source spans; reindex without --no-spans"
3364                                    .into(),
3365                            )
3366                        })?;
3367                        span.end_line
3368                    }
3369                    None => usize::MAX,
3370                };
3371                let rendered = render_markdown_for(&spec.block_type, spec.depth, &spec.content)?;
3372                insertions.push(Insertion {
3373                    at,
3374                    row_index: spec.row_index,
3375                    rendered,
3376                });
3377            }
3378            (path, insertions)
3379        };
3380
3381        let original = std::fs::read_to_string(&path)?;
3382        let had_trailing_newline = original.ends_with('\n');
3383        let mut lines: Vec<String> = original.lines().map(str::to_string).collect();
3384
3385        for insertion in &mut insertions {
3386            if insertion.at == usize::MAX {
3387                insertion.at = lines.len();
3388            }
3389        }
3390
3391        // Bottom-up so earlier insertions don't shift later line numbers;
3392        // ties broken by declared VALUES order.
3393        insertions.sort_by_key(|ins| (std::cmp::Reverse(ins.at), std::cmp::Reverse(ins.row_index)));
3394
3395        for insertion in &insertions {
3396            let at = insertion.at.min(lines.len());
3397            let needs_leading_blank = at > 0 && !lines[at - 1].trim().is_empty();
3398            let needs_trailing_blank = at < lines.len() && !lines[at].trim().is_empty();
3399
3400            let mut new_lines = vec![insertion.rendered.clone()];
3401            if needs_trailing_blank {
3402                new_lines.push(String::new());
3403            }
3404            if needs_leading_blank {
3405                new_lines.insert(0, String::new());
3406            }
3407            lines.splice(at..at, new_lines);
3408        }
3409
3410        let mut patched = lines.join("\n");
3411        if had_trailing_newline {
3412            patched.push('\n');
3413        }
3414
3415        std::fs::write(&path, &patched)?;
3416        inserted += doc_specs.len();
3417        store.replace_document(doc_id, &patched, Some(path))?;
3418    }
3419
3420    Ok(inserted)
3421}
3422
3423impl DocumentStore {
3424    /// Execute a SQL statement that may mutate the store.
3425    ///
3426    /// `UPDATE`/`DELETE` against the `blocks` table are handled directly —
3427    /// see the module-level write-back notes above — and are written back
3428    /// to the affected document's *source Markdown file* (re-parsed in
3429    /// place, same `DocumentId`). Everything else (`SELECT`, `CREATE
3430    /// TABLE`, `INSERT`, `DROP TABLE`, `DESC`, `SHOW TABLES`) delegates to
3431    /// the regular read-only [`SqlEngine::execute`].
3432    ///
3433    /// Callers that expose this over an interface an end user might not
3434    /// expect to mutate files (a CLI, an HTTP/MCP endpoint) should gate it
3435    /// behind an explicit opt-in before calling this — write-back mutates
3436    /// the user's Markdown source on disk.
3437    pub fn execute_sql_mut(&mut self, sql: &str) -> Result<QueryOutput, MqdbError> {
3438        let trimmed = sql.trim().trim_end_matches(';');
3439        let upper = trimmed.to_ascii_uppercase();
3440        if upper.starts_with("DESC ") || upper.starts_with("DESCRIBE ") || upper == "SHOW TABLES" {
3441            return SqlEngine::new(self)?.execute(sql);
3442        }
3443
3444        let stmts = Parser::parse_sql(&GenericDialect {}, sql)
3445            .map_err(|e| MqdbError::SqlParse(e.to_string()))?;
3446        let stmt = stmts
3447            .into_iter()
3448            .next()
3449            .ok_or_else(|| MqdbError::SqlParse("empty query".into()))?;
3450
3451        match stmt {
3452            Statement::Update(update) => {
3453                if update.from.is_some() {
3454                    return Err(MqdbError::SqlExec(
3455                        "UPDATE ... FROM is not supported for write-back".into(),
3456                    ));
3457                }
3458                if update.assignments.len() != 1 {
3459                    return Err(MqdbError::SqlExec(
3460                        "write-back UPDATE supports exactly one assignment: SET content = ..."
3461                            .into(),
3462                    ));
3463                }
3464                let assignment = &update.assignments[0];
3465                let column = match &assignment.target {
3466                    AssignmentTarget::ColumnName(name) => {
3467                        name.0.last().map(ident_value).unwrap_or("").to_lowercase()
3468                    }
3469                    AssignmentTarget::Tuple(_) => {
3470                        return Err(MqdbError::SqlExec(
3471                            "write-back UPDATE does not support tuple assignment targets".into(),
3472                        ));
3473                    }
3474                };
3475                if column != "content" {
3476                    return Err(MqdbError::SqlExec(format!(
3477                        "write-back UPDATE only supports the 'content' column (got '{column}')"
3478                    )));
3479                }
3480
3481                let edits = collect_matched_edits(
3482                    self,
3483                    &update.table,
3484                    update.selection.as_ref(),
3485                    Some(&assignment.value),
3486                )?;
3487                let n = apply_matched_edits(self, edits)?;
3488                Ok(QueryOutput {
3489                    columns: vec!["updated".to_string()],
3490                    rows: vec![vec![n.to_string()]],
3491                })
3492            }
3493            Statement::Delete(delete) => {
3494                let tables = match &delete.from {
3495                    FromTable::WithFromKeyword(tables) | FromTable::WithoutKeyword(tables) => {
3496                        tables
3497                    }
3498                };
3499                if tables.len() != 1 {
3500                    return Err(MqdbError::SqlExec(
3501                        "write-back DELETE supports exactly one target table".into(),
3502                    ));
3503                }
3504                let edits =
3505                    collect_matched_edits(self, &tables[0], delete.selection.as_ref(), None)?;
3506                let n = apply_matched_edits(self, edits)?;
3507                Ok(QueryOutput {
3508                    columns: vec!["deleted".to_string()],
3509                    rows: vec![vec![n.to_string()]],
3510                })
3511            }
3512            Statement::Insert(ins) => {
3513                let table_name = match &ins.table {
3514                    TableObject::TableName(name) => require_unqualified(name)?,
3515                    _ => return Err(MqdbError::SqlExec("unsupported INSERT target".into())),
3516                };
3517                if table_name == "blocks" {
3518                    let specs = collect_new_blocks(&ins)?;
3519                    let n = apply_new_blocks(self, specs)?;
3520                    Ok(QueryOutput {
3521                        columns: vec!["inserted".to_string()],
3522                        rows: vec![vec![n.to_string()]],
3523                    })
3524                } else {
3525                    SqlEngine::new(self)?.execute(sql)
3526                }
3527            }
3528            _ => SqlEngine::new(self)?.execute(sql),
3529        }
3530    }
3531}
3532
3533fn projection_columns(projection: &[SelectItem], first_row: Option<&Row>) -> Vec<String> {
3534    if projection.len() == 1 && matches!(projection[0], SelectItem::Wildcard(_)) {
3535        return first_row
3536            .map(|r| {
3537                r.columns
3538                    .iter()
3539                    .map(|c| c.split('.').next_back().unwrap_or(c).to_string())
3540                    .collect()
3541            })
3542            .unwrap_or_default();
3543    }
3544    projection
3545        .iter()
3546        .enumerate()
3547        .map(|(i, item)| projection_col_name(item, i))
3548        .collect()
3549}
3550
3551fn projection_col_name(item: &SelectItem, idx: usize) -> String {
3552    match item {
3553        SelectItem::UnnamedExpr(Expr::Identifier(i)) => i.value.clone(),
3554        SelectItem::UnnamedExpr(Expr::CompoundIdentifier(parts)) => parts
3555            .last()
3556            .map(|i| i.value.as_str())
3557            .unwrap_or("")
3558            .to_string(),
3559        SelectItem::UnnamedExpr(Expr::Function(f)) => {
3560            f.name.0.last().map(ident_value).unwrap_or("").to_string()
3561        }
3562        SelectItem::ExprWithAlias { alias, .. } => alias.value.clone(),
3563        SelectItem::Wildcard(_) => "*".to_string(),
3564        _ => format!("col{}", idx),
3565    }
3566}
3567
3568fn project_row(projection: &[SelectItem], row: &Row) -> Vec<String> {
3569    if projection.len() == 1 && matches!(projection[0], SelectItem::Wildcard(_)) {
3570        return row.values.iter().map(|v| v.display()).collect();
3571    }
3572    projection
3573        .iter()
3574        .map(|item| match item {
3575            SelectItem::UnnamedExpr(e) | SelectItem::ExprWithAlias { expr: e, .. } => {
3576                eval_expr(e, row).display()
3577            }
3578            SelectItem::ExprWithAliases { expr: e, .. } => eval_expr(e, row).display(),
3579            SelectItem::Wildcard(_) => row
3580                .values
3581                .iter()
3582                .map(|v| v.display())
3583                .collect::<Vec<_>>()
3584                .join(","),
3585            SelectItem::QualifiedWildcard(kind, _) => {
3586                let prefix = match kind {
3587                    sqlparser::ast::SelectItemQualifiedWildcardKind::ObjectName(name) => {
3588                        name.0.last().map(ident_value).unwrap_or("").to_string()
3589                    }
3590                    _ => String::new(),
3591                };
3592                row.columns
3593                    .iter()
3594                    .zip(row.values.iter())
3595                    .filter(|(c, _)| c.starts_with(&format!("{}.", prefix)))
3596                    .map(|(_, v)| v.display())
3597                    .collect::<Vec<_>>()
3598                    .join(",")
3599            }
3600        })
3601        .collect()
3602}
3603
3604fn has_aggregate(projection: &[SelectItem]) -> bool {
3605    projection.iter().any(|item| match item {
3606        SelectItem::UnnamedExpr(e) | SelectItem::ExprWithAlias { expr: e, .. } => is_agg_expr(e),
3607        _ => false,
3608    })
3609}
3610
3611fn is_agg_expr(expr: &Expr) -> bool {
3612    matches!(expr, Expr::Function(f) if {
3613        let name = f.name.0.last().map(ident_value).unwrap_or("").to_lowercase();
3614        is_aggregate_name(&name)
3615    })
3616}
3617
3618fn is_aggregate_name(name: &str) -> bool {
3619    matches!(
3620        name,
3621        "count" | "sum" | "min" | "max" | "avg" | "group_concat" | "string_agg"
3622    )
3623}
3624
3625/// Rejects non-aggregate columns not covered by GROUP BY (PostgreSQL-style), instead of silently picking a row.
3626fn validate_agg_projection(
3627    projection: &[SelectItem],
3628    group_by_exprs: &[Expr],
3629) -> Result<(), MqdbError> {
3630    for item in projection {
3631        let expr = match item {
3632            SelectItem::UnnamedExpr(e) | SelectItem::ExprWithAlias { expr: e, .. } => e,
3633            _ => continue,
3634        };
3635        if is_agg_expr(expr) || matches!(expr, Expr::Value(_)) {
3636            continue;
3637        }
3638        if group_by_exprs.iter().any(|g| expr_structurally_eq(g, expr)) {
3639            continue;
3640        }
3641        return Err(MqdbError::SqlExec(format!(
3642            "column \"{expr}\" must appear in the GROUP BY clause or be used in an aggregate function"
3643        )));
3644    }
3645    Ok(())
3646}
3647
3648fn eval_agg_row(
3649    projection: &[SelectItem],
3650    group_by_exprs: &[Expr],
3651    key_vals: &[Value],
3652    group_rows: &[&Row],
3653) -> Vec<String> {
3654    projection
3655        .iter()
3656        .map(|item| {
3657            let expr = match item {
3658                SelectItem::UnnamedExpr(e) | SelectItem::ExprWithAlias { expr: e, .. } => e,
3659                _ => return String::new(),
3660            };
3661            match expr {
3662                Expr::Function(f) => {
3663                    let name = f
3664                        .name
3665                        .0
3666                        .last()
3667                        .map(ident_value)
3668                        .unwrap_or("")
3669                        .to_lowercase();
3670                    match name.as_str() {
3671                        "count" if is_distinct(f) => {
3672                            let mut seen: Vec<Value> = Vec::new();
3673                            for r in group_rows {
3674                                let v = agg_arg(f, r);
3675                                if !matches!(v, Value::Null) && !seen.contains(&v) {
3676                                    seen.push(v);
3677                                }
3678                            }
3679                            seen.len().to_string()
3680                        }
3681                        "count" => group_rows.len().to_string(),
3682                        "group_concat" | "string_agg" => {
3683                            let sep = agg_separator(f);
3684                            group_rows
3685                                .iter()
3686                                .map(|r| agg_arg(f, r))
3687                                .filter(|v| !matches!(v, Value::Null))
3688                                .map(|v| v.display())
3689                                .collect::<Vec<_>>()
3690                                .join(&sep)
3691                        }
3692                        "sum" => {
3693                            let sum: f64 = group_rows
3694                                .iter()
3695                                .filter_map(|r| agg_arg(f, r).as_f64())
3696                                .sum();
3697                            sum.to_string()
3698                        }
3699                        "min" => group_rows
3700                            .iter()
3701                            .map(|r| agg_arg(f, r))
3702                            .min_by(|a, b| a.cmp_val(b).unwrap_or(std::cmp::Ordering::Equal))
3703                            .map(|v| v.display())
3704                            .unwrap_or_else(|| "NULL".into()),
3705                        "max" => group_rows
3706                            .iter()
3707                            .map(|r| agg_arg(f, r))
3708                            .max_by(|a, b| a.cmp_val(b).unwrap_or(std::cmp::Ordering::Equal))
3709                            .map(|v| v.display())
3710                            .unwrap_or_else(|| "NULL".into()),
3711                        "avg" => {
3712                            let vals: Vec<f64> = group_rows
3713                                .iter()
3714                                .filter_map(|r| agg_arg(f, r).as_f64())
3715                                .collect();
3716                            if vals.is_empty() {
3717                                "NULL".into()
3718                            } else {
3719                                (vals.iter().sum::<f64>() / vals.len() as f64).to_string()
3720                            }
3721                        }
3722                        _ => String::new(),
3723                    }
3724                }
3725                other => {
3726                    if let Some(ki) = group_by_exprs
3727                        .iter()
3728                        .position(|e| expr_structurally_eq(e, other))
3729                    {
3730                        key_vals.get(ki).map(|v| v.display()).unwrap_or_default()
3731                    } else {
3732                        group_rows
3733                            .first()
3734                            .map(|r| eval_expr(other, r).display())
3735                            .unwrap_or_default()
3736                    }
3737                }
3738            }
3739        })
3740        .collect()
3741}
3742
3743fn agg_arg(f: &Function, row: &Row) -> Value {
3744    match &f.args {
3745        FunctionArguments::List(al) => al.args.iter().find_map(|a| match a {
3746            FunctionArg::Unnamed(FunctionArgExpr::Expr(e)) => Some(eval_expr(e, row)),
3747            FunctionArg::Unnamed(FunctionArgExpr::Wildcard) => Some(Value::Int(1)),
3748            _ => None,
3749        }),
3750        _ => None,
3751    }
3752    .unwrap_or(Value::Null)
3753}
3754
3755fn is_distinct(f: &Function) -> bool {
3756    matches!(
3757        &f.args,
3758        FunctionArguments::List(al) if al.duplicate_treatment == Some(DuplicateTreatment::Distinct)
3759    )
3760}
3761
3762/// Separator for `group_concat(expr[, sep])` / `string_agg(expr, sep)`; the
3763/// second argument is expected to be a literal, so it's read straight off
3764/// the AST rather than through `eval_expr` (which needs a row).
3765fn agg_separator(f: &Function) -> String {
3766    if let FunctionArguments::List(al) = &f.args
3767        && let Some(FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Value(v)))) = al.args.get(1)
3768        && let Value::Str(s) = eval_sql_value(&v.value)
3769    {
3770        return s;
3771    }
3772    ",".to_string()
3773}
3774
3775fn expr_structurally_eq(a: &Expr, b: &Expr) -> bool {
3776    a == b
3777}
3778
3779fn apply_order_by(rows: &mut [(Row, Vec<String>)], kind: &OrderByKind) {
3780    let exprs: &[OrderByExpr] = match kind {
3781        OrderByKind::Expressions(exprs) => exprs,
3782        _ => return,
3783    };
3784    rows.sort_by(|(ra, _), (rb, _)| {
3785        for ob in exprs {
3786            let va = eval_expr(&ob.expr, ra);
3787            let vb = eval_expr(&ob.expr, rb);
3788            let ord = va.cmp_val(&vb).unwrap_or(std::cmp::Ordering::Equal);
3789            // asc=None or asc=Some(true) → ascending; asc=Some(false) → descending
3790            let ord = if ob.options.asc == Some(false) {
3791                ord.reverse()
3792            } else {
3793                ord
3794            };
3795            if ord != std::cmp::Ordering::Equal {
3796                return ord;
3797            }
3798        }
3799        std::cmp::Ordering::Equal
3800    });
3801}
3802
3803fn apply_limit(mut rows: Vec<Vec<String>>, limit: Option<&Expr>) -> Vec<Vec<String>> {
3804    if let Some(lim) = limit {
3805        let dummy = Row {
3806            columns: vec![],
3807            values: vec![],
3808        };
3809        if let Value::Int(n) = eval_expr(lim, &dummy) {
3810            rows.truncate(n as usize);
3811        }
3812    }
3813    rows
3814}
3815
3816/// Flattens a top-level AND-chain into its conjuncts, unwrapping parens.
3817/// Anything else (including `OR`) is returned as a single, unrecognized leaf.
3818fn flatten_and_conjuncts(expr: &Expr) -> Vec<&Expr> {
3819    match expr {
3820        Expr::BinaryOp {
3821            left,
3822            op: BinaryOperator::And,
3823            right,
3824        } => {
3825            let mut out = flatten_and_conjuncts(left);
3826            out.extend(flatten_and_conjuncts(right));
3827            out
3828        }
3829        Expr::Nested(inner) => flatten_and_conjuncts(inner),
3830        other => vec![other],
3831    }
3832}
3833
3834/// Whether `schema` has a column matching `short` (an already-lowercased,
3835/// unqualified name from [`expr_col_name`]). Mirrors `Row::get`'s fallback.
3836fn schema_has_short_col(schema: &[String], short: &str) -> bool {
3837    schema.iter().any(|c| {
3838        let cl = c.to_lowercase();
3839        cl == short || cl.split('.').next_back().unwrap_or(&cl) == short
3840    })
3841}
3842
3843/// First top-level `AND`-conjunct of `on` that is a plain `column = column`
3844/// equality across `left_cols`/`right_cols`, as `(left_key_expr,
3845/// right_key_expr)`. `None` if there's no such conjunct (e.g. only a
3846/// computed key like `nxt.pre = h.pre + 1`) — caller falls back to cross-join.
3847fn find_equi_join_exprs<'a>(
3848    on: &'a Expr,
3849    left_cols: &[String],
3850    right_cols: &[String],
3851) -> Option<(&'a Expr, &'a Expr)> {
3852    for conjunct in flatten_and_conjuncts(on) {
3853        let Expr::BinaryOp {
3854            left,
3855            op: BinaryOperator::Eq,
3856            right,
3857        } = conjunct
3858        else {
3859            continue;
3860        };
3861        let (Some(lname), Some(rname)) = (expr_col_name(left), expr_col_name(right)) else {
3862            continue;
3863        };
3864        if schema_has_short_col(left_cols, &lname) && schema_has_short_col(right_cols, &rname) {
3865            return Some((left, right));
3866        }
3867        if schema_has_short_col(right_cols, &lname) && schema_has_short_col(left_cols, &rname) {
3868            return Some((right, left));
3869        }
3870    }
3871    None
3872}
3873
3874/// Decides whether a whole document can be skipped using [`ZoneMaps`],
3875/// without reading any of its blocks. Unlike [`IndexHint`], a wrong skip
3876/// here silently drops matching rows, so this only returns `true` when it
3877/// can prove no block in the document satisfies `where_expr`.
3878fn zone_map_skip(zone_maps: &ZoneMaps, where_expr: &Expr) -> bool {
3879    let mut eq_block_type: Option<BlockType> = None;
3880    let mut eq_content: Option<String> = None;
3881    let mut eq_lang: Option<String> = None;
3882    let mut eq_depth: Option<u8> = None;
3883
3884    for conjunct in flatten_and_conjuncts(where_expr) {
3885        let Expr::BinaryOp {
3886            left,
3887            op: BinaryOperator::Eq,
3888            right,
3889        } = conjunct
3890        else {
3891            continue;
3892        };
3893        let col = expr_col_name(left).or_else(|| expr_col_name(right));
3894        let val = expr_str_val(right).or_else(|| expr_str_val(left));
3895        let int_val = expr_int_val(right).or_else(|| expr_int_val(left));
3896
3897        match col.as_deref() {
3898            Some("block_type") => {
3899                if let Some(s) = val.as_deref()
3900                    && let Some(bt) = BlockType::from_str(s)
3901                {
3902                    eq_block_type = Some(bt);
3903                }
3904            }
3905            Some("content") => eq_content = val,
3906            // lang = '' means "no lang" (matches non-code blocks), which
3907            // code_languages says nothing about.
3908            Some("lang") => {
3909                if let Some(s) = val
3910                    && !s.is_empty()
3911                {
3912                    eq_lang = Some(s);
3913                }
3914            }
3915            // depth = 0 means "no heading depth" (matches non-heading
3916            // blocks), which max_heading_depth says nothing about.
3917            Some("depth") => {
3918                if let Some(n) = int_val
3919                    && let Ok(n) = u8::try_from(n)
3920                    && n > 0
3921                {
3922                    eq_depth = Some(n);
3923                }
3924            }
3925            _ => {}
3926        }
3927    }
3928
3929    if let Some(lang) = &eq_lang
3930        && !zone_maps.code_languages.contains(lang)
3931    {
3932        return true;
3933    }
3934    if let Some(depth) = eq_depth
3935        && depth > zone_maps.max_heading_depth
3936    {
3937        return true;
3938    }
3939    // Only safe when `block_type = 'heading'` is also required — `content`
3940    // alone could match a non-heading block.
3941    if let Some(content) = &eq_content
3942        && eq_block_type == Some(BlockType::Heading)
3943        && !zone_maps
3944            .heading_contents
3945            .iter()
3946            .any(|h| h.eq_ignore_ascii_case(content))
3947    {
3948        return true;
3949    }
3950
3951    false
3952}
3953
3954/// Strips redundant `(...)` wrappers so callers can pattern-match the inner
3955/// expression directly.
3956fn unwrap_nested(expr: &Expr) -> &Expr {
3957    match expr {
3958        Expr::Nested(inner) => unwrap_nested(inner),
3959        other => other,
3960    }
3961}
3962
3963fn limit_expr_of(query: &Query) -> Option<Expr> {
3964    query.limit_clause.as_ref().and_then(|lc| match lc {
3965        LimitClause::LimitOffset { limit, .. } => limit.clone(),
3966        LimitClause::OffsetCommaLimit { limit, .. } => Some(limit.clone()),
3967    })
3968}
3969
3970fn table_factor_ident(factor: &TableFactor) -> Option<String> {
3971    match factor {
3972        TableFactor::Table { name, .. } => {
3973            Some(name.0.last().map(ident_value).unwrap_or("").to_lowercase())
3974        }
3975        _ => None,
3976    }
3977}
3978
3979fn resolve_table_function(
3980    name: &str,
3981    args: &TableFunctionArgs,
3982    prefix: &str,
3983) -> Result<Vec<Row>, MqdbError> {
3984    let path = table_function_path_arg(name, args)?;
3985    match name {
3986        "read_csv" => read_csv_rows(&path, prefix),
3987        "read_json" => read_json_rows(&path, prefix),
3988        _ => Err(MqdbError::SqlExec(format!(
3989            "unknown table function: {name}"
3990        ))),
3991    }
3992}
3993
3994fn table_function_path_arg(name: &str, args: &TableFunctionArgs) -> Result<String, MqdbError> {
3995    let [FunctionArg::Unnamed(FunctionArgExpr::Expr(e))] = args.args.as_slice() else {
3996        return Err(MqdbError::SqlExec(format!(
3997            "{name}(path) expects exactly one string-literal argument"
3998        )));
3999    };
4000    expr_str_val(e)
4001        .ok_or_else(|| MqdbError::SqlExec(format!("{name}(path): path must be a string literal")))
4002}
4003
4004fn parse_csv(text: &str) -> Vec<Vec<String>> {
4005    let mut records = Vec::new();
4006    let mut record = Vec::new();
4007    let mut field = String::new();
4008    let mut in_quotes = false;
4009    let mut chars = text.chars().peekable();
4010
4011    while let Some(c) = chars.next() {
4012        if in_quotes {
4013            if c == '"' {
4014                if chars.peek() == Some(&'"') {
4015                    field.push('"');
4016                    chars.next();
4017                } else {
4018                    in_quotes = false;
4019                }
4020            } else {
4021                field.push(c);
4022            }
4023            continue;
4024        }
4025        match c {
4026            '"' => in_quotes = true,
4027            ',' => record.push(std::mem::take(&mut field)),
4028            '\r' => {}
4029            '\n' => {
4030                record.push(std::mem::take(&mut field));
4031                records.push(std::mem::take(&mut record));
4032            }
4033            _ => field.push(c),
4034        }
4035    }
4036    if !field.is_empty() || !record.is_empty() {
4037        record.push(field);
4038        records.push(record);
4039    }
4040    records
4041}
4042
4043fn read_csv_rows(path: &str, prefix: &str) -> Result<Vec<Row>, MqdbError> {
4044    let text = std::fs::read_to_string(path)
4045        .map_err(|e| MqdbError::SqlExec(format!("read_csv('{path}'): {e}")))?;
4046    let mut records = parse_csv(&text).into_iter();
4047    let header = records.next().unwrap_or_default();
4048    let rows = records
4049        .map(|record| {
4050            let values = header
4051                .iter()
4052                .enumerate()
4053                .map(|(i, _)| {
4054                    record
4055                        .get(i)
4056                        .map(|v| parse_display_value(v))
4057                        .unwrap_or(Value::Null)
4058                })
4059                .collect();
4060            qualify_row(
4061                Row {
4062                    columns: header.clone(),
4063                    values,
4064                },
4065                prefix,
4066            )
4067        })
4068        .collect();
4069    Ok(rows)
4070}
4071
4072fn json_to_value(v: &serde_json::Value) -> Value {
4073    match v {
4074        serde_json::Value::Null => Value::Null,
4075        serde_json::Value::Bool(b) => Value::Bool(*b),
4076        serde_json::Value::Number(n) => {
4077            if let Some(i) = n.as_i64() {
4078                Value::Int(i)
4079            } else if let Some(f) = n.as_f64() {
4080                Value::Float(f)
4081            } else {
4082                Value::Null
4083            }
4084        }
4085        serde_json::Value::String(s) => Value::Str(s.clone()),
4086        other => Value::Str(other.to_string()),
4087    }
4088}
4089
4090fn read_json_rows(path: &str, prefix: &str) -> Result<Vec<Row>, MqdbError> {
4091    let text = std::fs::read_to_string(path)
4092        .map_err(|e| MqdbError::SqlExec(format!("read_json('{path}'): {e}")))?;
4093
4094    let mut columns: Vec<String> = Vec::new();
4095    let mut objects: Vec<serde_json::Map<String, serde_json::Value>> = Vec::new();
4096    for (i, line) in text.lines().enumerate() {
4097        let line = line.trim();
4098        if line.is_empty() {
4099            continue;
4100        }
4101        let value: serde_json::Value = serde_json::from_str(line)
4102            .map_err(|e| MqdbError::SqlExec(format!("read_json('{path}'): line {}: {e}", i + 1)))?;
4103        let serde_json::Value::Object(obj) = value else {
4104            return Err(MqdbError::SqlExec(format!(
4105                "read_json('{path}'): line {}: expected a JSON object per line",
4106                i + 1
4107            )));
4108        };
4109        for key in obj.keys() {
4110            if !columns.iter().any(|c| c == key) {
4111                columns.push(key.clone());
4112            }
4113        }
4114        objects.push(obj);
4115    }
4116
4117    let rows = objects
4118        .into_iter()
4119        .map(|obj| {
4120            let values = columns
4121                .iter()
4122                .map(|c| obj.get(c).map(json_to_value).unwrap_or(Value::Null))
4123                .collect();
4124            qualify_row(
4125                Row {
4126                    columns: columns.clone(),
4127                    values,
4128                },
4129                prefix,
4130            )
4131        })
4132        .collect();
4133    Ok(rows)
4134}
4135
4136const MAX_RECURSIVE_CTE_ITERATIONS: usize = 10_000;
4137
4138fn select_references_table(select: &Select, name: &str) -> bool {
4139    select.from.iter().any(|twj| {
4140        table_factor_ident(&twj.relation).as_deref() == Some(name)
4141            || twj
4142                .joins
4143                .iter()
4144                .any(|j| table_factor_ident(&j.relation).as_deref() == Some(name))
4145    })
4146}
4147
4148fn describe_hint(hint: &IndexHint) -> String {
4149    match hint {
4150        IndexHint::BlockType(types) => format!(
4151            "BitmapIndex(block_type IN ({}))",
4152            types
4153                .iter()
4154                .map(|t| t.as_str())
4155                .collect::<Vec<_>>()
4156                .join(", ")
4157        ),
4158        IndexHint::PreExact(n) => format!("BTreeIndex(pre = {n})"),
4159        IndexHint::PreRange(lo, hi) => format!("BTreeIndex(pre BETWEEN {lo} AND {hi})"),
4160        IndexHint::ContentExact(s) => format!("HashIndex(content = '{s}')"),
4161        IndexHint::LangExact(s) => format!("HashIndex(lang = '{s}')"),
4162        IndexHint::DepthExact(d) => format!("HashIndex(depth = {d})"),
4163        IndexHint::TermMatch(terms) => format!("TermIndex(match: {})", terms.join(", ")),
4164        IndexHint::FullScan => "full scan".to_string(),
4165    }
4166}
4167
4168fn zone_map_candidate_fields(where_expr: &Expr) -> Vec<&'static str> {
4169    let mut eq_block_type: Option<BlockType> = None;
4170    let mut has_content = false;
4171    let mut fields = Vec::new();
4172
4173    for conjunct in flatten_and_conjuncts(where_expr) {
4174        let Expr::BinaryOp {
4175            left,
4176            op: BinaryOperator::Eq,
4177            right,
4178        } = conjunct
4179        else {
4180            continue;
4181        };
4182        let col = expr_col_name(left).or_else(|| expr_col_name(right));
4183        let val = expr_str_val(right).or_else(|| expr_str_val(left));
4184        let int_val = expr_int_val(right).or_else(|| expr_int_val(left));
4185
4186        match col.as_deref() {
4187            Some("block_type") => {
4188                if let Some(s) = val.as_deref()
4189                    && let Some(bt) = BlockType::from_str(s)
4190                {
4191                    eq_block_type = Some(bt);
4192                }
4193            }
4194            Some("lang") => {
4195                if let Some(s) = val
4196                    && !s.is_empty()
4197                {
4198                    fields.push("lang");
4199                }
4200            }
4201            Some("depth") => {
4202                if let Some(n) = int_val
4203                    && n > 0
4204                {
4205                    fields.push("depth");
4206                }
4207            }
4208            Some("content") => has_content = true,
4209            _ => {}
4210        }
4211    }
4212
4213    if has_content && eq_block_type == Some(BlockType::Heading) {
4214        fields.push("heading content");
4215    }
4216    fields
4217}
4218
4219fn describe_join_strategy(on: &Expr) -> String {
4220    for conjunct in flatten_and_conjuncts(on) {
4221        if let Expr::BinaryOp {
4222            left,
4223            op: BinaryOperator::Eq,
4224            right,
4225        } = conjunct
4226            && expr_col_name(left).is_some()
4227            && expr_col_name(right).is_some()
4228        {
4229            return format!("hash join on {left} = {right}");
4230        }
4231    }
4232    "nested loop (cross join + filter)".to_string()
4233}
4234
4235/// True if `twj`'s relation is the real `blocks` table — not a `WITH`-clause
4236/// CTE of the same name shadowing it — which is what `table_rows_with_hint`
4237/// actually applies [`IndexHint`]s to. Used to gate skipping the WHERE
4238/// row-by-row recheck: that's only sound when the index was truly consulted.
4239fn from_names_unshadowed_blocks(
4240    twj: &TableWithJoins,
4241    cte_scopes: &[FxHashMap<String, std::rc::Rc<QueryOutput>>],
4242) -> bool {
4243    let TableFactor::Table { name, .. } = &twj.relation else {
4244        return false;
4245    };
4246    if name.0.last().map(ident_value).unwrap_or("").to_lowercase() != "blocks" {
4247        return false;
4248    }
4249    !cte_scopes.iter().any(|scope| scope.contains_key("blocks"))
4250}
4251
4252/// Recognises a single conjunct's index-hint shape — no `AND` handling (see
4253/// [`candidate_hints_for_where`] for combining multiple conjuncts). The full
4254/// WHERE predicate is still evaluated row-by-row after pre-filtering, so a
4255/// false positive from an index lookup is harmless (but there shouldn't be
4256/// any).
4257///
4258/// Patterns recognised:
4259/// - `block_type = 'X'` → [`IndexHint::BlockType`]
4260/// - `block_type IN ('X','Y',...)` → [`IndexHint::BlockType`] (union)
4261/// - `pre = N` → [`IndexHint::PreExact`]
4262/// - `pre BETWEEN lo AND hi` → [`IndexHint::PreRange`]
4263/// - `content = 'X'` → [`IndexHint::ContentExact`]
4264/// - `lang = 'X'` → [`IndexHint::LangExact`]
4265/// - `depth = N` → [`IndexHint::DepthExact`]
4266/// - `match(content, 'terms')` → [`IndexHint::TermMatch`]
4267fn hint_for_conjunct(expr: &Expr) -> IndexHint {
4268    match expr {
4269        // col = 'value'
4270        Expr::BinaryOp {
4271            left,
4272            op: BinaryOperator::Eq,
4273            right,
4274        } => {
4275            let col = expr_col_name(left).or_else(|| expr_col_name(right));
4276            let val = expr_str_val(right).or_else(|| expr_str_val(left));
4277            let int_val = expr_int_val(right).or_else(|| expr_int_val(left));
4278
4279            match col.as_deref() {
4280                Some("block_type") => {
4281                    if let Some(s) = val
4282                        && let Some(bt) = BlockType::from_str(&s)
4283                    {
4284                        return IndexHint::BlockType(vec![bt]);
4285                    }
4286                    IndexHint::FullScan
4287                }
4288                Some("pre") => {
4289                    if let Some(n) = int_val {
4290                        return IndexHint::PreExact(n as u32);
4291                    }
4292                    IndexHint::FullScan
4293                }
4294                Some("content") => {
4295                    if let Some(s) = val {
4296                        return IndexHint::ContentExact(s);
4297                    }
4298                    IndexHint::FullScan
4299                }
4300                Some("lang") => {
4301                    if let Some(s) = val
4302                        && !s.is_empty()
4303                    {
4304                        return IndexHint::LangExact(s);
4305                    }
4306                    IndexHint::FullScan
4307                }
4308                Some("depth") => {
4309                    if let Some(n) = int_val {
4310                        // depth 0 means "no heading depth" — not in the index
4311                        if n > 0 {
4312                            return IndexHint::DepthExact(n as u8);
4313                        }
4314                    }
4315                    IndexHint::FullScan
4316                }
4317                _ => IndexHint::FullScan,
4318            }
4319        }
4320        // block_type IN ('heading', 'code')
4321        Expr::InList {
4322            expr,
4323            list,
4324            negated: false,
4325        } => {
4326            if expr_col_name(expr).as_deref() == Some("block_type") {
4327                let types: Vec<BlockType> = list
4328                    .iter()
4329                    .filter_map(expr_str_val)
4330                    .filter_map(|s| BlockType::from_str(&s))
4331                    .collect();
4332                if !types.is_empty() {
4333                    return IndexHint::BlockType(types);
4334                }
4335            }
4336            IndexHint::FullScan
4337        }
4338        // pre BETWEEN lo AND hi
4339        Expr::Between {
4340            expr,
4341            negated: false,
4342            low,
4343            high,
4344        } => {
4345            if expr_col_name(expr).as_deref() == Some("pre")
4346                && let (Some(lo), Some(hi)) = (expr_int_val(low), expr_int_val(high))
4347            {
4348                return IndexHint::PreRange(lo as u32, hi as u32);
4349            }
4350            IndexHint::FullScan
4351        }
4352        // match(content, 'query terms') used directly as a boolean predicate
4353        // (unlike the other arms above, this isn't wrapped in a BinaryOp).
4354        Expr::Function(_) => match match_terms_from_expr(expr) {
4355            Some(terms) => IndexHint::TermMatch(terms),
4356            None => IndexHint::FullScan,
4357        },
4358        Expr::Nested(inner) => hint_for_conjunct(inner),
4359        _ => IndexHint::FullScan,
4360    }
4361}
4362
4363/// Every viable (non-[`IndexHint::FullScan`]) index-hint candidate for
4364/// `expr`'s conjuncts. The actual choice among them is cost-based — see
4365/// [`SqlEngine::choose_best_hint`].
4366fn candidate_hints_for_where(expr: &Expr) -> Vec<IndexHint> {
4367    flatten_and_conjuncts(expr)
4368        .into_iter()
4369        .map(hint_for_conjunct)
4370        .filter(|h| !matches!(h, IndexHint::FullScan))
4371        .collect()
4372}
4373
4374/// Recognises `match(content, 'query terms')` and returns its tokenized
4375/// query terms, or `None` if `expr` isn't that exact shape (wrong function
4376/// name, wrong column, or a non-literal query argument). Shared by
4377/// [`hint_for_conjunct`] (to build the [`IndexHint::TermMatch`] hint)
4378/// and [`zone_map_skip`] (to rule out whole documents via the term bloom
4379/// filter) so the two stay in lockstep.
4380fn match_terms_from_expr(expr: &Expr) -> Option<Vec<String>> {
4381    let Expr::Function(f) = expr else {
4382        return None;
4383    };
4384    let name = f
4385        .name
4386        .0
4387        .last()
4388        .map(ident_value)
4389        .unwrap_or("")
4390        .to_lowercase();
4391    if name != "match" {
4392        return None;
4393    }
4394    let FunctionArguments::List(al) = &f.args else {
4395        return None;
4396    };
4397    let [
4398        FunctionArg::Unnamed(FunctionArgExpr::Expr(col)),
4399        FunctionArg::Unnamed(FunctionArgExpr::Expr(q)),
4400    ] = al.args.as_slice()
4401    else {
4402        return None;
4403    };
4404    if expr_col_name(col).as_deref() != Some("content") {
4405        return None;
4406    }
4407    let query_str = expr_str_val(q)?;
4408    let terms = tokenize(&query_str);
4409    if terms.is_empty() { None } else { Some(terms) }
4410}
4411
4412/// Returns the column name if the expression is a bare identifier or `alias.col`.
4413fn expr_col_name(expr: &Expr) -> Option<String> {
4414    match expr {
4415        Expr::Identifier(i) => Some(i.value.to_lowercase()),
4416        Expr::CompoundIdentifier(parts) => parts.last().map(|i| i.value.to_lowercase()),
4417        _ => None,
4418    }
4419}
4420
4421fn expr_str_val(expr: &Expr) -> Option<String> {
4422    match expr {
4423        Expr::Value(v) => match &v.value {
4424            SqlValue::SingleQuotedString(s) | SqlValue::DoubleQuotedString(s) => Some(s.clone()),
4425            _ => None,
4426        },
4427        _ => None,
4428    }
4429}
4430
4431fn expr_int_val(expr: &Expr) -> Option<i64> {
4432    match expr {
4433        Expr::Value(v) => match &v.value {
4434            SqlValue::Number(n, _) => n.parse::<i64>().ok(),
4435            _ => None,
4436        },
4437        _ => None,
4438    }
4439}
4440
4441/// Pick the more selective of two hints (prefer specific types over FullScan).
4442impl BlockType {
4443    fn from_str(s: &str) -> Option<Self> {
4444        match s {
4445            "heading" => Some(BlockType::Heading),
4446            "paragraph" => Some(BlockType::Paragraph),
4447            "code" => Some(BlockType::Code),
4448            "list" => Some(BlockType::List),
4449            "table_cell" => Some(BlockType::TableCell),
4450            "table_row" => Some(BlockType::TableRow),
4451            "table_align" => Some(BlockType::TableAlign),
4452            "blockquote" => Some(BlockType::Blockquote),
4453            "horizontal_rule" => Some(BlockType::HorizontalRule),
4454            "html" => Some(BlockType::Html),
4455            "yaml" => Some(BlockType::Yaml),
4456            "toml" => Some(BlockType::Toml),
4457            "math" => Some(BlockType::Math),
4458            "definition" => Some(BlockType::Definition),
4459            "footnote" => Some(BlockType::Footnote),
4460            _ => None,
4461        }
4462    }
4463}
4464
4465#[cfg(test)]
4466mod tests {
4467    use super::*;
4468    use crate::DocumentStore;
4469    use rstest::rstest;
4470
4471    fn make_store() -> DocumentStore {
4472        let mut s = DocumentStore::new();
4473        s.add_str(
4474            "# Doc\n\n## Architecture\n\nDetails\n\n```rust\nfn main(){}\n```\n\n## Other\n\nOther\n",
4475        )
4476        .unwrap();
4477        s
4478    }
4479
4480    // Doc B (no code, depth 1) sits between two rust/depth-3 docs.
4481    fn make_multi_doc_store() -> DocumentStore {
4482        let mut s = DocumentStore::new();
4483        s.add_str("# A\n\n```rust\nfn a(){}\n```\n").unwrap();
4484        s.add_str("# B\n\nParagraph\n").unwrap();
4485        s.add_str("# C\n\n## C2\n\n### C3\n\n```rust\nfn c(){}\n```\n")
4486            .unwrap();
4487        s
4488    }
4489
4490    #[test]
4491    fn test_sql_select_all_blocks() {
4492        let store = make_store();
4493        let engine = SqlEngine::new(&store).unwrap();
4494        let out = engine
4495            .execute("SELECT block_type, content FROM blocks ORDER BY pre")
4496            .unwrap();
4497        assert!(!out.rows.is_empty());
4498    }
4499
4500    #[test]
4501    fn test_sql_heading_filter() {
4502        let store = make_store();
4503        let engine = SqlEngine::new(&store).unwrap();
4504        let out = engine
4505            .execute("SELECT content FROM blocks WHERE block_type = 'heading' ORDER BY pre")
4506            .unwrap();
4507        assert_eq!(out.rows.len(), 3);
4508    }
4509
4510    #[test]
4511    fn test_sql_under_function() {
4512        let store = make_store();
4513        let engine = SqlEngine::new(&store).unwrap();
4514        let out = engine
4515            .execute(
4516                "SELECT b.content FROM blocks b
4517             WHERE under(b.pre, b.post,
4518               (SELECT pre FROM blocks WHERE block_type='heading' AND content='Architecture'),
4519               (SELECT post FROM blocks WHERE block_type='heading' AND content='Architecture')
4520             )",
4521            )
4522            .unwrap();
4523        assert_eq!(out.rows.len(), 2);
4524    }
4525
4526    #[test]
4527    fn test_query_output_table() {
4528        let out = QueryOutput {
4529            columns: vec!["id".to_string(), "type".to_string()],
4530            rows: vec![
4531                vec!["1".to_string(), "heading".to_string()],
4532                vec!["2".to_string(), "paragraph".to_string()],
4533            ],
4534        };
4535        let table = out.to_table();
4536        assert!(table.contains("heading"));
4537        assert!(table.contains("paragraph"));
4538        assert!(table.contains("2 rows"));
4539    }
4540
4541    #[test]
4542    fn test_sql_count_aggregate() {
4543        let store = make_store();
4544        let engine = SqlEngine::new(&store).unwrap();
4545        let out = engine
4546            .execute("SELECT count(*) FROM blocks WHERE block_type = 'heading'")
4547            .unwrap();
4548        assert_eq!(out.rows.len(), 1);
4549        assert_eq!(out.rows[0][0], "3");
4550    }
4551
4552    #[test]
4553    fn test_sql_count_with_grouped_column() {
4554        let store = make_store();
4555        let engine = SqlEngine::new(&store).unwrap();
4556        let out = engine
4557            .execute("SELECT block_type, count(*) FROM blocks GROUP BY block_type")
4558            .unwrap();
4559        assert!(!out.rows.is_empty());
4560    }
4561
4562    #[test]
4563    fn test_sql_count_with_ungrouped_column_errors() {
4564        let store = make_store();
4565        let engine = SqlEngine::new(&store).unwrap();
4566        let err = engine
4567            .execute("SELECT count(*), content FROM blocks")
4568            .unwrap_err();
4569        assert!(err.to_string().contains("GROUP BY"));
4570    }
4571
4572    #[test]
4573    fn test_sql_limit() {
4574        let store = make_store();
4575        let engine = SqlEngine::new(&store).unwrap();
4576        let out = engine
4577            .execute("SELECT content FROM blocks LIMIT 2")
4578            .unwrap();
4579        assert_eq!(out.rows.len(), 2);
4580    }
4581
4582    #[test]
4583    fn test_sql_like() {
4584        let store = make_store();
4585        let engine = SqlEngine::new(&store).unwrap();
4586        let out = engine
4587            .execute("SELECT content FROM blocks WHERE content LIKE '%chitect%'")
4588            .unwrap();
4589        assert!(!out.rows.is_empty());
4590    }
4591
4592    #[test]
4593    fn test_sql_order_by_desc() {
4594        let store = make_store();
4595        let engine = SqlEngine::new(&store).unwrap();
4596        let out = engine
4597            .execute("SELECT content FROM blocks ORDER BY pre DESC LIMIT 1")
4598            .unwrap();
4599        assert_eq!(out.rows.len(), 1);
4600    }
4601
4602    #[test]
4603    fn test_sql_engine_zero_copy() {
4604        let mut store = DocumentStore::new();
4605        for _ in 0..100 {
4606            store.add_str("# Heading\n\nParagraph text\n").unwrap();
4607        }
4608        let start = std::time::Instant::now();
4609        let _engine = SqlEngine::new(&store).unwrap();
4610        let elapsed = start.elapsed();
4611        // Bound loosened from 1ms to 5ms when `TermIndex` (a fourth
4612        // per-document index) was added — still catches anything
4613        // pathological (e.g. accidental file I/O or O(n^2) behaviour) while
4614        // tolerating cold-start allocator/thread warmup noise on the first
4615        // test invocation in a fresh process.
4616        assert!(
4617            elapsed.as_micros() < 5000,
4618            "SqlEngine::new took {}us — should be cheap",
4619            elapsed.as_micros()
4620        );
4621    }
4622
4623    // make_store() produces:
4624    //   "# Doc\n\n## Architecture\n\nDetails\n\n```rust\nfn main(){}\n```\n\n## Other\n\nOther\n"
4625    // → heading×3, paragraph×2, code×1  (6 blocks total)
4626
4627    #[rstest]
4628    #[case("SELECT content FROM blocks WHERE block_type = 'heading'", 3)]
4629    #[case("SELECT content FROM blocks WHERE block_type = 'paragraph'", 2)]
4630    #[case("SELECT content FROM blocks WHERE block_type = 'code'", 1)]
4631    #[case("SELECT content FROM blocks WHERE block_type = 'list'", 0)]
4632    fn test_sql_where_block_type_param(#[case] sql: &str, #[case] expected: usize) {
4633        let store = make_store();
4634        let engine = SqlEngine::new(&store).unwrap();
4635        assert_eq!(engine.execute(sql).unwrap().rows.len(), expected);
4636    }
4637
4638    #[rstest]
4639    #[case("SELECT content FROM blocks WHERE content LIKE '%Doc%'", 1)]
4640    #[case("SELECT content FROM blocks WHERE content LIKE '%chitect%'", 1)]
4641    #[case("SELECT content FROM blocks WHERE content LIKE '%Other%'", 2)]
4642    #[case("SELECT content FROM blocks WHERE content LIKE '%Details%'", 1)]
4643    #[case("SELECT content FROM blocks WHERE content LIKE '%nonexistent%'", 0)]
4644    fn test_sql_like_pattern_param(#[case] sql: &str, #[case] expected: usize) {
4645        let store = make_store();
4646        let engine = SqlEngine::new(&store).unwrap();
4647        assert_eq!(engine.execute(sql).unwrap().rows.len(), expected);
4648    }
4649
4650    #[rstest]
4651    #[case("SELECT content FROM blocks LIMIT 1", 1)]
4652    #[case("SELECT content FROM blocks LIMIT 3", 3)]
4653    #[case("SELECT content FROM blocks LIMIT 5", 5)]
4654    #[case("SELECT content FROM blocks LIMIT 1000", 6)]
4655    fn test_sql_limit_row_count_param(#[case] sql: &str, #[case] expected: usize) {
4656        let store = make_store();
4657        let engine = SqlEngine::new(&store).unwrap();
4658        assert_eq!(engine.execute(sql).unwrap().rows.len(), expected);
4659    }
4660
4661    #[rstest]
4662    #[case("SELECT count(*) FROM blocks", "6")]
4663    #[case("SELECT count(*) FROM blocks WHERE block_type = 'heading'", "3")]
4664    #[case("SELECT count(*) FROM blocks WHERE block_type = 'code'", "1")]
4665    fn test_sql_count_aggregate_param(#[case] sql: &str, #[case] expected: &str) {
4666        let store = make_store();
4667        let engine = SqlEngine::new(&store).unwrap();
4668        let out = engine.execute(sql).unwrap();
4669        assert_eq!(out.rows.len(), 1);
4670        assert_eq!(out.rows[0][0], expected);
4671    }
4672
4673    // depth = 0 should return all non-heading blocks (paragraphs + code), not 0 rows
4674    #[test]
4675    fn test_sql_depth_zero_returns_non_headings() {
4676        let store = make_store();
4677        let engine = SqlEngine::new(&store).unwrap();
4678        let out = engine
4679            .execute("SELECT content FROM blocks WHERE depth = 0")
4680            .unwrap();
4681        // make_store has 2 paragraphs + 1 code block = 3 non-heading blocks
4682        assert_eq!(out.rows.len(), 3, "depth=0 must return non-heading blocks");
4683    }
4684
4685    // lang = '' should return non-code blocks (paragraph, heading blocks have empty lang)
4686    #[test]
4687    fn test_sql_empty_lang_returns_non_code_blocks() {
4688        let store = make_store();
4689        let engine = SqlEngine::new(&store).unwrap();
4690        let out = engine
4691            .execute("SELECT block_type FROM blocks WHERE lang = ''")
4692            .unwrap();
4693        // make_store: 3 headings + 2 paragraphs = 5 blocks with no lang
4694        assert_eq!(out.rows.len(), 5, "lang='' must return non-code blocks");
4695    }
4696
4697    // to_table() must not let newlines inside cells break the table row structure
4698    #[test]
4699    fn test_to_table_newline_in_cell() {
4700        let out = QueryOutput {
4701            columns: vec!["content".to_string()],
4702            rows: vec![
4703                vec!["line one\nline two".to_string()],
4704                vec!["plain".to_string()],
4705            ],
4706        };
4707        let table = out.to_table();
4708        // Lines that start with '│' = header + 2 data rows = 3 (no extra split)
4709        let bar_lines: Vec<&str> = table.lines().filter(|l| l.starts_with('│')).collect();
4710        assert_eq!(
4711            bar_lines.len(),
4712            3,
4713            "newline in cell must not produce extra table rows"
4714        );
4715        // The first data row (index 1, after the header) must contain the normalised content
4716        assert!(bar_lines[1].contains("line one line two"));
4717    }
4718
4719    // register_table / custom table query
4720    #[test]
4721    fn test_custom_table_query() {
4722        let mut store = DocumentStore::new();
4723        store.register_table(
4724            "kv",
4725            vec!["key".to_string(), "value".to_string()],
4726            vec![
4727                vec!["foo".to_string(), "bar".to_string()],
4728                vec!["hello".to_string(), "world".to_string()],
4729            ],
4730        );
4731        let engine = SqlEngine::new(&store).unwrap();
4732        let out = engine
4733            .execute("SELECT key, value FROM kv WHERE key = 'hello'")
4734            .unwrap();
4735        assert_eq!(out.rows.len(), 1);
4736        assert_eq!(out.rows[0][1], "world");
4737    }
4738
4739    // CREATE TABLE (empty) then INSERT then SELECT
4740    #[test]
4741    fn test_ddl_create_insert_select() {
4742        let store = DocumentStore::new();
4743        let engine = SqlEngine::new(&store).unwrap();
4744
4745        // create
4746        engine
4747            .execute("CREATE TABLE notes (id TEXT, body TEXT)")
4748            .unwrap();
4749        // insert two rows
4750        engine
4751            .execute("INSERT INTO notes VALUES ('1', 'hello')")
4752            .unwrap();
4753        engine
4754            .execute("INSERT INTO notes VALUES ('2', 'world')")
4755            .unwrap();
4756        // select with filter
4757        let out = engine
4758            .execute("SELECT body FROM notes WHERE id = '1'")
4759            .unwrap();
4760        assert_eq!(out.rows.len(), 1);
4761        assert_eq!(out.rows[0][0], "hello");
4762        // total rows
4763        let all = engine.execute("SELECT * FROM notes").unwrap();
4764        assert_eq!(all.rows.len(), 2);
4765    }
4766
4767    // CREATE TABLE AS SELECT
4768    #[test]
4769    fn test_ddl_create_as_select() {
4770        let store = {
4771            let mut s = DocumentStore::new();
4772            s.add_str("# H1\n\n## H2\n\nParagraph\n").unwrap();
4773            s
4774        };
4775        let engine = SqlEngine::new(&store).unwrap();
4776        engine
4777            .execute(
4778                "CREATE TABLE headings AS \
4779                 SELECT block_type, content FROM blocks WHERE block_type = 'heading'",
4780            )
4781            .unwrap();
4782        let out = engine.execute("SELECT content FROM headings").unwrap();
4783        assert_eq!(out.rows.len(), 2);
4784    }
4785
4786    // DROP TABLE
4787    #[test]
4788    fn test_ddl_drop_table() {
4789        let store = DocumentStore::new();
4790        let engine = SqlEngine::new(&store).unwrap();
4791        engine.execute("CREATE TABLE tmp (x TEXT)").unwrap();
4792        engine.execute("DROP TABLE tmp").unwrap();
4793        let err = engine.execute("SELECT * FROM tmp").unwrap_err();
4794        assert!(err.to_string().contains("unknown table"));
4795    }
4796
4797    // DROP TABLE IF EXISTS (must not error on missing table)
4798    #[test]
4799    fn test_ddl_drop_if_exists() {
4800        let store = DocumentStore::new();
4801        let engine = SqlEngine::new(&store).unwrap();
4802        engine
4803            .execute("DROP TABLE IF EXISTS no_such_table")
4804            .unwrap();
4805    }
4806
4807    // DESC blocks (built-in)
4808    #[test]
4809    fn test_desc_builtin() {
4810        let store = DocumentStore::new();
4811        let engine = SqlEngine::new(&store).unwrap();
4812        let out = engine.execute("DESC blocks").unwrap();
4813        assert_eq!(out.columns, vec!["column", "type"]);
4814        assert!(out.rows.iter().any(|r| r[0] == "block_type"));
4815        assert!(out.rows.iter().any(|r| r[0] == "content"));
4816    }
4817
4818    // DESC custom table
4819    #[test]
4820    fn test_desc_custom() {
4821        let store = DocumentStore::new();
4822        let engine = SqlEngine::new(&store).unwrap();
4823        engine
4824            .execute("CREATE TABLE meta (k TEXT, v TEXT)")
4825            .unwrap();
4826        let out = engine.execute("DESC meta").unwrap();
4827        assert_eq!(out.rows.len(), 2);
4828        assert_eq!(out.rows[0][0], "k");
4829        assert_eq!(out.rows[1][0], "v");
4830    }
4831
4832    // SHOW TABLES
4833    #[test]
4834    fn test_show_tables() {
4835        let store = DocumentStore::new();
4836        let engine = SqlEngine::new(&store).unwrap();
4837        engine.execute("CREATE TABLE extra (a TEXT)").unwrap();
4838        let out = engine.execute("SHOW TABLES").unwrap();
4839        let names: Vec<&str> = out.rows.iter().map(|r| r[0].as_str()).collect();
4840        assert!(names.contains(&"blocks"));
4841        assert!(names.contains(&"documents"));
4842        assert!(names.contains(&"extra"));
4843    }
4844
4845    // mq() scalar function applied to a literal markdown string
4846    #[test]
4847    fn test_mq_scalar_function() {
4848        let store = make_store();
4849        let engine = SqlEngine::new(&store).unwrap();
4850        let out = engine
4851            .execute(
4852                "SELECT mq('.h1 | to_text', '# Hello\n\nWorld\n') AS title FROM blocks LIMIT 1",
4853            )
4854            .unwrap();
4855        assert_eq!(out.rows.len(), 1);
4856        assert_eq!(out.rows[0][0], "Hello");
4857    }
4858
4859    // mq() returns NULL when program produces no output
4860    #[test]
4861    fn test_mq_scalar_null_on_no_match() {
4862        let store = make_store();
4863        let engine = SqlEngine::new(&store).unwrap();
4864        let out = engine
4865            .execute("SELECT mq('.h1', '## No h1 here\n') FROM blocks LIMIT 1")
4866            .unwrap();
4867        assert_eq!(out.rows.len(), 1);
4868        assert_eq!(out.rows[0][0], "NULL");
4869    }
4870
4871    #[test]
4872    fn match_function_true_for_all_terms_present() {
4873        let store = DocumentStore::new();
4874        let engine = SqlEngine::new(&store).unwrap();
4875        let out = engine
4876            .execute("SELECT match('The quick brown fox', 'quick fox')")
4877            .unwrap();
4878        assert_eq!(out.rows[0][0], "true");
4879    }
4880
4881    #[test]
4882    fn match_function_false_if_any_term_missing() {
4883        let store = DocumentStore::new();
4884        let engine = SqlEngine::new(&store).unwrap();
4885        let out = engine
4886            .execute("SELECT match('The quick brown fox', 'quick zebra')")
4887            .unwrap();
4888        assert_eq!(out.rows[0][0], "false");
4889    }
4890
4891    #[test]
4892    fn match_function_case_insensitive() {
4893        let store = DocumentStore::new();
4894        let engine = SqlEngine::new(&store).unwrap();
4895        let out = engine
4896            .execute("SELECT match('Rust Programming', 'rust')")
4897            .unwrap();
4898        assert_eq!(out.rows[0][0], "true");
4899    }
4900
4901    #[test]
4902    fn score_function_ranks_denser_matches_higher() {
4903        let mut store = DocumentStore::new();
4904        store
4905            .add_str("# Doc\n\nrust rust rust other words here\n\nrust is fine\n")
4906            .unwrap();
4907        let engine = SqlEngine::new(&store).unwrap();
4908        let out = engine
4909            .execute(
4910                "SELECT content FROM blocks WHERE block_type = 'paragraph'
4911                 ORDER BY score(content, 'rust') DESC",
4912            )
4913            .unwrap();
4914        assert_eq!(out.rows[0][0], "rust rust rust other words here");
4915    }
4916
4917    #[test]
4918    fn where_match_uses_term_match_index_hint() {
4919        let stmts = Parser::parse_sql(
4920            &GenericDialect {},
4921            "SELECT * FROM blocks WHERE match(content, 'foo bar')",
4922        )
4923        .unwrap();
4924        let Statement::Query(q) = stmts.into_iter().next().unwrap() else {
4925            panic!("expected query")
4926        };
4927        let SetExpr::Select(select) = q.body.as_ref() else {
4928            panic!("expected select")
4929        };
4930        let candidates = candidate_hints_for_where(select.selection.as_ref().unwrap());
4931        assert_eq!(
4932            candidates,
4933            vec![IndexHint::TermMatch(vec![
4934                "foo".to_string(),
4935                "bar".to_string()
4936            ])]
4937        );
4938    }
4939
4940    #[test]
4941    fn where_match_and_block_type_combines_hints() {
4942        let store = make_store();
4943        let engine = SqlEngine::new(&store).unwrap();
4944        let out = engine
4945            .execute(
4946                "SELECT content FROM blocks
4947                 WHERE match(content, 'architecture') AND block_type = 'heading'",
4948            )
4949            .unwrap();
4950        assert_eq!(out.rows, vec![vec!["Architecture".to_string()]]);
4951    }
4952
4953    #[test]
4954    fn where_bare_match_skips_recheck_but_result_is_still_correct() {
4955        // "architecture" only tokenizes out of the heading block, so if the
4956        // recheck-skip path (bare `match()` fully covering WHERE) somehow
4957        // returned a false positive, this would catch it.
4958        let store = make_store();
4959        let engine = SqlEngine::new(&store).unwrap();
4960        let out = engine
4961            .execute("SELECT content FROM blocks WHERE match(content, 'architecture')")
4962            .unwrap();
4963        assert_eq!(out.rows, vec![vec!["Architecture".to_string()]]);
4964    }
4965
4966    #[test]
4967    fn where_bare_match_still_rechecked_when_blocks_shadowed_by_cte() {
4968        // A CTE named `blocks` shadows the real table, so `table_rows_with_hint`
4969        // never consults the TermIndex for it — the recheck-skip path must
4970        // not fire here, or a CTE that fabricates non-matching content would
4971        // slip through uncaught.
4972        let store = make_store();
4973        let engine = SqlEngine::new(&store).unwrap();
4974        let out = engine
4975            .execute(
4976                "WITH blocks AS (SELECT 'no match here' AS content)
4977                 SELECT content FROM blocks WHERE match(content, 'architecture')",
4978            )
4979            .unwrap();
4980        assert!(out.rows.is_empty());
4981    }
4982
4983    #[test]
4984    fn cost_based_planner_picks_cheaper_of_two_candidates() {
4985        let mut store = DocumentStore::new();
4986        store
4987            .add_str("# Doc\n\nP1\n\nP2\n\nP3\n\nP4\n\n```rust\nfn main(){}\n```\n")
4988            .unwrap();
4989        let engine = SqlEngine::new(&store).unwrap();
4990
4991        let stmts = Parser::parse_sql(
4992            &GenericDialect {},
4993            "SELECT * FROM blocks WHERE block_type = 'paragraph' AND lang = 'rust'",
4994        )
4995        .unwrap();
4996        let Statement::Query(q) = stmts.into_iter().next().unwrap() else {
4997            panic!("expected query")
4998        };
4999        let SetExpr::Select(select) = q.body.as_ref() else {
5000            panic!("expected select")
5001        };
5002        let candidates = candidate_hints_for_where(select.selection.as_ref().unwrap());
5003        assert_eq!(candidates.len(), 2);
5004
5005        // 4 paragraphs vs. 1 rust code block — the lang lookup is cheaper.
5006        let chosen = engine.choose_best_hint(candidates);
5007        assert_eq!(chosen, IndexHint::LangExact("rust".to_string()));
5008    }
5009
5010    #[test]
5011    fn cost_based_planner_tie_breaks_deterministically() {
5012        let mut store = DocumentStore::new();
5013        store.add_str("# Title\n\nBody\n").unwrap();
5014        let engine = SqlEngine::new(&store).unwrap();
5015
5016        let stmts = Parser::parse_sql(
5017            &GenericDialect {},
5018            "SELECT * FROM blocks WHERE block_type = 'heading' AND depth = 1",
5019        )
5020        .unwrap();
5021        let Statement::Query(q) = stmts.into_iter().next().unwrap() else {
5022            panic!("expected query")
5023        };
5024        let SetExpr::Select(select) = q.body.as_ref() else {
5025            panic!("expected select")
5026        };
5027        let candidates = candidate_hints_for_where(select.selection.as_ref().unwrap());
5028        assert_eq!(candidates.len(), 2);
5029
5030        // Both candidates match exactly one block (the single H1) — equal
5031        // cost, so the first-encountered candidate (BlockType) must win,
5032        // consistently across repeated calls.
5033        let first = engine.choose_best_hint(candidates.clone());
5034        let second = engine.choose_best_hint(candidates);
5035        assert_eq!(first, second);
5036        assert_eq!(first, IndexHint::BlockType(vec![BlockType::Heading]));
5037    }
5038
5039    #[test]
5040    fn where_match_full_scan_fallback_when_query_not_literal() {
5041        let stmts = Parser::parse_sql(
5042            &GenericDialect {},
5043            "SELECT * FROM blocks WHERE match(content, lang)",
5044        )
5045        .unwrap();
5046        let Statement::Query(q) = stmts.into_iter().next().unwrap() else {
5047            panic!("expected query")
5048        };
5049        let SetExpr::Select(select) = q.body.as_ref() else {
5050            panic!("expected select")
5051        };
5052        let candidates = candidate_hints_for_where(select.selection.as_ref().unwrap());
5053        assert_eq!(candidates, Vec::<IndexHint>::new());
5054    }
5055
5056    fn eval_one(sql: &str) -> String {
5057        let store = DocumentStore::new();
5058        let engine = SqlEngine::new(&store).unwrap();
5059        engine.execute(sql).unwrap().rows[0][0].clone()
5060    }
5061
5062    #[rstest]
5063    // string functions
5064    #[case("SELECT lower('Hello')", "hello")]
5065    #[case("SELECT upper('Hello')", "HELLO")]
5066    #[case("SELECT length('héllo')", "5")]
5067    #[case("SELECT trim('  hi  ')", "hi")]
5068    #[case("SELECT ltrim('  hi  ')", "hi  ")]
5069    #[case("SELECT rtrim('  hi  ')", "  hi")]
5070    #[case("SELECT trim(LEADING 'x' FROM 'xxhixx')", "hixx")]
5071    #[case("SELECT trim(TRAILING 'x' FROM 'xxhixx')", "xxhi")]
5072    #[case("SELECT trim('x' FROM 'xxhixx')", "hi")]
5073    #[case("SELECT concat('a', 'b', 'c')", "abc")]
5074    #[case("SELECT concat_ws('-', 'a', 'b', NULL, 'c')", "a-b-c")]
5075    #[case("SELECT replace('foobar', 'o', '0')", "f00bar")]
5076    #[case("SELECT left('hello', 3)", "hel")]
5077    #[case("SELECT right('hello', 3)", "llo")]
5078    #[case("SELECT lpad('7', 3, '0')", "007")]
5079    #[case("SELECT rpad('7', 3, '0')", "700")]
5080    #[case("SELECT reverse('hello')", "olleh")]
5081    #[case("SELECT repeat('ab', 3)", "ababab")]
5082    #[case("SELECT initcap('hello world')", "Hello World")]
5083    #[case("SELECT ascii('A')", "65")]
5084    #[case("SELECT chr(65)", "A")]
5085    #[case("SELECT instr('hello world', 'world')", "7")]
5086    #[case("SELECT position('world' in 'hello world')", "7")]
5087    #[case("SELECT split_part('a,b,c', ',', 2)", "b")]
5088    #[case("SELECT substring('hello world', 1, 5)", "hello")]
5089    #[case("SELECT substring('hello world' from 7)", "world")]
5090    #[case("SELECT substr('hello world', 7, 5)", "world")]
5091    // numeric functions
5092    #[case("SELECT abs(-5)", "5")]
5093    #[case("SELECT abs(-5.5)", "5.5")]
5094    #[case("SELECT round(3.456, 2)", "3.46")]
5095    #[case("SELECT round(3.5)", "4")]
5096    #[case("SELECT ceil(3.1)", "4")]
5097    #[case("SELECT floor(3.9)", "3")]
5098    #[case("SELECT trunc(3.789, 1)", "3.7")]
5099    #[case("SELECT mod(10, 3)", "1")]
5100    #[case("SELECT power(2, 10)", "1024")]
5101    #[case("SELECT sqrt(16)", "4")]
5102    #[case("SELECT sign(-3)", "-1")]
5103    #[case("SELECT greatest(3, 7, 2)", "7")]
5104    #[case("SELECT least(3, 7, 2)", "2")]
5105    // null handling
5106    #[case("SELECT coalesce(NULL, NULL, 'x')", "x")]
5107    #[case("SELECT ifnull(NULL, 'y')", "y")]
5108    #[case("SELECT nullif('a', 'a')", "NULL")]
5109    #[case("SELECT nullif('a', 'b')", "a")]
5110    // misc
5111    #[case("SELECT typeof('x')", "text")]
5112    #[case("SELECT typeof(1)", "integer")]
5113    // CASE
5114    #[case(
5115        "SELECT CASE WHEN 1 = 2 THEN 'a' WHEN 1 = 1 THEN 'b' ELSE 'c' END",
5116        "b"
5117    )]
5118    #[case("SELECT CASE 2 WHEN 1 THEN 'a' WHEN 2 THEN 'b' ELSE 'c' END", "b")]
5119    #[case("SELECT CASE WHEN 1 = 2 THEN 'a' ELSE 'c' END", "c")]
5120    fn test_sql_scalar_functions(#[case] sql: &str, #[case] expected: &str) {
5121        assert_eq!(eval_one(sql), expected);
5122    }
5123
5124    #[test]
5125    fn test_sql_group_concat() {
5126        let store = make_store();
5127        let engine = SqlEngine::new(&store).unwrap();
5128        let out = engine
5129            .execute("SELECT group_concat(content) FROM blocks WHERE block_type = 'heading'")
5130            .unwrap();
5131        assert_eq!(out.rows[0][0], "Doc,Architecture,Other");
5132    }
5133
5134    #[test]
5135    fn test_sql_string_agg_custom_separator() {
5136        let store = make_store();
5137        let engine = SqlEngine::new(&store).unwrap();
5138        let out = engine
5139            .execute("SELECT string_agg(content, ' | ') FROM blocks WHERE block_type = 'heading'")
5140            .unwrap();
5141        assert_eq!(out.rows[0][0], "Doc | Architecture | Other");
5142    }
5143
5144    #[test]
5145    fn test_sql_count_distinct() {
5146        let store = make_store();
5147        let engine = SqlEngine::new(&store).unwrap();
5148        let out = engine
5149            .execute("SELECT count(DISTINCT block_type) FROM blocks")
5150            .unwrap();
5151        assert_eq!(out.rows[0][0], "3");
5152    }
5153
5154    // doc B has no code at all; A and C's rust blocks must still come through.
5155    #[test]
5156    fn test_sql_zone_map_skip_by_lang() {
5157        let store = make_multi_doc_store();
5158        let engine = SqlEngine::new(&store).unwrap();
5159        let out = engine
5160            .execute("SELECT content FROM blocks WHERE lang = 'rust' ORDER BY content")
5161            .unwrap();
5162        let contents: Vec<&str> = out.rows.iter().map(|r| r[0].as_str()).collect();
5163        assert_eq!(contents, vec!["fn a(){}", "fn c(){}"]);
5164    }
5165
5166    // depth=3 only exists in doc C; A and B (max depth 1) must be skipped.
5167    #[test]
5168    fn test_sql_zone_map_skip_by_depth() {
5169        let store = make_multi_doc_store();
5170        let engine = SqlEngine::new(&store).unwrap();
5171        let out = engine
5172            .execute("SELECT content FROM blocks WHERE depth = 3")
5173            .unwrap();
5174        assert_eq!(out.rows.len(), 1);
5175        assert_eq!(out.rows[0][0], "C3");
5176    }
5177
5178    // Only doc B has a heading named "B"; requires block_type='heading' too.
5179    #[test]
5180    fn test_sql_zone_map_skip_by_heading_content() {
5181        let store = make_multi_doc_store();
5182        let engine = SqlEngine::new(&store).unwrap();
5183        let out = engine
5184            .execute("SELECT content FROM blocks WHERE block_type = 'heading' AND content = 'B'")
5185            .unwrap();
5186        assert_eq!(out.rows.len(), 1);
5187        assert_eq!(out.rows[0][0], "B");
5188    }
5189
5190    // `lang = ''` means "no lang"; must never trigger a code-language skip.
5191    #[test]
5192    fn test_sql_zone_map_no_skip_on_empty_lang() {
5193        let store = make_multi_doc_store();
5194        let engine = SqlEngine::new(&store).unwrap();
5195        let out = engine
5196            .execute("SELECT content FROM blocks WHERE lang = ''")
5197            .unwrap();
5198        let contents: Vec<&str> = out.rows.iter().map(|r| r[0].as_str()).collect();
5199        assert!(contents.contains(&"B"), "doc B must not be skipped");
5200        assert!(contents.contains(&"Paragraph"));
5201    }
5202
5203    // `id` must stay stable regardless of which documents get skipped.
5204    #[test]
5205    fn test_sql_zone_map_skip_preserves_block_ids() {
5206        let store = make_multi_doc_store();
5207        let engine = SqlEngine::new(&store).unwrap();
5208        let full = engine.execute("SELECT id, content FROM blocks").unwrap();
5209        let filtered = engine
5210            .execute("SELECT id, content FROM blocks WHERE lang = 'rust'")
5211            .unwrap();
5212        assert_eq!(filtered.rows.len(), 2);
5213        for row in &filtered.rows {
5214            let same_id = full.rows.iter().find(|r| r[0] == row[0]).unwrap();
5215            assert_eq!(
5216                same_id[1], row[1],
5217                "id {} must reference the same block content in both queries",
5218                row[0]
5219            );
5220        }
5221    }
5222
5223    // Zone-map skip is disabled whenever FROM has a join (see `exec_query`).
5224    // Just checks a join with a recognized conjunct still scans normally.
5225    #[test]
5226    fn test_sql_zone_map_skip_disabled_for_joins() {
5227        let store = make_multi_doc_store();
5228        let engine = SqlEngine::new(&store).unwrap();
5229        let out = engine
5230            .execute(
5231                "SELECT h.content, c.content FROM blocks h
5232                 JOIN blocks c ON c.document_id = h.document_id AND c.block_type = 'code'
5233                 WHERE h.block_type = 'heading'",
5234            )
5235            .unwrap();
5236        let headings: Vec<&str> = out.rows.iter().map(|r| r[0].as_str()).collect();
5237        assert_eq!(headings, vec!["A", "C", "C2", "C3"]);
5238    }
5239
5240    #[test]
5241    fn cte_basic_select_from_named_cte() {
5242        let store = make_store();
5243        let engine = SqlEngine::new(&store).unwrap();
5244        let out = engine
5245            .execute(
5246                "WITH headings AS (SELECT content FROM blocks WHERE block_type = 'heading')
5247                 SELECT content FROM headings ORDER BY content",
5248            )
5249            .unwrap();
5250        let contents: Vec<&str> = out.rows.iter().map(|r| r[0].as_str()).collect();
5251        assert_eq!(contents, vec!["Architecture", "Doc", "Other"]);
5252    }
5253
5254    #[test]
5255    fn cte_later_cte_references_earlier_cte_in_same_with() {
5256        let store = make_store();
5257        let engine = SqlEngine::new(&store).unwrap();
5258        let out = engine
5259            .execute(
5260                "WITH h AS (SELECT content FROM blocks WHERE block_type = 'heading'),
5261                      h2 AS (SELECT content FROM h WHERE content != 'Doc')
5262                 SELECT content FROM h2 ORDER BY content",
5263            )
5264            .unwrap();
5265        let contents: Vec<&str> = out.rows.iter().map(|r| r[0].as_str()).collect();
5266        assert_eq!(contents, vec!["Architecture", "Other"]);
5267    }
5268
5269    #[test]
5270    fn cte_forward_reference_to_later_cte_errors_unknown_table() {
5271        let store = make_store();
5272        let engine = SqlEngine::new(&store).unwrap();
5273        let err = engine
5274            .execute(
5275                "WITH a AS (SELECT content FROM b),
5276                      b AS (SELECT content FROM blocks WHERE block_type = 'heading')
5277                 SELECT content FROM a",
5278            )
5279            .unwrap_err();
5280        assert!(err.to_string().contains("unknown table"));
5281    }
5282
5283    #[test]
5284    fn cte_used_in_join_both_sides() {
5285        let store = make_store();
5286        let engine = SqlEngine::new(&store).unwrap();
5287        let out = engine
5288            .execute(
5289                "WITH h AS (SELECT content, document_id FROM blocks WHERE block_type = 'heading')
5290                 SELECT a.content, b.content FROM h a JOIN h b
5291                   ON a.document_id = b.document_id AND a.content = 'Doc' AND b.content = 'Other'",
5292            )
5293            .unwrap();
5294        assert_eq!(out.rows, vec![vec!["Doc".to_string(), "Other".to_string()]]);
5295    }
5296
5297    #[test]
5298    fn cte_visible_inside_subquery_in_where_clause() {
5299        let store = make_store();
5300        let engine = SqlEngine::new(&store).unwrap();
5301        let out = engine
5302            .execute(
5303                "WITH h AS (SELECT content FROM blocks WHERE block_type = 'heading')
5304                 SELECT content FROM blocks
5305                 WHERE content = (SELECT content FROM h WHERE content = 'Doc')",
5306            )
5307            .unwrap();
5308        assert_eq!(out.rows, vec![vec!["Doc".to_string()]]);
5309    }
5310
5311    #[test]
5312    fn with_recursive_generates_number_sequence() {
5313        let store = make_store();
5314        let engine = SqlEngine::new(&store).unwrap();
5315        let out = engine
5316            .execute(
5317                "WITH RECURSIVE seq AS (
5318                   SELECT 1 AS n
5319                   UNION ALL
5320                   SELECT n + 1 FROM seq WHERE n < 5
5321                 )
5322                 SELECT n FROM seq ORDER BY n",
5323            )
5324            .unwrap();
5325        assert_eq!(
5326            out.rows,
5327            vec![
5328                vec!["1".to_string()],
5329                vec!["2".to_string()],
5330                vec!["3".to_string()],
5331                vec!["4".to_string()],
5332                vec!["5".to_string()],
5333            ]
5334        );
5335    }
5336
5337    #[test]
5338    fn with_recursive_union_dedupes_across_iterations() {
5339        let store = make_store();
5340        let engine = SqlEngine::new(&store).unwrap();
5341        // Without dedup this would cycle between 1 and 2 forever; plain
5342        // UNION must drop the repeat and terminate.
5343        let out = engine
5344            .execute(
5345                "WITH RECURSIVE cyc AS (
5346                   SELECT 1 AS n
5347                   UNION
5348                   SELECT mod(n, 2) + 1 FROM cyc
5349                 )
5350                 SELECT n FROM cyc ORDER BY n",
5351            )
5352            .unwrap();
5353        assert_eq!(out.rows, vec![vec!["1".to_string()], vec!["2".to_string()]]);
5354    }
5355
5356    #[test]
5357    fn with_recursive_walks_heading_ancestors_via_interval_containment() {
5358        let mut store = DocumentStore::new();
5359        store.add_str("# A\n\n## B\n\n### C\n\nLeaf\n").unwrap();
5360        let engine = SqlEngine::new(&store).unwrap();
5361        let out = engine
5362            .execute(
5363                "WITH RECURSIVE ancestors AS (
5364                   SELECT pre, post, content FROM blocks
5365                   WHERE block_type = 'heading' AND content = 'C'
5366                   UNION
5367                   SELECT b.pre, b.post, b.content
5368                   FROM blocks b, ancestors
5369                   WHERE b.pre < ancestors.pre AND ancestors.post < b.post
5370                     AND b.block_type = 'heading'
5371                 )
5372                 SELECT content FROM ancestors ORDER BY pre",
5373            )
5374            .unwrap();
5375        let contents: Vec<&str> = out.rows.iter().map(|r| r[0].as_str()).collect();
5376        assert_eq!(contents, vec!["A", "B", "C"]);
5377    }
5378
5379    #[test]
5380    fn with_recursive_rejects_anchor_self_reference() {
5381        let store = make_store();
5382        let engine = SqlEngine::new(&store).unwrap();
5383        let err = engine
5384            .execute(
5385                "WITH RECURSIVE r AS (
5386                   SELECT n FROM r
5387                   UNION ALL
5388                   SELECT n FROM r
5389                 )
5390                 SELECT n FROM r",
5391            )
5392            .unwrap_err();
5393        assert!(err.to_string().contains("anchor"));
5394    }
5395
5396    #[test]
5397    fn with_recursive_rejects_mismatched_column_counts() {
5398        let store = make_store();
5399        let engine = SqlEngine::new(&store).unwrap();
5400        let err = engine
5401            .execute(
5402                "WITH RECURSIVE r AS (
5403                   SELECT 1 AS n
5404                   UNION ALL
5405                   SELECT n, n FROM r WHERE n < 5
5406                 )
5407                 SELECT n FROM r",
5408            )
5409            .unwrap_err();
5410        assert!(err.to_string().contains("different numbers of columns"));
5411    }
5412
5413    #[test]
5414    fn with_recursive_hits_iteration_cap_with_clear_error() {
5415        let store = make_store();
5416        let engine = SqlEngine::new(&store).unwrap();
5417        let err = engine
5418            .execute(
5419                "WITH RECURSIVE r AS (
5420                   SELECT 1 AS n
5421                   UNION ALL
5422                   SELECT n + 1 FROM r
5423                 )
5424                 SELECT n FROM r",
5425            )
5426            .unwrap_err();
5427        assert!(err.to_string().contains("iterations"));
5428    }
5429
5430    #[test]
5431    fn with_recursive_non_self_referencing_cte_still_works() {
5432        let store = make_store();
5433        let engine = SqlEngine::new(&store).unwrap();
5434        let out = engine
5435            .execute("WITH RECURSIVE r AS (SELECT content FROM blocks) SELECT content FROM r")
5436            .unwrap();
5437        assert!(!out.rows.is_empty());
5438    }
5439
5440    #[test]
5441    fn cte_name_shadows_blocks_table() {
5442        let store = make_store();
5443        let engine = SqlEngine::new(&store).unwrap();
5444        let out = engine
5445            .execute("WITH blocks AS (SELECT 'shadowed' AS content) SELECT content FROM blocks")
5446            .unwrap();
5447        assert_eq!(out.rows, vec![vec!["shadowed".to_string()]]);
5448    }
5449
5450    #[test]
5451    fn cte_name_collision_with_custom_table_prefers_cte() {
5452        let mut store = make_store();
5453        store
5454            .execute_sql_mut("CREATE TABLE notes (name TEXT)")
5455            .unwrap();
5456        store
5457            .execute_sql_mut("INSERT INTO notes (name) VALUES ('real')")
5458            .unwrap();
5459
5460        let engine = SqlEngine::new(&store).unwrap();
5461        let out = engine
5462            .execute("WITH notes AS (SELECT 'cte' AS name) SELECT name FROM notes")
5463            .unwrap();
5464        assert_eq!(out.rows, vec![vec!["cte".to_string()]]);
5465    }
5466
5467    #[test]
5468    fn cte_column_alias_list_rejected() {
5469        let store = make_store();
5470        let engine = SqlEngine::new(&store).unwrap();
5471        let err = engine
5472            .execute(
5473                "WITH h(a) AS (SELECT content FROM blocks WHERE block_type = 'heading')
5474                 SELECT a FROM h",
5475            )
5476            .unwrap_err();
5477        assert!(err.to_string().contains("column aliases"));
5478    }
5479
5480    #[test]
5481    fn cte_shadowing_across_nested_subquery_with_same_name() {
5482        let store = make_store();
5483        let engine = SqlEngine::new(&store).unwrap();
5484        let out = engine
5485            .execute(
5486                "WITH x AS (SELECT content FROM blocks WHERE content = 'Doc')
5487                 SELECT content FROM blocks
5488                 WHERE block_type = 'heading'
5489                   AND (content = (WITH x AS (SELECT content FROM blocks WHERE content = 'Other') SELECT content FROM x)
5490                        OR content = (SELECT content FROM x))
5491                 ORDER BY content",
5492            )
5493            .unwrap();
5494        let contents: Vec<&str> = out.rows.iter().map(|r| r[0].as_str()).collect();
5495        assert_eq!(contents, vec!["Doc", "Other"]);
5496    }
5497
5498    // UPDATE/DELETE write-back
5499
5500    fn write_md(dir: &tempfile::TempDir, name: &str, content: &str) -> std::path::PathBuf {
5501        let path = dir.path().join(name);
5502        std::fs::write(&path, content).unwrap();
5503        path
5504    }
5505
5506    #[test]
5507    fn write_back_update_rewrites_heading_and_keeps_rest_of_file() {
5508        let dir = tempfile::tempdir().unwrap();
5509        let path = write_md(&dir, "doc.md", "# Old Title\n\nBody text\n");
5510
5511        let mut store = DocumentStore::new();
5512        let doc_id = store.add_file(&path).unwrap();
5513
5514        let out = store
5515            .execute_sql_mut("UPDATE blocks SET content = 'New Title' WHERE block_type = 'heading'")
5516            .unwrap();
5517        assert_eq!(out.rows[0][0], "1");
5518
5519        let on_disk = std::fs::read_to_string(&path).unwrap();
5520        assert_eq!(on_disk, "# New Title\n\nBody text\n");
5521
5522        assert_eq!(store.documents()[0].id, doc_id);
5523        assert!(
5524            store.documents()[0]
5525                .blocks
5526                .iter()
5527                .any(|b| b.content == "New Title")
5528        );
5529    }
5530
5531    #[test]
5532    fn write_back_update_rewrites_paragraph_only() {
5533        let dir = tempfile::tempdir().unwrap();
5534        let path = write_md(&dir, "doc.md", "# Title\n\nOld body\n\nAnother paragraph\n");
5535
5536        let mut store = DocumentStore::new();
5537        store.add_file(&path).unwrap();
5538
5539        store
5540            .execute_sql_mut("UPDATE blocks SET content = 'New body' WHERE content = 'Old body'")
5541            .unwrap();
5542
5543        let on_disk = std::fs::read_to_string(&path).unwrap();
5544        assert_eq!(on_disk, "# Title\n\nNew body\n\nAnother paragraph\n");
5545    }
5546
5547    #[test]
5548    fn write_back_delete_removes_matched_block_and_blank_line() {
5549        let dir = tempfile::tempdir().unwrap();
5550        let path = write_md(&dir, "doc.md", "# Title\n\nKeep me\n\nRemove me\n");
5551
5552        let mut store = DocumentStore::new();
5553        store.add_file(&path).unwrap();
5554
5555        let out = store
5556            .execute_sql_mut("DELETE FROM blocks WHERE content = 'Remove me'")
5557            .unwrap();
5558        assert_eq!(out.rows[0][0], "1");
5559
5560        let on_disk = std::fs::read_to_string(&path).unwrap();
5561        assert_eq!(on_disk, "# Title\n\nKeep me\n");
5562        assert!(
5563            !store.documents()[0]
5564                .blocks
5565                .iter()
5566                .any(|b| b.content == "Remove me")
5567        );
5568    }
5569
5570    #[test]
5571    fn write_back_update_rejects_non_heading_paragraph_block_type() {
5572        let dir = tempfile::tempdir().unwrap();
5573        let path = write_md(&dir, "doc.md", "# Title\n\n```rust\nfn main() {}\n```\n");
5574
5575        let mut store = DocumentStore::new();
5576        store.add_file(&path).unwrap();
5577
5578        let err = store
5579            .execute_sql_mut(
5580                "UPDATE blocks SET content = 'fn other() {}' WHERE block_type = 'code'",
5581            )
5582            .unwrap_err();
5583        assert!(err.to_string().contains("heading/paragraph"));
5584    }
5585
5586    #[test]
5587    fn write_back_rejects_document_with_no_source_path() {
5588        let mut store = DocumentStore::new();
5589        store.add_str("# Title\n\nBody\n").unwrap();
5590
5591        let err = store
5592            .execute_sql_mut("UPDATE blocks SET content = 'x' WHERE block_type = 'heading'")
5593            .unwrap_err();
5594        assert!(err.to_string().contains("no source file"));
5595    }
5596
5597    #[test]
5598    fn write_back_rejects_column_other_than_content() {
5599        let dir = tempfile::tempdir().unwrap();
5600        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
5601
5602        let mut store = DocumentStore::new();
5603        store.add_file(&path).unwrap();
5604
5605        let err = store
5606            .execute_sql_mut("UPDATE blocks SET pre = 5 WHERE block_type = 'heading'")
5607            .unwrap_err();
5608        assert!(err.to_string().contains("'content'"));
5609    }
5610
5611    #[test]
5612    fn write_back_rejects_joins() {
5613        let dir = tempfile::tempdir().unwrap();
5614        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
5615
5616        let mut store = DocumentStore::new();
5617        store.add_file(&path).unwrap();
5618
5619        let err = store
5620            .execute_sql_mut(
5621                "UPDATE blocks b JOIN blocks c ON c.document_id = b.document_id SET b.content = 'x'",
5622            )
5623            .unwrap_err();
5624        assert!(err.to_string().contains("joins"));
5625    }
5626
5627    #[test]
5628    fn write_back_read_only_statements_still_work_via_execute_sql_mut() {
5629        let dir = tempfile::tempdir().unwrap();
5630        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
5631
5632        let mut store = DocumentStore::new();
5633        store.add_file(&path).unwrap();
5634
5635        let out = store
5636            .execute_sql_mut("SELECT content FROM blocks WHERE block_type = 'heading'")
5637            .unwrap();
5638        assert_eq!(out.rows, vec![vec!["Title".to_string()]]);
5639    }
5640
5641    fn title_pre(store: &DocumentStore) -> String {
5642        SqlEngine::new(store)
5643            .unwrap()
5644            .execute("SELECT pre FROM blocks WHERE content = 'Title'")
5645            .unwrap()
5646            .rows[0][0]
5647            .clone()
5648    }
5649
5650    #[test]
5651    fn write_back_insert_heading_after_pre_anchor() {
5652        let dir = tempfile::tempdir().unwrap();
5653        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
5654
5655        let mut store = DocumentStore::new();
5656        store.add_file(&path).unwrap();
5657        let pre = title_pre(&store);
5658
5659        let out = store
5660            .execute_sql_mut(&format!(
5661                "INSERT INTO blocks (document_id, block_type, content, depth, after_pre) VALUES (0, 'heading', 'Subsection', 2, {pre})"
5662            ))
5663            .unwrap();
5664        assert_eq!(out.rows[0][0], "1");
5665
5666        let on_disk = std::fs::read_to_string(&path).unwrap();
5667        assert_eq!(on_disk, "# Title\n\n## Subsection\n\nBody\n");
5668        assert!(
5669            store.documents()[0]
5670                .blocks
5671                .iter()
5672                .any(|b| b.content == "Subsection")
5673        );
5674    }
5675
5676    #[test]
5677    fn write_back_insert_paragraph_append_at_end_no_after_pre() {
5678        let dir = tempfile::tempdir().unwrap();
5679        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
5680
5681        let mut store = DocumentStore::new();
5682        store.add_file(&path).unwrap();
5683
5684        let out = store
5685            .execute_sql_mut(
5686                "INSERT INTO blocks (document_id, block_type, content) VALUES (0, 'paragraph', 'Appended')",
5687            )
5688            .unwrap();
5689        assert_eq!(out.rows[0][0], "1");
5690
5691        let on_disk = std::fs::read_to_string(&path).unwrap();
5692        assert_eq!(on_disk, "# Title\n\nBody\n\nAppended\n");
5693    }
5694
5695    #[test]
5696    fn write_back_insert_append_preserves_missing_trailing_newline() {
5697        let dir = tempfile::tempdir().unwrap();
5698        let path = dir.path().join("doc.md");
5699        std::fs::write(&path, "# Title\n\nBody").unwrap();
5700
5701        let mut store = DocumentStore::new();
5702        store.add_file(&path).unwrap();
5703
5704        store
5705            .execute_sql_mut(
5706                "INSERT INTO blocks (document_id, block_type, content) VALUES (0, 'paragraph', 'Appended')",
5707            )
5708            .unwrap();
5709
5710        let on_disk = std::fs::read_to_string(&path).unwrap();
5711        assert_eq!(on_disk, "# Title\n\nBody\n\nAppended");
5712    }
5713
5714    #[test]
5715    fn write_back_insert_two_rows_same_after_pre_preserves_order() {
5716        let dir = tempfile::tempdir().unwrap();
5717        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
5718
5719        let mut store = DocumentStore::new();
5720        store.add_file(&path).unwrap();
5721        let pre = title_pre(&store);
5722
5723        store
5724            .execute_sql_mut(&format!(
5725                "INSERT INTO blocks (document_id, block_type, content, after_pre) VALUES (0, 'paragraph', 'First', {pre}), (0, 'paragraph', 'Second', {pre})"
5726            ))
5727            .unwrap();
5728
5729        let on_disk = std::fs::read_to_string(&path).unwrap();
5730        assert_eq!(on_disk, "# Title\n\nFirst\n\nSecond\n\nBody\n");
5731    }
5732
5733    #[test]
5734    fn write_back_insert_mixed_anchors_same_document() {
5735        let dir = tempfile::tempdir().unwrap();
5736        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
5737
5738        let mut store = DocumentStore::new();
5739        store.add_file(&path).unwrap();
5740        let pre = title_pre(&store);
5741
5742        store
5743            .execute_sql_mut(&format!(
5744                "INSERT INTO blocks (document_id, block_type, content, after_pre) VALUES \
5745                 (0, 'paragraph', 'AfterTitle', {pre}), \
5746                 (0, 'paragraph', 'AtEnd', NULL)"
5747            ))
5748            .unwrap();
5749
5750        let on_disk = std::fs::read_to_string(&path).unwrap();
5751        assert_eq!(on_disk, "# Title\n\nAfterTitle\n\nBody\n\nAtEnd\n");
5752    }
5753
5754    #[test]
5755    fn write_back_insert_multi_row_different_documents() {
5756        let dir = tempfile::tempdir().unwrap();
5757        let path_a = write_md(&dir, "a.md", "# A\n\nBodyA\n");
5758        let path_b = write_md(&dir, "b.md", "# B\n\nBodyB\n");
5759
5760        let mut store = DocumentStore::new();
5761        store.add_file(&path_a).unwrap();
5762        store.add_file(&path_b).unwrap();
5763
5764        let out = store
5765            .execute_sql_mut(
5766                "INSERT INTO blocks (document_id, block_type, content) VALUES \
5767                 (0, 'paragraph', 'ExtraA'), (1, 'paragraph', 'ExtraB')",
5768            )
5769            .unwrap();
5770        assert_eq!(out.rows[0][0], "2");
5771
5772        assert_eq!(
5773            std::fs::read_to_string(&path_a).unwrap(),
5774            "# A\n\nBodyA\n\nExtraA\n"
5775        );
5776        assert_eq!(
5777            std::fs::read_to_string(&path_b).unwrap(),
5778            "# B\n\nBodyB\n\nExtraB\n"
5779        );
5780    }
5781
5782    #[test]
5783    fn write_back_insert_rejects_unsupported_block_type() {
5784        let dir = tempfile::tempdir().unwrap();
5785        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
5786        let mut store = DocumentStore::new();
5787        store.add_file(&path).unwrap();
5788
5789        let err = store
5790            .execute_sql_mut(
5791                "INSERT INTO blocks (document_id, block_type, content) VALUES (0, 'code', 'fn f(){}')",
5792            )
5793            .unwrap_err();
5794        assert!(err.to_string().contains("heading/paragraph"));
5795    }
5796
5797    #[test]
5798    fn write_back_insert_rejects_missing_depth_for_heading() {
5799        let dir = tempfile::tempdir().unwrap();
5800        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
5801        let mut store = DocumentStore::new();
5802        store.add_file(&path).unwrap();
5803
5804        let err = store
5805            .execute_sql_mut(
5806                "INSERT INTO blocks (document_id, block_type, content) VALUES (0, 'heading', 'New')",
5807            )
5808            .unwrap_err();
5809        assert!(err.to_string().contains("depth"));
5810    }
5811
5812    #[test]
5813    fn write_back_insert_rejects_depth_for_paragraph() {
5814        let dir = tempfile::tempdir().unwrap();
5815        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
5816        let mut store = DocumentStore::new();
5817        store.add_file(&path).unwrap();
5818
5819        let err = store
5820            .execute_sql_mut(
5821                "INSERT INTO blocks (document_id, block_type, content, depth) VALUES (0, 'paragraph', 'New', 2)",
5822            )
5823            .unwrap_err();
5824        assert!(err.to_string().contains("depth"));
5825    }
5826
5827    #[test]
5828    fn write_back_insert_rejects_positional_values() {
5829        let dir = tempfile::tempdir().unwrap();
5830        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
5831        let mut store = DocumentStore::new();
5832        store.add_file(&path).unwrap();
5833
5834        let err = store
5835            .execute_sql_mut("INSERT INTO blocks VALUES (0, 'paragraph', 'New', NULL, NULL)")
5836            .unwrap_err();
5837        assert!(err.to_string().contains("column list"));
5838    }
5839
5840    #[test]
5841    fn write_back_insert_rejects_unknown_after_pre() {
5842        let dir = tempfile::tempdir().unwrap();
5843        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
5844        let mut store = DocumentStore::new();
5845        store.add_file(&path).unwrap();
5846
5847        let err = store
5848            .execute_sql_mut(
5849                "INSERT INTO blocks (document_id, block_type, content, after_pre) VALUES (0, 'paragraph', 'New', 999)",
5850            )
5851            .unwrap_err();
5852        assert!(err.to_string().contains("after_pre"));
5853    }
5854
5855    #[test]
5856    fn write_back_insert_rejects_unknown_document_id() {
5857        let dir = tempfile::tempdir().unwrap();
5858        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
5859        let mut store = DocumentStore::new();
5860        store.add_file(&path).unwrap();
5861
5862        let err = store
5863            .execute_sql_mut(
5864                "INSERT INTO blocks (document_id, block_type, content) VALUES (99, 'paragraph', 'New')",
5865            )
5866            .unwrap_err();
5867        assert!(err.to_string().contains("no such document"));
5868    }
5869
5870    #[test]
5871    fn write_back_insert_rejects_document_with_no_source_path() {
5872        let mut store = DocumentStore::new();
5873        store.add_str("# Title\n\nBody\n").unwrap();
5874
5875        let err = store
5876            .execute_sql_mut(
5877                "INSERT INTO blocks (document_id, block_type, content) VALUES (0, 'paragraph', 'New')",
5878            )
5879            .unwrap_err();
5880        assert!(err.to_string().contains("no source file"));
5881    }
5882
5883    #[test]
5884    fn write_back_read_only_insert_into_blocks_still_rejected() {
5885        let dir = tempfile::tempdir().unwrap();
5886        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
5887        let mut store = DocumentStore::new();
5888        store.add_file(&path).unwrap();
5889
5890        let engine = SqlEngine::new(&store).unwrap();
5891        let err = engine
5892            .execute(
5893                "INSERT INTO blocks (document_id, block_type, content) VALUES (0, 'paragraph', 'New')",
5894            )
5895            .unwrap_err();
5896        assert!(err.to_string().contains("blocks"));
5897    }
5898
5899    #[test]
5900    fn write_back_insert_into_custom_table_still_works_via_execute_sql_mut() {
5901        let dir = tempfile::tempdir().unwrap();
5902        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
5903        let mut store = DocumentStore::new();
5904        store.add_file(&path).unwrap();
5905
5906        store
5907            .execute_sql_mut("CREATE TABLE notes (name TEXT)")
5908            .unwrap();
5909        let out = store
5910            .execute_sql_mut("INSERT INTO notes (name) VALUES ('hello')")
5911            .unwrap();
5912        assert_eq!(out.rows[0][0], "1");
5913    }
5914
5915    // EXPLAIN / EXPLAIN ANALYZE
5916
5917    fn explain_detail<'a>(out: &'a QueryOutput, step: &str) -> &'a str {
5918        out.rows
5919            .iter()
5920            .find(|r| r[0] == step)
5921            .unwrap_or_else(|| panic!("no EXPLAIN row for step '{step}' in {:?}", out.rows))[1]
5922            .as_str()
5923    }
5924
5925    #[test]
5926    fn explain_reports_bitmap_index_for_block_type_eq() {
5927        let store = make_store();
5928        let engine = SqlEngine::new(&store).unwrap();
5929        let out = engine
5930            .execute("EXPLAIN SELECT content FROM blocks WHERE block_type = 'heading'")
5931            .unwrap();
5932        assert_eq!(out.columns, vec!["step", "detail"]);
5933        assert!(explain_detail(&out, "query:where").contains("BitmapIndex"));
5934        assert!(explain_detail(&out, "query:zone-map").contains("not eligible"));
5935    }
5936
5937    #[test]
5938    fn explain_reports_zone_map_skip_eligibility() {
5939        let store = make_store();
5940        let engine = SqlEngine::new(&store).unwrap();
5941        let out = engine
5942            .execute("EXPLAIN SELECT content FROM blocks WHERE lang = 'rust'")
5943            .unwrap();
5944        assert!(explain_detail(&out, "query:zone-map").contains("eligible via lang"));
5945    }
5946
5947    #[test]
5948    fn explain_reports_full_scan_when_no_hint_applies() {
5949        let store = make_store();
5950        let engine = SqlEngine::new(&store).unwrap();
5951        let out = engine
5952            .execute("EXPLAIN SELECT content FROM blocks WHERE content LIKE '%foo%'")
5953            .unwrap();
5954        assert!(explain_detail(&out, "query:where").contains("full scan"));
5955    }
5956
5957    #[test]
5958    fn explain_reports_multiple_candidates_with_costs() {
5959        let mut store = DocumentStore::new();
5960        store
5961            .add_str("# Doc\n\nP1\n\nP2\n\nP3\n\nP4\n\n```rust\nfn main(){}\n```\n")
5962            .unwrap();
5963        let engine = SqlEngine::new(&store).unwrap();
5964        let out = engine
5965            .execute(
5966                "EXPLAIN SELECT * FROM blocks WHERE block_type = 'paragraph' AND lang = 'rust'",
5967            )
5968            .unwrap();
5969        let detail = explain_detail(&out, "query:where");
5970        assert!(detail.contains("est."));
5971        assert!(detail.contains("also considered"));
5972        assert!(detail.contains("HashIndex(lang = 'rust') used"));
5973    }
5974
5975    #[test]
5976    fn explain_reports_no_where_row_when_no_where_clause() {
5977        let store = make_store();
5978        let engine = SqlEngine::new(&store).unwrap();
5979        let out = engine
5980            .execute("EXPLAIN SELECT content FROM blocks")
5981            .unwrap();
5982        assert!(explain_detail(&out, "query:where").contains("full scan"));
5983    }
5984
5985    #[test]
5986    fn explain_reports_hash_join_for_equi_join() {
5987        let store = make_store();
5988        let engine = SqlEngine::new(&store).unwrap();
5989        let out = engine
5990            .execute(
5991                "EXPLAIN SELECT h.content FROM blocks h
5992                 JOIN blocks n ON n.document_id = h.document_id",
5993            )
5994            .unwrap();
5995        assert!(explain_detail(&out, "query:join[0]").contains("hash join"));
5996    }
5997
5998    #[test]
5999    fn explain_reports_nested_loop_for_non_equi_join() {
6000        let store = make_store();
6001        let engine = SqlEngine::new(&store).unwrap();
6002        let out = engine
6003            .execute(
6004                "EXPLAIN SELECT h.content FROM blocks h
6005                 JOIN blocks n ON n.pre = h.pre + 1",
6006            )
6007            .unwrap();
6008        assert!(explain_detail(&out, "query:join[0]").contains("nested loop"));
6009    }
6010
6011    #[test]
6012    fn explain_reports_group_by_order_by_and_limit() {
6013        let store = make_store();
6014        let engine = SqlEngine::new(&store).unwrap();
6015        let out = engine
6016            .execute(
6017                "EXPLAIN SELECT block_type, count(*) FROM blocks
6018                 GROUP BY block_type ORDER BY block_type LIMIT 5",
6019            )
6020            .unwrap();
6021        assert!(explain_detail(&out, "query:group-by").contains("1 key"));
6022        assert!(explain_detail(&out, "query:order-by").contains("ASC"));
6023        assert_eq!(explain_detail(&out, "query:limit"), "5");
6024    }
6025
6026    #[test]
6027    fn explain_describes_cte_separately_from_outer_query() {
6028        let store = make_store();
6029        let engine = SqlEngine::new(&store).unwrap();
6030        let out = engine
6031            .execute(
6032                "EXPLAIN WITH headings AS (SELECT content FROM blocks WHERE block_type = 'heading')
6033                 SELECT content FROM headings",
6034            )
6035            .unwrap();
6036        assert!(explain_detail(&out, "cte:headings:where").contains("BitmapIndex"));
6037        assert!(explain_detail(&out, "query:from").contains("headings (cte)"));
6038    }
6039
6040    #[test]
6041    fn explain_analyze_runs_query_and_reports_row_count() {
6042        let store = make_store();
6043        let engine = SqlEngine::new(&store).unwrap();
6044        let out = engine
6045            .execute("EXPLAIN ANALYZE SELECT content FROM blocks WHERE block_type = 'heading'")
6046            .unwrap();
6047        assert_eq!(explain_detail(&out, "actual:rows"), "3 row(s) returned");
6048        assert!(explain_detail(&out, "actual:elapsed").contains("ms"));
6049        assert!(
6050            out.rows
6051                .iter()
6052                .any(|r| r[1].contains("document(s) skipped by zone map"))
6053        );
6054    }
6055
6056    #[test]
6057    fn explain_analyze_skips_doc_stats_for_joins() {
6058        let store = make_store();
6059        let engine = SqlEngine::new(&store).unwrap();
6060        let out = engine
6061            .execute(
6062                "EXPLAIN ANALYZE SELECT h.content FROM blocks h
6063                 JOIN blocks n ON n.document_id = h.document_id",
6064            )
6065            .unwrap();
6066        assert!(
6067            !out.rows
6068                .iter()
6069                .any(|r| r[1].contains("document(s) skipped by zone map"))
6070        );
6071        assert!(explain_detail(&out, "actual:rows").contains("row(s) returned"));
6072    }
6073
6074    #[test]
6075    fn explain_rejects_non_select_statement() {
6076        let store = make_store();
6077        let engine = SqlEngine::new(&store).unwrap();
6078        let err = engine
6079            .execute("EXPLAIN CREATE TABLE notes (name TEXT)")
6080            .unwrap_err();
6081        assert!(err.to_string().contains("SELECT"));
6082    }
6083
6084    #[test]
6085    fn explain_under_write_back_delegates_correctly() {
6086        let dir = tempfile::tempdir().unwrap();
6087        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
6088        let mut store = DocumentStore::new();
6089        store.add_file(&path).unwrap();
6090
6091        let out = store
6092            .execute_sql_mut("EXPLAIN SELECT content FROM blocks WHERE block_type = 'heading'")
6093            .unwrap();
6094        assert!(explain_detail(&out, "query:where").contains("BitmapIndex"));
6095    }
6096
6097    // CREATE VIEW / DROP VIEW
6098
6099    #[test]
6100    fn create_view_then_select_reflects_current_data() {
6101        let dir = tempfile::tempdir().unwrap();
6102        let path = write_md(&dir, "doc.md", "# Title\n\nOld Content\n");
6103        let mut store = DocumentStore::new();
6104        store.add_file(&path).unwrap();
6105
6106        store
6107            .execute_sql_mut(
6108                "CREATE VIEW paras AS SELECT content FROM blocks WHERE block_type = 'paragraph'",
6109            )
6110            .unwrap();
6111
6112        let out = SqlEngine::new(&store)
6113            .unwrap()
6114            .execute("SELECT content FROM paras")
6115            .unwrap();
6116        assert_eq!(out.rows, vec![vec!["Old Content".to_string()]]);
6117
6118        store
6119            .execute_sql_mut(
6120                "UPDATE blocks SET content = 'New Content' WHERE content = 'Old Content'",
6121            )
6122            .unwrap();
6123
6124        let out = SqlEngine::new(&store)
6125            .unwrap()
6126            .execute("SELECT content FROM paras")
6127            .unwrap();
6128        assert_eq!(out.rows, vec![vec!["New Content".to_string()]]);
6129    }
6130
6131    #[test]
6132    fn create_view_rejects_builtin_name() {
6133        let store = make_store();
6134        let engine = SqlEngine::new(&store).unwrap();
6135        let err = engine
6136            .execute("CREATE VIEW blocks AS SELECT 1")
6137            .unwrap_err();
6138        assert!(err.to_string().contains("built-in"));
6139    }
6140
6141    #[test]
6142    fn create_view_rejects_duplicate_without_if_not_exists() {
6143        let store = make_store();
6144        let engine = SqlEngine::new(&store).unwrap();
6145        engine.execute("CREATE VIEW v AS SELECT 1").unwrap();
6146        let err = engine.execute("CREATE VIEW v AS SELECT 2").unwrap_err();
6147        assert!(err.to_string().contains("already exists"));
6148    }
6149
6150    #[test]
6151    fn create_view_if_not_exists_is_a_noop() {
6152        let store = make_store();
6153        let engine = SqlEngine::new(&store).unwrap();
6154        engine.execute("CREATE VIEW v AS SELECT 1").unwrap();
6155        let out = engine
6156            .execute("CREATE VIEW IF NOT EXISTS v AS SELECT 2")
6157            .unwrap();
6158        assert_eq!(out.rows[0][0], "already exists");
6159
6160        let sel = engine.execute("SELECT * FROM v").unwrap();
6161        assert_eq!(sel.rows, vec![vec!["1".to_string()]]);
6162    }
6163
6164    #[test]
6165    fn create_view_or_replace_overwrites() {
6166        let store = make_store();
6167        let engine = SqlEngine::new(&store).unwrap();
6168        engine.execute("CREATE VIEW v AS SELECT 1").unwrap();
6169        engine
6170            .execute("CREATE OR REPLACE VIEW v AS SELECT 2")
6171            .unwrap();
6172        let out = engine.execute("SELECT * FROM v").unwrap();
6173        assert_eq!(out.rows, vec![vec!["2".to_string()]]);
6174    }
6175
6176    #[test]
6177    fn create_view_rejects_name_colliding_with_custom_table() {
6178        let store = make_store();
6179        let engine = SqlEngine::new(&store).unwrap();
6180        engine.execute("CREATE TABLE t (x TEXT)").unwrap();
6181        let err = engine.execute("CREATE VIEW t AS SELECT 1").unwrap_err();
6182        assert!(err.to_string().contains("table"));
6183    }
6184
6185    #[test]
6186    fn create_table_rejects_name_colliding_with_view() {
6187        let store = make_store();
6188        let engine = SqlEngine::new(&store).unwrap();
6189        engine.execute("CREATE VIEW v AS SELECT 1").unwrap();
6190        let err = engine.execute("CREATE TABLE v (x TEXT)").unwrap_err();
6191        assert!(err.to_string().contains("view"));
6192    }
6193
6194    #[test]
6195    fn view_detects_circular_reference() {
6196        let store = make_store();
6197        let engine = SqlEngine::new(&store).unwrap();
6198        engine.execute("CREATE VIEW a AS SELECT 1").unwrap();
6199        engine.execute("CREATE VIEW b AS SELECT * FROM a").unwrap();
6200        // At validation time this only sees b's current (non-circular)
6201        // definition, so it succeeds — but now a -> b -> a is a real cycle.
6202        engine
6203            .execute("CREATE OR REPLACE VIEW a AS SELECT * FROM b")
6204            .unwrap();
6205
6206        let err = engine.execute("SELECT * FROM a").unwrap_err();
6207        assert!(err.to_string().contains("circular"));
6208    }
6209
6210    #[test]
6211    fn drop_view_removes_it() {
6212        let store = make_store();
6213        let engine = SqlEngine::new(&store).unwrap();
6214        engine.execute("CREATE VIEW v AS SELECT 1").unwrap();
6215        engine.execute("DROP VIEW v").unwrap();
6216        let err = engine.execute("SELECT * FROM v").unwrap_err();
6217        assert!(err.to_string().contains("unknown table"));
6218    }
6219
6220    #[test]
6221    fn drop_view_if_exists_on_missing_is_noop() {
6222        let store = make_store();
6223        let engine = SqlEngine::new(&store).unwrap();
6224        let out = engine.execute("DROP VIEW IF EXISTS missing").unwrap();
6225        assert!(out.rows[0][0].contains("0 view"));
6226    }
6227
6228    #[test]
6229    fn show_tables_lists_views() {
6230        let store = make_store();
6231        let engine = SqlEngine::new(&store).unwrap();
6232        engine.execute("CREATE VIEW v AS SELECT 1").unwrap();
6233        let out = engine.execute("SHOW TABLES").unwrap();
6234        assert!(out.rows.iter().any(|r| r[0] == "v" && r[1] == "view"));
6235    }
6236
6237    #[test]
6238    fn desc_view_reports_query_columns() {
6239        let store = make_store();
6240        let engine = SqlEngine::new(&store).unwrap();
6241        engine
6242            .execute("CREATE VIEW headings AS SELECT content, depth FROM blocks WHERE block_type = 'heading'")
6243            .unwrap();
6244        let out = engine.execute("DESC headings").unwrap();
6245        let cols: Vec<&str> = out.rows.iter().map(|r| r[0].as_str()).collect();
6246        assert_eq!(cols, vec!["content", "depth"]);
6247    }
6248
6249    #[test]
6250    fn view_persists_across_save_and_load() {
6251        let dir = tempfile::tempdir().unwrap();
6252        let md_path = write_md(&dir, "doc.md", "# Title\n\nHello\n");
6253        let path = dir.path().join("test.mq-db");
6254
6255        let mut store = DocumentStore::new();
6256        store.add_file(&md_path).unwrap();
6257        store
6258            .execute_sql_mut(
6259                "CREATE VIEW paras AS SELECT content FROM blocks WHERE block_type = 'paragraph'",
6260            )
6261            .unwrap();
6262        store.save(&path).unwrap();
6263
6264        let reloaded = DocumentStore::load(&path).unwrap();
6265        let engine = SqlEngine::new(&reloaded).unwrap();
6266        let out = engine.execute("SELECT content FROM paras").unwrap();
6267        assert_eq!(out.rows, vec![vec!["Hello".to_string()]]);
6268
6269        // Still live after reload, not a frozen snapshot.
6270        let mut reloaded = reloaded;
6271        reloaded
6272            .execute_sql_mut("UPDATE blocks SET content = 'Updated' WHERE content = 'Hello'")
6273            .unwrap();
6274        let out = SqlEngine::new(&reloaded)
6275            .unwrap()
6276            .execute("SELECT content FROM paras")
6277            .unwrap();
6278        assert_eq!(out.rows, vec![vec!["Updated".to_string()]]);
6279    }
6280
6281    #[test]
6282    fn view_works_through_execute_sql_mut() {
6283        let dir = tempfile::tempdir().unwrap();
6284        let path = write_md(&dir, "doc.md", "# Title\n\nBody\n");
6285        let mut store = DocumentStore::new();
6286        store.add_file(&path).unwrap();
6287
6288        store
6289            .execute_sql_mut(
6290                "CREATE VIEW v AS SELECT content FROM blocks WHERE block_type = 'heading'",
6291            )
6292            .unwrap();
6293        let out = store.execute_sql_mut("SELECT content FROM v").unwrap();
6294        assert_eq!(out.rows, vec![vec!["Title".to_string()]]);
6295    }
6296
6297    // read_csv() / read_json() table functions
6298
6299    #[test]
6300    fn read_csv_selects_rows_with_header_as_columns() {
6301        let dir = tempfile::tempdir().unwrap();
6302        let path = write_md(
6303            &dir,
6304            "people.csv",
6305            "name,age\n\"Ann, B\",30\n\"She said \"\"hi\"\"\",25\n",
6306        );
6307        let store = DocumentStore::new();
6308        let engine = SqlEngine::new(&store).unwrap();
6309        let out = engine
6310            .execute(&format!(
6311                "SELECT name, age FROM read_csv('{}') ORDER BY age",
6312                path.display()
6313            ))
6314            .unwrap();
6315        assert_eq!(
6316            out.rows,
6317            vec![
6318                vec!["She said \"hi\"".to_string(), "25".to_string()],
6319                vec!["Ann, B".to_string(), "30".to_string()],
6320            ]
6321        );
6322    }
6323
6324    #[test]
6325    fn read_csv_supports_numeric_where_and_arithmetic() {
6326        let dir = tempfile::tempdir().unwrap();
6327        let path = write_md(&dir, "people.csv", "name,age\nAnn,30\nCarl,25\n");
6328        let store = DocumentStore::new();
6329        let engine = SqlEngine::new(&store).unwrap();
6330        let out = engine
6331            .execute(&format!(
6332                "SELECT name, age + 1 FROM read_csv('{}') WHERE age > 26",
6333                path.display()
6334            ))
6335            .unwrap();
6336        assert_eq!(out.rows, vec![vec!["Ann".to_string(), "31".to_string()]]);
6337    }
6338
6339    #[test]
6340    fn read_csv_pads_ragged_rows_with_null() {
6341        let dir = tempfile::tempdir().unwrap();
6342        let path = write_md(&dir, "ragged.csv", "a,b,c\n1,2,3\n4\n");
6343        let store = DocumentStore::new();
6344        let engine = SqlEngine::new(&store).unwrap();
6345        let out = engine
6346            .execute(&format!(
6347                "SELECT a, b, c FROM read_csv('{}') ORDER BY a",
6348                path.display()
6349            ))
6350            .unwrap();
6351        assert_eq!(
6352            out.rows,
6353            vec![
6354                vec!["1".to_string(), "2".to_string(), "3".to_string()],
6355                vec!["4".to_string(), "NULL".to_string(), "NULL".to_string()],
6356            ]
6357        );
6358    }
6359
6360    #[test]
6361    fn read_csv_rejects_missing_file_with_clear_error() {
6362        let store = DocumentStore::new();
6363        let engine = SqlEngine::new(&store).unwrap();
6364        let err = engine
6365            .execute("SELECT * FROM read_csv('/no/such/file.csv')")
6366            .unwrap_err();
6367        assert!(err.to_string().contains("read_csv"));
6368    }
6369
6370    #[test]
6371    fn read_json_selects_rows_from_jsonl() {
6372        let dir = tempfile::tempdir().unwrap();
6373        let path = write_md(
6374            &dir,
6375            "people.jsonl",
6376            "{\"name\":\"Ann\",\"age\":30,\"active\":true}\n{\"name\":\"Carl\",\"age\":25,\"active\":false}\n",
6377        );
6378        let store = DocumentStore::new();
6379        let engine = SqlEngine::new(&store).unwrap();
6380        let out = engine
6381            .execute(&format!(
6382                "SELECT name, age, active FROM read_json('{}') WHERE age > 26",
6383                path.display()
6384            ))
6385            .unwrap();
6386        assert_eq!(
6387            out.rows,
6388            vec![vec![
6389                "Ann".to_string(),
6390                "30".to_string(),
6391                "true".to_string()
6392            ]]
6393        );
6394    }
6395
6396    #[test]
6397    fn read_json_unions_columns_across_varying_objects() {
6398        let dir = tempfile::tempdir().unwrap();
6399        let path = write_md(
6400            &dir,
6401            "mixed.jsonl",
6402            "{\"name\":\"Ann\",\"age\":30}\n{\"name\":\"Carl\",\"city\":\"NYC\"}\n",
6403        );
6404        let store = DocumentStore::new();
6405        let engine = SqlEngine::new(&store).unwrap();
6406        let out = engine
6407            .execute(&format!(
6408                "SELECT name, age, city FROM read_json('{}') ORDER BY name",
6409                path.display()
6410            ))
6411            .unwrap();
6412        assert_eq!(
6413            out.rows,
6414            vec![
6415                vec!["Ann".to_string(), "30".to_string(), "NULL".to_string()],
6416                vec!["Carl".to_string(), "NULL".to_string(), "NYC".to_string()],
6417            ]
6418        );
6419    }
6420
6421    #[test]
6422    fn read_json_rejects_non_object_line_with_line_number() {
6423        let dir = tempfile::tempdir().unwrap();
6424        let path = write_md(&dir, "bad.jsonl", "{\"a\":1}\n[1,2,3]\n");
6425        let store = DocumentStore::new();
6426        let engine = SqlEngine::new(&store).unwrap();
6427        let err = engine
6428            .execute(&format!("SELECT * FROM read_json('{}')", path.display()))
6429            .unwrap_err();
6430        assert!(err.to_string().contains("line 2"));
6431    }
6432
6433    #[test]
6434    fn read_csv_works_inside_create_table_as_select() {
6435        let dir = tempfile::tempdir().unwrap();
6436        let path = write_md(&dir, "people.csv", "name,age\nAnn,30\n");
6437        let store = DocumentStore::new();
6438        let engine = SqlEngine::new(&store).unwrap();
6439        engine
6440            .execute(&format!(
6441                "CREATE TABLE people AS SELECT * FROM read_csv('{}')",
6442                path.display()
6443            ))
6444            .unwrap();
6445        let out = engine.execute("SELECT name FROM people").unwrap();
6446        assert_eq!(out.rows, vec![vec!["Ann".to_string()]]);
6447    }
6448
6449    #[test]
6450    fn read_table_function_rejects_unknown_name() {
6451        let store = DocumentStore::new();
6452        let engine = SqlEngine::new(&store).unwrap();
6453        let err = engine
6454            .execute("SELECT * FROM read_parquet('/tmp/x.parquet')")
6455            .unwrap_err();
6456        assert!(err.to_string().contains("unknown table function"));
6457    }
6458
6459    #[test]
6460    fn vacuum_statement_redirects_to_cli() {
6461        let store = make_store();
6462        let engine = SqlEngine::new(&store).unwrap();
6463        let err = engine.execute("VACUUM").unwrap_err();
6464        assert!(err.to_string().contains("mq-db vacuum"));
6465
6466        let mut store = DocumentStore::new();
6467        store.add_str("# Title\n\nBody\n").unwrap();
6468        let err = store.execute_sql_mut("VACUUM").unwrap_err();
6469        assert!(err.to_string().contains("mq-db vacuum"));
6470    }
6471
6472    // ATTACH / DETACH
6473
6474    fn saved_store(dir: &tempfile::TempDir, name: &str, md: &str) -> std::path::PathBuf {
6475        let mut s = DocumentStore::new();
6476        s.add_str(md).unwrap();
6477        let path = dir.path().join(name);
6478        s.save(&path).unwrap();
6479        path
6480    }
6481
6482    #[test]
6483    fn attach_selects_rows_from_other_store() {
6484        let dir = tempfile::tempdir().unwrap();
6485        let other_path = saved_store(&dir, "other.mq-db", "# Other Doc\n\nOther body\n");
6486
6487        let store = make_store();
6488        let engine = SqlEngine::new(&store).unwrap();
6489        engine
6490            .execute(&format!(
6491                "ATTACH DATABASE '{}' AS other",
6492                other_path.display()
6493            ))
6494            .unwrap();
6495
6496        let out = engine
6497            .execute("SELECT content FROM other.blocks WHERE block_type = 'heading'")
6498            .unwrap();
6499        assert_eq!(out.rows, vec![vec!["Other Doc".to_string()]]);
6500    }
6501
6502    #[test]
6503    fn attach_join_across_local_and_other_store() {
6504        let dir = tempfile::tempdir().unwrap();
6505        let other_path = saved_store(&dir, "other.mq-db", "# Other Doc\n\nOther body\n");
6506
6507        let store = make_store();
6508        let engine = SqlEngine::new(&store).unwrap();
6509        engine
6510            .execute(&format!(
6511                "ATTACH DATABASE '{}' AS other",
6512                other_path.display()
6513            ))
6514            .unwrap();
6515
6516        let out = engine
6517            .execute(
6518                "SELECT b.content, o.content FROM blocks b JOIN other.blocks o \
6519                 ON b.block_type = o.block_type WHERE b.block_type = 'heading'",
6520            )
6521            .unwrap();
6522        assert!(!out.rows.is_empty());
6523    }
6524
6525    #[test]
6526    fn detach_makes_alias_unknown_again() {
6527        let dir = tempfile::tempdir().unwrap();
6528        let other_path = saved_store(&dir, "other.mq-db", "# Other Doc\n\nOther body\n");
6529
6530        let store = make_store();
6531        let engine = SqlEngine::new(&store).unwrap();
6532        engine
6533            .execute(&format!(
6534                "ATTACH DATABASE '{}' AS other",
6535                other_path.display()
6536            ))
6537            .unwrap();
6538        engine.execute("DETACH other").unwrap();
6539
6540        let err = engine.execute("SELECT * FROM other.blocks").unwrap_err();
6541        assert!(err.to_string().contains("unknown database"));
6542
6543        let err = engine.execute("DETACH other").unwrap_err();
6544        assert!(err.to_string().contains("not attached"));
6545    }
6546
6547    #[test]
6548    fn attach_rejects_duplicate_alias() {
6549        let dir = tempfile::tempdir().unwrap();
6550        let other_path = saved_store(&dir, "other.mq-db", "# Other Doc\n\nOther body\n");
6551
6552        let store = make_store();
6553        let engine = SqlEngine::new(&store).unwrap();
6554        let attach_sql = format!("ATTACH DATABASE '{}' AS other", other_path.display());
6555        engine.execute(&attach_sql).unwrap();
6556
6557        let err = engine.execute(&attach_sql).unwrap_err();
6558        assert!(err.to_string().contains("already attached"));
6559    }
6560
6561    #[test]
6562    fn qualified_writes_to_attached_store_are_rejected() {
6563        let dir = tempfile::tempdir().unwrap();
6564        let other_path = saved_store(&dir, "other.mq-db", "# Other Doc\n\nOther body\n");
6565
6566        let mut store = make_store();
6567        {
6568            let engine = SqlEngine::new(&store).unwrap();
6569            engine
6570                .execute(&format!(
6571                    "ATTACH DATABASE '{}' AS other",
6572                    other_path.display()
6573                ))
6574                .unwrap();
6575        }
6576
6577        let err = SqlEngine::new(&store)
6578            .unwrap()
6579            .execute("CREATE TABLE other.t (a TEXT)")
6580            .unwrap_err();
6581        assert!(err.to_string().contains("not supported"));
6582
6583        let err = store
6584            .execute_sql_mut("UPDATE other.blocks SET content = 'x' WHERE block_type = 'heading'")
6585            .unwrap_err();
6586        assert!(err.to_string().contains("not supported"));
6587
6588        let err = store
6589            .execute_sql_mut(
6590                "INSERT INTO other.blocks (block_type, content) VALUES ('paragraph', 'x')",
6591            )
6592            .unwrap_err();
6593        assert!(err.to_string().contains("not supported"));
6594    }
6595}