1use 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 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 fs::write(
256 dir.path().join("_mdql.md"),
257 "---\ntype: database\nname: testdb\n---\n",
258 )
259 .unwrap();
260
261 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 #[test]
422 fn test_create_view_with_having() {
423 let dir = make_test_db();
424 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 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 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}