Skip to content
Tutorials

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 run

The 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
    }
}