1use 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#[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, 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 let short = col_lower.split('.').next_back().unwrap_or(&col_lower);
176 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 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('&', "&")
221 .replace('<', "<")
222 .replace('>', ">")
223 .replace('"', """)
224}
225
226#[derive(Debug)]
228pub struct QueryOutput {
229 pub columns: Vec<String>,
230 pub rows: Vec<Vec<String>>,
231}
232
233impl QueryOutput {
234 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 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 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 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 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 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
567fn 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
636fn 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
648fn 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 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 _ => 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 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 _ => 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 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 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
1351fn 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
1392fn 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
1415fn 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
1489fn 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 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
1520pub struct SqlEngine<'a> {
1526 store: &'a DocumentStore,
1527 indexes: Vec<DocumentIndex>,
1529 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 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 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 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 pub fn execute(&self, sql: &str) -> Result<QueryOutput, MqdbError> {
1595 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 fn exec_detach(&self, trimmed: &str) -> Result<QueryOutput, MqdbError> {
1665 let mut tokens = trimmed.split_whitespace().skip(1); 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 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 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 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 let col_indices: Option<Vec<usize>> = if ins.columns.is_empty() {
2248 None } 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 }; let inserted = new_rows.len();
2304 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 }; 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if let Some(local_indices) = hint.resolve(doc_idx) {
2733 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 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 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 if let Some(ob) = order_by {
2864 apply_order_by(&mut result, &ob.kind);
2865 }
2866
2867 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 let mut groups: Vec<(Vec<Value>, Vec<&Row>)> = Vec::new();
2908 let mut key_index: FxHashMap<Vec<String>, usize> = FxHashMap::default();
2909
2910 let owned: Vec<Row> = rows;
2912
2913 if group_by_exprs.is_empty() {
2914 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
2947struct MatchedBlockEdit {
2952 document_id: u32,
2953 pre: u32,
2954 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
2972fn 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
3022fn 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
3044fn render_replacement(block: &Block, new_content: &str) -> Result<String, MqdbError> {
3046 render_markdown_for(&block.block_type, block.heading_depth(), new_content)
3047}
3048
3049fn 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 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 remove_end += 1;
3134 } else if remove_start > 0 && lines[remove_start - 1].trim().is_empty() {
3135 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
3157struct NewBlockSpec {
3160 document_id: u32,
3161 block_type: BlockType,
3162 content: String,
3163 depth: Option<u8>,
3165 after_pre: Option<u32>,
3167 row_index: usize,
3169}
3170
3171const INSERT_BLOCKS_COLUMNS: [&str; 5] =
3172 ["document_id", "block_type", "content", "depth", "after_pre"];
3173
3174fn 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
3318fn 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 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 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 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
3625fn 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
3762fn 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 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
3816fn 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
3834fn 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
3843fn 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
3874fn 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 Some("lang") => {
3909 if let Some(s) = val
3910 && !s.is_empty()
3911 {
3912 eq_lang = Some(s);
3913 }
3914 }
3915 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 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
3954fn 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
4235fn 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
4252fn hint_for_conjunct(expr: &Expr) -> IndexHint {
4268 match expr {
4269 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 if n > 0 {
4312 return IndexHint::DepthExact(n as u8);
4313 }
4314 }
4315 IndexHint::FullScan
4316 }
4317 _ => IndexHint::FullScan,
4318 }
4319 }
4320 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 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 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
4363fn 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
4374fn 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
4412fn 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
4441impl 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 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 assert!(
4617 elapsed.as_micros() < 5000,
4618 "SqlEngine::new took {}us — should be cheap",
4619 elapsed.as_micros()
4620 );
4621 }
4622
4623 #[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 #[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 assert_eq!(out.rows.len(), 3, "depth=0 must return non-heading blocks");
4683 }
4684
4685 #[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 assert_eq!(out.rows.len(), 5, "lang='' must return non-code blocks");
4695 }
4696
4697 #[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 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 assert!(bar_lines[1].contains("line one line two"));
4717 }
4718
4719 #[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 #[test]
4741 fn test_ddl_create_insert_select() {
4742 let store = DocumentStore::new();
4743 let engine = SqlEngine::new(&store).unwrap();
4744
4745 engine
4747 .execute("CREATE TABLE notes (id TEXT, body TEXT)")
4748 .unwrap();
4749 engine
4751 .execute("INSERT INTO notes VALUES ('1', 'hello')")
4752 .unwrap();
4753 engine
4754 .execute("INSERT INTO notes VALUES ('2', 'world')")
4755 .unwrap();
4756 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 let all = engine.execute("SELECT * FROM notes").unwrap();
4764 assert_eq!(all.rows.len(), 2);
4765 }
4766
4767 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 #[case("SELECT typeof('x')", "text")]
5112 #[case("SELECT typeof(1)", "integer")]
5113 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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 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 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 #[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 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}