Constraints and cascades
A comment needs a real post. A foreign key lets the database enforce that rule, including for writers outside your application. RSL and migrations create the actual foreign keys, unique constraints, and referential actions.
An insert for a missing post fails. When the relationship declares
onDelete: Cascade, deleting a real post removes its dependent comments too:
let orphan = client
.create_comment()
.post_id(999)
.author_id(1)
.save()
.await;
assert!(orphan.is_err());
client
.delete_post_one(&post)
.exec()
.await?;
assert_eq!(client.comment().count().await?, 0);Choose onDelete: SetNull for a recursive parent/child relationship when deleting
the parent should detach its children without deleting them. The foreign-key
field must be nullable.
Not every relation should be enforced. A PostView counts reads of a post and must outlive it, so its relation is
declared foreignKey: false. Deleting the post leaves the view, whose post then loads as None:
let kept = client
.post_view()
.id_eq(view.id)
.with_post(|post| post)
.only()
.await?;
ensure!(kept.post()?.is_none());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-17-constraints-cascades"
version = "0.1.0"
edition = "2024"
[workspace]
[[bin]]
name = "rent-lesson-17-constraints-cascades"
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"
}
model User {
id BigInt @id
name String
posts Post[]
comments Comment[] @relation("CommentAuthor")
@@map("users")
}
model Post {
id BigInt @id
title String
body String
published Boolean
author_id BigInt
author User @relation(fields: [author_id], references: [id])
comments Comment[]
views PostView[]
@@map("posts")
}
model Comment {
id BigInt @id
body String
post_id BigInt
author_id BigInt
post Post @relation(fields: [post_id], references: [id], onDelete: Cascade)
author User @relation("CommentAuthor", fields: [author_id], references: [id])
@@map("comments")
}
model Node {
id BigInt @id
name String
parent_id BigInt?
parent Node? @relation("NodeTree", fields: [parent_id], references: [id], onDelete: SetNull)
children Node[] @relation("NodeTree")
@@map("nodes")
}
/// An analytics row that outlives the post it counts, so its relation has no foreign key.
model PostView {
id BigInt @id
post_id BigInt
post Post? @relation(fields: [post_id], references: [id], foreignKey: false)
@@map("post_views")
}main.rs
use anyhow::{Result, ensure};
use rent::driver::DatabasePool;
use rent::sql::Dialect;
#[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("PRAGMA foreign_keys = ON").await?;
for statement in [
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
"CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL, published INTEGER NOT NULL, author_id INTEGER NOT NULL REFERENCES users(id))",
"CREATE TABLE comments (id INTEGER PRIMARY KEY, body TEXT NOT NULL, post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, author_id INTEGER NOT NULL REFERENCES users(id))",
"CREATE TABLE nodes (id INTEGER PRIMARY KEY, name TEXT NOT NULL, parent_id INTEGER REFERENCES nodes(id) ON DELETE SET NULL)",
"CREATE TABLE post_views (id INTEGER PRIMARY KEY, post_id INTEGER NOT NULL)",
"CREATE INDEX post_views_post_id_idx ON post_views (post_id)",
] {
pool.execute(statement).await?;
}
let client = generated::client::Client::new(pool);
let maya = client.create_user().id(1).name("Maya").save().await?;
let ken = client.create_user().id(2).name("Ken").save().await?;
let post = client
.create_post()
.id(10)
.title("Planning the next release")
.body("A short plan for our next release.")
.published(true)
.author_id(maya.id)
.save()
.await?;
client
.create_comment()
.id(100)
.body("Now let us build it.")
.post_id(post.id)
.author_id(ken.id)
.save()
.await?;
let orphan = client
.create_comment()
.id(101)
.body("No parent")
.post_id(999)
.author_id(1)
.save()
.await;
ensure!(orphan.is_err());
let view = client.create_post_view().id(300).post_id(post.id).save().await?;
let loaded = client.post_view().id_eq(view.id).with_post(|post| post).only().await?;
ensure!(loaded.post()?.map(|post| post.id) == Some(post.id));
ensure!(client.comment().count().await? == 1);
ensure!(client.delete_post_one(&post).exec().await? == 1);
ensure!(client.comment().count().await? == 0);
let kept = client.post_view().id_eq(view.id).with_post(|post| post).only().await?;
ensure!(kept.post()?.is_none());
let parent = client.create_node().id(200).name("parent").clear_parent_id().save().await?;
let child = client.create_node().id(201).name("child").parent_id(parent.id).save().await?;
ensure!(client.node().id_eq(child.id).query_parent().only().await? == parent);
client.delete_node_one(&parent).exec().await?;
ensure!(client.node().id_eq(child.id).only().await?.parent_id.is_none());
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
exercise().await
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn example_runs() -> anyhow::Result<()> {
super::exercise().await
}
}