Skip to main content

rustrails_record/
touch.rs

1use std::collections::HashMap;
2
3use chrono::{DateTime, Utc};
4
5use crate::{RecordError, RecordState, no_touching};
6
7/// Trait implemented by records that support touch-style timestamp updates.
8pub trait TouchRecord {
9    /// Returns the mutable timestamp map keyed by column name.
10    fn touch_fields_mut(&mut self) -> &mut HashMap<String, DateTime<Utc>>;
11    /// Returns the current lifecycle state.
12    fn touch_record_state(&self) -> RecordState;
13
14    /// Updates the conventional `updated_at` timestamp.
15    fn touch(&mut self) -> Result<(), RecordError> {
16        touch(self)
17    }
18
19    /// Updates a specific timestamp column.
20    fn touch_field(&mut self, field: &str) -> Result<(), RecordError> {
21        touch_field(self, field)
22    }
23}
24
25/// A dynamically dispatched target that can be touched.
26pub trait TouchTarget {
27    /// Updates the conventional `updated_at` timestamp.
28    fn touch(&mut self) -> Result<(), RecordError>;
29    /// Updates a specific timestamp column.
30    fn touch_field(&mut self, field: &str) -> Result<(), RecordError>;
31}
32
33impl<T> TouchTarget for T
34where
35    T: TouchRecord,
36{
37    fn touch(&mut self) -> Result<(), RecordError> {
38        TouchRecord::touch(self)
39    }
40
41    fn touch_field(&mut self, field: &str) -> Result<(), RecordError> {
42        TouchRecord::touch_field(self, field)
43    }
44}
45
46/// Updates the `updated_at` timestamp to the current time.
47pub fn touch<T>(record: &mut T) -> Result<(), RecordError>
48where
49    T: TouchRecord + ?Sized,
50{
51    touch_field(record, "updated_at")
52}
53
54/// Updates a specific timestamp column to the current time.
55pub fn touch_field<T>(record: &mut T, field: &str) -> Result<(), RecordError>
56where
57    T: TouchRecord + ?Sized,
58{
59    if no_touching::is_disabled() {
60        return Ok(());
61    }
62    if record.touch_record_state() == RecordState::Destroyed {
63        return Err(RecordError::NotSaved);
64    }
65
66    record
67        .touch_fields_mut()
68        .insert(field.to_owned(), Utc::now());
69    Ok(())
70}
71
72/// Touches the record and then cascades the same timestamp update to associations.
73pub fn cascade_touch(
74    record: &mut dyn TouchTarget,
75    associations: &mut [&mut dyn TouchTarget],
76    field: Option<&str>,
77) -> Result<(), RecordError> {
78    match field {
79        Some(field) => record.touch_field(field)?,
80        None => record.touch()?,
81    }
82
83    for association in associations {
84        match field {
85            Some(field) => association.touch_field(field)?,
86            None => association.touch()?,
87        }
88    }
89    Ok(())
90}
91
92#[cfg(test)]
93mod tests {
94    use std::collections::HashMap;
95
96    use chrono::{Duration, Utc};
97
98    use super::{TouchRecord, cascade_touch, touch, touch_field};
99    use crate::{RecordState, no_touching::NoTouching};
100
101    #[derive(Debug, Default)]
102    struct TimestampedRecord {
103        state: RecordState,
104        fields: HashMap<String, chrono::DateTime<chrono::Utc>>,
105    }
106
107    impl TouchRecord for TimestampedRecord {
108        fn touch_fields_mut(&mut self) -> &mut HashMap<String, chrono::DateTime<chrono::Utc>> {
109            &mut self.fields
110        }
111
112        fn touch_record_state(&self) -> RecordState {
113            self.state
114        }
115    }
116
117    #[test]
118    fn touch_updates_updated_at() {
119        let mut record = TimestampedRecord::default();
120        touch(&mut record).expect("touch should succeed");
121        assert!(record.fields.contains_key("updated_at"));
122    }
123
124    #[test]
125    fn touch_field_updates_specific_column() {
126        let mut record = TimestampedRecord::default();
127        touch_field(&mut record, "published_at").expect("touch should succeed");
128        assert!(record.fields.contains_key("published_at"));
129    }
130
131    #[test]
132    fn cascade_touch_updates_associations() {
133        let mut parent = TimestampedRecord::default();
134        let mut child = TimestampedRecord::default();
135        let mut associations: [&mut dyn super::TouchTarget; 1] = [&mut child];
136
137        cascade_touch(&mut parent, &mut associations, None).expect("cascade should succeed");
138
139        assert!(parent.fields.contains_key("updated_at"));
140        assert!(child.fields.contains_key("updated_at"));
141    }
142
143    #[test]
144    fn cascade_touch_updates_custom_field() {
145        let mut parent = TimestampedRecord::default();
146        let mut child = TimestampedRecord::default();
147        let mut associations: [&mut dyn super::TouchTarget; 1] = [&mut child];
148
149        cascade_touch(&mut parent, &mut associations, Some("synced_at"))
150            .expect("cascade should succeed");
151
152        assert!(parent.fields.contains_key("synced_at"));
153        assert!(child.fields.contains_key("synced_at"));
154    }
155
156    #[test]
157    fn touch_rejects_destroyed_records() {
158        let mut record = TimestampedRecord {
159            state: RecordState::Destroyed,
160            ..TimestampedRecord::default()
161        };
162
163        assert_eq!(
164            touch(&mut record).map_err(|error| error.to_string()),
165            Err("record not saved".to_owned())
166        );
167    }
168
169    #[test]
170    fn no_touching_disables_updates() {
171        let mut record = TimestampedRecord::default();
172        NoTouching::apply(|| touch(&mut record).expect("no touching should still return success"));
173        assert!(record.fields.is_empty());
174    }
175
176    #[test]
177    fn touch_preserves_non_target_fields() {
178        let existing = Utc::now() - Duration::minutes(10);
179        let prior_update = Utc::now() - Duration::minutes(5);
180        let mut record = TimestampedRecord::default();
181        record.fields.insert("published_at".to_owned(), existing);
182        record.fields.insert("updated_at".to_owned(), prior_update);
183
184        touch(&mut record).expect("touch should succeed");
185
186        assert_eq!(record.fields.get("published_at"), Some(&existing));
187        assert!(
188            record
189                .fields
190                .get("updated_at")
191                .expect("updated_at should exist after touch")
192                >= &prior_update
193        );
194    }
195
196    #[test]
197    fn touch_field_updates_only_requested_field() {
198        let published_before = Utc::now() - Duration::minutes(10);
199        let updated_before = Utc::now() - Duration::minutes(5);
200        let mut record = TimestampedRecord::default();
201        record
202            .fields
203            .insert("published_at".to_owned(), published_before);
204        record
205            .fields
206            .insert("updated_at".to_owned(), updated_before);
207
208        touch_field(&mut record, "published_at").expect("touch_field should succeed");
209
210        assert!(
211            record
212                .fields
213                .get("published_at")
214                .expect("published_at should remain present")
215                >= &published_before
216        );
217        assert_eq!(record.fields.get("updated_at"), Some(&updated_before));
218    }
219
220    #[test]
221    fn cascade_touch_stops_before_associations_when_parent_fails() {
222        let mut parent = TimestampedRecord {
223            state: RecordState::Destroyed,
224            ..TimestampedRecord::default()
225        };
226        let mut child = TimestampedRecord::default();
227        let mut associations: [&mut dyn super::TouchTarget; 1] = [&mut child];
228
229        let error = cascade_touch(&mut parent, &mut associations, None)
230            .expect_err("destroyed parent should fail before touching associations");
231
232        assert!(matches!(error, crate::RecordError::NotSaved));
233        assert!(parent.fields.is_empty());
234        assert!(child.fields.is_empty());
235    }
236
237    #[test]
238    fn cascade_touch_stops_after_first_association_error() {
239        let mut parent = TimestampedRecord::default();
240        let mut failing_child = TimestampedRecord {
241            state: RecordState::Destroyed,
242            ..TimestampedRecord::default()
243        };
244        let mut untouched_child = TimestampedRecord::default();
245        let mut associations: [&mut dyn super::TouchTarget; 2] =
246            [&mut failing_child, &mut untouched_child];
247
248        let error = cascade_touch(&mut parent, &mut associations, Some("synced_at"))
249            .expect_err("destroyed child should stop further cascade updates");
250
251        assert!(matches!(error, crate::RecordError::NotSaved));
252        assert!(parent.fields.contains_key("synced_at"));
253        assert!(failing_child.fields.is_empty());
254        assert!(untouched_child.fields.is_empty());
255    }
256
257    #[test]
258    fn no_touching_disables_cascade_for_parent_and_associations() {
259        let mut parent = TimestampedRecord::default();
260        let mut child = TimestampedRecord::default();
261        let mut associations: [&mut dyn super::TouchTarget; 1] = [&mut child];
262
263        NoTouching::apply(|| {
264            cascade_touch(&mut parent, &mut associations, Some("synced_at"))
265                .expect("no touching should short-circuit cascade");
266        });
267
268        assert!(parent.fields.is_empty());
269        assert!(child.fields.is_empty());
270    }
271}