diff --git a/.github/workflows/tests-php.yml b/.github/workflows/tests-php.yml index 9446707..8086ce9 100644 --- a/.github/workflows/tests-php.yml +++ b/.github/workflows/tests-php.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v2 + uses: actions/checkout@v6 with: fetch-depth: 1000 submodules: recursive @@ -18,25 +18,13 @@ jobs: # Checkout slic # ------------------------------------------------------------------------------ - name: Checkout slic - uses: actions/checkout@v2 + uses: actions/checkout@v6 with: repository: stellarwp/slic ref: main path: slic fetch-depth: 1 - # ------------------------------------------------------------------------------ - # Prepare our composer cache directory - # ------------------------------------------------------------------------------ - - name: Get Composer Cache Directory - id: composer-cache - run: | - echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - uses: actions/cache@v4 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- + # ------------------------------------------------------------------------------ # Initialize slic # ------------------------------------------------------------------------------ @@ -62,6 +50,7 @@ jobs: ${SLIC_BIN} build-subdir off ${SLIC_BIN} xdebug off ${SLIC_BIN} debug on + ${SLIC_BIN} php-version set 7.4 --skip-rebuild ${SLIC_BIN} info ${SLIC_BIN} config - name: Set up StellarWP DB diff --git a/CHANGELOG.md b/CHANGELOG.md index 1949a10..102ab45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. This project adhere to the [Semantic Versioning](http://semver.org/) standard. +## [1.3.0] - 2026-02-25 + +### Added +- Transaction nesting support via savepoints. Nested `beginTransaction()` calls now create savepoints instead of issuing `START TRANSACTION`, which would implicitly commit any existing transaction. +- `DB::transactionLevel()` method to retrieve the current transaction nesting depth. + +### Changed +- `DB::commit()` releases the current savepoint when nested, and issues a real `COMMIT` only at the outermost level. +- `DB::rollback()` rolls back to the current savepoint when nested, and issues a real `ROLLBACK` only at the outermost level. +- `tests-php.yml` ensure slic runs in PHP 7.4. + ## [1.0.8] TBD * 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. diff --git a/src/DB/DB.php b/src/DB/DB.php index 4aefa20..b032ba2 100644 --- a/src/DB/DB.php +++ b/src/DB/DB.php @@ -45,6 +45,16 @@ class DB { */ private static $provider; + /** + * Current transaction nesting depth. + * + * @since 1.3.0 + * + * @var int + */ + private static $transactionDepth = 0; + + /** * Initializes the service provider. * @@ -60,6 +70,17 @@ public static function init(): void { self::$initialized = true; } + /** + * Returns the current transaction nesting depth. + * + * @since 1.3.0 + * + * @return int + */ + public static function transactionLevel() { + return self::$transactionDepth; + } + /** * Runs the dbDelta function and returns a WP_Error with any errors that occurred during the process * @@ -211,39 +232,72 @@ public static function transaction( callable $callback ) { } /** - * Manually starts a transaction + * Manually starts a transaction. + * + * Nested calls create savepoints instead of starting a new transaction, + * preventing MySQL's implicit commit behavior. * * @since 1.0.0 + * @since 1.3.0 Added savepoint support for nested transactions. * * @return void */ public static function beginTransaction() { global $wpdb; - $wpdb->query( 'START TRANSACTION' ); + + if ( self::$transactionDepth === 0 ) { + $wpdb->query( 'START TRANSACTION' ); + } else { + $wpdb->query( 'SAVEPOINT sp_' . self::$transactionDepth ); + } + + self::$transactionDepth++; } /** - * Manually rolls back a transaction + * Manually rolls back a transaction. + * + * When nested, rolls back to the current savepoint. When at the outermost + * level, issues a real ROLLBACK. * * @since 1.0.0 + * @since 1.3.0 Added savepoint support for nested transactions. * * @return void */ public static function rollback() { global $wpdb; - $wpdb->query( 'ROLLBACK' ); + + self::$transactionDepth = max( 0, self::$transactionDepth - 1 ); + + if ( self::$transactionDepth === 0 ) { + $wpdb->query( 'ROLLBACK' ); + } else { + $wpdb->query( 'ROLLBACK TO SAVEPOINT sp_' . self::$transactionDepth ); + } } /** - * Manually commits a transaction + * Manually commits a transaction. + * + * When nested, releases the current savepoint. When at the outermost + * level, issues a real COMMIT. * * @since 1.0.0 + * @since 1.3.0 Added savepoint support for nested transactions. * * @return void */ public static function commit() { global $wpdb; - $wpdb->query( 'COMMIT' ); + + self::$transactionDepth = max( 0, self::$transactionDepth - 1 ); + + if ( self::$transactionDepth === 0 ) { + $wpdb->query( 'COMMIT' ); + } else { + $wpdb->query( 'RELEASE SAVEPOINT sp_' . self::$transactionDepth ); + } } /** diff --git a/tests/_support/Helper/DBTestCase.php b/tests/_support/Helper/DBTestCase.php index 73fa8a8..263ae64 100644 --- a/tests/_support/Helper/DBTestCase.php +++ b/tests/_support/Helper/DBTestCase.php @@ -5,6 +5,11 @@ use Codeception\TestCase\WPTestCase; use StellarWP\DB\DB; +/** + * The base test case. + * + * @mixin \Codeception\PHPUnit\TestCase + */ class DBTestCase extends WPTestCase { protected $backupGlobals = false; diff --git a/tests/wpunit/TransactionTest.php b/tests/wpunit/TransactionTest.php new file mode 100644 index 0000000..6da269d --- /dev/null +++ b/tests/wpunit/TransactionTest.php @@ -0,0 +1,168 @@ + 0 ) { + DB::rollback(); + } + } + + public function test_it_should_start_real_transaction_at_depth_zero(): void { + global $wpdb; + + DB::beginTransaction(); + + $this->assertSame( 1, DB::transactionLevel() ); + + // Verify we're in a real transaction by inserting + rolling back. + $wpdb->query( "CREATE TEMPORARY TABLE _tx_test (id INT)" ); + $wpdb->query( "INSERT INTO _tx_test VALUES (1)" ); + + DB::rollback(); + + $this->assertSame( 0, DB::transactionLevel() ); + + // Row should be gone after rollback. + $result = $wpdb->get_var( "SELECT COUNT(*) FROM _tx_test" ); + $this->assertEquals( 0, $result ); + + $wpdb->query( "DROP TEMPORARY TABLE IF EXISTS _tx_test" ); + } + + public function test_it_should_use_savepoint_for_nested_transaction(): void { + DB::beginTransaction(); + DB::beginTransaction(); + + $this->assertSame( 2, DB::transactionLevel() ); + + DB::commit(); + + $this->assertSame( 1, DB::transactionLevel() ); + + DB::commit(); + + $this->assertSame( 0, DB::transactionLevel() ); + } + + public function test_it_should_rollback_savepoint_without_affecting_outer_transaction(): void { + global $wpdb; + + $wpdb->query( "CREATE TEMPORARY TABLE _tx_test (id INT)" ); + + DB::beginTransaction(); + + $wpdb->query( "INSERT INTO _tx_test VALUES (1)" ); + + // Nested transaction via savepoint. + DB::beginTransaction(); + $wpdb->query( "INSERT INTO _tx_test VALUES (2)" ); + DB::rollback(); // Rolls back savepoint only. + + $this->assertSame( 1, DB::transactionLevel() ); + + DB::commit(); // Commits outer transaction. + + // Row 1 should survive, row 2 should not. + $count = $wpdb->get_var( "SELECT COUNT(*) FROM _tx_test WHERE id = 1" ); + $this->assertEquals( 1, $count ); + + $count = $wpdb->get_var( "SELECT COUNT(*) FROM _tx_test WHERE id = 2" ); + $this->assertEquals( 0, $count ); + + $wpdb->query( "DROP TEMPORARY TABLE IF EXISTS _tx_test" ); + } + + public function test_it_should_rollback_entire_transaction_at_depth_zero(): void { + global $wpdb; + + $wpdb->query( "CREATE TEMPORARY TABLE _tx_test (id INT)" ); + + DB::beginTransaction(); + $wpdb->query( "INSERT INTO _tx_test VALUES (1)" ); + + DB::beginTransaction(); + $wpdb->query( "INSERT INTO _tx_test VALUES (2)" ); + DB::commit(); // Release savepoint. + + DB::rollback(); // Rollback outer — both rows gone. + + $count = $wpdb->get_var( "SELECT COUNT(*) FROM _tx_test" ); + $this->assertEquals( 0, $count ); + + $wpdb->query( "DROP TEMPORARY TABLE IF EXISTS _tx_test" ); + } + + public function test_transaction_helper_should_commit_on_success() { + global $wpdb; + + $wpdb->query( "CREATE TEMPORARY TABLE _tx_test (id INT)" ); + + DB::transaction( static function () use ( $wpdb ) { + $wpdb->query( "INSERT INTO _tx_test VALUES (1)" ); + } ); + + $count = $wpdb->get_var( "SELECT COUNT(*) FROM _tx_test" ); + $this->assertEquals( 1, $count ); + + $wpdb->query( "DROP TEMPORARY TABLE IF EXISTS _tx_test" ); + } + + public function test_transaction_helper_should_rollback_on_exception(): void { + global $wpdb; + + $wpdb->query( "CREATE TEMPORARY TABLE _tx_test (id INT)" ); + + try { + DB::transaction( static function () use ( $wpdb ) { + $wpdb->query( "INSERT INTO _tx_test VALUES (1)" ); + throw new \RuntimeException( 'fail' ); + } ); + } catch ( \RuntimeException $e ) { + // Expected. + } + + $count = $wpdb->get_var( "SELECT COUNT(*) FROM _tx_test" ); + $this->assertEquals( 0, $count ); + + $this->assertSame( 0, DB::transactionLevel() ); + + $wpdb->query( "DROP TEMPORARY TABLE IF EXISTS _tx_test" ); + } + + public function test_nested_transaction_helper_should_use_savepoints(): void { + global $wpdb; + + $wpdb->query( "CREATE TEMPORARY TABLE _tx_test (id INT)" ); + + DB::transaction( static function () use ( $wpdb ) { + $wpdb->query( "INSERT INTO _tx_test VALUES (1)" ); + + // Nested transaction — should use savepoint. + DB::transaction( static function () use ( $wpdb ) { + $wpdb->query( "INSERT INTO _tx_test VALUES (2)" ); + } ); + } ); + + $count = $wpdb->get_var( "SELECT COUNT(*) FROM _tx_test" ); + $this->assertEquals( 2, $count ); + + $this->assertSame( 0, DB::transactionLevel() ); + + $wpdb->query( "DROP TEMPORARY TABLE IF EXISTS _tx_test" ); + } + + public function test_transaction_level_starts_at_zero(): void { + $this->assertSame( 0, DB::transactionLevel() ); + } +}