tablo-core 0.2.0

The core toolkit types for Tablo, a server-rendered admin toolkit on Topcoat and Toasty.
Documentation
use http::header::LOCATION;
use tablo_core::{
    Ability, Allow, Committed, Field, Mutation, Resource, ResourceDef, Schema, Table, TextColumn,
    lens,
};
use toasty::Db;
use topcoat::{context::Cx, router::Body};
use uuid::Uuid;

use crate::common::{memory_db, panel_router, post_fields};

#[derive(Debug, toasty::Model, Clone)]
struct Note {
    #[key]
    #[auto]
    id: Uuid,
    title: String,
}

#[derive(Debug, toasty::Model, Clone)]
struct Audit {
    #[key]
    #[auto]
    id: Uuid,
    mutation: String,
    row_key: String,
    title: String,
    rows: i64,
}

fn mutation_name(mutation: Mutation) -> &'static str {
    match mutation {
        Mutation::Create => "create",
        Mutation::Update => "update",
        Mutation::Delete => "delete",
        Mutation::Action(name) => name,
        _ => "other",
    }
}

async fn audit(cx: &Cx, committed: &Committed<Note>) -> topcoat::Result<()> {
    let first = committed.records().first();
    let mut db = tablo_core::db::db(cx);
    toasty::create!(Audit {
        mutation: mutation_name(committed.mutation()).to_string(),
        row_key: first.map(|note| note.id.to_string()).unwrap_or_default(),
        title: first.map(|note| note.title.clone()).unwrap_or_default(),
        rows: committed.records().len() as i64,
    })
    .exec(&mut db)
    .await
    .map_err(|error| -> topcoat::Error { error.into() })?;
    Ok(())
}

struct AuditedResource;

impl Resource for AuditedResource {
    type Model = Note;
    type Form = AuditedForm;

    fn declare() -> ResourceDef<Self> {
        ResourceDef::new()
            .slug("notes")
            .policy(Allow)
            .table(Table::new(TextColumn::new(lens!(Note.title))).paginate(25))
            .form(Schema::new(Field::text(Note::fields().title())))
    }

    async fn after_commit(cx: &Cx, committed: Committed<Note>) -> topcoat::Result<()> {
        audit(cx, &committed).await
    }
}
#[derive(tablo_core::RecordForm)]
#[form(model = Note)]
struct AuditedForm {
    title: String,
}
struct PlainResource;

impl Resource for PlainResource {
    type Model = Note;
    type Form = PlainForm;

    fn declare() -> ResourceDef<Self> {
        ResourceDef::new()
            .slug("plain-notes")
            .policy(|_cx: &Cx, ability: Ability<'_, Note>| {
                matches!(ability, Ability::ViewAny | Ability::Create)
            })
            .table(Table::new(TextColumn::new(lens!(Note.title))))
            .form(Schema::new(Field::text(Note::fields().title())))
    }
}
#[derive(tablo_core::RecordForm)]
#[form(model = Note)]
struct PlainForm {
    title: String,
}
struct FailingWriteResource;

impl Resource for FailingWriteResource {
    type Model = Note;
    type Form = FailingWriteForm;

    fn declare() -> ResourceDef<Self> {
        ResourceDef::new()
            .slug("failing-writes")
            .policy(|_cx: &Cx, ability: Ability<'_, Note>| {
                matches!(ability, Ability::ViewAny | Ability::Create)
            })
            .table(Table::new(TextColumn::new(lens!(Note.title))))
            .form(Schema::new(Field::text(Note::fields().title())))
    }

    async fn create_record(
        _cx: &Cx,
        _form: FailingWriteForm,
        _ex: &mut dyn toasty::Executor,
    ) -> topcoat::Result<Note> {
        Err(std::io::Error::other("the write refused itself").into())
    }

    async fn after_commit(cx: &Cx, committed: Committed<Note>) -> topcoat::Result<()> {
        audit(cx, &committed).await
    }
}
#[derive(tablo_core::RecordForm)]
#[form(model = Note)]
struct FailingWriteForm {
    title: String,
}
struct FailingHookResource;

impl Resource for FailingHookResource {
    type Model = Note;
    type Form = FailingHookForm;

    fn declare() -> ResourceDef<Self> {
        ResourceDef::new()
            .slug("failing-hooks")
            .policy(|_cx: &Cx, ability: Ability<'_, Note>| {
                matches!(ability, Ability::ViewAny | Ability::Create)
            })
            .table(Table::new(TextColumn::new(lens!(Note.title))))
            .form(Schema::new(Field::text(Note::fields().title())))
    }

    async fn after_commit(cx: &Cx, committed: Committed<Note>) -> topcoat::Result<()> {
        audit(cx, &committed).await?;
        Err(std::io::Error::other("the webhook is down").into())
    }
}
#[derive(tablo_core::RecordForm)]
#[form(model = Note)]
struct FailingHookForm {
    title: String,
}
async fn seeded_db() -> Db {
    memory_db(toasty::models!(Note, Audit)).await
}

async fn seed_note(db: &Db, title: &str) -> Note {
    let mut db = db.clone();
    toasty::create!(Note {
        title: title.to_string(),
    })
    .exec(&mut db)
    .await
    .expect("seed note")
}

async fn notes(db: &Db) -> Vec<Note> {
    let mut db = db.clone();
    Note::all().exec(&mut db).await.expect("query notes")
}

async fn audits(db: &Db) -> Vec<Audit> {
    let mut db = db.clone();
    Audit::all().exec(&mut db).await.expect("query audits")
}

#[tokio::test]
async fn a_create_audits_the_row_it_committed_exactly_once() {
    let db = seeded_db().await;
    let router = panel_router::<AuditedResource>(db.clone());

    let response = post_fields(&router, "/admin/notes/create", &[("title", "Alpha")]).await;
    assert_eq!(response.status(), 303, "a valid create redirects");

    let created = notes(&db).await;
    assert_eq!(created.len(), 1);
    assert_eq!(created[0].title, "Alpha");

    let audits = audits(&db).await;
    assert_eq!(audits.len(), 1, "one commit is one hook call");
    assert_eq!(audits[0].mutation, "create");
    assert_eq!(audits[0].row_key, created[0].id.to_string());
    assert_eq!(audits[0].rows, 1);
}

#[tokio::test]
async fn an_edit_and_a_delete_each_audit_the_row_they_named() {
    let db = seeded_db().await;
    let router = panel_router::<AuditedResource>(db.clone());
    let note = seed_note(&db, "Alpha").await;

    let response = post_fields(
        &router,
        &format!("/admin/notes/{}/edit", note.id),
        &[("title", "Beta")],
    )
    .await;
    assert_eq!(response.status(), 303, "a valid edit redirects");
    assert_eq!(notes(&db).await[0].title, "Beta");

    let after_edit = audits(&db).await;
    assert_eq!(after_edit.len(), 1);
    assert_eq!(after_edit[0].mutation, "update");
    assert_eq!(after_edit[0].row_key, note.id.to_string());
    assert_eq!(
        after_edit[0].title, "Beta",
        "an update hands over the committed row, not the loaded snapshot"
    );

    let response = post_fields(
        &router,
        &format!("/admin/notes/{}/delete", note.id),
        &[("confirm", "1")],
    )
    .await;
    assert_eq!(response.status(), 303, "a confirmed delete redirects");
    assert!(notes(&db).await.is_empty());

    let after_delete = audits(&db).await;
    assert_eq!(after_delete.len(), 2, "two writes, two calls");
    assert_eq!(after_delete[1].mutation, "delete");
    assert_eq!(after_delete[1].row_key, note.id.to_string());
}

#[tokio::test]
async fn a_bulk_delete_is_one_call_for_the_whole_batch() {
    let db = seeded_db().await;
    let router = panel_router::<AuditedResource>(db.clone());
    let first = seed_note(&db, "Alpha").await;
    let second = seed_note(&db, "Beta").await;

    let ids = format!("{},{}", first.id, second.id);
    let response = post_fields(
        &router,
        "/admin/notes/bulk-delete",
        &[("ids", &ids), ("confirm", "1")],
    )
    .await;
    assert_eq!(response.status(), 303);
    assert!(notes(&db).await.is_empty());

    let audits = audits(&db).await;
    assert_eq!(
        audits.len(),
        1,
        "one bulk write is one hook call, not one per row"
    );
    assert_eq!(audits[0].mutation, "delete");
    assert_eq!(audits[0].rows, 2, "both rows travel in the one call");
}

#[tokio::test]
async fn a_refused_submit_never_reaches_the_hook() {
    let db = seeded_db().await;
    let router = panel_router::<AuditedResource>(db.clone());

    let response = post_fields(&router, "/admin/notes/create", &[("title", "")]).await;
    assert_eq!(response.status(), 200, "the form re-renders");

    assert!(notes(&db).await.is_empty());
    assert!(
        audits(&db).await.is_empty(),
        "a rollback must not produce the side effect"
    );
}

#[tokio::test]
async fn a_failed_write_never_reaches_the_hook() {
    let db = seeded_db().await;
    let router = panel_router::<FailingWriteResource>(db.clone());

    let response = post_fields(
        &router,
        "/admin/failing-writes/create",
        &[("title", "Alpha")],
    )
    .await;
    assert!(
        response.status().is_server_error(),
        "a record fn that refuses is an error, got {}",
        response.status()
    );

    assert!(notes(&db).await.is_empty());
    assert!(
        audits(&db).await.is_empty(),
        "the transaction rolled back, so nothing committed"
    );
}

#[tokio::test]
async fn a_failing_hook_does_not_undo_the_write() {
    let db = seeded_db().await;
    let router = panel_router::<FailingHookResource>(db.clone());

    let response = post_fields(
        &router,
        "/admin/failing-hooks/create",
        &[("title", "Alpha")],
    )
    .await;
    assert_eq!(
        response.status(),
        303,
        "the write is committed, so the response reports it"
    );

    assert_eq!(
        notes(&db).await.len(),
        1,
        "a failed side effect cannot roll back a committed write"
    );
    assert_eq!(
        audits(&db).await.len(),
        1,
        "the hook ran; only its failure is swallowed"
    );
}

#[tokio::test]
async fn a_resource_without_the_hook_writes_exactly_as_before() {
    let db = seeded_db().await;
    let router = panel_router::<PlainResource>(db.clone());

    let response = post_fields(&router, "/admin/plain-notes/create", &[("title", "Alpha")]).await;
    assert_eq!(response.status(), 303, "the default hook is a no-op");
    let response = router
        .handle(
            http::Request::builder()
                .uri("/admin/plain-notes")
                .body(Body::empty())
                .expect("request builds"),
        )
        .await;
    assert!(response.status().is_success(), "the list still renders");
    assert!(
        response.headers().get(LOCATION).is_none(),
        "a plain GET is not a redirect"
    );
    let html = tablo_test::body_string(response).await;
    assert!(
        html.contains("Alpha"),
        "the list shows the created row: {html}"
    );

    assert_eq!(notes(&db).await.len(), 1);
}