toasty_driver_integration_suite/tests/
tx_atomic_stmt.rs

1use crate::prelude::*;
2
3use toasty_core::driver::{Operation, operation::Transaction};
4
5// ===== Transaction wrapping =====
6
7/// A multi-op create (user + associated todo) should be wrapped in
8/// BEGIN ... COMMIT so the driver sees all three transaction operations.
9#[driver_test(id(ID), requires(sql), scenario(crate::scenarios::has_many_belongs_to))]
10pub async fn multi_op_create_wraps_in_transaction(t: &mut Test) -> Result<()> {
11    let mut db = setup(t).await;
12
13    t.log().clear();
14    let user = User::create()
15        .name("Alice")
16        .todo(Todo::create().title("task"))
17        .exec(&mut db)
18        .await?;
19
20    assert_struct!(
21        t.log().pop_op(),
22        Operation::Transaction(Transaction::Start {
23            isolation: None,
24            read_only: false
25        })
26    );
27    assert_struct!(t.log().pop_op(), Operation::QuerySql(_)); // INSERT user
28    assert_struct!(t.log().pop_op(), Operation::QuerySql(_)); // INSERT todo
29    assert_struct!(
30        t.log().pop_op(),
31        Operation::Transaction(Transaction::Commit)
32    );
33    assert!(t.log().is_empty());
34
35    let todos = user.todos().exec(&mut db).await?;
36    assert_eq!(1, todos.len());
37
38    Ok(())
39}
40
41/// A single-op create (no associations) must NOT be wrapped in a transaction —
42/// the engine skips the overhead for plans with only one DB operation.
43#[driver_test(id(ID), requires(sql), scenario(crate::scenarios::two_models))]
44pub async fn single_op_skips_transaction(t: &mut Test) -> Result<()> {
45    let mut db = setup(t).await;
46
47    t.log().clear();
48    User::create().name("x").exec(&mut db).await?;
49
50    // Only the INSERT — no Transaction::Start { isolation: None, read_only: false } bookending it
51    assert_struct!(t.log().pop_op(), Operation::QuerySql(_));
52    assert!(t.log().is_empty());
53
54    Ok(())
55}
56
57// ===== Rollback on partial failure =====
58
59/// When the second INSERT in a has_many create plan fails (unique constraint),
60/// the driver should receive Transaction::Rollback and no orphaned user should
61/// remain in the database.
62///
63/// Uses u64 (auto-increment) IDs so that the engine always generates two
64/// separate DB operations (INSERT user then INSERT todo), ensuring the
65/// explicit transaction wrapping is exercised. With uuid::Uuid IDs the engine
66/// reorders execution (INSERT todo before INSERT user due to the Const
67/// optimization), which produces a different but equally valid log pattern.
68#[driver_test(requires(sql))]
69pub async fn create_with_has_many_rolls_back_on_failure(t: &mut Test) -> Result<()> {
70    #[derive(Debug, toasty::Model)]
71    struct User {
72        #[key]
73        #[auto]
74        id: u64,
75
76        #[has_many]
77        todos: toasty::HasMany<Todo>,
78    }
79
80    #[derive(Debug, toasty::Model)]
81    struct Todo {
82        #[key]
83        #[auto]
84        id: u64,
85
86        #[index]
87        user_id: u64,
88
89        #[belongs_to(key = user_id, references = id)]
90        user: toasty::BelongsTo<User>,
91
92        #[unique]
93        title: String,
94    }
95
96    let mut db = t.setup_db(models!(User, Todo)).await;
97
98    // Seed the title that will cause the second INSERT to fail.
99    User::create()
100        .todo(Todo::create().title("taken"))
101        .exec(&mut db)
102        .await?;
103
104    t.log().clear();
105    assert_err!(
106        User::create()
107            .todo(Todo::create().title("taken"))
108            .exec(&mut db)
109            .await
110    );
111
112    // Transaction::Start { isolation: None, read_only: false } → INSERT user (succeeds, logged) →
113    // INSERT todo (fails on unique constraint, NOT logged) → Transaction::Rollback
114    assert_struct!(
115        t.log().pop_op(),
116        Operation::Transaction(Transaction::Start {
117            isolation: None,
118            read_only: false
119        })
120    );
121    assert_struct!(t.log().pop_op(), Operation::QuerySql(_)); // INSERT user
122    assert_struct!(
123        t.log().pop_op(),
124        Operation::Transaction(Transaction::Rollback)
125    );
126    assert!(t.log().is_empty());
127
128    // No orphaned user — count unchanged from pre-seed
129    let users = User::all().exec(&mut db).await?;
130    assert_eq!(1, users.len());
131
132    Ok(())
133}
134
135/// Same rollback guarantee for a has_one association create.
136///
137/// Uses u64 (auto-increment) IDs so that the engine always generates two
138/// separate DB operations (INSERT user then INSERT profile), ensuring the
139/// explicit transaction wrapping is exercised. With uuid::Uuid IDs the engine
140/// can combine both inserts into a single atomic SQL statement, which provides
141/// atomicity without an explicit transaction.
142#[driver_test(requires(sql))]
143pub async fn create_with_has_one_rolls_back_on_failure(t: &mut Test) -> Result<()> {
144    #[derive(Debug, toasty::Model)]
145    struct User {
146        #[key]
147        #[auto]
148        id: u64,
149
150        #[has_one]
151        profile: toasty::HasOne<Option<Profile>>,
152    }
153
154    #[derive(Debug, toasty::Model)]
155    struct Profile {
156        #[key]
157        #[auto]
158        id: u64,
159
160        #[unique]
161        bio: String,
162
163        #[unique]
164        user_id: u64,
165
166        #[belongs_to(key = user_id, references = id)]
167        user: toasty::BelongsTo<User>,
168    }
169
170    let mut db = t.setup_db(models!(User, Profile)).await;
171
172    // Seed the bio that will cause the second INSERT to fail.
173    User::create()
174        .profile(Profile::create().bio("taken"))
175        .exec(&mut db)
176        .await?;
177
178    t.log().clear();
179    assert_err!(
180        User::create()
181            .profile(Profile::create().bio("taken"))
182            .exec(&mut db)
183            .await
184    );
185
186    assert_struct!(
187        t.log().pop_op(),
188        Operation::Transaction(Transaction::Start {
189            isolation: None,
190            read_only: false
191        })
192    );
193    assert_struct!(t.log().pop_op(), Operation::QuerySql(_)); // INSERT user
194    assert_struct!(
195        t.log().pop_op(),
196        Operation::Transaction(Transaction::Rollback)
197    );
198    assert!(t.log().is_empty());
199
200    // No orphaned user — count unchanged from pre-seed
201    let users = User::all().exec(&mut db).await?;
202    assert_eq!(1, users.len());
203
204    Ok(())
205}
206
207/// When an update + new-association plan fails on the UPDATE (after the
208/// INSERT succeeds), the INSERT must also be rolled back.
209///
210/// The engine always executes INSERT before UPDATE in such plans (INSERT is
211/// a dependency of the UPDATE's returning clause). So the collision is placed
212/// on the User's name field (not the Todo), ensuring the INSERT succeeds first
213/// and is then rolled back when the subsequent UPDATE fails.
214#[driver_test(id(ID), requires(sql))]
215pub async fn update_with_new_association_rolls_back_on_failure(t: &mut Test) -> Result<()> {
216    #[derive(Debug, toasty::Model)]
217    struct User {
218        #[key]
219        #[auto]
220        id: ID,
221
222        #[unique]
223        name: String,
224
225        #[has_many]
226        todos: toasty::HasMany<Todo>,
227    }
228
229    #[derive(Debug, toasty::Model)]
230    struct Todo {
231        #[key]
232        #[auto]
233        id: ID,
234
235        #[index]
236        user_id: ID,
237
238        #[belongs_to(key = user_id, references = id)]
239        user: toasty::BelongsTo<User>,
240
241        title: String,
242    }
243
244    let mut db = t.setup_db(models!(User, Todo)).await;
245
246    let mut user = User::create().name("original").exec(&mut db).await?;
247    // Seed the name collision — this user's name will be duplicated by the failing UPDATE.
248    User::create().name("taken").exec(&mut db).await?;
249
250    t.log().clear();
251    assert_err!(
252        user.update()
253            .name("taken") // UPDATE will fail: unique name
254            .todos(toasty::stmt::insert(Todo::create().title("new-todo"))) // INSERT runs first and succeeds
255            .exec(&mut db)
256            .await
257    );
258
259    // INSERT todo runs first (succeeds, logged), then UPDATE user fails on unique
260    // name → Transaction::Rollback undoes the INSERT.
261    assert_struct!(
262        t.log().pop_op(),
263        Operation::Transaction(Transaction::Start {
264            isolation: None,
265            read_only: false
266        })
267    );
268    assert_struct!(t.log().pop_op(), Operation::QuerySql(_)); // INSERT todo (rolled back)
269    assert_struct!(
270        t.log().pop_op(),
271        Operation::Transaction(Transaction::Rollback)
272    );
273    assert!(t.log().is_empty());
274
275    // INSERT was rolled back — no orphaned todo
276    let todos = user.todos().exec(&mut db).await?;
277    assert!(todos.is_empty());
278
279    Ok(())
280}
281
282// ===== ReadModifyWrite transaction behavior =====
283
284/// A successful standalone conditional update (link/unlink) wraps itself in
285/// its own BEGIN...COMMIT on drivers that don't support CTE-with-update
286/// (SQLite, MySQL). When nested inside an outer transaction it uses savepoints
287/// instead. On PostgreSQL the same operation is a single CTE-based QuerySql.
288#[driver_test(id(ID), requires(sql))]
289pub async fn rmw_uses_savepoints(t: &mut Test) -> Result<()> {
290    #[derive(Debug, toasty::Model)]
291    struct User {
292        #[key]
293        #[auto]
294        id: ID,
295
296        #[has_many]
297        todos: toasty::HasMany<Todo>,
298    }
299
300    #[derive(Debug, toasty::Model)]
301    struct Todo {
302        #[key]
303        #[auto]
304        id: ID,
305
306        #[index]
307        user_id: Option<ID>,
308
309        #[belongs_to(key = user_id, references = id)]
310        user: toasty::BelongsTo<Option<User>>,
311    }
312
313    let mut db = t.setup_db(models!(User, Todo)).await;
314
315    let user = User::create().todo(Todo::create()).exec(&mut db).await?;
316    let todos: Vec<_> = user.todos().exec(&mut db).await?;
317
318    t.log().clear();
319    user.todos().remove(&mut db, &todos[0]).await?;
320
321    if t.capability().cte_with_update {
322        // PostgreSQL: single CTE bundles the condition + update
323        assert_struct!(t.log().pop_op(), Operation::QuerySql(_));
324    } else {
325        // SQLite / MySQL: standalone RMW starts its own transaction
326        assert_struct!(
327            t.log().pop_op(),
328            Operation::Transaction(Transaction::Start {
329                isolation: None,
330                read_only: false
331            })
332        );
333        assert_struct!(t.log().pop_op(), Operation::QuerySql(_)); // read
334        assert_struct!(t.log().pop_op(), Operation::QuerySql(_)); // write
335        assert_struct!(
336            t.log().pop_op(),
337            Operation::Transaction(Transaction::Commit)
338        );
339    }
340    assert!(t.log().is_empty());
341
342    Ok(())
343}
344
345/// When a standalone RMW condition fails (todo doesn't belong to this user),
346/// the driver should receive ROLLBACK on the RMW's own transaction.
347/// On PostgreSQL the CTE handles this in a single statement.
348#[driver_test(id(ID), requires(sql))]
349pub async fn rmw_condition_failure_issues_rollback_to_savepoint(t: &mut Test) -> Result<()> {
350    #[derive(Debug, toasty::Model)]
351    struct User {
352        #[key]
353        #[auto]
354        id: ID,
355
356        #[has_many]
357        todos: toasty::HasMany<Todo>,
358    }
359
360    #[derive(Debug, toasty::Model)]
361    struct Todo {
362        #[key]
363        #[auto]
364        id: ID,
365
366        #[index]
367        user_id: Option<ID>,
368
369        #[belongs_to(key = user_id, references = id)]
370        user: toasty::BelongsTo<Option<User>>,
371    }
372
373    let mut db = t.setup_db(models!(User, Todo)).await;
374
375    let user1 = User::create().exec(&mut db).await?;
376    let user2 = User::create().todo(Todo::create()).exec(&mut db).await?;
377    let u2_todos: Vec<_> = user2.todos().exec(&mut db).await?;
378
379    t.log().clear();
380    // Remove u2's todo via user1 — condition (user_id = user1.id) won't match
381    assert_err!(user1.todos().remove(&mut db, &u2_todos[0]).await);
382
383    if t.capability().cte_with_update {
384        // PostgreSQL: a single QuerySql; condition handled inside the CTE
385        assert_struct!(t.log().pop_op(), Operation::QuerySql(_));
386    } else {
387        // SQLite / MySQL: standalone RMW starts its own transaction;
388        // condition failure rolls it back
389        assert_struct!(
390            t.log().pop_op(),
391            Operation::Transaction(Transaction::Start {
392                isolation: None,
393                read_only: false
394            })
395        );
396        assert_struct!(t.log().pop_op(), Operation::QuerySql(_)); // read
397        assert_struct!(
398            t.log().pop_op(),
399            Operation::Transaction(Transaction::Rollback)
400        );
401    }
402    assert!(t.log().is_empty());
403
404    // The todo is untouched — still belongs to user2
405    let reloaded = Todo::get_by_id(&mut db, u2_todos[0].id).await?;
406    assert_struct!(reloaded, { user_id: Some(== user2.id) });
407
408    Ok(())
409}