use std::collections::HashMap;
use chrono::{DateTime, Utc};
use crate::{RecordError, RecordState, no_touching};
pub trait TouchRecord {
fn touch_fields_mut(&mut self) -> &mut HashMap<String, DateTime<Utc>>;
fn touch_record_state(&self) -> RecordState;
fn touch(&mut self) -> Result<(), RecordError> {
touch(self)
}
fn touch_field(&mut self, field: &str) -> Result<(), RecordError> {
touch_field(self, field)
}
}
pub trait TouchTarget {
fn touch(&mut self) -> Result<(), RecordError>;
fn touch_field(&mut self, field: &str) -> Result<(), RecordError>;
}
impl<T> TouchTarget for T
where
T: TouchRecord,
{
fn touch(&mut self) -> Result<(), RecordError> {
TouchRecord::touch(self)
}
fn touch_field(&mut self, field: &str) -> Result<(), RecordError> {
TouchRecord::touch_field(self, field)
}
}
pub fn touch<T>(record: &mut T) -> Result<(), RecordError>
where
T: TouchRecord + ?Sized,
{
touch_field(record, "updated_at")
}
pub fn touch_field<T>(record: &mut T, field: &str) -> Result<(), RecordError>
where
T: TouchRecord + ?Sized,
{
if no_touching::is_disabled() {
return Ok(());
}
if record.touch_record_state() == RecordState::Destroyed {
return Err(RecordError::NotSaved);
}
record
.touch_fields_mut()
.insert(field.to_owned(), Utc::now());
Ok(())
}
pub fn cascade_touch(
record: &mut dyn TouchTarget,
associations: &mut [&mut dyn TouchTarget],
field: Option<&str>,
) -> Result<(), RecordError> {
match field {
Some(field) => record.touch_field(field)?,
None => record.touch()?,
}
for association in associations {
match field {
Some(field) => association.touch_field(field)?,
None => association.touch()?,
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use chrono::{Duration, Utc};
use super::{TouchRecord, cascade_touch, touch, touch_field};
use crate::{RecordState, no_touching::NoTouching};
#[derive(Debug, Default)]
struct TimestampedRecord {
state: RecordState,
fields: HashMap<String, chrono::DateTime<chrono::Utc>>,
}
impl TouchRecord for TimestampedRecord {
fn touch_fields_mut(&mut self) -> &mut HashMap<String, chrono::DateTime<chrono::Utc>> {
&mut self.fields
}
fn touch_record_state(&self) -> RecordState {
self.state
}
}
#[test]
fn touch_updates_updated_at() {
let mut record = TimestampedRecord::default();
touch(&mut record).expect("touch should succeed");
assert!(record.fields.contains_key("updated_at"));
}
#[test]
fn touch_field_updates_specific_column() {
let mut record = TimestampedRecord::default();
touch_field(&mut record, "published_at").expect("touch should succeed");
assert!(record.fields.contains_key("published_at"));
}
#[test]
fn cascade_touch_updates_associations() {
let mut parent = TimestampedRecord::default();
let mut child = TimestampedRecord::default();
let mut associations: [&mut dyn super::TouchTarget; 1] = [&mut child];
cascade_touch(&mut parent, &mut associations, None).expect("cascade should succeed");
assert!(parent.fields.contains_key("updated_at"));
assert!(child.fields.contains_key("updated_at"));
}
#[test]
fn cascade_touch_updates_custom_field() {
let mut parent = TimestampedRecord::default();
let mut child = TimestampedRecord::default();
let mut associations: [&mut dyn super::TouchTarget; 1] = [&mut child];
cascade_touch(&mut parent, &mut associations, Some("synced_at"))
.expect("cascade should succeed");
assert!(parent.fields.contains_key("synced_at"));
assert!(child.fields.contains_key("synced_at"));
}
#[test]
fn touch_rejects_destroyed_records() {
let mut record = TimestampedRecord {
state: RecordState::Destroyed,
..TimestampedRecord::default()
};
assert_eq!(
touch(&mut record).map_err(|error| error.to_string()),
Err("record not saved".to_owned())
);
}
#[test]
fn no_touching_disables_updates() {
let mut record = TimestampedRecord::default();
NoTouching::apply(|| touch(&mut record).expect("no touching should still return success"));
assert!(record.fields.is_empty());
}
#[test]
fn touch_preserves_non_target_fields() {
let existing = Utc::now() - Duration::minutes(10);
let prior_update = Utc::now() - Duration::minutes(5);
let mut record = TimestampedRecord::default();
record.fields.insert("published_at".to_owned(), existing);
record.fields.insert("updated_at".to_owned(), prior_update);
touch(&mut record).expect("touch should succeed");
assert_eq!(record.fields.get("published_at"), Some(&existing));
assert!(
record
.fields
.get("updated_at")
.expect("updated_at should exist after touch")
>= &prior_update
);
}
#[test]
fn touch_field_updates_only_requested_field() {
let published_before = Utc::now() - Duration::minutes(10);
let updated_before = Utc::now() - Duration::minutes(5);
let mut record = TimestampedRecord::default();
record
.fields
.insert("published_at".to_owned(), published_before);
record
.fields
.insert("updated_at".to_owned(), updated_before);
touch_field(&mut record, "published_at").expect("touch_field should succeed");
assert!(
record
.fields
.get("published_at")
.expect("published_at should remain present")
>= &published_before
);
assert_eq!(record.fields.get("updated_at"), Some(&updated_before));
}
#[test]
fn cascade_touch_stops_before_associations_when_parent_fails() {
let mut parent = TimestampedRecord {
state: RecordState::Destroyed,
..TimestampedRecord::default()
};
let mut child = TimestampedRecord::default();
let mut associations: [&mut dyn super::TouchTarget; 1] = [&mut child];
let error = cascade_touch(&mut parent, &mut associations, None)
.expect_err("destroyed parent should fail before touching associations");
assert!(matches!(error, crate::RecordError::NotSaved));
assert!(parent.fields.is_empty());
assert!(child.fields.is_empty());
}
#[test]
fn cascade_touch_stops_after_first_association_error() {
let mut parent = TimestampedRecord::default();
let mut failing_child = TimestampedRecord {
state: RecordState::Destroyed,
..TimestampedRecord::default()
};
let mut untouched_child = TimestampedRecord::default();
let mut associations: [&mut dyn super::TouchTarget; 2] =
[&mut failing_child, &mut untouched_child];
let error = cascade_touch(&mut parent, &mut associations, Some("synced_at"))
.expect_err("destroyed child should stop further cascade updates");
assert!(matches!(error, crate::RecordError::NotSaved));
assert!(parent.fields.contains_key("synced_at"));
assert!(failing_child.fields.is_empty());
assert!(untouched_child.fields.is_empty());
}
#[test]
fn no_touching_disables_cascade_for_parent_and_associations() {
let mut parent = TimestampedRecord::default();
let mut child = TimestampedRecord::default();
let mut associations: [&mut dyn super::TouchTarget; 1] = [&mut child];
NoTouching::apply(|| {
cascade_touch(&mut parent, &mut associations, Some("synced_at"))
.expect("no touching should short-circuit cascade");
});
assert!(parent.fields.is_empty());
assert!(child.fields.is_empty());
}
}