1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
//! audit_2 core-orm #2 / #4 — the dynamic pool write path is atomic and
//! reports the UPDATE's real affected-row count.
//!
//! #2: `insert_json` / `update_json` run the parent write AND the M2M
//! junction writes on ONE transaction. A junction-write failure rolls the
//! parent back instead of leaving an orphaned (tag-less) row durably
//! committed. We force a junction failure by deliberately NOT creating the
//! junction table — validation checks the *child* table (which exists), so
//! the write starts, the parent row inserts on the tx, and the junction
//! INSERT then fails on the missing table, rolling the whole thing back.
//!
//! #4: `update_json` returns the UPDATE's real `rows_affected()` (from the
//! effective WHERE, which excludes soft-deleted rows) rather than a
//! matched-count derived from a separate SELECT over the raw predicate
//! (which double-counted soft-deleted rows).
#![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;
/// Serialise the tests in this file. They share one file-backed DB and the
/// `atomtx_post` table; running them concurrently would race on the SQLite
/// write lock and on each other's rows.
fn test_lock() -> &'static TokioMutex<()> {
static LOCK: OnceLock<TokioMutex<()>> = OnceLock::new();
LOCK.get_or_init(|| TokioMutex::new(()))
}
// ── models ──────────────────────────────────────────────────────────────
#[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,
/// M2M to Tag — junction table `atomtx_post_tags` (intentionally NOT
/// created, so the junction write fails after the parent write).
#[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");
// File-backed so the pool's connections all see the same DB (an
// in-memory sqlite DB is per-connection, which breaks read-after-
// write across pool checkouts and across the tx/pool split).
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");
umbral_core::migrate::create_tables_for_tests()
.await
.expect("create the test schema");
// The junction `atomtx_post_tags` must be ABSENT: these tests assert that a write
// whose M2M step fails leaves no parent row behind, and the missing junction is how
// the failure is provoked. The derived schema creates a table for every registered
// model, so the absence has to be arranged on purpose.
sqlx::query("DROP TABLE atomtx_post_tags")
.execute(&pool)
.await
.expect("drop the junction so the M2M write fails");
// One tag so the M2M *validation* (which checks the child table)
// passes and the write actually starts.
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")
}
// ── #2 insert atomicity ─────────────────────────────────────────────────
#[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()));
// tag 1 exists → M2M validation passes; the junction INSERT then fails
// because atomtx_post_tags does not exist.
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"
);
// The parent must NOT be committed — a failed junction write leaves
// ZERO new rows, not an orphaned tag-less row. Scope the check to this
// test's title so a row another test durably seeded can't mask a
// regression here.
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)"
);
}
// ── #2 update atomicity ─────────────────────────────────────────────────
#[tokio::test]
async fn update_json_rolls_back_parent_when_junction_write_fails() {
let _guard = test_lock().lock().await;
boot().await;
// Seed a post directly (bypassing the M2M path).
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");
// The UPDATE must have rolled back with the failed junction write: the
// title is unchanged. Read it back with raw SQL — `fetch_as_json`
// would try to hydrate the (deliberately missing) junction table.
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"
);
}
// ── #4 real rows_affected on a soft-delete model ─────────────────────────
#[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();
// Two rows sharing title "dup": one live, one soft-deleted.
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()));
// Filter matches BOTH rows on the raw predicate, but the effective
// WHERE (deleted_at IS NULL) skips the soft-deleted one. The return
// must be the UPDATE's real rows_affected == 1, not the matched-count
// (2) the old code derived from a raw-predicate SELECT.
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"
);
// Read back: the live row changed, the soft-deleted row did not.
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"
);
}