Skip to content
Tutorials

Production schema options

The first chapter kept the schema small. This chapter revisits the same User and Post model with the controls needed by mature applications.

Use aligned field lists for a tenant-scoped compound foreign key:

author User @relation(fields: [tenant_id, author_id], references: [tenant_id, id], map: "posts_author_fkey", onDelete: Restrict, onUpdate: Cascade)

The referenced fields must also form a primary or unique key on User. The complete schema below declares @@unique([tenant_id, id]) to match this relationship.

Choose physical names and default ordering for a many-to-many relationship:

tags Tag[] @relation("PostTags") @rent.join(table: "post_tags", columns: [post_id, tag_id], constraints: [post_tags_post_fkey, post_tags_tag_fkey]) @rent.order(field: "name")

rent migrate diff, plan, and apply create that join table, its composite primary key, and both foreign keys from the same declaration used to generate the relationship client.

Database expressions can own defaults:

public_id Uuid @rent.defaultSql("gen_random_uuid()")

Advanced indexes remain part of the RSL schema too:

@@unique([tenant_id, slug(sort: Desc)], map: "posts_published_slug", where: "deleted_at IS NULL", include: [title], type: BTree)
@@index([], map: "posts_lower_title", expression: "lower(title)")

The partial unique applies only to live posts: setting deleted_at releases the slug for a new row. It does not hide deleted rows from queries. Apply the live-row filter or an application policy where required. The upsert chapter combines that index with a typed conflict target and an explicit conflict update. PostgreSQL and SQLite support partial uniques. MySQL and MariaDB do not.

For a document field, a generated column can make a JSON member available to ordinary typed filters, while @@fulltext indexes String paths directly. The rich-values chapter shows both on one model. Generated expressions use physical SQL and JSON names. Full-text paths use schema member names.

RSL semantic validation reports invalid combinations before generation or migration, including missing referenced fields and misaligned compound foreign keys.

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.

Use an empty PostgreSQL database for the advanced indexes and SQL defaults in this example:

export DATABASE_URL='postgres://user:password@localhost/writing_example'
rent generate
rent migrate dev --name initial
cargo run

The SQL default supplies the post's public ID. The generated client follows the compound foreign key when it loads Maya as the author.

Cargo.toml
[package]
name = "rent-lesson-18-schema-options"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-18-schema-options"
path = "main.rs"

[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.8.6", default-features = false, features = ["postgres", "migrate"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }
rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"

[database]
dialect = "postgres"
backends = ["postgres"]
schema.rsl
datasource db {
  provider = "postgresql"
}

tenancy {
  scope = "required"
  field = "tenant_id"
}

model User {
  id        BigInt  @id
  tenant_id BigInt
  email     String

  @@unique([tenant_id, id])
  @@unique([tenant_id, email], map: "users_tenant_email")
  @@map("users")
}

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

  @@rent.system
  @@map("tags")
}

model Post {
  id         BigInt   @id
  tenant_id  BigInt
  author_id  BigInt
  slug       String
  title      String
  deleted_at String?
  public_id  Uuid     @rent.defaultSql("gen_random_uuid()")
  author     User     @relation(fields: [tenant_id, author_id], references: [tenant_id, id], map: "posts_author_fkey", onDelete: Restrict, onUpdate: Cascade)
  tags       Tag[]    @relation("PostTags")  @rent.join(table: "post_tags", columns: [post_id, tag_id], constraints: [post_tags_post_fkey, post_tags_tag_fkey])  @rent.order(field: "name")

  @@unique([tenant_id, slug(sort: Desc)], map: "posts_published_slug", where: "deleted_at IS NULL", include: [title], type: BTree)
  @@index([], map: "posts_lower_title", expression: "lower(title)")
  @@map("posts")
}
main.rs
use anyhow::{Result, ensure};
use rent::policy::viewer::Viewer;
#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;

#[tokio::main]
async fn main() -> Result<()> {
    let root = generated::client::Client::connect(&std::env::var("DATABASE_URL")?).await?;
    let db = root.with_viewer(Viewer::anonymous().tenant(10));
    let maya = db.create_user().id(1).email("maya@example.com").save().await?;
    let post = db
        .create_post()
        .id(1)
        .author_id(maya.id)
        .slug("launch-notes")
        .title("Launch notes")
        .save()
        .await?;
    let author = db.post().id_eq(post.id).query_author().only().await?;
    ensure!(author.id == maya.id);
    println!("{} received database-generated public ID {}", post.title, post.public_id);
    Ok(())
}

On this page