Skip to main content

tablo_core/resource/
mod.rs

1//! `Resource` — maps one Toasty [`Model`](toasty::schema::Model) to its admin UI.
2
3use std::collections::HashMap;
4
5use toasty::{
6    Executor,
7    stmt::{List, Query},
8};
9use topcoat::{Result, context::Cx};
10
11use crate::form::{FieldErrors, Posted, RecordForm};
12
13mod action;
14mod commit;
15mod def;
16mod mounted;
17mod relation;
18mod write;
19
20pub use action::Action;
21pub(crate) use action::{ActionEntry, Actions};
22pub(crate) use commit::run_after_commit;
23pub use commit::{Committed, Mutation};
24pub use def::ResourceDef;
25pub(crate) use mounted::{MountScope, Mounted, Mounts, mounted, require_mounted};
26pub(crate) use relation::ScopeFn;
27pub use relation::{ForeignKey, Relation};
28pub use write::{write_create, write_update};
29
30/// Maps one Toasty `Model` to its admin UI.
31///
32/// # Contract
33///
34/// Requires [`Model`](Self::Model) and [`Form`](Self::Form); every other item has a default.
35/// [`declare`](Self::declare) returns what the resource declares, as one [`ResourceDef`] value;
36/// the methods load, display and write records for a request.
37///
38/// The panel builds the def once when it mounts, binding the paths it names to the database
39/// schema, and serves the result to every request. Record fns run inside the handler transaction
40/// and fail without partial writes.
41pub trait Resource: Sized + Send + Sync + 'static {
42    /// The persisted model this resource administers.
43    type Model: toasty::schema::Model
44        + toasty::stmt::IntoExpr<Self::Model>
45        + Send
46        + Sync
47        + Clone
48        + 'static;
49
50    /// The typed value the create and edit forms parse into.
51    ///
52    /// A resource with create and edit pages names its
53    /// [`#[derive(RecordForm)]`](crate::RecordForm) struct; a list-only resource
54    /// names [`NoForm<Self::Model>`](crate::NoForm), and
55    /// [`Panel::resource`](crate::Panel::resource) registers no form route for
56    /// it ([`RecordForm::HAS_FORM`]).
57    type Form: RecordForm<Model = Self::Model>;
58
59    /// What the resource declares: names, navigation, policy, tenancy, table, form, view,
60    /// relations and actions. Defaults to [`ResourceDef::new`], whose policy denies all.
61    ///
62    /// ```rust
63    /// # #[derive(Debug, Clone, toasty::Model)]
64    /// # struct Post {
65    /// #     #[key] #[auto] id: uuid::Uuid,
66    /// #     title: String,
67    /// #     body: String,
68    /// # }
69    /// # #[derive(Debug, Clone, tablo_core::RecordForm)]
70    /// # #[form(model = Post)]
71    /// # struct PostForm { title: String, body: String }
72    /// # struct PostResource;
73    /// # use tablo_core::{ReadOnly, RecordForm, Resource, ResourceDef, Schema, Section};
74    /// impl Resource for PostResource {
75    ///     type Model = Post;
76    ///     type Form = PostForm;
77    ///
78    ///     fn declare() -> ResourceDef<Self> {
79    ///         let c = PostForm::controls();
80    ///         ResourceDef::new().policy(ReadOnly).form(Schema::new(
81    ///             Section::new("Content").schema((c.title, c.body.multiline(6))),
82    ///         ))
83    ///     }
84    /// }
85    /// ```
86    fn declare() -> ResourceDef<Self> {
87        ResourceDef::new()
88    }
89
90    /// Renders free-form content below the detail page's view and above its relations.
91    fn view_content<'a>(_cx: &'a Cx, _record: &Self::Model) -> Option<topcoat::view::BoxView<'a>> {
92        None
93    }
94
95    /// The record's label in the detail page's title, or `None` when
96    /// the record has no label to show.
97    ///
98    /// The detail page titles itself with this label when a resource returns
99    /// `Some`, and with the def's [`label`](ResourceDef::label) plus the URL's record key when
100    /// it returns `None`, the default. The showcase's `PostResource` returns
101    /// the post's title, so its heading reads the title instead of
102    /// `Blog Post <record key>`.
103    ///
104    /// A label is display text, not a key. Two records can share one (two
105    /// users named Ada), so it cannot replace the primary key that keys the
106    /// table's rows and the action routes.
107    fn record_label(_cx: &Cx, _record: &Self::Model) -> Option<String> {
108        None
109    }
110
111    /// A public URL for one record, rendered as a "View public post"-style
112    /// link on the detail and edit pages when `Some`.
113    ///
114    /// The default declares none, so no link renders. A resource whose records
115    /// have a public page overrides this with its URL — the showcase's posts
116    /// return their `/blog/{id}` page.
117    fn public_url(_cx: &Cx, _record: &Self::Model) -> Option<String> {
118        None
119    }
120
121    /// Base query — the seam for a resource's **own** row scoping (ADR-0002):
122    /// soft deletes and row-level visibility. Every loader starts from it.
123    ///
124    /// **Relations are not this method's job.** The list and the export load
125    /// the relations the table's columns declare
126    /// ([`ComputedColumn::include`](crate::ComputedColumn::include)), and the detail page loads
127    /// [`Self::view_query`]. Include a relation here only when a closure no column covers reads
128    /// it on every loader's rows: one the [`policy`](ResourceDef::policy) reads, or one a
129    /// table's `group_by` or row key reads without a column including it.
130    ///
131    /// **Tenancy is not this method's job either.** For a resource whose
132    /// [`tenancy`](ResourceDef::tenancy) is scoped the framework ANDs the tenant filter
133    /// onto whatever this returns, at every loader through [`scoped_query`]. Do
134    /// not re-state it here. Code outside the framework's loaders starts from
135    /// [`scoped_query`].
136    ///
137    /// Returns the raw typed statement query, which composes generically —
138    /// `filter`, `order_by` and `Paginate::new` work on the raw form for any
139    /// `M: Model` — so one panel handler drives every resource's list page.
140    ///
141    /// # Keep unique constraints in step with this scope
142    ///
143    /// The app-side unique pre-check probes through the same tenant-scoped
144    /// query, so a `#[unique]` index *broader* than the scope is invisible: the
145    /// probe misses the colliding row and the user gets a 500 instead of the
146    /// inline "has already been taken". Scope the constraint to match —
147    /// `#[unique(tenant_id, email)]`. Not checkable at declaration time, since
148    /// a query's filters are not introspectable; upstream #117 is the fix.
149    fn query(_cx: &Cx) -> toasty::stmt::Query<List<Self::Model>> {
150        toasty::stmt::Query::<List<Self::Model>>::all()
151    }
152
153    /// The detail page's query: [`Self::query`] plus the relations the page
154    /// reads off the loaded row — in [`view_values`](Self::view_values) or
155    /// [`view_content`](Self::view_content) — so include them here. A
156    /// [`relation`](ResourceDef::relation) table runs its own query and needs none.
157    ///
158    /// ```text
159    /// fn view_query(cx: &Cx) -> Query<List<Post>> {
160    ///     let author: Include<Post, Author> = Post::fields().author().into();
161    ///     Self::query(cx).include(author)
162    /// }
163    /// ```
164    ///
165    /// The default is [`Self::query`] unchanged. The framework ANDs the tenant
166    /// scope onto it, as it does onto [`Self::query`].
167    fn view_query(cx: &Cx) -> toasty::stmt::Query<List<Self::Model>> {
168        Self::query(cx)
169    }
170
171    /// App-level rules on the parsed form. The errors render inline with a
172    /// 200 and nothing is written; a record fn error keeps its own mapping, so
173    /// a range or cross-field rule belongs here.
174    ///
175    /// Each error names the key it renders under: a control's own key, or a
176    /// [`Repeater`](crate::Repeater) group's label. A key the submitted form
177    /// renders nowhere fails the submit as a declaration error instead of
178    /// writing past the rule.
179    fn validate_record(_cx: &Cx, _form: &Self::Form) -> FieldErrors {
180        FieldErrors::new()
181    }
182
183    /// Create a record from the parsed form, inside the handler's transaction.
184    ///
185    /// Defaults to the derived write, [`write_create`]; override to check
186    /// something inside the transaction, then delegate. An override on a
187    /// [`Tenancy::column`](crate::Tenancy::column) resource stamps the tenant
188    /// only by delegating to [`write_create`]. `ex` is the open
189    /// transaction: run every statement through it. Return the created row; it
190    /// is what [`Self::after_commit`] receives.
191    fn create_record(
192        cx: &Cx,
193        form: Self::Form,
194        ex: &mut dyn Executor,
195    ) -> impl Future<Output = Result<Self::Model>> + Send {
196        write_create::<Self>(cx, form, ex)
197    }
198
199    /// Update the already-authorized `record` from the posted form, inside the
200    /// handler's transaction.
201    ///
202    /// Defaults to the derived write, [`write_update`]. `record` is the
203    /// snapshot the handler loaded and policy-checked inside the transaction:
204    /// use it, never re-query. Return the row as it now stands.
205    fn update_record(
206        cx: &Cx,
207        record: Self::Model,
208        posted: Posted<Self::Form>,
209        ex: &mut dyn Executor,
210    ) -> impl Future<Output = Result<Self::Model>> + Send {
211        write_update::<Self>(cx, record, posted, ex)
212    }
213
214    /// Delete the already-authorized `record` (#86).
215    ///
216    /// The handler loads `record` through the tenancy-scoped query inside the
217    /// framework transaction and checks the policy on that snapshot, then
218    /// calls this with the same transaction as `ex`. The after-commit hook
219    /// receives the snapshot once the delete commits.
220    ///
221    /// The default deletes the row through [`scoped_query`], filtered to the
222    /// record's primary key. Override to delete another way, such as a soft
223    /// delete.
224    fn delete_record(
225        cx: &Cx,
226        record: &Self::Model,
227        ex: &mut dyn toasty::Executor,
228    ) -> impl std::future::Future<Output = Result<()>> + Send
229    where
230        Self: Sized,
231    {
232        let filter = crate::toasty_compat::pk::pk_filter(record);
233        let query = scoped_query::<Self>(cx).map(|query| query.filter(filter));
234        async move {
235            query?
236                .delete()
237                .exec(ex)
238                .await
239                .map_err(|error| -> topcoat::Error { error.into() })?;
240            Ok(())
241        }
242    }
243
244    /// Bulk-delete the already-authorized `records`: the handler
245    /// fetches through the tenancy-scoped `IN` query inside the framework
246    /// transaction and checks the policy on every row before calling this.
247    /// The default deletes each record through [`Self::delete_record`] in
248    /// order, through the same `ex` — any error rolls the whole batch back,
249    /// so mid-loop failures delete zero rows. An override of `delete_record`,
250    /// such as a soft delete, therefore covers bulk delete too. Override this
251    /// for a single-statement batch.
252    fn bulk_delete_records(
253        cx: &Cx,
254        records: &[Self::Model],
255        ex: &mut dyn toasty::Executor,
256    ) -> impl std::future::Future<Output = Result<()>> + Send
257    where
258        Self: Sized,
259    {
260        async move {
261            for record in records {
262                Self::delete_record(cx, record, &mut *ex).await?;
263            }
264            Ok(())
265        }
266    }
267
268    /// Post-commit work for a mutation this resource committed.
269    ///
270    /// The place for a side effect that must not survive a rollback: an email, a
271    /// webhook, an audit row, cache invalidation. Called once per successful
272    /// write, after `tx.commit()` and before the response — running it inside a
273    /// record fn would leak the effect on rollback, and the write handlers' pool
274    /// discipline forbids a second handle while the transaction is open.
275    ///
276    /// [`Committed`] names the mutation and the rows it wrote: the committed row
277    /// a create or update returned, the rows a delete or bulk delete removed
278    /// (one call with every row). It is never called when nothing committed: a
279    /// validation error, a policy denial, a failed record fn, or a failed commit
280    /// all leave the hook untouched, so a rollback cannot produce the effect. A
281    /// hook that returns `Err` is logged and ignored — the write is committed,
282    /// and retries are the app's to build; a panic surfaces as Topcoat's
283    /// panic-isolated 500.
284    ///
285    /// ```text
286    /// async fn after_commit(cx: &Cx, committed: Committed<Post>) -> Result<()> {
287    ///     let mut db = db(cx); // a fresh handle is allowed here
288    ///     for post in committed.records() { notify(post).await?; }
289    ///     Ok(())
290    /// }
291    /// ```
292    fn after_commit(
293        _cx: &Cx,
294        _committed: Committed<Self::Model>,
295    ) -> impl std::future::Future<Output = Result<()>> + Send
296    where
297        Self: Sized,
298    {
299        async move { Ok(()) }
300    }
301
302    /// The record's values for the detail page, keyed by the name each
303    /// [`view`](ResourceDef::view) field binds.
304    ///
305    /// The detail page reads the form's keys from
306    /// [`RecordForm::hydrate`], and this adds any
307    /// key only the view shows; the form's keys win on a collision. A
308    /// resource with no form ([`NoForm`](crate::NoForm)) supplies every key its
309    /// view shows here.
310    ///
311    /// `cx` carries the database, whose schema an embedded value's keys need
312    /// ([`EmbeddedForm::write_form`](crate::schema::EmbeddedForm::write_form)).
313    /// The default is empty.
314    fn view_values(_cx: &Cx, _record: &Self::Model) -> HashMap<String, String> {
315        HashMap::new()
316    }
317}
318
319/// The tenant-scoped base query.
320///
321/// [`Resource::query`] with the [`tenancy`](ResourceDef::tenancy) filter ANDed onto it, so a
322/// resource that overrides `query` for soft deletes cannot drop the tenant
323/// scope by forgetting to re-state it. Every framework loader and app code
324/// start here.
325///
326/// # Errors
327///
328/// A tenant-scoped resource and no tenant in `cx`: 403, the same answer the
329/// handler gate gives. A declaration error when the request's panel does not mount `R`; a
330/// context with no panel at all answers from `R`'s own [`declare`](Resource::declare).
331///
332/// App code that loads rows itself must call this: on a scoped resource
333/// [`Resource::query`] is the *tenant-unscoped* base by design.
334pub fn scoped_query<R: Resource>(cx: &Cx) -> Result<Query<List<R::Model>>> {
335    require_mounted::<R>(cx)?.scoped_query(cx)
336}
337
338/// [`Resource::view_query`] under the same tenant gate and filter as
339/// [`scoped_query`]: the detail page's loader, and the entry point for a page
340/// that owns its own detail view.
341///
342/// # Errors
343///
344/// The same as [`scoped_query`].
345pub fn scoped_view_query<R: Resource>(cx: &Cx) -> Result<Query<List<R::Model>>> {
346    require_mounted::<R>(cx)?.scoped_view_query(cx)
347}
348
349impl<R: Resource> Mounted<R> {
350    /// [`scoped_query`] for this mount.
351    pub(crate) fn scoped_query(&self, cx: &Cx) -> Result<Query<List<R::Model>>> {
352        self.tenant_scope(cx, R::query(cx))
353    }
354
355    /// [`scoped_view_query`] for this mount.
356    pub(crate) fn scoped_view_query(&self, cx: &Cx) -> Result<Query<List<R::Model>>> {
357        self.tenant_scope(cx, R::view_query(cx))
358    }
359
360    /// AND the resource's tenant filter onto `query`.
361    fn tenant_scope(&self, cx: &Cx, query: Query<List<R::Model>>) -> Result<Query<List<R::Model>>> {
362        if !self.tenancy.is_scoped() {
363            return Ok(query);
364        }
365        if let Some(Err(error)) = self.tenancy.column_field() {
366            return Err(crate::error::declaration(
367                crate::DeclarationError::of::<R>(crate::Site::Tenancy, error).to_string(),
368            ));
369        }
370        let tenant = crate::tenancy::require_tenant(cx)?;
371        Ok(match self.tenancy.filter(tenant) {
372            Some(filter) => query.filter(filter),
373            None => query,
374        })
375    }
376}
377
378/// Whether `R`'s policy, as the request's panel mounted it, allows `ability`; `false` when the
379/// panel does not mount `R`, and `R`'s own [`declare`](Resource::declare) answers for a context
380/// with no panel at all. Does not check sign-in or tenant scope.
381pub fn can<R: Resource>(cx: &Cx, ability: crate::Ability<'_, R::Model>) -> bool {
382    mounted::<R>(cx).is_some_and(|resource| resource.can(cx, ability))
383}
384
385/// Every `Resource` is an [`OptionSource`](crate::schema::OptionSource), answering from its def
386/// as the request's panel mounted it.
387///
388/// [`scoped_query`](crate::schema::OptionSource::scoped_query) forwards to
389/// [`scoped_query`], so an option load inherits the tenant gate and filter
390/// exactly as every other loader does. The search expression and default ordering come from the
391/// resource's [`table`](ResourceDef::table), which is where "the option search searches the
392/// related resource's searchable columns" lives.
393impl<R: Resource> crate::schema::OptionSource for R {
394    type Model = R::Model;
395
396    fn scoped_query(cx: &Cx) -> Result<Query<List<R::Model>>> {
397        scoped_query::<R>(cx)
398    }
399
400    fn allows(cx: &Cx, ability: crate::Ability<'_, R::Model>) -> bool {
401        can::<R>(cx, ability)
402    }
403
404    fn requires_tenant(cx: &Cx) -> bool {
405        mounted::<R>(cx).is_some_and(|mounted| mounted.tenancy.is_scoped())
406    }
407
408    fn search_expr(cx: &Cx, term: &str) -> Option<toasty::stmt::Expr<bool>> {
409        mounted::<R>(cx)?.table.search_expr(term)
410    }
411
412    fn order_by(cx: &Cx) -> Option<toasty::stmt::OrderByExpr> {
413        mounted::<R>(cx)?.table.order_by(false)
414    }
415
416    fn available(cx: &Cx) -> bool {
417        mounted::<R>(cx).is_some()
418    }
419}
420
421#[cfg(test)]
422mod tests;