Introduction
Toasty is an async ORM for Rust. It supports both SQL databases (SQLite, PostgreSQL, MySQL) and NoSQL databases (DynamoDB).
You define your models as Rust structs and annotate them with
#[derive(toasty::Model)]. Toasty infers the database schema from your
annotated structs — field types map to column types, and attributes like
#[key], #[unique], and #[index] control the schema. You can customize the
mapping with attributes for table names, column names, and column types. Toasty’s
derive macro also generates query builders, create/update builders, and
relationship accessors at compile time.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
}
From this definition, Toasty generates:
toasty::create!(User { ... })— insert new usersUser::get_by_id()— fetch a user by primary keyUser::get_by_email()— fetch a user by the unique email fieldUser::all()— query all usersuser.update()— a builder for modifying a useruser.delete()— remove a userUser::fields()— field accessors for building filter expressions
The rest of this guide walks through each feature with examples. By the end, you will know how to define models, set up relationships, query data, and use Toasty’s more advanced features like embedded types, batch operations, and transactions.
What this guide covers
- Getting Started — set up a project and run your first query
- Defining Models — struct fields, types, and table mapping
- Keys and Auto-Generation — primary keys, auto-generated values, composite keys
- Creating Records — insert one or many records
- Querying Records — find, filter, and iterate over results
- Updating Records — modify existing records
- Deleting Records — remove records
- Indexes and Unique Constraints — add indexes and unique constraints
- Field Options — column names, types, defaults, and JSON serialization
- Relationships — overview of how models connect to each other
- BelongsTo — define and use many-to-one relationships
- HasMany — define and use one-to-many relationships
- HasOne — define and use one-to-one relationships
- Preloading Associations — eager loading to avoid extra queries
- Filtering with Expressions — comparisons, AND/OR, and more
- Sorting, Limits, and Pagination — order results and paginate
- Embedded Types — store structs and enums inline
- Batch Operations — multiple queries in one round-trip
- Transactions — atomic operations
- Database Setup — connection URLs, table creation, and supported databases
- Migrations and Schema Management — create and reset database tables
Getting Started
This chapter walks through creating a project, defining a model, and running your first queries.
Create a new project
cargo new my-app
cd my-app
Add the following dependencies to Cargo.toml:
[dependencies]
toasty = { version = "0.6", features = ["sqlite"] }
tokio = { version = "1", features = ["full"] }
The sqlite feature enables the SQLite driver. Toasty also supports
postgresql, mysql, and dynamodb — swap the feature flag to use a
different database.
Define a model
Replace the contents of src/main.rs with:
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example() -> toasty::Result<()> {
#[tokio::main]
async fn main() -> toasty::Result<()> {
// Build a Db handle, registering all models in this crate
let mut db = toasty::Db::builder()
.models(toasty::models!(crate::*))
.connect("sqlite::memory:")
.await?;
// Create tables based on registered models
db.push_schema().await?;
// Create a user
let user = toasty::create!(User {
name: "Alice",
email: "alice@example.com",
})
.exec(&mut db)
.await?;
println!("Created: {:?}", user.name);
// Fetch the user back by primary key
let found = User::get_by_id(&mut db, &user.id).await?;
println!("Found: {:?}", found.email);
Ok(())
}
Ok(())
}
Run it:
cargo run
You should see:
Created: "Alice"
Found: "alice@example.com"
What just happened
The #[derive(toasty::Model)] macro read the User struct and generated
several types and methods at compile time:
| You wrote | Toasty generated |
|---|---|
struct User | toasty::create!(User { ... }) — insert rows |
#[key] on id | User::get_by_id() — fetch by primary key |
#[auto] on id | Auto-generates an ID when you create a user |
#[unique] on email | User::get_by_email() — fetch by email |
You did not write any of these methods. They come from the derive macro. The rest of this guide shows everything the macro can generate and how to use it.
Connecting to a database
Db::builder() creates a builder where you provide your models and then
connect to a database. Toasty uses the registered models to infer the full
database schema — tables, columns, indexes, and relationships between models.
let mut db = toasty::Db::builder()
.models(toasty::models!(crate::*))
.connect("sqlite::memory:")
.await?;
crate::* automatically discovers all #[derive(Model)] and #[derive(Embed)]
types in your crate. You can also list models individually
(toasty::models!(User, Post)), register all models from an external crate
(toasty::models!(other_crate::*)), or combine these forms freely.
The connection URL determines which database driver to use. See Database Setup for more on model registration, connection URLs, and supported databases.
Creating tables
db.push_schema() creates all tables and indexes defined by your models. See
Migrations and Schema Management for more on managing
your database schema.
Defining Models
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.
#![allow(unused)]
fn main() {
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
email: String,
}
}
This defines a User model that maps to a users table with three columns.
In SQLite, the generated table looks like:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL
);
Each struct field becomes a column. Required fields (String, u64, etc.) map
to NOT NULL columns. The #[key] attribute marks the primary key, and
#[auto] tells Toasty to auto-generate the value on insert.
Supported field types
Toasty supports these Rust types as model fields:
| Rust type | Database type |
|---|---|
bool | Boolean |
String | Text |
i8, i16, i32, i64 | Integer (1, 2, 4, 8 bytes) |
u8, u16, u32, u64 | Unsigned integer |
f32, f64 | Floating point |
uuid::Uuid | UUID |
Vec<u8> | Binary / Blob |
Vec<T> (T scalar, not u8) | Native array column on PostgreSQL (text[], int8[], …); JSON on MySQL / SQLite; List L on DynamoDB. See field-options.md. |
Option<T> | Nullable version of T |
Embedded types (#[derive(toasty::Embed)]) | Flattened into parent table columns (see Embedded Types) |
With optional feature flags:
| Feature | Rust type | Database type |
|---|---|---|
rust_decimal | rust_decimal::Decimal | Decimal |
bigdecimal | bigdecimal::BigDecimal | Decimal |
jiff | jiff::Timestamp | Timestamp |
jiff | jiff::civil::Date | Date |
jiff | jiff::civil::Time | Time |
jiff | jiff::civil::DateTime | DateTime |
Enable feature flags in your Cargo.toml:
[dependencies]
toasty = { version = "0.6", features = ["sqlite", "jiff"] }
Optional fields
Wrap a field in Option<T> to make it nullable. An Option<T> field maps to a
nullable column in the database — the column allows NULL values instead of
requiring NOT NULL.
#![allow(unused)]
fn main() {
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
bio: Option<String>,
}
}
The bio field produces a nullable column:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
bio TEXT -- nullable, allows NULL
);
When creating a record, optional fields default to NULL if not set:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
bio: Option<String>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// bio will be NULL in the database, None in Rust
let user = toasty::create!(User { name: "Alice" })
.exec(&mut db)
.await?;
Ok(())
}
}
Table names
Toasty auto-pluralizes the struct name to derive the table name. User becomes
users, Post becomes posts.
Override the table name with #[table]:
#![allow(unused)]
fn main() {
#[derive(Debug, toasty::Model)]
#[table = "people"]
struct User {
#[key]
#[auto]
id: u64,
name: String,
}
}
This maps to a table named people instead of the default users.
What gets generated
For a model with basic fields (no relationships or indexes), #[derive(Model)]
generates:
Static methods on the model:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
}
fn __example() {
// Returns a create builder (usually called via the toasty::create! macro)
let _ =
User::create();
// Returns a builder for bulk inserts
let _ =
User::create_many();
// Returns a query builder for all records
let _ =
User::all();
// Returns a query builder with a filter applied
let _ =
User::filter(User::fields().name().eq("Alice"));
// Returns field accessors (for building filter expressions)
let _ =
User::fields();
}
}
Instance methods:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
}
fn __example(mut user: User) {
// Returns an update builder for this record
let _ =
user.update();
// Returns a delete builder for this record
let _ =
user.delete();
}
}
Builders:
-
The create builder is typically used through the
toasty::create!macro, which provides struct-literal syntax:#![allow(unused)] fn main() { use toasty::Model; #[derive(Debug, toasty::Model)] struct User { #[key] #[auto] id: u64, name: String, email: String, } async fn __example(mut db: toasty::Db) -> toasty::Result<()> { let user = toasty::create!(User { name: "Alice", email: "alice@example.com", }) .exec(&mut db) .await?; Ok(()) } } -
The update builder returned by
user.update()has a setter for each field. Only set the fields you want to change:#![allow(unused)] fn main() { use toasty::Model; #[derive(Debug, toasty::Model)] struct User { #[key] #[auto] id: u64, name: String, email: String, } async fn __example(mut user: User, mut db: toasty::Db) -> toasty::Result<()> { user.update() .name("Bob") .exec(&mut db) .await?; Ok(()) } } -
The query builder returned by
User::all()orUser::filter()has methods like.exec(),.first(),.get(), and.collect::<Vec<_>>()to execute the query.
What types can you pass to setters?
Builder setters accept more than just the exact field type. For a String
field, you can pass a String, a &str, or even an Option<String>. For
numeric fields, you can pass the value directly or a reference. This works
through Toasty’s IntoExpr trait, which handles the conversion automatically.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
}
fn __example() {
// String literal (&str)
let _ =
toasty::create!(User { name: "Alice" });
// Owned String — shorthand since the variable matches the field name
let name = "Bob".to_string();
let _ =
toasty::create!(User { name });
// Reference to a String
let name = "Carol".to_string();
let _ =
toasty::create!(User { name: &name });
}
}
You don’t need to call .to_string() or .clone() to satisfy the setter —
pass the value in whatever form you have it.
Additional methods are generated when you add attributes like #[key],
#[unique], and #[index]. The next chapters cover these.
Keys and Auto-Generation
Every model needs a primary key. Toasty uses the #[key] attribute to mark
which field (or fields) form the primary key, and #[auto] to optionally
auto-generate values for key fields.
Single-field keys
Mark a field with #[key] to make it the primary key:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
}
}
This generates User::get_by_id() to fetch a user by primary key:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = User::get_by_id(&mut db, &1).await?;
Ok(())
}
}
Keys without #[auto]
The #[auto] attribute is optional. Without it, you are responsible for
providing the key value when creating a record and for ensuring uniqueness:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Country {
#[key]
code: String,
name: String,
}
}
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Country {
#[key]
code: String,
name: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let country = toasty::create!(Country {
code: "US",
name: "United States",
})
.exec(&mut db)
.await?;
Ok(())
}
}
Other key types
The primary key field can be any supported type. UUID is a common choice:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: uuid::Uuid,
name: String,
}
}
When #[auto] is used on a uuid::Uuid field, Toasty generates a UUID v7
(time-ordered) by default. See auto strategies for other
options.
A newtype embedded struct can also be used as a key. The newtype wraps a primitive and maps to a single column, so it works like any other key type while adding type safety:
#[derive(Debug, toasty::Embed)]
struct UserId(String);
#[derive(Debug, toasty::Model)]
struct User {
#[key]
id: UserId,
name: String,
}
When the newtype wraps a type that already supports #[auto] — uuid::Uuid,
an integer type — the model field can carry #[auto] and the macro proxies
to the inner type’s strategy:
#[derive(Debug, toasty::Embed)]
struct UserId(uuid::Uuid);
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto] // proxies through UserId to <Uuid as Auto> — UUID v7
id: UserId,
name: String,
}
Auto-generated values
The #[auto] attribute tells Toasty to generate the field’s value
automatically. You don’t set auto fields when creating a record — Toasty fills
them in.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// No need to set `id` — it's auto-generated
let user = toasty::create!(User { name: "Alice" })
.exec(&mut db)
.await?;
// The generated id is available on the returned value
println!("id: {}", user.id);
Ok(())
}
}
Auto strategies
The behavior of #[auto] depends on the field type:
| Field type | #[auto] behavior | Explicit form |
|---|---|---|
uuid::Uuid | Generates a UUID v7 | #[auto(uuid(v7))] |
u64, i64, etc. | Auto-incrementing integer | #[auto(increment)] |
You can specify the strategy explicitly:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct ExampleA {
#[key]
// UUID v7 (time-ordered, the default for Uuid)
#[auto(uuid(v7))]
id: uuid::Uuid,
name: String,
}
#[derive(Debug, toasty::Model)]
struct ExampleB {
#[key]
// UUID v4 (random)
#[auto(uuid(v4))]
id: uuid::Uuid,
name: String,
}
#[derive(Debug, toasty::Model)]
struct ExampleC {
#[key]
// Auto-incrementing integer
#[auto(increment)]
id: i64,
name: String,
}
}
UUID v7 vs v4
UUID v7 values are time-ordered — UUIDs created later sort after earlier ones.
This is the default for uuid::Uuid fields because time-ordered keys perform
better in database indexes.
UUID v4 values are random with no ordering.
Integer auto-increment
Integer keys use the database’s auto-increment feature:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto(increment)]
id: i64,
title: String,
}
}
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto(increment)]
id: i64,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let post = toasty::create!(Post { title: "Hello World" })
.exec(&mut db)
.await?;
println!("post id: {}", post.id); // 1, 2, 3, ...
Ok(())
}
}
Auto-increment requires database support. SQLite, PostgreSQL, and MySQL all support auto-incrementing columns. DynamoDB does not.
Composite keys
A composite key uses two or more fields as the primary key. Toasty supports three ways to define composite keys.
Multiple #[key] fields
Mark each key field with #[key]:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Enrollment {
#[key]
student_id: u64,
#[key]
course_id: u64,
grade: Option<String>,
}
}
This generates lookup methods that take both fields:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Enrollment {
#[key]
student_id: u64,
#[key]
course_id: u64,
grade: Option<String>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let enrollment = Enrollment::get_by_student_id_and_course_id(
&mut db, &1, &101
).await?;
Ok(())
}
}
Model-level #[key(...)]
Instead of annotating each field, you can list the key fields in a single
#[key(...)] attribute on the struct:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
#[key(student_id, course_id)]
struct Enrollment {
student_id: u64,
course_id: u64,
grade: Option<String>,
}
}
#[key(student_id, course_id)] on the struct is equivalent to putting #[key]
on both student_id and course_id. It generates the same lookup methods:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
#[key(student_id, course_id)]
struct Enrollment {
student_id: u64,
course_id: u64,
grade: Option<String>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let enrollment = Enrollment::get_by_student_id_and_course_id(
&mut db, &1, &101
).await?;
Ok(())
}
}
This also works for single-field keys — #[key(code)] on the struct is
equivalent to #[key] on the code field:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
#[key(code)]
struct Country {
code: String,
name: String,
}
}
You cannot mix plain field names with partition/local syntax in the same
#[key(...)] attribute. Use one style or the other.
Partition and local keys
For databases like DynamoDB that use partition and sort keys, use the
#[key(partition = ..., local = ...)] attribute on the struct:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
#[key(partition = user_id, local = id)]
struct Todo {
#[auto]
id: u64,
title: String,
user_id: u64,
}
}
The partition field determines which partition the record is stored in. The
local field uniquely identifies the record within that partition.
Each side accepts a single bare identifier (partition = user_id) or
a bracketed list when more than one field belongs to that role:
#[key(partition = [tenant_id, org_id], local = [id])]
See Indexes and Unique Constraints — Named mode for how multi-field partition keys map onto DynamoDB GSIs versus SQL composite indexes; the same rules apply to the primary key.
With partition/local keys, Toasty generates methods to query by both fields or by the partition key alone:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
#[key(partition = user_id, local = id)]
struct Todo {
#[auto]
id: u64,
title: String,
user_id: u64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Get a specific todo
let todo = Todo::get_by_user_id_and_id(
&mut db, &1, &42
).await?;
// Get all todos for a user
let todos = Todo::filter_by_user_id(&1)
.exec(&mut db)
.await?;
Ok(())
}
}
What gets generated
For a model with #[key], Toasty generates these methods:
| Attribute | Generated methods |
|---|---|
#[key] on single field | get_by_<field>(), filter_by_<field>(), delete_by_<field>() |
#[key] on multiple fields | get_by_<a>_and_<b>(), filter_by_<a>_and_<b>(), delete_by_<a>_and_<b>() |
#[key(a, b)] on struct | Same as #[key] on multiple fields |
#[key(partition = a, local = b)] | get_by_<a>_and_<b>(), filter_by_<a>(), filter_by_<a>_and_<b>(), delete_by_<a>_and_<b>() |
Creating Records
Toasty provides two ways to create records: the toasty::create! macro and
the create builder. The macro uses a syntax inspired by struct literals and expands to builder
calls under the hood. Most code uses the macro; the builder is there when you
need programmatic control (e.g., conditional fields).
Creating a single record
With the macro:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User {
name: "Alice",
email: "alice@example.com",
})
.exec(&mut db)
.await?;
println!("Created user with id: {}", user.id);
Ok(())
}
}
The macro expands to an equivalent code block as:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = User::create()
.name("Alice")
.email("alice@example.com")
.exec(&mut db)
.await?;
Ok(())
}
}
The create! macro does not execute the query. It returns a create builder
with all the specified values already applied. Call .exec(&mut db) on the
builder to insert the row, or continue working with the builder value (for
example, to conditionally set additional fields). The returned User
instance has all fields set, including auto-generated ones like id.
Like Rust struct literals, the macro supports field shorthand — writing just
name instead of name: name when the variable matches the field name. You
can mix shorthand and explicit fields freely.
The generated SQL looks like:
INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com');
Field values in the macro can be any Rust expression — literals, variables, or
function calls. When a variable has the same name as the field, you can use the
shorthand syntax (just name instead of name: name), the same way Rust
struct literals work:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let name = "Bob";
let user = toasty::create!(User {
name,
email: format!("{}@example.com", name.to_lowercase()),
})
.exec(&mut db)
.await?;
Ok(())
}
}
When the variable name differs from the field name, use the explicit
field: expr form:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user_name = "Bob";
let user = toasty::create!(User {
name: user_name,
email: format!("{}@example.com", user_name.to_lowercase()),
})
.exec(&mut db)
.await?;
Ok(())
}
}
Required vs optional fields
Required fields (String, u64, etc.) must be set before calling .exec().
Optional fields (Option<T>) default to NULL if not set.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
bio: Option<String>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// bio defaults to None (NULL in the database)
let user = toasty::create!(User { name: "Alice" })
.exec(&mut db)
.await?;
assert!(user.bio.is_none());
// Or set it explicitly
let user = toasty::create!(User {
name: "Bob",
bio: "Likes Rust",
})
.exec(&mut db)
.await?;
assert_eq!(user.bio.as_deref(), Some("Likes Rust"));
Ok(())
}
}
Creating through a relation
Use the in keyword to create a record through a relation accessor. The in
prefix tells the macro to call .create() on the scope expression — so
in user.todos() { ... } expands to user.todos().create().title(...).
Toasty sets the foreign key automatically:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_many]
todos: toasty::HasMany<Todo>,
}
#[derive(Debug, toasty::Model)]
struct Todo {
#[key]
#[auto]
id: u64,
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User { name: "Alice" }).exec(&mut db).await?;
let todo = toasty::create!(in user.todos() { title: "Buy groceries" })
.exec(&mut db)
.await?;
assert_eq!(todo.user_id, user.id);
Ok(())
}
}
The macro expands to an equivalent code block as:
let todo = user.todos().create()
.title("Buy groceries")
.exec(&mut db)
.await?;
You don’t need to set user_id — Toasty fills it in from the parent
because the create builder is scoped to user.todos().
Nested creation
When models have relationships, you can create a parent and its children in a
single call. Inside the macro, use { ... } (without a type prefix) for
BelongsTo/HasOne fields, and [{ ... }, { ... }] for HasMany fields:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_many]
todos: toasty::HasMany<Todo>,
}
#[derive(Debug, toasty::Model)]
struct Todo {
#[key]
#[auto]
id: u64,
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User {
name: "Alice",
todos: [{ title: "Buy groceries" }, { title: "Write docs" }],
})
.exec(&mut db)
.await?;
let todos = user.todos().exec(&mut db).await?;
assert_eq!(2, todos.len());
Ok(())
}
}
The macro expands to an equivalent code block as:
User::create()
.name("Alice")
.todos([
Todo::create().title("Buy groceries"),
Todo::create().title("Write docs"),
])
.exec(&mut db)
.await?;
Toasty creates the user first, then creates each todo with the user’s ID automatically set as the foreign key. Nesting works to arbitrary depth — a nested record can itself contain nested records.
Toasty makes a best effort to execute nested creation atomically — either all records are inserted or none are. Whether full atomicity is guaranteed depends on your database’s capabilities, so check your database’s transaction and consistency documentation.
The relationship chapters (BelongsTo, HasMany, HasOne) cover nested creation in more detail.
Creating many records
Same-type batch
Use the ::[ ... ] syntax to create multiple records of the same model:
let users = toasty::create!(User::[
{ name: "Alice", email: "alice@example.com" },
{ name: "Bob", email: "bob@example.com" },
{ name: "Carol", email: "carol@example.com" },
])
.exec(&mut db)
.await?;
The macro expands to an equivalent code block as:
toasty::batch([
User::create().name("Alice").email("alice@example.com"),
User::create().name("Bob").email("bob@example.com"),
User::create().name("Carol").email("carol@example.com"),
])
The same-type batch returns a Vec<User>. The batch is atomic — all
records are inserted together or none are.
Mixed-type batch
Use ( ... ) to create records of different models in a single batch:
let (user, post) = toasty::create!((
User { name: "Alice" },
Post { title: "Hello World" },
))
.exec(&mut db)
.await?;
The macro expands to an equivalent code block as:
toasty::batch((
User::create().name("Alice"),
Post::create().title("Hello World"),
))
You can mix type-target and scoped forms in the same batch:
let (user, todo) = toasty::create!((
User { name: "Carl" },
in user.todos() { title: "Buy milk" },
))
.exec(&mut db)
.await?;
Dynamic batches with toasty::batch()
When the number of records is determined at runtime, collect create builders
into a Vec and pass it to toasty::batch():
let names = get_names_from_csv();
let mut insertions = vec![];
for (i, name) in names.iter().enumerate() {
insertions.push(toasty::create!(User {
name,
email: format!("user{i}@example.com"),
}));
}
let users = toasty::batch(insertions).exec(&mut db).await?;
When to use the builder directly
The macro covers the common case. Use the builder directly when you need to conditionally set fields, since the macro requires all fields to be specified in the struct literal:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
bio: Option<String>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let mut builder = User::create().name("Alice");
if true /* some condition */ {
builder = builder.bio("Likes Rust");
}
let user = builder.exec(&mut db).await?;
Ok(())
}
}
Macro-to-builder reference
Each macro form has a direct builder equivalent:
| Macro syntax | Builder equivalent |
|---|---|
toasty::create!(User { name: "Alice" }) | User::create().name("Alice") |
toasty::create!(in user.todos() { title: "Buy milk" }) | user.todos().create().title("Buy milk") |
Nested { ... } for BelongsTo/HasOne | .field(ChildModel::create().field_calls) |
Nested [{ ... }] for HasMany | .fields([ChildModel::create()...]) |
toasty::create!(User::[{ ... }, { ... }]) | toasty::batch([User::create()...]) → Vec<User> |
toasty::create!((User { ... }, Post { ... })) | toasty::batch((User::create()..., Post::create()...)) → tuple |
What gets generated
For a model like User, #[derive(Model)] generates:
User::create()— returns a builder with a setter for each non-auto fieldtoasty::create!(User { ... })— macro syntax that expands to builder calls
The create builder’s setter methods accept flexible input types through the
IntoExpr trait. For a String field, you can pass &str, String, or
&String. For numeric fields, you can pass the value directly or by reference.
See Defining Models — What types can you pass to setters?
for details.
Querying Records
Toasty generates several ways to retrieve records: by primary key, by indexed fields, or by building queries with filters.
Get by primary key
<YourModel>::get_by_id() fetches a single record by its primary key. It
returns the record directly, or an error if no record exists with that key.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = User::get_by_id(&mut db, &1).await?;
println!("Found: {}", user.name);
Ok(())
}
}
The method name matches the key field name. A model with #[key] code: String
generates get_by_code(). Composite keys generate combined names like
get_by_student_id_and_course_id().
Get all records
<YourModel>::all() returns a query for all records of that model.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let users = User::all().exec(&mut db).await?;
for user in &users {
println!("{}: {}", user.id, user.name);
}
Ok(())
}
}
Executing queries
Queries returned by all(), filter(), and filter_by_*() are not executed
until you call a terminal method. Toasty provides three terminal methods:
.exec() — collect all results
Returns all matching records as a Vec:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let users: Vec<User> = User::all().exec(&mut db).await?;
Ok(())
}
}
.first() — get the first result or None
Returns Option<User> — Some if at least one record matches, None if the
query returns no results:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let maybe_user = User::all().first().exec(&mut db).await?;
match maybe_user {
Some(user) => println!("Found: {}", user.name),
None => println!("No users found"),
}
Ok(())
}
}
.get() — get exactly one result
Returns the record directly, or an error if no record matches. Use this when you expect the query to return exactly one result:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db, user_id: u64) -> toasty::Result<()> {
let user = User::filter_by_id(user_id).get(&mut db).await?;
Ok(())
}
}
Filtering by indexed fields
Toasty generates filter_by_* methods for indexed and key fields. These return
a query builder that you can execute with any terminal method.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// filter_by_id returns a query builder
let user = User::filter_by_id(1).get(&mut db).await?;
// filter_by_email is generated because email has #[unique]
let user = User::filter_by_email("alice@example.com")
.get(&mut db)
.await?;
Ok(())
}
}
The difference between get_by_* and filter_by_*: get_by_* methods execute
immediately and return the record. filter_by_* methods return a query builder
that you can further customize before executing.
Filtering with expressions
For queries beyond simple field equality, use <YourModel>::filter() with field
expressions. The Filtering with Expressions
chapter covers this in detail. Here is a quick example:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let users = User::filter(User::fields().name().eq("Alice"))
.exec(&mut db)
.await?;
Ok(())
}
}
Chaining filters
You can chain .filter() on an existing query to add more conditions:
let users = User::filter_by_name("Alice")
.filter(User::fields().age().gt(25))
.exec(&mut db)
.await?;
Each .filter() call adds an AND condition to the query.
Projecting columns with .select()
By default a query returns full model rows. .select() replaces the
projection so the query returns one or more chosen field values
instead. The terminal .exec() then yields Vec<T> where T matches
the projection — a single field path yields Vec<FieldType>, a tuple
yields Vec<(...)>.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Just the names.
let names: Vec<String> = User::all()
.select(User::fields().name())
.exec(&mut db)
.await?;
// A tuple of two fields.
let pairs: Vec<(u64, String)> = User::all()
.select((User::fields().id(), User::fields().name()))
.exec(&mut db)
.await?;
Ok(())
}
}
.select() works on any query — all(), filter(), and
filter_by_*() — and lets the database skip reading columns the caller
will not look at. Chain it after a filter to project a subset of
columns from the matching rows:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let name: Option<String> = User::filter_by_email("alice@example.com")
.select(User::fields().name())
.first()
.exec(&mut db)
.await?;
Ok(())
}
}
Sorting by most recent
.latest_by(field) sorts the query in descending order of the named
field — shorthand for order_by(field.desc()):
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let recent = Post::all()
.latest_by(Post::fields().id())
.limit(10)
.exec(&mut db)
.await?;
Ok(())
}
}
Use it when the natural ordering for a model is “newest first” and the
sort field doubles as a recency proxy — auto-incrementing keys, UUIDv7
keys, or a created_at timestamp.
What gets generated
For a model with #[key] on id and #[unique] on email, Toasty generates:
| Method | Returns | Description |
|---|---|---|
User::all() | Query builder | All records |
User::filter(expr) | Query builder | Records matching expression |
User::filter_by_id(id) | Query builder | Records matching key |
User::filter_by_email(email) | Query builder | Records matching unique field |
User::get_by_id(&mut db, &id) | Result<User> | One record by key (immediate) |
User::get_by_email(&mut db, email) | Result<User> | One record by unique field (immediate) |
Query builders support these terminal methods:
| Method | Returns |
|---|---|
.exec(&mut db) | Result<Vec<User>> |
.first().exec(&mut db) | Result<Option<User>> |
.get(&mut db) | Result<User> |
Updating Records
Toasty generates an update builder for each model. You can update a record through an instance method, through a query, or with a generated convenience method.
Updating an instance
Call .update() on a mutable model instance, set the fields you want to change,
and call .exec(&mut db):
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let mut user = toasty::create!(User {
name: "Alice",
email: "alice@example.com",
})
.exec(&mut db)
.await?;
user.update()
.name("Alice Smith")
.exec(&mut db)
.await?;
// The instance is updated in place
assert_eq!(user.name, "Alice Smith");
Ok(())
}
}
Only set the fields you want to change. Fields you don’t set keep their current
values. The .update() method takes &mut self, so you need a mutable binding.
After .exec() completes, the instance reflects the new values.
If the model has a #[version] field, instance
updates are version-guarded: Toasty conditions the write on the version the
instance was last loaded with and increments it atomically. If a concurrent
writer has modified the record in the meantime, .exec() returns an error.
The generated SQL looks like:
UPDATE users SET name = 'Alice Smith' WHERE id = 1;
Updating multiple fields
Chain multiple setters to update several fields at once:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let mut user = toasty::create!(User { name: "Alice", email: "alice@example.com" })
.exec(&mut db)
.await?;
user.update()
.name("Alice Smith")
.email("alice.smith@example.com")
.exec(&mut db)
.await?;
Ok(())
}
}
Updating by query
You can update records without first loading them by building a query and calling
.update() on it:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db, user_id: u64) -> toasty::Result<()> {
User::filter_by_id(user_id)
.update()
.name("Bob")
.exec(&mut db)
.await?;
Ok(())
}
}
This executes the update directly without loading the record first. The query determines which records to update, and the chained setters specify the new values.
Update by indexed field
Toasty generates update_by_* convenience methods for key and indexed fields:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db, user_id: u64) -> toasty::Result<()> {
User::update_by_id(user_id)
.name("Bob")
.exec(&mut db)
.await?;
Ok(())
}
}
This is shorthand for User::filter_by_id(user_id).update(). Toasty generates
update_by_* for each field that has #[key], #[unique], or #[index].
Setting optional fields to None
To clear an optional field, pass None with the appropriate type annotation:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
bio: Option<String>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let mut user = toasty::create!(User { name: "Alice", bio: "Likes Rust" })
.exec(&mut db)
.await?;
user.update()
.bio(Option::<String>::None)
.exec(&mut db)
.await?;
assert!(user.bio.is_none());
Ok(())
}
}
Modifying a Vec<scalar> field
A Vec<scalar> field (e.g. tags: Vec<String>) supports whole-value
replacement through the setter and a set of incremental builders —
stmt::push, stmt::extend, stmt::pop, stmt::remove,
stmt::remove_at, stmt::clear, and stmt::apply. See
Vec<scalar> Fields for the full
treatment, including which builders each driver supports.
What gets generated
For a model with #[key] on id and #[unique] on email, Toasty generates:
user.update()— instance method (takes&mut self), returns an update builder. After.exec(), the instance is reloaded with the new values.User::update_by_id(id)— returns an update builder for the record matching the given key.User::update_by_email(email)— returns an update builder for the record matching the given email.- Any query builder’s
.update()method — converts the query into an update builder.
The update builder has a setter method for each field. Only the fields you set
are included in the UPDATE statement.
Deleting Records
Toasty provides several ways to delete records: from an instance, by primary key, or through a query.
Deleting an instance
Call .delete() on a model instance, then .exec(&mut db):
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User {
name: "Alice",
email: "alice@example.com",
})
.exec(&mut db)
.await?;
let user_id = user.id;
user.delete().exec(&mut db).await?;
// The record no longer exists
let result = User::get_by_id(&mut db, &user_id).await;
assert!(result.is_err());
Ok(())
}
}
The .delete() method consumes the instance (takes self, not &self). After
deleting, you can no longer use the instance.
If the model has a #[version] field, instance
deletes are version-guarded: Toasty conditions the deletion on the version the
instance was last loaded with. If a concurrent writer has modified the record
in the meantime, .exec() returns an error.
The generated SQL looks like:
DELETE FROM users WHERE id = 1;
Deleting by primary key
Use the generated delete_by_* method to delete a record by its key without
loading it first:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User { name: "Alice", email: "alice@example.com" })
.exec(&mut db)
.await?;
User::delete_by_id(&mut db, user.id).await?;
Ok(())
}
}
This executes the delete directly — no SELECT query is issued first.
Deleting by query
Build a query and call .delete() on it to delete all matching records:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
toasty::create!(User { name: "Alice", email: "alice@example.com" })
.exec(&mut db)
.await?;
User::filter_by_email("alice@example.com")
.delete()
.exec(&mut db)
.await?;
Ok(())
}
}
You can use any query builder — filter_by_*, filter(), or all() — and
chain .delete() to convert it into a delete operation.
What gets generated
For a model with #[key] on id and #[unique] on email, Toasty generates:
user.delete()— instance method (consumesself), returns a delete statement. Call.exec(&mut db)to execute.User::delete_by_id(&mut db, id)— deletes the record matching the given key. Executes immediately.User::delete_by_email(&mut db, email)— deletes the record matching the given email. Executes immediately.- Any query builder’s
.delete()method — converts the query into a delete statement.
Indexes and Unique Constraints
Toasty supports two field-level attributes for indexing: #[unique] and
#[index]. Both create database indexes, but they differ in what gets
generated.
Unique fields
Add #[unique] to a field to create a unique index. Toasty enforces uniqueness
on all supported databases. SQL databases (SQLite, PostgreSQL, MySQL) use a
native unique index. DynamoDB uses a separate index table keyed on the unique
attribute; inserts and updates write to both tables in a single
TransactWriteItems call with an attribute_not_exists condition that rejects
duplicates.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
}
This generates a unique index on the email column:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL
);
CREATE UNIQUE INDEX idx_users_email ON users (email);
Attempting to insert a duplicate value returns an error:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
toasty::create!(User {
name: "Alice",
email: "alice@example.com",
})
.exec(&mut db)
.await?;
// This fails — email must be unique
let result = toasty::create!(User {
name: "Bob",
email: "alice@example.com",
})
.exec(&mut db)
.await;
assert!(result.is_err());
Ok(())
}
}
Generated methods for unique fields
Because a unique field identifies at most one record, Toasty generates a
get_by_* method that returns a single record:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Get a single user by email (errors if not found)
let user = User::get_by_email(&mut db, "alice@example.com").await?;
Ok(())
}
}
Toasty also generates filter_by_*, update_by_*, and delete_by_* methods:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Filter — returns a query builder
let user = User::filter_by_email("alice@example.com")
.get(&mut db)
.await?;
// Update by email
User::update_by_email("alice@example.com")
.name("Alice Smith")
.exec(&mut db)
.await?;
// Delete by email
User::delete_by_email(&mut db, "alice@example.com").await?;
Ok(())
}
}
Indexed fields
Add #[index] to a field to tell Toasty that this field is a query target. On
SQL databases, Toasty creates a database index on the column, which lets the
database find matching rows without scanning the entire table. On DynamoDB, the
attribute maps to a secondary index.
Unlike #[unique], #[index] does not enforce uniqueness — multiple records
can share the same value.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[index]
country: String,
}
}
This generates a non-unique index:
CREATE INDEX idx_users_country ON users (country);
Generated methods for indexed fields
Because an indexed field may match multiple records, the generated methods work with collections rather than single records:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[index]
country: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// filter_by_country returns a query builder (may match many records)
let users = User::filter_by_country("US")
.exec(&mut db)
.await?;
// Update all records matching the index
User::update_by_country("US")
.country("United States")
.exec(&mut db)
.await?;
// Delete all records matching the index
User::delete_by_country(&mut db, "US").await?;
Ok(())
}
}
Toasty also generates a get_by_* method for indexed fields. It returns the
matching record directly, but errors if no record matches or if more than one
record matches:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[index]
country: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = User::get_by_country(&mut db, "US").await?;
Ok(())
}
}
Multi-column indexes
Struct-level #[index] lets you define a composite index spanning multiple
fields. This is useful when you frequently query by a combination of fields
rather than a single one.
Simple mode
List the fields in order — the first field is the leading key, and the remaining fields extend it:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
#[index(game_title, top_score)]
struct GameScore {
#[key]
#[auto]
id: u64,
user_id: String,
game_title: String,
top_score: i64,
}
}
On SQL databases this creates a composite index with columns in the order specified:
CREATE INDEX idx_game_scores_game_title_top_score
ON game_scores (game_title, top_score);
On DynamoDB, the first field becomes the HASH key and the remaining fields become RANGE keys of a Global Secondary Index (GSI).
Toasty generates a method for each valid prefix of the index fields:
| Method | Description |
|---|---|
GameScore::filter_by_game_title(game_title) | All scores for a game |
GameScore::filter_by_game_title_and_top_score(game_title, top_score) | Scores for a game with a specific score |
You can use these the same way as single-column index methods:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
#[index(game_title, top_score)]
struct GameScore {
#[key]
#[auto]
id: u64,
user_id: String,
game_title: String,
top_score: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// All scores for "chess"
let scores: Vec<GameScore> = GameScore::filter_by_game_title("chess")
.exec(&mut db)
.await?;
// Scores for "chess" with a top score of exactly 1400
let scores: Vec<GameScore> = GameScore::filter_by_game_title_and_top_score("chess", 1400)
.exec(&mut db)
.await?;
Ok(())
}
}
For a three-column index, Toasty generates three prefix methods. Given
#[index(country, city, zip_code)]:
| Method | Columns matched |
|---|---|
filter_by_country(country) | country |
filter_by_country_and_city(country, city) | country, city |
filter_by_country_and_city_and_zip_code(country, city, zip_code) | country, city, zip_code |
Named mode
Use partition = ... and local = ... to explicitly assign fields to key
roles. This is required when you need multiple fields in the DynamoDB partition
key:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
#[index(partition = [tournament_id, region], local = [round])]
struct Match {
#[key]
#[auto]
id: u64,
tournament_id: String,
region: String,
round: String,
player1_id: String,
player2_id: String,
}
}
On DynamoDB, partition fields map to KeyType::Hash entries and local
fields map to KeyType::Range entries in the GSI KeySchema. This allows the
DynamoDB index to carry a composite identifier — here, a tournament is uniquely
identified by both tournament_id and region.
The generated methods require all partition fields:
| Method | Description |
|---|---|
Match::filter_by_tournament_id_and_region(tournament_id, region) | All rounds for a tournament+region |
Match::filter_by_tournament_id_and_region_and_round(tournament_id, region, round) | A specific round |
On SQL databases, the partition/local distinction is ignored — all fields
are placed in the composite index in the order they appear, producing
CREATE INDEX ... ON matches (tournament_id, region, round).
Custom index names
Toasty generates an index name from the table and field list (e.g.,
idx_users_email). Override it with name = "..." inside #[index(...)]
or #[key(...)]:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
#[index(name = "scores_by_game", game_title, top_score)]
struct GameScore {
#[key]
#[auto]
id: u64,
user_id: String,
game_title: String,
top_score: i64,
}
}
This becomes CREATE INDEX scores_by_game ON game_scores (...) on SQL
drivers and is used as the GSI name on DynamoDB. The name must be
non-empty and may only appear once per attribute. Use a custom name
when a migration tool or external query references it by name, or to
keep generated names within a database’s identifier-length limit.
SQL vs DynamoDB behavior
| Behavior | SQL | DynamoDB |
|---|---|---|
| Index structure | CREATE INDEX with all columns in order | GSI with HASH and RANGE key entries |
| Partition / local distinction | Ignored — all columns form a flat composite index | partition = KeyType::Hash, local = KeyType::Range |
| Query matching | Database uses leftmost-prefix matching | All partition fields required; local fields optional left-to-right |
| Column limits | No artificial limits | Up to 4 partition and 4 local attributes per index |
Indexing newtype fields
Newtype embedded structs (single unnamed field, e.g., struct Email(String))
support #[unique] and #[index] on the model field. The newtype maps to a
single column, so the index works the same as on a primitive:
#[derive(Debug, toasty::Embed)]
struct Email(String);
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: Email,
}
This generates User::get_by_email(), User::filter_by_email(), and the other
index methods. The argument type is the newtype itself:
let user = User::get_by_email(&mut db, Email("alice@example.com".into())).await?;
Multi-field embedded structs do not support #[unique] or #[index] on the
parent field because the column ordering within the index is ambiguous. Index
individual fields inside the embedded struct instead (see
Embedded Types — Indexing embedded fields).
Choosing between #[unique] and #[index]
Both attributes tell Toasty that a field is a query target and generate the same
set of methods: get_by_*, filter_by_*, update_by_*, and delete_by_*.
The difference is in the constraint they express:
| Attribute | Meaning | Database effect (SQL) |
|---|---|---|
#[unique] | Each record has a distinct value | CREATE UNIQUE INDEX — the database rejects duplicates |
#[index] | Multiple records may share a value | CREATE INDEX — no uniqueness enforcement |
Use #[unique] for fields that identify a single record — email addresses,
usernames, slugs. Use #[index] for fields you query frequently but that
naturally repeat — country, status, category.
What gets generated
For a model with #[unique] on email and #[index] on country:
| Method | Description |
|---|---|
User::get_by_email(&mut db, email) | One record by unique field |
User::filter_by_email(email) | Query builder for unique field |
User::update_by_email(email) | Update builder for unique field |
User::delete_by_email(&mut db, email) | Delete by unique field |
User::get_by_country(&mut db, country) | One record by indexed field |
User::filter_by_country(country) | Query builder for indexed field |
User::update_by_country(country) | Update builder for indexed field |
User::delete_by_country(&mut db, country) | Delete by indexed field |
These methods follow the same patterns as key-generated methods. See Querying Records, Updating Records, and Deleting Records for details on terminal methods and builders.
Field Options
Toasty provides several field-level attributes to control how fields map to database columns: custom column names, explicit types, default values, update expressions, and JSON serialization.
Custom column names
By default, a Rust field name maps directly to a column name. Use
#[column("name")] to override this:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
#[column("display_name")]
name: String,
}
}
The field is still accessed as user.name in Rust, but the database column is
named display_name:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL
);
Explicit column types
Toasty infers the column type from the Rust field type. Use
#[column(type = ...)] to specify an explicit database type instead:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
#[column(type = varchar(100))]
name: String,
}
}
This creates a VARCHAR(100) column instead of TEXT. The database rejects
values that exceed the specified length.
You can combine a custom name with an explicit type:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
#[column("display_name", type = varchar(100))]
name: String,
}
}
Supported type values:
| Type syntax | Database type |
|---|---|
boolean | Boolean |
int, i8, i16, i32, i64 | Integer (various sizes) |
uint, u8, u16, u32, u64 | Unsigned integer |
text | Text |
varchar(N) | Variable-length string with max length |
numeric, numeric(P, S) | Decimal with optional precision and scale |
binary(N), blob | Binary data |
timestamp(P) | Timestamp with precision |
date | Date |
time(P) | Time with precision |
datetime(P) | Date and time with precision |
Not all databases support all column types. Toasty validates explicit column
types against the database’s capabilities when you call db.push_schema(). If a
type is not supported, schema creation fails with an error. For example,
varchar is supported by PostgreSQL and MySQL but not by SQLite or DynamoDB —
using #[column(type = varchar(100))] with SQLite produces an error like
"unsupported feature: VARCHAR type is not supported by this database". If the
requested size exceeds the database’s maximum, Toasty reports that as well.
Default values
Use #[default(expr)] to set a default value applied when creating a record.
If you don’t set the field on the create builder, Toasty evaluates the
expression and uses the result.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
title: String,
#[default(0)]
view_count: i64,
}
}
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
title: String,
#[default(0)]
view_count: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// view_count defaults to 0
let post = toasty::create!(Post { title: "Hello World" })
.exec(&mut db)
.await?;
assert_eq!(post.view_count, 0);
// Override the default by setting it explicitly
let post = toasty::create!(Post {
title: "Popular Post",
view_count: 100,
})
.exec(&mut db)
.await?;
assert_eq!(post.view_count, 100);
Ok(())
}
}
The expression inside #[default(...)] is any valid Rust expression. It runs at
insert time, not at compile time.
#[default] only applies on create. It has no effect on updates.
Update expressions
Use #[update(expr)] to set an expression that applies on both create and
update. Each time the record is created or updated, Toasty evaluates the
expression and sets the field — unless you explicitly override it.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
title: String,
#[update(jiff::Timestamp::now())]
updated_at: jiff::Timestamp,
}
}
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
title: String,
#[update(jiff::Timestamp::now())]
updated_at: jiff::Timestamp,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// updated_at is set automatically on create
let mut post = toasty::create!(Post { title: "Hello World" })
.exec(&mut db)
.await?;
// updated_at is refreshed automatically on update
post.update()
.title("Updated Title")
.exec(&mut db)
.await?;
Ok(())
}
}
You can override the automatic value by setting the field explicitly:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
title: String,
#[update(jiff::Timestamp::now())]
updated_at: jiff::Timestamp,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let mut post = toasty::create!(Post { title: "Hello World" })
.exec(&mut db)
.await?;
let explicit_ts = jiff::Timestamp::from_second(946684800).unwrap();
post.update()
.title("Backdated")
.updated_at(explicit_ts)
.exec(&mut db)
.await?;
assert_eq!(post.updated_at, explicit_ts);
Ok(())
}
}
Combining #[default] and #[update]
You can use both attributes on the same field. #[default] applies on create,
#[update] applies on update:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
title: String,
// On create: "draft". On update: "edited".
#[default("draft".to_string())]
#[update("edited".to_string())]
status: String,
}
}
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
title: String,
#[default("draft".to_string())]
#[update("edited".to_string())]
status: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let mut post = toasty::create!(Post { title: "Hello" })
.exec(&mut db)
.await?;
assert_eq!(post.status, "draft");
post.update().title("Updated").exec(&mut db).await?;
assert_eq!(post.status, "edited");
Ok(())
}
}
Timestamps with #[auto]
For timestamp fields named created_at or updated_at, #[auto] provides a
shorthand:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
title: String,
#[auto]
created_at: jiff::Timestamp,
#[auto]
updated_at: jiff::Timestamp,
}
}
When #[auto] appears without arguments on a non-key field, Toasty uses a
heuristic based on the field name and type to determine the behavior:
| Field name | Field type | #[auto] expands to |
|---|---|---|
created_at | jiff::Timestamp | #[default(jiff::Timestamp::now())] — set once on create |
updated_at | jiff::Timestamp | #[update(jiff::Timestamp::now())] — refreshed on every create and update |
On key fields, bare #[auto] defers to the type’s default auto-generation
strategy (e.g., auto-increment for integers, UUID v7 for uuid::Uuid). See
Keys and Auto-Generation for details.
This is the recommended way to add timestamps to your models. The created_at
field is set when the record is first inserted and never changes. The
updated_at field is refreshed each time the record is updated.
Timestamp fields require the jiff feature:
[dependencies]
toasty = { version = "0.6", features = ["sqlite", "jiff"] }
Date and time fields
With the jiff feature enabled, you can use these types for date and time
fields:
| Rust type | Description |
|---|---|
jiff::Timestamp | An instant in time (UTC) |
jiff::civil::Date | A date without time |
jiff::civil::Time | A time of day without date |
jiff::civil::DateTime | A date and time without timezone |
You can control the storage precision with #[column(type = ...)]:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Event {
#[key]
#[auto]
id: u64,
name: String,
#[column(type = timestamp(3))]
starts_at: jiff::Timestamp,
#[column(type = time(0))]
reminder_time: jiff::civil::Time,
}
}
JSON serialization
Use #[serialize(json)] to store an arbitrary serde-serializable Rust
value as a JSON string in the database. The field type must implement
serde::Serialize and serde::Deserialize.
Reach for #[serialize(json)] when the field is a serde-typed value
that Toasty doesn’t natively support — for example, a third-party type
or a struct you don’t want to declare as #[derive(toasty::Embed)].
For Vec<scalar> fields, prefer the native form (tags: Vec<String>,
documented in Defining Models) which is
queryable and supports collection mutations via stmt::push,
stmt::extend, stmt::pop, stmt::remove, stmt::remove_at, and
stmt::clear.
use toasty::Model;
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Metadata {
version: u32,
labels: Vec<String>,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
title: String,
// Native `Vec<scalar>` storage — no attribute needed.
tags: Vec<String>,
// Arbitrary serde type — `#[serialize(json)]` stores it as one
// opaque JSON column. Toasty cannot query into it.
#[serialize(json)]
meta: Metadata,
}
Toasty serializes the value to a JSON string on insert and update, and
deserializes it back when reading. The default database column type is TEXT.
You can override this with #[column(type = ...)] if needed — for example,
#[column(type = varchar(1000))] to limit the stored JSON size on databases
that support varchar.
use toasty::Model;
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Metadata {
version: u32,
labels: Vec<String>,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
title: String,
tags: Vec<String>,
#[serialize(json)]
meta: Metadata,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let post = toasty::create!(Post {
title: "Hello",
tags: vec!["rust".to_string(), "toasty".to_string()],
meta: Metadata {
version: 1,
labels: vec!["alpha".to_string()],
},
})
.exec(&mut db)
.await?;
assert_eq!(post.tags, vec!["rust", "toasty"]);
assert_eq!(post.meta.version, 1);
Ok(())
}
Nullable JSON fields
By default, #[serialize(json)] creates a NOT NULL column. An Option<T>
field with #[serialize(json)] serializes None as the JSON text "null" —
the column still stores a non-null string.
To allow SQL NULL in the column, add the nullable modifier:
#![allow(unused)]
fn main() {
use toasty::Model;
use std::collections::HashMap;
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
title: String,
#[serialize(json, nullable)]
metadata: Option<HashMap<String, String>>,
}
}
With nullable:
Nonemaps to SQLNULLin the databaseSome(value)maps to the JSON string representation
Without nullable:
Nonemaps to the JSON text"null"(a non-null string)Some(value)maps to the JSON string representation
Scalar arrays
A Vec<T> field where T is a scalar type stores a homogeneous,
ordered collection in a single column. These fields have their own
guide page covering the element types, storage per driver, creation,
querying, and updates — see Vec<scalar> Fields.
Attribute summary
| Attribute | Purpose | Applies on |
|---|---|---|
#[column("name")] | Custom column name | — |
#[column(type = ...)] | Explicit column type | — |
#[default(expr)] | Default value | Create only |
#[update(expr)] | Automatic value | Create and update |
#[auto] on created_at | Shorthand for #[default(jiff::Timestamp::now())] | Create only |
#[auto] on updated_at | Shorthand for #[update(jiff::Timestamp::now())] | Create and update |
#[serialize(json)] | Store as JSON text | Create and update |
#[serialize(json, nullable)] | Store as JSON text with SQL NULL support | Create and update |
See Concurrency Control for the #[version]
attribute.
Vec<scalar> Fields
A Vec<scalar> field stores a homogeneous, ordered collection of
scalar values in a single column — tags: Vec<String>, scores: Vec<i64>, weights: Vec<f64>. Toasty stores the collection directly;
you do not wrap it in JSON by hand or manage a separate join table.
The element type must be a scalar: any primitive other than u8, plus
String, Uuid, the decimal types, and the jiff date/time types.
Vec<u8> keeps its existing meaning — a single binary blob, not a
collection of one-byte integers.
Storage depends on the driver:
| Driver | Representation |
|---|---|
| PostgreSQL | Native array column (text[], int8[], double precision[], …) |
| MySQL | JSON column |
| SQLite | JSON-encoded text |
| DynamoDB | List L attribute |
All four built-in drivers support Vec<scalar> fields. A driver that
does not will reject the model at schema build with an error naming the
unsupported field rather than mis-storing it. The incremental update
builders have narrower support — see Driver support.
Defining a scalar collection field
Declare the field as a Vec<T> for a scalar T. No attribute is
needed:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Article {
#[key]
#[auto]
id: u64,
title: String,
tags: Vec<String>,
scores: Vec<i64>,
}
}
A Vec<scalar> field is always present — there is no unset state. A
row with no elements holds an empty list, not NULL.
Creating records
The field accepts any value that converts into a list: a Vec<T>, an
array literal [T; N], or a slice. The create! macro and the create
builder take the same forms.
With the create! macro — an array literal works, no vec! needed:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Article {
#[key]
#[auto]
id: u64,
title: String,
tags: Vec<String>,
scores: Vec<i64>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let article = toasty::create!(Article {
title: "Hello",
tags: ["rust", "toasty"],
scores: [1, 2, 3],
})
.exec(&mut db)
.await?;
// A `Vec` works the same way.
let tags = vec!["rust".to_string(), "toasty".to_string()];
let article = toasty::create!(Article {
title: "Hello",
tags: tags,
scores: Vec::<i64>::new(),
})
.exec(&mut db)
.await?;
Ok(())
}
}
With the create builder, the per-field setter accepts the same forms:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Article {
#[key]
#[auto]
id: u64,
title: String,
tags: Vec<String>,
scores: Vec<i64>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let article = Article::create()
.title("Hello")
.tags(["rust", "toasty"])
.scores(vec![1, 2, 3])
.exec(&mut db)
.await?;
Ok(())
}
}
The batch form of create! works as well:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Article {
#[key]
#[auto]
id: u64,
title: String,
tags: Vec<String>,
scores: Vec<i64>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
toasty::create!(Article::[
{ title: "First", tags: ["rust"], scores: [1] },
{ title: "Second", tags: ["toasty", "orm"], scores: [2, 3] },
])
.exec(&mut db)
.await?;
Ok(())
}
}
Querying
A path to a Vec<scalar> field exposes array predicates:
| Method | Meaning |
|---|---|
.contains(value) | The array contains value. |
.is_superset(values) | The array contains every element of values. |
.intersects(values) | The array shares at least one element with values. |
.len() | The array’s length, as Expr<i64>. |
.is_empty() | The array is empty. |
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Article {
#[key]
#[auto]
id: u64,
title: String,
tags: Vec<String>,
scores: Vec<i64>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Articles tagged "rust".
let tagged = Article::filter(Article::fields().tags().contains("rust"))
.exec(&mut db)
.await?;
// Articles tagged with both "rust" and "orm".
let both = Article::filter(
Article::fields().tags().is_superset(["rust", "orm"]),
)
.exec(&mut db)
.await?;
// Articles sharing at least one tag with this set.
let related = Article::filter(
Article::fields().tags().intersects(["rust", "toasty"]),
)
.exec(&mut db)
.await?;
// Articles with more than three tags.
let many = Article::filter(Article::fields().tags().len().gt(3))
.exec(&mut db)
.await?;
// Articles with no tags.
let untagged = Article::filter(Article::fields().tags().is_empty())
.exec(&mut db)
.await?;
Ok(())
}
}
.len() produces an Expr<i64> rather than a boolean, so pair it with
a comparison (.gt(), .eq(), …) to form a predicate.
These predicates lower to PostgreSQL-specific operators (@>, &&,
= ANY(col), cardinality). On document-backed drivers the engine
substitutes equivalent JSON or list operations. A few carry
backend-specific restrictions — see the per-database pages (for
example, DynamoDB, where is_superset and
intersects require a literal right-hand side).
Updating
Replacing the whole list
Passing a list to the field setter replaces the entire value:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Article {
#[key]
#[auto]
id: u64,
title: String,
tags: Vec<String>,
scores: Vec<i64>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let mut article = toasty::create!(Article {
title: "Hello",
tags: ["rust"],
scores: [1],
}).exec(&mut db).await?;
article.update()
.tags(["x", "y", "z"])
.exec(&mut db)
.await?;
Ok(())
}
}
toasty::stmt::set(value) is the explicit form of the same whole-value
replacement, useful when building an assignment programmatically.
Incremental mutations
For changes relative to the stored value, the toasty::stmt module
provides builders. Each produces one update statement and refreshes the
in-memory field after .exec():
| Function | What it does |
|---|---|
stmt::push(value) | Append one element. |
stmt::extend(iter) | Append every element of an iterator, in order. |
stmt::pop() | Remove the last element. |
stmt::remove(value) | Remove every element equal to the value. |
stmt::remove_at(idx) | Remove the element at a 0-based index. |
stmt::clear() | Replace the field with an empty list. |
stmt::apply([ops]) | Apply several of the above in order, in one statement. |
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Article {
#[key]
#[auto]
id: u64,
title: String,
tags: Vec<String>,
scores: Vec<i64>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let mut article = toasty::create!(Article {
title: "Hello",
tags: ["rust"],
scores: [1],
}).exec(&mut db).await?;
// Append one element.
article.update()
.tags(toasty::stmt::push("toasty"))
.exec(&mut db)
.await?;
// Append several. `stmt::extend` of an empty iterator is a no-op.
article.update()
.tags(toasty::stmt::extend(["orm", "async"]))
.exec(&mut db)
.await?;
// Remove the last element.
article.update()
.tags(toasty::stmt::pop())
.exec(&mut db)
.await?;
// Remove every element equal to "orm".
article.update()
.tags(toasty::stmt::remove("orm"))
.exec(&mut db)
.await?;
// Remove the element at index 0.
article.update()
.tags(toasty::stmt::remove_at(0usize))
.exec(&mut db)
.await?;
// Remove every element.
article.update()
.tags(toasty::stmt::clear())
.exec(&mut db)
.await?;
// Combine operations into one statement, applied in order.
article.update()
.tags(toasty::stmt::apply([
toasty::stmt::push("rust"),
toasty::stmt::push("toasty"),
]))
.exec(&mut db)
.await?;
Ok(())
}
}
Each operation is atomic against the existing column value — the database applies it to whatever the row currently holds, not to the in-memory snapshot. Concurrent writers can still interleave between operations, but no single operation reads then writes in a way another writer can split.
pop on an empty list, remove of an absent value, and remove_at
past the end of the list are all no-ops rather than errors. remove
deletes every matching element, not just the first.
stmt::apply runs each operation in order, against the result of the
previous one. An apply is valid only where every operation it
contains is valid on the target backend.
After .exec(), the in-memory field reflects the new value.
Driver support
Defining a Vec<scalar> field, creating and reading rows, and the
array query predicates work on every built-in driver. Whole-value
replacement and the appending builders — set, push, extend,
clear — also work everywhere.
The element-removal builders are narrower:
| Operation | PostgreSQL | MySQL | SQLite | DynamoDB |
|---|---|---|---|---|
| Define field, create, read | ✓ | ✓ | ✓ | ✓ |
contains, len, is_empty | ✓ | ✓ | ✓ | ✓ |
is_superset, intersects | ✓ | ✓ | ✓ | literal right-hand side only |
Replace, set, push, extend, clear | ✓ | ✓ | ✓ | ✓ |
pop, remove, remove_at | ✓ | — | — | — |
pop, remove, and remove_at currently require PostgreSQL, where
they lower to array_remove and array slicing. On the other drivers
they return an error. See the per-database pages for the storage and
operator details specific to each backend.
Relationships
Models rarely exist in isolation. A blog has users, posts, and comments. An e-commerce site has customers, orders, and products. Relationships define how these models connect to each other.
In Toasty, you declare relationships on your model structs using attributes like
#[belongs_to], #[has_many], and #[has_one]. Toasty uses these declarations
to generate methods for traversing between models, creating related records, and
maintaining data consistency when records are deleted or updated.
How relationships work at the database level
Relationships are implemented through foreign keys — a column in one table
that stores the primary key of a row in another table. For example, a posts
table has a user_id column that references the users table:
users posts
┌────┬───────┐ ┌────┬──────────┬─────────┐
│ id │ name │ │ id │ title │ user_id │
├────┼───────┤ ├────┼──────────┼─────────┤
│ 1 │ Alice │◄─────────│ 1 │ Hello │ 1 │
│ 2 │ Bob │◄────┐ │ 2 │ World │ 1 │
└────┴───────┘ └────│ 3 │ Goodbye │ 2 │
└────┴──────────┴─────────┘
The posts table holds the foreign key (user_id). Each post points to exactly
one user. A user can have many posts.
This single pattern — a foreign key column in one table referencing the primary key of another — underlies all three relationship types in Toasty.
Relationship types
Toasty supports three relationship types. They differ in how many records each side of the relationship holds, and which model contains the foreign key.
| Type | Foreign key on | Parent has | Child has | Example |
|---|---|---|---|---|
| BelongsTo | This model | — | One parent | A post belongs to a user |
| HasMany | Other model | Many children | — | A user has many posts |
| HasOne | Other model | One child | — | A user has one profile |
Which model gets which attribute?
The model whose table contains the foreign key column declares
#[belongs_to]. The model on the other side declares #[has_many] or
#[has_one].
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
// User's table has no FK — declares has_many
#[has_many]
posts: toasty::HasMany<Post>,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
// Post's table has the FK — declares belongs_to
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
title: String,
}
}
Relationship pairs
Most relationships are bidirectional — declared on both models. The User above
has #[has_many] posts and the Post has #[belongs_to] user. Toasty matches
these two sides into a pair automatically by looking at the model types —
field names do not factor into the matching. If there is ambiguity (for example,
a model with two BelongsTo relations pointing to the same parent type), use
pair to link them explicitly:
// On User: the child's relation field is named "owner", not "user"
#[has_many(pair = owner)]
posts: toasty::HasMany<Post>,
You can define one-sided relationships with only #[belongs_to] on the child
and no corresponding #[has_many] or #[has_one] on the parent. This is useful
when you need to navigate from child to parent but not the reverse. The opposite
is not allowed — a #[has_many] or #[has_one] field always requires a
matching #[belongs_to] on the target model, because Toasty needs the foreign
key definition to know how the models connect.
Required vs optional relationships
The nullability of the foreign key field controls whether the relationship is required or optional.
Required: non-nullable foreign key
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
Every post must have a user. The user_id column is NOT NULL in the database.
Optional: nullable foreign key
#[index]
user_id: Option<u64>,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<Option<User>>,
A post can exist without a user. The user_id column allows NULL.
This distinction matters beyond just data modeling — it determines what happens when a relationship is broken, as the next section explains.
Data consistency on delete and unlink
When you delete a parent record or disassociate a child, Toasty automatically maintains consistency based on the foreign key’s nullability:
| Action | FK is required (u64) | FK is optional (Option<u64>) |
|---|---|---|
| Delete parent | Child is deleted | Child stays, FK set to NULL |
Unset relation (e.g., update().profile(None)) | Child is deleted | Child stays, FK set to NULL |
| Delete child | Parent is unaffected | Parent is unaffected |
The logic: a required foreign key means the child cannot exist without its
parent. If the parent goes away, the child must go too. An optional foreign key
means the child can stand on its own, so Toasty sets the FK to NULL and leaves
the child in place.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_many]
posts: toasty::HasMany<Post>,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User {
name: "Alice",
posts: [{ title: "Hello" }],
})
.exec(&mut db)
.await?;
let posts = user.posts().exec(&mut db).await?;
assert_eq!(1, posts.len());
// user_id is required (u64), so deleting the user deletes the post too
user.delete().exec(&mut db).await?;
assert!(Post::get_by_id(&mut db, &posts[0].id).await.is_err());
Ok(())
}
}
If user_id were Option<u64> instead, the post would survive the deletion
with user_id set to None.
This behavior is applied at the application level by Toasty’s query engine, not by database-level foreign key constraints. Toasty inspects the schema and generates the appropriate cascade deletes or null-setting updates automatically.
Choosing the right relationship type
| You want to express… | Use | FK goes on |
|---|---|---|
| A post has one author | Post → BelongsTo<User> + User → HasMany<Post> | posts.user_id |
| A user has one profile | User → HasOne<Profile> + Profile → BelongsTo<User> | profiles.user_id |
| A comment belongs to a post | Comment → BelongsTo<Post> + Post → HasMany<Comment> | comments.post_id |
When deciding between HasOne and HasMany, ask: “Can the parent have more
than one?” If yes, use HasMany. If exactly one (or zero), use HasOne. The
foreign key placement is the same either way — it always goes on the child.
When deciding between HasOne and BelongsTo for a one-to-one relationship,
ask: “Which model is the dependent one — the one that doesn’t make sense without
the other?” Put the FK on the dependent model with BelongsTo, and declare
HasOne on the independent model.
Composite foreign keys
When a parent model has a composite primary key, the foreign key on the child
spans the same set of columns. Pass arrays to key and references to list
each column:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
#[key(org_id, id)]
struct Team {
org_id: u64,
id: u64,
#[has_many]
members: toasty::HasMany<Member>,
}
#[derive(Debug, toasty::Model)]
#[index(org_id, team_id)]
struct Member {
#[key]
#[auto]
id: u64,
org_id: u64,
team_id: u64,
#[belongs_to(key = [org_id, team_id], references = [org_id, id])]
team: toasty::BelongsTo<Team>,
}
}
The first field in key pairs with the first field in references, the second
with the second, and so on, so the two arrays must have the same length. With
a single-column foreign key, the arrays can be omitted: key = user_id, references = id is equivalent to key = [user_id], references = [id].
The foreign key fields need a model-level composite index that covers them in
order — #[index(org_id, team_id)] on the struct, not a separate #[index]
on each field. Two single-column indexes don’t compose into a covering index
for a composite foreign key. Without one, schema verification rejects the
model and suggests the exact attribute to add.
What the following chapters cover
Each relationship type has its own chapter with full details on definition, querying, creating, and updating:
- BelongsTo — defining foreign keys, accessing the parent, setting the relation on create
- HasMany — querying children, creating through the relation, inserting and removing, scoped queries
- HasOne — required vs optional, creating and updating the child, replace and unset behavior
- Preloading Associations — avoiding extra
queries by loading relations upfront with
.include()
BelongsTo
A BelongsTo relationship connects a child model to a parent model through a
foreign key. The child stores the parent’s ID in one of its own fields.
Defining a BelongsTo relationship
A BelongsTo relationship requires two things on the child model: a foreign key
field and a BelongsTo<T> relation field. The #[belongs_to] attribute tells
Toasty which field holds the foreign key and which field on the parent it
references.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_many]
posts: toasty::HasMany<Post>,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
title: String,
}
}
The user_id field is the foreign key — it stores the id of the associated
User. The #[belongs_to(key = user_id, references = id)] attribute tells
Toasty that user_id on Post maps to id on User.
The foreign key field should have #[index] so that Toasty can efficiently look
up posts by user. In the database, this creates:
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL
);
CREATE INDEX idx_posts_user_id ON posts (user_id);
The parent model (User) typically declares a #[has_many] field pointing back
at the child. See HasMany for details.
Optional BelongsTo
If a child does not always have a parent, make the foreign key Option<T> and
wrap the relation type in Option:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
#[index]
user_id: Option<u64>,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<Option<User>>,
title: String,
}
}
The user_id column is now nullable. A post can exist without a user.
Accessing the related record
Call the relation method on the child instance to get the parent. The method name matches the relation field name.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_many]
posts: toasty::HasMany<Post>,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let post = toasty::create!(Post { title: "Hello", user_id: 1 }).exec(&mut db).await?;
// Load the associated user from the database
let user = post.user().exec(&mut db).await?;
println!("Author: {}", user.name);
Ok(())
}
}
For an optional BelongsTo, .get() returns Option<User>:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
#[index]
user_id: Option<u64>,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<Option<User>>,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let post = toasty::create!(Post { title: "Hello" }).exec(&mut db).await?;
match post.user().exec(&mut db).await? {
Some(user) => println!("Author: {}", user.name),
None => println!("No author"),
}
Ok(())
}
}
Each call to .user().exec() executes a database query. To avoid repeated
queries, use preloading.
Setting the relation on create
You can associate a child with a parent in two ways: by passing a reference to the parent, or by setting the foreign key directly.
By parent reference
Pass a reference to an existing parent record:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_many]
posts: toasty::HasMany<Post>,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User { name: "Alice" }).exec(&mut db).await?;
let post = toasty::create!(Post {
title: "Hello World",
user: &user,
})
.exec(&mut db)
.await?;
assert_eq!(post.user_id, user.id);
Ok(())
}
}
Toasty extracts the parent’s primary key and sets the foreign key field automatically.
By foreign key value
Set the foreign key field directly:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_many]
posts: toasty::HasMany<Post>,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User { name: "Alice" }).exec(&mut db).await?;
let post = toasty::create!(Post {
title: "Hello World",
user_id: user.id,
})
.exec(&mut db)
.await?;
Ok(())
}
}
This is useful when you have the parent’s ID but not the full record.
Customizing the pair name
By default, Toasty matches a #[has_many] field on the parent to a
#[belongs_to] field on the child by the singularized parent model name. If the
child’s relation field has a different name, use #[has_many(pair = field_name)]
on the parent:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
#[has_many(pair = owner)]
todos: toasty::HasMany<Todo>,
}
#[derive(Debug, toasty::Model)]
struct Todo {
#[key]
#[auto]
id: u64,
#[index]
owner_id: u64,
#[belongs_to(key = owner_id, references = id)]
owner: toasty::BelongsTo<User>,
title: String,
}
}
Here the child’s relation field is named owner instead of user, so the
parent specifies pair = owner to establish the connection.
What gets generated
For a Post model with #[belongs_to] user: BelongsTo<User>, Toasty generates:
| Method | Returns | Description |
|---|---|---|
post.user() | Relation accessor | Returns an accessor for the associated user |
.get(&mut db) | Result<User> | Loads the associated user from the database |
toasty::create!(Post { user: &user }) | Create builder | Sets the foreign key from a parent reference |
toasty::create!(Post { user_id: id }) | Create builder | Sets the foreign key directly |
Post::fields().user() | Field path | Used with .include() for preloading |
HasMany
A HasMany relationship connects a parent model to multiple child records. The
parent declares a HasMany<T> field, and the child stores a foreign key
pointing back to the parent via BelongsTo.
Defining a HasMany relationship
Add a #[has_many] field of type HasMany<T> on the parent model. The child
model must have a corresponding #[belongs_to] field.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_many]
posts: toasty::HasMany<Post>,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
title: String,
}
}
The #[has_many] attribute does not add any columns to the parent’s table. The
relationship is stored entirely in the child’s foreign key column (user_id).
Querying children
Call the relation method on a parent instance to get an accessor for its children:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_many]
posts: toasty::HasMany<Post>,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User { name: "Alice" }).exec(&mut db).await?;
let posts: Vec<Post> = user.posts().exec(&mut db).await?;
for post in &posts {
println!("{}", post.title);
}
Ok(())
}
}
The generated SQL is:
SELECT * FROM posts WHERE user_id = ?;
All queries through the relation accessor are automatically scoped to the
parent. user.posts() only returns posts belonging to that user.
Creating through the relation
Create a child record through the parent’s relation accessor. Toasty automatically sets the foreign key:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_many]
posts: toasty::HasMany<Post>,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User { name: "Alice" }).exec(&mut db).await?;
let post = toasty::create!(in user.posts() { title: "Hello World" })
.exec(&mut db)
.await?;
assert_eq!(post.user_id, user.id);
Ok(())
}
}
You don’t need to set user_id — Toasty fills it in from the parent.
Nested creation
Create a parent and its children in a single call using the singular form of the relation name on the create builder:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_many]
posts: toasty::HasMany<Post>,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User {
name: "Alice",
posts: [{ title: "First post" }, { title: "Second post" }],
})
.exec(&mut db)
.await?;
let posts = user.posts().exec(&mut db).await?;
assert_eq!(2, posts.len());
Ok(())
}
}
Toasty creates the user first, then creates each post with the user’s ID as the foreign key.
Inserting and removing children
Use .insert() and .remove() to link and unlink existing records.
Inserting
Associate an existing child record with a parent:
let post = toasty::create!(Post { title: "Orphan post", user_id: 0 }).exec(&mut db).await?;
// Associate the post with a user
user.posts().insert(&mut db, &post).await?;
This updates the child’s foreign key to point to the parent. You can also insert multiple records at once:
user.posts().insert(&mut db, &[post1, post2, post3]).await?;
If the child is already associated with a different parent, .insert() moves it
to the new parent.
Removing
Disassociate a child from a parent:
user.posts().remove(&mut db, &post).await?;
What happens to the child depends on whether the foreign key is required or optional:
| Foreign key type | Effect of .remove() |
|---|---|
Required (user_id: u64) | Deletes the child record |
Optional (user_id: Option<u64>) | Sets the foreign key to NULL |
When the foreign key is required, the child cannot exist without a parent, so Toasty deletes it. When the foreign key is optional, the child remains in the database with a null foreign key.
Scoped queries
The relation accessor supports scoped queries — filtering, updating, and deleting within the parent’s children.
Filtering with .filter()
// Find posts with a specific condition
let drafts = user
.posts()
.filter(Post::fields().published().eq(false))
.exec(&mut db)
.await?;
Looking up by ID within the scope
let post = user.posts().get_by_id(&mut db, &post_id).await?;
This only returns the post if it belongs to the user. If the post exists but belongs to a different user, this returns an error.
Updating through the scope
user.posts()
.filter_by_id(post_id)
.update()
.title("New title")
.exec(&mut db)
.await?;
Deleting through the scope
user.posts()
.filter_by_id(post_id)
.delete()
.exec(&mut db)
.await?;
Filtering parents by children
You can filter parent records based on conditions on their children using
.any() on the relation field:
// Find users who have at least one published post
let users = User::filter(
User::fields()
.posts()
.any(Post::fields().published().eq(true))
)
.exec(&mut db)
.await?;
What gets generated
For a User model with #[has_many] posts: HasMany<Post>, Toasty generates:
On the parent instance:
| Method | Returns | Description |
|---|---|---|
user.posts() | Relation accessor | Accessor scoped to this user’s posts |
.exec(&mut db) | Result<Vec<Post>> | All posts belonging to this user |
.create() | Create builder | Create a post with the foreign key pre-filled |
.get_by_id(&mut db, &id) | Result<Post> | Get a post by ID within the scope |
.filter(expr) | Query builder | Filter posts within the scope |
.insert(&mut db, &post) | Result<()> | Associate an existing post with the user |
.remove(&mut db, &post) | Result<()> | Disassociate a post from the user |
On the create builder:
| Method | Description |
|---|---|
toasty::create!(User { posts: [{ ... }] }) | Add children to create alongside the parent |
On the fields accessor:
| Method | Description |
|---|---|
User::fields().posts() | Field path for preloading and filtering |
User::fields().posts().any(expr) | Filter parents by child conditions |
HasOne
A HasOne relationship connects a parent model to a single child record. Like
HasMany, the foreign key lives on the child model, but
HasOne enforces that at most one child exists per parent.
Defining a HasOne relationship
Add a #[has_one] field of type HasOne<T> on the parent model. The child
model must have a corresponding #[belongs_to] field with a #[unique] foreign
key (since each parent maps to at most one child):
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_one]
profile: toasty::HasOne<Option<Profile>>,
}
#[derive(Debug, toasty::Model)]
struct Profile {
#[key]
#[auto]
id: u64,
#[unique]
user_id: Option<u64>,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<Option<User>>,
bio: String,
}
}
The child’s foreign key has #[unique] instead of #[index], which guarantees
that only one profile can reference a given user. In the database:
CREATE TABLE profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
bio TEXT NOT NULL
);
CREATE UNIQUE INDEX idx_profiles_user_id ON profiles (user_id);
Optional vs required HasOne
The type parameter on HasOne controls whether the parent must have a child.
Optional: HasOne<Option<Profile>>
The parent may or may not have a child. Creating a parent without a child is allowed:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_one]
profile: toasty::HasOne<Option<Profile>>,
}
#[derive(Debug, toasty::Model)]
struct Profile {
#[key]
#[auto]
id: u64,
#[unique]
user_id: Option<u64>,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<Option<User>>,
bio: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// A user without a profile — this is fine
let user = toasty::create!(User { name: "Alice" }).exec(&mut db).await?;
assert!(user.profile().exec(&mut db).await?.is_none());
Ok(())
}
}
Required: HasOne<Profile>
The parent must have a child. Creating a parent requires providing a child:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
#[has_one]
profile: toasty::HasOne<Profile>,
}
#[derive(Debug, toasty::Model)]
struct Profile {
#[key]
#[auto]
id: u64,
#[unique]
user_id: Option<u64>,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<Option<User>>,
bio: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Must provide a profile when creating the user
let user = toasty::create!(User {
profile: { bio: "Hello" },
})
.exec(&mut db)
.await?;
let profile = user.profile().exec(&mut db).await?;
assert_eq!(profile.bio, "Hello");
Ok(())
}
}
Accessing the related record
Call the relation method on the parent instance to load the child:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_one]
profile: toasty::HasOne<Option<Profile>>,
}
#[derive(Debug, toasty::Model)]
struct Profile {
#[key]
#[auto]
id: u64,
#[unique]
user_id: Option<u64>,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<Option<User>>,
bio: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User { name: "Alice", profile: { bio: "A person" } })
.exec(&mut db)
.await?;
// For HasOne<Option<Profile>> — returns Option<Profile>
let profile = user.profile().exec(&mut db).await?;
if let Some(profile) = profile {
println!("Bio: {}", profile.bio);
}
Ok(())
}
}
For a required HasOne<Profile>, .get() returns Profile directly (not
wrapped in Option).
Each call to .profile().exec() executes a database query. To avoid this, use
preloading.
Creating through the relation
Create a child for an existing parent through the relation accessor:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_one]
profile: toasty::HasOne<Option<Profile>>,
}
#[derive(Debug, toasty::Model)]
struct Profile {
#[key]
#[auto]
id: u64,
#[unique]
user_id: Option<u64>,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<Option<User>>,
bio: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User { name: "Alice" }).exec(&mut db).await?;
let profile = toasty::create!(in user.profile() { bio: "A person" })
.exec(&mut db)
.await?;
assert_eq!(profile.user_id, Some(user.id));
Ok(())
}
}
Or create the parent and child together:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_one]
profile: toasty::HasOne<Option<Profile>>,
}
#[derive(Debug, toasty::Model)]
struct Profile {
#[key]
#[auto]
id: u64,
#[unique]
user_id: Option<u64>,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<Option<User>>,
bio: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User {
name: "Alice",
profile: { bio: "A person" },
})
.exec(&mut db)
.await?;
Ok(())
}
}
Updating the relation
Replacing with a new child
Create a new child and associate it with the parent in an update:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_one]
profile: toasty::HasOne<Option<Profile>>,
}
#[derive(Debug, toasty::Model)]
struct Profile {
#[key]
#[auto]
id: u64,
#[unique]
user_id: Option<u64>,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<Option<User>>,
bio: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let mut user = toasty::create!(User { name: "Alice", profile: { bio: "Old bio" } })
.exec(&mut db)
.await?;
user.update()
.profile(toasty::create!(Profile { bio: "New bio" }))
.exec(&mut db)
.await?;
let profile = user.profile().exec(&mut db).await?.unwrap();
assert_eq!(profile.bio, "New bio");
Ok(())
}
}
Associating an existing child
Pass a reference to an existing child record:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
#[has_one]
profile: toasty::HasOne<Option<Profile>>,
}
#[derive(Debug, toasty::Model)]
struct Profile {
#[key]
#[auto]
id: u64,
#[unique]
user_id: Option<u64>,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<Option<User>>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User {}).exec(&mut db).await?;
let profile = toasty::create!(Profile {}).exec(&mut db).await?;
User::filter_by_id(user.id)
.update()
.profile(&profile)
.exec(&mut db)
.await?;
Ok(())
}
}
Unsetting the relation
For an optional HasOne, pass None to disassociate the child:
user.update().profile(None).exec(&mut db).await?;
// The profile no longer belongs to the user
assert!(user.profile().exec(&mut db).await?.is_none());
What happens to the child when you unset the relation depends on the child’s foreign key:
| Child’s foreign key type | Effect of unsetting |
|---|---|
Required (user_id: u64) | Deletes the child record |
Optional (user_id: Option<u64>) | Sets the foreign key to NULL |
Deleting behavior
When you delete a parent, the behavior depends on the child’s foreign key type:
- Required foreign key (
user_id: u64): Toasty deletes the child record, since it cannot exist without a parent. - Optional foreign key (
user_id: Option<u64>): Toasty sets the foreign key toNULL, leaving the child record in place.
What gets generated
For a User model with #[has_one] profile: HasOne<Option<Profile>>, Toasty
generates:
| Method | Returns | Description |
|---|---|---|
user.profile() | Relation accessor | Accessor for the associated profile |
.get(&mut db) | Result<Option<Profile>> | Load the associated profile |
.create() | Create builder | Create a profile with the foreign key pre-filled |
toasty::create!(User { profile: { ... } }) | Create builder | Associate a profile on creation |
user.update().profile(...) | Update builder | Replace or associate a profile |
user.update().profile(None) | Update builder | Disassociate the profile |
User::fields().profile() | Field path | Used with .include() for preloading |
Preloading Associations
Preloading (also called eager loading) loads related records alongside the main query, avoiding extra database round-trips when you access associations.
Async means a query
Toasty’s API follows one rule for associations: if you .await it, it hits
the database. The two ways to access
an association make this visible in the code:
user.posts().exec(&mut db).await?— async, executes a query.user.posts.get()— not async, reads already-loaded data in memory.
Because .get() is a plain (non-async) method, the compiler won’t let you
confuse the two. You can scan any code path for .await to know exactly where
database round-trips happen. This makes N+1 problems easy to spot and impossible
to introduce by accident.
The N+1 problem
Without preloading, accessing a relation on each record in a list causes one query per record:
// 1 query: load all users
let users = User::all().exec(&mut db).await?;
for user in &users {
// N queries: one per user to load their posts
let posts = user.posts().exec(&mut db).await?;
println!("{}: {} posts", user.name, posts.len());
}
If there are 100 users, this executes 101 queries. The .await on each
user.posts().exec() call is a clear signal that a query runs on every
iteration. Preloading reduces this to a fixed number of queries regardless of
how many records you load.
Using .include()
Add .include() to a query to preload a relation. Pass the field path from the
model’s fields() accessor:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_many]
posts: toasty::HasMany<Post>,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User { name: "Alice", posts: [{ title: "Hello" }] })
.exec(&mut db)
.await?;
let user = User::filter_by_id(user.id)
.include(User::fields().posts())
.get(&mut db)
.await?;
// Access preloaded posts — .get() is not async, so no query happens
let posts: &[Post] = user.posts.get();
assert_eq!(1, posts.len());
Ok(())
}
}
The .include() call tells Toasty to load the associated posts as part of the
query. After preloading, access the data through the field directly with
user.posts.get() — a synchronous call that reads from memory. Compare this
with user.posts().exec(&mut db).await?, which is async and always runs a
query. The presence or absence of .await tells you whether code touches the
database.
Preloaded vs unloaded access
There are two ways to access a relation, depending on whether it was preloaded:
| Access pattern | Async | When to use | Queries |
|---|---|---|---|
user.posts().exec(&mut db).await? | Yes | Relation was not preloaded | Executes a query |
user.posts.get() | No | Relation was preloaded with .include() | No query |
Calling .get() on an unloaded relation panics. Only use .get() when you know
the relation was preloaded.
Preloading BelongsTo
Preload a parent record from the child side:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_many]
posts: toasty::HasMany<Post>,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User { name: "Alice", posts: [{ title: "Hello" }] })
.exec(&mut db)
.await?;
let post_id = user.posts().exec(&mut db).await?[0].id;
let post = Post::filter_by_id(post_id)
.include(Post::fields().user())
.get(&mut db)
.await?;
// Access the preloaded user
let user: &User = post.user.get();
assert_eq!("Alice", user.name);
Ok(())
}
}
Preloading HasOne
Preload a single child record from the parent side:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_one]
profile: toasty::HasOne<Option<Profile>>,
}
#[derive(Debug, toasty::Model)]
struct Profile {
#[key]
#[auto]
id: u64,
bio: String,
#[unique]
user_id: Option<u64>,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<Option<User>>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User { name: "Alice", profile: { bio: "A person" } })
.exec(&mut db)
.await?;
let user = User::filter_by_id(user.id)
.include(User::fields().profile())
.get(&mut db)
.await?;
// Access the preloaded profile
let profile = user.profile.get().as_ref().unwrap();
assert_eq!("A person", profile.bio);
Ok(())
}
}
If no related record exists, the preloaded value is None rather than causing a
panic:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_one]
profile: toasty::HasOne<Option<Profile>>,
}
#[derive(Debug, toasty::Model)]
struct Profile {
#[key]
#[auto]
id: u64,
bio: String,
#[unique]
user_id: Option<u64>,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<Option<User>>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let user = toasty::create!(User { name: "No Profile" }).exec(&mut db).await?;
let user = User::filter_by_id(user.id)
.include(User::fields().profile())
.get(&mut db)
.await?;
// Preloaded and empty — .get() returns None, not a panic
assert!(user.profile.get().is_none());
Ok(())
}
}
Multiple includes
Chain multiple .include() calls to preload several relations in one query:
let user = User::filter_by_id(user_id)
.include(User::fields().posts())
.include(User::fields().comments())
.get(&mut db)
.await?;
// Both are preloaded
let posts: &[Post] = user.posts.get();
let comments: &[Comment] = user.comments.get();
You can mix relation types — preload HasMany, HasOne, and BelongsTo relations in the same query:
let user = User::filter_by_id(user_id)
.include(User::fields().profile()) // HasOne
.include(User::fields().posts()) // HasMany
.get(&mut db)
.await?;
Preloading with collection queries
.include() works with .exec() (collection queries), not just .get()
(single record). All records in the result have their relations preloaded:
let users = User::all()
.include(User::fields().posts())
.exec(&mut db)
.await?;
for user in &users {
// .get() is not async — no query per user, no N+1
let posts: &[Post] = user.posts.get();
println!("{}: {} posts", user.name, posts.len());
}
Summary
| Syntax | Description |
|---|---|
.include(Model::fields().relation()) | Preload a relation in the query |
model.relation.get() | Access preloaded HasMany data (returns &[T]) |
model.relation.get() | Access preloaded BelongsTo data (returns &T) |
model.relation.get() | Access preloaded HasOne data (returns &T or &Option<T>) |
model.relation.is_unloaded() | Check if a relation was preloaded |
Filtering with Expressions
The filter_by_* methods generated for indexed fields cover simple equality
lookups. For anything else — comparisons, combining conditions with AND/OR,
checking for null — use Model::filter() with field expressions.
| Expression | Description | Database equivalent |
|---|---|---|
.eq(value) | Equal | = value |
.ne(value) | Not equal | != value |
.gt(value) | Greater than | > value |
.ge(value) | Greater than or equal | >= value |
.lt(value) | Less than | < value |
.le(value) | Less than or equal | <= value |
.in_list([...]) | Value in list | IN (...) |
.is_none() | Null check (Option fields) | IS NULL |
.is_some() | Not-null check (Option fields) | IS NOT NULL |
.starts_with(prefix) | Prefix match | begins_with(field, prefix) / LIKE 'prefix%' |
.like(pattern) | SQL pattern match | LIKE pattern |
.ilike(pattern) | Case-insensitive SQL pattern match | ILIKE pattern |
.and(expr) | Both conditions true | AND |
.or(expr) | Either condition true | OR |
.not() / !expr | Negate condition | NOT |
.any(expr) | Any related record matches (HasMany) | IN (SELECT ...) |
.all(expr) | Every related record matches (HasMany) | NOT IN (SELECT ... WHERE NOT ...) |
Field paths
Every model has a fields() method that returns typed accessors for each field.
These accessors produce field paths that you pass to comparison methods:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[index]
country: String,
}
fn __example() {
// User::fields() returns a struct with one method per field
let name_path = User::fields().name();
let country_path = User::fields().country();
}
}
Field paths are the building blocks for filter expressions. Call a comparison
method on a path to get an Expr<bool>, then pass that expression to
Model::filter().
Equality and inequality
.eq() tests whether a field equals a value. .ne() tests whether it does not:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[index]
country: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Find users named "Alice"
let users = User::filter(User::fields().name().eq("Alice"))
.exec(&mut db)
.await?;
// Find users not from the US
let users = User::filter(User::fields().country().ne("US"))
.exec(&mut db)
.await?;
Ok(())
}
}
Ordering comparisons
Four methods compare field values by order:
| Method | Meaning |
|---|---|
.gt(value) | Greater than |
.ge(value) | Greater than or equal |
.lt(value) | Less than |
.le(value) | Less than or equal |
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Event {
#[key]
#[auto]
id: u64,
kind: String,
timestamp: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Events after timestamp 1000
let events = Event::filter(Event::fields().timestamp().gt(1000))
.exec(&mut db)
.await?;
// Events at or before timestamp 500
let events = Event::filter(Event::fields().timestamp().le(500))
.exec(&mut db)
.await?;
Ok(())
}
}
Membership with in_list
.in_list() tests whether a field’s value is in a given list, equivalent to
SQL’s IN clause:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[index]
country: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let users = User::filter(
User::fields().country().in_list(["US", "CA", "MX"]),
)
.exec(&mut db)
.await?;
Ok(())
}
}
Null checks
For Option<T> fields, use .is_none() and .is_some() to filter by whether
the value is null:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
bio: Option<String>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Users who have not set a bio
let users = User::filter(User::fields().bio().is_none())
.exec(&mut db)
.await?;
// Users who have set a bio
let users = User::filter(User::fields().bio().is_some())
.exec(&mut db)
.await?;
Ok(())
}
}
These methods are only available on paths to Option<T> fields. Calling
.is_none() on a non-optional field is a compile error.
Combining with AND
.and() combines two expressions so both must be true:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Event {
#[key]
#[auto]
id: u64,
kind: String,
timestamp: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let events = Event::filter(
Event::fields()
.kind()
.eq("info")
.and(Event::fields().timestamp().gt(1000)),
)
.exec(&mut db)
.await?;
Ok(())
}
}
Chain multiple .and() calls to add more conditions:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Event {
#[key]
#[auto]
id: u64,
kind: String,
timestamp: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let events = Event::filter(
Event::fields()
.kind()
.eq("info")
.and(Event::fields().timestamp().gt(1000))
.and(Event::fields().timestamp().lt(2000)),
)
.exec(&mut db)
.await?;
Ok(())
}
}
You can also add AND conditions by chaining .filter() on a query. Each
.filter() call adds another AND condition:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Event {
#[key]
#[auto]
id: u64,
kind: String,
timestamp: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Equivalent to the previous example
let events = Event::filter(Event::fields().kind().eq("info"))
.filter(Event::fields().timestamp().gt(1000))
.filter(Event::fields().timestamp().lt(2000))
.exec(&mut db)
.await?;
Ok(())
}
}
Combining with OR
.or() combines two expressions so at least one must be true:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
age: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Users named "Alice" or aged 35
let users = User::filter(
User::fields()
.name()
.eq("Alice")
.or(User::fields().age().eq(35)),
)
.exec(&mut db)
.await?;
Ok(())
}
}
Expressions evaluate left to right through method chaining. Each method wraps
everything before it. a.or(b).and(c) produces (a OR b) AND c:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
age: i64,
active: bool,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// (name = "Alice" OR age = 35) AND active = true
let users = User::filter(
User::fields()
.name()
.eq("Alice")
.or(User::fields().age().eq(35))
.and(User::fields().active().eq(true)),
)
.exec(&mut db)
.await?;
Ok(())
}
}
To group differently, build sub-expressions and pass them as arguments. Here,
a.or(b.and(c)) produces a OR (b AND c):
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
age: i64,
active: bool,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// name = "Alice" OR (age = 35 AND active = true)
let users = User::filter(
User::fields().name().eq("Alice").or(User::fields()
.age()
.eq(35)
.and(User::fields().active().eq(true))),
)
.exec(&mut db)
.await?;
Ok(())
}
}
Negation with NOT
.not() negates an expression. The ! operator works too:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
age: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Users not named "Alice"
let users = User::filter(User::fields().name().eq("Alice").not())
.exec(&mut db)
.await?;
// Same thing with the ! operator
let users = User::filter(!User::fields().name().eq("Alice"))
.exec(&mut db)
.await?;
Ok(())
}
}
NOT works on compound expressions too:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
age: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// NOT (name = "Alice" OR name = "Bob")
let users = User::filter(
!(User::fields()
.name()
.eq("Alice")
.or(User::fields().name().eq("Bob"))),
)
.exec(&mut db)
.await?;
Ok(())
}
}
String pattern matching
starts_with
.starts_with() tests whether a string field starts with the given prefix. It
works on all supported databases — SQL drivers translate it to LIKE 'prefix%',
and DynamoDB uses its native begins_with condition expression:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Find users whose name starts with "Al"
let users = User::filter(User::fields().name().starts_with("Al"))
.exec(&mut db)
.await?;
Ok(())
}
}
like
.like() tests a string field against a SQL LIKE pattern. % matches any
sequence of characters; _ matches any single character. This operator is
only supported on SQL databases (SQLite, PostgreSQL, MySQL). Calling it
against DynamoDB will panic at runtime.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Find users whose name matches a pattern
let users = User::filter(User::fields().name().like("Al%"))
.exec(&mut db)
.await?;
Ok(())
}
}
Prefer .starts_with() over .like("prefix%") when you only need a prefix
match — it works across all drivers.
ilike
.ilike() is the case-insensitive form of .like(). SQL-only. On
PostgreSQL it lowers to ILIKE; on SQLite and MySQL, plain LIKE is
already ASCII-case-insensitive, so the same query works.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Matches "Alice", "ALICIA", "alfred", and so on.
let users = User::filter(User::fields().name().ilike("al%".to_string()))
.exec(&mut db)
.await?;
Ok(())
}
}
Filtering on associations
A field path can traverse a relation. The path User::fields().profile()
refers to the user’s HasOne Profile; chaining .score() produces a
path to score on the related profile. Comparison methods on such a
path generate a subquery that filters the parent by the value of a child
field. Same syntax works through BelongsTo and through chains of
HasOne/BelongsTo (e.g., A::fields().b().c().name()). SQL-only.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_one]
profile: toasty::HasOne<Option<Profile>>,
}
#[derive(Debug, toasty::Model)]
struct Profile {
#[key]
#[auto]
id: u64,
score: i64,
#[unique]
user_id: Option<u64>,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<Option<User>>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Users whose profile has a score above 50
let users = User::filter(User::fields().profile().score().gt(50))
.exec(&mut db)
.await?;
Ok(())
}
}
any — at least one match
For HasMany relations, .any() tests whether at least one related record
matches a condition. This generates a subquery:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_many]
todos: toasty::HasMany<Todo>,
}
#[derive(Debug, toasty::Model)]
struct Todo {
#[key]
#[auto]
id: u64,
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
title: String,
complete: bool,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Find users who have at least one incomplete todo
let users = User::filter(
User::fields()
.todos()
.any(Todo::fields().complete().eq(false)),
)
.exec(&mut db)
.await?;
Ok(())
}
}
The path User::fields().todos() refers to the HasMany relation. Calling
.any() on it takes a filter expression on the child model (Todo) and
produces a filter expression on the parent (User).
all — every related record matches
.all() on a HasMany path is the universal counterpart to .any():
it tests whether every related record matches the filter. SQL-only.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[has_many]
todos: toasty::HasMany<Todo>,
}
#[derive(Debug, toasty::Model)]
struct Todo {
#[key]
#[auto]
id: u64,
#[index]
user_id: u64,
#[belongs_to(key = user_id, references = id)]
user: toasty::BelongsTo<User>,
complete: bool,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Users whose todos are all complete
let users = User::filter(
User::fields()
.todos()
.all(Todo::fields().complete().eq(true)),
)
.exec(&mut db)
.await?;
Ok(())
}
}
.all() is vacuously true for a parent with no related records — a
user with no todos matches todos().all(...) for any filter. This
mirrors Rust’s [].iter().all(...) semantics.
Sorting, Limits, and Pagination
Queries return results in an unspecified order by default. Use .order_by() to
sort results, .limit() and .offset() to restrict the result set, and
.paginate() to walk through results one page at a time.
Sorting with order_by
Call .order_by() on a query with a field path and a direction — .asc() for
ascending or .desc() for descending:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Item {
#[key]
#[auto]
id: u64,
#[index]
order: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Ascending: smallest order first
let items = Item::all()
.order_by(Item::fields().order().asc())
.exec(&mut db)
.await?;
// Descending: largest order first
let items = Item::all()
.order_by(Item::fields().order().desc())
.exec(&mut db)
.await?;
Ok(())
}
}
Item::fields().order() returns a field path. Calling .asc() or .desc() on
it produces an ordering expression that .order_by() accepts.
Sorting by multiple fields
Pass a tuple of ordering expressions to sort by several fields at once. Each field beyond the first acts as a tie-breaker for the ones before it:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
age: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Sort by age ascending; users with the same age are ordered by name descending
let users = User::all()
.order_by((
User::fields().age().asc(),
User::fields().name().desc(),
))
.exec(&mut db)
.await?;
Ok(())
}
}
Chained .order_by() calls behave the same way — each call appends its
expressions to the existing order rather than replacing them, so the two forms
below are equivalent:
q.order_by((User::fields().age().asc(), User::fields().name().desc()));
q.order_by(User::fields().age().asc())
.order_by(User::fields().name().desc());
Sorting works with filters too:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Item {
#[key]
#[auto]
id: u64,
#[index]
order: i64,
category: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let items = Item::filter(Item::fields().category().eq("books"))
.order_by(Item::fields().order().desc())
.exec(&mut db)
.await?;
Ok(())
}
}
Limiting results
.limit(n) caps the number of records returned:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Item {
#[key]
#[auto]
id: u64,
#[index]
order: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// At most 5 items
let items = Item::all().limit(5).exec(&mut db).await?;
Ok(())
}
}
If the query matches fewer records than the limit, all matching records are returned.
.limit(n) is an upper bound, not a guarantee. Toasty applies the limit to
the database query, but it may filter the returned rows further before
producing the final result set. When that happens, a query can return fewer
than n records even if more than n rows match the filter expression. Use
cursor-based pagination to walk every matching
record.
Combine .order_by() with .limit() to get the top or bottom N records:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Item {
#[key]
#[auto]
id: u64,
#[index]
order: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Top 7 items by order (highest first)
let items = Item::all()
.order_by(Item::fields().order().desc())
.limit(7)
.exec(&mut db)
.await?;
Ok(())
}
}
Offset
.offset(n) skips the first n results. It requires .limit() to be called
first:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Item {
#[key]
#[auto]
id: u64,
#[index]
order: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Skip the first 5, then return the next 7
let items = Item::all()
.order_by(Item::fields().order().asc())
.limit(7)
.offset(5)
.exec(&mut db)
.await?;
Ok(())
}
}
Limit and offset work for simple cases, but cursor-based pagination (below) is a better fit for paging through large result sets. Offset-based pagination gets slower as the offset increases because the database still reads and discards the skipped rows. It can also produce inconsistent results when rows are inserted or deleted between page fetches. See Markus Winand’s “No Offset” for an in-depth explanation.
Cursor-based pagination
.paginate(per_page) splits results into pages. It requires .order_by() and
returns a Page instead of a Vec:
#![allow(unused)]
fn main() {
use toasty::Model;
use toasty::stmt::Page;
#[derive(Debug, toasty::Model)]
struct Item {
#[key]
#[auto]
id: u64,
#[index]
order: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let page: Page<_> = Item::all()
.order_by(Item::fields().order().desc())
.paginate(10)
.exec(&mut db)
.await?;
// Access items in the page
for item in page.iter() {
println!("order: {}", item.order);
}
// Check how many items are in this page
println!("items: {}", page.len());
Ok(())
}
}
A Page dereferences to a slice, so you can index into it, iterate over it, and
call slice methods like .len() and .iter() directly.
per_page is an upper bound on the page size, not a guarantee. Toasty
applies it to the database query, but it may filter the returned rows further
before producing the page. A page can therefore contain fewer than per_page
items even when more results exist. Check .has_next() (or follow
.next() until it returns None) to detect the end of the result set rather
than relying on the size of any individual page.
Navigating pages
Page provides .next() and .prev() methods that fetch the next or previous
page. Both return Option<Page> — None when there are no more results in that
direction:
#![allow(unused)]
fn main() {
use toasty::Model;
use toasty::stmt::Page;
#[derive(Debug, toasty::Model)]
struct Item {
#[key]
#[auto]
id: u64,
#[index]
order: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let first_page: Page<_> = Item::all()
.order_by(Item::fields().order().asc())
.paginate(10)
.exec(&mut db)
.await?;
// Move to the next page
if let Some(second_page) = first_page.next(&mut db).await? {
println!("page 2 has {} items", second_page.len());
// Go back
if let Some(back) = second_page.prev(&mut db).await? {
println!("back to page 1: {} items", back.len());
}
}
Ok(())
}
}
Use .has_next() and .has_prev() to check whether more pages exist without
fetching them:
if page.has_next() {
let next = page.next(&mut db).await?.unwrap();
}
Walking all pages
To process every record in pages:
#![allow(unused)]
fn main() {
use toasty::Model;
use toasty::stmt::Page;
#[derive(Debug, toasty::Model)]
struct Item {
#[key]
#[auto]
id: u64,
#[index]
order: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let mut page: Page<_> = Item::all()
.order_by(Item::fields().order().asc())
.paginate(10)
.exec(&mut db)
.await?;
loop {
for item in page.iter() {
println!("order: {}", item.order);
}
match page.next(&mut db).await? {
Some(next) => page = next,
None => break,
}
}
Ok(())
}
}
Starting from a cursor position
Use .after() to start pagination after a specific value in the sort field:
#![allow(unused)]
fn main() {
use toasty::Model;
use toasty::stmt::Page;
#[derive(Debug, toasty::Model)]
struct Item {
#[key]
#[auto]
id: u64,
#[index]
order: i64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// Start after order=90 (descending), so the first item will be order=89
let page: Page<_> = Item::all()
.order_by(Item::fields().order().desc())
.paginate(10)
.after(90)
.exec(&mut db)
.await?;
Ok(())
}
}
The value passed to .after() corresponds to the field used in .order_by().
Method summary
Methods available on query builders:
| Method | Description |
|---|---|
.order_by(field.asc()) | Sort ascending by field |
.order_by(field.desc()) | Sort descending by field |
.order_by((a.asc(), b.desc())) | Sort by multiple fields; later fields are tie-breakers |
.limit(n) | Return at most n records |
.offset(n) | Skip first n records (requires .limit()) |
.paginate(per_page) | Cursor-based pagination (requires .order_by()) |
Methods available on Page:
| Method | Returns | Description |
|---|---|---|
.next(&mut db) | Result<Option<Page>> | Fetch next page |
.prev(&mut db) | Result<Option<Page>> | Fetch previous page |
.has_next() | bool | Whether a next page exists |
.has_prev() | bool | Whether a previous page exists |
.items | Vec<M> | The records in this page |
.len() | usize | Number of items (via Deref to slice) |
.iter() | iterator | Iterate items (via Deref to slice) |
Embedded Types
An embedded type is a struct or enum annotated with #[derive(toasty::Embed)].
Unlike models, embedded types do not get their own database table. Their fields
are stored inline in the parent model’s table.
Use embedded types to group related fields without creating a separate table.
Newtype structs
A newtype struct is a single-field tuple struct like struct Email(String).
Annotate it with #[derive(toasty::Embed)] to use it as a model field:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Embed)]
struct Email(String);
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
email: Email,
}
}
Unlike multi-field embedded structs, a newtype maps to a single column with the parent field’s name — no prefix is added:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL -- not "email_0"
);
Use newtypes to add type safety to primitive fields. An Email and a Username
are both strings, but the type system prevents mixing them up:
let user = toasty::create!(User {
name: "Alice",
email: Email("alice@example.com".into()),
})
.exec(&mut db)
.await?;
assert_eq!(user.email.0, "alice@example.com");
Newtypes support the same operations as primitive fields — filtering, updating,
#[key], #[unique], and #[index] all work:
// Filter by newtype field
let users = User::filter(User::fields().email().eq(Email("alice@example.com".into())))
.exec(&mut db)
.await?;
// Update a newtype field
user.update()
.email(Email("new@example.com".into()))
.exec(&mut db)
.await?;
A newtype can also be used as a primary key:
#[derive(Debug, toasty::Embed)]
struct UserId(String);
#[derive(Debug, toasty::Model)]
struct User {
#[key]
id: UserId,
name: String,
}
Newtype with #[unique] and #[index]
Place #[unique] or #[index] on the model field (not inside the newtype):
#[derive(Debug, toasty::Embed)]
struct Email(String);
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
#[unique]
email: Email,
}
This generates the same methods as a primitive unique field —
User::get_by_email(), User::filter_by_email(), etc.
Newtypes inside embedded structs
Newtypes can be nested inside multi-field embedded structs:
#[derive(Debug, toasty::Embed)]
struct ZipCode(String);
#[derive(Debug, toasty::Embed)]
struct Address {
city: String,
zip: ZipCode,
}
The ZipCode field inside Address produces a single column (address_zip,
not address_zip_0). Filtering works through the normal chained accessors:
let users = User::filter(User::fields().address().zip().eq(ZipCode("98101".into())))
.exec(&mut db)
.await?;
Embedded structs
Define a struct with #[derive(toasty::Embed)] and use it as a field in a
model:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Embed)]
struct Address {
street: String,
city: String,
}
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
address: Address,
}
}
Toasty flattens the embedded struct’s fields into the parent table as individual columns, prefixed with the field name:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
address_street TEXT NOT NULL,
address_city TEXT NOT NULL
);
The Address struct has no table of its own. Its street and city fields
become address_street and address_city columns in the users table.
Creating records with embedded structs
Set the embedded field on the create builder by passing an instance of the struct:
let user = toasty::create!(User {
name: "Alice",
address: Address {
street: "123 Main St".to_string(),
city: "Seattle".to_string(),
},
})
.exec(&mut db)
.await?;
Updating embedded fields
You can replace the entire embedded struct:
user.update()
.address(Address {
street: "456 Oak Ave".to_string(),
city: "Portland".to_string(),
})
.exec(&mut db)
.await?;
Or patch individual fields within the struct with stmt::patch:
use toasty::stmt;
user.update()
.address(stmt::patch(Address::fields().city(), "Portland"))
.exec(&mut db)
.await?;
stmt::patch targets a sub-field by its typed path and leaves the other
fields of the embedded struct unchanged. Combine multiple sub-field updates
with stmt::apply:
user.update()
.address(stmt::apply([
stmt::patch(Address::fields().street(), "456 Oak Ave"),
stmt::patch(Address::fields().city(), "Portland"),
]))
.exec(&mut db)
.await?;
Nested embedding
Embedded structs can contain other embedded structs. Each level of nesting adds another prefix to the column name:
#[derive(Debug, toasty::Embed)]
struct Coordinates {
lat: i64,
lng: i64,
}
#[derive(Debug, toasty::Embed)]
struct Address {
street: String,
city: String,
coords: Coordinates,
}
A User model with an address: Address field produces columns: address_street,
address_city, address_coords_lat, address_coords_lng.
Embedded enums
Enums annotated with #[derive(toasty::Embed)] store a variant discriminant in
the database. Each variant must have an explicit #[column(variant = N)]
attribute specifying its integer discriminant value.
Unit enums
A unit enum (all variants have no fields) maps to a single integer column:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, PartialEq, toasty::Embed)]
enum Status {
#[column(variant = 1)]
Pending,
#[column(variant = 2)]
Active,
#[column(variant = 3)]
Done,
}
#[derive(Debug, toasty::Model)]
struct Task {
#[key]
#[auto]
id: u64,
title: String,
status: Status,
}
}
The status column stores the discriminant as an integer:
CREATE TABLE tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status INTEGER NOT NULL -- 1 = Pending, 2 = Active, 3 = Done
);
Use it like any other field:
let task = toasty::create!(Task {
title: "Write docs",
status: Status::Pending,
})
.exec(&mut db)
.await?;
task.update().status(Status::Done).exec(&mut db).await?;
Data-carrying enums
Enum variants can carry fields. Each variant’s fields become nullable columns in the parent table. Only the active variant’s columns are non-null for a given row:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, PartialEq, toasty::Embed)]
enum ContactInfo {
#[column(variant = 1)]
Email { address: String },
#[column(variant = 2)]
Phone { number: String },
}
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
contact: ContactInfo,
}
}
This produces three columns — one discriminant column plus one nullable column per variant field:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
contact INTEGER NOT NULL, -- discriminant: 1 = Email, 2 = Phone
contact_address TEXT, -- non-null when contact = 1
contact_number TEXT -- non-null when contact = 2
);
Create records by passing enum values:
let user = toasty::create!(User {
name: "Alice",
contact: ContactInfo::Email {
address: "alice@example.com".to_string(),
},
})
.exec(&mut db)
.await?;
Mixed enums
An enum can have both unit variants and data-carrying variants:
#[derive(Debug, PartialEq, toasty::Embed)]
enum Status {
#[column(variant = 1)]
Pending,
#[column(variant = 2)]
Failed { reason: String },
#[column(variant = 3)]
Done,
}
Unit variants (Pending, Done) store only the discriminant. The Failed
variant also stores its reason in a nullable column.
The #[column(variant = N)] attribute
Every variant in an embedded enum must have #[column(variant = N)] where N
is the integer stored in the database. This is required — Toasty does not
auto-assign discriminant values.
The discriminant values do not need to be sequential. You can choose any i64
values, which is useful when adding new variants to an existing schema without
renumbering:
#[derive(toasty::Embed)]
enum Priority {
#[column(variant = 10)]
Low,
#[column(variant = 20)]
Normal,
#[column(variant = 30)]
High,
}
Filtering on embedded fields
Struct fields
Use chained field accessors to filter on embedded struct fields:
// Find users in Seattle
let users = User::filter(User::fields().address().city().eq("Seattle"))
.exec(&mut db)
.await?;
User::fields().address() returns the embedded struct’s field accessors.
.city() returns a field path for the address_city column. All comparison
operators (.eq(), .ne(), .gt(), etc.) work on embedded struct fields.
Combine conditions on multiple embedded fields with .and():
let users = User::filter(
User::fields()
.address()
.city()
.eq("Seattle")
.and(User::fields().address().street().eq("123 Main St")),
)
.exec(&mut db)
.await?;
Enum variants
For embedded enums, Toasty generates is_*() methods to filter by variant:
// Find all tasks with status = Active
let tasks = Task::filter(Task::fields().status().is_active())
.exec(&mut db)
.await?;
This compiles to WHERE status = 2.
For unit enums, you can also use .eq() directly:
let tasks = Task::filter(Task::fields().status().eq(Status::Active))
.exec(&mut db)
.await?;
For data-carrying enums, use .is_*() to check the variant and .matches() to
filter on the variant’s fields:
// Find users whose contact is an email with a specific address
let users = User::filter(
User::fields()
.contact()
.email()
.matches(|e| e.address().eq("alice@example.com")),
)
.exec(&mut db)
.await?;
The .matches() closure receives the variant’s field accessors. It checks both
the discriminant and the field condition.
Indexing embedded fields
Add #[index] or #[unique] to fields inside an embedded type. The index
applies to the flattened column in the parent table:
#[derive(toasty::Embed)]
struct Contact {
#[unique]
email: String,
#[index]
country: String,
}
#[derive(toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
contact: Contact,
}
This creates a unique index on the contact_email column and a non-unique index
on contact_country. The same rules from
Indexes and Unique Constraints apply.
Indexes on data-carrying enum variant fields work the same way. The index is created on the nullable column for that variant’s field.
Deferred Fields
A deferred field is a column Toasty omits from the default SELECT
list. Records returned from a query have the field unloaded; loading
the value requires either a follow-up .exec() call or a preload with
.include().
The pattern fits columns that are large, expensive to fetch, or rarely
read: a Document body, a binary blob, an audit-event JSON payload.
Without the deferred annotation, every list query reads every column
whether the caller needs it or not.
The API mirrors BelongsTo: a synchronous .get() reads an
already-loaded value, an async per-field accessor loads on demand, and
.include() preloads as part of the parent query.
Marking a field as deferred
Annotate the field with #[deferred] and wrap its type in Deferred<T>:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Document {
#[key]
#[auto]
id: u64,
title: String,
#[deferred]
body: toasty::Deferred<String>,
}
}
Both are required; using one without the other is a compile error. The attribute directs the macro to omit the field from the default projection and to generate the per-field load method. The wrapper type provides the unloaded-state runtime API.
A record from an ordinary query has body unloaded. Load it explicitly
with a follow-up read keyed on the primary key:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Document {
#[key]
#[auto]
id: u64,
title: String,
#[deferred]
body: toasty::Deferred<String>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let created = toasty::create!(Document {
title: "Hello",
body: "the long body",
}).exec(&mut db).await?;
let doc = Document::filter_by_id(created.id).get(&mut db).await?;
assert!(doc.body.is_unloaded());
// Issue a follow-up read for just the deferred column.
let body: String = doc.body().exec(&mut db).await?;
Ok(())
}
}
Or preload it with .include() so the value arrives on the record the
query returns — no second round-trip:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Document {
#[key]
#[auto]
id: u64,
title: String,
#[deferred]
body: toasty::Deferred<String>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let created = toasty::create!(Document {
title: "Hello",
body: "the long body",
}).exec(&mut db).await?;
let doc = Document::filter_by_id(created.id)
.include(Document::fields().body())
.get(&mut db)
.await?;
let body: &String = doc.body.get(); // synchronous, no query
Ok(())
}
}
#[deferred] is supported on primitive fields and on embedded types
(#[derive(Embed)] structs and enums). It does not compose with
#[belongs_to], #[has_many], or #[has_one] — relations are already
lazy.
A deferred embed value omits all of the embed’s columns from the default
projection. Loading is the same as for a primitive: call the per-field
accessor, or chain .include() to preload alongside the parent query.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Embed)]
struct Metadata {
author: String,
notes: String,
}
#[derive(Debug, toasty::Model)]
struct Document {
#[key]
#[auto]
id: u64,
title: String,
#[deferred]
metadata: toasty::Deferred<Metadata>,
}
}
#[deferred] is also valid on a primitive field inside an embedded
struct. The annotation defers just that column wherever the embed is
used; the embed’s other (eager) fields still load with the parent query.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Embed)]
struct Metadata {
author: String,
#[deferred]
notes: toasty::Deferred<String>,
}
}
To load such a sub-field on a parent query, name it in .include():
let doc = Document::filter_by_id(id)
.include(Document::fields().metadata().notes())
.get(&mut db)
.await?;
When the user constructs an embed value directly (struct-literal
syntax), a deferred sub-field accepts the inner value via .into():
Metadata {
author: "Alice".to_string(),
notes: "the note".to_string().into(),
}
Loaded state on create vs query
The record returned by create! is loaded with the deferred value the
caller just wrote — .get() reads it without a round-trip. A
subsequent query against the same row returns a separate record with
the deferred field unloaded:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Document {
#[key]
#[auto]
id: u64,
title: String,
#[deferred]
body: toasty::Deferred<String>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let created = toasty::create!(Document {
title: "Hello",
body: "the long body",
})
.exec(&mut db)
.await?;
// Loaded — the value the caller passed in.
assert_eq!("the long body", created.body.get());
// A separate query returns the deferred field unloaded.
let doc = Document::filter_by_id(created.id).get(&mut db).await?;
assert_eq!("Hello", doc.title);
assert!(doc.body.is_unloaded());
Ok(())
}
}
Calling doc.body.get() in the unloaded state panics. .get() is the
synchronous accessor for a value already loaded into the record; on an
unloaded field there is nothing to return.
Loading on demand
The macro generates a per-field method that issues a single-row read
keyed on the model’s primary key. Call .exec() to fetch the value:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Document {
#[key]
#[auto]
id: u64,
title: String,
#[deferred]
body: toasty::Deferred<String>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let created = toasty::create!(Document {
title: "Hello",
body: "the long body",
}).exec(&mut db).await?;
let doc = Document::filter_by_id(created.id).get(&mut db).await?;
let body: String = doc.body().exec(&mut db).await?;
Ok(())
}
}
The return type of .exec() is the type within Deferred<T>. The
call does not mutate the in-memory record — doc.body.is_unloaded()
is still true afterward, and re-issuing the same load returns the
value again.
The .await makes the round-trip explicit. Code that needs the value
many times should preload with .include() instead — calling .exec()
in a loop over a Vec<Document> is N+1 by definition.
Preloading with .include()
.include() extends the parent query’s projection so deferred fields
are loaded onto the same record returned by the query:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Document {
#[key]
#[auto]
id: u64,
title: String,
#[deferred]
body: toasty::Deferred<String>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let created = toasty::create!(Document {
title: "Hello",
body: "the long body",
}).exec(&mut db).await?;
let doc = Document::filter_by_id(created.id)
.include(Document::fields().body())
.get(&mut db)
.await?;
let body: &String = doc.body.get(); // synchronous, no query
Ok(())
}
}
The .include() call adds the deferred column to the existing query —
no extra round-trip. Multiple .include() calls on the same query
coalesce, and they combine with relation .include()s:
let doc = Document::filter_by_id(id)
.include(Document::fields().body())
.include(Document::fields().summary())
.include(Document::fields().author()) // BelongsTo
.get(&mut db)
.await?;
Across a result set, .include() is the way to avoid N+1: a single
query loads the deferred fields for every record it returns.
Filtering and sorting
Filtering or sorting on a deferred field references the column in
WHERE or ORDER BY without loading the value — only .include()
adds the field to the SELECT list:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Document {
#[key]
#[auto]
id: u64,
title: String,
#[deferred]
body: toasty::Deferred<String>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let alpha = toasty::create!(Document {
title: "First",
body: "alpha body",
}).exec(&mut db).await?;
let docs = Document::filter_by_id(alpha.id)
.filter(Document::fields().body().eq("alpha body".to_string()))
.exec(&mut db)
.await?;
assert_eq!(1, docs.len());
assert!(docs[0].body.is_unloaded());
Ok(())
}
}
Updating
Updating a deferred field does not require it to be loaded. The
caller already supplies the value, so the field is loaded with the new
value after the update — no follow-up .exec() or .include() is
needed to read what was just written:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Document {
#[key]
#[auto]
id: u64,
title: String,
#[deferred]
body: toasty::Deferred<String>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let created = toasty::create!(Document {
title: "Hello",
body: "old body",
}).exec(&mut db).await?;
let mut doc = Document::filter_by_id(created.id).get(&mut db).await?;
assert!(doc.body.is_unloaded());
doc.update().body("new body".to_string()).exec(&mut db).await?;
// The field is loaded with the value just assigned.
assert_eq!("new body", doc.body.get());
Ok(())
}
}
Optional deferred fields
Deferred<T> where T is Option<U> makes the field nullable. The
column stores NULL when the value is None, and create! treats the
field as optional:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Document {
#[key]
#[auto]
id: u64,
title: String,
#[deferred]
summary: toasty::Deferred<Option<String>>,
}
}
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Document {
#[key]
#[auto]
id: u64,
title: String,
#[deferred]
summary: toasty::Deferred<Option<String>>,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
// summary may be set or omitted at create time.
let with = toasty::create!(Document {
title: "With summary",
summary: "a brief summary",
}).exec(&mut db).await?;
let without = toasty::create!(Document {
title: "No summary",
}).exec(&mut db).await?;
let summary: Option<String> = with.summary().exec(&mut db).await?;
assert_eq!(Some("a brief summary".to_string()), summary);
let summary: Option<String> = without.summary().exec(&mut db).await?;
assert_eq!(None, summary);
Ok(())
}
}
A required Deferred<T> (where T is not Option<_>) is a required
argument to create!, just like any other non-nullable field —
create! fails to compile when it is missing.
Driver support
#[deferred] is supported on every driver. SQL backends shorten the
SELECT column list; DynamoDB shortens the ProjectionExpression.
Drivers do not need a capability flag for this feature.
Batch Operations
toasty::batch() executes multiple queries or creates in a single database
round-trip. Instead of sending each query separately, Toasty combines them into
one composed statement.
Batch operations are atomic, database permitting — all operations in a batch either succeed together or fail together. When you need atomicity, prefer batch operations over interactive transactions. Batch operations are more efficient because they can be sent as a single statement to the database, while interactive transactions require separate round-trips to begin the transaction, execute each statement, and commit. In many cases, batch operations are sufficient. Reach for interactive transactions only when you need to read data and make decisions based on those reads within the same atomic scope.
Batching queries with tuples
Pass a tuple of queries to toasty::batch(). The return type matches the tuple
structure:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
#[index]
name: String,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
#[index]
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let (users, posts): (Vec<User>, Vec<Post>) = toasty::batch((
User::filter_by_name("Alice"),
Post::filter_by_title("Hello"),
))
.exec(&mut db)
.await?;
Ok(())
}
}
Each element in the tuple is an independent query. Toasty sends them together and returns the results in the same tuple order. Tuples support up to 8 elements.
You can batch queries for the same model too:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
#[index]
name: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let (alices, bobs): (Vec<User>, Vec<User>) = toasty::batch((
User::filter_by_name("Alice"),
User::filter_by_name("Bob"),
))
.exec(&mut db)
.await?;
Ok(())
}
}
Batching with arrays and Vecs
When all queries are the same type, use an array or Vec instead of a tuple.
The return type is Vec<Vec<Model>> — one inner Vec per query:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
#[index]
name: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let results: Vec<Vec<User>> = toasty::batch([
User::filter_by_name("Alice"),
User::filter_by_name("Bob"),
User::filter_by_name("Carol"),
])
.exec(&mut db)
.await?;
assert_eq!(results.len(), 3); // one result set per query
Ok(())
}
}
This works with Vec too, which is useful when the number of queries is
determined at runtime:
let names = vec!["Alice", "Bob", "Carol"];
let queries: Vec<_> = names
.iter()
.map(|n| User::filter_by_name(*n))
.collect();
let results: Vec<Vec<User>> = toasty::batch(queries)
.exec(&mut db)
.await?;
Batching creates with create!
The toasty::create! macro’s batch forms (Type::[ ... ] and ( ... ))
produce a batch of creates. The same atomicity guarantees apply — all records
are inserted together or none are.
// Same-type batch — returns Vec<User>
let users = toasty::create!(User::[
{ name: "Alice", email: "alice@example.com" },
{ name: "Bob", email: "bob@example.com" },
])
.exec(&mut db)
.await?;
// Mixed-type batch — returns a tuple
let (user, post) = toasty::create!((
User { name: "Alice" },
Post { title: "Hello World" },
))
.exec(&mut db)
.await?;
The same-type batch (Type::[...]) returns a Vec<Model>. The mixed-type
batch ((...)) returns a tuple matching the input, with one element per
create.
See Creating Records for the full create! macro
syntax.
Batching creates with toasty::batch()
toasty::batch() also accepts create builders directly. This is useful when
you want to mix creates and queries in the same batch, or when building
creates dynamically at runtime:
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
}
#[derive(Debug, toasty::Model)]
struct Post {
#[key]
#[auto]
id: u64,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let (user, post): (User, Post) = toasty::batch((
toasty::create!(User { name: "Alice" }),
toasty::create!(Post { title: "Hello World" }),
))
.exec(&mut db)
.await?;
Ok(())
}
}
Bulk creation with create_many()
Model::create_many() inserts multiple records of the same model. Add records
with .item() or .with_item():
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Todo {
#[key]
#[auto]
id: u64,
title: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let todos = Todo::create_many()
.item(toasty::create!(Todo { title: "Buy groceries" }))
.item(toasty::create!(Todo { title: "Write docs" }))
.item(toasty::create!(Todo { title: "Ship feature" }))
.exec(&mut db)
.await?;
assert_eq!(todos.len(), 3);
Ok(())
}
}
.item() takes a create builder. .with_item() takes a closure that receives
the create builder, which is useful for inline construction:
let todos = Todo::create_many()
.with_item(|c| c.title("Buy groceries"))
.with_item(|c| c.title("Write docs"))
.exec(&mut db)
.await?;
create_many() returns a Vec of the created records, including auto-generated
fields like id.
create_many() vs batch() for inserts
Both can insert multiple records, but they differ:
create_many() | batch() | |
|---|---|---|
| Scope | Single model | Any mix of models, queries, and creates |
| Return type | Vec<Model> | Matches the input structure |
| Use case | Insert many records of the same type | Combine diverse operations |
Use create_many() when inserting multiple records of the same model. Use
batch() when combining different operations or models.
Transactions
A transaction groups multiple database operations so they either all succeed or all fail. Toasty supports interactive transactions on SQL databases (SQLite, PostgreSQL, MySQL).
Tip: If you just need multiple operations to execute atomically, consider using batch operations first. Batch operations are atomic and more efficient — they can be sent as a single statement, avoiding the extra round-trips that interactive transactions require (begin, execute, commit). Use interactive transactions when you need to read data and branch on the results within the same atomic scope.
Starting a transaction
Call db.transaction() to begin a transaction:
#![allow(unused)]
fn main() {
use toasty::{Model, Executor};
#[derive(Debug, toasty::Model)]
struct User {
#[key]
#[auto]
id: u64,
name: String,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let mut tx = db.transaction().await?;
toasty::create!(User { name: "Alice" }).exec(&mut tx).await?;
toasty::create!(User { name: "Bob" }).exec(&mut tx).await?;
tx.commit().await?;
Ok(())
}
}
The transaction borrows &mut Db, preventing other operations on the same Db
handle while the transaction is open. Pass &mut tx to query builders the same
way you pass &mut db.
The exclusive borrow is deliberate. Without it, it would be easy to run a
statement against db while holding tx — that statement would execute on a
separate connection pulled from the pool, bypassing the transaction entirely.
The &mut keeps db unusable until the transaction ends, so every statement
has to go through &mut tx.
If you genuinely need a second handle while a transaction is open — for
example, an independent task doing unrelated work — clone the Db before
starting the transaction:
let mut db2 = db.clone();
let mut tx = db.transaction().await?;
// `db2` is a separate handle backed by the same pool; use it freely
// for work that is not part of `tx`.
Clones share the underlying pool, so cloning is cheap and does not open a new connection.
The same rule applies to transactions started from a Connection and to
nested transactions created from an existing Transaction: each takes
&mut self so statements cannot accidentally bypass the innermost scope.
Running queries in a transaction
All the same operations work inside a transaction — creates, queries, updates, and deletes:
let mut tx = db.transaction().await?;
// Create
let user = toasty::create!(User { name: "Alice" }).exec(&mut tx).await?;
// Query
let users = User::all().exec(&mut tx).await?;
// Update
user.update().name("Bob").exec(&mut tx).await?;
// Delete
User::filter_by_id(user.id).delete().exec(&mut tx).await?;
tx.commit().await?;
Reads inside a transaction see the writes made earlier in the same transaction, even before commit:
let mut tx = db.transaction().await?;
toasty::create!(User { name: "Alice" }).exec(&mut tx).await?;
// This sees the record we just created
let users = User::all().exec(&mut tx).await?;
assert_eq!(users.len(), 1);
tx.commit().await?;
Commit and rollback
Call .commit() to save all changes made in the transaction:
let mut tx = db.transaction().await?;
toasty::create!(User { name: "Alice" }).exec(&mut tx).await?;
tx.commit().await?;
// The record is now visible outside the transaction
let users = User::all().exec(&mut db).await?;
assert_eq!(users.len(), 1);
Call .rollback() to discard all changes:
let mut tx = db.transaction().await?;
toasty::create!(User { name: "Alice" }).exec(&mut tx).await?;
tx.rollback().await?;
// The record was never persisted
let users = User::all().exec(&mut db).await?;
assert!(users.is_empty());
Auto-rollback on drop
If a transaction is dropped without calling .commit() or .rollback(), it
automatically rolls back. This means you don’t need explicit rollback when an
error occurs — just let the transaction go out of scope:
let mut tx = db.transaction().await?;
toasty::create!(User { name: "Alice" }).exec(&mut tx).await?;
// tx is dropped here without commit — changes are rolled back
This is useful in functions that return Result. If an operation inside the
transaction fails with ?, the transaction is dropped and rolled back:
async fn transfer(db: &mut Db) -> toasty::Result<()> {
let mut tx = db.transaction().await?;
// If this fails, tx is dropped and rolled back
let user = User::get_by_id(&mut tx, &1).await?;
user.update().balance(user.balance - 100).exec(&mut tx).await?;
let other = User::get_by_id(&mut tx, &2).await?;
other.update().balance(other.balance + 100).exec(&mut tx).await?;
tx.commit().await?;
Ok(())
}
Nested transactions
Call .transaction() on an existing transaction to create a nested transaction.
Nested transactions use database savepoints:
let mut tx = db.transaction().await?;
toasty::create!(User { name: "Alice" }).exec(&mut tx).await?;
{
let mut nested = tx.transaction().await?;
toasty::create!(User { name: "Bob" }).exec(&mut nested).await?;
nested.commit().await?; // releases the savepoint
}
tx.commit().await?; // commits both Alice and Bob
Rolling back a nested transaction only undoes the work done inside it. The outer transaction continues:
let mut tx = db.transaction().await?;
toasty::create!(User { name: "Alice" }).exec(&mut tx).await?;
{
let mut nested = tx.transaction().await?;
toasty::create!(User { name: "Bob" }).exec(&mut nested).await?;
nested.rollback().await?; // rolls back to savepoint — Bob is discarded
}
tx.commit().await?; // only Alice is committed
Nested transactions also auto-rollback on drop, just like top-level transactions.
Transaction options
Use transaction_builder() to configure a transaction before starting it:
use toasty::IsolationLevel;
let mut tx = db.transaction_builder()
.isolation(IsolationLevel::Serializable)
.read_only(true)
.begin()
.await?;
Isolation levels
Toasty supports four isolation levels:
| Level | Description |
|---|---|
ReadUncommitted | Allows dirty reads |
ReadCommitted | Only reads committed data |
RepeatableRead | Consistent reads within the transaction |
Serializable | Full isolation between transactions |
Driver support varies. SQLite only supports Serializable. PostgreSQL and MySQL
support all four levels.
Read-only transactions
Set .read_only(true) to create a read-only transaction. The database rejects
write operations inside a read-only transaction.
Concurrency Control
When multiple writers modify the same record, you need a way to detect
conflicting writes. Toasty supports optimistic concurrency control (OCC)
through the #[version] attribute: Toasty conditions each write on a version
field and atomically increments it, so a stale writer sees its update fail
rather than silently overwrite a newer value.
Optimistic concurrency with #[version]
Add #[version] to a u64 field to enable OCC on a model. Toasty manages the
field — you declare it but never set it manually.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Document {
#[key]
#[auto]
id: uuid::Uuid,
content: String,
#[version]
version: u64,
}
}
Create. Toasty sets the version to 1 on the new record.
Instance update. doc.update()...exec() conditions the write on the
current version and atomically increments it. If another writer has updated
the record since you last loaded it, the update returns an error.
Instance delete. doc.delete().exec() conditions the delete on the
current version. If the record has been updated since you last loaded it, the
delete returns an error.
#![allow(unused)]
fn main() {
use toasty::Model;
#[derive(Debug, toasty::Model)]
struct Document {
#[key]
#[auto]
id: uuid::Uuid,
content: String,
#[version]
version: u64,
}
async fn __example(mut db: toasty::Db) -> toasty::Result<()> {
let mut doc = toasty::create!(Document { content: "hello" })
.exec(&mut db)
.await?;
assert_eq!(doc.version, 1);
// Load a second handle — both start at version 1
let mut stale = Document::get_by_id(&mut db, &doc.id).await?;
// Advance doc to version 2
doc.update().content("world").exec(&mut db).await?;
assert_eq!(doc.version, 2);
// stale is still at version 1 — the update fails with a conflict error
let result = stale.update().content("conflict").exec(&mut db).await;
assert!(result.is_err());
Ok(())
}
}
Query-based updates (Document::filter_by_id(id).update()...) neither check
nor increment the version. OCC applies only to instance updates and instance
deletes.
Note:
#[version]is supported by the DynamoDB driver only. SQL drivers do not yet implement OCC.
Database Setup
Opening a database connection in Toasty has two steps: register your models,
then connect to a database. Db::builder() handles both.
let mut db = toasty::Db::builder()
.models(toasty::models!(User, Post))
.connect("sqlite::memory:")
.await?;
Registering models
The models! macro builds a ModelSet — the collection of model definitions
Toasty uses to generate the database schema. It accepts three forms, which can
be combined freely:
toasty::models!(
// All models from the current crate
crate::*,
// All models from an external crate
third_party_models::*,
// Individual models
User,
other_module::Post,
)
crate::* finds all #[derive(Model)] and #[derive(Embed)] types in your
crate at compile time. This is the simplest option when all your models live in
one crate.
You don’t need to list every model. Registering a model also registers any
models reachable through its fields — BelongsTo, HasMany, HasOne, and
embedded types are all discovered by traversing the model’s fields. For
example, if User has a HasMany<Post> field and Post has a BelongsTo<User>
field, toasty::models!(User) registers both User and Post.
Connection URLs
The connection URL determines which database driver Toasty uses. Each driver
requires its corresponding feature flag in Cargo.toml.
| Scheme | Database | Feature flag |
|---|---|---|
sqlite | SQLite | sqlite |
postgresql or postgres | PostgreSQL | postgresql |
mysql | MySQL | mysql |
dynamodb | DynamoDB | dynamodb |
Examples:
// In-memory SQLite
.connect("sqlite::memory:")
// SQLite file
.connect("sqlite:./path/to/db.sqlite")
// PostgreSQL
.connect("postgresql://user:pass@localhost:5432/mydb")
// MySQL
.connect("mysql://user:pass@localhost:3306/mydb")
// DynamoDB (uses AWS config from environment)
.connect("dynamodb://us-east-1")
For per-backend details — URL query parameters, TLS, type mapping, and per-driver behavior — see the Database Backends chapters.
Using a driver directly
If you need more control over the driver configuration, construct the driver
yourself and pass it to build() instead of connect():
let driver = toasty_driver_sqlite::Sqlite::in_memory();
let mut db = toasty::Db::builder()
.models(toasty::models!(User))
.build(driver)
.await?;
Connection pool
Db owns a connection pool. Each query checks out a connection from the
pool for the duration of the call and returns it when finished. The pool
defaults work for typical applications; the builder exposes knobs for
tuning size, timeouts, and broken-connection recovery.
use std::time::Duration;
let mut db = toasty::Db::builder()
.models(toasty::models!(crate::*))
.max_pool_size(32)
.pool_wait_timeout(Some(Duration::from_secs(5)))
.pool_create_timeout(Some(Duration::from_secs(10)))
.connect("postgresql://user:pass@localhost/mydb")
.await?;
| Builder method | Default | Purpose |
|---|---|---|
max_pool_size(n) | num_cpus * 2 | Cap on simultaneous open connections. Drivers may enforce a lower cap (e.g., in-memory SQLite is single-connection). |
pool_wait_timeout(Some(d)) | None | Maximum time Db waits for a free connection before returning an error. None waits indefinitely. |
pool_create_timeout(Some(d)) | None | Maximum time to spend opening a new connection. |
pool_health_check_interval(Some(d)) | Some(60s) | How often the background sweep pings an idle connection to detect a silently-broken backend. None disables the sweep. |
pool_pre_ping(true) | false | Ping every connection before handing it to the caller. Adds one round-trip per checkout in exchange for guaranteeing the caller sees a live connection. |
Recovering from a backend restart
A database restart, a load-balancer-closed socket, or a backend session timeout leaves the pool holding TCP sockets that look open but reject the next query. Toasty handles this two ways:
- Background sweep. Every
pool_health_check_interval, the pool pings one idle connection. If the ping fails, the pool drops the failing connection and eagerly pings the rest of the idle slots so a single bad result drains every dead connection in one pass. - Reactive sweep. When a user query observes a connection-lost error, the same eager sweep runs immediately. A backend restart typically costs one failed user query rather than one per pooled connection.
Enable pool_pre_ping(true) if even one failed query is unacceptable —
for example, a public API behind a flaky network or an idempotent
worker without retry. The cost is one extra round-trip per checkout.
Table name prefix
To prefix all generated table names (useful when multiple services share a
database), call table_name_prefix() on the builder:
let mut db = toasty::Db::builder()
.models(toasty::models!(crate::*))
.table_name_prefix("myapp_")
.connect("sqlite::memory:")
.await?;
Migrations and Schema Management
Toasty provides two ways to manage your database schema: push_schema for
quick development, and a migration system for production databases.
Quick setup with push_schema
db.push_schema() creates all tables and indexes based on your registered
models. It issues CREATE TABLE and CREATE INDEX statements directly against
the database.
let mut db = toasty::Db::builder()
.models(toasty::models!(crate::*))
.connect("sqlite::memory:")
.await?;
db.push_schema().await?;
This works well for prototyping and tests. It does not track what has changed between runs — it pushes the full schema every time. For a database that already has data, use migrations instead.
The migration system
The migration system compares your current model definitions against a stored snapshot of the previous schema. It computes the diff and generates a SQL migration file containing only the changes (new tables, altered columns, dropped indexes, etc.).
Migrations are managed through a small CLI binary that you create in your
project using the toasty-cli library crate. Toasty cannot ship a ready-made
CLI tool because the tool needs access to your model types to compute the
schema. The toasty-cli crate provides ToastyCli, which handles argument
parsing and all migration subcommands:
| Command | What it does |
|---|---|
migration generate | Diffs the current schema against the last snapshot and writes a SQL migration file |
migration apply | Runs pending migrations against the database |
migration snapshot | Prints the current schema as TOML |
migration drop | Removes a migration from history and deletes its files |
migration reset | Drops all tables and optionally re-applies all migrations |
Setting up the CLI
Add toasty-cli to your project:
[dependencies]
toasty = { version = "0.6", features = ["sqlite"] }
toasty-cli = "0.6"
tokio = { version = "1", features = ["full"] }
anyhow = "1"
Create a CLI binary in src/bin/cli.rs:
use toasty_cli::{Config, ToastyCli};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = Config::load()?;
let db = toasty::Db::builder()
.models(toasty::models!(crate::*))
.connect("sqlite:./my_app.db")
.await?;
let cli = ToastyCli::with_config(db, config);
cli.parse_and_run().await?;
Ok(())
}
Add a Toasty.toml configuration file in your project root:
[migration]
path = "toasty"
prefix_style = "Sequential"
checksums = false
statement_breakpoints = true
Configuration options
The [migration] section in Toasty.toml controls migration behavior:
| Option | Default | Description |
|---|---|---|
path | "toasty" | Base directory for migration files, snapshots, and history |
prefix_style | "Sequential" | File naming: "Sequential" (0001_, 0002_) or "Timestamp" (20240112_153045_) |
checksums | false | When true, stores MD5 checksums in history to detect modified migration files |
statement_breakpoints | true | Adds -- #[toasty::breakpoint] comments between SQL statements so drivers can split them for execution |
Generating a migration
Run the generate command to create your first migration:
cargo run --bin my-cli -- migration generate
If there are schema changes since the last snapshot (or no snapshot exists yet),
the CLI creates three things inside the configured path directory:
toasty/
├── history.toml
├── migrations/
│ └── 0000_migration.sql
└── snapshots/
└── 0000_snapshot.toml
migrations/0000_migration.sql— the SQL DDL statements for this migration. For a new project this contains allCREATE TABLEandCREATE INDEXstatements.snapshots/0000_snapshot.toml— a TOML serialization of the full schema at this point. The nextgeneraterun diffs against this snapshot.history.toml— tracks all migrations by name and ID.
You can give a migration a descriptive name with --name:
cargo run --bin my-cli -- migration generate --name add_posts_table
This produces 0001_add_posts_table.sql instead of 0001_migration.sql.
Rename detection
When the diff contains a dropped table and an added table (or dropped and added
columns within a table), the CLI asks whether this is a rename. For example, if
you rename a users table to accounts, the CLI prompts:
Table "users" is missing
> Drop "users" ✖
Rename "users" → "accounts"
Choosing the rename option generates an ALTER TABLE ... RENAME statement
instead of a DROP TABLE followed by a CREATE TABLE.
Applying migrations
Run pending migrations against the database:
cargo run --bin my-cli -- migration apply
The CLI reads history.toml to find all defined migrations, then queries the
database’s __toasty_migrations tracking table to see which ones have already
been applied. It executes each pending migration in order inside a transaction
and records it in the tracking table.
If all migrations are already applied, the command prints a message and exits without changes.
Inspecting the current schema
Print the schema snapshot derived from your current model definitions:
cargo run --bin my-cli -- migration snapshot
This outputs the full schema as TOML, showing all tables, columns, and indexes. It does not modify any files — it reads directly from the registered models.
Dropping a migration
Remove a migration from history and delete its files:
# Drop by name
cargo run --bin my-cli -- migration drop --name 0001_add_posts_table.sql
# Drop the latest migration
cargo run --bin my-cli -- migration drop --latest
# Interactive picker
cargo run --bin my-cli -- migration drop
Dropping a migration removes its SQL file, its snapshot file, and its entry in
history.toml. It does not undo changes already applied to the database. To
undo applied changes, use migration reset and re-apply.
Resetting the database
Drop all tables and optionally re-apply migrations from scratch:
cargo run --bin my-cli -- migration reset
The CLI prompts for confirmation before proceeding. After dropping all tables, it re-applies every migration in the history. To skip the re-apply step:
cargo run --bin my-cli -- migration reset --skip-migrations
Generated SQL
A generated migration file contains standard SQL DDL. Toasty generates database-specific SQL based on the driver you connect with. Here is an example for SQLite:
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
PRIMARY KEY ("id")
);
-- #[toasty::breakpoint]
CREATE UNIQUE INDEX "index_users_by_email" ON "users" ("email");
The -- #[toasty::breakpoint] comments mark boundaries where the driver splits
statements for execution. Some databases (like PostgreSQL) can execute multiple
statements in a single batch, while others require them one at a time. The
breakpoint markers handle this transparently.
Migration tracking
Toasty tracks applied migrations in a __toasty_migrations table that it
creates automatically. Each row stores the migration’s ID (a random 64-bit
integer from history.toml), its name, and a timestamp. The migration apply
command checks this table to determine which migrations are pending.
Typical workflow
A common development cycle looks like this:
- Edit your model structs (add a field, change a type, add an index)
- Run
migration generate --name describe_change - Review the generated SQL file
- Run
migration applyto update the database - Commit the migration files, snapshot, and updated history alongside your code
For early development when the schema changes frequently, push_schema is
simpler. Switch to migrations when your database has data you want to preserve
across schema changes.
Tracing
Toasty emits structured events through the tracing crate. The most
common reason to enable them is to see the SQL each query produces.
Toasty does not print anything by default — install a subscriber in your
application and the events appear.
Seeing executed SQL
The SQL drivers emit a tracing::debug! event for every statement they
send to the database. Install tracing-subscriber with the
env-filter feature and run with RUST_LOG=toasty=debug:
[dependencies]
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> toasty::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let db = toasty::Db::builder()
.models(toasty::models!(crate::*))
.connect("sqlite::memory:")
.await?;
// ... queries here ...
Ok(())
}
Run the program with:
RUST_LOG=toasty=debug cargo run
Each query produces two events. The engine logs the statement kind, and the driver logs the SQL it sends:
DEBUG toasty::engine: executing statement stmt.kind="query"
DEBUG toasty_driver_sqlite: executing SQL db.system="sqlite" db.statement=SELECT tbl_0_0."id", tbl_0_0."name" FROM "users" AS tbl_0_0 WHERE tbl_0_0."id" = ?1; params=1
The SQL event carries three fields:
| Field | Meaning |
|---|---|
db.system | Driver that ran the statement: sqlite, postgresql, or mysql. |
db.statement | The serialized SQL, with ?N (SQLite, MySQL) or $N (PostgreSQL) placeholders for parameters. |
params | Number of bound parameters. The values themselves are not logged. |
db.statement is recorded with the Display representation, so the SQL
appears bare in the default fmt subscriber — no surrounding quotes,
no escaping of the identifier quotes inside.
Parameter values are passed to the driver as typed bindings and are not included in the trace. To inspect a specific parameter, log it from your application code.
The field names follow OpenTelemetry’s database semantic conventions, so subscribers that understand those conventions (for example, an OTLP exporter) pick the SQL up without extra mapping.
Filtering to one driver
RUST_LOG accepts per-target directives. To see SQL from one driver
only, filter on its crate:
# Only PostgreSQL statements
RUST_LOG=toasty_driver_postgresql=debug cargo run
# Only SQLite statements
RUST_LOG=toasty_driver_sqlite=debug cargo run
# Only MySQL statements
RUST_LOG=toasty_driver_mysql=debug cargo run
Other events
The info level reports lifecycle events: schema build, database ready,
and applied migrations. Enable it with RUST_LOG=toasty=info.
At debug, the engine also dumps the decoded result of each statement
(Final result from var ...) alongside the SQL event. This is verbose
on large result sets; filter it out with
RUST_LOG=toasty=debug,toasty::engine::exec=info if you only want the
SQL.
The trace level adds per-operation detail — driver dispatch, execution
plan size, and transaction begin/commit/rollback. It is verbose; reach
for it when debug does not show enough.
RUST_LOG=toasty=trace cargo run
DynamoDB
The DynamoDB driver does not run SQL, but it emits tracing::trace!
events for each item operation it performs (getting single item,
querying primary key, batch inserting items, and so on) with the
table name, index name, and item counts. Enable them with
RUST_LOG=toasty_driver_dynamodb=trace.
PostgreSQL
Toasty’s PostgreSQL driver uses tokio-postgres under the hood. It
covers the full SQL feature set Toasty exercises — row locking, native
arrays, native temporal types, named enum types, and ILIKE — and
integrates with Toasty’s connection pool for retry and recovery.
Enabling the driver
Add the postgresql feature to Toasty in Cargo.toml:
[dependencies]
toasty = { version = "0.6", features = ["postgresql"] }
Then pass a postgresql:// (or postgres://) URL to Db::builder:
let db = toasty::Db::builder()
.models(toasty::models!(crate::*))
.connect("postgresql://user:pass@localhost:5432/mydb")
.await?;
TLS support is on by default through the driver’s tls feature, which
pulls in rustls. To build the driver without TLS, depend on
toasty-driver-postgresql directly with default-features = false.
Connection URL options
Append query parameters to the URL to tune the connection:
| Parameter | Purpose |
|---|---|
application_name=<string> | Reported to PostgreSQL as the connecting client. Appears in pg_stat_activity and the server log — useful for distinguishing services sharing a database. |
sslmode=<mode> | TLS negotiation mode. See the table below. |
sslrootcert=<path> | PEM file with root certificates to trust. |
sslcert=<path> and sslkey=<path> | Client certificate and matching private key, for mutual TLS. |
channel_binding=<mode> | disable, prefer (default), or require. |
sslnegotiation=<mode> | postgres (default — SSL request over a plain socket) or direct (TLS from the first byte). |
.connect("postgresql://app:secret@db.internal/store\
?sslmode=verify-full&application_name=store-api")
TLS modes
sslmode | What it does |
|---|---|
disable | Plain TCP. The driver does not negotiate TLS. |
prefer (default) | Attempt TLS; fall back to plain if the server rejects it. The certificate is not verified. |
require | Require TLS, but accept any certificate. |
verify-ca | Require TLS and verify the certificate chains to a trusted root. |
verify-full | verify-ca plus verify the certificate matches the server hostname. |
Without the driver’s tls feature, any sslmode other than disable
fails at connect time.
Type mapping
Toasty maps Rust types to PostgreSQL columns as follows. Most types land in their native PostgreSQL form; the exceptions are called out below the table.
| Rust type | PostgreSQL column type |
|---|---|
bool | BOOL |
i8, i16 | SMALLINT (INT2) |
i32 | INTEGER (INT4) |
i64 | BIGINT (INT8) |
u8 | SMALLINT (INT2) |
u16 | INTEGER (INT4) |
u32, u64 | BIGINT (INT8) |
f32 | REAL (FLOAT4) |
f64 | DOUBLE PRECISION (FLOAT8) |
String | TEXT by default; VARCHAR(N) with #[column(type = varchar(N))] |
Vec<u8> | BYTEA |
uuid::Uuid | UUID |
rust_decimal::Decimal (feature) | NUMERIC |
jiff::Timestamp (feature) | TIMESTAMPTZ with microsecond precision |
jiff::civil::Date (feature) | DATE |
jiff::civil::Time (feature) | TIME with microsecond precision |
jiff::civil::DateTime (feature) | TIMESTAMP with microsecond precision |
Vec<T> (T scalar) | Native array (text[], int8[], uuid[], …) |
Embedded enum | Native ENUM type (CREATE TYPE ... AS ENUM) |
Notes on the type mapping
Unsigned integers cap at i64::MAX. PostgreSQL has no unsigned
integer types, so Toasty stores them in the next-wider signed type
(u8 in SMALLINT, u16 in INTEGER, u32/u64 in BIGINT).
A u64 value above i64::MAX (≈9.22 × 10¹⁸) rejects on insert.
jiff::Timestamp vs jiff::civil::DateTime. Timestamp is an
instant in time; it stores in TIMESTAMPTZ and round-trips as UTC.
civil::DateTime is a wall-clock value with no zone; it stores in
TIMESTAMP (without time zone). Pick the one that matches the
semantics you want.
jiff::Zoned and bigdecimal::BigDecimal store as TEXT.
PostgreSQL’s TIMESTAMPTZ does not carry an IANA zone name (only an
instant), so a true zoned value round-trips through text. Likewise
bigdecimal::BigDecimal does not yet ride PostgreSQL’s NUMERIC wire
encoding and falls back to text. Use rust_decimal::Decimal if you
want native NUMERIC.
VARCHAR length cap. PostgreSQL’s VARCHAR(N) allows N up to
10,485,760. Toasty rejects larger values at schema-build time.
Behavior specific to PostgreSQL
Toasty enables these features automatically when the driver is PostgreSQL. No configuration is required.
Native arrays for Vec<scalar> fields.
A tags: Vec<String> field is a text[] column. The array predicates
(contains, is_superset, intersects, len, is_empty) lower to
PostgreSQL’s native operators (= ANY(col), @>, &&,
cardinality(col)):
let admins = User::filter(User::fields().roles().contains("admin"))
.exec(&mut db)
.await?;
Native ILIKE. The .ilike()
filter method lowers directly to the SQL ILIKE operator. On other
SQL drivers .ilike() falls back to plain LIKE, which is
case-sensitive on PostgreSQL — so the behavior actually differs
between backends. Reach for .ilike() when you need case-insensitive
matching here.
Native prefix match. The
.starts_with() filter
lowers to PostgreSQL’s ^@ operator. The optimizer can use a
text_pattern_ops index for ^@ queries the same way it would for an
anchored LIKE 'prefix%'.
Named enum types. An embed-tagged Rust enum
maps to a real PostgreSQL enum type created with CREATE TYPE ... AS ENUM. Adding a new variant requires an ALTER TYPE ... ADD VALUE,
which the migration generator handles.
Row-level locking. Generated transactions can
use SELECT ... FOR UPDATE to lock rows for the duration of a
transaction. SQLite and DynamoDB do not have row-level locking;
Toasty’s transaction layer falls back to serializable transaction
isolation on those backends.
Backward pagination.
.paginate(per_page).prev(&db)
walks backwards from a page cursor. DynamoDB cannot do this;
PostgreSQL (like the other SQL backends) can.
Migrations
The migration generator emits DDL that PostgreSQL can apply inside a
single transaction, and apply_migration wraps each migration in
BEGIN / COMMIT. A failure rolls back cleanly.
Two PostgreSQL-specific behaviors worth knowing:
Enum types come first. Each migration emits CREATE TYPE or
ALTER TYPE statements for enum types before any CREATE TABLE /
ALTER TABLE that references them.
Column changes are not always atomic. PostgreSQL can ALTER TABLE ... ALTER COLUMN to change a column’s type, but Toasty emits each
property change (type, name, NOT NULL, default) as a separate
statement. Inside a single migration this is invisible — they all
commit together — but at the SQL level the change is several
statements, not one.
The migration tooling does not yet manage zero-downtime online migrations on PostgreSQL (concurrent index creation, dual-write schemes, etc.); migrations assume exclusive access to the schema for their duration.
Errors and the connection pool
The driver classifies tokio-postgres errors into Toasty’s typed
error variants so the pool and caller can react sensibly.
| SQLSTATE or condition | Toasty error |
|---|---|
40001 (serialization_failure) | Error::SerializationFailure — retryable. The transaction lost an optimistic conflict and should be retried by the caller. |
25006 (read_only_sql_transaction) | Error::ReadOnlyTransaction — the connection is read-only. |
| Other server errors with a SQLSTATE | Error::DriverOperationFailed |
| Socket / protocol errors (closed connection, broken pipe, end-of-stream during handshake) | Error::ConnectionLost |
A ConnectionLost error tells the pool to evict the failed
connection. The next acquire pings idle connections, drops the ones
that fail, and opens a fresh slot if needed — so a backend restart
typically costs one failed user query rather than one per pooled
connection. See Database Setup
for the pool knobs (max_pool_size, pool_pre_ping,
pool_health_check_interval, …) and what they do.
The driver caches prepared statements and enum type OIDs per connection. Both caches are invalidated automatically when a connection is dropped, so they do not cause stale-state issues after an eviction.
MySQL
Toasty’s MySQL driver uses mysql_async under the hood. It covers
the SQL feature set Toasty exercises — row locking, native temporal
types, inline enum columns, full unsigned 64-bit integers, and both
fixed-precision and arbitrary-precision decimals — and integrates
with Toasty’s connection pool for retry and recovery.
Enabling the driver
Add the mysql feature to Toasty in Cargo.toml:
[dependencies]
toasty = { version = "0.6", features = ["mysql"] }
Then pass a mysql:// URL to Db::builder:
let db = toasty::Db::builder()
.models(toasty::models!(crate::*))
.connect("mysql://user:pass@localhost:3306/mydb")
.await?;
The URL must include a database name in the path; mysql_async
refuses URLs without one. TLS uses native-tls and is built in.
Connection URL options
mysql_async parses query parameters out of the URL. The ones worth
knowing about for a typical service:
| Parameter | Purpose |
|---|---|
require_ssl=<bool> | Require TLS. Without it, the driver connects in plaintext. |
verify_ca=<bool> | When true (the default once TLS is on), verify the server certificate chains to a trusted root. Set false to accept any certificate. |
verify_identity=<bool> | When true (default), verify the certificate matches the server hostname. Set false to skip hostname validation. |
built_in_roots=<bool> | When false, do not trust the system root store — useful when pinning a private CA. |
socket=<path> | Connect over a Unix socket instead of TCP. |
prefer_socket=<bool> | When connecting to localhost, try a Unix socket first and fall back to TCP. |
compression=<level> | Enable wire-protocol compression. Accepts fast, on, best, or a digit 0–9. |
tcp_keepalive=<ms> | TCP keepalive interval in milliseconds. |
tcp_nodelay=<bool> | Disable Nagle’s algorithm on the TCP socket. |
max_allowed_packet=<bytes> | Cap the client-side max packet size, clamped to 1024–1073741824. |
wait_timeout=<secs> | Server-side wait_timeout for the session. |
stmt_cache_size=<n> | Per-connection prepared-statement cache size. Defaults to 32; set to 0 to disable caching. |
Client certificates for mutual TLS are not exposed through URL
parameters; build them programmatically with mysql_async’s
SslOpts::with_client_identity and pass a constructed driver to
Db::builder().build(driver) (see
Database Setup).
.connect("mysql://app:secret@db.internal/store\
?require_ssl=true&verify_identity=true&compression=on")
Type mapping
Toasty maps Rust types to MySQL columns as follows. MySQL has a few quirks worth knowing about; the notes below the table call them out.
| Rust type | MySQL column type |
|---|---|
bool | BOOLEAN (a TINYINT(1) alias) |
i8 | TINYINT |
i16 | SMALLINT |
i32 | INTEGER |
i64 | BIGINT |
u8 | TINYINT UNSIGNED |
u16 | SMALLINT UNSIGNED |
u32 | INTEGER UNSIGNED |
u64 | BIGINT UNSIGNED |
f32 | FLOAT |
f64 | DOUBLE |
String | VARCHAR(191) by default; override with #[column(type = varchar(N))] |
Vec<u8> | BLOB |
uuid::Uuid | VARCHAR(36) |
rust_decimal::Decimal (feature) | DECIMAL(p, s) — precision and scale required |
bigdecimal::BigDecimal (feature) | DECIMAL(p, s) — precision and scale required |
jiff::Timestamp (feature) | DATETIME(6), stored in UTC |
jiff::civil::Date (feature) | DATE |
jiff::civil::Time (feature) | TIME(6) |
jiff::civil::DateTime (feature) | DATETIME(6) |
Vec<T> (T scalar) | JSON |
Embedded enum | Inline ENUM('a', 'b', ...) column |
Notes on the type mapping
VARCHAR(191) is the default string type. MySQL’s row format
caps the total row size at 65,535 bytes, and utf8mb4 consumes up to
four bytes per character. An indexed VARCHAR column has a per-index
prefix limit of 767 bytes on older InnoDB row formats — 191
characters fits inside that limit with utf8mb4, so the default lets
you index a string field without configuring anything. Override with
#[column(type = varchar(N))] for N up to 65,535 (see
Field Options). The
schema builder rejects larger values.
Full unsigned 64-bit range. MySQL has native unsigned integer
types, so u64 rides BIGINT UNSIGNED and can hold the full
0..=2⁶⁴−1 range. This is the only Toasty backend where u64 is not
capped at i64::MAX.
UUIDs go in VARCHAR(36). MySQL has no native UUID type. Toasty
stores UUIDs as their hyphenated text form. A 16-byte BINARY(16)
column would pack tighter, but VARCHAR(36) is easier to inspect from
a SQL prompt.
jiff::Timestamp maps to DATETIME(6), not TIMESTAMP. MySQL’s
TIMESTAMP only spans 1970-01-01 to 2038-01-19. Toasty uses
DATETIME(6) instead and converts to and from UTC at the driver
layer, so values round-trip as UTC instants without being bound to
the 2038 cutoff.
Decimal requires fixed precision and scale. Unlike PostgreSQL’s
NUMERIC, MySQL’s DECIMAL always has a declared precision and
scale; there is no arbitrary-precision mode. Set them with
#[column(type = decimal(p, s))] when declaring the field.
BigDecimal works natively on MySQL. Toasty rides DECIMAL(p, s) for bigdecimal::BigDecimal here. PostgreSQL falls back to text
for BigDecimal; MySQL is currently the only backend that exchanges
it as a native decimal value over the wire.
Vec<scalar> goes in a JSON
column. Toasty serializes the list to a JSON array at bind time and
parses it back on read. Array predicates (contains, is_superset,
intersects, len, is_empty) lower to MySQL’s JSON_CONTAINS,
JSON_LENGTH, and related functions.
jiff::Zoned stores as TEXT. MySQL has no column type that
carries an IANA zone name alongside an instant, so zoned values
round-trip through text.
Behavior specific to MySQL
Toasty enables these features automatically when the driver is MySQL. No configuration is required.
Full unsigned 64-bit integers. Values up to u64::MAX round-trip
through BIGINT UNSIGNED without truncation. SQLite and PostgreSQL
cap unsigned types at i64::MAX.
Inline enum columns. An embed-tagged Rust enum
maps to a column declared ENUM('variant_a', 'variant_b', ...). There
is no separate named type to maintain; adding a variant emits an
ALTER TABLE ... MODIFY COLUMN against the same column.
Both Decimal and BigDecimal are native. Enable the
rust_decimal feature for rust_decimal::Decimal, the bigdecimal
feature for bigdecimal::BigDecimal, or both. Each maps to
DECIMAL(p, s) with declared precision and scale.
Row-level locking. Generated transactions can
use SELECT ... FOR UPDATE to lock rows for the duration of a
transaction.
Backward pagination.
.paginate(per_page).prev(&db)
walks backwards from a page cursor.
A few things that exist on PostgreSQL are absent here:
No ILIKE. MySQL’s .ilike()
filter lowers to plain LIKE, whose case sensitivity depends on the
collation of the column.
utf8mb4_unicode_ci and similar _ci collations match
case-insensitively for free; binary or _bin collations match
case-sensitively. Pick the collation that matches the semantics you
want when declaring the column.
No RETURNING from INSERT or UPDATE. MySQL does not support
RETURNING clauses on mutations. For inserts into a table with an
auto-increment primary key, Toasty fetches the generated ID with
LAST_INSERT_ID() on the same connection and synthesizes the same
result a RETURNING clause would produce. This is transparent at the
API level — Model::create() returns a
populated model the same way it does on other backends. The constraint to know about: if you wire
up a RETURNING-style read through a non-auto-increment column,
Toasty will reject the query rather than silently issue a second
round-trip.
No CTE-driven updates. MySQL does not allow UPDATE inside a
common table expression. The query planner avoids generating those.
Most update patterns do not need them; the engine routes complex
multi-statement updates differently on this backend.
Migrations
The migration generator emits DDL that MySQL can apply inside a
single transaction, and apply_migration wraps each migration in
START TRANSACTION / COMMIT. A failure rolls back the migration
and the bookkeeping row in __toasty_migrations together.
Two MySQL-specific behaviors worth knowing:
Column changes are atomic. MySQL’s ALTER TABLE ... MODIFY COLUMN rewrites name, type, nullability, and default in a single
statement. The migration generator takes advantage of this — a
property change emits one statement, not several. PostgreSQL needs
one statement per property; MySQL does not.
Enum types live inline on the column. Adding a variant emits an
ALTER TABLE ... MODIFY COLUMN against the column whose type is the
enum. There is no separate CREATE TYPE step.
The migration tooling does not yet manage zero-downtime online
migrations on MySQL (pt-online-schema-change-style copy and swap,
gh-ost integration, etc.); migrations assume exclusive access to the
schema for their duration.
Errors and the connection pool
The driver classifies mysql_async errors into Toasty’s typed error
variants so the pool and caller can react sensibly.
| MySQL error or condition | Toasty error |
|---|---|
Error 1213 (ER_LOCK_DEADLOCK) | Error::SerializationFailure — retryable. InnoDB rolled back the transaction to break a deadlock; retry the unit of work. |
Error 1792 (ER_CANT_EXECUTE_IN_READ_ONLY_TRANSACTION) | Error::ReadOnlyTransaction — the connection is read-only. |
| Other server errors with a SQLSTATE | Error::DriverOperationFailed |
| Socket / protocol errors (closed connection, pool disconnected) | Error::ConnectionLost |
A ConnectionLost error tells the pool to evict the failed
connection and flips the connection’s internal validity flag so the
pool does not hand it back out. The next acquire pings idle
connections, drops the ones that fail, and opens a fresh slot if
needed — so a backend restart typically costs one failed user query
rather than one per pooled connection. See
Database Setup for the pool
knobs (max_pool_size, pool_pre_ping,
pool_health_check_interval, …) and what they do.
mysql_async caches prepared statements per connection (up to 32 by
default, tunable via the stmt_cache_size URL parameter). The cache
is bound to the connection and is dropped when the connection is
evicted, so it does not cause stale-state issues after a backend
restart.
SQLite
Toasty’s SQLite driver uses rusqlite under the hood. It runs the
full Toasty query surface — SELECT, INSERT, UPDATE, DELETE,
RETURNING, transactions, scalar arrays via JSON1 — against either a
file-backed database or an ephemeral in-memory one.
Enabling the driver
Add the sqlite feature to Toasty in Cargo.toml:
[dependencies]
toasty = { version = "0.6", features = ["sqlite"] }
Then pass a sqlite: URL to Db::builder:
let db = toasty::Db::builder()
.models(toasty::models!(crate::*))
.connect("sqlite::memory:")
.await?;
For a file-backed database, point at a path on disk:
let db = toasty::Db::builder()
.models(toasty::models!(crate::*))
.connect("sqlite:./app.db")
.await?;
You can also construct the driver yourself and pass it to build()
instead of connect() — useful in tests that want an in-memory database
without parsing a URL:
let driver = toasty_driver_sqlite::Sqlite::in_memory();
let db = toasty::Db::builder()
.models(toasty::models!(crate::*))
.build(driver)
.await?;
Connection URL options
The driver recognizes two URL forms:
| URL | Meaning |
|---|---|
sqlite::memory: | An in-memory database. Each connection opens a fresh database — see the section on in-memory databases below. |
sqlite:<path> | A file-backed database at <path>. Relative paths resolve against the process’s working directory. |
The driver does not parse query parameters from the URL. To set
SQLite pragmas (journal_mode, synchronous, foreign_keys, …),
construct the driver directly and issue the pragmas through your own
connection-setup code, or open the database with Sqlite::open and
work from there.
Type mapping
Toasty maps Rust types to SQLite columns as follows. SQLite uses type affinity rather than strict types, so several Rust types share an underlying SQLite storage class.
| Rust type | SQLite column type |
|---|---|
bool | BOOLEAN (stored as INTEGER 0/1) |
i8, i16 | SMALLINT |
i32 | INTEGER |
i64 | BIGINT |
u8, u16, u32, u64 | INTEGER |
f32, f64 | REAL |
String | TEXT by default; VARCHAR(N) with #[column(type = varchar(N))] |
Vec<u8> | BLOB |
uuid::Uuid | BLOB |
rust_decimal::Decimal (feature) | TEXT |
bigdecimal::BigDecimal (feature) | TEXT |
jiff::Timestamp (feature) | TEXT (ISO 8601) |
jiff::Zoned (feature) | TEXT (ISO 8601) |
jiff::civil::Date (feature) | TEXT (ISO 8601) |
jiff::civil::Time (feature) | TEXT (ISO 8601) |
jiff::civil::DateTime (feature) | TEXT (ISO 8601) |
Vec<T> (T scalar) | TEXT holding a JSON array |
Embedded enum | TEXT with a CHECK constraint over the variant names |
Notes on the type mapping
UUIDs are stored as BLOB. SQLite has no native UUID type. The
driver writes the 16-byte representation and reads it back through the
Uuid parser. Pick #[column(type = text)] on the field if you’d
rather store the hyphenated string form — at the cost of more bytes
per row and a slower comparison.
Temporal types are ISO 8601 text. Every jiff type lands in a
TEXT column. Values round-trip losslessly, but range and ordering
queries compare strings rather than packed timestamps. Index the
column if you query by date frequently; the lexicographic order of
ISO 8601 matches chronological order.
Decimals are stored as TEXT. SQLite has no native fixed-point
type. Both rust_decimal::Decimal and bigdecimal::BigDecimal
round-trip through text. Arithmetic in SQL coerces to REAL, which
loses precision — keep decimal math in Rust.
VARCHAR(N) does not enforce N. SQLite ignores the length
specifier on VARCHAR, CHAR, and TEXT-affinity types. A field
declared #[column(type = varchar(10))] accepts strings of any
length; the only hard cap is SQLite’s SQLITE_MAX_LENGTH, which is
one billion by default. Validate lengths in your application code if
you need the limit enforced.
Unsigned integers cap at i64::MAX. SQLite’s INTEGER is a
signed 64-bit value. u8, u16, and u32 round-trip without
trouble; a u64 value above i64::MAX (≈9.22 × 10¹⁸) rejects on
insert.
Embedded enums become TEXT plus a CHECK.
SQLite has no ENUM type. Each variant stores as its name, and the
column carries a CHECK constraint listing the allowed values.
Behavior specific to SQLite
Most Toasty features work the same on SQLite as on PostgreSQL or
MySQL — filters, joins implemented via subqueries, RETURNING on
mutations, batch operations,
pagination in both directions,
embedded types,
#[unique],
association preloading, and
serializable transactions all run natively. A few
behaviors differ from the other SQL backends:
LIKE is case-insensitive for ASCII. SQLite’s default LIKE
ignores case for ASCII characters. The
.ilike() filter lowers to
the same LIKE, so case-insensitive matching works — but a
.like("Rust") filter also matches "rust" and "RUST". Use
GLOB (which Toasty does not currently expose) or a CHECK against
exact bytes if you need case-sensitive pattern matching.
No native prefix-match operator.
.starts_with("abc")
lowers to LIKE 'abc%'. The optimizer can use a regular index for the
common-prefix lookup.
Scalar arrays use JSON1. A
Vec<T> field lives in a TEXT
column holding a JSON array. The array predicates lower to JSON1
expressions:
| Method | SQLite expression |
|---|---|
.contains(value) | value IN (SELECT value FROM json_each(col)) |
.is_superset(values) | NOT EXISTS (SELECT 1 FROM json_each(rhs) AS r WHERE r.value NOT IN (SELECT value FROM json_each(col))) |
.intersects(values) | EXISTS (SELECT 1 FROM json_each(rhs) AS r WHERE r.value IN (SELECT value FROM json_each(col))) |
.len() | json_array_length(col) |
These subqueries scan the JSON document, so array predicates against a large table do not use an index. See Field Options for the model-level view.
No row-level locking. SQLite has no SELECT ... FOR UPDATE.
Toasty’s transaction layer falls back to
serializable isolation — which is SQLite’s only isolation level — so
the guarantees are still sound; you just can’t pin individual rows for
the duration of a transaction.
Only Serializable isolation. Starting a transaction with any
other isolation level returns Error::UnsupportedFeature. The
default (no explicit level) is accepted and runs as serializable.
let users = User::filter(User::fields().email().ilike("%@example.com"))
.exec(&mut db)
.await?;
In-memory databases
The sqlite::memory: URL opens an ephemeral database that lives only
as long as the connection. Two practical consequences fall out of
that:
The pool caps at one connection. The driver reports
max_connections() = Some(1) for in-memory mode, so the pool will
never open a second connection — opening one would land in a
different, empty database. Concurrent queries serialize on that one
slot. For tests this is usually fine; for anything else, use a
file-backed database.
reset_db is a no-op. Each connect produces a fresh in-memory
database, so there is nothing to clear between runs.
In-memory mode is the standard choice for unit tests, embedded examples, and the rustdoc examples throughout this guide.
Migrations
apply_migration wraps each migration in BEGIN / COMMIT; a
statement failure rolls the migration back. The migration generator
emits SQLite-compatible DDL, with one important caveat:
SQLite cannot ALTER COLUMN a type. Changing a column’s type,
nullability, or auto-increment status requires rebuilding the table.
The migration generator handles this automatically with the standard
six-step rebuild:
PRAGMA foreign_keys = OFFCREATE TABLE _toasty_new_<name>with the target schemaINSERT INTO _toasty_new_<name> SELECT ... FROM <name>(renames are tracked, so the column mapping uses the old names on the source side)DROP TABLE <name>ALTER TABLE _toasty_new_<name> RENAME TO <name>PRAGMA foreign_keys = ON
The rebuild copies every row, so a column type change on a large
table is proportional to the table size. Renaming a column on its
own goes through ALTER TABLE ... RENAME COLUMN and does not
trigger a rebuild. Adding or dropping a column uses the corresponding
ALTER TABLE form when no type-changing alterations are present in
the same diff.
The migration tooling does not yet manage zero-downtime online migrations.
Errors and the connection pool
The driver does not classify errors into Toasty’s typed retry
variants. Every rusqlite failure surfaces as
Error::DriverOperationFailed, with two specific exceptions:
| Condition | Toasty error |
|---|---|
URL with a non-sqlite scheme | Error::InvalidConnectionUrl |
Transaction started with an isolation level other than Serializable | Error::UnsupportedFeature |
SQLite has no out-of-process backend to lose, so there are no
ConnectionLost errors and no health-check pings to perform. The
driver’s is_valid and ping implementations are the defaults — a
constant true and a no-op — and the pool’s background sweep does
no work against a SQLite connection.
The pool sizing knobs from
Database Setup still apply for
file-backed databases. In-memory mode pins the pool to a single
connection regardless of max_pool_size.
DynamoDB
Toasty’s DynamoDB driver uses the official aws-sdk-dynamodb crate.
DynamoDB is a key-value / document store, not a SQL database, so the
mapping is meaningfully different from the SQL backends. Most Toasty
model code still works — create!,
find_by_pk,
filter_by_<indexed>,
#[unique],
#[version],
Vec<scalar> fields — but the set
of queries you can write is narrower, and the chapter below catalogues
the gaps so you can avoid building a model that the driver cannot
serve.
Enabling the driver
Add the dynamodb feature to Toasty in Cargo.toml:
[dependencies]
toasty = { version = "0.6", features = ["dynamodb"] }
Then pass a dynamodb:// URL to Db::builder:
let db = toasty::Db::builder()
.models(toasty::models!(crate::*))
.connect("dynamodb://us-east-1")
.await?;
The URL is opaque to the driver — only the scheme matters. AWS region,
credentials, and the optional endpoint override come from the standard
AWS configuration sources: AWS_REGION, AWS_ACCESS_KEY_ID /
AWS_SECRET_ACCESS_KEY, the shared ~/.aws/credentials file, IAM
instance profiles, and so on. The driver calls
aws_config::defaults(BehaviorVersion::latest()) and uses whatever
that resolves.
To point at a local DynamoDB instance for development or tests, set
AWS_ENDPOINT_URL_DYNAMODB:
AWS_ENDPOINT_URL_DYNAMODB=http://localhost:8000 \
AWS_REGION=us-east-1 \
AWS_ACCESS_KEY_ID=dummy AWS_SECRET_ACCESS_KEY=dummy \
cargo run
The Toasty repository’s compose.yaml boots amazon/dynamodb-local on
port 8000 for this purpose.
The connection pool described in Database Setup
does not apply: the AWS SDK manages its own HTTP connections internally,
so max_pool_size and the related knobs have no effect on this backend.
Behavior specific to DynamoDB
Most of the Toasty surface works the same way on DynamoDB as on a SQL
backend: define a model, derive Model, and
call create!,
find_by_pk,
filter_by_<field>,
update(), and
delete().
Associations,
embedded structs and enums,
#[unique], and
#[version] all work.
What’s different falls into three buckets:
- No native types. DynamoDB has three scalar attribute types:
string (
S), number (N), and binary (B). Everything else — UUIDs, timestamps, dates, decimals, enums — rides on top of those. - A narrower set of supported queries. No
LIKE, no!=on the primary key, no backward pagination, noORDER BYon a full-table scan. - No interactive transactions and no migrations. The driver
creates tables on
push_schemabut does not generate or apply migrations, anddb.transaction()returns an error.
The rest of this chapter walks through each of these.
Type mapping
DynamoDB attributes are one of three scalar types or a List. Toasty encodes Rust values into them as follows:
| Rust type | DynamoDB attribute |
|---|---|
bool | BOOL |
i8, i16, i32, i64 | N (number, stringified on the wire) |
u8, u16, u32, u64 | N |
f32, f64 | N |
String | S |
Vec<u8> | B |
uuid::Uuid | S (hyphenated form) |
rust_decimal::Decimal (feature) | S |
jiff::Timestamp (feature) | S (ISO 8601) |
jiff::civil::Date / Time / DateTime (feature) | S |
Embedded enum | S (variant tag) plus per-variant data attributes |
Vec<T> (T scalar) | L (DynamoDB list of element attributes) |
Notes on the type mapping
Numbers ride as strings. DynamoDB’s N type is a decimal string on
the wire with up to 38 digits of precision. Integers and floats all
flow through this representation; Toasty parses them back into the Rust
type declared on the field. A u64 field accepts the full unsigned
range — unlike the SQL backends, which cap unsigned integers at
i64::MAX because they ride a signed column.
No native temporal types. Timestamps, dates, times, and datetimes all serialize to ISO 8601 strings. Sort order on a string-encoded timestamp matches chronological order, so range queries on a sort key work as expected — but the database has no awareness that the column is a date.
No native decimal type. rust_decimal::Decimal round-trips through
a string. Comparison still works, but the database does not normalize
or round.
No native enum type. An embed-tagged enum stores the variant tag
in one attribute and the variant’s data fields in further attributes on
the same item — there is no separate enum type object.
Vec<scalar> lives on the L attribute. A tags: Vec<String>
field maps to a single List attribute containing one S element per
tag. See Field Options for the model-level
details.
Keys and indexes
Every DynamoDB table has a primary key built from a partition key
and an optional sort key (called the “local key” in Toasty’s macro
syntax). Use #[key(partition = ..., local = ...)] to map a model onto
that layout (see Keys and Auto-Generation):
#[derive(Debug, toasty::Model)]
#[key(partition = user_id, local = id)]
struct Post {
user_id: uuid::Uuid,
id: uuid::Uuid,
title: String,
}
A single #[key] field on a regular type compiles to a partition-only
table.
What works as a key type
The partition and sort key columns must be a string, a number, or a
binary blob — DynamoDB has no other key types. Toasty maps Rust types
onto those: String and uuid::Uuid become S, all integer and float
types become N, and Vec<u8> becomes B. Anything else as the key
type is a schema-build error.
Auto-generation
#[auto] on a uuid::Uuid key works as on the SQL backends — it
generates a UUID v7 client-side. Auto-incrementing integer keys
(#[auto] on i64, u64, etc.) do not work on DynamoDB; the
database has no equivalent. Either pre-generate the key value yourself
or pick uuid::Uuid as the key type.
Secondary indexes
A #[index] on a non-key field becomes a Global Secondary Index (GSI)
that projects all attributes. The GSI is built when push_schema
creates the table and can be queried through the usual
filter_by_<field> accessor.
Uniqueness
#[unique] on a non-key field is enforced through a separate index
table — DynamoDB has no native unique constraint. For each unique
index Toasty creates a second table keyed on the unique value, and
inserts/updates that touch a unique field run through
TransactWriteItems: one Put against the main table plus one Put
against the index table guarded by attribute_not_exists. A duplicate
fails the conditional check and the entire transaction rolls back, so
the main table and the index table stay consistent.
Two operational consequences worth knowing:
- Every insert that touches a unique field issues two writes (one to the base table, one to each index table). The cost shows up in the AWS bill.
- Updates that change a unique value read the old value first, then delete the old index entry and insert the new one inside a single transaction. The transaction’s condition expression catches concurrent writers atomically.
See Indexes and Unique Constraints for the model-level syntax.
Billing mode
Tables and GSIs are created with on-demand (PAY_PER_REQUEST) billing. Toasty does not currently expose a knob to switch to provisioned capacity — change that on the AWS console after the table exists if your workload needs it.
Supported queries
The query builder methods that translate cleanly to DynamoDB:
find_by_pk(...)— single-itemGetItemagainst the base table.filter_by_<partition>(...)andfilter_by_<partition>_and_<local>(...)—Queryagainst the base table with the supplied values forming the key condition.filter_by_<indexed_field>(...)—Queryagainst the GSI.find_by_<unique_field>(...)—Queryagainst the unique index table to fetch the primary key, followed byGetItemon the base table..limit(n)and.limit(n).offset(m)— server-sideLimitplus cursor paging;offsetis emulated by fetchingn + mitems and discarding the firstm..order_by(field.asc())/.order_by(field.desc())on a sort key or GSI sort key —QueryflipsScanIndexForward..select(...)for column-narrow reads — projected toProjectionExpressionon the underlying operation..starts_with("prefix")on a string column — nativebegins_with().Vec<scalar>predicates:.contains(v),.len(),.is_empty()lower to DynamoDB’scontains()andsize()functions.
Cursor-based pagination
(.paginate(per_page).next()) works and tracks DynamoDB’s
LastEvaluatedKey under the hood. The 1 MB-per-response cap that
DynamoDB imposes on Query and Scan is transparent: the driver
follows LastEvaluatedKey automatically when the caller hasn’t passed
a limit.
Unsupported queries
These will either fail at planning time, fall back to a full-table scan, or panic in the driver:
!= on the primary key. The query planner removes the predicate
from the index key condition and applies it as a post-filter, which
forces a Scan instead of a Query. Avoid != against the partition
or sort key — it defeats the index.
LIKE and ILIKE. DynamoDB has no LIKE operator. Calling
.like(...) or .ilike(...)
on a string field panics inside the driver. Use .starts_with(...)
for prefix matching; substring and suffix matching are not supported
by the backend at all and have to be done client-side after fetching
the rows.
Backward pagination.
.paginate(per_page).prev(&db)
does not work — the driver does not produce a prev_cursor. DynamoDB
itself can walk a query in either direction, but Toasty does not
currently generate the reverse cursor.
ORDER BY on a full-table scan. DynamoDB’s Scan API returns
items in an arbitrary order with no server-side sort. A query that
needs both a scan and an ordering returns an unsupported-feature
error at planning time. Order is only available on a Query, which
means the partition key must be pinned.
is_superset / intersects with a non-literal right-hand side.
For Vec<scalar> fields these
predicates expand to one contains() clause per element on the
right-hand side, so the right-hand side has to be a concrete Vec<T>
known at query-construction time. A column
reference or a subquery on the right is rejected with an
unsupported-feature error. On the SQL backends the same predicates
take any expression on the right.
// Works — concrete Vec on the right.
let admins = User::filter(User::fields().roles().is_superset(vec!["admin", "owner"]))
.all(&mut db)
.await?;
Scans vs queries
A Query against DynamoDB is bounded by a partition key — it touches
only the items in one partition and is paid for by the bytes returned.
A Scan reads every item in the table and pays for every byte read.
Costs and latency between the two diverge sharply once a table grows.
The Toasty engine picks between them based on which fields the filter touches:
- A filter that pins the partition key (and optionally constrains the
sort key) compiles to a
Query. - A filter on a
#[index]field compiles to aQueryagainst that GSI. - A filter on a
#[unique]field compiles to aQueryagainst the unique index table. - A filter that pins no key — or one that uses
!=on the partition key — falls back to aScanwith aFilterExpression.
Scan results come back unordered, so .order_by(...) combined with a
scan returns an error rather than silently re-sorting client-side. If
you need ordered access to every row, the model needs a partition key
you can pin (a synthetic “all rows” partition is a common pattern) and
a sort key to order on.
A Scan with no limit follows LastEvaluatedKey to drain every
page; a Scan with .limit(n) stops after the page that satisfies
the limit. Either way, full-table scans are expensive enough that the
driver does not emit them for queries that could match an index.
Transactions and concurrency
No interactive transactions.
db.transaction() returns an error on DynamoDB. The Operation::Transaction variant that the SQL drivers
handle is rejected with an unsupported-feature error before it reaches
the wire. DynamoDB does have TransactWriteItems, but it’s a single
RPC that takes the full batch up front — not the begin/execute/commit
sequence the Toasty Transaction API exposes. Toasty uses
TransactWriteItems internally for unique-index maintenance and for
batched writes that need conditional checks, but it does not surface
as a transaction handle.
No row-level locking. DynamoDB has no equivalent of SELECT ... FOR UPDATE. The engine relies on optimistic concurrency control
(#[version]) and on DynamoDB’s per-item conditional expressions for
mutation-time safety.
Optimistic concurrency works. A #[version] field on a model
maps to a conditional UpdateItem (or a conditional Put inside a
transaction): the driver builds the right attribute_not_exists check
on insert and the right version = :old check on update. A concurrent
writer causes the conditional check to fail, and the engine surfaces
the failure as a serialization error so the caller can retry. See
Concurrency Control for the model-level
syntax.
Unique-index updates are atomic with the main item. Updating a
record’s unique field issues a single TransactWriteItems that
covers the base-table update, the old index entry’s delete, and the
new index entry’s insert. Either everything commits or nothing does;
the index table will not drift out of sync with the main table.
Migrations
The driver supports schema creation but does not generate migrations.
On first run, push_schema issues CreateTable for each model — base
tables plus one auxiliary table per unique index — with the GSIs and
attribute definitions filled in from the model. Tables are created
with on-demand billing.
generate_migration and apply_migration are not implemented and
will panic if called. The driver does not change column types, add
GSIs, or rename attributes on an existing table — DynamoDB itself
treats most of those as expensive or impossible operations, and Toasty
does not paper over the difference.
The practical upshot: model evolution on DynamoDB is a manual process
today. Adding a new attribute to a model is generally fine (DynamoDB
items are schemaless and new fields on new writes coexist with old
items that don’t have them), but adding a GSI or a #[unique]
constraint to an existing model requires deleting and recreating the
table, or doing the schema change through the AWS console and
back-filling the index data yourself. The Migrations and Schema
Management chapter has more on what Toasty’s
migration tooling does on the SQL backends — none of it currently
applies here.