Skip to main content

mdql_core/
executor.rs

1//! Unified SQL execution — single entry point for CLI, REPL, and web server.
2
3use std::path::Path;
4
5use crate::api::Table;
6use crate::cascade;
7use crate::database::{ViewDef, is_database_dir, load_database_config, save_database_config};
8use crate::errors::{MdqlError, ValidationError};
9use crate::model::Row;
10use crate::query_ast::DeleteMode;
11use crate::query_engine::{execute_join_query, execute_query};
12use crate::query_parser::{Statement, parse_query};
13
14#[derive(Debug)]
15pub enum QueryResult {
16    Rows { rows: Vec<Row>, columns: Vec<String> },
17    Message(String),
18}
19
20pub fn execute(path: &Path, sql: &str) -> crate::errors::Result<(QueryResult, Vec<ValidationError>)> {
21    let stmt = parse_query(sql)?;
22    let is_db = is_database_dir(path);
23
24    match stmt {
25        Statement::Select(ref q) => {
26            if q.subquery.is_some() || !q.joins.is_empty() || is_db {
27                let (_config, tables, errors) = crate::loader::load_database(path)?;
28                let (rows, cols) = if let Some(ref sub) = q.subquery {
29                    let source_table = &sub.table;
30                    let (schema, table_rows) = tables.get(source_table).ok_or_else(|| {
31                        MdqlError::QueryExecution(format!(
32                            "table '{}' not found in database",
33                            source_table
34                        ))
35                    })?;
36                    execute_query(q, table_rows, schema)?
37                } else if !q.joins.is_empty() {
38                    execute_join_query(q, &tables)?
39                } else {
40                    let (schema, rows) = tables.get(&q.table).ok_or_else(|| {
41                        MdqlError::QueryExecution(format!(
42                            "table '{}' not found in database",
43                            q.table
44                        ))
45                    })?;
46                    execute_query(q, rows, schema)?
47                };
48                Ok((QueryResult::Rows { rows, columns: cols }, errors))
49            } else {
50                let (schema, rows, errors) = crate::loader::load_table(path)?;
51                let (rows, cols) = execute_query(q, &rows, &schema)?;
52                Ok((QueryResult::Rows { rows, columns: cols }, errors))
53            }
54        }
55        Statement::CreateView(ref cv) => {
56            if !is_db {
57                return Err(MdqlError::QueryExecution(
58                    "CREATE VIEW requires a database directory".into(),
59                ));
60            }
61            let mut config = load_database_config(path)?;
62
63            let (_config_check, tables, _errors) = crate::loader::load_database(path)?;
64            if tables.contains_key(&cv.view_name) {
65                return Err(MdqlError::QueryExecution(format!(
66                    "Name '{}' already exists as a table or view",
67                    cv.view_name
68                )));
69            }
70
71            if config.views.iter().any(|v| v.name == cv.view_name) {
72                return Err(MdqlError::QueryExecution(format!(
73                    "View '{}' already exists",
74                    cv.view_name
75                )));
76            }
77
78            let query_str = extract_view_query(sql)?;
79
80            let view_def = ViewDef {
81                name: cv.view_name.clone(),
82                query: query_str,
83            };
84
85            let test_result = crate::loader::load_database(path);
86            if let Ok((_cfg, test_tables, _errs)) = test_result {
87                let test_view = ViewDef {
88                    name: view_def.name.clone(),
89                    query: view_def.query.clone(),
90                };
91                if let Err(e) = super::loader::materialize_view(&test_view, &test_tables) {
92                    return Err(MdqlError::QueryExecution(format!(
93                        "View query failed validation: {}",
94                        e
95                    )));
96                }
97            }
98
99            config.views.push(view_def);
100            save_database_config(path, &config)?;
101            Ok((
102                QueryResult::Message(format!("View '{}' created", cv.view_name)),
103                vec![],
104            ))
105        }
106        Statement::DropView(ref dv) => {
107            if !is_db {
108                return Err(MdqlError::QueryExecution(
109                    "DROP VIEW requires a database directory".into(),
110                ));
111            }
112            let mut config = load_database_config(path)?;
113            let len_before = config.views.len();
114            config.views.retain(|v| v.name != dv.view_name);
115            if config.views.len() == len_before {
116                return Err(MdqlError::QueryExecution(format!(
117                    "View '{}' does not exist",
118                    dv.view_name
119                )));
120            }
121            save_database_config(path, &config)?;
122            Ok((
123                QueryResult::Message(format!("View '{}' dropped", dv.view_name)),
124                vec![],
125            ))
126        }
127        Statement::Delete(ref dq) if dq.mode != DeleteMode::Default => {
128            if !is_db {
129                return Err(MdqlError::QueryExecution(
130                    "CASCADE/RESTRICT requires a database directory".into(),
131                ));
132            }
133            let config = load_database_config(path)?;
134            if config.views.iter().any(|v| v.name == dq.table) {
135                return Err(MdqlError::QueryExecution(format!(
136                    "Cannot write to view '{}' — views are read-only",
137                    dq.table
138                )));
139            }
140            let (_cfg, tables, errors) = crate::loader::load_database(path)?;
141            let (_, rows) = tables.get(&dq.table).ok_or_else(|| {
142                MdqlError::QueryExecution(format!("table '{}' not found in database", dq.table))
143            })?;
144            let matched_filenames: Vec<String> = if let Some(ref wc) = dq.where_clause {
145                rows.iter()
146                    .filter(|r| crate::query_engine::evaluate(wc, r))
147                    .filter_map(|r| r.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()))
148                    .collect()
149            } else {
150                rows.iter()
151                    .filter_map(|r| r.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()))
152                    .collect()
153            };
154
155            match dq.mode {
156                DeleteMode::Cascade => {
157                    let plan = cascade::build_cascade_plan(
158                        &dq.table, &matched_filenames, &config, &tables,
159                    );
160                    let msg = cascade::execute_cascade_plan(&plan, path)?;
161                    Ok((QueryResult::Message(msg), errors))
162                }
163                DeleteMode::Restrict => {
164                    let plan = cascade::build_restrict_plan(
165                        &dq.table, &matched_filenames, &config, &tables,
166                    );
167                    if !plan.restrict_violations.is_empty() {
168                        let violations = plan.restrict_violations.join("\n  ");
169                        return Err(MdqlError::QueryExecution(format!(
170                            "RESTRICT: cannot delete — {} dependent references:\n  {}",
171                            plan.restrict_violations.len(),
172                            violations,
173                        )));
174                    }
175                    let table_path = path.join(&dq.table);
176                    let table = Table::new(&table_path)?;
177                    let msg = table.exec_delete_matched(&matched_filenames)?;
178                    Ok((QueryResult::Message(msg), errors))
179                }
180                DeleteMode::Default => unreachable!(),
181            }
182        }
183        ref stmt @ (Statement::Insert(_)
184        | Statement::Update(_)
185        | Statement::Delete(_)
186        | Statement::AlterRename(_)
187        | Statement::AlterDrop(_)
188        | Statement::AlterMerge(_)) => {
189            if is_db {
190                let config = load_database_config(path)?;
191                let target = stmt.table_name();
192                if config.views.iter().any(|v| v.name == target) {
193                    return Err(MdqlError::QueryExecution(format!(
194                        "Cannot write to view '{}' — views are read-only",
195                        target
196                    )));
197                }
198            }
199            let table_path = if is_db {
200                path.join(stmt.table_name())
201            } else {
202                path.to_path_buf()
203            };
204            let mut table = Table::new(&table_path)?;
205            let msg = table.execute_sql(sql)?;
206            Ok((QueryResult::Message(msg), vec![]))
207        }
208    }
209}
210
211fn extract_view_query(sql: &str) -> crate::errors::Result<String> {
212    let upper = sql.to_uppercase();
213    let as_keyword = upper.find(" AS ");
214    if let Some(pos) = as_keyword {
215        let after = &sql[pos + 4..];
216        let trimmed = after.trim_start();
217        let trimmed_upper = trimmed.to_uppercase();
218        if trimmed_upper.starts_with("SELECT") {
219            return Ok(trimmed.to_string());
220        }
221    }
222    // Fallback: scan for any whitespace-surrounded AS that precedes SELECT
223    let bytes = upper.as_bytes();
224    let mut i = 0;
225    while i + 4 < bytes.len() {
226        if bytes[i].is_ascii_whitespace()
227            && bytes[i + 1] == b'A'
228            && bytes[i + 2] == b'S'
229            && bytes[i + 3].is_ascii_whitespace()
230        {
231            let after = &sql[i + 3..];
232            let trimmed = after.trim_start();
233            let trimmed_upper = trimmed.to_uppercase();
234            if trimmed_upper.starts_with("SELECT") {
235                return Ok(trimmed.to_string());
236            }
237        }
238        i += 1;
239    }
240    Err(crate::errors::MdqlError::QueryExecution(
241        "CREATE VIEW must contain AS clause followed by SELECT".into(),
242    ))
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::model::Value;
249    use std::fs;
250
251    fn make_test_db() -> tempfile::TempDir {
252        let dir = tempfile::tempdir().unwrap();
253
254        // Database-level _mdql.md
255        fs::write(
256            dir.path().join("_mdql.md"),
257            "---\ntype: database\nname: testdb\n---\n",
258        )
259        .unwrap();
260
261        // Table: strategies
262        let strats = dir.path().join("strategies");
263        fs::create_dir(&strats).unwrap();
264        fs::write(
265            strats.join("_mdql.md"),
266            "---\ntype: schema\ntable: strategies\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n  status:\n    type: string\n---\n",
267        )
268        .unwrap();
269        fs::write(
270            strats.join("alpha.md"),
271            "---\ntitle: Alpha\nstatus: LIVE\n---\n# Alpha\n",
272        )
273        .unwrap();
274        fs::write(
275            strats.join("beta.md"),
276            "---\ntitle: Beta\nstatus: DRAFT\n---\n# Beta\n",
277        )
278        .unwrap();
279
280        dir
281    }
282
283    #[test]
284    fn test_create_and_query_view() {
285        let dir = make_test_db();
286        let (result, _) = execute(
287            dir.path(),
288            "CREATE VIEW live AS SELECT * FROM strategies WHERE status = 'LIVE'",
289        )
290        .unwrap();
291        assert!(matches!(result, QueryResult::Message(ref m) if m.contains("created")));
292
293        let (result, _) = execute(dir.path(), "SELECT * FROM live").unwrap();
294        if let QueryResult::Rows { rows, columns } = result {
295            assert_eq!(rows.len(), 1);
296            assert!(columns.contains(&"title".to_string()));
297        } else {
298            panic!("Expected Rows");
299        }
300    }
301
302    #[test]
303    fn test_drop_view() {
304        let dir = make_test_db();
305        execute(
306            dir.path(),
307            "CREATE VIEW live AS SELECT * FROM strategies WHERE status = 'LIVE'",
308        )
309        .unwrap();
310
311        let (result, _) = execute(dir.path(), "DROP VIEW live").unwrap();
312        assert!(matches!(result, QueryResult::Message(ref m) if m.contains("dropped")));
313
314        let err = execute(dir.path(), "SELECT * FROM live");
315        assert!(err.is_err());
316    }
317
318    #[test]
319    fn test_drop_nonexistent_view() {
320        let dir = make_test_db();
321        let err = execute(dir.path(), "DROP VIEW nonexistent");
322        assert!(err.is_err());
323        assert!(err.unwrap_err().to_string().contains("does not exist"));
324    }
325
326    #[test]
327    fn test_create_view_duplicate_name() {
328        let dir = make_test_db();
329        execute(
330            dir.path(),
331            "CREATE VIEW live AS SELECT * FROM strategies WHERE status = 'LIVE'",
332        )
333        .unwrap();
334
335        let err = execute(
336            dir.path(),
337            "CREATE VIEW live AS SELECT * FROM strategies",
338        );
339        assert!(err.is_err());
340        assert!(err.unwrap_err().to_string().contains("already exists"));
341    }
342
343    #[test]
344    fn test_create_view_conflicts_with_table() {
345        let dir = make_test_db();
346        let err = execute(
347            dir.path(),
348            "CREATE VIEW strategies AS SELECT * FROM strategies",
349        );
350        assert!(err.is_err());
351        assert!(err.unwrap_err().to_string().contains("already exists"));
352    }
353
354    #[test]
355    fn test_write_to_view_rejected() {
356        let dir = make_test_db();
357        execute(
358            dir.path(),
359            "CREATE VIEW live AS SELECT * FROM strategies WHERE status = 'LIVE'",
360        )
361        .unwrap();
362
363        let err = execute(
364            dir.path(),
365            "INSERT INTO live (title, status) VALUES ('Gamma', 'LIVE')",
366        );
367        assert!(err.is_err());
368        assert!(err.unwrap_err().to_string().contains("read-only"));
369    }
370
371    #[test]
372    fn test_create_view_not_database() {
373        let dir = tempfile::tempdir().unwrap();
374        fs::write(
375            dir.path().join("_mdql.md"),
376            "---\ntype: schema\ntable: t\nprimary_key: path\nfrontmatter:\n  x:\n    type: string\n---\n",
377        )
378        .unwrap();
379
380        let err = execute(
381            dir.path(),
382            "CREATE VIEW v AS SELECT * FROM t",
383        );
384        assert!(err.is_err());
385        assert!(err.unwrap_err().to_string().contains("database directory"));
386    }
387
388    #[test]
389    fn test_extract_view_query_basic() {
390        let q = extract_view_query("CREATE VIEW v AS SELECT * FROM t").unwrap();
391        assert!(q.starts_with("SELECT"));
392    }
393
394    #[test]
395    fn test_extract_view_query_with_column_alias() {
396        let q = extract_view_query(
397            "CREATE VIEW v AS SELECT token, SUM(size) as sell_size FROM orders GROUP BY token HAVING sell_size > 0"
398        ).unwrap();
399        assert!(q.starts_with("SELECT"));
400        assert!(q.contains("HAVING"));
401    }
402
403    #[test]
404    fn test_extract_view_query_newline_after_as() {
405        let q = extract_view_query("CREATE VIEW v AS\nSELECT * FROM t").unwrap();
406        assert!(q.starts_with("SELECT"));
407    }
408
409    #[test]
410    fn test_create_view_with_aggregate_arithmetic() {
411        let dir = make_test_db();
412        let result = execute(
413            dir.path(),
414            "CREATE VIEW v AS SELECT status, COUNT(*) - COUNT(*) as zero FROM strategies GROUP BY status",
415        );
416        assert!(result.is_ok());
417    }
418
419    // ── Issue #44: HAVING in CREATE VIEW ──
420
421    #[test]
422    fn test_create_view_with_having() {
423        let dir = make_test_db();
424        // Create a view with HAVING — both statuses have cnt=1, so HAVING cnt > 0 keeps both
425        let (result, _) = execute(
426            dir.path(),
427            "CREATE VIEW popular AS SELECT status, COUNT(*) as cnt FROM strategies GROUP BY status HAVING cnt > 0",
428        )
429        .unwrap();
430        assert!(matches!(result, QueryResult::Message(ref m) if m.contains("created")));
431
432        // Query the view to confirm it works
433        let (result, _) = execute(dir.path(), "SELECT * FROM popular").unwrap();
434        if let QueryResult::Rows { rows, columns } = result {
435            assert!(columns.contains(&"status".to_string()));
436            assert!(columns.contains(&"cnt".to_string()));
437            // Both LIVE and DRAFT have count 1, both > 0
438            assert_eq!(rows.len(), 2);
439        } else {
440            panic!("Expected Rows, got {:?}", result);
441        }
442    }
443
444    #[test]
445    fn test_extract_view_query_tab_after_as() {
446        let q = extract_view_query("CREATE VIEW v AS\tSELECT * FROM t").unwrap();
447        assert!(q.starts_with("SELECT"));
448        assert!(q.contains("FROM t"));
449    }
450
451    fn make_join_db() -> tempfile::TempDir {
452        let dir = tempfile::tempdir().unwrap();
453        fs::write(
454            dir.path().join("_mdql.md"),
455            "---\ntype: database\nname: testdb\n---\n",
456        )
457        .unwrap();
458
459        let strats = dir.path().join("strategies");
460        fs::create_dir(&strats).unwrap();
461        fs::write(
462            strats.join("_mdql.md"),
463            "---\ntype: schema\ntable: strategies\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n---\n",
464        )
465        .unwrap();
466        fs::write(strats.join("alpha.md"), "---\ntitle: Alpha\n---\n# Alpha\n").unwrap();
467        fs::write(strats.join("beta.md"), "---\ntitle: Beta\n---\n# Beta\n").unwrap();
468        fs::write(strats.join("gamma.md"), "---\ntitle: Gamma\n---\n# Gamma\n").unwrap();
469
470        let bt = dir.path().join("backtests");
471        fs::create_dir(&bt).unwrap();
472        fs::write(
473            bt.join("_mdql.md"),
474            "---\ntype: schema\ntable: backtests\nprimary_key: path\nfrontmatter:\n  strategy:\n    type: string\n  sharpe:\n    type: float\n---\n",
475        )
476        .unwrap();
477        fs::write(bt.join("bt-alpha.md"), "---\nstrategy: alpha.md\nsharpe: 1.5\n---\n# BT Alpha\n").unwrap();
478
479        dir
480    }
481
482    #[test]
483    fn test_inner_join() {
484        let dir = make_join_db();
485        let (result, _) = execute(
486            dir.path(),
487            "SELECT s.title, b.sharpe FROM strategies s JOIN backtests b ON b.strategy = s.path",
488        )
489        .unwrap();
490        if let QueryResult::Rows { rows, .. } = result {
491            assert_eq!(rows.len(), 1);
492            assert_eq!(rows[0].get("s.title").unwrap(), &Value::String("Alpha".into()));
493        } else {
494            panic!("Expected Rows");
495        }
496    }
497
498    #[test]
499    fn test_left_join() {
500        let dir = make_join_db();
501        let (result, _) = execute(
502            dir.path(),
503            "SELECT s.title, b.sharpe FROM strategies s LEFT JOIN backtests b ON b.strategy = s.path",
504        )
505        .unwrap();
506        if let QueryResult::Rows { rows, .. } = result {
507            assert_eq!(rows.len(), 3);
508            let alpha = rows.iter().find(|r| r.get("s.title") == Some(&Value::String("Alpha".into()))).unwrap();
509            assert_eq!(alpha.get("b.sharpe"), Some(&Value::Float(1.5)));
510            let beta = rows.iter().find(|r| r.get("s.title") == Some(&Value::String("Beta".into()))).unwrap();
511            assert_eq!(beta.get("b.sharpe"), Some(&Value::Null));
512        } else {
513            panic!("Expected Rows");
514        }
515    }
516
517    #[test]
518    fn test_left_join_in_view() {
519        let dir = make_join_db();
520        execute(
521            dir.path(),
522            "CREATE VIEW overview AS SELECT s.title, b.sharpe FROM strategies s LEFT JOIN backtests b ON b.strategy = s.path",
523        )
524        .unwrap();
525        let (result, _) = execute(dir.path(), "SELECT * FROM overview").unwrap();
526        if let QueryResult::Rows { rows, .. } = result {
527            assert_eq!(rows.len(), 3);
528        } else {
529            panic!("Expected Rows");
530        }
531    }
532
533    fn make_compound_join_db() -> tempfile::TempDir {
534        let dir = tempfile::tempdir().unwrap();
535        fs::write(
536            dir.path().join("_mdql.md"),
537            "---\ntype: database\nname: testdb\n---\n",
538        )
539        .unwrap();
540
541        let strats = dir.path().join("strategies");
542        fs::create_dir(&strats).unwrap();
543        fs::write(
544            strats.join("_mdql.md"),
545            "---\ntype: schema\ntable: strategies\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n---\n",
546        )
547        .unwrap();
548        fs::write(strats.join("alpha.md"), "---\ntitle: Alpha\n---\n# Alpha\n").unwrap();
549        fs::write(strats.join("beta.md"), "---\ntitle: Beta\n---\n# Beta\n").unwrap();
550
551        let bt = dir.path().join("backtests");
552        fs::create_dir(&bt).unwrap();
553        fs::write(
554            bt.join("_mdql.md"),
555            "---\ntype: schema\ntable: backtests\nprimary_key: path\nfrontmatter:\n  strategy:\n    type: string\n  mode:\n    type: string\n  sharpe:\n    type: float\n---\n",
556        )
557        .unwrap();
558        fs::write(bt.join("bt-alpha-paper.md"), "---\nstrategy: alpha.md\nmode: PAPER\nsharpe: 1.5\n---\n# BT\n").unwrap();
559        fs::write(bt.join("bt-alpha-live.md"), "---\nstrategy: alpha.md\nmode: LIVE\nsharpe: 1.2\n---\n# BT\n").unwrap();
560        fs::write(bt.join("bt-beta-paper.md"), "---\nstrategy: beta.md\nmode: PAPER\nsharpe: 0.8\n---\n# BT\n").unwrap();
561
562        dir
563    }
564
565    #[test]
566    fn test_join_compound_and() {
567        let dir = make_compound_join_db();
568        let (result, _) = execute(
569            dir.path(),
570            "SELECT s.title, b.sharpe FROM strategies s JOIN backtests b ON b.strategy = s.path AND b.mode = 'PAPER'",
571        )
572        .unwrap();
573        if let QueryResult::Rows { rows, .. } = result {
574            assert_eq!(rows.len(), 2);
575            let alpha = rows.iter().find(|r| r.get("s.title") == Some(&Value::String("Alpha".into()))).unwrap();
576            assert_eq!(alpha.get("b.sharpe"), Some(&Value::Float(1.5)));
577            let beta = rows.iter().find(|r| r.get("s.title") == Some(&Value::String("Beta".into()))).unwrap();
578            assert_eq!(beta.get("b.sharpe"), Some(&Value::Float(0.8)));
579        } else {
580            panic!("Expected Rows");
581        }
582    }
583
584    #[test]
585    fn test_left_join_compound() {
586        let dir = make_compound_join_db();
587        let (result, _) = execute(
588            dir.path(),
589            "SELECT s.title, b.sharpe FROM strategies s LEFT JOIN backtests b ON b.strategy = s.path AND b.mode = 'LIVE'",
590        )
591        .unwrap();
592        if let QueryResult::Rows { rows, .. } = result {
593            assert_eq!(rows.len(), 2);
594            let alpha = rows.iter().find(|r| r.get("s.title") == Some(&Value::String("Alpha".into()))).unwrap();
595            assert_eq!(alpha.get("b.sharpe"), Some(&Value::Float(1.2)));
596            let beta = rows.iter().find(|r| r.get("s.title") == Some(&Value::String("Beta".into()))).unwrap();
597            assert_eq!(beta.get("b.sharpe"), Some(&Value::Null));
598        } else {
599            panic!("Expected Rows");
600        }
601    }
602
603    #[test]
604    fn test_join_compound_with_comparison() {
605        let dir = make_compound_join_db();
606        let (result, _) = execute(
607            dir.path(),
608            "SELECT s.title, b.sharpe FROM strategies s JOIN backtests b ON b.strategy = s.path AND b.sharpe > 1.0",
609        )
610        .unwrap();
611        if let QueryResult::Rows { rows, .. } = result {
612            assert_eq!(rows.len(), 2);
613            assert!(rows.iter().all(|r| {
614                if let Some(Value::Float(s)) = r.get("b.sharpe") { *s > 1.0 } else { false }
615            }));
616        } else {
617            panic!("Expected Rows");
618        }
619    }
620
621    fn make_cascade_db() -> tempfile::TempDir {
622        let dir = tempfile::tempdir().unwrap();
623
624        fs::write(
625            dir.path().join("_mdql.md"),
626            "---\ntype: database\nname: testdb\nforeign_keys:\n  - from: backtests.strategy\n    to: strategies.path\n---\n",
627        )
628        .unwrap();
629
630        let strats = dir.path().join("strategies");
631        fs::create_dir(&strats).unwrap();
632        fs::write(
633            strats.join("_mdql.md"),
634            "---\ntype: schema\ntable: strategies\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n  status:\n    type: string\n---\n",
635        )
636        .unwrap();
637        fs::write(strats.join("alpha.md"), "---\ntitle: Alpha\nstatus: KILLED\n---\n# Alpha\n").unwrap();
638        fs::write(strats.join("beta.md"), "---\ntitle: Beta\nstatus: LIVE\n---\n# Beta\n").unwrap();
639
640        let bt = dir.path().join("backtests");
641        fs::create_dir(&bt).unwrap();
642        fs::write(
643            bt.join("_mdql.md"),
644            "---\ntype: schema\ntable: backtests\nprimary_key: path\nfrontmatter:\n  strategy:\n    type: string\n  sharpe:\n    type: float\n---\n",
645        )
646        .unwrap();
647        fs::write(bt.join("bt-alpha.md"), "---\nstrategy: alpha.md\nsharpe: 1.5\n---\n# BT Alpha\n").unwrap();
648        fs::write(bt.join("bt-beta.md"), "---\nstrategy: beta.md\nsharpe: 0.8\n---\n# BT Beta\n").unwrap();
649
650        dir
651    }
652
653    #[test]
654    fn test_cascade_delete() {
655        let dir = make_cascade_db();
656        let (result, _) = execute(
657            dir.path(),
658            "DELETE FROM strategies WHERE status = 'KILLED' CASCADE",
659        )
660        .unwrap();
661        if let QueryResult::Message(msg) = result {
662            assert!(msg.contains("DELETE 1"));
663            assert!(msg.contains("cascade"));
664        } else {
665            panic!("Expected Message");
666        }
667        assert!(!dir.path().join("strategies/alpha.md").exists());
668        assert!(!dir.path().join("backtests/bt-alpha.md").exists());
669        assert!(dir.path().join("strategies/beta.md").exists());
670        assert!(dir.path().join("backtests/bt-beta.md").exists());
671    }
672
673    #[test]
674    fn test_restrict_delete_blocks() {
675        let dir = make_cascade_db();
676        let err = execute(
677            dir.path(),
678            "DELETE FROM strategies WHERE status = 'KILLED' RESTRICT",
679        );
680        assert!(err.is_err());
681        let msg = err.unwrap_err().to_string();
682        assert!(msg.contains("RESTRICT"));
683        assert!(dir.path().join("strategies/alpha.md").exists());
684    }
685
686    #[test]
687    fn test_restrict_delete_allows_no_dependents() {
688        let dir = make_cascade_db();
689        fs::remove_file(dir.path().join("backtests/bt-alpha.md")).unwrap();
690
691        let (result, _) = execute(
692            dir.path(),
693            "DELETE FROM strategies WHERE status = 'KILLED' RESTRICT",
694        )
695        .unwrap();
696        if let QueryResult::Message(msg) = result {
697            assert!(msg.contains("DELETE 1"));
698        } else {
699            panic!("Expected Message");
700        }
701        assert!(!dir.path().join("strategies/alpha.md").exists());
702    }
703
704    #[test]
705    fn test_cascade_default_unchanged() {
706        let dir = make_cascade_db();
707        let (result, _) = execute(
708            dir.path(),
709            "DELETE FROM strategies WHERE status = 'KILLED'",
710        )
711        .unwrap();
712        if let QueryResult::Message(msg) = result {
713            assert!(msg.contains("DELETE 1"));
714        } else {
715            panic!("Expected Message");
716        }
717        assert!(!dir.path().join("strategies/alpha.md").exists());
718        assert!(dir.path().join("backtests/bt-alpha.md").exists());
719    }
720}