rustrails_record/
touch.rs1use std::collections::HashMap;
2
3use chrono::{DateTime, Utc};
4
5use crate::{RecordError, RecordState, no_touching};
6
7pub trait TouchRecord {
9 fn touch_fields_mut(&mut self) -> &mut HashMap<String, DateTime<Utc>>;
11 fn touch_record_state(&self) -> RecordState;
13
14 fn touch(&mut self) -> Result<(), RecordError> {
16 touch(self)
17 }
18
19 fn touch_field(&mut self, field: &str) -> Result<(), RecordError> {
21 touch_field(self, field)
22 }
23}
24
25pub trait TouchTarget {
27 fn touch(&mut self) -> Result<(), RecordError>;
29 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
46pub fn touch<T>(record: &mut T) -> Result<(), RecordError>
48where
49 T: TouchRecord + ?Sized,
50{
51 touch_field(record, "updated_at")
52}
53
54pub 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
72pub 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}