Relations¶
A relation tells the ORM how two tables join: which columns match, which
side owns the foreign key, and what happens on delete or update. One
declaration serves three purposes at once: the JOIN ... ON clause of a
query, the batch loaders, and the FOREIGN KEY constraint in the
generated DDL. This page explains how to declare relations, the ways to
read across them, and how the less common shapes are handled: tables that
meet through a junction, a table that refers to itself, two relations to
the same table, and paths that cross several tables.
The example schema¶
The basic example uses three tables. A bakery owns cakes; a cake carries fruits; a fruit may sit on no cake at all.
erDiagram
bakery ||--o{ cake : "has many"
cake ||--o{ fruit : "has many"
bakery {
int id PK
text name UK
real profit_margin
}
cake {
int id PK
text name
real price
int bakery_id FK
}
fruit {
int id PK
text name
int cake_id FK "nullable"
}
Declaring a relation¶
Relations live in the Relation enum next to the model. The rule that
makes declarations short: the side that holds the foreign key declares
it in full, the other side only points back.
The owning side uses belongs_to and names its own column (from) and
the column it references (to):
//! The `fruit` entity: optionally attached to a cake.
use turso_orm::prelude::*;
/// A row of the `fruit` table.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[turso(table_name = "fruit")]
pub struct Model {
/// Auto-incremented key.
#[turso(primary_key)]
pub id: i32,
/// The fruit name.
pub name: String,
/// The cake this fruit sits on, if any; detaching keeps the fruit.
pub cake_id: Option<i32>,
}
/// The relations of `fruit`.
#[derive(Copy, Clone, Debug, DeriveRelation)]
pub enum Relation {
/// The cake carrying the fruit; deleting the cake detaches the fruit.
#[turso(
belongs_to = "super::cake::Entity",
from = "Column::CakeId",
to = "super::cake::Column::Id",
on_delete = "SetNull"
)]
Cake,
}
impl ActiveModelBehavior for ActiveModel {}
The reverse side uses has_many or has_one and gives only the target
entity. The macro finds the matching belongs_to on that entity and
reuses its columns, so the join condition is written once:
//! The `bakery` entity: the root of the schema, owning cakes.
use turso_orm::prelude::*;
/// A row of the `bakery` table.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[turso(table_name = "bakery")]
pub struct Model {
/// Auto-incremented key.
#[turso(primary_key)]
pub id: i32,
/// The bakery name, unique across the table.
#[turso(unique)]
pub name: String,
/// Profit margin in percent.
pub profit_margin: f64,
}
/// The relations of `bakery`.
#[derive(Copy, Clone, Debug, DeriveRelation)]
pub enum Relation {
/// One bakery, many cakes; the join columns come from `cake`'s `belongs_to`.
#[turso(has_many = "super::cake::Entity")]
Cake,
}
impl ActiveModelBehavior for ActiveModel {}
| Attribute | On which side | Effect |
|---|---|---|
belongs_to = "path::Entity" |
The table holding the foreign key | Declares the many-to-one relation and generates the FOREIGN KEY clause. |
from = "Column::X", to = "path::Column::Y" |
With belongs_to |
The local column and the referenced column; repeat both for a composite key. |
on_delete, on_update |
With belongs_to |
Cascade, SetNull, Restrict, NoAction or SetDefault, rendered into the constraint. |
skip_fk |
With belongs_to |
Keeps the relation for queries but emits no constraint, for tables you do not control. |
has_many = "path::Entity" |
The referenced table | One-to-many, columns inferred from the reverse belongs_to. |
has_one = "path::Entity" |
The referenced table | One-to-one, same inference. |
via = "path::JunctionEntity" |
With has_many |
Many-to-many through the junction entity, explained below. |
fruit.cake_id is an Option<i32>, so the column is nullable and
ON DELETE SET NULL is a sensible action: deleting a cake detaches its
fruits. cake.bakery_id is a plain i32 with ON DELETE CASCADE:
deleting a bakery deletes its cakes. The engine enforces both because the
pool opens every connection with PRAGMA foreign_keys = ON, which
ConnectOptions::foreign_keys controls.
Index your foreign keys
SQLite does not index foreign-key columns automatically, and every
find_related, loader and cascade filters on them. Mark them
#[turso(indexed)], as cake.bakery_id is, so that
Schema::create_index_from_entity emits the index.
Reading across a relation¶
There are five ways, and they differ in how many queries run and in what shape the result takes. Pick by what you have in hand.
/// Walks relations in both directions, with one query per hop or a single join.
async fn relations(db: &Database) -> Result<(), DbErr> {
let bakery = Bakery::find().one(db).await?.expect("seeded");
println!("cakes of {}:", bakery.name);
for cake in bakery.find_related(Cake).all(db).await? {
println!(" {}", cake.name);
}
let fruit = Fruit::find()
.order_by_asc(fruit::Column::Id)
.one(db)
.await?
.expect("seeded");
let cake = fruit.find_related(Cake).one(db).await?;
println!("{} sits on {:?}", fruit.name, cake.map(|c| c.name));
println!("every fruit with its cake in one SELECT:");
for (fruit, cake) in Fruit::find()
.find_also_related(Cake)
.order_by(fruit::Column::Id, Order::Asc)
.all(db)
.await?
{
println!(" {} -> {:?}", fruit.name, cake.map(|c| c.name));
}
println!("fruits on cakes above 9.0 (inner join, filter on the joined table):");
for fruit in Fruit::find()
.inner_join(Cake)
.filter(cake::Column::Price.gt(9.0))
.all(db)
.await?
{
println!(" {}", fruit.name);
}
Ok(())
}
From one model: find_related¶
model.find_related(Other) returns a Select<Other> already filtered on
the key of model. It works in both directions: a bakery finds its
cakes, a fruit finds its cake. Being a Select, it accepts every builder
method, so ordering or a further filter can be added before all or
one. It costs one query per call, which is fine for a single model and
is exactly the N+1 pattern to avoid inside a loop.
Both sides in one query: find_also_related¶
Entity::find().find_also_related(Other) turns the select into a
SelectTwo: a LEFT JOIN whose rows decode into
(Model, Option<Other::Model>). Under the hood the two column lists are
aliased A_<col> and B_<col> so that identically named columns do not
collide, and a right side made only of NULLs becomes None, which is
how a fruit without a cake comes back. One query, pairs in return.
order_by_related orders by a column of the other side.
One entry per model: find_with_related¶
For a has_many, pairs repeat the left side once per related row.
find_with_related(Other) runs the same joined statement and folds the
rows into Vec<(Model, Vec<Other::Model>)>: each model once, identified
by its primary key, with its related models in row order. An ORDER BY
on the model orders the groups and one on the other side orders inside
each group. LIMIT is deliberately absent here, because it would count
joined rows rather than models; use the loaders for a paged list.
Filtering on the other table: joins¶
inner_join(Other) and left_join(Other) add the join without changing
the selected columns. The result is still Select<Entity>, but filters
can now mention the other entity's columns, as in "fruits whose cake costs
more than nine". Because every column is rendered qualified, fruit.id
and cake.id never clash. join(kind, &Relation::X.def()) does the same
for a relation that Related does not name, and join_as lets you pick
the alias of the joined table.
Many models at once: the loaders¶
When you already hold a Vec of models and want their related rows, the
loaders fetch everything in one WHERE key IN (...) query and hand the
result back aligned with your input:
/// Batch loaders: one `IN (...)` query for many parents, results aligned with the input.
async fn loaders(db: &Database) -> Result<(), DbErr> {
let cakes = Cake::find().order_by_asc(cake::Column::Id).all(db).await?;
let fruits = cakes.load_many(Fruit, db).await?;
println!("fruits per cake through load_many:");
for (cake, fruits) in cakes.iter().zip(&fruits) {
let names: Vec<&str> = fruits.iter().map(|f| f.name.as_str()).collect();
println!(" {}: {names:?}", cake.name);
}
let bakeries = cakes.load_one(Bakery, db).await?;
println!("bakery per cake through load_one:");
for (cake, bakery) in cakes.iter().zip(&bakeries) {
println!(" {} <- {:?}", cake.name, bakery.as_ref().map(|b| &b.name));
}
Ok(())
}
load_many(Other, db) returns Vec<Vec<Other::Model>>, one inner vector
per input model, in the same order; load_one(Other, db) returns
Vec<Option<Other::Model>>. Zipping the input with the result pairs them
back without a lookup table. This is the tool for list endpoints: one
query for the page of cakes, one for all their fruits, whatever the page
size.
| Call | Queries | You have | You get |
|---|---|---|---|
model.find_related(Other) |
one per call | one model | a Select<Other> to run |
find_also_related(Other) |
one | nothing yet | Vec<(Model, Option<Other>)> |
find_with_related(Other) |
one | nothing yet | Vec<(Model, Vec<Other>)> |
inner_join / left_join |
one | nothing yet | Vec<Model>, filtered on the other table |
load_one / load_many |
one | a Vec<Model> |
related rows aligned with the input |
Many-to-many through a junction¶
Two tables that can each refer to many rows of the other meet in a third
one, the junction, that holds one row per pair. The junction is an
ordinary entity with a composite key and a belongs_to towards each
side:
/// The junction between `post` and `tag`: a composite key and a
/// `belongs_to` towards each side, which is all a many-to-many relation
/// needs.
mod post_tag {
use turso_orm::prelude::*;
/// A row of the `post_tag` table.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[turso(table_name = "post_tag")]
pub(crate) struct Model {
#[turso(primary_key)]
pub post_id: i32,
#[turso(primary_key)]
pub tag_id: i32,
}
/// The two sides of the junction.
#[derive(Copy, Clone, Debug, DeriveRelation)]
pub(crate) enum Relation {
#[turso(
belongs_to = "super::post::Entity",
from = "Column::PostId",
to = "super::post::Column::Id",
on_delete = "Cascade"
)]
Post,
#[turso(
belongs_to = "super::tag::Entity",
from = "Column::TagId",
to = "super::tag::Column::Id",
on_delete = "Cascade"
)]
Tag,
}
impl ActiveModelBehavior for ActiveModel {}
}
Each side then declares a has_many to the other with via naming the
junction. Nothing else is written: the two hops are assembled from the
junction's own belongs_to relations.
/// The relations of `post`.
///
/// `Author` and `Editor` both point at `author`: the first one provides
/// `Related<author::Entity>`, the second is used through its `def()`.
/// `Parent` is the self-reference and `Children` its inferred reverse.
/// `Tags` goes through the `post_tag` junction.
#[derive(Copy, Clone, Debug, DeriveRelation)]
pub(crate) enum Relation {
#[turso(
belongs_to = "super::author::Entity",
from = "Column::AuthorId",
to = "super::author::Column::Id",
on_delete = "Cascade"
)]
Author,
#[turso(
belongs_to = "super::author::Entity",
from = "Column::EditorId",
to = "super::author::Column::Id",
on_delete = "SetNull"
)]
Editor,
#[turso(
belongs_to = "Entity",
from = "Column::ParentId",
to = "Column::Id",
on_delete = "Cascade"
)]
Parent,
#[turso(has_many = "Entity")]
Children,
#[turso(has_many = "super::post_tag::Entity")]
PostTag,
#[turso(has_many = "super::tag::Entity", via = "super::post_tag::Entity")]
Tags,
}
Every way of reading across a relation follows the two hops on its own.
find_related joins the junction and pins its column; the joins and
find_also_related join the junction and then the target;
find_with_related groups per model; load_many fetches the target rows
together with the junction columns that point back at the input and
groups on those, still in one query. load_many_to_many is the same
loader under a name that states the intent, and fails on a relation
without a junction.
/// Walks the many-to-many relation in every form: from one model, as a
/// join, as pairs, grouped, and batch-loaded for a list of posts.
async fn many_to_many(db: &Database) -> Result<(), DbErr> {
let root = post::Entity::find_by_id(1).one(db).await?.expect("seeded");
// From one model, through the junction, like any other relation.
let tags = root.find_related(tag::Entity).all(db).await?;
println!("tags of {:?}: {:?}", root.title, tags);
// A join through the junction lets a filter mention the other side.
let about_rust = post::Entity::find()
.inner_join(tag::Entity)
.filter(tag::Column::Label.eq("rust"))
.all(db)
.await?;
println!("posts tagged rust: {}", about_rust.len());
// One query, one entry per post, with all of its tags.
let grouped = post::Entity::find()
.find_with_related(tag::Entity)
.order_by(post::Column::Id, Order::Asc)
.all(db)
.await?;
for (post, tags) in &grouped {
println!(
"{} -> {:?}",
post.title,
tags.iter().map(|t| &t.label).collect::<Vec<_>>()
);
}
// The loader takes a list of posts and answers in input order.
let posts = post::Entity::find().all(db).await?;
let per_post = posts.load_many(tag::Entity, db).await?;
for (post, tags) in posts.iter().zip(&per_post) {
println!("loaded {}: {} tag(s)", post.title, tags.len());
}
Ok(())
}
The via relation generates no foreign key of its own: the junction's
belongs_to relations already carry the two constraints, with the
ON DELETE CASCADE that removes the pair rows when either side goes.
A table that refers to itself, and two relations to one table¶
A reply points at the post it answers; a post has an author and may have
an editor, both in author. Both shapes are declared like any other
relation, and both run into the same rule: Rust allows one
Related<Target> impl per entity, so the first variant naming a target
is the one find_related, the joins and the loaders use. The other
variants keep their definition, reachable through Relation::X.def():
Select::related_to(&def, &model)filters a query onmodelthrough that definition, which is the explicit form offind_related. For the reverse side of a self-reference, pass thehas_manydefinition; for a secondbelongs_to, pass its definition reversed with.rev()and the model of the target.Select::join_as(kind, &def, "alias")joins through that definition under an alias that filters can name withExpr::col(("alias", "column")).
/// Follows the self-reference and the second relation to `author`.
async fn self_reference(db: &Database) -> Result<(), DbErr> {
let reply = post::Entity::find_by_id(2).one(db).await?.expect("seeded");
// `Related<post::Entity>` is `Parent`, the first self-relation declared.
let parent = reply.find_related(post::Entity).one(db).await?;
println!("parent of {:?}: {:?}", reply.title, parent.map(|p| p.title));
// The reverse side goes through its definition.
let root = post::Entity::find_by_id(1).one(db).await?.expect("seeded");
let children = post::Entity::find()
.related_to(&post::Relation::Children.def(), &root)
.all(db)
.await?;
println!("replies to {:?}: {}", root.title, children.len());
// A second relation to the same entity is joined under an alias, so a
// filter can name it.
let edited_by_ada = post::Entity::find()
.join_as(JoinType::Inner, &post::Relation::Editor.def(), "editor")
.filter(Expr::col(("editor", "name")).eq("ada"))
.all(db)
.await?;
println!("posts edited by ada: {}", edited_by_ada.len());
Ok(())
}
When a join brings the base table in a second time, as a self-join does,
the second occurrence is aliased table_1 automatically and the ON
condition is rendered against that alias, so the statement stays
unambiguous without any help. find_also_related(post::Entity) on a
post therefore yields (reply, Some(parent)) pairs out of the box.
Paths across several tables: Linked¶
Some questions cross more than one relation: the tags of every post an
author wrote, the bakeries whose cakes carry a given fruit. A Linked
chain names the hops once, as a unit struct, and every hop is a relation
definition you already have:
/// The chain from an author to the tags of the posts they wrote: three
/// hops, each an existing relation definition.
struct AuthorToTag;
impl Linked for AuthorToTag {
type FromEntity = author::Entity;
type ToEntity = tag::Entity;
fn link(&self) -> Vec<RelationDef> {
vec![
author::Relation::Posts.def(),
post::Relation::PostTag.def(),
post_tag::Relation::Tag.def(),
]
}
}
model.find_linked(Chain) returns a Select of the entity at the end of
the chain, joined through every intermediate table and pinned on the
model's key. find_also_linked(&Chain) and find_with_linked(&Chain)
are the chain forms of find_also_related and find_with_related: only
the first and the last tables are selected, the ones in between serve the
join. A table the chain visits twice is aliased as in a self-join.
/// Reaches the tags of an author's posts in one query through the chain.
async fn linked(db: &Database) -> Result<(), DbErr> {
let ada = author::Entity::find()
.filter(author::Column::Name.eq("ada"))
.one(db)
.await?
.expect("seeded");
let tags = ada.find_linked(AuthorToTag).distinct().all(db).await?;
println!(
"tags used by ada: {:?}",
tags.iter().map(|t| &t.label).collect::<Vec<_>>()
);
let per_author = author::Entity::find()
.find_with_linked(&AuthorToTag)
.order_by(author::Column::Id, Order::Asc)
.all(db)
.await?;
for (author, tags) in &per_author {
println!("{} -> {} tag row(s)", author.name, tags.len());
}
Ok(())
}
The same two entities can be linked by several chains, each a struct of its own, which is how "posts an author edited" and "posts an author wrote" stay distinct paths rather than one ambiguous relation.
Cascades and deletion¶
Referential actions are run by the engine, not by the ORM, which means
they also apply to writes made outside turso-orm. The basic example ends
by deleting a bakery and counting the cakes: zero, because the
constraint cascaded. If you need application logic at that point, a
before_delete hook on the active model is the place; see
entities.