1use core::ops::{Deref, DerefMut};
32
33use crate::{repo, Entity, Executor, Result};
34use sea_query::Value;
35
36pub struct Tracked<E: Entity> {
40 baseline: Vec<(&'static str, Value)>,
42 current: E,
43}
44
45impl<E: Entity> Tracked<E> {
46 pub fn new(entity: E) -> Self {
48 Self { baseline: entity.update_values(), current: entity }
49 }
50
51 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 pub fn changes(&self) -> Vec<(&'static str, Value)> {
63 let now = self.current.update_values();
64 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 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 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 pub fn get(&self) -> &E {
97 &self.current
98 }
99
100 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 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 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 assert!(!t.is_dirty());
215 }
216}