Skip to content
Concepts

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 AgentIndex name
key String @uniqueagents_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.