1use 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}