Skip to main content

rainier_orm/
active.rs

1//! ActiveRecord-style change tracking: load a row, mutate it **in place**, then
2//! [`save`](Tracked::save) the diff.
3//!
4//! The repository layer ([`crate::repo`]) is deliberately stateless — free
5//! functions over an [`Entity`] value. That's the right default, but it means a
6//! "read, tweak a couple of fields, write it back" flow has to either re-send
7//! every column ([`repo::update`]) or hand-list the changed
8//! ones ([`Query::update`](crate::Query::update())). [`Tracked`] adds the missing
9//! stateful convenience without changing the entity or the derive: it snapshots
10//! a row's column values when wrapped, hands you the inner `E` through
11//! `Deref`/`DerefMut` so you mutate fields exactly as you would the struct, and
12//! on [`save`](Tracked::save) computes the dirty set and issues a single partial
13//! `UPDATE` of **only** what changed (routed to the right shard by the primary
14//! key, like every other write).
15//!
16//! ```ignore
17//! use rainier_orm::{active::Tracked, repo};
18//!
19//! let mut user = Tracked::load::<_, _>(&db, user_id).await?.expect("exists");
20//! user.email = "new@example.com".into();   // DerefMut → mutate the row
21//! user.locale = "fr".into();
22//! let changed = user.save(&db).await?;      // UPDATE users SET email=?, locale=? WHERE id=?
23//! assert_eq!(changed, 1);
24//! user.save(&db).await?;                     // no-op: nothing dirty since the last save
25//! ```
26//!
27//! It tracks the columns [`Entity::update_values`] reports (every non-primary-key
28//! column), so the primary key is never part of a diff — a `save` can't move a
29//! row between shards, by construction.
30
31use core::ops::{Deref, DerefMut};
32
33use crate::{repo, Entity, Executor, Result};
34use sea_query::Value;
35
36/// A loaded [`Entity`] that remembers its original column values so
37/// [`save`](Self::save) persists only what changed. Deref targets the inner
38/// `E`, so reads and field mutations go straight through.
39pub struct Tracked<E: Entity> {
40    /// Snapshot of `update_values()` at load (or last save) — the diff baseline.
41    baseline: Vec<(&'static str, Value)>,
42    current: E,
43}
44
45impl<E: Entity> Tracked<E> {
46    /// Wrap an in-hand entity; its current column values become the baseline.
47    pub fn new(entity: E) -> Self {
48        Self { baseline: entity.update_values(), current: entity }
49    }
50
51    /// Load `pk`'s row and wrap it for tracking. `None` when no row matches.
52    pub async fn load<X, V>(exec: &X, pk: V) -> Result<Option<Self>>
53    where
54        X: Executor,
55        V: Into<Value>,
56    {
57        Ok(repo::find_by_pk::<E, X, V>(exec, pk).await?.map(Self::new))
58    }
59
60    /// The columns whose value differs from the baseline (the dirty set), in
61    /// the entity's declared column order.
62    pub fn changes(&self) -> Vec<(&'static str, Value)> {
63        let now = self.current.update_values();
64        // `update_values()` is deterministic in column order, so the snapshot
65        // and the current values line up positionally.
66        now.into_iter()
67            .zip(self.baseline.iter())
68            .filter_map(|((col, val), (_, base))| (val != *base).then_some((col, val)))
69            .collect()
70    }
71
72    /// Whether any tracked column has changed since load / last save.
73    pub fn is_dirty(&self) -> bool {
74        let now = self.current.update_values();
75        now.iter().zip(self.baseline.iter()).any(|((_, val), (_, base))| val != base)
76    }
77
78    /// Persist the dirty columns with a single `UPDATE … WHERE pk = ?`, returning
79    /// rows affected. A no-op returning `0` when nothing changed. On success the
80    /// baseline is reset to the saved state, so a later `save` writes only newer
81    /// changes (and an immediate re-`save` is a no-op).
82    pub async fn save<X: Executor>(&mut self, exec: &X) -> Result<u64> {
83        let changes = self.changes();
84        if changes.is_empty() {
85            return Ok(0);
86        }
87        let affected = repo::query::<E>()
88            .where_eq(E::primary_key(), self.current.pk_value())
89            .update(exec, changes)
90            .await?;
91        self.baseline = self.current.update_values();
92        Ok(affected)
93    }
94
95    /// Borrow the inner entity (also available through `Deref`).
96    pub fn get(&self) -> &E {
97        &self.current
98    }
99
100    /// Unwrap, discarding tracking state.
101    pub fn into_inner(self) -> E {
102        self.current
103    }
104}
105
106impl<E: Entity> Deref for Tracked<E> {
107    type Target = E;
108    fn deref(&self) -> &E {
109        &self.current
110    }
111}
112
113impl<E: Entity> DerefMut for Tracked<E> {
114    fn deref_mut(&mut self) -> &mut E {
115        &mut self.current
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::{Dialect, ExecOutcome, Row};
123    use core::cell::RefCell;
124    use sea_query::Value;
125
126    #[derive(Debug, Clone, crate::Entity)]
127    #[orm(table = "widgets")]
128    struct Widget {
129        #[orm(pk, auto_increment)]
130        id: u64,
131        name: String,
132        count: i32,
133    }
134
135    /// Records every `execute` so a test can assert what `save` emitted; a flag
136    /// makes it panic if touched (to prove `save` is a true no-op when clean).
137    struct MockExec {
138        calls: RefCell<Vec<(String, Vec<Value>)>>,
139        forbid: bool,
140    }
141
142    impl MockExec {
143        fn new() -> Self {
144            Self { calls: RefCell::new(Vec::new()), forbid: false }
145        }
146        fn forbidding() -> Self {
147            Self { calls: RefCell::new(Vec::new()), forbid: true }
148        }
149    }
150
151    impl Executor for MockExec {
152        fn dialect(&self) -> Dialect {
153            Dialect::Sqlite
154        }
155        async fn fetch_all(&self, _sql: &str, _params: Vec<Value>) -> Result<Vec<Box<dyn Row>>> {
156            Ok(Vec::new())
157        }
158        async fn execute(&self, sql: &str, params: Vec<Value>) -> Result<ExecOutcome> {
159            if self.forbid {
160                panic!("execute called on a clean save: {sql}");
161            }
162            self.calls.borrow_mut().push((sql.to_string(), params));
163            Ok(ExecOutcome { rows_affected: 1, last_insert_id: 0 })
164        }
165    }
166
167    #[test]
168    fn clean_entity_has_no_changes() {
169        let t = Tracked::new(Widget { id: 1, name: "a".into(), count: 0 });
170        assert!(!t.is_dirty());
171        assert!(t.changes().is_empty());
172    }
173
174    #[test]
175    fn mutation_is_detected() {
176        let mut t = Tracked::new(Widget { id: 1, name: "a".into(), count: 0 });
177        t.name = "b".into();
178        assert!(t.is_dirty());
179        let changes = t.changes();
180        assert_eq!(changes.len(), 1);
181        assert_eq!(changes[0].0, "name");
182        // The primary key is never in the diff.
183        assert!(!t.changes().iter().any(|(c, _)| *c == "id"));
184    }
185
186    #[tokio::test]
187    async fn save_when_clean_is_a_noop() {
188        let exec = MockExec::forbidding();
189        let mut t = Tracked::new(Widget { id: 1, name: "a".into(), count: 0 });
190        let n = t.save(&exec).await.unwrap();
191        assert_eq!(n, 0);
192    }
193
194    #[tokio::test]
195    async fn save_emits_partial_update_then_clears_dirty() {
196        let exec = MockExec::new();
197        let mut t = Tracked::new(Widget { id: 7, name: "a".into(), count: 0 });
198        t.name = "b".into();
199        t.count = 3;
200
201        let n = t.save(&exec).await.unwrap();
202        assert_eq!(n, 1);
203
204        let calls = exec.calls.borrow();
205        assert_eq!(calls.len(), 1, "one UPDATE");
206        let sql = calls[0].0.to_uppercase();
207        assert!(sql.starts_with("UPDATE"), "{sql}");
208        assert!(sql.contains("\"NAME\""), "{sql}");
209        assert!(sql.contains("\"COUNT\""), "{sql}");
210        assert!(sql.contains("WHERE"), "{sql}");
211        drop(calls);
212
213        // Baseline advanced → immediately clean again.
214        assert!(!t.is_dirty());
215    }
216}