Skip to main content

tablo_core/
resource.rs

1//! `Resource` — maps one Toasty [`Model`](toasty::schema::Model) to its admin UI.
2
3use toasty::{
4    Executor,
5    stmt::{List, Query},
6};
7use topcoat::{Result, context::Cx};
8
9use crate::form::{FieldErrors, Posted, RecordForm};
10
11mod action;
12mod check;
13mod commit;
14mod def;
15mod editable;
16mod field_access;
17mod global_search;
18mod header_action;
19mod mounted;
20mod pages;
21mod query;
22mod record_page;
23mod record_title;
24mod relation;
25mod soft_delete;
26mod write;
27mod write_error;
28
29pub use action::{Action, Places, filter_keys};
30pub(crate) use action::{
31    ActionEntry, Actions, ErasedInput, InputResult, InputSpec, RESERVED_KEYS, parse_validated,
32};
33pub(crate) use check::{ResourceCheck, ServesTenants, check_inputs, check_resource};
34pub use commit::Change;
35pub(crate) use commit::{acted, run_after_commit};
36pub use def::{PublicLink, ResourceDef};
37pub use field_access::FieldAccess;
38pub(crate) use field_access::{FieldMask, FieldRule};
39pub use global_search::GlobalSearch;
40pub(crate) use global_search::{Opens, SearchHit, search, searchable};
41pub(crate) use header_action::HeaderEntry;
42pub use header_action::{HeaderAction, HeaderActions};
43pub(crate) use mounted::{
44    MountScope, Mounted, Mounts, is_mounted, is_restricted_model, is_tenant_model, model_rules,
45    mounted, require_mounted, restricts_any,
46};
47pub(crate) use pages::Pages;
48pub use pages::ResourcePage;
49pub(crate) use query::QueryFn;
50pub(crate) use record_page::{
51    RESERVED_SEGMENTS as RESERVED_RECORD_SEGMENTS, RecordPageEntry, check as check_record_pages,
52};
53pub use record_page::{RecordPage, RecordPageDef};
54use record_title::RecordTitle;
55pub use relation::{ForeignKey, Relation};
56pub(crate) use relation::{Links, RelationKind};
57pub use soft_delete::Trashed;
58pub(crate) use soft_delete::{SoftDelete, TRASH_FILTER, TrashFilter};
59pub use write::{write_create, write_delete, write_force_delete, write_restore, write_update};
60pub use write_error::WriteError;
61
62/// Maps one Toasty `Model` to its admin UI.
63///
64/// # Contract
65///
66/// Requires [`Model`](Self::Model) and [`Form`](Self::Form); every other item has a default.
67/// [`declare`](Self::declare) returns what the resource declares, as one [`ResourceDef`] value;
68/// the methods scope, validate and write records for a request.
69///
70/// The panel builds the def once when it mounts, binding the paths it names to the database
71/// schema, and serves the result to every request. Record fns run inside the handler transaction
72/// and fail without partial writes.
73pub trait Resource: Sized + Send + Sync + 'static {
74    /// The persisted model this resource administers. Its [`ModelColumns`](crate::ModelColumns)
75    /// give the table and detail page the resource declares none of.
76    type Model: crate::ModelColumns
77        + toasty::stmt::IntoExpr<Self::Model>
78        + Send
79        + Sync
80        + Clone
81        + 'static;
82
83    /// The typed value the create and edit forms parse into.
84    ///
85    /// A resource with create and edit pages names its
86    /// [`#[derive(RecordForm)]`](crate::RecordForm) struct; a list-only resource
87    /// names [`NoForm<Self::Model>`](crate::NoForm), and
88    /// [`Panel::resource`](crate::Panel::resource) registers no form route for
89    /// it ([`RecordForm::HAS_FORM`]).
90    type Form: RecordForm<Model = Self::Model>;
91
92    /// What the resource declares: names, navigation, policy, tenancy, table, form, detail page,
93    /// relations and actions. Defaults to [`ResourceDef::new`], whose policy denies all.
94    ///
95    /// ```rust
96    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
97    /// # struct Post {
98    /// #     #[key] #[auto] id: uuid::Uuid,
99    /// #     title: String,
100    /// #     body: String,
101    /// # }
102    /// # #[derive(Debug, Clone, tablo_core::RecordForm)]
103    /// # #[form(model = Post)]
104    /// # struct PostForm { title: String, body: String }
105    /// # struct PostResource;
106    /// # use tablo_core::{ReadOnly, RecordForm, Resource, ResourceDef, Schema, Section};
107    /// impl Resource for PostResource {
108    ///     type Model = Post;
109    ///     type Form = PostForm;
110    ///
111    ///     fn declare() -> ResourceDef<Self> {
112    ///         let c = PostForm::controls();
113    ///         ResourceDef::new().policy(ReadOnly).form(Schema::new(
114    ///             Section::new("Content").schema((c.title, c.body.multiline(6))),
115    ///         ))
116    ///     }
117    /// }
118    /// ```
119    ///
120    /// The panel builds the def once when it mounts and serves that copy to every request, so
121    /// `declare` takes no user, tenant or query string: a request-dependent decision belongs to
122    /// the policy or to a trait method, which receives `cx`.
123    fn declare() -> ResourceDef<Self> {
124        ResourceDef::new()
125    }
126
127    /// App-level rules on the parsed form. The errors render inline with a
128    /// 200 and nothing is written, so a range or cross-field rule belongs here. A rule that
129    /// reads the database belongs in [`create_record`](Self::create_record) or
130    /// [`update_record`](Self::update_record), which refuse through the transaction they write
131    /// in.
132    ///
133    /// Each error names a field of the record form (`UserFormField::Age`) and renders under its
134    /// control, or under an embedded value's first control.
135    fn validate_record(
136        _cx: &Cx,
137        _form: &Self::Form,
138    ) -> FieldErrors<<Self::Form as RecordForm>::Field> {
139        FieldErrors::new()
140    }
141
142    /// Create a record from the parsed form, inside the handler's transaction.
143    ///
144    /// Defaults to the derived write, [`write_create`]; override to check
145    /// something inside the transaction, then delegate. An override on a
146    /// [`Tenancy::column`](crate::Tenancy::column) resource stamps the tenant
147    /// only by delegating to [`write_create`]: the handler re-reads the returned row through the
148    /// request's tenant scope before it commits, and a row outside it fails the write closed.
149    /// `ex` is the open transaction: run every statement through it. Return the created row; the
150    /// hooks receive it as [`Change::Create`].
151    ///
152    /// A rule that reads the database refuses the submission with [`WriteError::Refused`]: the
153    /// transaction rolls back and the form renders again with the errors under their fields. A
154    /// reason that names no field declines it with [`WriteError::declined`], which the form
155    /// renders above its fields.
156    fn create_record(
157        cx: &Cx,
158        form: Self::Form,
159        ex: &mut dyn Executor,
160    ) -> impl Future<Output = Result<Self::Model, WriteError<<Self::Form as RecordForm>::Field>>> + Send
161    {
162        write_create::<Self>(cx, form, ex)
163    }
164
165    /// Update the already-authorized `record` from the posted form, inside the
166    /// handler's transaction.
167    ///
168    /// Defaults to the derived write, [`write_update`]. `record` is the
169    /// snapshot the handler loaded and policy-checked inside the transaction:
170    /// use it, never re-query. Return the row as it now stands: a row the request's tenant scope
171    /// no longer holds fails the write closed, as in [`create_record`](Self::create_record).
172    ///
173    /// Refuses or declines the submission as [`create_record`](Self::create_record) does.
174    fn update_record(
175        cx: &Cx,
176        record: Self::Model,
177        posted: Posted<Self::Form>,
178        ex: &mut dyn Executor,
179    ) -> impl Future<Output = Result<Self::Model, WriteError<<Self::Form as RecordForm>::Field>>> + Send
180    {
181        write_update::<Self>(cx, record, posted, ex)
182    }
183
184    /// Delete the already-authorized `record`.
185    ///
186    /// The handler loads `record` through the tenancy-scoped query inside the
187    /// framework transaction and checks the policy on that snapshot, then
188    /// calls this with the same transaction as `ex`. The hooks receive the
189    /// snapshot as [`Change::Delete`].
190    ///
191    /// The default is [`write_delete`]: it deletes the row through [`scoped_query`], filtered to
192    /// the record's primary key, or stamps its [`soft_delete`](ResourceDef::soft_delete) column
193    /// with the time when the resource declares one. Override to delete another way, or to refuse
194    /// with [`WriteError::declined`] and delegate the rest to [`write_delete`]: the transaction
195    /// rolls back and the message flashes, as it does for the restore, the force delete and the
196    /// bulk delete.
197    fn delete_record(
198        cx: &Cx,
199        record: &Self::Model,
200        ex: &mut dyn toasty::Executor,
201    ) -> impl std::future::Future<Output = Result<(), WriteError>> + Send {
202        write_delete::<Self>(cx, record, ex)
203    }
204
205    /// Restore the already-authorized trashed `record` of a resource that
206    /// [soft-deletes](ResourceDef::soft_delete), inside the handler's transaction.
207    ///
208    /// The handler loads `record` through [`scoped_query_in`] with [`Trashed::Only`] and checks
209    /// [`Ability::Restore`](crate::Ability::Restore) on it. The default, [`write_restore`], empties
210    /// the soft-delete column.
211    fn restore_record(
212        cx: &Cx,
213        record: &Self::Model,
214        ex: &mut dyn toasty::Executor,
215    ) -> impl std::future::Future<Output = Result<(), WriteError>> + Send {
216        write_restore::<Self>(cx, record, ex)
217    }
218
219    /// Delete the already-authorized trashed `record` for good, inside the handler's
220    /// transaction.
221    ///
222    /// The handler loads `record` through [`scoped_query_in`] with [`Trashed::Only`] and checks
223    /// [`Ability::ForceDelete`](crate::Ability::ForceDelete) on it. The default,
224    /// [`write_force_delete`], removes the row.
225    fn force_delete_record(
226        cx: &Cx,
227        record: &Self::Model,
228        ex: &mut dyn toasty::Executor,
229    ) -> impl std::future::Future<Output = Result<(), WriteError>> + Send {
230        write_force_delete::<Self>(cx, record, ex)
231    }
232
233    /// Bulk-delete the already-authorized `records`: the handler
234    /// fetches through the tenancy-scoped `IN` query inside the framework
235    /// transaction, checks `View` on every row, and passes the rows
236    /// [`Ability::Delete`](crate::Ability::Delete) allows, skipping the rest
237    /// as a bulk custom action does. The default deletes each record through
238    /// [`Self::delete_record`] in order, through the same `ex` — any error rolls the whole
239    /// batch back, so mid-loop failures delete zero rows. An override of `delete_record`,
240    /// such as a soft delete, therefore covers bulk delete too. Override this
241    /// for a single-statement batch.
242    fn bulk_delete_records(
243        cx: &Cx,
244        records: &[Self::Model],
245        ex: &mut dyn toasty::Executor,
246    ) -> impl std::future::Future<Output = Result<(), WriteError>> + Send {
247        async move {
248            for record in records {
249                Self::delete_record(cx, record, &mut *ex).await?;
250            }
251            Ok(())
252        }
253    }
254
255    /// Work inside the write's transaction, after the record fn or action wrote: an audit row,
256    /// a counter, a row in another table that must commit with the write or not at all.
257    /// Work every resource of a panel does, such as an audit trail, belongs in a
258    /// [`CommitHook`](crate::CommitHook), which the panel runs right after this one.
259    ///
260    /// Called once per write, with the [`Change`] it made, through `ex`, the open transaction:
261    /// run every statement through it, and notify through
262    /// [`Notifications::send_in`](crate::Notifications::send_in), which stores the
263    /// notification only if the write commits. A second connection, through
264    /// [`db`](crate::db::db) or [`Notifications::send`](crate::Notifications::send),
265    /// would write outside the transaction, and on a pool of one wait forever for the connection
266    /// the transaction holds; either refuses instead, and the write rolls back.
267    ///
268    /// Every write this resource's panel routes make comes here: a create, once per row of a CSV
269    /// import, an update, a delete or bulk delete, a restore or force delete, a custom or header
270    /// action, and a many-to-many attach or detach. The profile and tenant-settings pages save
271    /// their user and tenant rows themselves and do not. A validation error, a policy denial or
272    /// a failed or refused record fn returns before it. An `Err` rolls the whole write back and
273    /// fails the request with the write's failure toast.
274    ///
275    /// ```rust
276    /// # use tablo_core::{Change, NoForm, Resource};
277    /// # use topcoat::{Result, context::Cx};
278    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
279    /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
280    /// # #[derive(Debug, toasty::Model)]
281    /// # struct Audit { #[key] #[auto] id: uuid::Uuid, post_id: uuid::Uuid, change: String }
282    /// # struct PostResource;
283    /// # impl Resource for PostResource {
284    /// #     type Model = Post;
285    /// #     type Form = NoForm<Post>;
286    /// async fn before_commit(
287    ///     _cx: &Cx,
288    ///     change: &Change<Post>,
289    ///     ex: &mut dyn toasty::Executor,
290    /// ) -> Result<()> {
291    ///     let kind = match change {
292    ///         Change::Create(_) => "create",
293    ///         Change::Update { .. } => "update",
294    ///         Change::Delete(_) => "delete",
295    ///         Change::Restore(_) => "restore",
296    ///         Change::ForceDelete(_) => "force delete",
297    ///         Change::Action { name, .. } | Change::HeaderAction { name } => name,
298    ///         Change::Attach { .. } => "attach",
299    ///         Change::Detach { .. } => "detach",
300    ///     };
301    ///     for post in change.records() {
302    ///         toasty::create!(Audit {
303    ///             post_id: post.id,
304    ///             change: kind.to_string()
305    ///         })
306    ///         .exec(&mut *ex)
307    ///         .await?;
308    ///     }
309    ///     Ok(())
310    /// }
311    /// # }
312    /// ```
313    fn before_commit(
314        _cx: &Cx,
315        _change: &Change<Self::Model>,
316        _ex: &mut dyn Executor,
317    ) -> impl Future<Output = Result<()>> + Send {
318        async move { Ok(()) }
319    }
320
321    /// Work after the write commits, outside the database: an email, a webhook, cache
322    /// invalidation.
323    ///
324    /// Called once per committed write, after `tx.commit()` and before the response, with the
325    /// [`Change`] [`Self::before_commit`] received. It is never called when nothing committed, so
326    /// a rollback cannot produce the effect. Nothing guarantees it runs either: an error it
327    /// returns is logged and ignored, since the write is committed, and a crash between the
328    /// commit and the hook skips it. A row that must exist whenever the write does belongs in
329    /// [`Self::before_commit`]; retries are the app's to build. A panic surfaces as Topcoat's
330    /// panic-isolated 500.
331    ///
332    /// ```rust
333    /// # use tablo_core::{Change, NoForm, Resource};
334    /// # use topcoat::{Result, context::Cx};
335    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
336    /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
337    /// # async fn notify(_cx: &Cx, _post: &Post) -> Result<()> { Ok(()) }
338    /// # struct PostResource;
339    /// # impl Resource for PostResource {
340    /// #     type Model = Post;
341    /// #     type Form = NoForm<Post>;
342    /// async fn after_commit(cx: &Cx, change: Change<Post>) -> Result<()> {
343    ///     if let Change::Create(post) = &change {
344    ///         notify(cx, post).await?;
345    ///     }
346    ///     Ok(())
347    /// }
348    /// # }
349    /// ```
350    fn after_commit(
351        _cx: &Cx,
352        _change: Change<Self::Model>,
353    ) -> impl Future<Output = Result<()>> + Send {
354        async move { Ok(()) }
355    }
356}
357
358/// The tenant-scoped base query.
359///
360/// The resource's rows as the context's panel narrows them with [`ResourceDef::query`], with the
361/// [`tenancy`](ResourceDef::tenancy) filter ANDed onto them, so no narrowing can drop the tenant
362/// scope, and with the trashed records of a resource that [soft-deletes](ResourceDef::soft_delete)
363/// left out.
364/// Every framework loader and app code start here; [`scoped_query_in`] admits the trashed
365/// records too.
366///
367/// # Errors
368///
369/// A tenant-scoped resource and no tenant in `cx`: 403, the same answer the
370/// panel's handlers give. A declaration error when the context's panel does not mount `R`; a
371/// background job builds its context with [`PanelHandle::context`](crate::PanelHandle::context).
372///
373/// App code that loads rows itself must call this: `Query::all()` reads every tenant's rows and
374/// skips the panel's narrowings.
375pub fn scoped_query<R: Resource>(cx: &Cx) -> Result<Query<List<R::Model>>> {
376    scoped_query_in::<R>(cx, Trashed::Without)
377}
378
379/// [`scoped_query`] admitting the records `trashed` names, as a restore or a trash list loads
380/// them, under the same tenant filter.
381///
382/// On a resource that declares no [`soft_delete`](ResourceDef::soft_delete) column every record
383/// is live: [`Trashed::Only`] admits none, and the other scopes admit all.
384///
385/// # Errors
386///
387/// As [`scoped_query`].
388pub fn scoped_query_in<R: Resource>(cx: &Cx, trashed: Trashed) -> Result<Query<List<R::Model>>> {
389    require_mounted::<R>(cx)?.scoped_query_in(cx, trashed)
390}
391
392impl<R: Resource> Mounted<R> {
393    /// [`scoped_query`] for this mount.
394    pub(crate) fn scoped_query(&self, cx: &Cx) -> Result<Query<List<R::Model>>> {
395        self.scoped_query_in(cx, Trashed::Without)
396    }
397
398    /// [`scoped_query_in`] for this mount.
399    pub(crate) fn scoped_query_in(
400        &self,
401        cx: &Cx,
402        trashed: Trashed,
403    ) -> Result<Query<List<R::Model>>> {
404        let query = self
405            .queries
406            .iter()
407            .fold(Query::<List<R::Model>>::all(), |query, narrow| {
408                narrow(cx, query)
409            });
410        let query = self.tenant_scope(cx, query)?;
411        Ok(match &self.soft_delete {
412            Some(soft_delete) => match soft_delete.filter(trashed) {
413                Some(filter) => query.filter(filter),
414                None => query,
415            },
416            None if trashed == Trashed::Only => query.filter(crate::toasty_compat::never()),
417            None => query,
418        })
419    }
420
421    /// The records a list in `state` admits: the ones its trash control selects, else the live
422    /// ones.
423    pub(crate) fn list_trashed(&self, state: &crate::TableState) -> Trashed {
424        if self.soft_delete.is_none() {
425            return Trashed::Without;
426        }
427        Trashed::from_filter(state.filters.get(TRASH_FILTER)).unwrap_or_default()
428    }
429
430    /// [`scoped_query_in`] for the list in `state`.
431    pub(crate) fn list_query(
432        &self,
433        cx: &Cx,
434        state: &crate::TableState,
435    ) -> Result<Query<List<R::Model>>> {
436        self.scoped_query_in(cx, self.list_trashed(state))
437    }
438
439    /// Whether `record` is trashed: never on a resource that does not soft-delete.
440    pub(crate) fn is_trashed(&self, record: &R::Model) -> bool {
441        self.soft_delete
442            .as_ref()
443            .is_some_and(|soft_delete| soft_delete.is_trashed(record))
444    }
445
446    /// AND the resource's tenant filter onto `query`.
447    fn tenant_scope(&self, cx: &Cx, query: Query<List<R::Model>>) -> Result<Query<List<R::Model>>> {
448        if !self.tenancy.is_scoped() {
449            return Ok(query);
450        }
451        if let Some(Err(error)) = self.tenancy.column_field() {
452            return Err(crate::error::declaration(
453                crate::DeclarationError::of::<R>(crate::Site::Tenancy, error).to_string(),
454            ));
455        }
456        let tenant = crate::tenancy::require_tenant(cx)?;
457        Ok(match self.tenancy.filter(tenant) {
458            Some(filter) => query.filter(filter),
459            None => query,
460        })
461    }
462}
463
464/// Whether `R`'s policy, as the context's panel mounted it, allows `ability`; `false` when the
465/// panel does not mount `R`. Does not check sign-in or tenant scope.
466pub fn can<R: Resource>(cx: &Cx, ability: crate::Ability<'_, R::Model>) -> bool {
467    mounted::<R>(cx).is_some_and(|resource| resource.can(cx, ability))
468}
469
470/// Every `Resource` is an [`OptionSource`](crate::extend::OptionSource), answering from its def
471/// as the context's panel mounted it.
472///
473/// [`scoped_query`](crate::extend::OptionSource::scoped_query) forwards to
474/// [`scoped_query`], so an option load inherits the tenant check and filter
475/// exactly as every other loader does. The search expression and default ordering come from the
476/// resource's [`table`](ResourceDef::table), whose searchable columns the option search searches
477/// with the [`record_title`](ResourceDef::record_title) column, and each record's label is its
478/// title.
479impl<R: Resource> crate::schema::OptionSource for R {
480    type Model = R::Model;
481
482    fn scoped_query(cx: &Cx) -> Result<Query<List<R::Model>>> {
483        scoped_query::<R>(cx)
484    }
485
486    fn lists(cx: &Cx) -> bool {
487        can::<R>(cx, crate::Ability::ViewAny)
488    }
489
490    fn requires_tenant(cx: &Cx) -> bool {
491        mounted::<R>(cx).is_some_and(|mounted| mounted.tenancy.is_scoped())
492    }
493
494    fn search_expr(cx: &Cx, term: &str) -> Option<toasty::stmt::Expr<bool>> {
495        mounted::<R>(cx)?.option_search_expr(cx, term)
496    }
497
498    fn order_by(cx: &Cx) -> Option<toasty::stmt::OrderByExpr> {
499        mounted::<R>(cx)?.table_for(cx).first_order_by()
500    }
501
502    /// The record's title, as its detail page shows it.
503    fn label(cx: &Cx, record: &R::Model) -> String {
504        let key = crate::toasty_compat::pk::pk_text(record);
505        match mounted::<R>(cx) {
506            Some(mounted) => mounted.record_title(cx, record, &key),
507            None => key,
508        }
509    }
510
511    fn available(cx: &Cx) -> bool {
512        mounted::<R>(cx).is_some()
513    }
514
515    fn hidden_fields(cx: &Cx) -> Vec<String> {
516        mounted::<R>(cx).map_or_else(Vec::new, |mounted| mounted.field_mask(cx).hidden().to_vec())
517    }
518
519    fn creates() -> bool {
520        true
521    }
522
523    fn create_page(cx: &Cx) -> Option<String> {
524        let mounted = mounted::<R>(cx)?;
525        // The page a save lands on offers the record only to a user who may list the resource.
526        (mounted.pages.create && mounted.can_create(cx) && mounted.can(cx, crate::Ability::ViewAny))
527            .then(|| crate::naming::create_page_url(&mounted.url))
528    }
529}
530
531#[cfg(test)]
532mod tests;