Skip to main content

Resource

Trait Resource 

Source
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§

Source

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.

Source

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§

Source

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.

Source

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.

Source

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.

Source

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.

Source

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.

Source

fn delete_record( cx: &Cx, record: &Self::Model, ex: &mut dyn Executor, ) -> impl Future<Output = Result<()>> + Send
where 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.

Source

fn restore_record( cx: &Cx, record: &Self::Model, ex: &mut dyn Executor, ) -> impl Future<Output = Result<()>> + Send
where 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.

Source

fn force_delete_record( cx: &Cx, record: &Self::Model, ex: &mut dyn Executor, ) -> impl Future<Output = Result<()>> + Send
where 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.

Source

fn bulk_delete_records( cx: &Cx, records: &[Self::Model], ex: &mut dyn Executor, ) -> impl Future<Output = Result<()>> + Send
where 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.

Source

fn before_commit( _cx: &Cx, _change: &Change<Self::Model>, _ex: &mut dyn Executor, ) -> impl Future<Output = Result<()>> + Send
where 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(())
}
Source

fn after_commit( _cx: &Cx, _change: Change<Self::Model>, ) -> impl Future<Output = Result<()>> + Send
where 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".

Implementors§