Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 4 additions & 15 deletions .github/workflows/tests-php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,21 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v2
uses: actions/checkout@v6
with:
fetch-depth: 1000
submodules: recursive
# ------------------------------------------------------------------------------
# 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
# ------------------------------------------------------------------------------
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 60 additions & 6 deletions src/DB/DB.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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
*
Expand Down Expand Up @@ -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 );
}
}

/**
Expand Down
5 changes: 5 additions & 0 deletions tests/_support/Helper/DBTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
168 changes: 168 additions & 0 deletions tests/wpunit/TransactionTest.php
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This filename is odd. I think transaction is a single word 😄

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call

Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<?php declare( strict_types=1 );

namespace StellarWP\DB;

use StellarWP\DB\Tests\DBTestCase;

/**
* @backupStaticAttributes
*/
final class TransactionTest extends DBTestCase {

protected function tearDown() {
parent::tearDown();

// Reset transaction depth in case a test leaves it dirty.
while ( DB::transactionLevel() > 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() );
}
}