#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use tokio::sync::OnceCell;
use umbral::orm::{ForeignKey, M2M};
use umbral_core::db;
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "dj_author")]
pub struct Author {
pub id: i64,
#[umbral(string)]
pub name: String,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "dj_plugin")]
pub struct Plugin {
pub id: i64,
#[umbral(string)]
pub name: String,
pub author: ForeignKey<Author>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "dj_comment")]
pub struct Comment {
pub id: i64,
pub body: String,
pub plugin: Option<ForeignKey<Plugin>>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "dj_cat")]
pub struct Cat {
pub id: i64,
#[umbral(string)]
pub name: String,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "dj_tag")]
pub struct Tag2 {
pub id: i64,
#[umbral(string)]
pub name: String,
pub category: ForeignKey<Cat>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "dj_post")]
pub struct Post2 {
pub id: i64,
pub title: String,
#[sqlx(skip)]
#[serde(skip)]
#[umbral(m2m = "dj_tag")]
pub tags: M2M<Tag2>,
}
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("sqlite");
umbral::App::builder()
.settings(settings)
.database("default", pool.clone())
.model::<Author>()
.model::<Plugin>()
.model::<Comment>()
.model::<Cat>()
.model::<Tag2>()
.model::<Post2>()
.build()
.expect("App::build");
for ddl in [
"CREATE TABLE dj_author (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)",
"CREATE TABLE dj_plugin (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, \
author INTEGER NOT NULL REFERENCES dj_author(id))",
"CREATE TABLE dj_comment (id INTEGER PRIMARY KEY AUTOINCREMENT, body TEXT NOT NULL, \
plugin INTEGER REFERENCES dj_plugin(id))",
"CREATE TABLE dj_cat (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)",
"CREATE TABLE dj_tag (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, \
category INTEGER NOT NULL REFERENCES dj_cat(id))",
"CREATE TABLE dj_post (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL)",
"CREATE TABLE dj_post_tags (parent_id INTEGER NOT NULL REFERENCES dj_post(id), \
child_id INTEGER NOT NULL REFERENCES dj_tag(id), PRIMARY KEY (parent_id, child_id))",
] {
sqlx::query(ddl).execute(&pool).await.expect("ddl");
}
sqlx::query("INSERT INTO dj_author (name) VALUES ('Ada')")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO dj_plugin (name, author) VALUES ('Cache', 1)")
.execute(&pool)
.await
.unwrap();
{
let mut conn = pool.acquire().await.unwrap();
sqlx::query("PRAGMA foreign_keys=OFF")
.execute(&mut *conn)
.await
.unwrap();
sqlx::query("INSERT INTO dj_plugin (name, author) VALUES ('Orphaned', 999)")
.execute(&mut *conn)
.await
.unwrap();
sqlx::query("PRAGMA foreign_keys=ON")
.execute(&mut *conn)
.await
.unwrap();
}
sqlx::query("INSERT INTO dj_comment (body, plugin) VALUES ('nice', 1)")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO dj_comment (body, plugin) VALUES ('orphan', NULL)")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO dj_cat (name) VALUES ('news')")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO dj_tag (name, category) VALUES ('rust', 1)")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO dj_post (title) VALUES ('hello')")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO dj_post_tags (parent_id, child_id) VALUES (1, 1)")
.execute(&pool)
.await
.unwrap();
})
.await;
}
#[tokio::test]
async fn inner_join_drops_orphan_left_keeps_it() {
boot().await;
let inner = Comment::objects()
.inner_join_related("plugin")
.fetch()
.await
.expect("inner fetch");
let bodies: Vec<&str> = inner.iter().map(|c| c.body.as_str()).collect();
assert!(bodies.contains(&"nice"), "INNER keeps the matched row");
assert!(
!bodies.contains(&"orphan"),
"INNER drops the orphan, got {bodies:?}"
);
let sql = Comment::objects().inner_join_related("plugin").to_sql();
assert!(sql.contains("INNER JOIN"), "expected INNER JOIN: {sql}");
let left = Comment::objects()
.left_join_related("plugin")
.fetch()
.await
.expect("left fetch");
let lbodies: Vec<&str> = left.iter().map(|c| c.body.as_str()).collect();
assert!(
lbodies.contains(&"orphan"),
"LEFT keeps the orphan, got {lbodies:?}"
);
let orphan = left.iter().find(|c| c.body == "orphan").unwrap();
assert!(orphan.plugin.is_none(), "orphan's plugin relation is None");
let lsql = Comment::objects().left_join_related("plugin").to_sql();
assert!(lsql.contains("LEFT JOIN"), "expected LEFT JOIN: {lsql}");
}
#[tokio::test]
async fn nested_inner_join_hydrates_three_level_graph_in_one_query() {
boot().await;
let sql = Comment::objects()
.filter(comment::ID.eq(1))
.inner_join_related("plugin__author")
.to_sql();
assert_eq!(sql.matches("JOIN").count(), 2, "two chained joins: {sql}");
assert!(
sql.contains("INNER JOIN"),
"explicit INNER on the chain: {sql}"
);
assert!(
sql.contains("\"plugin__author__name\""),
"dotted alias: {sql}"
);
let comments = Comment::objects()
.filter(comment::ID.eq(1))
.inner_join_related("plugin__author")
.fetch()
.await
.expect("nested fetch");
assert_eq!(comments.len(), 1, "exactly one matched comment");
let plugin = comments[0]
.plugin
.as_ref()
.expect("plugin wrapper")
.resolved()
.expect("plugin hydrated");
assert_eq!(plugin.name, "Cache");
let author = plugin
.author
.resolved()
.expect("author hydrated from same query");
assert_eq!(
author.name, "Ada",
"comment.plugin.author.name round-trips from ONE query"
);
}
#[tokio::test]
async fn plain_join_infers_inner_for_not_null_fk() {
boot().await;
let sql = Plugin::objects().join_related("author").to_sql();
assert!(sql.contains("INNER JOIN"), "NOT NULL FK -> INNER: {sql}");
assert!(!sql.contains("LEFT JOIN"), "no LEFT for NOT NULL FK: {sql}");
let plugins = Plugin::objects()
.join_related("author")
.fetch()
.await
.expect("fetch");
let names: Vec<&str> = plugins.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"Cache"), "matched plugin survives");
assert!(
!names.contains(&"Orphaned"),
"dangling-FK plugin dropped by inferred INNER, got {names:?}"
);
}
#[tokio::test]
async fn plain_join_infers_left_for_nullable_fk() {
boot().await;
let sql = Comment::objects().join_related("plugin").to_sql();
assert!(sql.contains("LEFT JOIN"), "nullable FK -> LEFT: {sql}");
let comments = Comment::objects()
.join_related("plugin")
.fetch()
.await
.expect("fetch");
let bodies: Vec<&str> = comments.iter().map(|c| c.body.as_str()).collect();
assert!(
bodies.contains(&"orphan"),
"nullable orphan kept by inferred LEFT: {bodies:?}"
);
}
#[tokio::test]
async fn m2m_chain_hydrates_child_and_onward_fk_without_dropping_parents() {
boot().await;
let before = Post2::objects().fetch().await.expect("base").len();
let posts = Post2::objects()
.inner_join_related("tags__category")
.fetch()
.await
.expect("m2m chain fetch");
assert_eq!(posts.len(), before, "parent count stable through M2M hop");
let post = posts.iter().find(|p| p.title == "hello").expect("post");
let tags = post.tags.resolved().expect("tags hydrated");
assert_eq!(tags.len(), 1, "one tag");
let cat = tags[0]
.category
.resolved()
.expect("tag.category hydrated through the chain");
assert_eq!(cat.name, "news");
}
#[tokio::test]
async fn right_join_emits_keyword_and_builds() {
boot().await;
let sql = Comment::objects().right_join_related("plugin").to_sql();
assert!(sql.contains("RIGHT JOIN"), "RIGHT JOIN keyword: {sql}");
let rows = Comment::objects()
.right_join_related("plugin")
.fetch()
.await;
match rows {
Ok(comments) => {
let bodies: Vec<&str> = comments.iter().map(|c| c.body.as_str()).collect();
assert!(
bodies.contains(&"nice"),
"RIGHT JOIN surfaces the matched comment, got {bodies:?}"
);
}
Err(e) => {
let msg = e.to_string();
assert!(
msg.to_uppercase().contains("RIGHT")
|| msg.to_lowercase().contains("syntax")
|| msg.to_lowercase().contains("near"),
"expected a RIGHT-JOIN-unsupported driver error, got: {msg}"
);
}
}
}