pub trait Resource:
Sized
+ Send
+ Sync
+ 'static {
type Model: ModelColumns + IntoExpr<Self::Model> + Send + Sync + Clone + 'static;
type Form: RecordForm<Model = Self::Model>;
// Provided methods
fn declare() -> ResourceDef<Self> { ... }
fn query(_cx: &Cx) -> Query<List<Self::Model>> { ... }
fn validate_record(
_cx: &Cx,
_form: &Self::Form,
) -> FieldErrors<<Self::Form as RecordForm>::Field> { ... }
fn create_record(
cx: &Cx,
form: Self::Form,
ex: &mut dyn Executor,
) -> impl Future<Output = Result<Self::Model, WriteError<<Self::Form as RecordForm>::Field>>> + Send { ... }
fn update_record(
cx: &Cx,
record: Self::Model,
posted: Posted<Self::Form>,
ex: &mut dyn Executor,
) -> impl Future<Output = Result<Self::Model, WriteError<<Self::Form as RecordForm>::Field>>> + Send { ... }
fn delete_record(
cx: &Cx,
record: &Self::Model,
ex: &mut dyn Executor,
) -> impl Future<Output = Result<()>> + Send
where Self: Sized { ... }
fn restore_record(
cx: &Cx,
record: &Self::Model,
ex: &mut dyn Executor,
) -> impl Future<Output = Result<()>> + Send
where Self: Sized { ... }
fn force_delete_record(
cx: &Cx,
record: &Self::Model,
ex: &mut dyn Executor,
) -> impl Future<Output = Result<()>> + Send
where Self: Sized { ... }
fn bulk_delete_records(
cx: &Cx,
records: &[Self::Model],
ex: &mut dyn Executor,
) -> impl Future<Output = Result<()>> + Send
where Self: Sized { ... }
fn before_commit(
_cx: &Cx,
_change: &Change<Self::Model>,
_ex: &mut dyn Executor,
) -> impl Future<Output = Result<()>> + Send
where Self: Sized { ... }
fn after_commit(
_cx: &Cx,
_change: Change<Self::Model>,
) -> impl Future<Output = Result<()>> + Send
where Self: Sized { ... }
}Expand description
Maps one Toasty Model to its admin UI.
§Contract
Requires Model and Form; every other item has a default.
declare returns what the resource declares, as one ResourceDef value;
the methods scope, validate and write records for a request.
The panel builds the def once when it mounts, binding the paths it names to the database schema, and serves the result to every request. Record fns run inside the handler transaction and fail without partial writes.
Required Associated Types§
Sourcetype Model: ModelColumns + IntoExpr<Self::Model> + Send + Sync + Clone + 'static
type Model: ModelColumns + IntoExpr<Self::Model> + Send + Sync + Clone + 'static
The persisted model this resource administers. Its ModelColumns
give the table and detail page the resource declares none of.
Sourcetype Form: RecordForm<Model = Self::Model>
type Form: RecordForm<Model = Self::Model>
The typed value the create and edit forms parse into.
A resource with create and edit pages names its
#[derive(RecordForm)] struct; a list-only resource
names NoForm<Self::Model>, and
Panel::resource registers no form route for
it (RecordForm::HAS_FORM).
Provided Methods§
Sourcefn declare() -> ResourceDef<Self>
fn declare() -> ResourceDef<Self>
What the resource declares: names, navigation, policy, tenancy, table, form, detail page,
relations and actions. Defaults to ResourceDef::new, whose policy denies all.
impl Resource for PostResource {
type Model = Post;
type Form = PostForm;
fn declare() -> ResourceDef<Self> {
let c = PostForm::controls();
ResourceDef::new().policy(ReadOnly).form(Schema::new(
Section::new("Content").schema((c.title, c.body.multiline(6))),
))
}
}The panel builds the def once when it mounts and serves that copy to every request, so
declare takes no user, tenant or query string: a request-dependent decision belongs to
the policy or to a trait method, which receives cx.
Sourcefn query(_cx: &Cx) -> Query<List<Self::Model>>
fn query(_cx: &Cx) -> Query<List<Self::Model>>
Base query — the seam for a resource’s own row scoping (ADR-0002): soft deletes and row-level visibility. Every loader starts from it.
Relations are not this method’s job. The list, the export and the detail page load
the relations their columns declare (Column::includes):
a RelationColumn declares its own. Include a relation here
only when a closure no column covers reads it on every loader’s rows: one the
policy reads, or one a table’s group_by or row key reads without
a column including it.
Tenancy is not this method’s job either. For a resource whose
tenancy is scoped the framework ANDs the tenant filter
onto whatever this returns, at every loader through scoped_query. Do
not re-state it here. Code outside the framework’s loaders starts from
scoped_query.
Returns the raw typed statement query, which composes generically —
filter, order_by and Paginate::new work on the raw form for any
M: Model — so one panel handler drives every resource’s list page.
A list and its export replace any ordering this sets with the table’s own sort: order a
list with Table::default_sort.
§Keep unique constraints in step with this scope
The app-side unique pre-check probes through the same tenant-scoped
query, so a #[unique] index broader than the scope is invisible: the
probe misses the colliding row and the user gets a 500 instead of the
inline “has already been taken”. Scope the constraint to match —
#[unique(tenant_id, email)] — or, when the index must stay broader, probe it in
create_record and update_record and refuse
with WriteError::Refused. Not checkable at declaration time, since a query’s filters
are not introspectable; tablo#117 tracks the fix.
Sourcefn validate_record(
_cx: &Cx,
_form: &Self::Form,
) -> FieldErrors<<Self::Form as RecordForm>::Field>
fn validate_record( _cx: &Cx, _form: &Self::Form, ) -> FieldErrors<<Self::Form as RecordForm>::Field>
App-level rules on the parsed form. The errors render inline with a
200 and nothing is written, so a range or cross-field rule belongs here. A rule that
reads the database belongs in create_record or
update_record, which refuse through the transaction they write
in.
Each error names a field of the record form (UserFormField::Age) and renders under its
control, or under an embedded value’s first control.
Sourcefn create_record(
cx: &Cx,
form: Self::Form,
ex: &mut dyn Executor,
) -> impl Future<Output = Result<Self::Model, WriteError<<Self::Form as RecordForm>::Field>>> + Send
fn create_record( cx: &Cx, form: Self::Form, ex: &mut dyn Executor, ) -> impl Future<Output = Result<Self::Model, WriteError<<Self::Form as RecordForm>::Field>>> + Send
Create a record from the parsed form, inside the handler’s transaction.
Defaults to the derived write, write_create; override to check
something inside the transaction, then delegate. An override on a
Tenancy::column resource stamps the tenant
only by delegating to write_create. ex is the open
transaction: run every statement through it. Return the created row; the
hooks receive it as Change::Create.
A rule that reads the database refuses the submission with WriteError::Refused: the
transaction rolls back and the form renders again with the errors under their fields.
Sourcefn update_record(
cx: &Cx,
record: Self::Model,
posted: Posted<Self::Form>,
ex: &mut dyn Executor,
) -> impl Future<Output = Result<Self::Model, WriteError<<Self::Form as RecordForm>::Field>>> + Send
fn update_record( cx: &Cx, record: Self::Model, posted: Posted<Self::Form>, ex: &mut dyn Executor, ) -> impl Future<Output = Result<Self::Model, WriteError<<Self::Form as RecordForm>::Field>>> + Send
Update the already-authorized record from the posted form, inside the
handler’s transaction.
Defaults to the derived write, write_update. record is the
snapshot the handler loaded and policy-checked inside the transaction:
use it, never re-query. Return the row as it now stands.
Refuses the submission with WriteError::Refused, as
create_record does.
Sourcefn delete_record(
cx: &Cx,
record: &Self::Model,
ex: &mut dyn Executor,
) -> impl Future<Output = Result<()>> + Sendwhere
Self: Sized,
fn delete_record(
cx: &Cx,
record: &Self::Model,
ex: &mut dyn Executor,
) -> impl Future<Output = Result<()>> + Sendwhere
Self: Sized,
Delete the already-authorized record.
The handler loads record through the tenancy-scoped query inside the
framework transaction and checks the policy on that snapshot, then
calls this with the same transaction as ex. The hooks receive the
snapshot as Change::Delete.
The default deletes the row through scoped_query, filtered to the
record’s primary key, or stamps its
soft_delete column with the time when the resource declares
one. Override to delete another way.
Sourcefn restore_record(
cx: &Cx,
record: &Self::Model,
ex: &mut dyn Executor,
) -> impl Future<Output = Result<()>> + Sendwhere
Self: Sized,
fn restore_record(
cx: &Cx,
record: &Self::Model,
ex: &mut dyn Executor,
) -> impl Future<Output = Result<()>> + Sendwhere
Self: Sized,
Restore the already-authorized trashed record of a resource that
soft-deletes, inside the handler’s transaction.
The handler loads record through scoped_query_in with Trashed::Only and checks
Ability::Restore on it. The default empties the soft-delete
column.
Sourcefn force_delete_record(
cx: &Cx,
record: &Self::Model,
ex: &mut dyn Executor,
) -> impl Future<Output = Result<()>> + Sendwhere
Self: Sized,
fn force_delete_record(
cx: &Cx,
record: &Self::Model,
ex: &mut dyn Executor,
) -> impl Future<Output = Result<()>> + Sendwhere
Self: Sized,
Delete the already-authorized trashed record for good, inside the handler’s
transaction.
The handler loads record through scoped_query_in with Trashed::Only and checks
Ability::ForceDelete on it. The default removes the row.
Sourcefn bulk_delete_records(
cx: &Cx,
records: &[Self::Model],
ex: &mut dyn Executor,
) -> impl Future<Output = Result<()>> + Sendwhere
Self: Sized,
fn bulk_delete_records(
cx: &Cx,
records: &[Self::Model],
ex: &mut dyn Executor,
) -> impl Future<Output = Result<()>> + Sendwhere
Self: Sized,
Bulk-delete the already-authorized records: the handler
fetches through the tenancy-scoped IN query inside the framework
transaction, checks View on every row, and passes the rows
Ability::Delete allows, skipping the rest
as a bulk custom action does. The default deletes each record through
Self::delete_record in order, through the same ex — any error rolls the whole
batch back, so mid-loop failures delete zero rows. An override of delete_record,
such as a soft delete, therefore covers bulk delete too. Override this
for a single-statement batch.
Sourcefn before_commit(
_cx: &Cx,
_change: &Change<Self::Model>,
_ex: &mut dyn Executor,
) -> impl Future<Output = Result<()>> + Sendwhere
Self: Sized,
fn before_commit(
_cx: &Cx,
_change: &Change<Self::Model>,
_ex: &mut dyn Executor,
) -> impl Future<Output = Result<()>> + Sendwhere
Self: Sized,
Work inside the write’s transaction, after the record fn or action wrote: an audit row, a counter, a row in another table that must commit with the write or not at all.
Called once per write, with the Change it made, through ex, the open transaction:
run every statement through it, and notify through
Notifications::send_in, which stores the
notification only if the write commits. A second connection, through
db or Notifications::send,
would write outside the transaction, and on a pool of one wait forever for the connection
the transaction holds; either refuses instead, and the write rolls back.
Every write this resource’s panel routes make comes here: a create, once per row of a CSV
import, an update, a delete or bulk delete, a restore or force delete, a custom or header
action, and a many-to-many attach or detach. The profile and tenant-settings pages save
their user and tenant rows themselves and do not. A validation error, a policy denial or
a failed or refused record fn returns before it. An Err rolls the whole write back and
fails the request with the write’s failure toast.
async fn before_commit(
_cx: &Cx,
change: &Change<Post>,
ex: &mut dyn toasty::Executor,
) -> Result<()> {
let kind = match change {
Change::Create(_) => "create",
Change::Update { .. } => "update",
Change::Delete(_) => "delete",
Change::Restore(_) => "restore",
Change::ForceDelete(_) => "force delete",
Change::Action { name, .. } | Change::HeaderAction { name } => name,
Change::Attach { .. } => "attach",
Change::Detach { .. } => "detach",
};
for post in change.records() {
toasty::create!(Audit {
post_id: post.id,
change: kind.to_string()
})
.exec(&mut *ex)
.await?;
}
Ok(())
}Sourcefn after_commit(
_cx: &Cx,
_change: Change<Self::Model>,
) -> impl Future<Output = Result<()>> + Sendwhere
Self: Sized,
fn after_commit(
_cx: &Cx,
_change: Change<Self::Model>,
) -> impl Future<Output = Result<()>> + Sendwhere
Self: Sized,
Work after the write commits, outside the database: an email, a webhook, cache invalidation.
Called once per committed write, after tx.commit() and before the response, with the
Change Self::before_commit received. It is never called when nothing committed, so
a rollback cannot produce the effect. Nothing guarantees it runs either: an error it
returns is logged and ignored, since the write is committed, and a crash between the
commit and the hook skips it. A row that must exist whenever the write does belongs in
Self::before_commit; retries are the app’s to build. A panic surfaces as Topcoat’s
panic-isolated 500.
async fn after_commit(cx: &Cx, change: Change<Post>) -> Result<()> {
if let Change::Create(post) = &change {
notify(cx, post).await?;
}
Ok(())
}Dyn Compatibility§
This trait is not dyn compatible.
In older versions of Rust, dyn compatibility was called "object safety".