Skip to content

Commit e5dcac4

Browse files
committed
Add support for nested transactions
1 parent f92d6ae commit e5dcac4

4 files changed

Lines changed: 244 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
All notable changes to this project will be documented in this file. This project adhere to the [Semantic Versioning](http://semver.org/) standard.
44

5+
## [1.3.0] - 2026-02-25
6+
7+
### Added
8+
- Transaction nesting support via savepoints. Nested `beginTransaction()` calls now create savepoints instead of issuing `START TRANSACTION`, which would implicitly commit any existing transaction.
9+
- `DB::transactionLevel()` method to retrieve the current transaction nesting depth.
10+
11+
### Changed
12+
- `DB::commit()` releases the current savepoint when nested, and issues a real `COMMIT` only at the outermost level.
13+
- `DB::rollback()` rolls back to the current savepoint when nested, and issues a real `ROLLBACK` only at the outermost level.
14+
- `tests-php.yml` ensure slic runs in PHP 7.4.
15+
516
## [1.0.8] TBD
617

718
* Feat - Add the `DB::generate_results` and `DB::generate_col` methods to the `DB` class to fetch all results matching an unbounded query with a set of bounded queries.

src/DB/DB.php

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ class DB {
4545
*/
4646
private static $provider;
4747

48+
/**
49+
* Current transaction nesting depth.
50+
*
51+
* @since 1.3.0
52+
*
53+
* @var int
54+
*/
55+
private static $transactionDepth = 0;
56+
57+
4858
/**
4959
* Initializes the service provider.
5060
*
@@ -60,6 +70,17 @@ public static function init(): void {
6070
self::$initialized = true;
6171
}
6272

73+
/**
74+
* Returns the current transaction nesting depth.
75+
*
76+
* @since 1.3.0
77+
*
78+
* @return int
79+
*/
80+
public static function transactionLevel() {
81+
return self::$transactionDepth;
82+
}
83+
6384
/**
6485
* Runs the dbDelta function and returns a WP_Error with any errors that occurred during the process
6586
*
@@ -211,39 +232,72 @@ public static function transaction( callable $callback ) {
211232
}
212233

213234
/**
214-
* Manually starts a transaction
235+
* Manually starts a transaction.
236+
*
237+
* Nested calls create savepoints instead of starting a new transaction,
238+
* preventing MySQL's implicit commit behavior.
215239
*
216240
* @since 1.0.0
241+
* @since 1.3.0 Added savepoint support for nested transactions.
217242
*
218243
* @return void
219244
*/
220245
public static function beginTransaction() {
221246
global $wpdb;
222-
$wpdb->query( 'START TRANSACTION' );
247+
248+
if ( self::$transactionDepth === 0 ) {
249+
$wpdb->query( 'START TRANSACTION' );
250+
} else {
251+
$wpdb->query( 'SAVEPOINT sp_' . self::$transactionDepth );
252+
}
253+
254+
self::$transactionDepth++;
223255
}
224256

225257
/**
226-
* Manually rolls back a transaction
258+
* Manually rolls back a transaction.
259+
*
260+
* When nested, rolls back to the current savepoint. When at the outermost
261+
* level, issues a real ROLLBACK.
227262
*
228263
* @since 1.0.0
264+
* @since 1.3.0 Added savepoint support for nested transactions.
229265
*
230266
* @return void
231267
*/
232268
public static function rollback() {
233269
global $wpdb;
234-
$wpdb->query( 'ROLLBACK' );
270+
271+
self::$transactionDepth = max( 0, self::$transactionDepth - 1 );
272+
273+
if ( self::$transactionDepth === 0 ) {
274+
$wpdb->query( 'ROLLBACK' );
275+
} else {
276+
$wpdb->query( 'ROLLBACK TO SAVEPOINT sp_' . self::$transactionDepth );
277+
}
235278
}
236279

237280
/**
238-
* Manually commits a transaction
281+
* Manually commits a transaction.
282+
*
283+
* When nested, releases the current savepoint. When at the outermost
284+
* level, issues a real COMMIT.
239285
*
240286
* @since 1.0.0
287+
* @since 1.3.0 Added savepoint support for nested transactions.
241288
*
242289
* @return void
243290
*/
244291
public static function commit() {
245292
global $wpdb;
246-
$wpdb->query( 'COMMIT' );
293+
294+
self::$transactionDepth = max( 0, self::$transactionDepth - 1 );
295+
296+
if ( self::$transactionDepth === 0 ) {
297+
$wpdb->query( 'COMMIT' );
298+
} else {
299+
$wpdb->query( 'RELEASE SAVEPOINT sp_' . self::$transactionDepth );
300+
}
247301
}
248302

249303
/**

tests/_support/Helper/DBTestCase.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
use Codeception\TestCase\WPTestCase;
66
use StellarWP\DB\DB;
77

8+
/**
9+
* The base test case.
10+
*
11+
* @mixin \Codeception\PHPUnit\TestCase
12+
*/
813
class DBTestCase extends WPTestCase {
914
protected $backupGlobals = false;
1015

tests/wpunit/TransActionTest.php

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<?php declare( strict_types=1 );
2+
3+
namespace StellarWP\DB;
4+
5+
use StellarWP\DB\Tests\DBTestCase;
6+
7+
/**
8+
* @backupStaticAttributes
9+
*/
10+
final class TransactionTest extends DBTestCase {
11+
12+
protected function tearDown() {
13+
parent::tearDown();
14+
15+
// Reset transaction depth in case a test leaves it dirty.
16+
while ( DB::transactionLevel() > 0 ) {
17+
DB::rollback();
18+
}
19+
}
20+
21+
public function test_it_should_start_real_transaction_at_depth_zero(): void {
22+
global $wpdb;
23+
24+
DB::beginTransaction();
25+
26+
$this->assertSame( 1, DB::transactionLevel() );
27+
28+
// Verify we're in a real transaction by inserting + rolling back.
29+
$wpdb->query( "CREATE TEMPORARY TABLE _tx_test (id INT)" );
30+
$wpdb->query( "INSERT INTO _tx_test VALUES (1)" );
31+
32+
DB::rollback();
33+
34+
$this->assertSame( 0, DB::transactionLevel() );
35+
36+
// Row should be gone after rollback.
37+
$result = $wpdb->get_var( "SELECT COUNT(*) FROM _tx_test" );
38+
$this->assertEquals( 0, $result );
39+
40+
$wpdb->query( "DROP TEMPORARY TABLE IF EXISTS _tx_test" );
41+
}
42+
43+
public function test_it_should_use_savepoint_for_nested_transaction(): void {
44+
DB::beginTransaction();
45+
DB::beginTransaction();
46+
47+
$this->assertSame( 2, DB::transactionLevel() );
48+
49+
DB::commit();
50+
51+
$this->assertSame( 1, DB::transactionLevel() );
52+
53+
DB::commit();
54+
55+
$this->assertSame( 0, DB::transactionLevel() );
56+
}
57+
58+
public function test_it_should_rollback_savepoint_without_affecting_outer_transaction(): void {
59+
global $wpdb;
60+
61+
$wpdb->query( "CREATE TEMPORARY TABLE _tx_test (id INT)" );
62+
63+
DB::beginTransaction();
64+
65+
$wpdb->query( "INSERT INTO _tx_test VALUES (1)" );
66+
67+
// Nested transaction via savepoint.
68+
DB::beginTransaction();
69+
$wpdb->query( "INSERT INTO _tx_test VALUES (2)" );
70+
DB::rollback(); // Rolls back savepoint only.
71+
72+
$this->assertSame( 1, DB::transactionLevel() );
73+
74+
DB::commit(); // Commits outer transaction.
75+
76+
// Row 1 should survive, row 2 should not.
77+
$count = $wpdb->get_var( "SELECT COUNT(*) FROM _tx_test WHERE id = 1" );
78+
$this->assertEquals( 1, $count );
79+
80+
$count = $wpdb->get_var( "SELECT COUNT(*) FROM _tx_test WHERE id = 2" );
81+
$this->assertEquals( 0, $count );
82+
83+
$wpdb->query( "DROP TEMPORARY TABLE IF EXISTS _tx_test" );
84+
}
85+
86+
public function test_it_should_rollback_entire_transaction_at_depth_zero(): void {
87+
global $wpdb;
88+
89+
$wpdb->query( "CREATE TEMPORARY TABLE _tx_test (id INT)" );
90+
91+
DB::beginTransaction();
92+
$wpdb->query( "INSERT INTO _tx_test VALUES (1)" );
93+
94+
DB::beginTransaction();
95+
$wpdb->query( "INSERT INTO _tx_test VALUES (2)" );
96+
DB::commit(); // Release savepoint.
97+
98+
DB::rollback(); // Rollback outer — both rows gone.
99+
100+
$count = $wpdb->get_var( "SELECT COUNT(*) FROM _tx_test" );
101+
$this->assertEquals( 0, $count );
102+
103+
$wpdb->query( "DROP TEMPORARY TABLE IF EXISTS _tx_test" );
104+
}
105+
106+
public function test_transaction_helper_should_commit_on_success() {
107+
global $wpdb;
108+
109+
$wpdb->query( "CREATE TEMPORARY TABLE _tx_test (id INT)" );
110+
111+
DB::transaction( static function () use ( $wpdb ) {
112+
$wpdb->query( "INSERT INTO _tx_test VALUES (1)" );
113+
} );
114+
115+
$count = $wpdb->get_var( "SELECT COUNT(*) FROM _tx_test" );
116+
$this->assertEquals( 1, $count );
117+
118+
$wpdb->query( "DROP TEMPORARY TABLE IF EXISTS _tx_test" );
119+
}
120+
121+
public function test_transaction_helper_should_rollback_on_exception(): void {
122+
global $wpdb;
123+
124+
$wpdb->query( "CREATE TEMPORARY TABLE _tx_test (id INT)" );
125+
126+
try {
127+
DB::transaction( static function () use ( $wpdb ) {
128+
$wpdb->query( "INSERT INTO _tx_test VALUES (1)" );
129+
throw new \RuntimeException( 'fail' );
130+
} );
131+
} catch ( \RuntimeException $e ) {
132+
// Expected.
133+
}
134+
135+
$count = $wpdb->get_var( "SELECT COUNT(*) FROM _tx_test" );
136+
$this->assertEquals( 0, $count );
137+
138+
$this->assertSame( 0, DB::transactionLevel() );
139+
140+
$wpdb->query( "DROP TEMPORARY TABLE IF EXISTS _tx_test" );
141+
}
142+
143+
public function test_nested_transaction_helper_should_use_savepoints(): void {
144+
global $wpdb;
145+
146+
$wpdb->query( "CREATE TEMPORARY TABLE _tx_test (id INT)" );
147+
148+
DB::transaction( static function () use ( $wpdb ) {
149+
$wpdb->query( "INSERT INTO _tx_test VALUES (1)" );
150+
151+
// Nested transaction — should use savepoint.
152+
DB::transaction( static function () use ( $wpdb ) {
153+
$wpdb->query( "INSERT INTO _tx_test VALUES (2)" );
154+
} );
155+
} );
156+
157+
$count = $wpdb->get_var( "SELECT COUNT(*) FROM _tx_test" );
158+
$this->assertEquals( 2, $count );
159+
160+
$this->assertSame( 0, DB::transactionLevel() );
161+
162+
$wpdb->query( "DROP TEMPORARY TABLE IF EXISTS _tx_test" );
163+
}
164+
165+
public function test_transaction_level_starts_at_zero(): void {
166+
$this->assertSame( 0, DB::transactionLevel() );
167+
}
168+
}

0 commit comments

Comments
 (0)