Skip to main content

vantage_table/table/sets/
hooks.rs

1//! Fire a table's lifecycle hooks around a write.
2//!
3//! Before-write hooks run on the record about to be written, in phase order,
4//! just ahead of set-invariant enforcement; an error cancels the operation.
5//! `before_delete` hooks may instead return [`HookReturn::Handled`] to take over
6//! (soft-delete). After-commit hooks run for their side-effects. Hooks receive
7//! the entity-erased table for relation/datasource access.
8
9use vantage_core::Result;
10use vantage_types::{EmptyEntity, Entity, InvariantValue, Record};
11
12use super::invariants::enforce_invariants;
13use crate::table::{AfterFn, BeforeDeleteFn, BeforeFn, HookReturn, Phase, Table};
14use crate::traits::table_source::TableSource;
15
16/// The two halves of a single-record insert that surround the driver's write.
17/// A driver's bulk path calls them around its batch so that it stays
18/// equivalent to inserting the records one by one.
19impl<T: TableSource, E: Entity<T::Value>> Table<T, E>
20where
21    T::Value: InvariantValue,
22{
23    /// Everything a single insert does to `record` before the driver write:
24    /// the confined-writes check, before-insert hooks, dropping read-only
25    /// imported columns, and conforming to the set's invariants. Returns the
26    /// record to store.
27    pub async fn prepare_insert(&self, record: &Record<T::Value>) -> Result<Record<T::Value>> {
28        self.require_confined_writes("insert")?;
29        let mut record = record.clone();
30        run_before(
31            self.before_insert_hooks(),
32            &mut record,
33            self.as_entity_erased(),
34        )
35        .await?;
36        self.strip_imported_columns(&mut record);
37        enforce_invariants(&mut record, self.invariants())?;
38        Ok(record)
39    }
40
41    /// Run the after-insert hooks for a record stored under `id`, as a single
42    /// insert does once the driver write has succeeded.
43    pub async fn finish_insert(&self, id: &T::Id, record: &Record<T::Value>) -> Result<()> {
44        run_after(
45            self.after_insert_hooks(),
46            id,
47            record,
48            self.as_entity_erased(),
49        )
50        .await
51    }
52}
53
54pub(crate) async fn run_before<T: TableSource>(
55    hooks: &[(Phase, BeforeFn<T>)],
56    record: &mut Record<T::Value>,
57    table: &Table<T, EmptyEntity>,
58) -> Result<()> {
59    for (_phase, hook) in hooks {
60        hook(record, table).await?;
61    }
62    Ok(())
63}
64
65pub(crate) async fn run_after<T: TableSource>(
66    hooks: &[AfterFn<T>],
67    id: &T::Id,
68    record: &Record<T::Value>,
69    table: &Table<T, EmptyEntity>,
70) -> Result<()> {
71    for hook in hooks {
72        hook(id, record, table).await?;
73    }
74    Ok(())
75}
76
77/// Run before-delete hooks; the first `Handled` short-circuits and signals the
78/// caller to skip the underlying delete.
79pub(crate) async fn run_before_delete<T: TableSource>(
80    hooks: &[BeforeDeleteFn<T>],
81    id: &T::Id,
82    record: &Record<T::Value>,
83    table: &Table<T, EmptyEntity>,
84) -> Result<HookReturn> {
85    for hook in hooks {
86        if let HookReturn::Handled = hook(id, record, table).await? {
87            return Ok(HookReturn::Handled);
88        }
89    }
90    Ok(HookReturn::Proceed)
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::mocks::mock_table_source::MockTableSource;
97    use crate::table::{AfterFn, BeforeFn, Hook};
98    use serde_json::{Value, json};
99    use std::sync::Arc;
100    use std::sync::atomic::{AtomicUsize, Ordering};
101    use vantage_dataset::prelude::{InsertableValueSet, ReadableValueSet, WritableValueSet};
102
103    type MockTable = Table<MockTableSource, EmptyEntity>;
104
105    // Non-capturing hooks are free fns returning a boxed future with an elided
106    // lifetime tied to the args; `Arc::new(f)` coerces to the hook type.
107    fn stamp<'a>(
108        rec: &'a mut Record<Value>,
109        _t: &'a MockTable,
110    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
111        Box::pin(async move {
112            rec.insert("created".into(), json!("yes"));
113            Ok(())
114        })
115    }
116
117    // `&String` (not `&str`) is dictated by the hook signature — `T::Id` is `String`.
118    #[allow(clippy::ptr_arg)]
119    fn veto<'a>(
120        _id: &'a String,
121        _rec: &'a Record<Value>,
122        _t: &'a MockTable,
123    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<HookReturn>> + Send + 'a>> {
124        Box::pin(async move { Err(vantage_core::error!("deletion not allowed")) })
125    }
126
127    #[allow(clippy::ptr_arg)]
128    fn soft_delete<'a>(
129        id: &'a String,
130        _rec: &'a Record<Value>,
131        table: &'a MockTable,
132    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<HookReturn>> + Send + 'a>> {
133        Box::pin(async move {
134            let mut patch = Record::new();
135            patch.insert("deleted".into(), json!("2026-01-01"));
136            table.patch_value(id.clone(), &patch).await?;
137            Ok(HookReturn::Handled)
138        })
139    }
140
141    #[tokio::test]
142    async fn before_insert_mutates_record() {
143        let src = MockTableSource::new().with_data("t", vec![]).await;
144        let table = MockTable::new("t", src)
145            .with_hook(Hook::BeforeInsert(Phase::Populate, Arc::new(stamp)));
146        let id = table
147            .insert_return_id_value(&Record::from(json!({"name": "a"})))
148            .await
149            .unwrap();
150        assert_eq!(
151            table.get_value(id).await.unwrap().unwrap()["created"],
152            json!("yes")
153        );
154    }
155
156    #[tokio::test]
157    async fn before_delete_vetoes() {
158        let src = MockTableSource::new()
159            .with_data("t", vec![json!({"id": "1", "name": "a"})])
160            .await;
161        let table = MockTable::new("t", src).with_hook(Hook::BeforeDelete(Arc::new(veto)));
162        assert!(table.delete("1").await.is_err());
163        assert!(table.get_value("1".to_string()).await.unwrap().is_some());
164    }
165
166    #[tokio::test]
167    async fn before_delete_handled_soft_deletes() {
168        let src = MockTableSource::new()
169            .with_data("t", vec![json!({"id": "1", "name": "a"})])
170            .await;
171        let table = MockTable::new("t", src).with_hook(Hook::BeforeDelete(Arc::new(soft_delete)));
172        table.delete("1").await.unwrap();
173        // Real delete skipped; the row is still there, now marked.
174        let row = table.get_value("1".to_string()).await.unwrap().unwrap();
175        assert_eq!(row["deleted"], json!("2026-01-01"));
176    }
177
178    #[tokio::test]
179    async fn after_delete_runs_side_effect() {
180        let hits = Arc::new(AtomicUsize::new(0));
181        let seen = hits.clone();
182        let hook: AfterFn<MockTableSource> = Arc::new(
183            move |_id: &String,
184                  _rec: &Record<Value>,
185                  _t: &MockTable|
186                  -> std::pin::Pin<
187                Box<dyn std::future::Future<Output = Result<()>> + Send + '_>,
188            > {
189                let seen = seen.clone();
190                Box::pin(async move {
191                    seen.fetch_add(1, Ordering::SeqCst);
192                    Ok(())
193                })
194            },
195        );
196        let src = MockTableSource::new()
197            .with_data("t", vec![json!({"id": "1", "name": "a"})])
198            .await;
199        let table = MockTable::new("t", src).with_hook(Hook::AfterDelete(hook));
200        table.delete("1").await.unwrap();
201        assert_eq!(hits.load(Ordering::SeqCst), 1);
202    }
203
204    #[tokio::test]
205    async fn before_hooks_run_in_phase_order() {
206        let order = Arc::new(std::sync::Mutex::new(Vec::<&'static str>::new()));
207        let recorder = |label: &'static str,
208                        log: Arc<std::sync::Mutex<Vec<&'static str>>>|
209         -> BeforeFn<MockTableSource> {
210            Arc::new(
211                move |_r: &mut Record<Value>,
212                      _t: &MockTable|
213                      -> std::pin::Pin<
214                    Box<dyn std::future::Future<Output = Result<()>> + Send + '_>,
215                > {
216                    let log = log.clone();
217                    Box::pin(async move {
218                        log.lock().unwrap().push(label);
219                        Ok(())
220                    })
221                },
222            )
223        };
224        let src = MockTableSource::new().with_data("t", vec![]).await;
225        // Registered Validate-first, but Normalize must run first.
226        let table = MockTable::new("t", src)
227            .with_hook(Hook::BeforeInsert(
228                Phase::Validate,
229                recorder("validate", order.clone()),
230            ))
231            .with_hook(Hook::BeforeInsert(
232                Phase::Normalize,
233                recorder("normalize", order.clone()),
234            ));
235        table
236            .insert_return_id_value(&Record::from(json!({"name": "a"})))
237            .await
238            .unwrap();
239        assert_eq!(*order.lock().unwrap(), vec!["normalize", "validate"]);
240    }
241}