Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 users
  • User::get_by_id() — fetch a user by primary key
  • User::get_by_email() — fetch a user by the unique email field
  • User::all() — query all users
  • user.update() — a builder for modifying a user
  • user.delete() — remove a user
  • User::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

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 wroteToasty generated
struct Usertoasty::create!(User { ... }) — insert rows
#[key] on idUser::get_by_id() — fetch by primary key
#[auto] on idAuto-generates an ID when you create a user
#[unique] on emailUser::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 typeDatabase type
boolBoolean
StringText
i8, i16, i32, i64Integer (1, 2, 4, 8 bytes)
u8, u16, u32, u64Unsigned integer
f32, f64Floating point
uuid::UuidUUID
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:

FeatureRust typeDatabase type
rust_decimalrust_decimal::DecimalDecimal
bigdecimalbigdecimal::BigDecimalDecimal
jiffjiff::TimestampTimestamp
jiffjiff::civil::DateDate
jiffjiff::civil::TimeTime
jiffjiff::civil::DateTimeDateTime

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() or User::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] behaviorExplicit form
uuid::UuidGenerates 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:

AttributeGenerated methods
#[key] on single fieldget_by_<field>(), filter_by_<field>(), delete_by_<field>()
#[key] on multiple fieldsget_by_<a>_and_<b>(), filter_by_<a>_and_<b>(), delete_by_<a>_and_<b>()
#[key(a, b)] on structSame 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 syntaxBuilder 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 field
  • toasty::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:

MethodReturnsDescription
User::all()Query builderAll records
User::filter(expr)Query builderRecords matching expression
User::filter_by_id(id)Query builderRecords matching key
User::filter_by_email(email)Query builderRecords 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:

MethodReturns
.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 (consumes self), 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:

MethodDescription
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)]:

MethodColumns 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:

MethodDescription
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

BehaviorSQLDynamoDB
Index structureCREATE INDEX with all columns in orderGSI with HASH and RANGE key entries
Partition / local distinctionIgnored — all columns form a flat composite indexpartition = KeyType::Hash, local = KeyType::Range
Query matchingDatabase uses leftmost-prefix matchingAll partition fields required; local fields optional left-to-right
Column limitsNo artificial limitsUp 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:

AttributeMeaningDatabase effect (SQL)
#[unique]Each record has a distinct valueCREATE UNIQUE INDEX — the database rejects duplicates
#[index]Multiple records may share a valueCREATE 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:

MethodDescription
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 syntaxDatabase type
booleanBoolean
int, i8, i16, i32, i64Integer (various sizes)
uint, u8, u16, u32, u64Unsigned integer
textText
varchar(N)Variable-length string with max length
numeric, numeric(P, S)Decimal with optional precision and scale
binary(N), blobBinary data
timestamp(P)Timestamp with precision
dateDate
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 nameField type#[auto] expands to
created_atjiff::Timestamp#[default(jiff::Timestamp::now())] — set once on create
updated_atjiff::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 typeDescription
jiff::TimestampAn instant in time (UTC)
jiff::civil::DateA date without time
jiff::civil::TimeA time of day without date
jiff::civil::DateTimeA 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:

  • None maps to SQL NULL in the database
  • Some(value) maps to the JSON string representation

Without nullable:

  • None maps 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

AttributePurposeApplies on
#[column("name")]Custom column name
#[column(type = ...)]Explicit column type
#[default(expr)]Default valueCreate only
#[update(expr)]Automatic valueCreate and update
#[auto] on created_atShorthand for #[default(jiff::Timestamp::now())]Create only
#[auto] on updated_atShorthand for #[update(jiff::Timestamp::now())]Create and update
#[serialize(json)]Store as JSON textCreate and update
#[serialize(json, nullable)]Store as JSON text with SQL NULL supportCreate 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:

DriverRepresentation
PostgreSQLNative array column (text[], int8[], double precision[], …)
MySQLJSON column
SQLiteJSON-encoded text
DynamoDBList 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:

MethodMeaning
.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():

FunctionWhat 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:

OperationPostgreSQLMySQLSQLiteDynamoDB
Define field, create, read
contains, len, is_empty
is_superset, intersectsliteral 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.

TypeForeign key onParent hasChild hasExample
BelongsToThis modelOne parentA post belongs to a user
HasManyOther modelMany childrenA user has many posts
HasOneOther modelOne childA 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.

When you delete a parent record or disassociate a child, Toasty automatically maintains consistency based on the foreign key’s nullability:

ActionFK is required (u64)FK is optional (Option<u64>)
Delete parentChild is deletedChild stays, FK set to NULL
Unset relation (e.g., update().profile(None))Child is deletedChild stays, FK set to NULL
Delete childParent is unaffectedParent 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…UseFK goes on
A post has one authorPostBelongsTo<User> + UserHasMany<Post>posts.user_id
A user has one profileUserHasOne<Profile> + ProfileBelongsTo<User>profiles.user_id
A comment belongs to a postCommentBelongsTo<Post> + PostHasMany<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.

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:

MethodReturnsDescription
post.user()Relation accessorReturns an accessor for the associated user
.get(&mut db)Result<User>Loads the associated user from the database
toasty::create!(Post { user: &user })Create builderSets the foreign key from a parent reference
toasty::create!(Post { user_id: id })Create builderSets the foreign key directly
Post::fields().user()Field pathUsed 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 typeEffect 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:

MethodReturnsDescription
user.posts()Relation accessorAccessor scoped to this user’s posts
.exec(&mut db)Result<Vec<Post>>All posts belonging to this user
.create()Create builderCreate 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 builderFilter 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:

MethodDescription
toasty::create!(User { posts: [{ ... }] })Add children to create alongside the parent

On the fields accessor:

MethodDescription
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(())
}
}

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 typeEffect 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 to NULL, leaving the child record in place.

What gets generated

For a User model with #[has_one] profile: HasOne<Option<Profile>>, Toasty generates:

MethodReturnsDescription
user.profile()Relation accessorAccessor for the associated profile
.get(&mut db)Result<Option<Profile>>Load the associated profile
.create()Create builderCreate a profile with the foreign key pre-filled
toasty::create!(User { profile: { ... } })Create builderAssociate a profile on creation
user.update().profile(...)Update builderReplace or associate a profile
user.update().profile(None)Update builderDisassociate the profile
User::fields().profile()Field pathUsed 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 patternAsyncWhen to useQueries
user.posts().exec(&mut db).await?YesRelation was not preloadedExecutes a query
user.posts.get()NoRelation 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

SyntaxDescription
.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.

ExpressionDescriptionDatabase 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 listIN (...)
.is_none()Null check (Option fields)IS NULL
.is_some()Not-null check (Option fields)IS NOT NULL
.starts_with(prefix)Prefix matchbegins_with(field, prefix) / LIKE 'prefix%'
.like(pattern)SQL pattern matchLIKE pattern
.ilike(pattern)Case-insensitive SQL pattern matchILIKE pattern
.and(expr)Both conditions trueAND
.or(expr)Either condition trueOR
.not() / !exprNegate conditionNOT
.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:

MethodMeaning
.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() 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.

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:

MethodDescription
.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:

MethodReturnsDescription
.next(&mut db)Result<Option<Page>>Fetch next page
.prev(&mut db)Result<Option<Page>>Fetch previous page
.has_next()boolWhether a next page exists
.has_prev()boolWhether a previous page exists
.itemsVec<M>The records in this page
.len()usizeNumber of items (via Deref to slice)
.iter()iteratorIterate 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()
ScopeSingle modelAny mix of models, queries, and creates
Return typeVec<Model>Matches the input structure
Use caseInsert many records of the same typeCombine 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:

LevelDescription
ReadUncommittedAllows dirty reads
ReadCommittedOnly reads committed data
RepeatableReadConsistent reads within the transaction
SerializableFull 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.

SchemeDatabaseFeature flag
sqliteSQLitesqlite
postgresql or postgresPostgreSQLpostgresql
mysqlMySQLmysql
dynamodbDynamoDBdynamodb

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 methodDefaultPurpose
max_pool_size(n)num_cpus * 2Cap on simultaneous open connections. Drivers may enforce a lower cap (e.g., in-memory SQLite is single-connection).
pool_wait_timeout(Some(d))NoneMaximum time Db waits for a free connection before returning an error. None waits indefinitely.
pool_create_timeout(Some(d))NoneMaximum 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)falsePing 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:

CommandWhat it does
migration generateDiffs the current schema against the last snapshot and writes a SQL migration file
migration applyRuns pending migrations against the database
migration snapshotPrints the current schema as TOML
migration dropRemoves a migration from history and deletes its files
migration resetDrops 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:

OptionDefaultDescription
path"toasty"Base directory for migration files, snapshots, and history
prefix_style"Sequential"File naming: "Sequential" (0001_, 0002_) or "Timestamp" (20240112_153045_)
checksumsfalseWhen true, stores MD5 checksums in history to detect modified migration files
statement_breakpointstrueAdds -- #[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 all CREATE TABLE and CREATE INDEX statements.
  • snapshots/0000_snapshot.toml — a TOML serialization of the full schema at this point. The next generate run 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:

  1. Edit your model structs (add a field, change a type, add an index)
  2. Run migration generate --name describe_change
  3. Review the generated SQL file
  4. Run migration apply to update the database
  5. 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:

FieldMeaning
db.systemDriver that ran the statement: sqlite, postgresql, or mysql.
db.statementThe serialized SQL, with ?N (SQLite, MySQL) or $N (PostgreSQL) placeholders for parameters.
paramsNumber 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:

ParameterPurpose
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

sslmodeWhat it does
disablePlain 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.
requireRequire TLS, but accept any certificate.
verify-caRequire TLS and verify the certificate chains to a trusted root.
verify-fullverify-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 typePostgreSQL column type
boolBOOL
i8, i16SMALLINT (INT2)
i32INTEGER (INT4)
i64BIGINT (INT8)
u8SMALLINT (INT2)
u16INTEGER (INT4)
u32, u64BIGINT (INT8)
f32REAL (FLOAT4)
f64DOUBLE PRECISION (FLOAT8)
StringTEXT by default; VARCHAR(N) with #[column(type = varchar(N))]
Vec<u8>BYTEA
uuid::UuidUUID
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 enumNative 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 conditionToasty 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 SQLSTATEError::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:

ParameterPurpose
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 typeMySQL column type
boolBOOLEAN (a TINYINT(1) alias)
i8TINYINT
i16SMALLINT
i32INTEGER
i64BIGINT
u8TINYINT UNSIGNED
u16SMALLINT UNSIGNED
u32INTEGER UNSIGNED
u64BIGINT UNSIGNED
f32FLOAT
f64DOUBLE
StringVARCHAR(191) by default; override with #[column(type = varchar(N))]
Vec<u8>BLOB
uuid::UuidVARCHAR(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 enumInline 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 conditionToasty 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 SQLSTATEError::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:

URLMeaning
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 typeSQLite column type
boolBOOLEAN (stored as INTEGER 0/1)
i8, i16SMALLINT
i32INTEGER
i64BIGINT
u8, u16, u32, u64INTEGER
f32, f64REAL
StringTEXT by default; VARCHAR(N) with #[column(type = varchar(N))]
Vec<u8>BLOB
uuid::UuidBLOB
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 enumTEXT 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:

MethodSQLite 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:

  1. PRAGMA foreign_keys = OFF
  2. CREATE TABLE _toasty_new_<name> with the target schema
  3. INSERT INTO _toasty_new_<name> SELECT ... FROM <name> (renames are tracked, so the column mapping uses the old names on the source side)
  4. DROP TABLE <name>
  5. ALTER TABLE _toasty_new_<name> RENAME TO <name>
  6. 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:

ConditionToasty error
URL with a non-sqlite schemeError::InvalidConnectionUrl
Transaction started with an isolation level other than SerializableError::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:

  1. 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.
  2. A narrower set of supported queries. No LIKE, no != on the primary key, no backward pagination, no ORDER BY on a full-table scan.
  3. No interactive transactions and no migrations. The driver creates tables on push_schema but does not generate or apply migrations, and db.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 typeDynamoDB attribute
boolBOOL
i8, i16, i32, i64N (number, stringified on the wire)
u8, u16, u32, u64N
f32, f64N
StringS
Vec<u8>B
uuid::UuidS (hyphenated form)
rust_decimal::Decimal (feature)S
jiff::Timestamp (feature)S (ISO 8601)
jiff::civil::Date / Time / DateTime (feature)S
Embedded enumS (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:

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 a Query against that GSI.
  • A filter on a #[unique] field compiles to a Query against the unique index table.
  • A filter that pins no key — or one that uses != on the partition key — falls back to a Scan with a FilterExpression.

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.