Skip to content
Concepts

Relationships

Maya writes several posts. Each post points back to her user account, and her account leads to all the posts she wrote. Describe that connection once in RSL, then follow it from either side in Rust without assembling foreign-key queries yourself.

The same approach covers one-to-one, one-to-many, many-to-many, recursive and inverse relationships, including join models that hold data of their own.

Maya writes posts, and each post collects comments from people such as Ken.

Declare the common case

The RSL type and field name provide the usual relationship metadata:

model Post {
  id        BigInt @id
  author_id BigInt
  author    User   @relation(fields: [author_id], references: [id])
  tags      Tag[]  @relation("PostTags")
}

model User {
  id    BigInt @id
  posts Post[]
}

model Tag {
  id    BigInt @id
  posts Post[] @relation("PostTags")
}

Rent infers a required to-one relationship from User and binds authorId to the target primary key. User? makes the relationship nullable. Tag[] establishes a collection and receives deterministic many-to-many storage when no foreign-key side exists.

let users = client
    .user()
    .with_posts(|posts| posts.with_comments(|comments| comments))
    .all()
    .await?;

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

Generated entity values expose eager-loaded data directly through user.posts()? and post.author()?. The accessors return RelationNotLoaded when the query did not request that relationship. Relationships compile to ordinary SQL foreign keys and join tables.

A relationship can use a name that Rust reserves. For a relationship named type, load it with with_type(...) and read it with row.r#type()?. The r# is Rust's way of using a keyword as a name. It does not change the RSL name or serialized JSON. Rust's path names self, Self, super, and crate use an underscore instead, such as _self(). Give relationships distinct names so their Rust accessors do not collide with other model members.

Referenced keys can have any valid field name and can map to different SQL column names with @map. For an alternate unique key, specify it explicitly in references, such as references: [email] for a unique User.email. The same connection, traversal, eager-loading, and nested-create methods apply. Many-to-many helpers use the declared primary keys on both sides. See the named-key example.

Migrations use the same key metadata: foreign keys reference the mapped SQL columns, and join columns use their parent key's storage type. For a compound identity, declare all fields and references explicitly. Use an explicit join model when either side of a many-to-many relationship has a compound identity.

Choose an eager-loading strategy

Auto is the default. It uses one joined statement for a simple to-one load and batched IN queries for collections, nested loads, and shapes where separate queries preserve pagination or ordering more safely.

use rent::runtime::RelationLoadStrategy;

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

let users = client
    .user()
    .relation_load_strategy(RelationLoadStrategy::SelectIn)
    .with_posts(|posts| {
        posts.with_comments(|comments| comments)
    })
    .all()
    .await?;

Choose Join when one round trip matters for a direct relationship. Choose SelectIn when loading collections or a nested tree. If a requested join shape cannot preserve the query's semantics, Rent safely uses select-in loading.

Child limit and offset apply independently to each parent, including many-to-many collections. Rent bounds these results in SQL and batches large parent-key lists within the database's parameter budget. Many-to-many loads join target rows to their links in the child query. They do not fetch every link into the application first. The tag tutorial demonstrates per-post previews and shared tags.

Compound foreign keys use the same with_posts, query_author, and connect_author methods. Rent matches the entire key tuple, including when multiple tenants reuse the same user ID. Child windows partition by all relationship columns, and bind budgets account for every component. See compound identities and relationships for the model and executable example.

Each to-many relationship generates a with_{relationship}_count method. It counts the related rows of every loaded row without loading them, the way a list page shows "12 comments" beside each post:

let users = client
    .user()
    .with_posts_count(|posts| posts.published_eq(true))
    .with_groups_count(|groups| groups)
    .all()
    .await?;

for user in &users {
    let published = user.posts_count()?;
    let groups = user.groups_count()?;
}

The closure filters what is counted. Pass |posts| posts to count every related row. A count runs one grouped query per batch of parents, whatever the load strategy, and applies the related model's policies, tenancy, and filters exactly as with_posts would, so the count always matches the rows the same closure would load. A row with no related rows counts 0. Counts also nest, so with_posts(|posts| posts.with_comments_count(|comments| comments)) counts each loaded post's comments.

A count takes filters only. Adding limit or offset inside the closure is an InvalidEagerBatch error rather than a count of one page. Many-to-many counts, through a join table or an explicit join model, count each linked row once. Compound foreign keys count by the whole key tuple.

user.posts_count() returns RelationNotLoaded when the query did not request it. Serialized rows carry requested counts under relations._count, keyed by relationship name. Because the count is named after its relationship, a model cannot also declare a relationship called posts_count beside posts. Generation reports the clash.

Create both sides atomically by nesting the related create builder:

let post = client
    .create_post()
    .title("Typed relationships")
    .create_author(
        client
            .create_user()
            .name("Maya"),
    )
    .save()
    .await?;

If either insert fails, Rent rolls back both rows. A create without nested writes remains a single insert.

Connect a foreign-key relationship through the generated relationship name. Rent assigns the backing key without making an extra database round trip:

let post = client
    .create_post()
    .title("Typed relationships")
    .connect_author(&user)
    .save()
    .await?;

let post = client
    .update_post_one(&post)
    .disconnect_editor()
    .save_one()
    .await?;

For a relationship that references the target's id, use connect_author_id(user_id) when only an ID is available. A relationship that references any other key, whether one alternate unique field or a compound key, provides connect_author_key(...) instead, taking the referenced values in the declared foreign-key field order. Connecting an entity whose referenced unique key contains null returns an error. Use disconnect_author() to clear an optional relationship explicitly. For many-to-many collections, set_tags([&rust, &databases]) replaces the complete collection atomically. sync_tags_ids([rust_id, databases_id]) is its ID-only form. Incremental connect_tags, connect_tags_entities, and disconnect_tags methods are also generated.

The inferred join table is enough for a new schema:

tags Tag[] @relation("PostTags")

Many-to-many storage can also use application-specific SQL names:

tags Tag[] @relation("PostTags") @rent.join(table: "post_labels", columns: [post_id, label_id], constraints: [post_labels_post_fkey, post_labels_label_fkey])

Compound foreign keys use aligned local and referenced fields:

author User @relation(fields: [tenant_id, author_id], references: [tenant_id, id], onDelete: Cascade)

Name the endpoints of a self-referencing join

A modeled join can connect two rows of the same model. Name its forward relations with source and target so Rent knows which end owns the collection:

model Group {
  id       BigInt @id
  children Group[] @relation("Hierarchy") @rent.through(name: "links", model: "GroupLink", source: "parent", target: "child")
  parents  Group[] @relation("Hierarchy")
}

model GroupLink {
  id        BigInt @id
  parent_id BigInt
  child_id  BigInt
  parent    Group @relation("ParentLink", fields: [parent_id], references: [id])
  child     Group @relation("ChildLink", fields: [child_id], references: [id])
}

source: "parent" and target: "child" name relations on GroupLink, not SQL columns. children follows parent → child. Its inverse parents follows child → parent. Both names must be provided together and must identify distinct forward relations to the correct models. When each model has exactly one reference in the join model, omit both names and Rent infers them. For compound identities, bind each endpoint's complete fields and references tuples.

The same endpoints drive generated queries, eager loads, counts, relationship writes, and permission expressions such as children.member. Permissions remain optional. This relationship schema works without an access or permissions block.

The descriptor builder exposes .through_with_endpoints("links", "GroupLink", "parent", "child"). Rust derive declarations use through_name, through_entity, through_source, and through_target.

The many-to-many tutorial includes a runnable group hierarchy with a composite link key, forward and inverse queries, eager loads, counts, filtering, and directional removal.

Relations without a foreign key

Some rows should not depend on the row they reference: a page view should survive its post being deleted, and an import may write children before their parents. Declare the relation foreignKey: false:

model PostView {
  id      BigInt @id
  post_id BigInt
  post    Post?  @relation(fields: [post_id], references: [id], foreignKey: false)
}

Rent still generates the relation (with_post, post_count, nested filters) but plans no constraint, so the database accepts any post_id and deleting a post leaves its views. The relation field is optional because a view's post may not exist. It loads as None then. The migration adds an index on post_id, which a foreign key would otherwise have implied on MySQL. Referential actions need the constraint, so onDelete and onUpdate are not allowed.

A foreign key derived by the database is read-only, for example author_id String @rent.generated("lower(source_id)"). You can still query and eagerly load its relation, and use that relation in permission rules. Rent omits the relation's connect_*, disconnect_*, and nested create_* helpers when any local foreign-key column is database-generated: those helpers would need to assign a value the database owns. Set the source fields instead. By contrast, @rent.immutable only prevents updates. Ordinary connection and nested creation remain available during creation.