rust_ef/cascade.rs
1//! Cascade save: drain children from principal HasMany navigations, FK fixup
2//! after principal PK backfill, and M2M join-row insertion via direct SQL.
3//!
4//! See `cascade-save-plan.md` for the full design. Key types:
5//! - [`DrainedChild`]: a child entity extracted from a principal's HasMany,
6//! with parent linkage metadata.
7//! - [`FixupLink`]: records the parent→child association for post-INSERT FK
8//! fixup (one-to-many) or join-row insertion (M2M).
9
10use crate::metadata::{NavigationKind, NavigationMeta};
11use crate::relations::DeleteBehavior;
12use std::any::{Any, TypeId};
13
14/// A child entity drained from a principal's HasMany navigation, with the
15/// metadata needed to fixup its FK after the principal's PK is backfilled.
16pub struct DrainedChild {
17 /// The principal entity's TypeId (e.g. `Blog`).
18 pub parent_type_id: TypeId,
19 /// Index of the principal entry in its DbSet (for PK lookup after INSERT).
20 pub parent_entry_idx: usize,
21 /// The drained child entity, type-erased.
22 pub child: Box<dyn Any + Send + Sync>,
23 /// The child entity's TypeId (e.g. `Post`).
24 pub child_type_id: TypeId,
25 /// The FK target type for `set_foreign_key` (the principal type).
26 pub fk_target_type_id: TypeId,
27 /// M2M only: the join table name. `None` for one-to-many.
28 pub through_table: Option<String>,
29 /// M2M only: FK column on the join table pointing to the principal.
30 pub through_parent_fk_col: Option<String>,
31 /// M2M only: FK column on the join table pointing to the child.
32 pub through_child_fk_col: Option<String>,
33}
34
35/// Records a parent→child association for post-INSERT FK fixup.
36///
37/// For one-to-many: after the principal is inserted and its PK backfilled,
38/// each child's FK field is set to the principal's PK via `set_foreign_key`.
39///
40/// For M2M: after both principal and child are inserted and their PKs
41/// backfilled, a join-table row is inserted via direct SQL.
42pub struct FixupLink {
43 pub parent_type_id: TypeId,
44 pub parent_entry_idx: usize,
45 pub child_type_id: TypeId,
46 pub child_entry_indices: Vec<usize>,
47 pub fk_target_type_id: TypeId,
48 /// M2M only: the join table name. `None` for one-to-many.
49 pub through_table: Option<String>,
50 pub through_parent_fk_col: Option<String>,
51 pub through_child_fk_col: Option<String>,
52}
53
54/// Generates a batched `INSERT INTO through_table (parent_col, child_col)
55/// VALUES (?, ?), (?, ?), ...` statement for M2M join rows.
56pub fn m2m_insert_sql(table: &str, parent_col: &str, child_col: &str, row_count: usize) -> String {
57 let placeholders: Vec<String> = (0..row_count).map(|_| "(?, ?)".to_string()).collect();
58 format!(
59 "INSERT INTO {} ({}, {}) VALUES {}",
60 table,
61 parent_col,
62 child_col,
63 placeholders.join(", ")
64 )
65}
66
67// ---------------------------------------------------------------------------
68// Cascade delete types
69// ---------------------------------------------------------------------------
70
71/// The action to take for untracked dependents when a principal is deleted.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum CascadeDeleteAction {
74 /// `DELETE FROM child_table WHERE fk = ?`
75 Delete,
76 /// `UPDATE child_table SET fk = NULL WHERE fk = ?`
77 SetNull,
78}
79
80/// A directive to delete or nullify untracked dependents of a Deleted
81/// principal. Generated during the cascade delete drain loop and executed
82/// as direct SQL at the start of the DELETE phase, before PK-based deletes.
83pub struct CascadeDeleteDirective {
84 /// The table to act on (child table for HasMany, join table for M2M).
85 pub table: String,
86 /// The FK column to filter on.
87 pub fk_column: String,
88 /// The principal's PK value.
89 pub principal_pk: i64,
90 /// The action to perform.
91 pub action: CascadeDeleteAction,
92}
93
94// ---------------------------------------------------------------------------
95// Delete behavior resolution
96// ---------------------------------------------------------------------------
97
98/// Resolves the effective `DeleteBehavior` for a navigation, applying
99/// EFCore-style defaults when `delete_behavior` is `None`:
100/// - ManyToMany → Cascade (join rows are always pruned)
101/// - required FK (non-nullable type, e.g. `i32`) → Cascade
102/// - optional FK (nullable type, e.g. `Option<i32>`) → Restrict
103pub fn resolve_delete_behavior(nav: &NavigationMeta) -> DeleteBehavior {
104 if let Some(b) = nav.delete_behavior {
105 return b;
106 }
107 if nav.kind == NavigationKind::ManyToMany {
108 return DeleteBehavior::Cascade;
109 }
110 if let Some(meta_fn) = nav.related_entity_meta {
111 let child_meta = meta_fn();
112 if let Some(fk_prop) = child_meta.properties.iter().find(|p| p.is_foreign_key) {
113 let is_nullable = fk_prop.type_name.contains("Option");
114 return if is_nullable {
115 DeleteBehavior::Restrict
116 } else {
117 DeleteBehavior::Cascade
118 };
119 }
120 }
121 DeleteBehavior::Cascade
122}