#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use tokio::sync::OnceCell;
use umbral::orm::ForeignKey;
use umbral_core::db;
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "srn_user")]
pub struct User {
pub id: i64,
#[umbral(string)]
pub name: String,
pub manager: Option<ForeignKey<User>>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "srn_post")]
pub struct Post {
pub id: i64,
pub title: String,
pub author: ForeignKey<User>,
}
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::<User>()
.model::<Post>()
.build()
.expect("App::build");
sqlx::query(
"CREATE TABLE srn_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
manager INTEGER REFERENCES srn_user(id)
)",
)
.execute(&pool)
.await
.expect("CREATE TABLE srn_user");
sqlx::query(
"CREATE TABLE srn_post (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author INTEGER NOT NULL REFERENCES srn_user(id)
)",
)
.execute(&pool)
.await
.expect("CREATE TABLE srn_post");
for (name, mgr) in &[
("ceo", None::<i64>),
("alice", Some(1)),
("bob", Some(1)),
("charlie", Some(2)),
] {
sqlx::query("INSERT INTO srn_user (name, manager) VALUES (?, ?)")
.bind(*name)
.bind(*mgr)
.execute(&pool)
.await
.expect("seed user");
}
for (title, author) in &[("first", 2_i64), ("second", 3), ("third", 4)] {
sqlx::query("INSERT INTO srn_post (title, author) VALUES (?, ?)")
.bind(*title)
.bind(*author)
.execute(&pool)
.await
.expect("seed post");
}
})
.await;
}
#[tokio::test]
async fn two_hop_select_related_resolves_chain() {
boot().await;
let posts = Post::objects()
.filter(post::TITLE.eq("first"))
.select_related("author__manager")
.fetch()
.await
.expect("fetch");
assert_eq!(posts.len(), 1);
let p = &posts[0];
let author = p.author.resolved().expect("author hydrated");
assert_eq!(author.name, "alice");
let manager = author
.manager
.as_ref()
.expect("alice has a manager wrapper")
.resolved()
.expect("manager hydrated through second hop");
assert_eq!(manager.name, "ceo");
}
#[tokio::test]
async fn three_hop_select_related_resolves_full_chain() {
boot().await;
let posts = Post::objects()
.filter(post::TITLE.eq("third"))
.select_related("author__manager__manager")
.fetch()
.await
.expect("fetch");
assert_eq!(posts.len(), 1);
let charlie = posts[0].author.resolved().expect("author");
assert_eq!(charlie.name, "charlie");
let alice = charlie
.manager
.as_ref()
.expect("charlie's manager")
.resolved()
.expect("alice hydrated");
assert_eq!(alice.name, "alice");
let ceo = alice
.manager
.as_ref()
.expect("alice's manager")
.resolved()
.expect("ceo hydrated");
assert_eq!(ceo.name, "ceo");
assert!(ceo.manager.is_none());
}
#[tokio::test]
async fn nested_select_related_batches_queries_per_hop_not_per_row() {
boot().await;
let posts = Post::objects()
.select_related("author__manager")
.fetch()
.await
.expect("fetch");
assert!(posts.len() >= 3);
let by_title: std::collections::HashMap<&str, &Post> =
posts.iter().map(|p| (p.title.as_str(), p)).collect();
let first_mgr_name = by_title["first"]
.author
.resolved()
.unwrap()
.manager
.as_ref()
.unwrap()
.resolved()
.unwrap()
.name
.as_str();
assert_eq!(first_mgr_name, "ceo");
let third_mgr_name = by_title["third"]
.author
.resolved()
.unwrap()
.manager
.as_ref()
.unwrap()
.resolved()
.unwrap()
.name
.as_str();
assert_eq!(third_mgr_name, "alice");
}
#[tokio::test]
async fn nested_path_with_null_middle_hop_does_not_panic() {
boot().await;
sqlx::query("INSERT INTO srn_post (title, author) VALUES (?, ?)")
.bind("ceo-post")
.bind(1_i64)
.execute(&umbral::db::pool())
.await
.expect("seed");
let posts = Post::objects()
.filter(post::TITLE.eq("ceo-post"))
.select_related("author__manager")
.fetch()
.await
.expect("fetch must not panic on null middle hop");
assert_eq!(posts.len(), 1);
let ceo = posts[0].author.resolved().expect("author hydrated");
assert_eq!(ceo.name, "ceo");
assert!(ceo.manager.is_none());
}
#[tokio::test]
async fn unknown_first_hop_field_returns_loud_error() {
boot().await;
let err = Post::objects()
.select_related("nope__manager")
.fetch()
.await
.expect_err("unknown first hop must error");
let msg = err.to_string();
assert!(
msg.contains("nope"),
"error should name the bad field: {msg}"
);
assert!(
msg.contains("select_related"),
"error should name the method: {msg}"
);
}
#[tokio::test]
async fn unknown_deeper_hop_field_returns_loud_error() {
boot().await;
let err = Post::objects()
.select_related("author__subordinate")
.fetch()
.await
.expect_err("unknown deeper hop must error");
let msg = err.to_string();
assert!(
msg.contains("subordinate"),
"error should name the bad hop: {msg}"
);
assert!(
msg.contains("srn_user"),
"error should name the table where the bad hop lives: {msg}"
);
}
#[tokio::test]
async fn unknown_single_hop_field_also_errors_loudly_now() {
boot().await;
let err = Post::objects()
.select_related("not_a_field")
.fetch()
.await
.expect_err("unknown single field must error");
assert!(err.to_string().contains("not_a_field"));
}