#![allow(dead_code)]
use std::sync::OnceLock;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::Mutex as TokioMutex;
use tokio::sync::OnceCell;
use umbral::migrate::registered_models;
use umbral::orm::{DynQuerySet, M2M};
use umbral_core::db;
fn test_lock() -> &'static TokioMutex<()> {
static LOCK: OnceLock<TokioMutex<()>> = OnceLock::new();
LOCK.get_or_init(|| TokioMutex::new(()))
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "atomtx_tag")]
pub struct Tag {
pub id: i64,
#[umbral(string)]
pub name: String,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "atomtx_post")]
pub struct Post {
pub id: i64,
#[umbral(string)]
pub title: String,
#[umbral(m2m = "atomtx_tag")]
pub tags: M2M<Tag>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(soft_delete, table = "atomtx_sd")]
pub struct SoftPost {
pub id: i64,
#[umbral(string)]
pub title: String,
pub deleted_at: Option<DateTime<Utc>>,
}
static BOOT: OnceCell<()> = OnceCell::const_new();
async fn boot() {
BOOT.get_or_init(|| async {
let settings = umbral::Settings::from_env().expect("figment defaults");
let dir = std::env::temp_dir();
let path = dir.join(format!(
"umbral_dyn_write_atomicity_{}.db",
std::process::id()
));
let _ = std::fs::remove_file(&path);
let url = format!("sqlite://{}?mode=rwc", path.display());
let pool = db::connect_sqlite(&url).await.expect("file-backed sqlite");
umbral::App::builder()
.settings(settings)
.database("default", pool.clone())
.model::<Tag>()
.model::<Post>()
.model::<SoftPost>()
.build()
.expect("App::build");
for sql in &[
"CREATE TABLE atomtx_tag (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
)",
"CREATE TABLE atomtx_post (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL
)",
"CREATE TABLE atomtx_sd (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
deleted_at TEXT
)",
] {
sqlx::query(sql).execute(&pool).await.expect("ddl");
}
sqlx::query("INSERT INTO atomtx_tag (id, name) VALUES (1, 'rust')")
.execute(&pool)
.await
.expect("seed tag");
})
.await;
}
fn meta(table: &str) -> umbral::migrate::ModelMeta {
registered_models()
.into_iter()
.find(|m| m.table == table)
.expect("registered")
}
#[tokio::test]
async fn insert_json_rolls_back_parent_when_junction_write_fails() {
let _guard = test_lock().lock().await;
boot().await;
let mut body = serde_json::Map::new();
body.insert("title".to_string(), Value::String("orphan?".to_string()));
body.insert("tags".to_string(), Value::Array(vec![Value::from(1_i64)]));
let res = DynQuerySet::for_meta(&meta("atomtx_post"))
.insert_json(&body)
.await;
assert!(
res.is_err(),
"insert must surface the junction-write failure as Err"
);
let remaining = DynQuerySet::for_meta(&meta("atomtx_post"))
.filter_eq_string("title", "orphan?")
.count()
.await
.expect("count");
assert_eq!(
remaining, 0,
"parent INSERT must roll back with the junction write; found {remaining} orphan(s)"
);
}
#[tokio::test]
async fn update_json_rolls_back_parent_when_junction_write_fails() {
let _guard = test_lock().lock().await;
boot().await;
let pool = db::pool();
sqlx::query("INSERT INTO atomtx_post (id, title) VALUES (100, 'orig')")
.execute(&pool)
.await
.expect("seed post");
let mut patch = serde_json::Map::new();
patch.insert("title".to_string(), Value::String("changed".to_string()));
patch.insert("tags".to_string(), Value::Array(vec![Value::from(1_i64)]));
let res = DynQuerySet::for_meta(&meta("atomtx_post"))
.filter_eq_string("id", "100")
.update_json(&patch)
.await;
assert!(res.is_err(), "update must surface the junction failure");
let title: String = sqlx::query_scalar("SELECT title FROM atomtx_post WHERE id = 100")
.fetch_one(&pool)
.await
.expect("read back");
assert_eq!(
title, "orig",
"the UPDATE must roll back with the junction write, not half-apply"
);
}
#[tokio::test]
async fn update_json_returns_real_rows_affected_excluding_soft_deleted() {
let _guard = test_lock().lock().await;
boot().await;
let pool = db::pool();
sqlx::query("INSERT INTO atomtx_sd (id, title, deleted_at) VALUES (1, 'dup', NULL)")
.execute(&pool)
.await
.expect("seed live");
sqlx::query(
"INSERT INTO atomtx_sd (id, title, deleted_at) VALUES (2, 'dup', '2020-01-01T00:00:00Z')",
)
.execute(&pool)
.await
.expect("seed trashed");
let mut patch = serde_json::Map::new();
patch.insert("title".to_string(), Value::String("touched".to_string()));
let n = DynQuerySet::for_meta(&meta("atomtx_sd"))
.filter_eq_string("title", "dup")
.update_json(&patch)
.await
.expect("update");
assert_eq!(
n, 1,
"rows_affected must count only the live row the UPDATE touched, not the soft-deleted one"
);
let live = DynQuerySet::for_meta(&meta("atomtx_sd"))
.filter_eq_string("id", "1")
.fetch_as_json()
.await
.expect("fetch live");
assert_eq!(live[0]["title"].as_str(), Some("touched"));
let trashed = DynQuerySet::for_meta(&meta("atomtx_sd"))
.with_deleted()
.filter_eq_string("id", "2")
.fetch_as_json()
.await
.expect("fetch trashed");
assert_eq!(
trashed[0]["title"].as_str(),
Some("dup"),
"the soft-deleted row must be untouched by the update"
);
}