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 link;
17mod mounted;
18mod relation;
19mod write;
20
21pub use action::Action;
22pub(crate) use action::{ActionEntry, Actions};
23pub(crate) use commit::run_after_commit;
24pub use commit::{Committed, Mutation};
25pub use def::ResourceDef;
26pub use link::PublicLink;
27pub(crate) use mounted::{MountScope, Mounted, Mounts, mounted, require_mounted};
28pub use relation::{ForeignKey, Relation};
29pub use write::{write_create, write_update};
30
31/// Maps one Toasty `Model` to its admin UI.
32///
33/// # Contract
34///
35/// Requires [`Model`](Self::Model) and [`Form`](Self::Form); every other item has a default.
36/// [`declare`](Self::declare) returns what the resource declares, as one [`ResourceDef`] value;
37/// the methods load, display and write records for a request.
38///
39/// The panel builds the def once when it mounts, binding the paths it names to the database
40/// schema, and serves the result to every request. Record fns run inside the handler transaction
41/// and fail without partial writes.
42pub trait Resource: Sized + Send + Sync + 'static {
43 /// The persisted model this resource administers.
44 type Model: toasty::schema::Model
45 + toasty::stmt::IntoExpr<Self::Model>
46 + Send
47 + Sync
48 + Clone
49 + 'static;
50
51 /// The typed value the create and edit forms parse into.
52 ///
53 /// A resource with create and edit pages names its
54 /// [`#[derive(RecordForm)]`](crate::RecordForm) struct; a list-only resource
55 /// names [`NoForm<Self::Model>`](crate::NoForm), and
56 /// [`Panel::resource`](crate::Panel::resource) registers no form route for
57 /// it ([`RecordForm::HAS_FORM`]).
58 type Form: RecordForm<Model = Self::Model>;
59
60 /// What the resource declares: names, navigation, policy, tenancy, table, form, view,
61 /// relations and actions. Defaults to [`ResourceDef::new`], whose policy denies all.
62 ///
63 /// ```rust
64 /// # #[derive(Debug, Clone, toasty::Model)]
65 /// # struct Post {
66 /// # #[key] #[auto] id: uuid::Uuid,
67 /// # title: String,
68 /// # body: String,
69 /// # }
70 /// # #[derive(Debug, Clone, tablo_core::RecordForm)]
71 /// # #[form(model = Post)]
72 /// # struct PostForm { title: String, body: String }
73 /// # struct PostResource;
74 /// # use tablo_core::{ReadOnly, RecordForm, Resource, ResourceDef, Schema, Section};
75 /// impl Resource for PostResource {
76 /// type Model = Post;
77 /// type Form = PostForm;
78 ///
79 /// fn declare() -> ResourceDef<Self> {
80 /// let c = PostForm::controls();
81 /// ResourceDef::new().policy(ReadOnly).form(Schema::new(
82 /// Section::new("Content").schema((c.title, c.body.multiline(6))),
83 /// ))
84 /// }
85 /// }
86 /// ```
87 fn declare() -> ResourceDef<Self> {
88 ResourceDef::new()
89 }
90
91 /// Renders free-form content below the detail page's view and above its relations.
92 fn view_content<'a>(_cx: &'a Cx, _record: &Self::Model) -> Option<topcoat::view::BoxView<'a>> {
93 None
94 }
95
96 /// The record's label in the detail page's title, or `None` when
97 /// the record has no label to show.
98 ///
99 /// The detail page titles itself with this label when a resource returns
100 /// `Some`, and with the def's [`label`](ResourceDef::label) plus the URL's record key when
101 /// it returns `None`, the default. The showcase's `PostResource` returns
102 /// the post's title, so its heading reads the title instead of
103 /// `Blog Post <record key>`.
104 ///
105 /// A label is display text, not a key. Two records can share one (two
106 /// users named Ada), so it cannot replace the primary key that keys the
107 /// table's rows and the action routes.
108 fn record_label(_cx: &Cx, _record: &Self::Model) -> Option<String> {
109 None
110 }
111
112 /// The record's public page, linked from the detail and edit pages.
113 ///
114 /// The default declares none, so no link renders. A resource whose records
115 /// have a public page overrides this with its URL and link text — the
116 /// showcase's posts return their `/blog/{id}` page.
117 fn public_link(_cx: &Cx, _record: &Self::Model) -> Option<PublicLink> {
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 a field of the record form (`UserFormField::Age`) and renders under its
176 /// control, or under an embedded value's first control.
177 fn validate_record(
178 _cx: &Cx,
179 _form: &Self::Form,
180 ) -> FieldErrors<<Self::Form as RecordForm>::Field> {
181 FieldErrors::new()
182 }
183
184 /// Create a record from the parsed form, inside the handler's transaction.
185 ///
186 /// Defaults to the derived write, [`write_create`]; override to check
187 /// something inside the transaction, then delegate. An override on a
188 /// [`Tenancy::column`](crate::Tenancy::column) resource stamps the tenant
189 /// only by delegating to [`write_create`]. `ex` is the open
190 /// transaction: run every statement through it. Return the created row; it
191 /// is what [`Self::after_commit`] receives.
192 fn create_record(
193 cx: &Cx,
194 form: Self::Form,
195 ex: &mut dyn Executor,
196 ) -> impl Future<Output = Result<Self::Model>> + Send {
197 write_create::<Self>(cx, form, ex)
198 }
199
200 /// Update the already-authorized `record` from the posted form, inside the
201 /// handler's transaction.
202 ///
203 /// Defaults to the derived write, [`write_update`]. `record` is the
204 /// snapshot the handler loaded and policy-checked inside the transaction:
205 /// use it, never re-query. Return the row as it now stands.
206 fn update_record(
207 cx: &Cx,
208 record: Self::Model,
209 posted: Posted<Self::Form>,
210 ex: &mut dyn Executor,
211 ) -> impl Future<Output = Result<Self::Model>> + Send {
212 write_update::<Self>(cx, record, posted, ex)
213 }
214
215 /// Delete the already-authorized `record` (#86).
216 ///
217 /// The handler loads `record` through the tenancy-scoped query inside the
218 /// framework transaction and checks the policy on that snapshot, then
219 /// calls this with the same transaction as `ex`. The after-commit hook
220 /// receives the snapshot once the delete commits.
221 ///
222 /// The default deletes the row through [`scoped_query`], filtered to the
223 /// record's primary key. Override to delete another way, such as a soft
224 /// delete.
225 fn delete_record(
226 cx: &Cx,
227 record: &Self::Model,
228 ex: &mut dyn toasty::Executor,
229 ) -> impl std::future::Future<Output = Result<()>> + Send
230 where
231 Self: Sized,
232 {
233 let filter = crate::toasty_compat::pk::pk_filter(record);
234 let query = scoped_query::<Self>(cx).map(|query| query.filter(filter));
235 async move {
236 query?
237 .delete()
238 .exec(ex)
239 .await
240 .map_err(|error| -> topcoat::Error { error.into() })?;
241 Ok(())
242 }
243 }
244
245 /// Bulk-delete the already-authorized `records`: the handler
246 /// fetches through the tenancy-scoped `IN` query inside the framework
247 /// transaction and checks the policy on every row before calling this.
248 /// The default deletes each record through [`Self::delete_record`] in
249 /// order, through the same `ex` — any error rolls the whole batch back,
250 /// so mid-loop failures delete zero rows. An override of `delete_record`,
251 /// such as a soft delete, therefore covers bulk delete too. Override this
252 /// for a single-statement batch.
253 fn bulk_delete_records(
254 cx: &Cx,
255 records: &[Self::Model],
256 ex: &mut dyn toasty::Executor,
257 ) -> impl std::future::Future<Output = Result<()>> + Send
258 where
259 Self: Sized,
260 {
261 async move {
262 for record in records {
263 Self::delete_record(cx, record, &mut *ex).await?;
264 }
265 Ok(())
266 }
267 }
268
269 /// Post-commit work for a mutation this resource committed.
270 ///
271 /// The place for a side effect that must not survive a rollback: an email, a
272 /// webhook, an audit row, cache invalidation. Called once per successful
273 /// write, after `tx.commit()` and before the response — running it inside a
274 /// record fn would leak the effect on rollback, and the write handlers' pool
275 /// discipline forbids a second handle while the transaction is open.
276 ///
277 /// [`Committed`] names the mutation and the rows it wrote: the committed row
278 /// a create or update returned, the rows a delete or bulk delete removed
279 /// (one call with every row). It is never called when nothing committed: a
280 /// validation error, a policy denial, a failed record fn, or a failed commit
281 /// all leave the hook untouched, so a rollback cannot produce the effect. A
282 /// hook that returns `Err` is logged and ignored — the write is committed,
283 /// and retries are the app's to build; a panic surfaces as Topcoat's
284 /// panic-isolated 500.
285 ///
286 /// ```text
287 /// async fn after_commit(cx: &Cx, committed: Committed<Post>) -> Result<()> {
288 /// let mut db = db(cx); // a fresh handle is allowed here
289 /// for post in committed.records() { notify(post).await?; }
290 /// Ok(())
291 /// }
292 /// ```
293 fn after_commit(
294 _cx: &Cx,
295 _committed: Committed<Self::Model>,
296 ) -> impl std::future::Future<Output = Result<()>> + Send
297 where
298 Self: Sized,
299 {
300 async move { Ok(()) }
301 }
302
303 /// The record's values for the detail page, keyed by the name each
304 /// [`view`](ResourceDef::view) field binds.
305 ///
306 /// The detail page reads the form's keys from
307 /// [`RecordForm::hydrate`], and this adds any
308 /// key only the view shows; the form's keys win on a collision. A
309 /// resource with no form ([`NoForm`](crate::NoForm)) supplies every key its
310 /// view shows here.
311 ///
312 /// `cx` carries the database, whose schema an embedded value's keys need
313 /// ([`EmbeddedForm::write_form`](crate::schema::EmbeddedForm::write_form)).
314 /// The default is empty.
315 fn view_values(_cx: &Cx, _record: &Self::Model) -> HashMap<String, String> {
316 HashMap::new()
317 }
318}
319
320/// The tenant-scoped base query.
321///
322/// [`Resource::query`] with the [`tenancy`](ResourceDef::tenancy) filter ANDed onto it, so a
323/// resource that overrides `query` for soft deletes cannot drop the tenant
324/// scope by forgetting to re-state it. Every framework loader and app code
325/// start here.
326///
327/// # Errors
328///
329/// A tenant-scoped resource and no tenant in `cx`: 403, the same answer the
330/// handler gate gives. A declaration error when the context's panel does not mount `R`; a
331/// background job builds its context with [`Panel::context`](crate::Panel::context).
332///
333/// App code that loads rows itself must call this: on a scoped resource
334/// [`Resource::query`] is the *tenant-unscoped* base by design.
335pub fn scoped_query<R: Resource>(cx: &Cx) -> Result<Query<List<R::Model>>> {
336 require_mounted::<R>(cx)?.scoped_query(cx)
337}
338
339/// [`Resource::view_query`] under the same tenant gate and filter as
340/// [`scoped_query`]: the detail page's loader, and the entry point for a page
341/// that owns its own detail view.
342///
343/// # Errors
344///
345/// The same as [`scoped_query`].
346pub fn scoped_view_query<R: Resource>(cx: &Cx) -> Result<Query<List<R::Model>>> {
347 require_mounted::<R>(cx)?.scoped_view_query(cx)
348}
349
350impl<R: Resource> Mounted<R> {
351 /// [`scoped_query`] for this mount.
352 pub(crate) fn scoped_query(&self, cx: &Cx) -> Result<Query<List<R::Model>>> {
353 self.tenant_scope(cx, R::query(cx))
354 }
355
356 /// [`scoped_view_query`] for this mount.
357 pub(crate) fn scoped_view_query(&self, cx: &Cx) -> Result<Query<List<R::Model>>> {
358 self.tenant_scope(cx, R::view_query(cx))
359 }
360
361 /// AND the resource's tenant filter onto `query`.
362 fn tenant_scope(&self, cx: &Cx, query: Query<List<R::Model>>) -> Result<Query<List<R::Model>>> {
363 if !self.tenancy.is_scoped() {
364 return Ok(query);
365 }
366 if let Some(Err(error)) = self.tenancy.column_field() {
367 return Err(crate::error::declaration(
368 crate::DeclarationError::of::<R>(crate::Site::Tenancy, error).to_string(),
369 ));
370 }
371 let tenant = crate::tenancy::require_tenant(cx)?;
372 Ok(match self.tenancy.filter(tenant) {
373 Some(filter) => query.filter(filter),
374 None => query,
375 })
376 }
377}
378
379/// Whether `R`'s policy, as the context's panel mounted it, allows `ability`; `false` when the
380/// panel does not mount `R`. 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 context'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;