1use crate::schema::{ForeignKey, ForeignKeyAction, Schema, Table};
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct RowRef {
9 pub table: String,
10 pub pk: String,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct SetNullUpdate {
16 pub table: String,
17 pub pk: String,
18 pub columns: Vec<String>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct RestrictedConstraint {
24 pub table: String,
25 pub constraint: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
30pub struct DeletePlan {
31 pub delete: Vec<RowRef>,
33 pub set_null: Vec<SetNullUpdate>,
35 pub restricted: Vec<RestrictedConstraint>,
37}
38
39#[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
48pub 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 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 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 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 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 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 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}