Skip to main content

mongreldb_kit_core/
planner.rs

1//! Delete planning: compute the set of rows affected by a delete, including
2//! cascade, set-null, and restrict foreign-key actions.
3
4use crate::schema::{ForeignKey, ForeignKeyAction, Schema, Table};
5
6/// A row identified for deletion planning.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct RowRef {
9    pub table: String,
10    pub pk: String,
11}
12
13/// A child row that must be updated to set its foreign-key columns to null.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct SetNullUpdate {
16    pub table: String,
17    pub pk: String,
18    pub columns: Vec<String>,
19}
20
21/// A foreign-key constraint that blocks the delete.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct RestrictedConstraint {
24    pub table: String,
25    pub constraint: String,
26}
27
28/// Result of planning a delete against a schema.
29#[derive(Debug, Clone, PartialEq, Eq, Default)]
30pub struct DeletePlan {
31    /// Rows to delete, in an order that respects dependencies (children first).
32    pub delete: Vec<RowRef>,
33    /// Child rows whose foreign-key columns must be nulled before the parent is deleted.
34    pub set_null: Vec<SetNullUpdate>,
35    /// Constraints that would be violated if the delete proceeded.
36    pub restricted: Vec<RestrictedConstraint>,
37}
38
39/// Errors returned by delete planning.
40#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
41pub enum PlannerError {
42    #[error("table \"{0}\" not found")]
43    TableNotFound(String),
44    #[error("circular delete detected involving table \"{0}\"")]
45    CircularDelete(String),
46}
47
48/// Plan the deletion of a single row identified by its table and primary key.
49///
50/// The plan does not interact with storage; callers supply child row lookup via
51/// the `find_children` closure. This keeps the core crate storage-agnostic.
52pub fn plan_delete<F>(
53    schema: &Schema,
54    table_name: &str,
55    pk: &str,
56    find_children: F,
57) -> Result<DeletePlan, PlannerError>
58where
59    F: Fn(&Table, &ForeignKey, &str) -> Vec<(String, String)>,
60{
61    let mut plan = DeletePlan::default();
62    let mut path = Vec::new();
63    let mut deleted = std::collections::HashSet::new();
64
65    plan_delete_recursive(
66        schema,
67        table_name,
68        pk,
69        &find_children,
70        &mut path,
71        &mut deleted,
72        &mut plan,
73    )?;
74
75    Ok(plan)
76}
77
78fn plan_delete_recursive<F>(
79    schema: &Schema,
80    table_name: &str,
81    pk: &str,
82    find_children: &F,
83    path: &mut Vec<String>,
84    deleted: &mut std::collections::HashSet<String>,
85    plan: &mut DeletePlan,
86) -> Result<bool, PlannerError>
87where
88    F: Fn(&Table, &ForeignKey, &str) -> Vec<(String, String)>,
89{
90    let visit_key = format!("{table_name}:{pk}");
91
92    if deleted.contains(&visit_key) {
93        return Ok(true);
94    }
95    if path.contains(&visit_key) {
96        return Err(PlannerError::CircularDelete(table_name.into()));
97    }
98
99    path.push(visit_key.clone());
100
101    let _table = schema
102        .table(table_name)
103        .ok_or_else(|| PlannerError::TableNotFound(table_name.into()))?
104        .clone();
105
106    // Snapshot the plan state before recursing into children. If any descendant
107    // restricts the delete, we must discard the cascade/set-null entries that
108    // were staged on this branch so a caller applying the plan cannot orphan
109    // children while `restricted` is non-empty.
110    let delete_snapshot = plan.delete.len();
111    let set_null_snapshot = plan.set_null.len();
112    let mut restricted = false;
113
114    for child_table in &schema.tables {
115        for fk in &child_table.foreign_keys {
116            if fk.references_table != table_name {
117                continue;
118            }
119
120            for (child_pk, _parent_pk_hint) in find_children(child_table, fk, pk) {
121                match fk.on_delete {
122                    ForeignKeyAction::Restrict => {
123                        plan.restricted.push(RestrictedConstraint {
124                            table: child_table.name.clone(),
125                            constraint: fk.name.clone(),
126                        });
127                        restricted = true;
128                    }
129                    ForeignKeyAction::SetNull => {
130                        plan.set_null.push(SetNullUpdate {
131                            table: child_table.name.clone(),
132                            pk: child_pk,
133                            columns: fk.columns.clone(),
134                        });
135                    }
136                    ForeignKeyAction::Cascade => {
137                        let child_deletable = plan_delete_recursive(
138                            schema,
139                            &child_table.name,
140                            &child_pk,
141                            find_children,
142                            path,
143                            deleted,
144                            plan,
145                        )?;
146                        if !child_deletable {
147                            restricted = true;
148                        }
149                    }
150                }
151            }
152        }
153    }
154
155    path.pop();
156
157    if restricted {
158        // Discard any cascade deletes and set-null updates staged on this
159        // branch. The plan stays consistent: either every affected row is
160        // accounted for, or `restricted` explains why the delete cannot run.
161        plan.delete.truncate(delete_snapshot);
162        plan.set_null.truncate(set_null_snapshot);
163        return Ok(false);
164    }
165
166    plan.delete.push(RowRef {
167        table: table_name.into(),
168        pk: pk.into(),
169    });
170    deleted.insert(visit_key);
171    Ok(true)
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::schema::{Column, ColumnType, ForeignKey, Table};
178
179    fn table(name: &str, id: u32, pk_col: &str) -> Table {
180        Table {
181            id,
182            name: name.into(),
183            columns: vec![Column::new(1, pk_col, ColumnType::Text)],
184            primary_key: vec![pk_col.into()],
185            indexes: vec![],
186            foreign_keys: vec![],
187            unique_constraints: vec![],
188            check_constraints: vec![],
189        }
190    }
191
192    fn fk(
193        name: &str,
194        cols: &[&str],
195        parent: &str,
196        parent_cols: &[&str],
197        action: ForeignKeyAction,
198    ) -> ForeignKey {
199        ForeignKey {
200            name: name.into(),
201            columns: cols.iter().map(|c| (*c).into()).collect(),
202            references_table: parent.into(),
203            references_columns: parent_cols.iter().map(|c| (*c).into()).collect(),
204            on_delete: action,
205        }
206    }
207
208    /// Child lookup that returns a single hard-coded child per FK.
209    fn single_child(
210        child_pk: &str,
211    ) -> impl Fn(&Table, &ForeignKey, &str) -> Vec<(String, String)> + use<'_> {
212        move |_table: &Table, _fk: &ForeignKey, _parent_pk: &str| {
213            vec![(child_pk.into(), _parent_pk.into())]
214        }
215    }
216
217    #[test]
218    fn restrict_blocks_delete() {
219        let parent = table("parent", 1, "id");
220        let mut child = table("child", 2, "id");
221        child.foreign_keys = vec![fk(
222            "fk_child_parent",
223            &["parent_id"],
224            "parent",
225            &["id"],
226            ForeignKeyAction::Restrict,
227        )];
228        child
229            .columns
230            .push(Column::new(2, "parent_id", ColumnType::Text));
231
232        let schema = Schema::new(vec![parent, child]).unwrap();
233        let plan = plan_delete(&schema, "parent", "p1", single_child("c1")).unwrap();
234
235        assert!(plan.delete.is_empty());
236        assert!(plan.set_null.is_empty());
237        assert_eq!(
238            plan.restricted,
239            vec![RestrictedConstraint {
240                table: "child".into(),
241                constraint: "fk_child_parent".into(),
242            }]
243        );
244    }
245
246    #[test]
247    fn cascade_deletes_children() {
248        let parent = table("parent", 1, "id");
249        let mut child = table("child", 2, "id");
250        child.foreign_keys = vec![fk(
251            "fk_child_parent",
252            &["parent_id"],
253            "parent",
254            &["id"],
255            ForeignKeyAction::Cascade,
256        )];
257        child
258            .columns
259            .push(Column::new(2, "parent_id", ColumnType::Text));
260
261        let schema = Schema::new(vec![parent, child]).unwrap();
262        let plan = plan_delete(&schema, "parent", "p1", single_child("c1")).unwrap();
263
264        assert!(plan.set_null.is_empty());
265        assert!(plan.restricted.is_empty());
266        assert_eq!(plan.delete.len(), 2);
267        assert_eq!(plan.delete[0].table, "child");
268        assert_eq!(plan.delete[1].table, "parent");
269    }
270
271    #[test]
272    fn set_null_updates_children() {
273        let parent = table("parent", 1, "id");
274        let mut child = table("child", 2, "id");
275        child.foreign_keys = vec![fk(
276            "fk_child_parent",
277            &["parent_id"],
278            "parent",
279            &["id"],
280            ForeignKeyAction::SetNull,
281        )];
282        child
283            .columns
284            .push(Column::new(2, "parent_id", ColumnType::Text));
285
286        let schema = Schema::new(vec![parent, child]).unwrap();
287        let plan = plan_delete(&schema, "parent", "p1", single_child("c1")).unwrap();
288
289        assert!(plan.restricted.is_empty());
290        assert_eq!(plan.delete.len(), 1);
291        assert_eq!(plan.set_null.len(), 1);
292        assert_eq!(plan.set_null[0].columns, vec!["parent_id"]);
293    }
294
295    #[test]
296    fn detects_circular_delete() {
297        let mut a = table("a", 1, "id");
298        a.foreign_keys = vec![fk(
299            "fk_a_b",
300            &["b_id"],
301            "b",
302            &["id"],
303            ForeignKeyAction::Cascade,
304        )];
305        a.columns.push(Column::new(2, "b_id", ColumnType::Text));
306
307        let mut b = table("b", 2, "id");
308        b.foreign_keys = vec![fk(
309            "fk_b_a",
310            &["a_id"],
311            "a",
312            &["id"],
313            ForeignKeyAction::Cascade,
314        )];
315        b.columns.push(Column::new(2, "a_id", ColumnType::Text));
316
317        let schema = Schema::new(vec![a, b]).unwrap();
318        let lookup = |_table: &Table, _fk: &ForeignKey, parent_pk: &str| {
319            vec![(
320                parent_pk.to_string().replace('p', "other"),
321                parent_pk.into(),
322            )]
323        };
324        let err = plan_delete(&schema, "a", "a1", lookup).unwrap_err();
325        assert!(matches!(err, PlannerError::CircularDelete(_)));
326    }
327
328    #[test]
329    fn restrict_in_sibling_discards_cascaded_deletes() {
330        // parent has two children: cascade_child (cascade) and restrict_child
331        // (restrict). Both report a single child row. Without snapshot/restore
332        // the cascade_child would be pushed into plan.delete; the parent would
333        // then be marked restricted, and a caller applying plan.delete would
334        // orphan cascade_child. The fix discards the cascade subtree when any
335        // sibling is restricted.
336        let parent = table("parent", 1, "id");
337
338        let mut cascade_child = table("cascade_child", 2, "id");
339        cascade_child.foreign_keys = vec![fk(
340            "fk_cascade",
341            &["parent_id"],
342            "parent",
343            &["id"],
344            ForeignKeyAction::Cascade,
345        )];
346        cascade_child
347            .columns
348            .push(Column::new(2, "parent_id", ColumnType::Text));
349
350        let mut restrict_child = table("restrict_child", 3, "id");
351        restrict_child.foreign_keys = vec![fk(
352            "fk_restrict",
353            &["parent_id"],
354            "parent",
355            &["id"],
356            ForeignKeyAction::Restrict,
357        )];
358        restrict_child
359            .columns
360            .push(Column::new(2, "parent_id", ColumnType::Text));
361
362        let schema = Schema::new(vec![parent, cascade_child, restrict_child]).unwrap();
363
364        let lookup = |table: &Table, _fk: &ForeignKey, parent_pk: &str| {
365            // Each FK on each child table reports exactly one child row keyed
366            // by the table name so they are distinguishable in assertions.
367            vec![[(table.name.as_str(), parent_pk.to_string().as_str())]]
368                .into_iter()
369                .flatten()
370                .map(|(t, p)| (format!("{t}_row_for_{p}"), p.to_string()))
371                .collect()
372        };
373
374        let plan = plan_delete(&schema, "parent", "p1", lookup).unwrap();
375
376        // Restrict should win: nothing should be deletable, and the cascade
377        // child must NOT appear in plan.delete.
378        assert!(
379            plan.delete.is_empty(),
380            "expected no deletes when a sibling restricts, got {:?}",
381            plan.delete
382        );
383        assert_eq!(
384            plan.restricted,
385            vec![RestrictedConstraint {
386                table: "restrict_child".into(),
387                constraint: "fk_restrict".into(),
388            }]
389        );
390    }
391}