vantage_table/table/
audit.rs1use std::future::Future;
14use std::pin::Pin;
15use std::sync::Arc;
16
17use vantage_core::Result;
18use vantage_types::{EmptyEntity, Entity, InvariantValue, Record};
19
20use crate::table::{BeforeFn, Hook, Phase, Table};
21use crate::traits::table_source::TableSource;
22
23#[derive(Clone, Debug)]
25pub struct Timestamps {
26 created_column: String,
27 updated_column: String,
28}
29
30impl Default for Timestamps {
31 fn default() -> Self {
32 Self {
33 created_column: "created_at".to_string(),
34 updated_column: "updated_at".to_string(),
35 }
36 }
37}
38
39impl Timestamps {
40 pub fn new() -> Self {
42 Self::default()
43 }
44
45 pub fn created(mut self, column: impl Into<String>) -> Self {
47 self.created_column = column.into();
48 self
49 }
50
51 pub fn updated(mut self, column: impl Into<String>) -> Self {
53 self.updated_column = column.into();
54 self
55 }
56}
57
58impl<T: TableSource, E: Entity<T::Value>> Table<T, E>
59where
60 T::Value: InvariantValue + From<String>,
61{
62 pub fn with_timestamps(self) -> Self {
70 self.with_audit(Timestamps::default())
71 }
72
73 pub fn with_audit(self, columns: Timestamps) -> Self {
75 self.with_hook(Hook::BeforeInsert(
76 Phase::Populate,
77 created_hook::<T>(columns.created_column),
78 ))
79 .with_hook(Hook::BeforeSave(
80 Phase::Populate,
81 updated_hook::<T>(columns.updated_column),
82 ))
83 }
84}
85
86fn created_hook<T: TableSource>(column: String) -> BeforeFn<T>
89where
90 T::Value: InvariantValue + From<String>,
91{
92 Arc::new(
93 move |rec: &mut Record<T::Value>,
94 _table: &Table<T, EmptyEntity>|
95 -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
96 let column = column.clone();
97 Box::pin(async move {
98 let absent_or_null = match rec.get(column.as_str()) {
99 None => true,
100 Some(v) => v.is_null(),
101 };
102 if absent_or_null {
103 rec.insert(column, T::Value::from(now_rfc3339()));
104 }
105 Ok(())
106 })
107 },
108 )
109}
110
111fn updated_hook<T: TableSource>(column: String) -> BeforeFn<T>
113where
114 T::Value: InvariantValue + From<String>,
115{
116 Arc::new(
117 move |rec: &mut Record<T::Value>,
118 _table: &Table<T, EmptyEntity>|
119 -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
120 let column = column.clone();
121 Box::pin(async move {
122 rec.insert(column, T::Value::from(now_rfc3339()));
123 Ok(())
124 })
125 },
126 )
127}
128
129fn now_rfc3339() -> String {
131 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use crate::mocks::mock_table_source::MockTableSource;
138 use serde_json::json;
139 use vantage_dataset::prelude::{InsertableValueSet, ReadableValueSet, WritableValueSet};
140
141 type MockTable = Table<MockTableSource, EmptyEntity>;
142
143 fn looks_like_timestamp(v: &serde_json::Value) -> bool {
144 v.as_str()
145 .is_some_and(|s| s.len() >= 20 && s.ends_with('Z') && s.contains('T'))
146 }
147
148 #[tokio::test]
149 async fn stamps_created_and_updated_on_insert() {
150 let src = MockTableSource::new().with_data("t", vec![]).await;
151 let table = MockTable::new("t", src).with_timestamps();
152
153 let id = table
154 .insert_return_id_value(&Record::from(json!({"id": "1", "name": "a"})))
155 .await
156 .unwrap();
157 let row = table.get_value(id).await.unwrap().unwrap();
158 assert!(looks_like_timestamp(&row["created_at"]), "{row:?}");
159 assert!(looks_like_timestamp(&row["updated_at"]), "{row:?}");
160 }
161
162 #[tokio::test]
163 async fn keeps_caller_supplied_created_at() {
164 let src = MockTableSource::new().with_data("t", vec![]).await;
165 let table = MockTable::new("t", src).with_timestamps();
166
167 let id = table
168 .insert_return_id_value(&Record::from(
169 json!({"id": "1", "name": "a", "created_at": "2000-01-01T00:00:00Z"}),
170 ))
171 .await
172 .unwrap();
173 let row = table.get_value(id).await.unwrap().unwrap();
174 assert_eq!(row["created_at"], json!("2000-01-01T00:00:00Z"));
176 assert!(looks_like_timestamp(&row["updated_at"]));
177 }
178
179 #[tokio::test]
180 async fn updates_only_updated_at_on_patch() {
181 let src = MockTableSource::new()
182 .with_data(
183 "t",
184 vec![json!({"id": "1", "name": "a", "created_at": "2000-01-01T00:00:00Z"})],
185 )
186 .await;
187 let table = MockTable::new("t", src).with_timestamps();
188
189 table
190 .patch_value("1", &Record::from(json!({"name": "b"})))
191 .await
192 .unwrap();
193 let row = table.get_value("1".to_string()).await.unwrap().unwrap();
194 assert_eq!(row["created_at"], json!("2000-01-01T00:00:00Z"));
196 assert!(looks_like_timestamp(&row["updated_at"]));
197 assert_eq!(row["name"], json!("b"));
198 }
199
200 #[tokio::test]
201 async fn custom_column_names() {
202 let src = MockTableSource::new().with_data("t", vec![]).await;
203 let table = MockTable::new("t", src)
204 .with_audit(Timestamps::new().created("inserted").updated("touched"));
205
206 let id = table
207 .insert_return_id_value(&Record::from(json!({"id": "1"})))
208 .await
209 .unwrap();
210 let row = table.get_value(id).await.unwrap().unwrap();
211 assert!(looks_like_timestamp(&row["inserted"]));
212 assert!(looks_like_timestamp(&row["touched"]));
213 }
214}