diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 0d167c2b92cc..b856017cd858 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -358,6 +358,20 @@ abstract class BaseConnection implements ConnectionInterface */ protected bool $transException = false; + /** + * Callbacks to run after the outermost transaction commits. + * + * @var list + */ + protected array $transCommitCallbacks = []; + + /** + * Callbacks to run after the outermost transaction rolls back. + * + * @var list + */ + protected array $transRollbackCallbacks = []; + /** * Array of table aliases. * @@ -985,13 +999,15 @@ public function transComplete(): bool // The query() function will set this flag to FALSE in the event that a query failed if ($this->transStatus === false || $this->transFailure === true) { - $this->transRollback(); - - // If we are NOT running in strict mode, we will reset - // the _trans_status flag so that subsequent groups of - // transactions will be permitted. - if ($this->transStrict === false) { - $this->transStatus = true; + try { + $this->transRollback(); + } finally { + // If we are NOT running in strict mode, we will reset + // the _trans_status flag so that subsequent groups of + // transactions will be permitted. + if ($this->transStrict === false) { + $this->transStatus = true; + } } return false; @@ -1008,6 +1024,48 @@ public function transStatus(): bool return $this->transStatus; } + /** + * Register a callback to run after the outermost transaction commits. + * + * If no transaction is active, the callback runs immediately. + * + * @param callable(): void $callback + * + * @return $this + */ + public function afterCommit(callable $callback): static + { + if ($this->transDepth === 0) { + $callback(); + + return $this; + } + + $this->transCommitCallbacks[] = $callback; + + return $this; + } + + /** + * Register a callback to run after the outermost transaction rolls back. + * + * If no transaction is active, the callback is not run. + * + * @param callable(): void $callback + * + * @return $this + */ + public function afterRollback(callable $callback): static + { + if ($this->transDepth === 0) { + return $this; + } + + $this->transRollbackCallbacks[] = $callback; + + return $this; + } + /** * Begin Transaction */ @@ -1055,6 +1113,11 @@ public function transCommit(): bool if ($this->transDepth > 1 || $this->_transCommit()) { $this->transDepth--; + if ($this->transDepth === 0) { + $this->transRollbackCallbacks = []; + $this->runTransCommitCallbacks(); + } + return true; } @@ -1074,6 +1137,11 @@ public function transRollback(): bool if ($this->transDepth > 1 || $this->_transRollback()) { $this->transDepth--; + if ($this->transDepth === 0) { + $this->transCommitCallbacks = []; + $this->runTransRollbackCallbacks(); + } + return true; } @@ -1102,6 +1170,32 @@ public function handleTransStatus(): void } } + /** + * Run and clear callbacks registered for a successful transaction commit. + */ + protected function runTransCommitCallbacks(): void + { + $callbacks = $this->transCommitCallbacks; + $this->transCommitCallbacks = []; + + foreach ($callbacks as $callback) { + $callback(); + } + } + + /** + * Run and clear callbacks registered for a transaction rollback. + */ + protected function runTransRollbackCallbacks(): void + { + $callbacks = $this->transRollbackCallbacks; + $this->transRollbackCallbacks = []; + + foreach ($callbacks as $callback) { + $callback(); + } + } + /** * Begin Transaction */ diff --git a/system/Database/ConnectionInterface.php b/system/Database/ConnectionInterface.php index 3c43b173fc4e..f5233d79e864 100644 --- a/system/Database/ConnectionInterface.php +++ b/system/Database/ConnectionInterface.php @@ -115,6 +115,24 @@ public function query(string $sql, $binds = null); */ public function simpleQuery(string $sql); + /** + * Register a callback to run after the outermost transaction commits. + * + * @param callable(): void $callback + * + * @return $this + */ + public function afterCommit(callable $callback): static; + + /** + * Register a callback to run after the outermost transaction rolls back. + * + * @param callable(): void $callback + * + * @return $this + */ + public function afterRollback(callable $callback): static; + /** * Returns an instance of the query builder for this connection. * diff --git a/tests/system/Database/Live/TransactionCallbacksTest.php b/tests/system/Database/Live/TransactionCallbacksTest.php new file mode 100644 index 000000000000..a295d4c86b74 --- /dev/null +++ b/tests/system/Database/Live/TransactionCallbacksTest.php @@ -0,0 +1,295 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live; + +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use Config\Database; +use PHPUnit\Framework\Attributes\Group; +use RuntimeException; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @internal + * + * @no-final + */ +#[Group('DatabaseLive')] +class TransactionCallbacksTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $refresh = true; + protected $seed = CITestSeeder::class; + + protected function setUp(): void + { + // Reset connection instance. + $this->db = Database::connect($this->DBGroup, false); + + parent::setUp(); + } + + public function testAfterCommitRunsImmediatelyWhenNoTransactionIsActive(): void + { + $callbacks = []; + + $result = $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'ran'; + }); + + $this->assertSame($this->db, $result); + $this->assertSame(['ran'], $callbacks); + } + + public function testAfterCommitRunsAfterSuccessfulTransactionCommit(): void + { + $callbacks = []; + + $this->db->transStart(); + $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'committed'; + }); + + $this->assertSame([], $callbacks); + + $this->db->transComplete(); + + $this->assertSame(['committed'], $callbacks); + } + + public function testAfterCommitRunsAfterManualTransactionCommit(): void + { + $callbacks = []; + + $this->db->transBegin(); + $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'committed'; + }); + + $this->assertSame([], $callbacks); + + $this->db->transCommit(); + + $this->assertSame(['committed'], $callbacks); + } + + public function testAfterCommitDoesNotRunAfterTransactionRollsBack(): void + { + $callbacks = []; + + $this->db->transStart(true); + $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'committed'; + }); + + $this->db->transComplete(); + + $this->assertSame([], $callbacks); + } + + public function testAfterCommitRunsAfterOutermostTransactionCommit(): void + { + $callbacks = []; + + $this->db->transStart(); + $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'outer'; + }); + + $this->db->transStart(); + $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'inner'; + }); + $this->db->transComplete(); + + $this->assertSame([], $callbacks); + + $this->db->transComplete(); + + $this->assertSame(['outer', 'inner'], $callbacks); + } + + public function testAfterCommitCallbackExceptionBubblesAfterTransactionCommit(): void + { + $builder = $this->db->table('job'); + + $this->db->transStart(); + $builder->insert([ + 'name' => 'Committed Job', + 'description' => 'The transaction should still commit.', + ]); + $this->db->afterCommit(static function (): void { + throw new RuntimeException('Commit callback failed.'); + }); + + try { + $this->db->transComplete(); + $this->fail('Expected commit callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Commit callback failed.', $e->getMessage()); + } + + $this->seeInDatabase('job', ['name' => 'Committed Job']); + } + + public function testAfterRollbackDoesNotRunWhenNoTransactionIsActive(): void + { + $callbacks = []; + + $result = $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + + $this->assertSame($this->db, $result); + $this->assertSame([], $callbacks); + } + + public function testAfterRollbackRunsAfterTransactionRollsBack(): void + { + $callbacks = []; + + $this->db->transStart(true); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + + $this->assertSame([], $callbacks); + + $this->db->transComplete(); + + $this->assertSame(['rolled back'], $callbacks); + } + + public function testAfterRollbackRunsAfterManualTransactionRollback(): void + { + $callbacks = []; + + $this->db->transBegin(); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + + $this->assertSame([], $callbacks); + + $this->db->transRollback(); + + $this->assertSame(['rolled back'], $callbacks); + } + + public function testAfterRollbackDoesNotRunAfterSuccessfulTransactionCommit(): void + { + $callbacks = []; + + $this->db->transStart(); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + + $this->db->transComplete(); + + $this->assertSame([], $callbacks); + } + + public function testAfterRollbackRunsAfterOutermostTransactionRollsBack(): void + { + $callbacks = []; + + $this->db->transStart(); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'outer'; + }); + + $this->db->transStart(); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'inner'; + }); + $this->db->transComplete(); + + $this->assertSame([], $callbacks); + + $this->db->transRollback(); + + $this->assertSame(['outer', 'inner'], $callbacks); + } + + public function testAfterRollbackCallbackExceptionBubblesAfterTransactionRollback(): void + { + $builder = $this->db->table('job'); + + $this->db->transStart(true); + $builder->insert([ + 'name' => 'Rolled Back Job', + 'description' => 'The transaction should still roll back.', + ]); + $this->db->afterRollback(static function (): void { + throw new RuntimeException('Rollback callback failed.'); + }); + + try { + $this->db->transComplete(); + $this->fail('Expected rollback callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Rollback callback failed.', $e->getMessage()); + } + + $this->dontSeeInDatabase('job', ['name' => 'Rolled Back Job']); + } + + public function testAfterRollbackCallbackExceptionDoesNotPreventNonStrictStatusReset(): void + { + $this->db->transStrict(false); + $this->db->transStart(true); + $this->db->afterRollback(static function (): void { + throw new RuntimeException('Rollback callback failed.'); + }); + + try { + $this->db->transComplete(); + $this->fail('Expected rollback callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Rollback callback failed.', $e->getMessage()); + } + + $this->assertTrue($this->db->transStatus()); + } + + public function testAfterRollbackRunsAfterAutomaticRollbackOnQueryFailure(): void + { + $callbacks = []; + $builder = $this->db->transException(true)->table('job'); + + try { + $this->db->transStart(); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + $builder->insert([ + 'name' => 'Rolled Back Job', + 'description' => 'The transaction should roll back.', + ]); + $builder->insert([ + 'id' => 1, + 'name' => 'Duplicate Job', + 'description' => 'This should fail.', + ]); + } catch (DatabaseException) { + // The framework already rolled back while handling the query failure. + } + + $this->assertSame(['rolled back'], $callbacks); + $this->dontSeeInDatabase('job', ['name' => 'Rolled Back Job']); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index af6ddecb3e94..145632c9bec9 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -43,6 +43,7 @@ Interface Changes **NOTE:** If you've implemented your own classes that implement these interfaces from scratch, you will need to update your implementations to include the new methods or method changes to ensure compatibility. +- **Database:** ``CodeIgniter\Database\ConnectionInterface`` now requires the ``afterCommit()`` and ``afterRollback()`` methods. - **Logging:** ``CodeIgniter\Log\Handlers\HandlerInterface::handle()`` now requires a third parameter ``array $context = []``. Any custom log handler that overrides ``handle()`` - whether implementing ``HandlerInterface`` directly or extending a built-in handler class - must add the parameter to its ``handle()`` method signature. - **Security:** The ``SecurityInterface``'s ``verify()`` method now has a native return type of ``static``. @@ -195,6 +196,7 @@ Testing Database ======== +- Added ``afterCommit()`` and ``afterRollback()`` transaction callbacks to database connections. These callbacks run after the outermost transaction commits or rolls back. See :ref:`transactions-transaction-callbacks`. - Added ``trustServerCertificate`` option to ``SQLSRV`` database connections in ``Config\Database``. Set it to ``true`` to trust the server certificate without CA validation when using encrypted connections. Query Builder diff --git a/user_guide_src/source/database/transactions.rst b/user_guide_src/source/database/transactions.rst index 5c48f2806240..77a188d1edd3 100644 --- a/user_guide_src/source/database/transactions.rst +++ b/user_guide_src/source/database/transactions.rst @@ -111,6 +111,54 @@ If you want an exception to be thrown when a query error occurs, you can use If a query error occurs, all the queries will be rolled backed, and a ``DatabaseException`` will be thrown. +.. _transactions-transaction-callbacks: + +Running Code after Commit or Rollback +===================================== + +.. versionadded:: 4.8.0 + +You may register callbacks to run only after the outermost transaction has +successfully committed by using the ``afterCommit()`` method, or after the +outermost transaction has rolled back by using the ``afterRollback()`` method: + +.. literalinclude:: transactions/010.php + +Callbacks registered during an active transaction are delayed until the +outermost transaction commits or rolls back. + +If the transaction commits, ``afterCommit()`` callbacks run and +``afterRollback()`` callbacks are discarded. If the transaction rolls back, +``afterRollback()`` callbacks run and ``afterCommit()`` callbacks are discarded. +If no transaction is active, ``afterCommit()`` callbacks run immediately, while +``afterRollback()`` callbacks are not run. + +For example: + +.. literalinclude:: transactions/011.php + +.. note:: When ``afterCommit()`` is called outside an active transaction, it runs + immediately. This includes calls from Model ``beforeInsert`` or + ``beforeUpdate`` events when the calling code has not already started a + transaction, so the callback may run before the Model's insert or update + query is executed. + +This is useful for side effects that should only happen after committed data is +visible, such as dispatching a queued job or sending a notification, and for +cleanup that should only happen after a real rollback. + +Callbacks run after the database transaction has already committed or rolled +back. If a callback throws an exception, that exception bubbles to the caller, +but the transaction outcome is not changed. + +.. warning:: When multiple callbacks are registered for the same transaction + outcome, they run in registration order. If one callback throws an exception, + the subsequent callbacks are not run. + +Rollback callbacks also run when CodeIgniter automatically rolls back an active +transaction while handling a transaction failure or cleaning up an unfinished +transaction. + Disabling Transactions ====================== diff --git a/user_guide_src/source/database/transactions/010.php b/user_guide_src/source/database/transactions/010.php new file mode 100644 index 000000000000..84f0ae2959b6 --- /dev/null +++ b/user_guide_src/source/database/transactions/010.php @@ -0,0 +1,14 @@ +db->transStart(); +$this->db->query('AN SQL QUERY...'); + +$this->db->afterCommit(static function (): void { + // Dispatch a queued job or run another side effect after commit. +}); + +$this->db->afterRollback(static function (): void { + // Run cleanup that should only happen after rollback. +}); + +$this->db->transComplete(); diff --git a/user_guide_src/source/database/transactions/011.php b/user_guide_src/source/database/transactions/011.php new file mode 100644 index 000000000000..972911b79372 --- /dev/null +++ b/user_guide_src/source/database/transactions/011.php @@ -0,0 +1,5 @@ +db->afterCommit(static function (): void { + // Runs immediately because there is no active transaction. +});