use std::{
any::TypeId,
collections::{BTreeSet, HashMap},
sync::{LazyLock, Mutex},
};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::{Map, Value};
use crate::{Record, RecordError};
pub type ChangeMap = HashMap<String, (Value, Value)>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum TrackingKey {
Persisted { type_id: TypeId, id: i64 },
Ephemeral { type_id: TypeId, addr: usize },
}
#[derive(Debug, Clone, Default)]
struct TrackingState {
snapshot: Option<Value>,
previous_changes: ChangeMap,
readonly: bool,
}
static TRACKING_STATE: LazyLock<Mutex<HashMap<TrackingKey, TrackingState>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
const FLOAT_TOLERANCE: f64 = 1e-12;
fn record_key<T: Record>(record: &T) -> TrackingKey {
match record.id() {
Some(id) => TrackingKey::Persisted {
type_id: TypeId::of::<T>(),
id,
},
None => TrackingKey::Ephemeral {
type_id: TypeId::of::<T>(),
addr: std::ptr::from_ref(record).cast::<()>() as usize,
},
}
}
fn with_tracking_state<T: Record, R>(record: &T, f: impl FnOnce(&mut TrackingState) -> R) -> R {
let mut states = TRACKING_STATE
.lock()
.expect("dirty tracking state lock should not poison");
let state = states.entry(record_key(record)).or_default();
f(state)
}
fn serialize_record<T: Serialize + ?Sized>(record: &T) -> Result<Value, RecordError> {
serde_json::to_value(record).map_err(|error| RecordError::Invalid(error.to_string()))
}
fn record_object(value: &Value) -> Result<&Map<String, Value>, RecordError> {
value
.as_object()
.ok_or_else(|| RecordError::Invalid("record must serialize to a JSON object".to_owned()))
}
#[allow(dead_code)]
fn project_record_json<T>(source: &Value) -> Result<Value, RecordError>
where
T: Default + Serialize,
{
let mut projected = serialize_record(&T::default())?;
let target = projected
.as_object_mut()
.ok_or_else(|| RecordError::Invalid("record must serialize to a JSON object".to_owned()))?;
let source = record_object(source)?;
for key in target.keys().cloned().collect::<Vec<_>>() {
if key == "type" {
continue;
}
if let Some(value) = source.get(&key).cloned() {
target.insert(key, value);
}
}
Ok(projected)
}
fn values_equal(left: &Value, right: &Value) -> bool {
match (left, right) {
(Value::Null, Value::Null) => true,
(Value::Bool(lhs), Value::Bool(rhs)) => lhs == rhs,
(Value::String(lhs), Value::String(rhs)) => lhs == rhs,
(Value::Number(lhs), Value::Number(rhs)) => lhs
.as_f64()
.zip(rhs.as_f64())
.is_some_and(|(lhs, rhs)| (lhs - rhs).abs() <= FLOAT_TOLERANCE),
(Value::Array(lhs), Value::Array(rhs)) => {
lhs.len() == rhs.len()
&& lhs
.iter()
.zip(rhs.iter())
.all(|(lhs, rhs)| values_equal(lhs, rhs))
}
(Value::Object(lhs), Value::Object(rhs)) => {
lhs.len() == rhs.len()
&& lhs
.iter()
.all(|(key, lhs)| rhs.get(key).is_some_and(|rhs| values_equal(lhs, rhs)))
}
_ => false,
}
}
fn diff(snapshot: &Value, current: &Value) -> Result<ChangeMap, RecordError> {
let snapshot = record_object(snapshot)?;
let current = record_object(current)?;
let mut changes = ChangeMap::new();
let keys = snapshot
.keys()
.chain(current.keys())
.cloned()
.collect::<BTreeSet<_>>();
for key in keys {
let old = snapshot.get(&key).cloned().unwrap_or(Value::Null);
let new = current.get(&key).cloned().unwrap_or(Value::Null);
if !values_equal(&old, &new) {
changes.insert(key, (old, new));
}
}
Ok(changes)
}
pub(crate) fn clear_state<T: Record>(record: &T) {
TRACKING_STATE
.lock()
.expect("dirty tracking state lock should not poison")
.remove(&record_key(record));
}
pub(crate) fn has_snapshot<T: Record>(record: &T) -> bool {
with_tracking_state(record, |state| state.snapshot.is_some())
}
pub(crate) fn initialize_snapshot_value<T: Record>(record: &T, snapshot: Value) {
with_tracking_state(record, |state| {
state.snapshot = Some(snapshot);
state.previous_changes.clear();
state.readonly = false;
});
}
pub(crate) fn snapshot_record<T: Record + Serialize>(record: &T) -> Result<(), RecordError> {
let snapshot = serialize_record(record)?;
with_tracking_state(record, |state| {
state.snapshot = Some(snapshot);
state.previous_changes.clear();
});
Ok(())
}
pub(crate) fn current_changes<T: Record + Serialize>(record: &T) -> Result<ChangeMap, RecordError> {
let current = serialize_record(record)?;
let snapshot = with_tracking_state(record, |state| state.snapshot.clone());
match snapshot {
Some(snapshot) => diff(&snapshot, ¤t),
None => {
initialize_snapshot_value(record, current);
Ok(ChangeMap::new())
}
}
}
pub(crate) fn previous_changes<T: Record>(record: &T) -> ChangeMap {
with_tracking_state(record, |state| state.previous_changes.clone())
}
pub(crate) fn commit_saved_changes<T: Record + Serialize>(
record: &T,
applied_changes: ChangeMap,
) -> Result<(), RecordError> {
let snapshot = serialize_record(record)?;
with_tracking_state(record, |state| {
state.previous_changes = applied_changes;
state.snapshot = Some(snapshot);
state.readonly = false;
});
Ok(())
}
#[allow(dead_code)]
pub(crate) fn apply_persisted_changes<T: Record + Serialize>(
record: &T,
applied_changes: &ChangeMap,
) -> Result<(), RecordError> {
let mut snapshot = with_tracking_state(record, |state| state.snapshot.clone())
.ok_or_else(|| RecordError::Invalid("dirty tracking snapshot is missing".to_owned()))?;
let snapshot_object = snapshot
.as_object_mut()
.ok_or_else(|| RecordError::Invalid("record must serialize to a JSON object".to_owned()))?;
for (field, (_, new)) in applied_changes {
snapshot_object.insert(field.clone(), new.clone());
}
with_tracking_state(record, |state| {
state.previous_changes = applied_changes.clone();
state.snapshot = Some(snapshot);
state.readonly = false;
});
Ok(())
}
pub(crate) fn snapshot_value<T: Record>(record: &T) -> Option<Value> {
with_tracking_state(record, |state| state.snapshot.clone())
}
#[allow(dead_code)]
pub(crate) fn attribute_from_snapshot<T: Record>(record: &T, name: &str) -> Option<Value> {
snapshot_value(record).and_then(|snapshot| {
snapshot
.as_object()
.and_then(|object| object.get(name).cloned())
})
}
#[allow(dead_code)]
pub(crate) fn mark_readonly<T: Record + Serialize>(record: &T) -> Result<(), RecordError> {
let snapshot = serialize_record(record)?;
with_tracking_state(record, |state| {
state.readonly = true;
state.snapshot.get_or_insert(snapshot);
});
Ok(())
}
pub(crate) fn readonly<T: Record>(record: &T) -> bool {
with_tracking_state(record, |state| state.readonly)
}
#[allow(dead_code)]
pub(crate) fn copy_tracking_state<T, U>(source: &T, target: &U) -> Result<(), RecordError>
where
T: Record + Serialize,
U: Record + Default + Serialize,
{
let projected_snapshot = snapshot_value(source)
.as_ref()
.map(project_record_json::<U>)
.transpose()?;
let readonly = readonly(source);
let previous = previous_changes(source)
.into_iter()
.filter(|(field, _)| {
projected_snapshot
.as_ref()
.and_then(Value::as_object)
.is_some_and(|object| object.contains_key(field))
})
.collect::<ChangeMap>();
with_tracking_state(target, |state| {
state.snapshot = projected_snapshot;
state.previous_changes = previous;
state.readonly = readonly;
});
Ok(())
}
pub trait DirtyTracking: Record + Serialize + DeserializeOwned {
fn changed(&self) -> bool {
!current_changes(self)
.expect("dirty tracking should serialize record")
.is_empty()
}
fn changes(&self) -> ChangeMap {
current_changes(self).expect("dirty tracking should serialize record")
}
fn changed_attributes(&self) -> Vec<String> {
let mut names = self.changes().into_keys().collect::<Vec<_>>();
names.sort();
names
}
fn previous_changes(&self) -> ChangeMap {
previous_changes(self)
}
fn attribute_changed(&self, name: &str) -> bool {
self.changes().contains_key(name)
}
fn attribute_was(&self, name: &str) -> Option<Value> {
self.changes().get(name).cloned().map(|(old, _)| old)
}
fn clear_changes(&mut self) {
snapshot_record(self).expect("dirty tracking should serialize record");
}
fn restore_attributes(&mut self) {
let Some(snapshot) = snapshot_value(self) else {
let _ = snapshot_record(self);
return;
};
let state = self.record_state();
let mut restored: Self = serde_json::from_value(snapshot)
.expect("dirty tracking snapshot should deserialize back into the record type");
restored.set_record_state(state);
*self = restored;
snapshot_record(self).expect("dirty tracking should serialize record");
}
}
impl<T> DirtyTracking for T where T: Record + Serialize + DeserializeOwned {}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use sea_orm::{ActiveModelTrait, ActiveValue::NotSet, ActiveValue::Set, EntityTrait};
use serde::{Deserialize, Serialize};
use serde_json::json;
use super::{
DirtyTracking, apply_persisted_changes, commit_saved_changes, copy_tracking_state,
current_changes, mark_readonly, snapshot_record,
};
use crate::{Record, RecordState, base::test_support::test_user};
fn persisted_state() -> RecordState {
RecordState::Persisted
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct DirtyProfile {
id: Option<i64>,
name: String,
email: String,
score: f64,
settings: serde_json::Value,
#[serde(rename = "type")]
kind: String,
#[serde(skip, default = "persisted_state")]
state: RecordState,
}
impl Default for DirtyProfile {
fn default() -> Self {
Self {
id: None,
name: "Alice".to_owned(),
email: "alice@example.com".to_owned(),
score: 1.25,
settings: json!({"theme": "light", "nested": {"volume": 1.0}}),
kind: "DirtyProfile".to_owned(),
state: RecordState::Persisted,
}
}
}
impl Record for DirtyProfile {
type Entity = test_user::Entity;
fn table_name() -> &'static str {
"test_users"
}
fn id(&self) -> Option<i64> {
self.id
}
fn record_state(&self) -> RecordState {
self.state
}
fn set_record_state(&mut self, state: RecordState) {
self.state = state;
}
fn from_sea_model(model: <Self::Entity as EntityTrait>::Model) -> Self {
Self {
id: Some(i64::from(model.id)),
name: model.name,
email: model.email,
..Self::default()
}
}
fn to_active_model(&self) -> <Self::Entity as EntityTrait>::ActiveModel
where
<Self::Entity as EntityTrait>::ActiveModel: ActiveModelTrait,
{
test_user::ActiveModel {
id: match self.id.and_then(|value| i32::try_from(value).ok()) {
Some(value) => Set(value),
None => NotSet,
},
name: Set(self.name.clone()),
email: Set(self.email.clone()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct PromotedProfile {
id: Option<i64>,
name: String,
email: String,
nickname: String,
#[serde(rename = "type")]
kind: String,
#[serde(skip, default = "persisted_state")]
state: RecordState,
}
impl Default for PromotedProfile {
fn default() -> Self {
Self {
id: None,
name: String::new(),
email: String::new(),
nickname: "guest".to_owned(),
kind: "PromotedProfile".to_owned(),
state: RecordState::Persisted,
}
}
}
impl Record for PromotedProfile {
type Entity = test_user::Entity;
fn table_name() -> &'static str {
"test_users"
}
fn id(&self) -> Option<i64> {
self.id
}
fn record_state(&self) -> RecordState {
self.state
}
fn set_record_state(&mut self, state: RecordState) {
self.state = state;
}
fn from_sea_model(model: <Self::Entity as EntityTrait>::Model) -> Self {
Self {
id: Some(i64::from(model.id)),
name: model.name,
email: model.email,
..Self::default()
}
}
fn to_active_model(&self) -> <Self::Entity as EntityTrait>::ActiveModel
where
<Self::Entity as EntityTrait>::ActiveModel: ActiveModelTrait,
{
test_user::ActiveModel {
id: match self.id.and_then(|value| i32::try_from(value).ok()) {
Some(value) => Set(value),
None => NotSet,
},
name: Set(self.name.clone()),
email: Set(self.email.clone()),
}
}
}
#[test]
fn changed_is_false_for_a_clean_snapshot() {
let profile = DirtyProfile::default();
snapshot_record(&profile).expect("snapshot should succeed");
assert!(!profile.changed());
}
#[test]
fn changed_is_true_after_attribute_mutation() {
let mut profile = DirtyProfile::default();
snapshot_record(&profile).expect("snapshot should succeed");
profile.name = "Alicia".to_owned();
assert!(profile.changed());
}
#[test]
fn changes_capture_old_and_new_values() {
let mut profile = DirtyProfile::default();
snapshot_record(&profile).expect("snapshot should succeed");
profile.email = "new@example.com".to_owned();
assert_eq!(
profile.changes().get("email"),
Some(&(json!("alice@example.com"), json!("new@example.com")))
);
}
#[test]
fn changed_attributes_are_sorted() {
let mut profile = DirtyProfile::default();
snapshot_record(&profile).expect("snapshot should succeed");
profile.email = "new@example.com".to_owned();
profile.name = "Alicia".to_owned();
assert_eq!(profile.changed_attributes(), vec!["email", "name"]);
}
#[test]
fn attribute_changed_and_attribute_was_reflect_snapshot() {
let mut profile = DirtyProfile::default();
snapshot_record(&profile).expect("snapshot should succeed");
profile.name = "Alicia".to_owned();
assert!(profile.attribute_changed("name"));
assert_eq!(profile.attribute_was("name"), Some(json!("Alice")));
assert_eq!(profile.attribute_was("email"), None);
}
#[test]
fn previous_changes_start_empty() {
let profile = DirtyProfile::default();
snapshot_record(&profile).expect("snapshot should succeed");
assert!(profile.previous_changes().is_empty());
}
#[test]
fn commit_saved_changes_records_previous_changes_and_clears_current_changes() {
let mut profile = DirtyProfile::default();
snapshot_record(&profile).expect("snapshot should succeed");
profile.name = "Alicia".to_owned();
let applied = current_changes(&profile).expect("current changes should succeed");
commit_saved_changes(&profile, applied).expect("commit should succeed");
assert!(!profile.changed());
assert_eq!(
profile.previous_changes().get("name"),
Some(&(json!("Alice"), json!("Alicia")))
);
}
#[test]
fn clear_changes_resets_current_dirty_state() {
let mut profile = DirtyProfile::default();
snapshot_record(&profile).expect("snapshot should succeed");
profile.name = "Alicia".to_owned();
profile.clear_changes();
assert!(!profile.changed());
assert!(profile.previous_changes().is_empty());
}
#[test]
fn restore_attributes_reverts_unsaved_changes() {
let mut profile = DirtyProfile::default();
snapshot_record(&profile).expect("snapshot should succeed");
profile.name = "Alicia".to_owned();
profile.settings = json!({"theme": "dark", "nested": {"volume": 1.0}});
profile.restore_attributes();
assert_eq!(profile.name, "Alice");
assert_eq!(profile.settings["theme"], json!("light"));
assert!(!profile.changed());
}
#[test]
fn float_changes_within_tolerance_are_not_dirty() {
let mut profile = DirtyProfile::default();
snapshot_record(&profile).expect("snapshot should succeed");
profile.score = 0.1 + 0.2;
profile.clear_changes();
profile.score = 0.3;
assert!(!profile.changed());
}
#[test]
fn float_changes_beyond_tolerance_are_dirty() {
let mut profile = DirtyProfile::default();
snapshot_record(&profile).expect("snapshot should succeed");
profile.score = 1.5;
assert!(profile.changed());
}
#[test]
fn nested_object_changes_are_detected() {
let mut profile = DirtyProfile::default();
snapshot_record(&profile).expect("snapshot should succeed");
profile.settings["nested"]["volume"] = json!(2.0);
assert!(profile.attribute_changed("settings"));
}
#[test]
fn apply_persisted_changes_updates_snapshot_only_for_applied_fields() {
let mut profile = DirtyProfile::default();
snapshot_record(&profile).expect("snapshot should succeed");
profile.name = "Alicia".to_owned();
profile.email = "new@example.com".to_owned();
apply_persisted_changes(
&profile,
&HashMap::from([(
"email".to_owned(),
(json!("alice@example.com"), json!("new@example.com")),
)]),
)
.expect("partial commit should succeed");
assert!(profile.attribute_changed("name"));
assert!(!profile.attribute_changed("email"));
assert_eq!(
profile.previous_changes().get("email"),
Some(&(json!("alice@example.com"), json!("new@example.com")))
);
}
#[test]
fn copy_tracking_state_projects_snapshot_onto_target_type() {
let mut source = DirtyProfile::default();
snapshot_record(&source).expect("snapshot should succeed");
source.name = "Alicia".to_owned();
mark_readonly(&source).expect("readonly tracking should serialize");
let target = PromotedProfile {
id: source.id,
name: source.name.clone(),
email: source.email.clone(),
..PromotedProfile::default()
};
copy_tracking_state(&source, &target).expect("tracking state copy should succeed");
assert!(target.attribute_changed("name"));
assert_eq!(target.nickname, "guest");
assert_eq!(target.kind, "PromotedProfile");
assert!(super::readonly(&target));
}
#[test]
fn changes_can_return_to_clean_state_by_restoring_original_value() {
let mut profile = DirtyProfile::default();
snapshot_record(&profile).expect("snapshot should succeed");
profile.name = "Alicia".to_owned();
profile.name = "Alice".to_owned();
assert!(!profile.changed());
assert!(profile.changes().is_empty());
}
}