#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use tokio::sync::OnceCell;
use umbral::orm::{ForeignKey, ReverseSet};
use umbral_core::db;
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "rfk_comment")]
pub struct Comment {
pub id: i64,
pub body: String,
pub post: ForeignKey<Post>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "rfk_post")]
pub struct Post {
pub id: i64,
pub title: String,
#[sqlx(skip)]
#[serde(skip)]
#[umbral(reverse_fk = "post")]
pub comment_set: ReverseSet<Comment>,
}
static BOOT: OnceCell<()> = OnceCell::const_new();
async fn boot() {
BOOT.get_or_init(|| async {
let settings = umbral::Settings::from_env().expect("figment defaults");
let pool = db::connect_sqlite("sqlite::memory:")
.await
.expect("in-memory sqlite");
umbral::App::builder()
.settings(settings)
.database("default", pool.clone())
.model::<Post>()
.model::<Comment>()
.model::<Article>()
.model::<Note>()
.model::<Tagline>()
.build()
.expect("App::build");
sqlx::query(
"CREATE TABLE rfk_post (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL
)",
)
.execute(&pool)
.await
.expect("CREATE TABLE rfk_post");
sqlx::query(
"CREATE TABLE rfk_comment (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body TEXT NOT NULL,
post INTEGER NOT NULL REFERENCES rfk_post(id)
)",
)
.execute(&pool)
.await
.expect("CREATE TABLE rfk_comment");
sqlx::query(
"CREATE TABLE rfk_article (
id INTEGER PRIMARY KEY AUTOINCREMENT,
headline TEXT NOT NULL,
deleted_at TEXT
)",
)
.execute(&pool)
.await
.expect("CREATE TABLE rfk_article");
sqlx::query(
"CREATE TABLE rfk_note (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
article INTEGER NOT NULL REFERENCES rfk_article(id)
)",
)
.execute(&pool)
.await
.expect("CREATE TABLE rfk_note");
sqlx::query(
"CREATE TABLE rfk_tagline (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phrase TEXT NOT NULL,
article INTEGER NOT NULL REFERENCES rfk_article(id),
created_at TEXT NOT NULL
)",
)
.execute(&pool)
.await
.expect("CREATE TABLE rfk_tagline");
sqlx::query("INSERT INTO rfk_article (headline) VALUES ('a1')")
.execute(&pool)
.await
.expect("seed article");
for text in &["n1", "n2"] {
sqlx::query("INSERT INTO rfk_note (text, article) VALUES (?, 1)")
.bind(*text)
.execute(&pool)
.await
.expect("seed note");
}
let now: chrono::DateTime<chrono::Utc> = chrono::Utc::now();
for phrase in &["t1"] {
sqlx::query("INSERT INTO rfk_tagline (phrase, article, created_at) VALUES (?, 1, ?)")
.bind(*phrase)
.bind(now)
.execute(&pool)
.await
.expect("seed tagline");
}
for title in &["alpha", "beta", "gamma"] {
sqlx::query("INSERT INTO rfk_post (title) VALUES (?)")
.bind(*title)
.execute(&pool)
.await
.expect("seed post");
}
for (body, post) in &[
("first on alpha", 1_i64),
("second on alpha", 1),
("first on beta", 2),
] {
sqlx::query("INSERT INTO rfk_comment (body, post) VALUES (?, ?)")
.bind(*body)
.bind(*post)
.execute(&pool)
.await
.expect("seed comment");
}
})
.await;
}
#[tokio::test]
async fn prefetch_related_populates_reverse_set_for_each_parent() {
boot().await;
let posts = Post::objects()
.prefetch_related("comment_set")
.fetch()
.await
.expect("fetch");
let by_title: std::collections::HashMap<&str, &Post> =
posts.iter().map(|p| (p.title.as_str(), p)).collect();
let alpha = by_title.get("alpha").expect("alpha present");
let alpha_comments = alpha
.comment_set
.resolved()
.expect("ReverseSet hydrated post-prefetch");
assert_eq!(alpha_comments.len(), 2, "alpha has 2 comments");
let bodies: Vec<&str> = alpha_comments.iter().map(|c| c.body.as_str()).collect();
assert!(bodies.contains(&"first on alpha"));
assert!(bodies.contains(&"second on alpha"));
let beta = by_title.get("beta").expect("beta present");
let beta_comments = beta.comment_set.resolved().expect("hydrated");
assert_eq!(beta_comments.len(), 1);
assert_eq!(beta_comments[0].body, "first on beta");
let gamma = by_title.get("gamma").expect("gamma present");
let gamma_comments = gamma.comment_set.resolved().expect("hydrated (empty)");
assert!(
gamma_comments.is_empty(),
"gamma has no children → resolved is Some(&[])"
);
}
#[tokio::test]
async fn without_prefetch_reverse_set_resolved_is_none() {
boot().await;
let posts = Post::objects().fetch().await.expect("fetch");
for p in &posts {
assert!(
p.comment_set.resolved().is_none(),
"unloaded ReverseSet must read as None"
);
}
}
#[tokio::test]
async fn loud_error_on_unknown_prefetch_field_naming_reverse_set() {
boot().await;
let err = Post::objects()
.prefetch_related("no_such_field")
.fetch()
.await
.expect_err("unknown field must error");
let msg = err.to_string();
assert!(
msg.contains("no_such_field"),
"error names the bad field: {msg}"
);
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "rfk_note")]
pub struct Note {
pub id: i64,
pub text: String,
pub article: ForeignKey<Article>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "rfk_tagline")]
pub struct Tagline {
pub id: i64,
pub phrase: String,
pub article: ForeignKey<Article>,
#[umbral(auto_now_add)]
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Default, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(soft_delete, table = "rfk_article")]
pub struct Article {
#[umbral(primary_key)]
pub id: i64,
pub headline: String,
#[sqlx(skip)]
#[serde(skip)]
#[umbral(reverse_fk = "article")]
pub note_set: ReverseSet<Note>,
#[sqlx(skip)]
#[serde(skip)]
#[umbral(reverse_fk = "article")]
pub tagline_set: ReverseSet<Tagline>,
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[test]
fn macro_emits_a_reverse_fk_spec_for_every_set() {
use umbral::orm::Model;
let names: Vec<&str> = Article::REVERSE_FK_RELATIONS
.iter()
.map(|s| s.field_name)
.collect();
assert!(names.contains(&"note_set"), "first set present: {names:?}");
assert!(
names.contains(&"tagline_set"),
"SECOND set present: {names:?}"
);
assert_eq!(names.len(), 2, "exactly the two declared sets: {names:?}");
}
#[tokio::test]
async fn prefetch_both_reverse_sets_populates_each_slot() {
boot().await;
let articles = Article::objects()
.prefetch_related("note_set")
.prefetch_related("tagline_set")
.fetch()
.await
.expect("fetch");
let a = articles
.iter()
.find(|a| a.headline == "a1")
.expect("a1 present");
let notes = a.note_set.resolved().expect("note_set hydrated");
let mut note_texts: Vec<&str> = notes.iter().map(|n| n.text.as_str()).collect();
note_texts.sort();
assert_eq!(note_texts, vec!["n1", "n2"], "note_set has both notes");
let taglines = a.tagline_set.resolved().expect("tagline_set hydrated");
let tag_phrases: Vec<&str> = taglines.iter().map(|t| t.phrase.as_str()).collect();
assert_eq!(tag_phrases, vec!["t1"], "tagline_set has its tagline");
}
#[tokio::test]
async fn prefetch_only_second_reverse_set_populates_it() {
boot().await;
let articles = Article::objects()
.prefetch_related("tagline_set")
.fetch()
.await
.expect("fetch");
let a = articles
.iter()
.find(|a| a.headline == "a1")
.expect("a1 present");
let taglines = a
.tagline_set
.resolved()
.expect("second reverse set must hydrate even when prefetched alone");
let tag_phrases: Vec<&str> = taglines.iter().map(|t| t.phrase.as_str()).collect();
assert_eq!(tag_phrases, vec!["t1"]);
assert!(
a.note_set.resolved().is_none(),
"un-prefetched first set stays None"
);
}