Skip to main content

tablo_core/resource/
write.rs

1//! The derived record writes a resource's create and update default to.
2
3use toasty::{Executor, stmt::IntoInsert};
4use topcoat::{Result, context::Cx};
5
6use super::{Mounted, Resource, Trashed, WriteError, require_mounted};
7use crate::{
8    form::{FormValues, Posted, RecordForm},
9    tenancy::require_tenant,
10    toasty_compat::{join::JoinTable, model::AppSchema, pk::pk_filter},
11};
12
13/// The derived create: the form's builder, the request tenant stamped on the
14/// resource's [`Tenancy::column`](crate::Tenancy::column), executed through
15/// `ex`, then a join row linking the record to each record its many-to-many fields hold.
16///
17/// A resource whose tenant is inherited ([`Tenancy::via`](crate::Tenancy::via))
18/// has nothing to stamp.
19///
20/// # Errors
21///
22/// [`WriteError::Failed`] on a tenantless request to a tenant-scoped resource (the handler
23/// answers 403 first),
24/// a `column` tenancy whose lens names no field of the model (the mount refuses it first), a
25/// resource the context's panel does not mount, or the driver's error.
26pub async fn write_create<R: Resource>(
27    cx: &Cx,
28    form: R::Form,
29    ex: &mut dyn Executor,
30) -> Result<R::Model, WriteError<<R::Form as RecordForm>::Field>> {
31    let resource = require_mounted::<R>(cx)?;
32    let links = form.links();
33    let mut insert = form.into_create().into_insert();
34    match resource.tenancy.column_field() {
35        Some(Ok(column)) => {
36            insert.set(
37                column.index,
38                toasty::stmt::Value::from(require_tenant(cx)?.get()),
39            );
40        }
41        Some(Err(error)) => {
42            return Err(crate::error::declaration(
43                crate::DeclarationError::of::<R>(crate::Site::Tenancy, error).to_string(),
44            )
45            .into());
46        }
47        None => {}
48    }
49    let record: R::Model = ex
50        .exec(insert.into())
51        .await
52        .map_err(|error| -> topcoat::Error { error.into() })?;
53    for (field, keys) in links {
54        let join = join_table(cx, &resource, field)?;
55        join.link(&record, &keys, &mut *ex)
56            .await
57            .map_err(|error| -> topcoat::Error { error.into() })?;
58    }
59    Ok(record)
60}
61
62/// The derived update: assign every named field and execute through `ex`, then link the record
63/// to the records each named many-to-many field holds, and unlink it from the others.
64///
65/// A submission that names no field writes nothing. The instance update
66/// reloads `record`, so the returned row is the written one.
67///
68/// # Errors
69///
70/// [`WriteError::Failed`] with the driver's error.
71pub async fn write_update<R: Resource>(
72    cx: &Cx,
73    mut record: R::Model,
74    posted: Posted<R::Form>,
75    ex: &mut dyn Executor,
76) -> Result<R::Model, WriteError<<R::Form as RecordForm>::Field>> {
77    let links: Vec<_> = posted
78        .links()
79        .into_iter()
80        .filter(|(field, _)| posted.named(*field))
81        .collect();
82    if !links.is_empty() {
83        let resource = require_mounted::<R>(cx)?;
84        let mut stored = <R::Form as RecordForm>::hydrate(cx, &record);
85        for (field, keys) in links {
86            let join = join_table(cx, &resource, field)?;
87            let key = form_key(&resource, field);
88            if !stored.contains(key) {
89                // A record loaded without its links, by an app's own update: read them now.
90                stored = linked_now::<R>(cx, &record, &mut *ex).await?;
91            }
92            let held = stored.list(key).unwrap_or_default();
93            let removed: Vec<String> = held
94                .iter()
95                .filter(|key| !keys.contains(key))
96                .cloned()
97                .collect();
98            let added: Vec<String> = keys.into_iter().filter(|key| !held.contains(key)).collect();
99            join.unlink(&record, &removed, &mut *ex)
100                .await
101                .map_err(|error| -> topcoat::Error { error.into() })?;
102            join.link(&record, &added, &mut *ex)
103                .await
104                .map_err(|error| -> topcoat::Error { error.into() })?;
105        }
106    }
107    // Build the execution future before awaiting: the builder itself is not
108    // known to be `Send`, the future `exec_update` returns is.
109    {
110        let pending = posted
111            .into_update(&mut record)
112            .map(|update| <R::Form as RecordForm>::exec_update(update, &mut *ex));
113        if let Some(pending) = pending {
114            pending
115                .await
116                .map_err(|error| -> topcoat::Error { error.into() })?;
117        }
118    }
119    Ok(record)
120}
121
122impl<R: Resource> Mounted<R> {
123    /// [`Resource::create_record`], then [`ensure_in_tenant`] on the row it returns.
124    pub(crate) async fn create(
125        &self,
126        cx: &Cx,
127        form: R::Form,
128        ex: &mut dyn Executor,
129    ) -> Result<R::Model, WriteError<<R::Form as RecordForm>::Field>> {
130        let written = R::create_record(cx, form, &mut *ex).await?;
131        ensure_in_tenant(cx, self, &written, "create_record", ex).await?;
132        Ok(written)
133    }
134
135    /// [`Resource::update_record`], then [`ensure_in_tenant`] on the row it returns.
136    pub(crate) async fn update(
137        &self,
138        cx: &Cx,
139        record: R::Model,
140        posted: Posted<R::Form>,
141        ex: &mut dyn Executor,
142    ) -> Result<R::Model, WriteError<<R::Form as RecordForm>::Field>> {
143        let written = R::update_record(cx, record, posted, &mut *ex).await?;
144        ensure_in_tenant(cx, self, &written, "update_record", ex).await?;
145        Ok(written)
146    }
147}
148
149/// Refuses a write whose row the request's tenant scope does not hold, read through `ex` so the
150/// check sees the row before the transaction commits.
151///
152/// The derived create stamps the tenant, but an overriding record fn that does not delegate to
153/// [`write_create`] stamps nothing, and an update may move the row to another tenant. Either
154/// fails closed here, and the transaction rolls back. The check reads the tenant scope alone:
155/// a [`query`](super::ResourceDef::query) narrowing or the trash may leave out a row a write
156/// rightly produces, such as an edit that archives it.
157async fn ensure_in_tenant<R: Resource>(
158    cx: &Cx,
159    resource: &Mounted<R>,
160    written: &R::Model,
161    record_fn: &str,
162    ex: &mut dyn Executor,
163) -> Result<()> {
164    if !resource.tenancy.is_scoped() {
165        return Ok(());
166    }
167    let query = resource
168        .tenant_scope(cx, toasty::stmt::Query::all())?
169        .filter(pk_filter(written));
170    let held = crate::toasty_compat::count(query, ex)
171        .await
172        .map_err(|error| -> topcoat::Error { error.into() })?;
173    if held == 0 {
174        return Err(crate::error::declaration(format!(
175            "resource '{}': `{record_fn}` wrote a row outside the request's tenant, so the \
176             write rolled back. An overriding `create_record` stamps the tenant by delegating \
177             to `write_create`.",
178            resource.slug
179        )));
180    }
181    Ok(())
182}
183
184/// The derived delete: stamps the [`soft_delete`](super::ResourceDef::soft_delete) column with
185/// the time on a resource that declares one, and removes the row on any other, through `ex`.
186///
187/// It writes through the tenant-scoped query, filtered to the record's primary key, so a record
188/// outside the request's tenant is left alone. An overriding
189/// [`delete_record`](Resource::delete_record) that refuses some records delegates the others
190/// here:
191///
192/// ```rust
193/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
194/// # struct Order { #[key] #[auto] id: uuid::Uuid, paid: bool }
195/// # use tablo_core::{NoForm, Resource, WriteError, write_delete};
196/// # use topcoat::context::Cx;
197/// # struct OrderResource;
198/// impl Resource for OrderResource {
199///     # type Model = Order;
200///     # type Form = NoForm<Order>;
201///     async fn delete_record(
202///         cx: &Cx,
203///         order: &Order,
204///         ex: &mut dyn toasty::Executor,
205///     ) -> Result<(), WriteError> {
206///         if order.paid {
207///             return Err(WriteError::declined("A paid order stays"));
208///         }
209///         write_delete::<Self>(cx, order, ex).await
210///     }
211/// }
212/// ```
213///
214/// # Errors
215///
216/// [`WriteError::Failed`] for a resource the context's panel does not mount, a soft-delete lens
217/// naming no column (the mount refuses it first), or the driver's error.
218pub async fn write_delete<R: Resource>(
219    cx: &Cx,
220    record: &R::Model,
221    ex: &mut dyn Executor,
222) -> Result<(), WriteError> {
223    let resource = require_mounted::<R>(cx)?;
224    if resource.soft_delete.is_some() {
225        let now = toasty::stmt::Value::from(jiff::Timestamp::now());
226        return Ok(stamp(cx, &resource, record, Trashed::Without, now, ex).await?);
227    }
228    Ok(remove(cx, &resource, record, Trashed::Without, ex).await?)
229}
230
231/// The derived restore: empties the [`soft_delete`](super::ResourceDef::soft_delete) column of a
232/// trashed record, through `ex` and the tenant-scoped query, as [`write_delete`] stamps it.
233///
234/// # Errors
235///
236/// [`WriteError::Failed`] for a resource that declares no soft-delete column or that the
237/// context's panel does not mount, or the driver's error.
238pub async fn write_restore<R: Resource>(
239    cx: &Cx,
240    record: &R::Model,
241    ex: &mut dyn Executor,
242) -> Result<(), WriteError> {
243    let resource = require_mounted::<R>(cx)?;
244    Ok(stamp(
245        cx,
246        &resource,
247        record,
248        Trashed::Only,
249        toasty::stmt::Value::Null,
250        ex,
251    )
252    .await?)
253}
254
255/// The derived force delete: removes a trashed record's row, through `ex` and the tenant-scoped
256/// query, as [`write_delete`] removes one.
257///
258/// # Errors
259///
260/// [`WriteError::Failed`] for a resource the context's panel does not mount, or the driver's
261/// error.
262pub async fn write_force_delete<R: Resource>(
263    cx: &Cx,
264    record: &R::Model,
265    ex: &mut dyn Executor,
266) -> Result<(), WriteError> {
267    let resource = require_mounted::<R>(cx)?;
268    Ok(remove(cx, &resource, record, Trashed::Only, ex).await?)
269}
270
271/// Sets `record`'s soft-delete column to `value`, if `trashed` admits it.
272///
273/// # Errors
274///
275/// A declaration error on a resource that does not soft-delete, or one whose lens names no
276/// column (the mount refuses both first), or the driver's error.
277async fn stamp<R: Resource>(
278    cx: &Cx,
279    resource: &Mounted<R>,
280    record: &R::Model,
281    trashed: Trashed,
282    value: toasty::stmt::Value,
283    ex: &mut dyn Executor,
284) -> Result<()> {
285    let index = match resource.soft_delete.as_ref().map(super::SoftDelete::field) {
286        Some(Ok((index, _))) => *index,
287        Some(Err(kind)) => {
288            return Err(crate::error::declaration(
289                crate::DeclarationError::of::<R>(crate::Site::Registration, kind.clone())
290                    .to_string(),
291            ));
292        }
293        None => {
294            return Err(crate::error::declaration(format!(
295                "resource '{}' declares no soft-delete column to restore through",
296                resource.slug
297            )));
298        }
299    };
300    let query = resource
301        .scoped_query_in(cx, trashed)?
302        .filter(pk_filter(record));
303    let mut update = toasty::stmt::Update::<toasty::stmt::List<R::Model>>::new(query);
304    update.as_untyped_mut().returning = None;
305    update.set(index, value);
306    toasty::stmt::Statement::<()>::from_untyped_stmt(update.into_untyped())
307        .exec(ex)
308        .await
309        .map_err(|error| -> topcoat::Error { error.into() })?;
310    Ok(())
311}
312
313/// Removes `record`'s row, if `trashed` admits it.
314async fn remove<R: Resource>(
315    cx: &Cx,
316    resource: &Mounted<R>,
317    record: &R::Model,
318    trashed: Trashed,
319    ex: &mut dyn Executor,
320) -> Result<()> {
321    resource
322        .scoped_query_in(cx, trashed)?
323        .filter(pk_filter(record))
324        .delete()
325        .exec(ex)
326        .await
327        .map_err(|error| -> topcoat::Error { error.into() })?;
328    Ok(())
329}
330
331/// The form key the record form binds `field` to.
332fn form_key<R: Resource>(resource: &Mounted<R>, field: <R::Form as RecordForm>::Field) -> &str {
333    resource
334        .fields
335        .iter()
336        .find(|claim| claim.field == field)
337        .and_then(|claim| claim.keys.first())
338        .map_or("", String::as_str)
339}
340
341/// The join model behind the many-to-many field `field`.
342///
343/// # Errors
344///
345/// A declaration error: no `Db` in `cx`, or a field the mount would have refused.
346fn join_table<R: Resource>(
347    cx: &Cx,
348    resource: &Mounted<R>,
349    field: <R::Form as RecordForm>::Field,
350) -> Result<JoinTable> {
351    let key = form_key(resource, field);
352    let schema = AppSchema::of(cx).ok_or_else(|| {
353        crate::error::declaration(crate::DeclarationErrorKind::MissingDb.to_string())
354    })?;
355    JoinTable::of::<R::Model>(&schema, key).map_err(|fault| {
356        crate::error::declaration(
357            crate::DeclarationError::of::<R>(
358                crate::Site::Form,
359                crate::DeclarationErrorKind::ManyToMany {
360                    field: key.to_string(),
361                    fault,
362                },
363            )
364            .to_string(),
365        )
366    })
367}
368
369/// `record` as the form spells it, reloaded with the links its many-to-many fields hold.
370async fn linked_now<R: Resource>(
371    cx: &Cx,
372    record: &R::Model,
373    ex: &mut dyn Executor,
374) -> Result<FormValues> {
375    let reloaded =
376        toasty::stmt::Query::<toasty::stmt::List<R::Model>>::all().filter(pk_filter(record));
377    let reloaded = <R::Form as RecordForm>::includes()
378        .into_vec()
379        .into_iter()
380        .fold(reloaded, |query, include| query.include(include))
381        .first()
382        .exec(ex)
383        .await
384        .map_err(|error| -> topcoat::Error { error.into() })?;
385    Ok(reloaded
386        .map(|reloaded| <R::Form as RecordForm>::hydrate(cx, &reloaded))
387        .unwrap_or_default())
388}