Skip to content
ReferenceRust API

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.

@@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:

DeclarationDefaultOverride
model Usertable users@@map("...")
author_id Stringcolumn author_id@map("...")
enum Status { DRAFT }stored value draft@map("...") on the variant
enum PostState @@rent.nativeSQL 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 generate

RSL 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.