Skip to content
Tutorials

User, Post, and Comment

Maya writes a post and Ken leaves a comment. Their accounts, the post, and its comments are separate records connected by relationships.

This lesson extends the first schema with post content, comment authors, and a profile. The complete schema and runnable program are at the end.

The author relationship still uses the same compact declaration:

model Post {
  id        BigInt    @id
  author_id BigInt
  author    User      @relation(fields: [author_id], references: [id])
  comments  Comment[]
}

author User tells Rent this is required and to-one. User? declares a nullable relation, and User[] declares a collection. RSL keeps the foreign-key fields visible and uses the same aligned array syntax for compound keys.

let post = client
    .user()
    .id_eq(maya.id)
    .query_posts()
    .only()
    .await?;

let author = client
    .post()
    .id_eq(post.id)
    .query_author()
    .only()
    .await?;

Traversal methods change the query target. Eager-loading keeps the original target and fills its relations field:

let discussion = client
    .post()
    .id_eq(post.id)
    .with_author(|query| query)
    .with_comments(|query| query.with_author(|author| author))
    .only()
    .await?;

let author = discussion.author()?;
let comments = discussion.comments()?;

Auto chooses the safe query shape by default. You can make the tradeoff explicit:

use rent::runtime::RelationLoadStrategy;

let post_with_author = client
    .post()
    .id_eq(post.id)
    .relation_load_strategy(RelationLoadStrategy::Join)
    .with_author(|author| author)
    .only()
    .await?;

let discussion = client
    .post()
    .id_eq(post.id)
    .relation_load_strategy(RelationLoadStrategy::SelectIn)
    .with_author(|author| author)
    .with_comments(|comments| {
        comments.with_author(|author| author)
    })
    .only()
    .await?;

The first query can load its direct to-one relationship in one statement. The second uses bounded batched queries for the nested relationship tree and avoids one query per post or comment.

For a feed, load a short comment preview for each post:

use generated::{comment, post};

let feed = client
    .post()
    .order_by(post::Post::id)
    .with_comments(|comments| {
        comments
            .order_by_desc(comment::Comment::id)
            .limit(3)
    })
    .all()
    .await?;

The child limit applies independently to every post. Rent applies relationship windows in the database, so a post with thousands of comments returns only its three selected comments. Posts without comments remain in the feed with an empty, loaded collection. Add offset(3) inside the child query to request the next preview window. limit(0) loads an empty collection. Generated primary-key fields break ordering ties deterministically.

A preview usually sits beside a total ("7 comments"). Count a relationship without loading its rows:

let feed = client
    .post()
    .with_comments(|comments| {
        comments
            .order_by_desc(comment::Comment::id)
            .limit(3)
    })
    .with_comments_count(|comments| comments)
    .all()
    .await?;

let total = feed[0].comments_count()?;

Each post carries both its three-comment preview and the number of all its comments, from one grouped query per batch of posts. The closure filters what is counted, so with_posts_count(|posts| posts.published_eq(true)) counts only published posts. See count related rows.

Large direct and many-to-many relation key lists are deduplicated and split to fit the database's parameter budget. Batches run sequentially on the query's pool or transaction context. Use a transaction with suitable isolation when the entire read needs one consistent snapshot. A policy-imposed global limit is not a per-parent limit: if it cannot safely fit in one batch, Rent reports an error asking for a smaller parent page.

Relationship state is explicit. A plain query does not pretend that an unloaded collection is empty:

let post = client
    .post()
    .id_eq(post_id)
    .only()
    .await?;

assert!(!post.comments_is_loaded());
assert!(post.comments().is_err());

After with_comments(...), comments_is_loaded() is true and comments()? returns the loaded slice, even when that slice is empty.

Connect a loaded entity without copying its ID through application code:

let post = client
    .create_post()
    .title("Linking an author")
    .body("Typed relationship references")
    .published(false)
    .connect_author(&maya)
    .save()
    .await?;

Use connect_author_id(maya.id) when the application only has an ID.

A unique User.profile relationship returns Option<Box<T>> rather than Vec<T>, because a user can have at most one profile.

Create a related row inline when both records belong to one operation:

let post = client
    .create_post()
    .title("Eve's first draft")
    .body("Created with its author atomically.")
    .published(false)
    .create_author(
        client
            .create_user()
            .name("Eve"),
    )
    .save()
    .await?;

Rent opens a transaction only because this builder contains a nested create. It inserts the author, assigns the returned ID to author_id, and inserts the post. If either insert fails, both are rolled back. A failed post insert therefore leaves no new author behind.

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-02-relationships"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-02-relationships"
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[]
  comments Comment[]  @relation("CommentAuthor")
  profile  Profile?

  @@map("users")
}

model Post {
  id        BigInt     @id
  title     String
  body      String
  published Boolean
  author_id BigInt
  author    User       @relation(fields: [author_id], references: [id])
  comments  Comment[]

  @@map("posts")
}

model Comment {
  id        BigInt  @id
  body      String
  post_id   BigInt
  author_id BigInt
  post      Post    @relation(fields: [post_id], references: [id], onDelete: Cascade)
  author    User    @relation("CommentAuthor", fields: [author_id], references: [id])

  @@map("comments")
}

model Profile {
  id      BigInt  @id
  bio     String
  user_id BigInt  @unique
  user    User    @relation(fields: [user_id], references: [id], onDelete: Cascade)

  @@map("profiles")
}
main.rs
use anyhow::{Result, ensure};
use rent::driver::DatabasePool;
use rent::runtime::RelationLoadStrategy;
use rent::sql::Dialect;

#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;

async fn database() -> Result<generated::client::Client> {
    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 comments (id INTEGER PRIMARY KEY, body TEXT NOT NULL, post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, author_id INTEGER NOT NULL REFERENCES users(id))",
        "CREATE TABLE profiles (id INTEGER PRIMARY KEY, bio TEXT NOT NULL, user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE)",
    ] {
        pool.execute(statement).await?;
    }
    Ok(generated::client::Client::new(pool))
}

async fn exercise() -> Result<()> {
    let client = database().await?;
    let maya = client.create_user().id(1).name("Maya").save().await?;
    let ken = client.create_user().id(2).name("Ken").save().await?;
    let post = client
        .create_post()
        .id(10)
        .title("Planning the next release")
        .body("A short plan for our next release.")
        .published(true)
        .author_id(maya.id)
        .save()
        .await?;
    let comment = client
        .create_comment()
        .id(100)
        .body("I'll review the timeline.")
        .post_id(post.id)
        .author_id(ken.id)
        .save()
        .await?;

    let author = client.post().id_eq(post.id).query_author().only().await?;
    ensure!(author == maya);

    let authored = client.user().id_eq(maya.id).query_posts().only().await?;
    ensure!(authored == post);

    let connected = client
        .create_post()
        .id(12)
        .title("An entity reference")
        .body("The relationship setter reads Maya's typed ID.")
        .published(false)
        .connect_author(&maya)
        .save()
        .await?;
    ensure!(client.post().id_eq(connected.id).query_author().only().await? == maya);

    let without_relations = client.post().id_eq(post.id).only().await?;
    ensure!(!without_relations.author_is_loaded());
    ensure!(without_relations.author().is_err());

    let nested_post = client
        .create_post()
        .id(11)
        .title("Eve's first draft")
        .body("Created with its author in one atomic operation.")
        .published(false)
        .create_author(client.create_user().id(3).name("Eve"))
        .save()
        .await?;
    let nested_author = client.post().id_eq(nested_post.id).query_author().only().await?;
    ensure!(nested_author.name == "Eve");

    let rejected = client
        .create_post()
        .id(nested_post.id)
        .title("Duplicate")
        .body("This parent insert fails.")
        .published(false)
        .create_author(client.create_user().id(4).name("Rolled back"))
        .save()
        .await;
    ensure!(rejected.is_err());
    ensure!(!client.user().id_eq(4).exist().await?);

    let profile = client
        .create_profile()
        .id(20)
        .bio("Writes release notes")
        .user_id(maya.id)
        .save()
        .await?;
    ensure!(client.user().id_eq(maya.id).query_profile().only().await? == profile);

    let discussion = client
        .post()
        .id_eq(post.id)
        .relation_load_strategy(RelationLoadStrategy::SelectIn)
        .with_author(|query| query)
        .with_comments(|query| query.with_author(|author| author))
        .only()
        .await?;
    ensure!(discussion.author()? == Some(&maya));
    ensure!(discussion.author_is_loaded());
    ensure!(discussion.comments()?.len() == 1);
    ensure!(discussion.comments()?.first().is_some_and(|loaded| loaded.id == comment.id));
    ensure!(
        discussion
            .comments()?
            .first()
            .is_some_and(|loaded| { loaded.author().is_ok_and(|author| author.is_some()) })
    );

    let joined_author = client
        .post()
        .id_eq(post.id)
        .relation_load_strategy(RelationLoadStrategy::Join)
        .with_author(|author| author)
        .only()
        .await?;
    ensure!(joined_author.author()? == Some(&maya));
    comment_previews(&client, &post, &connected, &ken).await?;
    Ok(())
}

async fn comment_previews(
    client: &generated::client::Client,
    first: &generated::post::Post,
    second: &generated::post::Post,
    author: &generated::user::User,
) -> Result<()> {
    for (post, start) in [(first, 100), (second, 200)] {
        for number in 1..=6 {
            client
                .create_comment()
                .id(start + number)
                .body("Another thought about the release.")
                .post_id(post.id)
                .author_id(author.id)
                .save()
                .await?;
        }
    }

    let feed = client
        .post()
        .order_by(generated::post::Post::id)
        .with_comments(|comments| comments.order_by_desc(generated::comment::Comment::id).limit(3))
        .all()
        .await?;

    for post in &feed {
        let expected = if post.id == first.id {
            vec![106, 105, 104]
        } else if post.id == second.id {
            vec![206, 205, 204]
        } else {
            Vec::new()
        };
        ensure!(post.comments()?.iter().map(|comment| comment.id).collect::<Vec<_>>() == expected);
    }

    let page = client
        .post()
        .id_eq(first.id)
        .with_comments(|comments| comments.order_by_desc(generated::comment::Comment::id).offset(1).limit(2))
        .only()
        .await?;

    ensure!(page.comments()?.iter().map(|comment| comment.id).collect::<Vec<_>>() == [105, 104]);

    let with_totals = client
        .post()
        .order_by(generated::post::Post::id)
        .with_comments(|comments| comments.order_by_desc(generated::comment::Comment::id).limit(3))
        .with_comments_count(|comments| comments)
        .all()
        .await?;

    for post in &with_totals {
        let expected = match post.id {
            id if id == first.id => 7,
            id if id == second.id => 6,
            _ => 0,
        };
        ensure!(post.comments_count()? == expected);
        ensure!(post.comments()?.len() <= 3);
    }

    let authors = client
        .user()
        .order_by(generated::user::User::id)
        .with_posts_count(|posts| posts.published_eq(true))
        .all()
        .await?;

    ensure!(
        authors
            .iter()
            .map(generated::user::User::posts_count)
            .collect::<Result<Vec<_>, _>>()?
            == [1, 0, 0]
    );
    Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
    exercise().await
}

#[cfg(test)]
mod tests {
    #[tokio::test]
    async fn example_runs() -> anyhow::Result<()> {
        super::exercise().await
    }
}

On this page