Schema language
RSL describes your application models and the database features they use. Its top-level
declarations are datasource, model, mixin, view, enum, type, and Rent's extension, tenancy, access, and permissions blocks.
The datasource names only the provider (postgresql, mysql or sqlite). Connections come from DATABASE_URL
and SHADOW_DATABASE_URL in the environment or .env, @@schema places a model in a schema, each extension is an
extension block, and generated code is configured in rent.toml under [generate], so a schema has no generator
block.
Reusable model mixins
mixin Name { ... } declares reusable fields and model attributes. Apply one with
use Name in a model or another mixin. References resolve against the receiving
model. The result is the same ordinary schema and generated client as writing
those declarations inline. Mixins create no tables or runtime inheritance.
Mixin names are unique among model and type declarations. Duplicate fields and conflicting singleton attributes are errors. There are no implicit overrides. Composition is limited to 64 levels and 4,096 fields. Views and composite value types do not accept mixin use. See Model mixins and the executable examples.
Reusable permission profiles
permissions profile Name { ... } defines reusable permission rules. Apply one
with use Name inside a model's permissions block, or inside another profile.
References resolve against the receiving model, so each must declare the fields
and relationships the profile uses. Rule names may not overlap across uses or
local declarations. Profiles can live in any file of the same schema directory.
They are expanded before ordinary validation and code generation, with no runtime
profile object. See the annotated example.
Fields and relationships
model Post {
id BigInt @id @default(autoincrement())
slug String @unique
body String? @map("post_body")
published Boolean @default(false)
metadata Json?
happened_at DateTime @default(now())
author_id BigInt
author User @relation(fields: [author_id], references: [id])
tags Tag[] @relation("PostTags")
}Built-in types include String, Boolean, Int, BigInt, Float, Decimal, DateTime, Json, and Bytes. RSL adds
UInt, explicit integer widths, Float32, Blob, and Uuid where Rent's Rust and storage semantics are more precise. Bool
and Float64 are accepted aliases. Native PostgreSQL extension types are declared as pgvector.Vector,
postgis.Point, and citext.CiText, and an unmodeled PostgreSQL column type as Unsupported("<sql type>"). Enums
store a native PostgreSQL enum type when they declare @@rent.native. @@map and @@schema then override the SQL
type name and schema, which default to the enum name's snake_case form and public. A question mark
means nullable and square brackets mean a list. These modifiers cannot be combined: String[]? and
String?[] are rejected with RSL005. List fields are non-nullable. An empty list means zero elements.
For a nullable stored collection, use nullable JSON with an explicit Rust type:
model Policy {
id BigInt @id
agents Json? @rent.rustType("Vec<String>") @rent.immutable
}This generates Option<Vec<String>>, preserving the distinction between None and Some(vec![]).
It represents stored values, not a nullable relationship list.
Use Int8, Int16, Int32 (also Int), and Int64 (also BigInt) for signed Rust integers.
UInt8, UInt16, UInt32, and UInt64 (also UInt) generate the corresponding unsigned types.
Nullable forms such as UInt64? generate Option<u64>. Integer decoding rejects negative unsigned values,
fractional numeric values, and values outside the declared Rust range instead of truncating them.
UInt64 uses numeric(20) on PostgreSQL and decimal(20,0) on MySQL/MariaDB, supporting the full u64 range.
SQLite's integer storage is signed, so unsigned values must not exceed i64::MAX (9,223,372,036,854,775,807).
Writes outside that range return an error. Choose UInt32 when that smaller range fits your application.
A relation becomes a foreign key unless it says foreignKey: false, for rows that must outlive or precede what
they reference, such as analytics, history or data loaded out of order:
post_id BigInt
post Post? @relation(fields: [post_id], references: [id], foreignKey: false)The relation keeps its includes, counts and filters, and the migration indexes post_id instead of constraining it
unless an index or the primary key already leads with it. Because nothing guarantees the referenced row exists, the
relation field must be optional and loads as None when the row is missing. With no constraint there is no
onDelete, onUpdate or map (RSL244).
An optional composite relation may share the required tenant field with its owner. On create, omitting a nullable key member without a database default means there is no relation. Rent still supplies the tenant from scope. Populated keys are checked against the target's tenant and policies. Database-defaulted keys must be explicit for scoped writes, and updates that change part of a populated key must supply the complete key through a generated connect method. This keeps omitted optional parents distinct from incomplete relationship writes.
Full-text search
@@fulltext declares a searchable index over stored String fields and String members of typed value objects:
model Article {
id BigInt @id
title String
body String
@@fulltext([title, body])
}A path selects a member of a value object stored as JSON:
type CommentBody {
text String
spoiler Boolean @default(false)
}
model Comment {
id BigInt @id
body CommentBody
@@fulltext([body.text])
}Paths may traverse nested optional value objects. Rent checks every member against the declared type, honors
@map on the SQL column and JSON members, and treats absent or null text as empty. Each source must end at a
String. Lists, model relationships, untyped JSON paths, and generated source columns are rejected before
migration. A generated search vector cannot reference another generated column, so index the original stored
field or value-object member directly.
On PostgreSQL this creates a stored generated tsvector column beside the source columns and a GIN index on
it, which is the form the database can actually index. The column is owned by the database: Rent creates and
maintains it, and it never appears in the generated model, in a select, or in row decoding, because
tsvector has no Rust representation. Querying it goes through the generated
search method.
config: selects the text search configuration and defaults to english, which supplies stemming and stop
words:
@@fulltext([title, body], config: "simple")Use simple when you want exact tokens with no stemming. The configuration is fixed in the stored column, so
changing it rewrites the column and its index.
A model may declare one full-text index. The attribute requires a declared PostgreSQL provider, and a schema targeting another backend is rejected rather than generating an index that backend cannot build.
Naming
Every name a schema needs is derived, so an ordinary model needs no naming attributes at all:
| Declaration | Default | Override |
|---|---|---|
model User | table users | @@map("...") |
author_id String | column author_id | @map("...") |
enum Status { DRAFT } | stored value draft | @map("...") on the variant |
enum PostState @@rent.native | SQL type post_state | @@map("...") |
Generated code is Rust, so identifiers are snake_case regardless of how the schema spells them. A field written
authorId generates author_id and stores to the column author_id, which keeps a schema ported from another
tool working without renaming anything. Prefer snake_case in new schemas: the generated code reads that way, so
the schema and the code agree. Two members that converge on one identifier, such as authorId beside
author_id, are rejected rather than silently merged.
A table name is the model's snake_case form with s appended, which is not an English pluralizer. Category
yields categorys and Address yields addresss, so an irregular plural needs @@map.
Reserve @map for a column that genuinely differs from its field, as in a database you did not create.
Field attributes describe identity, defaults, uniqueness, relationships, storage names, timestamps, ignored fields,
and native database types. @default(uuid()) and @default(uuid(7)) supply UUIDs in Rent.
@updatedAt supplies creation and update timestamps. @default(now()) uses the database clock.
See automatic values for overrides, bulk writes,
empty updates, and transaction behavior.
Rent-specific field behavior uses the rent namespace:
- @rent.version enables optimistic locking.
- @rent.sensitive marks a field as sensitive on the schema descriptor.
- @rent.immutable removes update setters. On a relation's foreign key it also removes update connect/disconnect helpers that would change the key. Create-time connection remains supported.
- @rent.manual marks an application-assigned ID.
- @rent.defaultSql("...") preserves a database default expression.
- @rent.generated("...") declares a generated column.
- @rent.rustType("path::Type") overrides the generated Rust value type.
- @rent.type(postgres: "...", mysql: "...", sqlite: "...") preserves exact storage types.
- @rent.previousName("...") preserves a column's identity across a rename.
- @rent.join(table: "...", columns: [...], constraints: [...]) fixes many-to-many storage names.
- @rent.through(name: "...", model: "...") adds a direct traversal through an explicit join model. Add
source: "...", target: "..."to name the join model's forward endpoint relations when either model is referenced more than once. Both names are required together. See self-referencing joins. - @rent.order(field: "...", direction: ...) sets the default ordering of a many-to-many traversal.
Models and indexes
Model attributes describe table names (@@map), PostgreSQL and MySQL schemas (@@schema), compound primary keys
(@@id), unique constraints, indexes, full-text indexes, ignored models, check constraints (@@rent.check),
row-level security (@@rent.rowLevelSecurity and @@rent.policy), triggers (@@rent.trigger), and a table's
previous name (@@rent.previousName). Views declare their SQL with @@rent.sql and @@rent.materialized. Models, enums, and composite types can ask their generated Rust types for extra derives (@@rent.derive) and outer attributes (@@rent.attribute). Index field arguments such as sort and ops, and options such as map and type,
are retained during lowering.
Named row checks
@@rent.check installs a database constraint, including for trusted system writes:
model GrantTarget {
id BigInt @id
team_id BigInt?
person_id BigInt?
// Exactly one target, on both inserts and updates.
@@rent.check(name: "grant_one_target", expression: "(team_id IS NULL) <> (person_id IS NULL)")
}Use SQL column names, including @map mappings, and persisted enum labels in the
expression. SQL CHECK accepts UNKNOWN as well as TRUE, so use explicit IS NULL
and IS NOT NULL tests for nullable fields. A check protects row shape. It does
not replace permissions or enforce cross-row counts such as keeping one owner.
The spelling is @@rent.check(name:, expression:), not @@check. Normal migration
planning and drift inspection include these constraints.
A plain unique uses the database column's collation and comparison rules. mode: insensitive makes its text fields ignore letter case, so Maya and
maya collide, for handles and email addresses:
model Account {
id BigInt @id
handle String @unique(mode: insensitive)
realm String
email String
@@unique([realm, email], mode: insensitive)
}On PostgreSQL and SQLite the unique indexes lower() of each text field. MySQL and MariaDB use a plain index and require
case-insensitive column collations for this mode. Their usual defaults are case-insensitive, but a custom
case-sensitive collation does not satisfy that requirement. Case-insensitive upserts check the column
collations before writing and return InvalidConflictTarget for an incompatible or missing column. This
adds one metadata query on MySQL/MariaDB. A duplicate is EntityError::UniqueViolation
naming the fields. A mode on a unique with no text field is RSL246.
These are database-native comparisons, not a portable Unicode normalization algorithm. SQLite's built-in
lower() folds ASCII only. PostgreSQL follows its locale rules, and MySQL/MariaDB collations may also
ignore accents. Select and verify the database collation for the application's supported names.
Case-insensitive uniques expose the same generated upsert keys as other uniques, such as
account::unique::handle. Both keep_existing() and on_conflict_update(...) use that comparison rule,
including for compound and per-tenant keys. The index folds text for comparison. It does not rewrite the
stored spelling. A conflicting keep_existing() returns the existing row with its original spelling.
An identity may use any required field. Use @@id when the database identity spans more than one field. Rent
generates a reusable <Model>Id Rust type for a compound identity, so update and delete operations remain typed.
model Membership {
user_id BigInt
group_id BigInt
role String
@@id([user_id, group_id])
}Reusable composite types become generated Rust value types and are stored in JSON-compatible columns:
type Address {
street String
city String @map("city_name") @default("Ferris")
}
model User {
id BigInt @id @default(autoincrement())
address Address
}@map on a value-object member names the JSON key, not a separate database column. Missing members receive
their literal @default when Rent decodes the object, and such members carry #[serde(default)], so schema
generators such as utoipa document them as optional. Explicit values take precedence. Explicit JSON null
only works for optional members. An optional member with no value and no default is left out of the JSON Rent
writes, and reads back as None. One with a default is written as null, so reading it back does not substitute the
default. Reading an older object applies defaults in memory without rewriting its stored JSON. A required member
with no default remains an error when absent.
When every member declares a default that is an enum variant, a string, a boolean, a number, or an empty
list, or is optional with no default, the generated type also implements Default with those values (None for
the optional ones), so policy.unwrap_or_default() builds the schema's value instead of repeating it in
application code. A required member without a default, or a member with an optional, JSON, or non-empty-list
default, leaves the type without Default.
Derives such as utoipa::ToSchema that make value types usable on an API's wire belong in rent.toml's
[generate.types], once for every value type. A type that alone needs something more takes @@rent.derive(...)
and @@rent.attribute(...) like a model. See
derives and attributes on generated types.
Value objects can contain other value objects and lists. Recursive structures need a list boundary such as
children Node[]. child Node? alone still produces an infinitely sized Rust value and is rejected. Defaults
must also terminate: use an explicit empty list at a recursive boundary. Database constraints, generated SQL,
and default callbacks belong on model fields, not on JSON members. Use mapped member keys inside JSON defaults.
An enum variant can carry data, declared like value-type members. One field stays on the variant's line. Several take a block:
/// How a tournament pairs its players.
enum TournamentFormat {
/// One loss and you are out.
SINGLE_ELIMINATION
ROUND_ROBIN
SWISS { rounds UInt }
CUSTOM {
label String
note String?
}
}Such an enum generates a Rust enum whose variants hold their fields, stored in a JSON-compatible column: a plain
variant as its stored label ("round_robin"), a variant with data as an object keyed by its label
({"swiss": {"rounds": 6}}), which is also how it serializes. A companion TournamentFormatVariant names the variants
without their data, and every field holding the enum gets format_is(variant) and format_in(variants) filters.
Variant fields take @map and nothing else, and the enum takes only @@rent.derive and @@rent.attribute
(RSL242). It uses rent.toml's [generate.enums] items. A /// comment above any enum variant, with or without
data, documents the generated variant.
Unknown attributes, arguments, and block properties are errors. This keeps misspellings from silently changing the schema. See the RSL diagnostics catalog for every stable error code.
tenancy {
scope = "required"
field = "tenant_id"
}
model Document {
id BigInt @id
tenant_id BigInt
title String
embedding Json
@@unique([tenant_id, title], map: "documents_tenant_title_key")
@@index([embedding(ops: _jsonb_path_ops)], map: "documents_embedding_idx", type: Gin)
@@rent.check(name: "documents_title_nonempty", expression: "length(title) > 0")
@@rent.rowLevelSecurity
@@rent.policy(name: "tenant_documents", command: "ALL", roles: [app], using: "tenant_id = current_setting('app.tenant')")
}Tenancy
The @@rent.policy declaration above defines PostgreSQL row-level security, not
a Rust application policy or an RSL relationship permission. Database RLS and
Rent's tenant guard are separate enforcement layers. See
tenancy and permissions.
A schema that stores a tenant per row declares it once. scope = "required" scopes every model by field. A model
whose rows belong to no tenant says so with @@rent.system:
tenancy {
scope = "required"
field = "tenant_id"
}
model Plan {
id BigInt @id
name String @unique
@@rent.system
}Every scoped model carries the tenant field as a required BigInt or String, and all of them use the same one. A
unique constraint on a scoped model is per tenant: it includes the tenant field, or opts into one value across every
tenant with global: true, as in @unique(global: true) or @@unique([handle], global: true).
When access is enabled, SQL views also require that same tenant field and key type. Their permission rules
intersect with tenant scope, including read = public. A missing, nullable, or incompatible view tenant field is
a schema error. Without access, views retain their ordinary exemption from this tenant-field requirement.
A schema whose tenant_id is ordinary data declares tenancy { scope = "none" }. A schema with a tenant_id field
and no tenancy block is rejected, so isolation is always a stated decision. See
multi-tenancy for what the generated client enforces.
Commands
rent validate
rent validate --syntax-only
rent format
rent format --check
rent describe
rent generateRSL diagnostics include a stable RSL code, source path, line, and column. A schema path may name one file or a directory of .rsl files.
Optional permissions
access opts into relationship permission declarations grouped in permissions Model blocks.
The parser, validator, formatter, editor highlighting, and generator support this syntax.
Generated clients install enforcement and expose model-specific permission enums and typed grants.
Schemas without access do not require permission declarations. See the
permissions introduction.
Field-scoped update permissions
Inside a permissions block, update(field, ...) = expression grants additional
write authority for the named mutable scalar fields. update_to(field, ...) = expression adds a mandatory resulting-value guard when any target field is
written. These declarations also compose through permission profiles. Identity,
immutable and relation fields are rejected as scalar targets. Ordinary update and
when semantics remain unchanged. See the permissions language
and the worked example.
System references
Since v0.4.0, @rent.reference marks an owned, enforced to-one foreign-key
relationship from an ordinary model to a @@rent.system target. It requires an
access block and takes no arguments. Compound keys are supported. It permits
only an internal existence check during source writes, with source authorization
and custom policies retained. It supplies no target read or mutation permission.
It is rejected on scalar fields, inverse/list relationships and ordinary targets.
See the connection tutorial.
Builder method names
Scalar fields may use names such as new, save and one. When a name collides
with a fixed generated create/update builder method, its setter uses a set_
prefix: .set_new(value) rather than .new(value). Create and update builders
use the same spelling. If a real set_new field already exists, the escaped new
setter becomes .set_set_new(value). Prefixes repeat until the name is free.
RSL names, model/patch fields, serialized values and database column names stay
unchanged. This adds no forwarding methods. Generated helpers derived from other
field/relation names still receive the existing collision diagnostics.
