use core::ops::{Deref, DerefMut};
use crate::{repo, Entity, Executor, Result};
use sea_query::Value;
pub struct Tracked<E: Entity> {
baseline: Vec<(&'static str, Value)>,
current: E,
}
impl<E: Entity> Tracked<E> {
pub fn new(entity: E) -> Self {
Self { baseline: entity.update_values(), current: entity }
}
pub async fn load<X, V>(exec: &X, pk: V) -> Result<Option<Self>>
where
X: Executor,
V: Into<Value>,
{
Ok(repo::find_by_pk::<E, X, V>(exec, pk).await?.map(Self::new))
}
pub fn changes(&self) -> Vec<(&'static str, Value)> {
let now = self.current.update_values();
now.into_iter()
.zip(self.baseline.iter())
.filter_map(|((col, val), (_, base))| (val != *base).then_some((col, val)))
.collect()
}
pub fn is_dirty(&self) -> bool {
let now = self.current.update_values();
now.iter().zip(self.baseline.iter()).any(|((_, val), (_, base))| val != base)
}
pub async fn save<X: Executor>(&mut self, exec: &X) -> Result<u64> {
let changes = self.changes();
if changes.is_empty() {
return Ok(0);
}
let affected = repo::query::<E>()
.where_eq(E::primary_key(), self.current.pk_value())
.update(exec, changes)
.await?;
self.baseline = self.current.update_values();
Ok(affected)
}
pub fn get(&self) -> &E {
&self.current
}
pub fn into_inner(self) -> E {
self.current
}
}
impl<E: Entity> Deref for Tracked<E> {
type Target = E;
fn deref(&self) -> &E {
&self.current
}
}
impl<E: Entity> DerefMut for Tracked<E> {
fn deref_mut(&mut self) -> &mut E {
&mut self.current
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Dialect, ExecOutcome, Row};
use core::cell::RefCell;
use sea_query::Value;
#[derive(Debug, Clone, crate::Entity)]
#[orm(table = "widgets")]
struct Widget {
#[orm(pk, auto_increment)]
id: u64,
name: String,
count: i32,
}
struct MockExec {
calls: RefCell<Vec<(String, Vec<Value>)>>,
forbid: bool,
}
impl MockExec {
fn new() -> Self {
Self { calls: RefCell::new(Vec::new()), forbid: false }
}
fn forbidding() -> Self {
Self { calls: RefCell::new(Vec::new()), forbid: true }
}
}
impl Executor for MockExec {
fn dialect(&self) -> Dialect {
Dialect::Sqlite
}
async fn fetch_all(&self, _sql: &str, _params: Vec<Value>) -> Result<Vec<Box<dyn Row>>> {
Ok(Vec::new())
}
async fn execute(&self, sql: &str, params: Vec<Value>) -> Result<ExecOutcome> {
if self.forbid {
panic!("execute called on a clean save: {sql}");
}
self.calls.borrow_mut().push((sql.to_string(), params));
Ok(ExecOutcome { rows_affected: 1, last_insert_id: 0 })
}
}
#[test]
fn clean_entity_has_no_changes() {
let t = Tracked::new(Widget { id: 1, name: "a".into(), count: 0 });
assert!(!t.is_dirty());
assert!(t.changes().is_empty());
}
#[test]
fn mutation_is_detected() {
let mut t = Tracked::new(Widget { id: 1, name: "a".into(), count: 0 });
t.name = "b".into();
assert!(t.is_dirty());
let changes = t.changes();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].0, "name");
assert!(!t.changes().iter().any(|(c, _)| *c == "id"));
}
#[tokio::test]
async fn save_when_clean_is_a_noop() {
let exec = MockExec::forbidding();
let mut t = Tracked::new(Widget { id: 1, name: "a".into(), count: 0 });
let n = t.save(&exec).await.unwrap();
assert_eq!(n, 0);
}
#[tokio::test]
async fn save_emits_partial_update_then_clears_dirty() {
let exec = MockExec::new();
let mut t = Tracked::new(Widget { id: 7, name: "a".into(), count: 0 });
t.name = "b".into();
t.count = 3;
let n = t.save(&exec).await.unwrap();
assert_eq!(n, 1);
let calls = exec.calls.borrow();
assert_eq!(calls.len(), 1, "one UPDATE");
let sql = calls[0].0.to_uppercase();
assert!(sql.starts_with("UPDATE"), "{sql}");
assert!(sql.contains("\"NAME\""), "{sql}");
assert!(sql.contains("\"COUNT\""), "{sql}");
assert!(sql.contains("WHERE"), "{sql}");
drop(calls);
assert!(!t.is_dirty());
}
}