Toasty 0.11 from zero one struct per table, builder writes, and preloading, contrasted with SeaORM
Describe each table once as a Rust struct, write only the fields you name, and let every .await show you a database round trip.
The problem
A Rust service stores authors and the notes they write. The first version talks to the database with hand-written SQL strings, and the trouble starts as soon as the code changes. Someone renames a struct field from title to heading. The code still compiles, because the SQL lives in a string the compiler never reads. The error shows up at runtime, on the one code path that touches that column.
The next problem is the update that rewrites every column. Two requests load the same note. One pins it, the other edits its title. Each writes back the whole row, so whichever finishes last silently undoes the other's change.
The third problem is the page that lists authors with their note titles. The code loads the authors, then loops and loads each author's notes. One page view has quietly become 1 + N queries, and nothing in the code shows where the database is being hit.
What you want is three things. First, the table described once, in Rust, so the struct and the schema cannot drift apart. Second, writes that send only the fields you changed, with a clear outcome when the row has vanished. Third, database round trips you can spot by reading the code. Toasty, the async ORM from the Tokio project, is designed around the first and third, and gets most of the way to the second; the notes app below shows where it stops.
Everything below was run on rustc 1.99.0 (edition 2024) with toasty 0.11.0, tokio 1.54.0 and tracing-subscriber 0.3.23. SeaORM comparisons come from sea-orm 2.0.4. Toasty is pre-1.0, and its README asks users who run into "missing features or rough edges" to open an issue. The code below runs into two: an unimplemented branch in the update path and a helper that the guide documents but the crate does not ship.
The idea, in plain words
In Toasty you write one ordinary Rust struct per table and derive toasty::Model on it. A derive macro is a Rust macro, attached with #[derive(...)], that generates extra code from your struct definition at compile time. Here it generates query, create, update and delete builders from the struct's attributes. The same struct is what you read from the database and what you hold after a write. Every call that touches the database is async and ends in .await, usually after .exec(&mut db). Reading a relation that is already in memory is a plain synchronous call, so the .await keyword marks every round trip.
SeaORM, the other async Rust ORM in this comparison, is organised differently. It describes a table with an entity: a struct named Model plus generated Entity and Column types, and an ActiveModel write-twin whose fields are each marked Set, Unchanged or NotSet. Toasty keeps a single struct and expresses "which fields to write" by which fields you name in the builder.
Concept A: the struct is the model
An ORM (object-relational mapper) is a library that maps program types to database tables, so you read and write rows as ordinary values instead of SQL strings. In Toasty, a model is a Rust struct annotated with #[derive(toasty::Model)]. Each struct maps to a database table and each field maps to a column.
Without a single description you keep two copies of the truth, the struct and the CREATE TABLE, and they drift. With one struct, the schema, the query methods and the write builders all come from the same source.
Here is the whole project, starting with Cargo.toml:
[package]
name = "notes_toasty"
version = "0.1.0"
edition = "2024"
[dependencies]
toasty = { version = "=0.11.0", features = ["sqlite"] }
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
The =0.11.0 requirement pins the exact release, which matters for a pre-1.0 crate whose minor versions are allowed to break the API. The sqlite feature compiles in the SQLite driver; without it the sqlite: connection URL has no driver to select. Tokio supplies the async runtime and the #[tokio::test] attribute the tests use. tracing-subscriber is only a dev-dependency, because it exists to print the SQL Toasty runs while the tests execute.
The models and a helper live in src/lib.rs:
#[derive(Debug, toasty::Model)]
pub struct Author {
#[key]
#[auto]
pub id: u64,
pub name: String,
#[unique]
pub email: String,
#[has_many]
pub notes: toasty::Deferred<Vec<Note>>,
}
#[derive(Debug, toasty::Model)]
pub struct Note {
#[key]
#[auto]
pub id: u64,
pub title: String,
pub body: Option<String>,
pub pinned: bool,
#[index]
pub author_id: u64,
#[belongs_to(key = author_id, references = id)]
pub author: toasty::Deferred<Author>,
}
/// Open a fresh in-memory database with both tables created.
pub async fn open() -> toasty::Result<toasty::Db> {
let db = toasty::Db::builder()
.models(toasty::models!(Author))
.connect("sqlite::memory:")
.await?;
db.push_schema().await?;
Ok(db)
}
The attributes are small. #[key] marks the primary key, the column whose value identifies one row. #[auto] tells Toasty to generate the value on insert, so you never pass an id yourself. #[unique] creates a unique index, a database structure that speeds up lookups on that column and rejects duplicate values, and it also generates a get_by_* method for it. #[index] creates a plain, non-unique index. Required fields such as String and u64 map to NOT NULL columns, and wrapping a field in Option<T> makes it nullable, meaning the column may hold NULL (no value). The Deferred<...> fields are relations, which Concept C covers.
open() does the setup. Db::builder() registers your models and then connects. You pass only Author to toasty::models!, because registering a model also registers any models reachable through its fields. Author reaches Note through notes, so both get registered. The URL sqlite::memory: selects the SQLite driver, which the sqlite Cargo feature enables, and opens an in-memory database.
push_schema() creates all tables and indexes based on your registered models, by issuing CREATE TABLE and CREATE INDEX statements directly. It does not track what has changed between runs, so it suits prototypes and tests. For a database that already holds data, the guide says to use migrations instead: generated SQL files that apply only the schema changes since the last snapshot.
An in-memory database disappears when the connection closes. To inspect the schema, one extra test (schema_on_disk) pushes the same models to a file database, which the sqlite3 shell then dumps with sqlite3 target/tmp/schema.db .schema:
CREATE TABLE "authors" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL
);
CREATE TABLE sqlite_sequence(name,seq);
CREATE UNIQUE INDEX "index_authors_by_email" ON "authors" ("email");
CREATE TABLE "notes" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"body" TEXT,
"pinned" BOOLEAN NOT NULL,
"author_id" INTEGER NOT NULL
);
CREATE INDEX "index_notes_by_author_id" ON "notes" ("author_id");
Read it against the structs. The table names are authors and notes, because Toasty auto-pluralizes the struct name (#[table = "people"] on the struct picks a different name). The u64 key became INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, and each String became TEXT NOT NULL. The Option<String> body became a bare TEXT, so it allows NULL. The bool became BOOLEAN, which SQLite stores as 0 or 1. The sqlite_sequence table is SQLite's own bookkeeping for AUTOINCREMENT.
The two indexes were created by the same push_schema call: a UNIQUE INDEX for email and a plain INDEX for author_id. The notes table has no FOREIGN KEY clause at all, a point that matters in Concept C.
The attributes also generate access paths. #[unique] on email produced Author::get_by_email, and the #[key] produced get_by_id and filter_by_id. The two prefixes behave differently. get_by_* runs immediately and returns the record, or an error if none exists. filter_by_* returns a query builder that runs nothing until you call a terminal method: .exec() for a Vec, .first() for an Option, or .get() for exactly one record. Here is the first test, which runs these paths:
let by_email = Author::get_by_email(&mut db, "[email protected]").await?;
let missing = Author::get_by_id(&mut db, &99).await;
let maybe = Author::filter_by_id(99).first().exec(&mut db).await?;
The first line is an immediate lookup that finds Ada. The second asks for an id that does not exist and keeps the Result so the test can inspect it. The third asks for the same id through a query builder, and .first() turns "nothing found" into None. Real output, with the SQL lines condensed as explained in the tracing section:
SQL executed rows=1 | SELECT tbl_0_0."id" AS column1, tbl_0_0."name" AS column2, tbl_0_0."email" AS column3 FROM "authors" AS tbl_0_0 WHERE tbl_0_0."email" = ?1 LIMIT 1;
BY_EMAIL: id=1
SQL executed rows=0 | SELECT tbl_0_0."id" AS column1, tbl_0_0."name" AS column2, tbl_0_0."email" AS column3 FROM "authors" AS tbl_0_0 WHERE tbl_0_0."id" = ?1 LIMIT 1;
GET_MISSING: is_record_not_found=true msg=record not found: Author
SQL executed rows=0 | SELECT tbl_0_0."id" AS column1, tbl_0_0."name" AS column2, tbl_0_0."email" AS column3 FROM "authors" AS tbl_0_0 WHERE tbl_0_0."id" = ?1 LIMIT 1;
FIRST_MISSING: None
Both missing-id calls run the identical SELECT and get zero rows (tbl_0_0 is the alias Toasty gives the table in the SQL it generates). The only difference is what Toasty does with the empty result.
The generated paths follow the indexes. The guide says Toasty generates filter_by_* methods for indexed and key fields, and the README frames this as generating query methods "only for access patterns that are indexed". pinned has no attribute, so asking for Note::filter_by_pinned(true) stops the build:
error[E0599]: no associated function or constant named `filter_by_pinned` found for struct `Note` in the current scope
--> tests/zz_filter_by_unindexed.rs:7:19
|
7 | let _ = Note::filter_by_pinned(true).exec(&mut db).await?;
| ^^^^^^^^^^^^^^^^ associated function or constant not found in `Note`
An unindexed column is still reachable through the general filter method with a field expression, as Concept C shows. The missing method is a hint that such a query scans the table, and adding #[index] to the field would generate filter_by_pinned together with the index.
Diagnostic: a missing id is an error
- Symptom:
Author::get_by_id(&mut db, &99)returnsErrwith the messagerecord not found: Author, andis_record_not_found()is true. A handler that expected "maybe nothing" now fails. - Cause:
get_by_*means "this must exist". It returns the record directly, or an error if no record exists. - Fix: when absence is normal, use
filter_by_id(99).first().exec(&mut db).await?, which returnedNonein the lab. Reserveget_by_*for ids you expect to exist, and mapis_record_not_found()to an HTTP 404.
A related symptom is a duplicate email. Creating a second author with [email protected] fails with UNIQUE constraint failed: authors.email: Error code 2067: constraint failed, and is_driver_operation_failed() is true. The SQLite driver does not sort errors into Toasty's typed variants, so every failure from rusqlite, the SQLite library the driver is built on, surfaces as DriverOperationFailed.
Compared with SeaORM. SeaORM 2.0 also starts from a struct named Model, but #[derive(DeriveEntityModel)] turns it into an entity with separate generated Entity, Column and PrimaryKey types, and queries name columns through that enum: note::Entity::find().filter(note::Column::Pinned.eq(true)).all(&db). Its Schema::create_table_from_entity printed a CREATE TABLE "note" with the same shape as Toasty's ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, a nullable "body" varchar), but the SeaORM docs say it has created no indexes since 0.7.0, whereas push_schema creates tables and indexes together. For a missing row, SeaORM's find_by_id(id).one(&db) returned None, which matches Toasty's filter_by_id(..).first() and not its get_by_id.
Concept B: writes are builders that name their fields
A builder is an object that collects settings step by step and does nothing until you call a terminal method such as .exec(). Toasty's writes are all builders. The reason is the clobbering problem from the start of this article. If a write is "set these named fields" rather than "save this whole struct", it cannot overwrite a column you never mentioned.
toasty::create! does not execute anything. It returns a create builder with the values already applied, and .exec(&mut db) inserts the row. The value you get back is a complete Author or Note with every field set, including the generated id:
let ada = toasty::create!(Author { name: "Ada", email: "[email protected]" })
.exec(&mut db)
.await?;
let note = toasty::create!(in ada.notes() { title: "buy milk", pinned: false })
.exec(&mut db)
.await?;
Because ada comes back with its generated id, the next line can create a note through it. The in ada.notes() form creates a note through the relation, so Toasty fills in author_id for you and the macro never mentions it. Required fields such as title and pinned must be set before .exec(), while optional ones such as body default to NULL when left out. The SQL for the second call:
SQL executed rows=1 | INSERT INTO "notes" ("id", "title", "body", "pinned", "author_id") VALUES (NULL, ?1, NULL, ?2, ?3) RETURNING "id" AS column1;
NOTE: 1 "buy milk" body=None pinned=false author_id=1
Several details are visible here. The id is NULL because it is #[auto], so SQLite assigns it. The body is NULL because we did not set it. ?1, ?2 and ?3 are bound parameters (placeholders): the statement text stays fixed while the values travel separately, so a title containing a quote cannot change the SQL. RETURNING "id" asks the database to hand back the id it just generated, which is how note.id is filled in without a second query.
To change an existing row, use toasty::update!, which takes a target (a loaded instance or a query) and a list of field assignments:
let mut note = toasty::create!(in ada.notes() { title: "buy milk", pinned: false })
.exec(&mut db)
.await?;
toasty::update!(note { pinned: true }).exec(&mut db).await?;
println!("AFTER_UPDATE: title={:?} pinned={}", note.title, note.pinned);
The target here is the loaded note, and the brace block lists the only assignment, pinned: true. Like create!, the macro only builds; .exec(&mut db) sends the statement. After it returns, the note value in memory holds the new pinned value, and fields you did not name keep their current values. The print line reads both fields back from the struct, not from the database:
SQL executed rows=1 | UPDATE "notes" AS tbl_0_0 SET "pinned" = ?1 WHERE "id" = ?2;
AFTER_UPDATE: title="buy milk" pinned=true
The statement sets pinned and nothing else. The title is absent from the SQL because the macro named only pinned, so a concurrent title edit survives. The binding must be let mut note, because an instance update builds the same statement as note.update(), which takes &mut self. Without mut, the compiler stops you (this snippet lives in a scratch test file that updates ada):
error[E0596]: cannot borrow `ada` as mutable, as it is not declared as mutable
--> tests/zz_immutable_update.rs:8:21
|
8 | toasty::update!(ada { name: "Ada Lovelace" }).exec(&mut db).await?;
| ^^^ cannot borrow as mutable
|
help: consider changing this to be mutable
|
7 | let mut ada = toasty::create!(Author { name: "Ada", email: "[email protected]" }).exec(&mut db).await?;
| +++
The error points at the macro argument rather than at .exec, because the mutable borrow happens when the macro builds the update from the instance. The help line already contains the fix.
The second target type is a query. Note::update_by_id(id) is shorthand for Note::filter_by_id(id).update(), and Toasty generates update_by_* for each field marked #[key], #[unique] or #[index]. The query runs inside the UPDATE statement, and no records are loaded first. That is efficient, and it has a consequence you can see in the output:
Note::update_by_id(note.id).title("buy oat milk").exec(&mut db).await?;
println!("IN_MEMORY_STALE: title={:?}", note.title);
let fresh = Note::get_by_id(&mut db, ¬e.id).await?;
println!("RELOADED: title={:?} pinned={}", fresh.title, fresh.pinned);
SQL executed rows=1 | UPDATE "notes" AS tbl_0_0 SET "title" = ?1 WHERE "id" = ?2;
IN_MEMORY_STALE: title="buy milk"
SQL executed rows=1 | SELECT tbl_0_0."id" AS column1, tbl_0_0."title" AS column2, tbl_0_0."body" AS column3, tbl_0_0."pinned" AS column4, tbl_0_0."author_id" AS column5 FROM "notes" AS tbl_0_0 WHERE tbl_0_0."id" = ?1 LIMIT 1;
RELOADED: title="buy oat milk" pinned=true
The database now holds buy oat milk, but the note variable in memory still says buy milk, because the write never touched it. Reloading shows the truth, including the earlier pinned = true. Use an instance update when you keep working with the struct afterwards, and a query update when you only need the row changed.
To set an optional field back to NULL, pass None with the field's type annotation: toasty::update!(note { body: Option::<String>::None }). The lab produced SET "body" = NULL WHERE "id" = ?1 and then BODY_CLEARED: None, so the in-memory instance followed. Leaving body out of a create! has the same effect at insert time, as the NULL in the INSERT above showed.
Diagnostic: updating a row that no longer exists
- Symptom 1:
Note::update_by_id(99).title("ghost").exec(&mut db).awaitreturnedOk, printed asGHOST_UPDATE: Ok("ok"), while the trace saysrows=0. Nothing tells the caller that nothing was written. - Symptom 2: an instance update after another request deleted the row panicked in toasty 0.11.0 with
not yet implemented, attoasty-core-0.11.0/src/stmt/sparse_record.rs:67:18. The statement just before the panic matched nothing:SQL executed rows=0 | UPDATE "notes" AS tbl_0_0 SET "pinned" = ?1 WHERE "id" = ?2;. - Cause: without a version field, the generated
UPDATEfilters on the primary key alone, so it does not check that the row still exists or still holds what you loaded. Zero matched rows is not an error for the query form. For the instance form, line 67 of that file is the_ => todo!()arm ofValue::into_sparse_record, which panics when it is handed anything other than a sparse record; after a zero-row update, that is what happens. - Fix: for query updates, check the result yourself (load the row first, or follow with
filter_by_id(..).first()when the outcome matters). For instance updates of rows that other requests can delete, add a#[version]field.
The #[version] attribute turns on optimistic concurrency control: instead of locking a row, each write checks that nobody changed it since you read it, and fails if somebody did. You add #[version] to a u64 field and never set it yourself. Toasty sets it to 1 on create, instance updates condition on the current version and increment it, and a stale or deleted target becomes a typed error. The lab used a separate model:
#[derive(Debug, toasty::Model)]
struct Doc {
#[key]
#[auto]
id: u64,
content: String,
#[version]
version: u64,
}
Nothing else about the struct is special. The derive recognizes the attribute, writes the column at create time (the lab's INSERT listed "content" and "version"), and takes over every later change to it. The test loads the same document twice (doc and stale), updates through doc, then tries to update through the now-outdated stale. Finally it loads a third copy, deletes the row by id, and updates that copy:
SQL executed rows=1 | SELECT tbl_0_0."version" = ?2 AS column1 FROM "docs" AS tbl_0_0 WHERE tbl_0_0."id" = ?1;
SQL executed rows=1 | UPDATE "docs" AS tbl_0_0 SET "content" = ?1, "version" = ?2 WHERE "id" = ?3;
VERSIONS: doc=2 stale=1
SQL executed rows=1 | SELECT tbl_0_0."version" = ?2 AS column1 FROM "docs" AS tbl_0_0 WHERE tbl_0_0."id" = ?1;
STALE_WRITE: is_condition_failed=true msg=condition failed: write condition did not match
SQL executed rows=1 | DELETE FROM "docs" AS tbl_0_0 WHERE tbl_0_0."id" = ?1;
SQL executed rows=0 | SELECT tbl_0_0."version" = ?2 AS column1 FROM "docs" AS tbl_0_0 WHERE tbl_0_0."id" = ?1;
DELETED_WRITE: is_record_not_found=true msg=record not found: conditional write matched no rows
Follow the first write. Toasty runs a SELECT comparing the stored version with the one the instance holds, then an UPDATE that sets content and the new version together. The guide states that SQLite runs the check and the write inside a transaction, so another writer cannot slip in between them (PostgreSQL folds both into one statement). After it, doc is at version 2 while stale still says 1. The stale writer's check comes back false and no UPDATE is sent, giving the typed error condition failed (is_condition_failed() true). After the delete, the check finds no row, and the error becomes record not found (is_record_not_found() true). Recover by reloading the record and retrying. Query-based updates increment the version on every matched row but never fail on a mismatch, because they do not condition on a prior version.
Compared with SeaORM. SeaORM writes go through an ActiveModel, which holds all the attributes of Model wrapped in ActiveValue. Set means "explicitly set by the application and sent to the database". Unchanged means "an existing, unchanged Value from the database". NotSet means nothing is sent. An update with id as Unchanged(1) and pinned as Set(true) rendered as UPDATE "note" SET "pinned" = TRUE WHERE "note"."id" = 1. So SeaORM chooses the written fields by wrapping each field of a second struct, while Toasty chooses them by naming them in the macro. For a missing row, SeaORM's update of an ActiveModel with id 99 returned Err(RecordNotUpdated). Toasty's plain update returned Ok or panicked, and only the #[version] model gave typed errors. The costs land in different places: SeaORM's three-state wrapper adds a type per table, while Toasty's versioned write adds a SELECT per update on SQLite, as the trace shows.
Concept C: relations, and "if you .await it, it hits the database"
A foreign key is a column in one table that stores the primary key of a row in another table. A relation is the Rust-side view of that link. In the lab, notes.author_id is the foreign key. The model whose table contains it declares #[belongs_to(key = author_id, references = id)], where key is the foreign key field on the child and references is the field on the parent it points at. The other side declares #[has_many], which adds no columns to the parent's table.
Both sides use Deferred<_>. Its documentation describes it as a field whose value is not loaded by default: after a normal load the value is unloaded, and accessing it with get panics. You load it by calling .include() on the query. A relation field written without Deferred, such as notes: Vec<Note>, is eager instead: the guide says every query that returns the model loads it, as if .include() were always present, and Toasty rejects a pair of models that would load each other eagerly forever. Toasty states one rule for associations: if you .await it, it hits the database. author.notes().exec(&mut db).await is a query. author.notes.get() is not async and only reads data already in memory. You can scan any code path for .await to see exactly where round trips happen.
The cost of ignoring that is the N+1 problem. Lazy loading means fetching a relation on demand, one record at a time. Applied to a list, it costs one query for the list plus one per record: with 100 users, 101 queries. Preloading (also called eager loading) fetches the relations up front with a fixed number of queries. Two authors are enough to show it:
// Lazy: one query for authors, then one per author.
let authors = Author::all().exec(&mut db).await?;
for a in &authors {
let notes = a.notes().exec(&mut db).await?;
println!("LAZY: {} has {} notes", a.name, notes.len());
}
println!("IS_UNLOADED: {}", authors[0].notes.is_unloaded());
// Preloaded: .include() then a plain, non-async .get().
let authors = Author::all().include(Author::fields().notes()).exec(&mut db).await?;
for a in &authors {
let titles: Vec<&str> = a.notes.get().iter().map(|n| n.title.as_str()).collect();
println!("PRELOADED: {} -> {:?}", a.name, titles);
}
The first half is the pattern the problem statement warned about. Author::all() loads the authors, and inside the loop a.notes() returns a query scoped to that author, which runs on each .await. The is_unloaded() call asks the Deferred field whether anything was loaded into it by the plain all(). The second half adds .include(Author::fields().notes()) to the same query and then reads a.notes.get(), which has no .await because it only reads memory:
SQL executed rows=2 | SELECT tbl_0_0."id" AS column1, tbl_0_0."name" AS column2, tbl_0_0."email" AS column3 FROM "authors" AS tbl_0_0 WHERE ?1;
SQL executed rows=2 | SELECT tbl_0_0."id" AS column1, tbl_0_0."title" AS column2, tbl_0_0."body" AS column3, tbl_0_0."pinned" AS column4, tbl_0_0."author_id" AS column5 FROM "notes" AS tbl_0_0 WHERE tbl_0_0."author_id" = ?1;
LAZY: Ada has 2 notes
SQL executed rows=1 | SELECT tbl_0_0."id" AS column1, tbl_0_0."title" AS column2, tbl_0_0."body" AS column3, tbl_0_0."pinned" AS column4, tbl_0_0."author_id" AS column5 FROM "notes" AS tbl_0_0 WHERE tbl_0_0."author_id" = ?1;
LAZY: Grace has 1 notes
IS_UNLOADED: true
SQL executed rows=2 | SELECT tbl_0_0."id" AS column1, tbl_0_0."name" AS column2, tbl_0_0."email" AS column3 FROM "authors" AS tbl_0_0 WHERE ?1;
SQL executed rows=3 | SELECT tbl_0_0."id" AS column1, tbl_0_0."title" AS column2, tbl_0_0."body" AS column3, tbl_0_0."pinned" AS column4, tbl_0_0."author_id" AS column5 FROM "notes" AS tbl_0_0 WHERE EXISTS (SELECT ?3 AS column1 FROM (VALUES (?1), (?2)) AS tbl_1_0 WHERE tbl_1_0.column1 = tbl_0_0."author_id");
PRELOADED: Ada -> ["buy milk", "ship release"]
PRELOADED: Grace -> ["fix compiler"]
Count the statements. The lazy loop ran one query for the authors and one per author, three queries for two authors. For 50 authors it would be 51. IS_UNLOADED: true confirms that a normal load leaves the relation empty. With .include(Author::fields().notes()) the lab ran exactly two queries, however many authors exist. The second query fetches the notes for all authors at once: the VALUES (?1), (?2) list holds the author ids, and WHERE EXISTS (...) keeps every note whose author_id appears in it. Toasty then attaches the right notes to each author, which is why a.notes.get() works without .await. The all() query renders with a bare WHERE ?1 placeholder and still returned both authors, so that placeholder carries no real condition.
Two more features appeared in the same test. Filtering uses field expressions, so Note::filter(Note::fields().pinned().eq(true)) produced WHERE tbl_0_0."pinned" and PINNED: ["ship release"]. Nested creation passes a list for a has_many field: create!(Author { ..., notes: [{...}, {...}] }) creates the author first, then inserts both notes with the author's id set as the foreign key, in one multi-row INSERT (rows=2).
Diagnostic: calling get on an unloaded relation
- Symptom:
thread 'get_on_unloaded_relation_panics' panicked at tests/notes.rs:122:23: deferred field not loaded. - Cause: the test loaded an author with
get_by_emailand then calledada.notes.get()without ever including the relation.Deferred::getis#[track_caller]and callsexpect("deferred field not loaded")on a value that was never filled. - Fix: add
.include(...)to the query that loads the parent, or use the async accessorada.notes().exec(&mut db).await. When a function cannot know how its argument was loaded, checknotes.is_unloaded()first.
The 0.11.0 guide also shows a non-panicking try_get() helper for this case, but the 0.11.0 crate has no such method:
error[E0599]: no method named `try_get` found for struct `Deferred<T>` in the current scope
--> tests/zz_try_get.rs:9:23
|
9 | let _ = ada.notes.try_get();
| ^^^^^^^ method not found in `Deferred<Vec<Note>>`
Use is_unloaded() together with get() instead.
The last relation behavior is deleting a parent. Because author_id is a required u64, deleting the author deletes its notes. With an Option<u64> foreign key, the child would stay and the key would be set to NULL. This is a cascade delete: removing a row also removes or detaches the rows that point at it. The lab shows the order:
BEFORE_PARENT_DELETE: notes=1
SQL executed rows=1 | DELETE FROM "notes" AS tbl_0_0 WHERE tbl_0_0."author_id" = ?1;
SQL executed rows=1 | DELETE FROM "authors" AS tbl_0_0 WHERE tbl_0_0."id" = ?1;
AFTER_PARENT_DELETE: notes=0
Toasty deleted the children first, then the author. It does this in its own query engine, not through database-level foreign key constraints, which is why the schema dump had no FOREIGN KEY clause. A different program writing to the same SQLite file with raw SQL gets none of this protection, and could delete an author and leave orphaned notes.
Compared with SeaORM. A SeaORM entity is several types, and it even needs an ActiveModelBehavior impl (the home for hooks such as before_save) when that impl is empty. A Toasty model is one type: the same Note struct is read, returned by create! and updated in place by update!. SeaORM's relation APIs are outside this article.
Putting it together
Follow one author through the test file. open() builds an in-memory database and pushes the schema, so every test starts with empty tables. A nested create! inserts Ada and her two notes. A page that lists authors with note titles uses .include(...), so it runs two queries however many authors there are. Pinning a note is update!(note { pinned: true }), which sends one column. A concurrent edit is caught only when the model carries #[version], and deleting the author removes her notes. Renaming a field renames the generated setters and field accessors, so code that still uses the old name stops compiling. Clobbered updates are avoided because writes name their fields, and #[version] turns races and vanished rows into errors you can handle. The silent 1 + N is visible as a loop with .await inside it.
Run the suite with cargo test --test notes -- --test-threads=1. The last line is the one to read:
running 8 tests
test create_and_read_back ... ok
test delete_and_count ... ok
test get_on_unloaded_relation_panics - should panic ... ok
test instance_update_after_row_is_gone - should panic ... ok
test relations_and_the_await_rule ... ok
test schema_on_disk ... ok
test update_names_only_its_fields ... ok
test version_field_turns_races_into_errors ... ok
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Two tests are marked should_panic with an expected message: they pin the two panics described above, so the suite fails loudly if a later Toasty release changes either behavior.
Keep the in-memory trade-off in mind. A sqlite::memory: database lives only as long as its connection, so the pool caps at one connection (a second would open a different, empty database). It is the standard choice for unit tests, and SeaORM's docs add the matching warning: in-memory SQLite is "not a substitute to integration testing against the target database, but a supplement".
All the SQL in this article came from Toasty's tracing. Toasty prints nothing by default. It emits structured events through the tracing crate, and a tracing subscriber is the component that receives those events and prints them. The test file installs one:
fn trace() {
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_target(false)
.without_time()
.with_ansi(false)
.with_test_writer()
.try_init();
}
with_env_filter(EnvFilter::from_default_env()) takes its filter from the RUST_LOG environment variable, so the SQL appears only when you ask for it. with_test_writer() sends the output through the output capture that cargo test applies to each test, which is why --nocapture is needed to see it, and try_init() returns an error instead of panicking when another test in the same binary already installed a subscriber; the helper ignores that error. This needs tracing-subscriber with the env-filter feature, as in the dev-dependencies above. Run with RUST_LOG=toasty::query=debug cargo test --test notes -- --nocapture --test-threads=1. Every driver emits one event per database operation on the target toasty::query. One raw line looks like this:
DEBUG query{stmt.kind="insert" model="Author"}: query executed duration_ms=0.210709 rows=1 db.system=sqlite db.statement=INSERT INTO "authors" ("id", "name", "email") VALUES (NULL, ?1, ?2) RETURNING "id" AS column1;
The db.statement field carries the SQL with ?N placeholders, and rows is the number of rows returned or affected. The other trace lines in this article were condensed with sed into the form SQL executed rows=N | <statement>, dropping the duration and driver fields.
Check yourself
- Why does
Author::get_by_id(&mut db, &99)return an error whileAuthor::filter_by_id(99).first()returnsNone? Answer:get_by_*means the record must exist and errors withrecord not foundotherwise.filter_by_idreturns a query builder, and.first()turns an empty result intoNone. Both run the sameSELECT. - What SQL does
update!(note { pinned: true })send, and why is the title absent? Answer:UPDATE "notes" ... SET "pinned" = ?1 WHERE "id" = ?2. The update includes only the fields you name, so other columns, including a concurrently edited title, are left alone. - Why does
Note::update_by_id(id).title(...)leave your in-memory struct stale? Answer: A query-based update runs inside theUPDATEstatement and loads no record, so no instance is refreshed. Only an instance update (update!on amutbinding) refreshes the struct. - How many queries does the lazy loop run for 50 authors, and how many with
.include(Author::fields().notes())? Answer: 51 lazy (one for the authors plus one per author), and 2 with.include()regardless of author count. - What changes for a write to a deleted row when the model has
#[version]? Answer: Without it, a query update returnsOkwithrows=0and an instance update panicked in 0.11.0. With it, a stale writer gets a condition-failed error and a deleted target gets a record-not-found error, so you can reload and retry.
Sources
- Toasty Guide 0.11.0: Introduction
- Toasty Guide: Defining Models
- Toasty Guide: Creating Records
- Toasty Guide: Querying Records
- Toasty Guide: Updating Records
- Toasty Guide: Preloading Associations
- Toasty Guide: Concurrency Control
- Toasty Guide: Relationships
- Toasty Guide: BelongsTo
- Toasty Guide: HasMany
- Toasty Guide: Database Setup
- Toasty Guide: Migrations and Schema Management
- Toasty Guide: SQLite
- Toasty Guide: Tracing
- Toasty repository README (tag toasty-v0.11.0)
- docs.rs: toasty 0.11.0
- docs.rs: toasty::schema::Deferred
- toasty 0.11.0 crate source: schema/deferred.rs
- toasty-core 0.11.0 crate source: stmt/sparse_record.rs
- SeaORM docs: Entity Format
- SeaORM docs: Create Table
- SeaORM docs: ActiveModel
- SeaORM docs: Update
- SeaORM docs: Using SQLite
- docs.rs: tracing-subscriber 0.3.23, SubscriberBuilder (with_test_writer, try_init)
- docs.rs: tracing-subscriber 0.3.23, EnvFilter