rustrails-record 0.1.2

ORM layer (ActiveRecord equivalent)
Documentation
use std::collections::HashMap;

use chrono::{DateTime, Utc};

use crate::{RecordError, RecordState, no_touching};

/// Trait implemented by records that support touch-style timestamp updates.
pub trait TouchRecord {
    /// Returns the mutable timestamp map keyed by column name.
    fn touch_fields_mut(&mut self) -> &mut HashMap<String, DateTime<Utc>>;
    /// Returns the current lifecycle state.
    fn touch_record_state(&self) -> RecordState;

    /// Updates the conventional `updated_at` timestamp.
    fn touch(&mut self) -> Result<(), RecordError> {
        touch(self)
    }

    /// Updates a specific timestamp column.
    fn touch_field(&mut self, field: &str) -> Result<(), RecordError> {
        touch_field(self, field)
    }
}

/// A dynamically dispatched target that can be touched.
pub trait TouchTarget {
    /// Updates the conventional `updated_at` timestamp.
    fn touch(&mut self) -> Result<(), RecordError>;
    /// Updates a specific timestamp column.
    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)
    }
}

/// Updates the `updated_at` timestamp to the current time.
pub fn touch<T>(record: &mut T) -> Result<(), RecordError>
where
    T: TouchRecord + ?Sized,
{
    touch_field(record, "updated_at")
}

/// Updates a specific timestamp column to the current time.
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(())
}

/// Touches the record and then cascades the same timestamp update to associations.
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());
    }
}