Schema options
Most models start with a few fields and relationships. As an application grows, you may want an index for a busy list, a particular database type, or a read-only view. Add those choices to the same RSL schema you already use for the Rust client and migrations.
This guide starts with relationship storage, then covers namespaces, indexes,
defaults, generated types, renames, database rules, views, and extensions. You can
read each section when its need comes up. Familiar model declarations use plain
attributes. Rent-specific choices use the rent namespace or an extension block.
Relationship storage
Ordinary foreign keys use @relation:
model Post {
id BigInt @id @default(autoincrement())
author_id BigInt
author User @relation(fields: [author_id], references: [id], map: "posts_author_fkey", onDelete: Cascade)
}onDelete and onUpdate accept Cascade, Restrict, NoAction, SetNull, and SetDefault. Compound foreign
keys use aligned fields and references arrays.
On PostgreSQL 15 or newer, a delete action can change only selected local fields.
For example, deleting a post can clear a comment's optional post_id while retaining
its required tenant_id:
post Post? @relation(fields: [tenant_id, post_id], references: [tenant_id, id], onDelete: SetNull(fields: [post_id]))The subset names RSL fields, including when @map gives them different storage names.
It must be nonempty, contain no duplicates, and belong to this foreign key. Every
SetNull subset field must be nullable. SetDefault(fields: [...]) uses the selected
fields' database defaults, which must still satisfy the foreign key. Without a subset,
the action affects all local foreign-key columns. Subsets apply only to onDelete,
require an enforced local foreign key, and are rejected for MySQL, MariaDB, and SQLite.
They preserve the same restrictions on implicit updates imposed by field permissions.
Implicit many-to-many relations receive deterministic storage names. Use @rent.join when the physical join table,
columns, or constraint symbols are part of the database contract:
model Post {
id BigInt @id
tags Tag[] @relation("PostTags") @rent.join(table: "post_labels", columns: [post_id, label_id], constraints: [post_labels_post_fkey, post_labels_label_fkey])
}
model Tag {
id BigInt @id
posts Post[] @relation("PostTags")
}When the join row has application data, model it normally and add a direct traversal with
@rent.through(name: "memberships", model: "Membership"). The name identifies the relation exposed on the
source model for join rows. model identifies the explicit join model. Generated code retains both the
Membership CRUD surface and the direct target traversal.
Database namespaces
Use @@schema to place models in explicit PostgreSQL schemas or MySQL/MariaDB databases. Generated
queries and migrations use that namespace, independent of the connection's default schema:
model Author {
id BigInt @id
name String
@@schema("publishing")
@@map("users")
}
model Reviewer {
id BigInt @id
name String
@@schema("moderation")
@@map("users")
}These are different tables with different generated Rust models. Relationships may cross namespaces.
Eager loading, tenant scoping, transactions, and bulk operations retain the declared storage locations.
A modeled join table uses its own @@schema. An implicit join table inherits the declaring model's schema.
Give relationship tables distinct storage names within each model's relationships.
SQLite does not support this server-namespace workflow. Remove explicit schema declarations for a SQLite schema rather than expecting them to select a same-named table in its default database.
Indexes
Index declarations support uniqueness, ordering, operator classes, mapped names, and access methods. Rent adds partial predicates, covering columns, and arbitrary SQL expressions:
model Post {
id BigInt @id
tenant_id BigInt
slug String
title String
published_at DateTime?
embedding Unsupported ("vector(1536)")
@@unique([tenant_id, slug], map: "posts_tenant_slug_key")
@@index([published_at(sort: _desc)], map: "posts_published_idx", where: "published_at IS NOT NULL", include: [title])
@@index([embedding(ops: vector_cosine_ops)], map: "posts_embedding_hnsw", type: Hnsw)
@@index([], map: "posts_lower_title_idx", expression: "lower(title)")
}SQL expressions and predicates are trusted migration input. Keep them static and review rent migrate diff.
where: partial indexes need PostgreSQL or SQLite. MySQL and MariaDB have none, so declaring one there is a
schema error (RSL241). A partial unique can be an upsert's conflict target, the pattern that lets a soft-deleted
row free its name. See Mutations.
Index names
map: is optional. Without it, an index is named after its table and every indexed column, ending in _key
when it enforces uniqueness and _idx otherwise. A field-level @unique follows the same rule:
Declaration on model Agent | Index name |
|---|---|
key String @unique | agents_key_key |
@@unique([owner_id, key]) | agents_owner_id_key_key |
@@index([status]) | agents_status_idx |
@@index([created_at, status]) | agents_created_at_status_idx |
Names are held to 63 bytes, the longest identifier PostgreSQL stores unaltered. A longer name keeps its first 54 bytes and ends in an eight-character digest of the full name, so two long names that share a prefix stay distinct and the database never truncates a name behind Rent's back.
Two indexes that resolve to the same name are rejected when the migration is planned. That happens with partial
indexes over the same columns. Give one of them an explicit map::
@@index([slug], where: "published", map: "posts_published_slug_idx")
@@index([slug], where: "NOT published")Defaults, generated columns, and exact SQL types
Use ordinary @default(...) for literals and supported functions. Rent extensions express database-owned behavior
and per-dialect storage precisely:
model Account {
id Uuid @id @rent.defaultSql("gen_random_uuid()")
balance Decimal @rent.type(postgres: "numeric(18,6)", mysql: "decimal(18,6)", sqlite: "numeric")
search_key String @rent.generated("lower(email)")
email String
}Generated fields are readable and filterable but omitted from create and update builders. @rent.manual marks an
application-supplied primary key. @rent.rustType("path::Type") overrides the generated Rust representation when a
portable scalar is insufficient.
Derives and attributes on generated types
Generated enums, value types, and models derive what Rent needs. When they are also your API contract types, give
them the extra derives and attributes once, for the whole project, in rent.toml, and use the generated types
directly instead of keeping a second copy of each:
[generate.enums]
derive = ["utoipa::ToSchema", "ts_rs::TS"]
attributes = ["ts(export, export_to = \"{file}/index.ts\")"]
[generate.types]
derive = ["utoipa::ToSchema", "ts_rs::TS"]
attributes = ["ts(export, export_to = \"{file}/index.ts\")"]{file} is the schema file that declares the type, relative to the schema directory and without .rsl. With a
multi-file schema, billing/invoices.rsl exports its enums to billing/invoices/index.ts, so each part of the
schema publishes its own bindings. [generate.models] takes the same two lists for models.
A type that needs something the rest do not declares it on its own block:
/// How urgent a message is; the declared order is the order of urgency.
enum MessagePriority {
LOW
NORMAL
HIGH
URGENT
@@rent.derive("PartialOrd", "Ord")
}@@rent.derive takes one or more derive paths, and each @@rent.attribute takes the inside of one outer
attribute. Both work on enum, type, and model blocks and come after the project's items. Repeating a derive
rent.toml already gives every type of that kind is an error, so each derive is declared in exactly one place.
The crates they name must be dependencies of the crate that owns the generated client. Code generation rejects text
that does not parse as Rust, and rejects a derive Rent already emits: generated enums always derive Clone, Copy,
Debug, PartialEq, Eq, Hash, Serialize, and Deserialize. Models always derive Clone, Debug,
PartialEq, Serialize, and Deserialize. A derived PartialOrd orders enum variants by declaration.
A model's derives also apply to its generated relations struct, because the model contains it. Its outer attributes apply to the model alone. Related models need the same derives for the same reason.
A /// comment above an enum, type, or model becomes the generated type's documentation, which ts-rs and
utoipa in turn publish to TypeScript and OpenAPI.
Rename identity
Mapped names describe current storage. Rename markers preserve identity across a migration:
model User {
id BigInt @id
name String @rent.previousName("display_name")
@@rent.previousName("people")
}Checks, triggers, row-level security, and policies
model Document {
id BigInt @id
tenant_id BigInt
@@rent.check(name: "documents_tenant_positive", expression: "tenant_id > 0")
@@rent.trigger(name: "documents_audit", event: "AFTER INSERT OR UPDATE", body: "EXECUTE FUNCTION audit_document()")
@@rent.rowLevelSecurity
@@rent.policy(name: "tenant_documents", command: "ALL", roles: [app], using: "tenant_id = current_setting('app.tenant')::bigint", check: "tenant_id = current_setting('app.tenant')::bigint")
}These are PostgreSQL row-level security policies, enforced by the database for
its roles. They are separate from Rent's custom Rust application policies and
permissions declarations. Rent does not translate relationship permission rules
into RLS policies. Configure the database role and any session values used by the
SQL expressions explicitly. RLS policies are permissive by default. Set
permissive: false for a restrictive PostgreSQL policy.
Views
Views generate read-only clients. Rent requires the SQL definition because it also drives migrations:
view PostCount {
author_id BigInt
count BigInt
@@rent.sql("SELECT author_id, count(*) AS count FROM posts GROUP BY 1")
@@rent.materialized
@@map("post_counts")
}Omit @@rent.materialized for an ordinary view.
Extensions
Extensions are first-class top-level declarations rather than model attributes:
extension pgcrypto {
name = "pgcrypto"
version = "1.3"
schema = "extensions"
}These declarations participate in the same migration diff, review, checksum, and apply workflow as tables and
indexes. Omit version when Rent may select the newest compatible installed version.
