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 runThe 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(())
}