Many-to-many tags
Maya organizes her posts with tags. A post can have several tags, and the same tag can appear on many posts.
Post.tags and Tag.posts share the post_tags join table. Rent generates
transactional nested writes, connection, removal, traversal, filtering, and
eager-loading methods from that relationship.
For a new schema, the declarations do not need join-table boilerplate:
model Post {
id BigInt @id
tags Tag[] @relation("PostTags")
}
model Tag {
id BigInt @id
posts Post[] @relation("PostTags")
}Rent derives post_tags, post_id, and tag_id. Existing databases can override those names explicitly, and a
relationship with its own fields should be modeled as a normal entity such as Membership.
let post = client
.create_post()
.id(11)
.title("Relationships without boilerplate")
.body("Connect and create related tags in one transaction.")
.published(true)
.connect_author(&maya)
.connect_tags_entities([&rust])
.create_tags([client.create_tag().id(2).name("databases")])
.save()
.await?;
let loaded = client
.post()
.id_eq(post.id)
.with_tags(|tags| tags)
.only()
.await?;
let database_post = client
.post()
.where_tags(|tags| tags.name_eq("databases"))
.only()
.await?;
let systems = client
.create_tag()
.id(3)
.name("systems")
.save()
.await?;
let updated = client
.update_post_one(&post)
.title("Typed relationship updates")
.set_tags([&systems])
.save_one()
.await?;The post, newly created tags, and join-table rows commit together. If any write
fails, Rent rolls the complete nested write back. Relationship changes on an
update share the same transaction as scalar and version-field changes. The
set_tags atomically makes the supplied entities the complete collection. sync_tags_ids does the same when you
only have IDs. connect_tags, connect_tags_entities, and disconnect_tags apply incremental changes instead. The
generated add_post_tags and remove_post_tags methods remain available for one direct connection change.
Preview tags on every post
Apply a child window when a feed only needs a few tags per post:
use generated::tag;
let posts = client
.post()
.with_tags(|tags| {
tags
.order_by(tag::Tag::id)
.offset(1)
.limit(3)
})
.all()
.await?;Each post independently skips its first tag and receives up to three more. Rent filters accessible tags, joins their links, and applies the per-post window in the database. Duplicate links do not consume preview slots. A tag shared by several posts can appear in each post's collection. Posts with no remaining tags stay in the result with a loaded empty collection. Nested eager loads can be configured inside the tag query too.
Large parent-key sets are deduplicated and batched within the database's parameter budget. Batches execute sequentially on the same pool or transaction context. Use an appropriate transaction isolation level when you need one consistent snapshot across the whole read. A policy-global limit cannot be repeated separately for each batch. Rent reports an actionable error when that combination requires a smaller parent page.
Relationships with their own data
When the connection has its own data, model it as an entity and name the generated traversal in RSL:
model User {
id BigInt @id
groups_through Group[] @relation("MembershipGroups")
@rent.through(name: "memberships", model: "Membership")
}
model Membership {
id BigInt @id
user_id BigInt
group_id BigInt
role String
user User @relation(fields: [user_id], references: [id])
group Group @relation(fields: [group_id], references: [id])
}@rent.through keeps the join row as a normal generated model while also generating a direct
User -> Group traversal, query_groups_through(), that joins the memberships
table for you. Store the role on Membership, then use that traversal to reach
the user's groups.
A group hierarchy with explicit endpoints
A join can also connect two rows of the same model. Give each group children and parents:
model Group {
id BigInt @id
name String
children Group[] @relation("GroupHierarchy") @rent.through(name: "group_links", model: "GroupLink", source: "parent", target: "child")
parents Group[] @relation("GroupHierarchy")
@@map("teams")
}
model GroupLink {
parent_id BigInt
child_id BigInt
parent Group @relation("ParentLink", fields: [parent_id], references: [id])
child Group @relation("ChildLink", fields: [child_id], references: [id])
@@id([parent_id, child_id])
@@map("group_links")
}source: "parent" and target: "child" name relation fields on GroupLink, not SQL columns.
Both point to Group, so these names tell Rent which direction children follows. The paired parents
collection traverses the same links in reverse. The composite primary key permits one link per ordered
parent/child pair. A child can have several different parents.
The link remains a normal generated model. Given the parent group created earlier, create a link directly
or use the generated collection helper:
let child = client
.create_group()
.id(20)
.name("Database writers")
.save()
.await?;
let other_parent = client
.create_group()
.id(30)
.name("Engineering")
.save()
.await?;
let link = client
.create_group_link()
.connect_parent(&parent)
.connect_child(&child)
.save()
.await?;
client.add_group_children(other_parent.id, child.id).await?;
let stored_link = client
.group_link()
.parent_id_eq(parent.id)
.child_id_eq(child.id)
.only()
.await?;
assert_eq!(stored_link, link);Load either direction with its count, or filter groups by their children:
use generated::group;
let loaded_parent = client
.group()
.id_eq(parent.id)
.with_children(|children| children.order_by(group::Group::id))
.with_children_count(|children| children)
.only()
.await?;
assert_eq!(loaded_parent.children()?.len(), 1);
assert_eq!(loaded_parent.children_count()?, 1);
let loaded_child = client
.group()
.id_eq(child.id)
.with_parents(|parents| parents.order_by(group::Group::id))
.with_parents_count(|parents| parents)
.only()
.await?;
assert_eq!(loaded_child.parents()?.len(), 2);
assert_eq!(loaded_child.parents_count()?, 2);
let parents = client
.group()
.where_children(|children| children.id_eq(child.id))
.order_by(group::Group::id)
.all()
.await?;
assert_eq!(parents.len(), 2);For direct traversal, client.group().id_eq(parent.id).query_children() queries children, while
client.group().id_eq(child.id).query_parents() queries parents. Both return ordinary typed queries.
Collection mutation helpers follow the declared forward direction: the first ID is the parent and the second is the child. Removing one parent's link leaves the other intact:
client.remove_group_children(other_parent.id, child.id).await?;
let remaining_parent = client
.group()
.id_eq(child.id)
.query_parents()
.only()
.await?;
assert_eq!(remaining_parent.id, parent.id);No access or permissions block is needed for any relationship in this chapter. Relationship modeling
and traversal work independently of optional permission enforcement.
Complete example
Download this project, or copy the files below into an empty directory.
Replace RENT_REPOSITORY_URL in Cargo.toml with the URL supplied with your preview access. Use the matching Rent CLI release.
rent generate
cargo runThe project includes its database setup. Each lesson uses a small, self-contained schema. You can run it without applying the earlier lessons.
Cargo.toml
[package]
name = "rent-lesson-03-many-to-many"
version = "0.1.0"
edition = "2024"
[workspace]
[[bin]]
name = "rent-lesson-03-many-to-many"
path = "main.rs"
[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.8.6", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"
[database]
dialect = "sqlite"
backends = ["sqlite"]schema.rsl
datasource db {
provider = "sqlite"
}
model User {
id BigInt @id
name String
posts Post[]
groups_through Group[] @relation("MembershipGroups") @rent.through(name: "memberships", model: "Membership")
@@map("users")
}
model Post {
id BigInt @id
title String
body String
published Boolean
author_id BigInt
author User @relation(fields: [author_id], references: [id])
tags Tag[] @relation("PostTags") @rent.join(table: "post_tags", columns: [post_id, tag_id])
@@map("posts")
}
model Tag {
id BigInt @id
name String @unique
posts Post[] @relation("PostTags")
@@map("tags")
}
model Group {
id BigInt @id
name String
users_through User[] @relation("MembershipGroups")
children Group[] @relation("GroupHierarchy") @rent.through(name: "group_links", model: "GroupLink", source: "parent", target: "child")
parents Group[] @relation("GroupHierarchy")
@@map("teams")
}
model Membership {
id BigInt @id
user_id BigInt
group_id BigInt
role String
user User @relation(fields: [user_id], references: [id])
group Group @relation(fields: [group_id], references: [id])
@@unique([user_id, group_id])
@@map("memberships")
}
model GroupLink {
parent_id BigInt
child_id BigInt
parent Group @relation("ParentLink", fields: [parent_id], references: [id])
child Group @relation("ChildLink", fields: [child_id], references: [id])
@@id([parent_id, child_id])
@@map("group_links")
}main.rs
use anyhow::{Result, ensure};
use rent::driver::DatabasePool;
use rent::sql::Dialect;
#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;
use generated::{
client::Client,
group::{self, Group},
post::Post,
tag::{self, Tag},
user::User,
};
async fn exercise() -> Result<()> {
let pool = DatabasePool::connect(Dialect::Sqlite, "sqlite::memory:").await?;
pool.execute("PRAGMA foreign_keys = ON").await?;
for statement in [
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
"CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL, published INTEGER NOT NULL, author_id INTEGER NOT NULL REFERENCES users(id))",
"CREATE TABLE tags (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE)",
"CREATE TABLE post_tags (post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (post_id, tag_id))",
"CREATE TABLE teams (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
"CREATE TABLE group_links (parent_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE, child_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE, PRIMARY KEY (parent_id, child_id))",
"CREATE TABLE memberships (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, group_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE, role TEXT NOT NULL, UNIQUE (user_id, group_id))",
] {
pool.execute(statement).await?;
}
let client = Client::new(pool);
let maya = client.create_user().id(1).name("Maya").save().await?;
let rust = client.create_tag().id(1).name("rust").save().await?;
let post = client
.create_post()
.id(11)
.title("Relationships without boilerplate")
.body("Connect existing tags and create new tags in one transaction.")
.published(true)
.connect_author(&maya)
.connect_tags_entities([&rust])
.create_tags([client.create_tag().id(2).name("databases")])
.save()
.await?;
let loaded = client
.post()
.id_eq(post.id)
.with_tags(|query| query.order_by(tag::Tag::id))
.only()
.await?;
ensure!(loaded.tags()?.len() == 2);
ensure!(client.tag().id_eq(rust.id).query_posts().only().await? == post);
ensure!(client.post().where_tags(|tags| tags.name_eq("databases")).only().await? == post);
preview_tags(&client, &post, &maya, &rust).await?;
let systems = client.create_tag().id(3).name("systems").save().await?;
let updated = client
.update_post_one(&post)
.title("Typed relationship updates")
.set_tags([&systems])
.save_one()
.await?;
ensure!(updated.title == "Typed relationship updates");
ensure!(client.post().id_eq(updated.id).query_tags().count().await? == 1);
ensure!(client.remove_post_tags(updated.id, systems.id).await? == 1);
ensure!(client.post().id_eq(post.id).query_tags().count().await? == 0);
// Use an explicit join entity when the relationship itself has data.
let group = client.create_group().id(10).name("Rust writers").save().await?;
let membership = client
.create_membership()
.id(11)
.user_id(1)
.group_id(group.id)
.role("editor")
.save()
.await?;
ensure!(membership.role == "editor");
ensure!(client.membership().count().await? == 1);
ensure!(client.group().id_eq(group.id).only().await? == group);
ensure!(client.user().id_eq(1).query_groups_through().only().await? == group);
group_hierarchy(&client, &group).await?;
Ok(())
}
async fn group_hierarchy(
client: &Client,
parent: &Group,
) -> Result<()> {
let child = client.create_group().id(20).name("Database writers").save().await?;
let other_parent = client.create_group().id(30).name("Engineering").save().await?;
// A modeled link remains an ordinary entity with a complete composite key.
let link = client
.create_group_link()
.connect_parent(parent)
.connect_child(&child)
.save()
.await?;
ensure!(link.parent_id == parent.id && link.child_id == child.id);
ensure!(client.add_group_children(other_parent.id, child.id).await? == 1);
ensure!(client.group_link().count().await? == 2);
ensure!(client.group_link().parent_id_eq(parent.id).child_id_eq(child.id).only().await? == link);
let loaded = client
.group()
.id_eq(parent.id)
.with_children(|children| children.order_by(group::Group::id))
.with_children_count(|children| children)
.only()
.await?;
ensure!(loaded.children()?.iter().map(|group| group.id).collect::<Vec<_>>() == [child.id]);
ensure!(loaded.children_count()? == 1);
ensure!(client.group().id_eq(parent.id).query_children().only().await? == child);
// The inverse follows the same stored pair from child back to each parent.
let loaded = client
.group()
.id_eq(child.id)
.with_parents(|parents| parents.order_by(group::Group::id))
.with_parents_count(|parents| parents)
.only()
.await?;
ensure!(loaded.parents()?.iter().map(|group| group.id).collect::<Vec<_>>() == [parent.id, other_parent.id]);
ensure!(loaded.parents_count()? == 2);
let parents = client
.group()
.id_eq(child.id)
.query_parents()
.order_by(group::Group::id)
.all()
.await?;
ensure!(parents.iter().map(|group| group.id).collect::<Vec<_>>() == [parent.id, other_parent.id]);
let filtered = client
.group()
.where_children(|children| children.id_eq(child.id))
.order_by(group::Group::id)
.all()
.await?;
ensure!(filtered.iter().map(|group| group.id).collect::<Vec<_>>() == [parent.id, other_parent.id]);
// Removal takes (parent, child); the other parent's link remains intact.
ensure!(client.remove_group_children(other_parent.id, child.id).await? == 1);
ensure!(client.group().id_eq(other_parent.id).query_children().count().await? == 0);
ensure!(client.group().id_eq(child.id).query_parents().only().await? == *parent);
ensure!(client.group_link().count().await? == 1);
ensure!(client.remove_group_children(parent.id, child.id).await? == 1);
ensure!(client.group().id_eq(child.id).query_parents().count().await? == 0);
ensure!(client.group_link().count().await? == 0);
Ok(())
}
async fn preview_tags(
client: &Client,
first: &Post,
author: &User,
shared: &Tag,
) -> Result<()> {
client
.create_post()
.id(12)
.title("A second post")
.body("Shared tags, independent previews.")
.published(true)
.connect_author(author)
.connect_tags_entities([shared])
.save()
.await?;
let posts = client
.post()
.with_tags(|tags| tags.order_by(tag::Tag::id).offset(1).limit(1))
.all()
.await?;
for post in posts {
if post.id == first.id {
ensure!(post.tags()?.iter().map(|tag| tag.name.as_str()).collect::<Vec<_>>() == ["databases"]);
} else {
ensure!(post.tags()?.is_empty());
}
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
exercise().await
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn example_runs() -> anyhow::Result<()> {
super::exercise().await
}
}