Skip to main content

tablo_core/panel/
commit_hook.rs

1//! [`Panel::before_commit`]: the hooks every write of a panel runs inside its transaction, whichever
2//! resource wrote it.
3
4use std::future::Future;
5
6use toasty::Executor;
7use topcoat::{Result, context::Cx};
8
9use super::{Panel, state::current};
10use crate::{
11    BoxFuture, ModelColumns, Text,
12    resource::{Change, Resource, require_mounted},
13};
14
15/// Work every write of a panel does inside its transaction, whichever of the panel's resources
16/// wrote it: an audit trail no resource can leave out. [`Panel::before_commit`] registers it.
17///
18/// It runs on the writes [`Resource::before_commit`] runs on, right after that hook, with the
19/// same transaction and the same rules: run every statement through `ex`, and an `Err` rolls the
20/// whole write back and fails the request with the write's failure toast.
21///
22/// ```rust
23/// # use tablo_core::{Change, CommitHook, PanelChange};
24/// # use topcoat::{Result, context::Cx};
25/// # #[derive(Debug, toasty::Model)]
26/// # struct AuditEntry { #[key] #[auto] id: uuid::Uuid, resource: String, record: String }
27/// struct Audit;
28///
29/// impl CommitHook for Audit {
30///     async fn before_commit(
31///         cx: &Cx,
32///         change: &PanelChange,
33///         ex: &mut dyn toasty::Executor,
34///     ) -> Result<()> {
35///         for record in change.change().records() {
36///             toasty::create!(AuditEntry {
37///                 resource: change.label().localize(cx),
38///                 record: record.key().to_string(),
39///             })
40///             .exec(&mut *ex)
41///             .await?;
42///         }
43///         Ok(())
44///     }
45/// }
46/// ```
47pub trait CommitHook: 'static {
48    /// Work inside the write's transaction, after the resource's own
49    /// [`Resource::before_commit`].
50    fn before_commit(
51        cx: &Cx,
52        change: &PanelChange,
53        ex: &mut dyn Executor,
54    ) -> impl Future<Output = Result<()>> + Send;
55}
56
57/// A [`Change`] as a [`CommitHook`] receives it: the resource that wrote it, and each record it
58/// names by its key, its URL and the fields the write changed.
59#[derive(Debug, Clone, PartialEq)]
60pub struct PanelChange {
61    slug: String,
62    label: Text,
63    change: Change<ChangedRecord>,
64}
65
66impl PanelChange {
67    /// The slug of the resource that wrote, as its URLs spell it: `posts`.
68    pub fn slug(&self) -> &str {
69        &self.slug
70    }
71
72    /// The resource's label: `Post`, or the message its catalog spells it with.
73    pub fn label(&self) -> &Text {
74        &self.label
75    }
76
77    /// What the write changed, each record named by its key, URL and fields.
78    pub fn change(&self) -> &Change<ChangedRecord> {
79        &self.change
80    }
81}
82
83/// A record a [`PanelChange`] names.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ChangedRecord {
86    key: String,
87    url: Option<String>,
88    fields: Vec<ChangedField>,
89}
90
91impl ChangedRecord {
92    /// The record's key, as its URLs spell it: `42`.
93    pub fn key(&self) -> &str {
94        &self.key
95    }
96
97    /// The record's detail page, as [`url::record`](crate::url::record) spells it; `None` when
98    /// the resource serves none.
99    pub fn url(&self) -> Option<&str> {
100        self.url.as_deref()
101    }
102
103    /// The fields the write changed on this record, each with its value before and after it.
104    ///
105    /// They are the fields the model's [`ModelColumns::table`] lists, each spelled as that column
106    /// spells it in an export, in the request's locale, then its foreign keys and
107    /// [`TenantId`](crate::TenantId) fields, which the table leaves out, spelled as the database
108    /// stores them: a field marked `#[columns(skip)]`, such as a password hash, never appears. A
109    /// create names each field it filled, an update's `after` record each field whose spelling
110    /// changed, and a delete's or a force delete's records each field they held. The other
111    /// records name none: a restore, an action, an attach and a detach write what the model's
112    /// columns do not say, and an update's `before` record leaves the change to its `after`.
113    pub fn fields(&self) -> &[ChangedField] {
114        &self.fields
115    }
116}
117
118/// One field a write changed on a [`ChangedRecord`]: its name, its label, and its spelling before
119/// and after the write, `None` where it held nothing.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct ChangedField {
122    name: String,
123    label: Text,
124    before: Option<String>,
125    after: Option<String>,
126}
127
128impl ChangedField {
129    /// The column's name, the model field it reads: `title`.
130    pub fn name(&self) -> &str {
131        &self.name
132    }
133
134    /// The column's label: `Title`, or the message its catalog spells it with.
135    pub fn label(&self) -> &Text {
136        &self.label
137    }
138
139    /// The field's spelling before the write; `None` for a create, or a field that held nothing.
140    pub fn before(&self) -> Option<&str> {
141        self.before.as_deref()
142    }
143
144    /// The field's spelling after the write; `None` for a delete, or a field left empty.
145    pub fn after(&self) -> Option<&str> {
146        self.after.as_deref()
147    }
148}
149
150/// The fields `before` and `after` spell differently, by the columns of `M`'s default table and
151/// its [`references`](ModelColumns::references); a record that is absent spells every field empty.
152fn changed_fields<M: ModelColumns>(
153    cx: &Cx,
154    before: Option<&M>,
155    after: Option<&M>,
156) -> Vec<ChangedField> {
157    let table = M::table();
158    let (before_record, after_record) = (before, after);
159    let spell = |record: Option<&M>| record.map(|record| table.spell(cx, record));
160    let (before, after) = (spell(before), spell(after));
161    let columns = after.as_ref().or(before.as_ref()).map_or(0, Vec::len);
162    let held = |spelled: &Option<Vec<(String, Text, String)>>, index: usize| {
163        spelled
164            .as_ref()
165            .map(|spelled| spelled[index].2.clone())
166            .filter(|text| !text.is_empty())
167    };
168    let mut changed: Vec<ChangedField> = (0..columns)
169        .filter_map(|index| {
170            let (was, is) = (held(&before, index), held(&after, index));
171            let (name, label, _) = after.as_ref().or(before.as_ref())?[index].clone();
172            (was != is).then_some(ChangedField {
173                name,
174                label,
175                before: was,
176                after: is,
177            })
178        })
179        .collect();
180    let references = |record: Option<&M>| record.map(ModelColumns::references);
181    let (was, is) = (references(before_record), references(after_record));
182    let names = is.as_ref().or(was.as_ref()).cloned().unwrap_or_default();
183    for (index, (name, _)) in names.into_iter().enumerate() {
184        let held = |spelled: &Option<Vec<(&str, Option<String>)>>| {
185            spelled
186                .as_ref()
187                .and_then(|spelled| spelled[index].1.clone())
188        };
189        let (before, after) = (held(&was), held(&is));
190        if before != after && changed.iter().all(|field| field.name != name) {
191            changed.push(ChangedField {
192                name: name.to_owned(),
193                label: Text::field(name),
194                before,
195                after,
196            });
197        }
198    }
199    changed
200}
201
202/// `change` with each record named by its key and URL, and the fields the write changed on it.
203fn changed_records<R: Resource>(
204    cx: &Cx,
205    change: &Change<R::Model>,
206    key: impl Fn(&R::Model) -> String,
207) -> Change<ChangedRecord> {
208    let named = |record: &R::Model, fields| ChangedRecord {
209        key: key(record),
210        url: crate::url::record::<R>(cx, record),
211        fields,
212    };
213    let held = |records: &[R::Model]| {
214        records
215            .iter()
216            .map(|record| named(record, changed_fields(cx, Some(record), None)))
217            .collect()
218    };
219    match change {
220        Change::Create(record) => Change::Create(named(
221            record,
222            changed_fields::<R::Model>(cx, None, Some(record)),
223        )),
224        Change::Update { before, after } => Change::Update {
225            before: named(before, Vec::new()),
226            after: named(after, changed_fields(cx, Some(before), Some(after))),
227        },
228        Change::Delete(records) => Change::Delete(held(records)),
229        Change::ForceDelete(records) => Change::ForceDelete(held(records)),
230        other => other.map(|record| named(record, Vec::new())),
231    }
232}
233
234/// A registered [`CommitHook`], its type erased.
235pub(crate) type CommitHookFn =
236    for<'a> fn(&'a Cx, &'a PanelChange, &'a mut dyn Executor) -> BoxFuture<'a, Result<()>>;
237
238fn erased<'a, H: CommitHook>(
239    cx: &'a Cx,
240    change: &'a PanelChange,
241    ex: &'a mut dyn Executor,
242) -> BoxFuture<'a, Result<()>> {
243    Box::pin(H::before_commit(cx, change, ex))
244}
245
246impl Panel {
247    /// Runs the [`CommitHook`] `H` inside the transaction of every write of the panel, after the
248    /// writing resource's own [`Resource::before_commit`]. Several hooks run in the order they
249    /// are registered.
250    pub fn before_commit<H: CommitHook>(mut self) -> Self {
251        self.commit_hooks.push(erased::<H>);
252        self
253    }
254}
255
256/// Run `R`'s [`Resource::before_commit`] on `change`, then each of the panel's [`CommitHook`]s,
257/// inside the write's transaction `ex`.
258pub(crate) async fn before_commit<R: Resource>(
259    cx: &Cx,
260    change: &Change<R::Model>,
261    ex: &mut dyn Executor,
262) -> Result<()> {
263    R::before_commit(cx, change, &mut *ex).await?;
264    let hooks = current(cx).map_or(&[][..], |panel| panel.commit_hooks.as_slice());
265    if hooks.is_empty() {
266        return Ok(());
267    }
268    let mounted = require_mounted::<R>(cx)?;
269    let change = PanelChange {
270        slug: mounted.slug.clone(),
271        label: mounted.label.clone(),
272        change: changed_records::<R>(cx, change, |record| mounted.table.key_of(record)),
273    };
274    run(cx, hooks, &change, ex).await
275}
276
277/// Run each of `hooks` on `change`, stopping at the first error.
278fn run<'a>(
279    cx: &'a Cx,
280    hooks: &'a [CommitHookFn],
281    change: &'a PanelChange,
282    ex: &'a mut dyn Executor,
283) -> BoxFuture<'a, Result<()>> {
284    Box::pin(async move {
285        for hook in hooks {
286            hook(cx, change, &mut *ex).await?;
287        }
288        Ok(())
289    })
290}
291
292#[cfg(test)]
293mod tests;