Skip to main content

rust_ef/
tracking.rs

1//! Change tracking — entity state management, snapshots, and detection.
2//!
3//! `ChangeTracker` is the single authoritative source for entity state, original
4//! snapshots, and modified-property lists. `DbSet` holds only the typed entity
5//! + `entry_id`; tracking state lives here, joined by `entry_id` during save.
6
7use crate::entity::EntityState;
8use crate::entity_snapshot::EntitySnapshot;
9use std::any::TypeId;
10use std::collections::HashMap;
11
12/// Tracks changes to entities within a DbContext.
13///
14/// The single source of truth for `EntityState`, original `EntitySnapshot`,
15/// `modified_properties`, and `is_upsert` across all tracked entities.
16/// `DbSet<T>` holds the typed entity + `entry_id`; this tracker holds the
17/// tracking state, joined by `entry_id` during `save_changes`.
18#[derive(Debug)]
19pub struct ChangeTracker {
20    entries: HashMap<u64, TrackerEntry>,
21    auto_detect_changes: bool,
22    next_id: u64,
23}
24
25/// A public read-only view of a tracked entry.
26#[derive(Debug, Clone)]
27pub struct EntityEntry {
28    pub entry_id: u64,
29    pub type_id: TypeId,
30    pub type_name: String,
31    pub state: EntityState,
32    pub modified_properties: Vec<String>,
33}
34
35/// Lightweight, type-erased view of a pending entity entry used to build
36/// `SaveChangesContext` for interceptors.
37#[derive(Debug, Clone)]
38pub struct EntityEntryView {
39    pub type_id: TypeId,
40    pub type_name: String,
41    pub state: EntityState,
42}
43
44/// Internal tracker entry — stores state, original snapshot, and modified
45/// property names for a single tracked entity.
46struct TrackerEntry {
47    id: u64,
48    type_id: TypeId,
49    type_name: String,
50    state: EntityState,
51    original: Option<EntitySnapshot>,
52    modified_properties: Vec<String>,
53    is_upsert: bool,
54}
55
56impl std::fmt::Debug for TrackerEntry {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.debug_struct("TrackerEntry")
59            .field("id", &self.id)
60            .field("type_name", &self.type_name)
61            .field("state", &self.state)
62            .finish()
63    }
64}
65
66impl ChangeTracker {
67    pub fn new() -> Self {
68        Self {
69            entries: HashMap::new(),
70            auto_detect_changes: true,
71            next_id: 0,
72        }
73    }
74
75    /// Begins tracking an entity with the given state and optional original
76    /// snapshot. Returns the assigned `entry_id` for joining with `DbSet`.
77    pub fn track(
78        &mut self,
79        type_id: TypeId,
80        type_name: &str,
81        state: EntityState,
82        original: Option<EntitySnapshot>,
83        is_upsert: bool,
84    ) -> u64 {
85        let id = self.next_id;
86        self.next_id += 1;
87        self.entries.insert(
88            id,
89            TrackerEntry {
90                id,
91                type_id,
92                type_name: type_name.to_string(),
93                state,
94                original,
95                modified_properties: Vec::new(),
96                is_upsert,
97            },
98        );
99        id
100    }
101
102    /// Compares current property snapshots against stored originals.
103    ///
104    /// For each `Unchanged` entry whose `original` exists, if the current
105    /// snapshot differs, transitions to `Modified` and records the changed
106    /// field names in `modified_properties`.
107    pub fn detect_changes(&mut self, current_snapshots: &[(u64, EntitySnapshot)]) {
108        let snap_map: HashMap<u64, &EntitySnapshot> =
109            current_snapshots.iter().map(|(id, s)| (*id, s)).collect();
110
111        for entry in self.entries.values_mut() {
112            if entry.state != EntityState::Unchanged {
113                continue;
114            }
115            let Some(original) = &entry.original else {
116                continue;
117            };
118            let Some(current) = snap_map.get(&entry.id) else {
119                continue;
120            };
121
122            if **current == *original {
123                entry.modified_properties.clear();
124                continue;
125            }
126
127            let changed: Vec<String> = current
128                .iter()
129                .filter(|(k, v)| original.get(k) != Some(*v))
130                .map(|(k, _)| k.to_string())
131                .collect();
132
133            if !changed.is_empty() {
134                entry.state = EntityState::Modified;
135                entry.modified_properties = changed;
136            }
137        }
138    }
139
140    // ── Query methods (read path for save phases) ──
141
142    pub fn entry_state(&self, entry_id: u64) -> Option<EntityState> {
143        self.entries.get(&entry_id).map(|e| e.state)
144    }
145
146    pub fn entry_original(&self, entry_id: u64) -> Option<&EntitySnapshot> {
147        self.entries
148            .get(&entry_id)
149            .and_then(|e| e.original.as_ref())
150    }
151
152    pub fn entry_modified(&self, entry_id: u64) -> Option<&[String]> {
153        self.entries
154            .get(&entry_id)
155            .map(|e| e.modified_properties.as_slice())
156    }
157
158    pub fn entry_is_upsert(&self, entry_id: u64) -> bool {
159        self.entries.get(&entry_id).is_some_and(|e| e.is_upsert)
160    }
161
162    // ── Mutation methods ──
163
164    pub fn set_state(&mut self, entry_id: u64, state: EntityState) {
165        if let Some(entry) = self.entries.get_mut(&entry_id) {
166            entry.state = state;
167        }
168    }
169
170    pub fn set_modified(&mut self, entry_id: u64, modified: Vec<String>) {
171        if let Some(entry) = self.entries.get_mut(&entry_id) {
172            entry.modified_properties = modified;
173        }
174    }
175
176    /// After successful SaveChanges:
177    /// - Deleted entries are removed
178    /// - Added/Modified → Unchanged (original refreshed to current snapshot)
179    /// - `modified_properties` cleared
180    ///
181    /// `current_snapshots` provides the post-save entity snapshots (with
182    /// backfilled auto-increment PKs) to become the new `original`.
183    pub fn accept_all_changes(&mut self, current_snapshots: &[(u64, EntitySnapshot)]) {
184        let snap_map: HashMap<u64, &EntitySnapshot> =
185            current_snapshots.iter().map(|(id, s)| (*id, s)).collect();
186
187        self.entries.retain(|_, e| e.state != EntityState::Deleted);
188
189        for (id, entry) in &mut self.entries {
190            if entry.state == EntityState::Added || entry.state == EntityState::Modified {
191                entry.state = EntityState::Unchanged;
192                entry.modified_properties.clear();
193                if let Some(snap) = snap_map.get(id) {
194                    entry.original = Some((*snap).clone());
195                }
196            }
197        }
198    }
199
200    /// Reverts all pending changes:
201    /// - Added entries are removed
202    /// - Modified/Deleted → Unchanged (original stays as-is)
203    /// - `modified_properties` cleared
204    pub fn reject_all_changes(&mut self) {
205        self.entries.retain(|_, e| e.state != EntityState::Added);
206        for entry in self.entries.values_mut() {
207            if entry.state == EntityState::Modified || entry.state == EntityState::Deleted {
208                entry.state = EntityState::Unchanged;
209                entry.modified_properties.clear();
210            }
211        }
212    }
213
214    /// Detaches a specific entry by ID.
215    pub fn detach(&mut self, entry_id: u64) {
216        self.entries.remove(&entry_id);
217    }
218
219    /// Removes all tracked entries of the given type. Used by `load_all` to
220    /// clear stale tracking state before re-attaching fresh entities.
221    pub fn clear_by_type(&mut self, type_id: TypeId) {
222        self.entries.retain(|_, e| e.type_id != type_id);
223    }
224
225    // ── Aggregate queries ──
226
227    pub fn has_changes(&self) -> bool {
228        self.entries.values().any(|e| {
229            matches!(
230                e.state,
231                EntityState::Added | EntityState::Modified | EntityState::Deleted
232            )
233        })
234    }
235
236    pub fn clear(&mut self) {
237        self.entries.clear();
238    }
239
240    pub fn entries(&self) -> Vec<EntityEntry> {
241        self.entries
242            .values()
243            .map(|e| EntityEntry {
244                entry_id: e.id,
245                type_id: e.type_id,
246                type_name: e.type_name.clone(),
247                state: e.state,
248                modified_properties: e.modified_properties.clone(),
249            })
250            .collect()
251    }
252
253    pub fn count_by_state(&self, state: EntityState) -> usize {
254        self.entries.values().filter(|e| e.state == state).count()
255    }
256
257    pub fn entries_by_state(&self, state: EntityState) -> Vec<EntityEntry> {
258        self.entries
259            .values()
260            .filter(|e| e.state == state)
261            .map(|e| EntityEntry {
262                entry_id: e.id,
263                type_id: e.type_id,
264                type_name: e.type_name.clone(),
265                state: e.state,
266                modified_properties: e.modified_properties.clone(),
267            })
268            .collect()
269    }
270
271    /// Returns `(type_id, type_name, state)` views for all tracked entries,
272    /// used to build `SaveChangesContext` for interceptors.
273    pub fn entry_views(&self) -> Vec<EntityEntryView> {
274        self.entries
275            .values()
276            .map(|e| EntityEntryView {
277                type_id: e.type_id,
278                type_name: e.type_name.clone(),
279                state: e.state,
280            })
281            .collect()
282    }
283
284    pub fn is_auto_detect_changes_enabled(&self) -> bool {
285        self.auto_detect_changes
286    }
287
288    pub fn set_auto_detect_changes(&mut self, enabled: bool) {
289        self.auto_detect_changes = enabled;
290    }
291}
292
293impl Default for ChangeTracker {
294    fn default() -> Self {
295        Self::new()
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::provider::DbValue;
303
304    #[test]
305    fn track_assigns_sequential_ids() {
306        let mut tracker = ChangeTracker::new();
307        let id1 = tracker.track(TypeId::of::<u32>(), "u32", EntityState::Added, None, false);
308        let id2 = tracker.track(TypeId::of::<u32>(), "u32", EntityState::Added, None, false);
309        assert_eq!(id1, 0);
310        assert_eq!(id2, 1);
311        assert_eq!(tracker.count_by_state(EntityState::Added), 2);
312    }
313
314    #[test]
315    fn entry_queries_return_tracked_data() {
316        let mut tracker = ChangeTracker::new();
317        let snap = EntitySnapshot::new(vec![("id", DbValue::I32(42))]);
318        let id = tracker.track(
319            TypeId::of::<u32>(),
320            "u32",
321            EntityState::Unchanged,
322            Some(snap),
323            false,
324        );
325        assert_eq!(tracker.entry_state(id), Some(EntityState::Unchanged));
326        assert!(tracker.entry_original(id).is_some());
327        assert_eq!(tracker.entry_modified(id), Some(&[][..]));
328        assert!(!tracker.entry_is_upsert(id));
329    }
330
331    #[test]
332    fn detect_changes_marks_modified() {
333        let mut tracker = ChangeTracker::new();
334        let original = EntitySnapshot::new(vec![
335            ("id", DbValue::I32(1)),
336            ("name", DbValue::String("old".into())),
337        ]);
338        let id = tracker.track(
339            TypeId::of::<u32>(),
340            "u32",
341            EntityState::Unchanged,
342            Some(original),
343            false,
344        );
345        let current = EntitySnapshot::new(vec![
346            ("id", DbValue::I32(1)),
347            ("name", DbValue::String("new".into())),
348        ]);
349        tracker.detect_changes(&[(id, current)]);
350        assert_eq!(tracker.entry_state(id), Some(EntityState::Modified));
351        let modified = tracker.entry_modified(id).unwrap();
352        assert_eq!(modified, &["name".to_string()]);
353    }
354
355    #[test]
356    fn detect_changes_noop_when_unchanged() {
357        let mut tracker = ChangeTracker::new();
358        let snap = EntitySnapshot::new(vec![("id", DbValue::I32(1))]);
359        let id = tracker.track(
360            TypeId::of::<u32>(),
361            "u32",
362            EntityState::Unchanged,
363            Some(snap.clone()),
364            false,
365        );
366        tracker.detect_changes(&[(id, snap)]);
367        assert_eq!(tracker.entry_state(id), Some(EntityState::Unchanged));
368    }
369
370    #[test]
371    fn accept_all_changes_transitions_and_updates_original() {
372        let mut tracker = ChangeTracker::new();
373        let id = tracker.track(TypeId::of::<u32>(), "u32", EntityState::Added, None, false);
374        let current = EntitySnapshot::new(vec![("id", DbValue::I32(10))]);
375        tracker.accept_all_changes(&[(id, current)]);
376        assert_eq!(tracker.entry_state(id), Some(EntityState::Unchanged));
377        assert!(tracker.entry_original(id).is_some());
378    }
379
380    #[test]
381    fn accept_all_changes_removes_deleted() {
382        let mut tracker = ChangeTracker::new();
383        let id = tracker.track(
384            TypeId::of::<u32>(),
385            "u32",
386            EntityState::Deleted,
387            None,
388            false,
389        );
390        tracker.accept_all_changes(&[]);
391        assert_eq!(tracker.entry_state(id), None);
392    }
393
394    #[test]
395    fn reject_all_changes_reverts_modified() {
396        let mut tracker = ChangeTracker::new();
397        let id = tracker.track(
398            TypeId::of::<u32>(),
399            "u32",
400            EntityState::Modified,
401            Some(EntitySnapshot::new(vec![("id", DbValue::I32(1))])),
402            false,
403        );
404        tracker.reject_all_changes();
405        assert_eq!(tracker.entry_state(id), Some(EntityState::Unchanged));
406    }
407
408    #[test]
409    fn reject_all_changes_removes_added() {
410        let mut tracker = ChangeTracker::new();
411        let id = tracker.track(TypeId::of::<u32>(), "u32", EntityState::Added, None, false);
412        tracker.reject_all_changes();
413        assert_eq!(tracker.entry_state(id), None);
414    }
415}