Skip to content
Tutorials

Reuse model fields

You have posts and notes. Both need timestamps, but each has its own content. A mixin lets you write the common fields once and keep the differences easy to see.

Start with two ordinary models

mixin Timestamps {
  created_at DateTime @default(now())
  updated_at DateTime @updatedAt
}

model Post {
  use Timestamps
  id    BigInt @id
  title String
}

model Note {
  use Timestamps
  id   BigInt @id
  body String
}

Both models now have timestamps. You still create a Post with create_post() and a Note with create_note(). There is no shared parent row to create or load.

Share ownership fields when you need them

The complete example at the end stops at shared timestamps and needs no permissions. Later, both resources may have an author. In the User/Post/Note application, put the repeated author fields into another mixin:

mixin OwnedFields {
  id        BigInt @id
  author_id BigInt
  author    User @relation(fields: [author_id], references: [id]) @rent.creator
}

model Post {
  use OwnedFields
  use Timestamps
  title String
}

model Note {
  use OwnedFields
  use Timestamps
  body String
}

These replace the earlier Post and Note definitions. User is your user model. @rent.creator assigns the signed-in person as author. The mixins supply fields, not a permission policy.

Reuse the access policy separately

If both resources should start private and allow read-only sharing, define that once:

permissions profile PrivateOwnedResource {
  owner = author
  reader = owner or [User]

  read = reader
  create, update, delete, manage_grants = owner
}

permissions Post {
  use PrivateOwnedResource
}

permissions Note {
  use PrivateOwnedResource
}

The access block opts into permissions as shown in the permissions concept. OwnedFields shares the storage shape. PrivateOwnedResource shares the access rules. Each Post and Note keeps its own author and its own sharing assignments.

You can use model mixins without permissions. See model mixins for the composition rules, or permission profiles to extend the policy.

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 a separate local database for this example:

export DATABASE_URL='sqlite://lesson.db?mode=rwc'
rent generate
rent migrate dev --name initial
cargo run

Run the included test with cargo nextest run. It creates an isolated temporary database.

Cargo.toml
[package]
name = "rent-lesson-39-model-mixins"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-39-model-mixins"
path = "main.rs"

[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.7.2", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }
tempfile = "3.27.0"

# Used by the generated client after `rent generate`.
[package.metadata.cargo-machete]
ignored = ["rent"]
rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"

[database]
dialect = "sqlite"
backends = ["sqlite"]
schema.rsl
datasource db {
  provider = "sqlite"
}

mixin Timestamps {
  created_at DateTime  @default(now())
  updated_at DateTime  @updatedAt
}

model Post {
  use Timestamps
  id    BigInt  @id  @default(autoincrement())
  title String
}

model Note {
  use Timestamps
  id   BigInt  @id  @default(autoincrement())
  body String
}
main.rs
use anyhow::{Result, ensure};
#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;
use generated::client::Client;
async fn example(url: &str) -> Result<()> {
    let client = Client::connect(url).await?;
    let post = client.create_post().title("Launch notes").save().await?;
    let note = client.create_note().body("Review the launch date").save().await?;
    let updated = client.update_post_one(&post).title("Reviewed notes").save_one().await?;
    ensure!(updated.created_at == post.created_at);
    ensure!(updated.updated_at >= post.updated_at);
    ensure!(note.body == "Review the launch date");
    println!("Two ordinary models share timestamps.");
    Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
    example(&std::env::var("DATABASE_URL")?).await
}
#[cfg(test)]
mod test_support;
#[cfg(test)]
mod tests {
    #[tokio::test]
    async fn example_runs() -> anyhow::Result<()> {
        let (_directory, url) = super::test_support::database()?;
        super::example(&url).await
    }
}
test_support.rs
use anyhow::{Context, Result, ensure};
use std::process::Command;

pub fn database() -> Result<(tempfile::TempDir, String)> {
    let directory = tempfile::tempdir()?;
    std::fs::write(directory.path().join("schema.rsl"), include_str!("schema.rsl"))?;
    std::fs::write(directory.path().join("rent.toml"), r#"schema = "schema.rsl""#)?;
    let url = format!("sqlite://{}?mode=rwc", directory.path().join("app.db").display());
    let executable = std::env::var("RENT_CLI").unwrap_or_else(|_| "rent".into());
    let output = Command::new(executable)
        .arg("migrate")
        .arg("dev")
        .arg("--root")
        .arg(directory.path())
        .arg("--to")
        .arg("schema.rsl")
        .arg("--database-url")
        .arg(&url)
        .output()
        .context("run the Rent CLI")?;
    ensure!(
        output.status.success(),
        "migration failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    Ok((directory, url))
}