Skip to main content

mdql_core/
cascade.rs

1//! CASCADE and RESTRICT delete: FK-aware deletion with BFS graph walk.
2
3use std::collections::{HashMap, HashSet, VecDeque};
4use std::path::Path;
5
6use crate::database::{DatabaseConfig, ForeignKey};
7use crate::model::{Row, Value};
8use crate::schema::Schema;
9use crate::txn::{TableLock, TableTransaction, atomic_write};
10
11#[derive(Debug, Clone)]
12pub enum CascadeAction {
13    Delete { table: String, filename: String },
14    PruneList { table: String, filename: String, column: String, value_to_remove: String },
15}
16
17#[derive(Debug, Clone)]
18pub struct CascadePlan {
19    pub primary_deletes: Vec<(String, String)>,
20    pub cascade_actions: Vec<CascadeAction>,
21    pub restrict_violations: Vec<String>,
22}
23
24impl CascadePlan {
25    pub fn total_deletes(&self) -> usize {
26        self.primary_deletes.len()
27            + self.cascade_actions.iter()
28                .filter(|a| matches!(a, CascadeAction::Delete { .. }))
29                .count()
30    }
31}
32
33pub fn build_cascade_plan(
34    target_table: &str,
35    matched_filenames: &[String],
36    config: &DatabaseConfig,
37    tables_data: &HashMap<String, (Schema, Vec<Row>)>,
38) -> CascadePlan {
39    let mut plan = CascadePlan {
40        primary_deletes: matched_filenames.iter()
41            .map(|f| (target_table.to_string(), f.clone()))
42            .collect(),
43        cascade_actions: Vec::new(),
44        restrict_violations: Vec::new(),
45    };
46
47    let mut queue: VecDeque<(String, String)> = VecDeque::new();
48    let mut visited: HashSet<(String, String)> = HashSet::new();
49
50    for f in matched_filenames {
51        let key = (target_table.to_string(), f.clone());
52        visited.insert(key);
53        queue.push_back((target_table.to_string(), f.clone()));
54    }
55
56    while let Some((deleted_table, deleted_filename)) = queue.pop_front() {
57        let referencing_fks: Vec<&ForeignKey> = config
58            .foreign_keys
59            .iter()
60            .filter(|fk| fk.to_table == deleted_table)
61            .collect();
62
63        for fk in referencing_fks {
64            let from_rows = match tables_data.get(&fk.from_table) {
65                Some(d) => &d.1,
66                None => continue,
67            };
68
69            let match_value = resolve_fk_value(
70                &deleted_table, &deleted_filename, &fk.to_column, tables_data,
71            );
72            let match_value = match match_value {
73                Some(v) => v,
74                None => continue,
75            };
76
77            for row in from_rows {
78                let filename = match row.get("path").and_then(|v| v.as_str()) {
79                    Some(f) => f.to_string(),
80                    None => continue,
81                };
82
83                let fk_value = match row.get(&fk.from_column) {
84                    Some(v) if !v.is_null() => v,
85                    _ => continue,
86                };
87
88                match fk_value {
89                    Value::List(items) => {
90                        if items.iter().any(|item| item == &match_value) {
91                            plan.cascade_actions.push(CascadeAction::PruneList {
92                                table: fk.from_table.clone(),
93                                filename: filename.clone(),
94                                column: fk.from_column.clone(),
95                                value_to_remove: match_value.clone(),
96                            });
97                        }
98                    }
99                    _ => {
100                        if fk_value.to_display_string() == match_value {
101                            let key = (fk.from_table.clone(), filename.clone());
102                            if visited.insert(key) {
103                                plan.cascade_actions.push(CascadeAction::Delete {
104                                    table: fk.from_table.clone(),
105                                    filename: filename.clone(),
106                                });
107                                queue.push_back((fk.from_table.clone(), filename.clone()));
108                            }
109                        }
110                    }
111                }
112            }
113        }
114    }
115
116    plan
117}
118
119pub fn build_restrict_plan(
120    target_table: &str,
121    matched_filenames: &[String],
122    config: &DatabaseConfig,
123    tables_data: &HashMap<String, (Schema, Vec<Row>)>,
124) -> CascadePlan {
125    let mut plan = CascadePlan {
126        primary_deletes: matched_filenames.iter()
127            .map(|f| (target_table.to_string(), f.clone()))
128            .collect(),
129        cascade_actions: Vec::new(),
130        restrict_violations: Vec::new(),
131    };
132
133    for filename in matched_filenames {
134        let referencing_fks: Vec<&ForeignKey> = config
135            .foreign_keys
136            .iter()
137            .filter(|fk| fk.to_table == target_table)
138            .collect();
139
140        for fk in &referencing_fks {
141            let from_rows = match tables_data.get(&fk.from_table) {
142                Some(d) => &d.1,
143                None => continue,
144            };
145
146            let match_value = resolve_fk_value(
147                target_table, filename, &fk.to_column, tables_data,
148            );
149            let match_value = match match_value {
150                Some(v) => v,
151                None => continue,
152            };
153
154            for row in from_rows {
155                let ref_filename = row.get("path")
156                    .and_then(|v| v.as_str())
157                    .unwrap_or("");
158
159                let fk_value = match row.get(&fk.from_column) {
160                    Some(v) if !v.is_null() => v,
161                    _ => continue,
162                };
163
164                let matches = match fk_value {
165                    Value::List(items) => items.iter().any(|i| i == &match_value),
166                    _ => fk_value.to_display_string() == match_value,
167                };
168
169                if matches {
170                    plan.restrict_violations.push(format!(
171                        "{}/{} references {}/{} via {}.{}",
172                        fk.from_table, ref_filename,
173                        target_table, filename,
174                        fk.from_table, fk.from_column,
175                    ));
176                }
177            }
178        }
179    }
180
181    plan
182}
183
184fn resolve_fk_value(
185    table: &str,
186    filename: &str,
187    to_column: &str,
188    tables_data: &HashMap<String, (Schema, Vec<Row>)>,
189) -> Option<String> {
190    if to_column == "path" {
191        return Some(filename.to_string());
192    }
193    let rows = &tables_data.get(table)?.1;
194    let row = rows.iter().find(|r| {
195        r.get("path").and_then(|v| v.as_str()).map_or(false, |p| p == filename)
196    })?;
197    row.get(to_column).map(|v| v.to_display_string())
198}
199
200pub fn execute_cascade_plan(
201    plan: &CascadePlan,
202    db_path: &Path,
203) -> crate::errors::Result<String> {
204    if plan.primary_deletes.is_empty() {
205        return Ok("DELETE 0".to_string());
206    }
207
208    let mut affected_tables: HashSet<String> = HashSet::new();
209    for (table, _) in &plan.primary_deletes {
210        affected_tables.insert(table.clone());
211    }
212    for action in &plan.cascade_actions {
213        match action {
214            CascadeAction::Delete { table, .. } | CascadeAction::PruneList { table, .. } => {
215                affected_tables.insert(table.clone());
216            }
217        }
218    }
219
220    let mut table_names: Vec<String> = affected_tables.into_iter().collect();
221    table_names.sort();
222
223    let _locks: Vec<TableLock> = table_names
224        .iter()
225        .map(|name| TableLock::acquire(&db_path.join(name)))
226        .collect::<Result<Vec<_>, _>>()?;
227
228    let mut txns: HashMap<String, TableTransaction> = HashMap::new();
229    for name in &table_names {
230        txns.insert(name.clone(), TableTransaction::new(&db_path.join(name), "CASCADE DELETE")?);
231    }
232
233    let result = (|| -> crate::errors::Result<(usize, usize, usize)> {
234        let mut delete_count = 0;
235        let mut cascade_delete_count = 0;
236        let mut prune_count = 0;
237
238        for (table, filename) in &plan.primary_deletes {
239            let filepath = db_path.join(table).join(filename);
240            if filepath.exists() {
241                let content = std::fs::read_to_string(&filepath)?;
242                txns.get_mut(table).unwrap().record_delete(&filepath, &content)?;
243                std::fs::remove_file(&filepath)?;
244                crate::checksums::remove_checksum(&db_path.join(table), filename)?;
245                delete_count += 1;
246            }
247        }
248
249        for action in &plan.cascade_actions {
250            match action {
251                CascadeAction::Delete { table, filename } => {
252                    let filepath = db_path.join(table).join(filename);
253                    if filepath.exists() {
254                        let content = std::fs::read_to_string(&filepath)?;
255                        txns.get_mut(table).unwrap().record_delete(&filepath, &content)?;
256                        std::fs::remove_file(&filepath)?;
257                        crate::checksums::remove_checksum(&db_path.join(table), filename)?;
258                        cascade_delete_count += 1;
259                    }
260                }
261                CascadeAction::PruneList { table, filename, column, value_to_remove } => {
262                    let filepath = db_path.join(table).join(filename);
263                    if filepath.exists() {
264                        let content = std::fs::read_to_string(&filepath)?;
265                        txns.get_mut(table).unwrap().record_delete(&filepath, &content)?;
266                        let updated = prune_list_value(&content, column, value_to_remove);
267                        atomic_write(&filepath, &updated)?;
268                        prune_count += 1;
269                    }
270                }
271            }
272        }
273
274        Ok((delete_count, cascade_delete_count, prune_count))
275    })();
276
277    match result {
278        Ok((delete_count, cascade_delete_count, prune_count)) => {
279            for (_, txn) in txns {
280                txn.commit()?;
281            }
282            let mut msg = format!("DELETE {}", delete_count);
283            if cascade_delete_count > 0 || prune_count > 0 {
284                msg.push_str(" (cascade:");
285                if cascade_delete_count > 0 {
286                    msg.push_str(&format!(" {} deleted", cascade_delete_count));
287                }
288                if prune_count > 0 {
289                    if cascade_delete_count > 0 { msg.push(','); }
290                    msg.push_str(&format!(" {} list refs pruned", prune_count));
291                }
292                msg.push(')');
293            }
294            Ok(msg)
295        }
296        Err(e) => {
297            for (_, txn) in txns {
298                let _ = txn.rollback();
299            }
300            Err(e)
301        }
302    }
303}
304
305fn prune_list_value(content: &str, column: &str, value_to_remove: &str) -> String {
306    let lines: Vec<&str> = content.lines().collect();
307    let mut result = Vec::new();
308    let mut in_target_list = false;
309    let mut found_column = false;
310
311    for line in &lines {
312        if in_target_list {
313            let trimmed = line.trim();
314            if trimmed.starts_with("- ") {
315                let item = trimmed.strip_prefix("- ").unwrap().trim();
316                let item = item.trim_matches('"').trim_matches('\'');
317                if item == value_to_remove {
318                    continue;
319                }
320                result.push(*line);
321            } else {
322                in_target_list = false;
323                result.push(*line);
324            }
325        } else if !found_column && line.trim_start().starts_with(&format!("{}:", column)) {
326            found_column = true;
327            let after_colon = line.trim_start()
328                .strip_prefix(&format!("{}:", column))
329                .unwrap_or("")
330                .trim();
331            if after_colon.is_empty() {
332                in_target_list = true;
333            }
334            result.push(*line);
335        } else {
336            result.push(*line);
337        }
338    }
339
340    let mut out = result.join("\n");
341    if content.ends_with('\n') && !out.ends_with('\n') {
342        out.push('\n');
343    }
344    out
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::model::Value;
351    use crate::schema::Schema;
352
353    fn test_schema(table: &str) -> Schema {
354        Schema {
355            table: table.to_string(),
356            primary_key: "path".to_string(),
357            frontmatter: indexmap::IndexMap::new(),
358            sections: indexmap::IndexMap::new(),
359            h1_required: false,
360            rules: crate::schema::Rules {
361                reject_unknown_frontmatter: false,
362                reject_unknown_sections: false,
363                reject_duplicate_sections: false,
364                normalize_numbered_headings: false,
365            },
366        }
367    }
368
369    fn make_row(path: &str, fields: &[(&str, Value)]) -> Row {
370        let mut row = Row::new();
371        row.insert("path".to_string(), Value::String(path.to_string()));
372        for (k, v) in fields {
373            row.insert(k.to_string(), v.clone());
374        }
375        row
376    }
377
378    #[test]
379    fn test_cascade_no_dependents() {
380        let config = DatabaseConfig {
381            name: "test".into(),
382            foreign_keys: vec![],
383            views: vec![],
384            sync: None,
385        };
386        let mut tables: HashMap<String, (Schema, Vec<Row>)> = HashMap::new();
387        tables.insert("strats".into(), (test_schema("strats"), vec![
388            make_row("alpha.md", &[("title", Value::String("Alpha".into()))]),
389        ]));
390
391        let plan = build_cascade_plan("strats", &["alpha.md".into()], &config, &tables);
392        assert_eq!(plan.primary_deletes.len(), 1);
393        assert!(plan.cascade_actions.is_empty());
394    }
395
396    #[test]
397    fn test_cascade_single_level() {
398        let config = DatabaseConfig {
399            name: "test".into(),
400            foreign_keys: vec![ForeignKey {
401                from_table: "backtests".into(),
402                from_column: "strategy".into(),
403                to_table: "strats".into(),
404                to_column: "path".into(),
405            }],
406            views: vec![],
407            sync: None,
408        };
409        let mut tables: HashMap<String, (Schema, Vec<Row>)> = HashMap::new();
410        tables.insert("strats".into(), (test_schema("strats"), vec![
411            make_row("alpha.md", &[]),
412        ]));
413        tables.insert("backtests".into(), (test_schema("backtests"), vec![
414            make_row("bt-alpha.md", &[("strategy", Value::String("alpha.md".into()))]),
415            make_row("bt-beta.md", &[("strategy", Value::String("beta.md".into()))]),
416        ]));
417
418        let plan = build_cascade_plan("strats", &["alpha.md".into()], &config, &tables);
419        assert_eq!(plan.primary_deletes.len(), 1);
420        assert_eq!(plan.cascade_actions.len(), 1);
421        assert!(matches!(&plan.cascade_actions[0], CascadeAction::Delete { table, filename }
422            if table == "backtests" && filename == "bt-alpha.md"));
423    }
424
425    #[test]
426    fn test_cascade_multi_level() {
427        let config = DatabaseConfig {
428            name: "test".into(),
429            foreign_keys: vec![
430                ForeignKey {
431                    from_table: "backtests".into(),
432                    from_column: "strategy".into(),
433                    to_table: "strats".into(),
434                    to_column: "path".into(),
435                },
436                ForeignKey {
437                    from_table: "events".into(),
438                    from_column: "backtest".into(),
439                    to_table: "backtests".into(),
440                    to_column: "path".into(),
441                },
442            ],
443            views: vec![],
444            sync: None,
445        };
446        let mut tables: HashMap<String, (Schema, Vec<Row>)> = HashMap::new();
447        tables.insert("strats".into(), (test_schema("strats"), vec![
448            make_row("alpha.md", &[]),
449        ]));
450        tables.insert("backtests".into(), (test_schema("backtests"), vec![
451            make_row("bt-alpha.md", &[("strategy", Value::String("alpha.md".into()))]),
452        ]));
453        tables.insert("events".into(), (test_schema("events"), vec![
454            make_row("ev-1.md", &[("backtest", Value::String("bt-alpha.md".into()))]),
455        ]));
456
457        let plan = build_cascade_plan("strats", &["alpha.md".into()], &config, &tables);
458        assert_eq!(plan.primary_deletes.len(), 1);
459        assert_eq!(plan.cascade_actions.len(), 2);
460    }
461
462    #[test]
463    fn test_cascade_list_prune() {
464        let config = DatabaseConfig {
465            name: "test".into(),
466            foreign_keys: vec![ForeignKey {
467                from_table: "strats".into(),
468                from_column: "ancestry".into(),
469                to_table: "strats".into(),
470                to_column: "path".into(),
471            }],
472            views: vec![],
473            sync: None,
474        };
475        let mut tables: HashMap<String, (Schema, Vec<Row>)> = HashMap::new();
476        tables.insert("strats".into(), (test_schema("strats"), vec![
477            make_row("alpha.md", &[]),
478            make_row("beta.md", &[("ancestry", Value::List(vec![
479                "alpha.md".to_string(), "gamma.md".to_string(),
480            ]))]),
481        ]));
482
483        let plan = build_cascade_plan("strats", &["alpha.md".into()], &config, &tables);
484        assert_eq!(plan.primary_deletes.len(), 1);
485        assert_eq!(plan.cascade_actions.len(), 1);
486        assert!(matches!(&plan.cascade_actions[0], CascadeAction::PruneList { column, value_to_remove, .. }
487            if column == "ancestry" && value_to_remove == "alpha.md"));
488    }
489
490    #[test]
491    fn test_cascade_self_referential_no_loop() {
492        let config = DatabaseConfig {
493            name: "test".into(),
494            foreign_keys: vec![ForeignKey {
495                from_table: "strats".into(),
496                from_column: "parent".into(),
497                to_table: "strats".into(),
498                to_column: "path".into(),
499            }],
500            views: vec![],
501            sync: None,
502        };
503        let mut tables: HashMap<String, (Schema, Vec<Row>)> = HashMap::new();
504        tables.insert("strats".into(), (test_schema("strats"), vec![
505            make_row("alpha.md", &[("parent", Value::String("beta.md".into()))]),
506            make_row("beta.md", &[("parent", Value::String("alpha.md".into()))]),
507        ]));
508
509        let plan = build_cascade_plan("strats", &["alpha.md".into()], &config, &tables);
510        assert_eq!(plan.primary_deletes.len(), 1);
511        assert_eq!(plan.cascade_actions.len(), 1);
512        assert!(matches!(&plan.cascade_actions[0], CascadeAction::Delete { filename, .. }
513            if filename == "beta.md"));
514    }
515
516    #[test]
517    fn test_restrict_blocks() {
518        let config = DatabaseConfig {
519            name: "test".into(),
520            foreign_keys: vec![ForeignKey {
521                from_table: "backtests".into(),
522                from_column: "strategy".into(),
523                to_table: "strats".into(),
524                to_column: "path".into(),
525            }],
526            views: vec![],
527            sync: None,
528        };
529        let mut tables: HashMap<String, (Schema, Vec<Row>)> = HashMap::new();
530        tables.insert("strats".into(), (test_schema("strats"), vec![
531            make_row("alpha.md", &[]),
532        ]));
533        tables.insert("backtests".into(), (test_schema("backtests"), vec![
534            make_row("bt-alpha.md", &[("strategy", Value::String("alpha.md".into()))]),
535        ]));
536
537        let plan = build_restrict_plan("strats", &["alpha.md".into()], &config, &tables);
538        assert!(!plan.restrict_violations.is_empty());
539    }
540
541    #[test]
542    fn test_restrict_allows() {
543        let config = DatabaseConfig {
544            name: "test".into(),
545            foreign_keys: vec![ForeignKey {
546                from_table: "backtests".into(),
547                from_column: "strategy".into(),
548                to_table: "strats".into(),
549                to_column: "path".into(),
550            }],
551            views: vec![],
552            sync: None,
553        };
554        let mut tables: HashMap<String, (Schema, Vec<Row>)> = HashMap::new();
555        tables.insert("strats".into(), (test_schema("strats"), vec![
556            make_row("alpha.md", &[]),
557        ]));
558        tables.insert("backtests".into(), (test_schema("backtests"), vec![
559            make_row("bt-beta.md", &[("strategy", Value::String("beta.md".into()))]),
560        ]));
561
562        let plan = build_restrict_plan("strats", &["alpha.md".into()], &config, &tables);
563        assert!(plan.restrict_violations.is_empty());
564    }
565
566    #[test]
567    fn test_prune_list_value() {
568        let content = "---\ntitle: Test\nancestry:\n  - alpha.md\n  - gamma.md\n---\n\n# Test\n";
569        let result = prune_list_value(content, "ancestry", "alpha.md");
570        assert!(!result.contains("alpha.md"));
571        assert!(result.contains("gamma.md"));
572        assert!(result.contains("title: Test"));
573    }
574}