Views, generated columns, and triggers
A user directory may need a read-only view of names. A table may also compute a length or populate an audit value itself. Rent lets those database-owned values work alongside ordinary generated models.
Query a view through its generated read-only client:
let viewed = client
.user_name_view()
.only()
.await?;Connect a post to the directory
Maya's post can refer to her entry in that directory. Declare the relationship as usual, while leaving the directory itself read-only:
model Post {
id BigInt @id
title String
author_id BigInt
author UserNameView? @relation(fields: [author_id], references: [id], foreignKey: false)
}Add posts Post[] to UserNameView for the reverse relationship. The
foreignKey: false option omits a database foreign-key constraint: an ordinary
SQL view cannot serve as its referenced table. Rent still provides the typed
relationship. The relation is optional because the view may not contain a
matching row. Keep a database constraint against the underlying table when
your application requires database-enforced referential integrity.
Read an existing directory entry and connect it when creating the post:
let maya = client.user_name_view().only().await?;
client.create_post()
.id(1)
.title("Launch notes")
.connect_author(&maya)
.save().await?;
let post = client.post().with_author(|query| query).only().await?;The post is writable. The view has no create, update or delete builder, and connecting it does not create a directory entry. Change the underlying table when you need to change what the view returns.
Let the database compute values
Stored generated columns and insert triggers can compute values when a row is saved. Read the resulting values back through the client. Declare them with the database object attributes.
For PostgreSQL row-level security, declare @@rent.rowLevelSecurity and
@@rent.policy. Migrations emit ENABLE ROW LEVEL SECURITY and CREATE POLICY.
Use PostgreSQL domains for reusable native column constraints. These database
rules apply independently of the Rust code writing the row.
Keep exactly one target
A row-shape rule belongs in a named database check:
model DeliveryTarget {
id BigInt @id
team_id BigInt?
person_id BigInt?
@@rent.check(name: "delivery_one_target", expression: "(team_id IS NULL) <> (person_id IS NULL)")
}A team-only or person-only row succeeds. Both identifiers set, or both NULL, fail on insert or update, even through a system handle. A transaction can switch targets by clearing one and setting the other in the same update. Rollback restores the previous target. See named row checks.
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.
rent generate
cargo runThe project includes its database setup. Each lesson uses a small, self-contained schema. You can run it without applying the earlier lessons.
Cargo.toml
[package]
name = "rent-lesson-14-database-objects"
version = "0.1.0"
edition = "2024"
[workspace]
[[bin]]
name = "rent-lesson-14-database-objects"
path = "main.rs"
[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.8.6", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"
[database]
dialect = "sqlite"
backends = ["sqlite"]schema.rsl
datasource db {
provider = "sqlite"
}
view UserNameView {
id BigInt @id
name String
posts Post[]
@@rent.sql("SELECT id, name FROM view_sources")
@@map("user_name_view")
}
model Post {
id BigInt @id
title String
author_id BigInt
author UserNameView? @relation(fields: [author_id], references: [id], foreignKey: false)
}main.rs
use anyhow::{Result, ensure};
use rent::driver::DatabasePool;
use rent::migrate::{MigrationPlan, Operation, RlsPolicy, SqlRenderer};
use rent::sql::Dialect;
use rent::sql::ast::{Select, TableRef};
#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;
async fn exercise() -> Result<()> {
let pool = DatabasePool::connect(Dialect::Sqlite, "sqlite::memory:").await?;
pool.execute("CREATE TABLE view_sources (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
.await?;
pool.execute("CREATE VIEW user_name_view AS SELECT id, name FROM view_sources")
.await?;
pool.execute("INSERT INTO view_sources (id, name) VALUES (1, 'Maya')").await?;
let client = generated::client::Client::new(pool.clone());
let maya = client.user_name_view().only().await?;
ensure!(maya.name == "Maya");
pool.execute("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT NOT NULL, author_id INTEGER NOT NULL)")
.await?;
client
.create_post()
.id(1)
.title("Launch notes")
.connect_author(&maya)
.save()
.await?;
let post = client.post().with_author(|query| query).only().await?;
ensure!(post.author()?.map(|author| author.name.as_str()) == Some("Maya"));
ensure!(client.user_name_view().query_posts().count().await? == 1);
pool.execute("CREATE TABLE article_metrics (body TEXT NOT NULL, body_length INTEGER GENERATED ALWAYS AS (length(body)) STORED, touched INTEGER NOT NULL DEFAULT 0)")
.await?;
pool.execute("CREATE TRIGGER article_metrics_touch AFTER INSERT ON article_metrics BEGIN UPDATE article_metrics SET touched = 1 WHERE rowid = NEW.rowid; END")
.await?;
pool.execute("INSERT INTO article_metrics (body) VALUES ('rent')").await?;
let lengths = pool
.fetch_i64(&Select::new(Dialect::Sqlite, TableRef::new("article_metrics"), ["body_length"]).render())
.await?;
let touched = pool
.fetch_i64(&Select::new(Dialect::Sqlite, TableRef::new("article_metrics"), ["touched"]).render())
.await?;
ensure!(lengths == [4] && touched == [1]);
let rls = MigrationPlan::new(vec![
Operation::SetRowLevelSecurity {
schema: "public".to_owned(),
table: "posts".to_owned(),
enabled: true,
},
Operation::CreatePolicy {
schema: "public".to_owned(),
table: "posts".to_owned(),
policy: RlsPolicy {
name: "tenant_posts".to_owned(),
command: "ALL".to_owned(),
roles: vec!["app".to_owned()],
using_expression: Some("tenant_id = current_setting('app.tenant_id')::bigint".to_owned()),
check_expression: Some("tenant_id = current_setting('app.tenant_id')::bigint".to_owned()),
permissive: true,
},
},
]);
let statements = SqlRenderer::new(Dialect::Postgres).render_plan(&rls)?;
ensure!(
statements
.iter()
.any(|statement| statement.contains("ENABLE ROW LEVEL SECURITY"))
);
ensure!(statements.iter().any(|statement| statement.contains("CREATE POLICY")));
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
exercise().await
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn example_runs() -> anyhow::Result<()> {
super::exercise().await
}
}