Skip to main content

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