Entities¶
An entity is how turso-orm knows a table. This page explains what the
derive macro generates from a Model struct, how each attribute changes
the generated code and the DDL, how Rust types map to column types, and
how the active model decides what a write touches.
One module per table¶
The convention is a module named after the table, holding a Model
struct and a Relation enum. The macros fill the module with the rest,
so that cake::Entity, cake::Column, cake::ActiveModel and
cake::Model all live side by side under one name:
//! The `cake` entity: belongs to a bakery and carries fruits.
use turso_orm::prelude::*;
/// A row of the `cake` table.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[turso(table_name = "cake")]
pub struct Model {
/// Auto-incremented key.
#[turso(primary_key)]
pub id: i32,
/// The cake name.
pub name: String,
/// Price as a floating point number; use `with-rust_decimal` for money in a real app.
pub price: f64,
/// Whether the cake is gluten free.
pub gluten_free: bool,
/// Free-form attributes stored as JSON.
pub attributes: Option<Json>,
/// The owning bakery; indexed because every lookup goes through it.
#[turso(indexed)]
pub bakery_id: i32,
}
/// The relations of `cake`.
#[derive(Copy, Clone, Debug, DeriveRelation)]
pub enum Relation {
/// The owning bakery; deleting the bakery deletes its cakes.
#[turso(
belongs_to = "super::bakery::Entity",
from = "Column::BakeryId",
to = "super::bakery::Column::Id",
on_delete = "Cascade"
)]
Bakery,
/// One cake, many fruits.
#[turso(has_many = "super::fruit::Entity")]
Fruit,
}
impl ActiveModelBehavior for ActiveModel {}
What DeriveEntityModel produces from that struct:
| Generated item | What it is for |
|---|---|
Entity |
A unit struct that starts queries: Entity::find(), Entity::insert_many(...), Entity::delete_by_id(...). It also knows the table name and the columns. |
Column |
An enum with one variant per field, in UpperCamelCase. It is what you pass to filter, order_by and the loaders, and it carries the condition builders (eq, gt, contains, ...). |
PrimaryKey |
An enum of the key columns, used by find_by_id, update and delete. |
ActiveModel |
The write-side twin of Model, described below. |
Model itself stays exactly as you wrote it. It is a plain value: the
ORM decodes rows into it and hands it back to you, and you can derive
Serialize or anything else on it, as the web examples do.
Do not derive PartialEq on Column
The macro deliberately leaves PartialEq off the generated Column
enum. Column::Name.eq("x") is the condition builder; if the enum
implemented PartialEq, Rust would resolve that call to the trait
method and the filter would silently become a boolean.
Attributes¶
Everything is declared with #[turso(...)]. On the struct, table_name
is the only attribute and it is required: the macro does not guess a
table name from the struct name.
On a field:
| Attribute | Effect |
|---|---|
primary_key |
The field is part of the primary key. A single integer key becomes INTEGER PRIMARY KEY AUTOINCREMENT, which is SQLite's row id, so the database assigns it. Several primary_key fields form a composite key, declared at table level, with no auto-increment. |
auto_increment |
Forces AUTOINCREMENT on, for a single integer key that would not get it by default. |
column_name = "..." |
The column name, when it must differ from the field name turned into snake_case. |
unique |
Adds a UNIQUE constraint to the column in the generated DDL. |
indexed |
Makes Schema::create_index_from_entity emit a CREATE INDEX for the column. Foreign-key columns are a typical candidate, since SQLite does not index them on its own. |
nullable |
Accepted for an explicit declaration, but nullability is already implied by Option<T>. |
default_value = "..." |
A literal DEFAULT in the DDL. It is inlined as text because SQLite does not bind parameters in DDL. |
ignore |
The field is not a column. It must implement Default, since the ORM fills it when decoding a row, and it is left out of the active model. |
The relation attributes (has_many, has_one, belongs_to, from,
to, via, on_delete, on_update, skip_fk) go on the Relation
enum and are explained on the relations page.
Column types¶
The macro asks each field type how it should be declared, through the
TursoType trait. Wrapping a type in Option makes the column nullable
and changes nothing else.
| Rust type | Column type | Stored as |
|---|---|---|
bool |
Boolean |
INTEGER, 0 or 1 |
i8, i16, i32, i64, u8, u16, u32 |
Integer |
INTEGER |
f32, f64 |
Real |
REAL |
String |
Text |
TEXT |
Vec<u8> |
Blob |
BLOB |
NaiveDate, NaiveTime, NaiveDateTime |
Date, Time, DateTime |
TEXT, ISO 8601 |
DateTime<Utc>, DateTime<FixedOffset> |
TimestampWithTimeZone |
TEXT, RFC 3339 |
Uuid |
Uuid |
TEXT, hyphenated |
serde_json::Value |
Json |
TEXT |
Decimal |
Decimal |
TEXT |
An enum of your own can be a column type too; see enum columns below.
The right column matters because SQLite has only five storage classes.
Declaring a column as DATETIME is a hint for readers; what the engine
stores is text, and what turso-orm writes in the DDL are the storage
classes themselves, so that tables can be created as STRICT without
surprises.
How values decode¶
Decoding is driven by the Rust type you ask for, not by the column's
declared type, and it is lenient in the way SQLite users expect: an
integer decodes into a bool, an integral REAL into an integer, a
number into a String, text into a date, a UUID or JSON. That keeps rows
written by other tools readable.
There is one strict rule. Option<T> is the only type that accepts
NULL. Reading a nullable column into a plain String fails with a
DbErr::Type that names the column, instead of defaulting to an empty
string. A model field that can be NULL must therefore be an Option,
and that is also what makes the generated column nullable.
Enum columns¶
SQLite has no enum type, so an enum column is stored as the text or the
integer each variant maps to. DeriveActiveEnum records that mapping on
a fieldless enum and makes it a column type in every sense: a model
field, a value in a condition, a default in the DDL.
/// The publication state of a post, stored as text.
///
/// Without `string_value`, a variant is stored as its `snake_case` name.
#[derive(Clone, Copy, Debug, PartialEq, Eq, DeriveActiveEnum)]
#[turso(rs_type = "String")]
pub(crate) enum Status {
Draft,
#[turso(string_value = "live")]
Published,
}
| Attribute | Effect |
|---|---|
rs_type = "String" on the enum |
Stored as TEXT; a variant without string_value is stored as its snake_case name. |
rs_type = "i32" (or another integer type) on the enum |
Stored as INTEGER; every variant needs a num_value. |
string_value = "...", num_value = n on a variant |
The stored value. |
A stored value no variant maps to is a decoding error that names the
column, never a silent default. Status::values() lists the variants,
and Column::Status.eq(Status::Published) binds the stored value like
any other.
The active model¶
A Model is a snapshot of a row. To write, you need to say which fields
should go to the database, and that is the job of the ActiveModel: the
same fields, each wrapped in an ActiveValue<T> with three states.
| State | Meaning | On insert |
On update |
|---|---|---|---|
Set(v) |
Write this value | Included in the INSERT |
Included in the SET |
Unchanged(v) |
Known, not to be written | Included, since the row does not exist yet | Left alone |
NotSet |
Unknown | Omitted, so the database default applies | Left alone |
ActiveModel implements Default with every field NotSet, which is
why ..Default::default() is the idiom for "only these fields":
/// Inserts a bakery and a cake, then updates the cake through its active model.
async fn insert_and_update(db: &Database) -> Result<(), DbErr> {
// `id` is left `NotSet`, so the database assigns it and `insert` hands
// back the stored row.
let bakery = bakery::ActiveModel {
name: Set("SeaSide Bakery".to_owned()),
profit_margin: Set(10.4),
..Default::default()
}
.insert(db)
.await?;
println!("inserted bakery: {bakery:?}");
let cake = cake::ActiveModel {
name: Set("New York Cheese".to_owned()),
price: Set(10.25),
gluten_free: Set(false),
attributes: Set(Some(serde_json::json!({ "layers": 2 }))),
bakery_id: Set(bakery.id),
..Default::default()
}
.insert(db)
.await?;
println!("inserted cake: {cake:?}");
// A model turned into an active model is entirely `Unchanged`; only the
// fields set afterwards reach the `UPDATE` statement.
let mut cake: cake::ActiveModel = cake.into();
cake.price = Set(11.0);
let cake = cake.update(db).await?;
println!("updated cake: {cake:?}");
// A unique violation is classified, not just stringified.
let dup = bakery::ActiveModel {
name: Set("SeaSide Bakery".to_owned()),
profit_margin: Set(0.0),
..Default::default()
}
.insert(db)
.await;
println!(
"duplicate bakery rejected as unique violation: {}",
dup.as_ref().is_err_and(DbErr::is_unique_violation)
);
Ok(())
}
The first insert sends only name and profit_margin; id is NotSet,
so SQLite assigns a row id, and RETURNING * brings the full row back as
a Model. The update shows the other direction: converting a Model into
an ActiveModel with .into() marks every field Unchanged, so after
cake.price = Set(11.0) the UPDATE statement contains that single
column and a WHERE on the primary key.
Insert, update, save, delete¶
| Method | What it does | Returns |
|---|---|---|
insert(db) |
INSERT ... RETURNING * with the Set and Unchanged fields. |
The stored Model. |
update(db) |
UPDATE ... SET <Set fields> WHERE <key> RETURNING *. With no Set field it simply fetches the row. Fails with PrimaryKeyNotSet if a key field is NotSet, and with RecordNotUpdated if no row matches. |
The stored Model. |
save(db) |
insert when the key is NotSet or Set, update when it is Unchanged. |
The ActiveModel, every field Unchanged, ready to be modified and saved again. |
delete(db) |
DELETE ... WHERE <key>. |
A DeleteResult with rows_affected. |
save is the convenient one for code that does not care whether the row
exists yet:
/// `save` inserts when the key is `NotSet` and updates otherwise; `delete` removes by key.
async fn save_and_delete(db: &Database) -> Result<(), DbErr> {
let banana = fruit::ActiveModel {
name: Set("Banana".to_owned()),
..Default::default()
}
.save(db)
.await?;
println!("saved (insert): {banana:?}");
let mut banana = banana;
banana.name = Set("Banana Mango".to_owned());
let banana = banana.save(db).await?;
println!("saved (update): {banana:?}");
let result = banana.delete(db).await?;
println!("deleted: {result:?}");
Ok(())
}
The first save inserts because id is NotSet. It returns an active
model whose id is now Unchanged(1), so the second save, after
changing name, runs an UPDATE of that one column.
Hooks¶
ActiveModelBehavior is the trait you implement, usually empty, on every
ActiveModel. Its four methods have default no-op bodies and let you
step in around writes:
| Hook | When it runs | Typical use |
|---|---|---|
before_save(self, db, insert) |
Before insert (insert == true) or update |
Normalise a field, fill a timestamp, validate and return an error |
after_save(model, db, insert) |
After the row is stored | Audit, cache invalidation |
before_delete(self, db) |
Before delete |
Refuse the deletion of a protected row |
after_delete(self, db) |
After delete |
Clean up related resources |
A hook that returns Err aborts the write; the error reaches the caller
unchanged.
From a request to an active model¶
A request body rarely matches the model one for one: it carries a subset
of the columns and leaves the rest to defaults. DeriveIntoActiveModel
turns such a struct into the entity's active model field by field:
/// The body of a "create post" request: a subset of the columns, with an
/// optional editor that is only set when the client sends one.
#[derive(Debug, DeriveIntoActiveModel)]
#[turso(active_model = "post::ActiveModel")]
struct CreatePost {
author_id: i32,
title: String,
editor_id: Option<i32>,
}
A plain field becomes Set. A field wrapped in one more Option than the
column type becomes Set when Some and NotSet when None, so an
optional field of the request leaves the column alone and the database
default applies; for a nullable column, that outer Option is
Option<Option<T>>. #[turso(ignore)] leaves a field out, and
#[turso(active_model = "path::ActiveModel")] names the target when it
is not the ActiveModel in scope.
The same conversion exists from JSON, behind the with-json feature:
ActiveModel::from_json(value) sets every attribute whose column name is
a key of the object and ignores the other keys, and set_from_json does
it on an existing active model. Numbers, strings, booleans and null
map onto the storage classes; an array or an object is stored as its
JSON text, which is what a JSON column expects.
The reverse direction is try_into_model(): an active model whose every
attribute carries a value, Set or Unchanged, becomes the plain
Model; the first NotSet attribute makes it fail with
DbErr::AttrNotSet naming the column. A Model also has delete(db),
which runs through the active model and its hooks.
Composite keys¶
Several primary_key fields form a composite key, up to six columns.
Nothing else changes in the model, but a few call sites take a tuple
instead of a single value: find_by_id((7, "rust".to_owned())), and the
same shape for delete_by_id. Composite keys are never auto-incremented,
so every key field must be Set on insert.
Projections that are not entities¶
Sometimes a query returns a shape that is not a table row: an aggregate, a join of two tables, a handful of columns. Two derives cover it.
A plain struct can derive FromQueryResult and be decoded by column
name, with #[turso(column_name = "...")] to rename a field; you build
the select list yourself. The
queries page shows one.
A partial model goes one step further and knows which columns to
select. Derive DerivePartialModel, name the entity, and each field
reads the column of the same name, the one given with from_col, or an
expression given with from_expr:
/// A projection of `post`: two columns under their own names and one
/// expression, selected and decoded from the one declaration.
#[derive(Debug, DerivePartialModel)]
#[turso(entity = "post::Entity")]
struct Headline {
id: i32,
#[turso(from_col = "Title")]
text: String,
#[turso(from_expr = "Expr::col((\"post\", \"title\")).concat(\" (draft)\")")]
draft_text: String,
}
Entity::find().into_partial_model::<Headline>() replaces the select
list with the fields of the struct and decodes rows into it, so the
projection is declared once and cannot drift from its decoder.