Skip to main content

pylon_plugin/builtin/
cascade.rs

1use std::sync::Mutex;
2
3use crate::Plugin;
4use pylon_auth::AuthContext;
5
6/// A cascade rule: when parent is deleted, delete children.
7#[derive(Debug, Clone)]
8pub struct CascadeRule {
9    /// Parent entity name.
10    pub parent: String,
11    /// Child entity name.
12    pub child: String,
13    /// Foreign key field on the child that references the parent.
14    pub foreign_key: String,
15}
16
17/// Cascade delete plugin. Automatically deletes child rows when a parent is deleted.
18/// Queues deletions to be executed by the runtime.
19pub struct CascadePlugin {
20    rules: Vec<CascadeRule>,
21    pending_deletes: Mutex<Vec<(String, String)>>, // (entity, id) pairs to delete
22}
23
24impl CascadePlugin {
25    pub fn new() -> Self {
26        Self {
27            rules: Vec::new(),
28            pending_deletes: Mutex::new(Vec::new()),
29        }
30    }
31
32    /// Add a cascade rule.
33    pub fn add_rule(&mut self, parent: &str, child: &str, foreign_key: &str) {
34        self.rules.push(CascadeRule {
35            parent: parent.to_string(),
36            child: child.to_string(),
37            foreign_key: foreign_key.to_string(),
38        });
39    }
40
41    /// Get pending cascade deletions (the runtime should execute these).
42    pub fn take_pending(&self) -> Vec<(String, String)> {
43        let mut pending = self.pending_deletes.lock().unwrap();
44        let items = pending.clone();
45        pending.clear();
46        items
47    }
48
49    /// Get cascade rules for an entity.
50    pub fn rules_for(&self, parent: &str) -> Vec<&CascadeRule> {
51        self.rules.iter().filter(|r| r.parent == parent).collect()
52    }
53}
54
55impl Plugin for CascadePlugin {
56    fn name(&self) -> &str {
57        "cascade-delete"
58    }
59
60    fn after_delete(&self, entity: &str, id: &str, _auth: &AuthContext) {
61        // When a parent is deleted, queue child deletions.
62        let rules = self.rules_for(entity);
63        if !rules.is_empty() {
64            let mut pending = self.pending_deletes.lock().unwrap();
65            for rule in rules {
66                // Queue a "find and delete children" marker.
67                // The runtime needs to: SELECT id FROM child WHERE foreign_key = parent_id, then DELETE each.
68                pending.push((
69                    rule.child.clone(),
70                    format!("__cascade__{}={}", rule.foreign_key, id),
71                ));
72            }
73        }
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn queues_cascade_on_delete() {
83        let mut plugin = CascadePlugin::new();
84        plugin.add_rule("User", "Todo", "authorId");
85
86        plugin.after_delete("User", "u1", &AuthContext::anonymous());
87
88        let pending = plugin.take_pending();
89        assert_eq!(pending.len(), 1);
90        assert_eq!(pending[0].0, "Todo");
91        assert!(pending[0].1.contains("authorId=u1"));
92    }
93
94    #[test]
95    fn no_rules_no_cascade() {
96        let plugin = CascadePlugin::new();
97        plugin.after_delete("User", "u1", &AuthContext::anonymous());
98        assert!(plugin.take_pending().is_empty());
99    }
100
101    #[test]
102    fn multiple_children() {
103        let mut plugin = CascadePlugin::new();
104        plugin.add_rule("User", "Todo", "authorId");
105        plugin.add_rule("User", "Comment", "userId");
106
107        plugin.after_delete("User", "u1", &AuthContext::anonymous());
108
109        let pending = plugin.take_pending();
110        assert_eq!(pending.len(), 2);
111    }
112
113    #[test]
114    fn take_clears_pending() {
115        let mut plugin = CascadePlugin::new();
116        plugin.add_rule("User", "Todo", "authorId");
117
118        plugin.after_delete("User", "u1", &AuthContext::anonymous());
119        let first = plugin.take_pending();
120        assert_eq!(first.len(), 1);
121
122        let second = plugin.take_pending();
123        assert!(second.is_empty());
124    }
125
126    #[test]
127    fn unrelated_entity_no_cascade() {
128        let mut plugin = CascadePlugin::new();
129        plugin.add_rule("User", "Todo", "authorId");
130
131        plugin.after_delete("Post", "p1", &AuthContext::anonymous());
132        assert!(plugin.take_pending().is_empty());
133    }
134}