Skip to content

Commit 2ef1571

Browse files
authored
feat: add scoped transaction options (#10244)
1 parent f605cd9 commit 2ef1571

6 files changed

Lines changed: 290 additions & 37 deletions

File tree

system/Database/BaseConnection.php

Lines changed: 60 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,66 +1089,91 @@ public function afterRollback(callable $callback): static
10891089
*
10901090
* @param callable(self): TReturn $callback
10911091
* @param positive-int $attempts
1092+
* @param bool|null $transException Temporarily override transaction exception mode.
1093+
* @param bool $resetTransStatus Reset transaction status before an outermost transaction starts.
10921094
*
10931095
* @return false|TReturn
10941096
*/
1095-
public function transaction(callable $callback, int $attempts = 1): mixed
1096-
{
1097+
public function transaction(
1098+
callable $callback,
1099+
int $attempts = 1,
1100+
?bool $transException = null,
1101+
bool $resetTransStatus = false,
1102+
): mixed {
10971103
if ($attempts < 1) {
10981104
throw new InvalidArgumentException('Transaction attempts must be a positive integer.');
10991105
}
11001106

1101-
if (! $this->transEnabled) {
1102-
return $callback($this);
1107+
$restoreTransException = $transException !== null;
1108+
$previousTransException = $this->transException;
1109+
1110+
if ($restoreTransException) {
1111+
$this->transException = $transException;
11031112
}
11041113

1105-
$attempts = $this->transDepth === 0 ? $attempts : 1;
1114+
try {
1115+
if (! $this->transEnabled) {
1116+
return $callback($this);
1117+
}
1118+
1119+
$outermostTransaction = $this->transDepth === 0;
11061120

1107-
for ($attempt = 1; $attempt <= $attempts; $attempt++) {
1108-
if (! $this->transBegin()) {
1109-
return false;
1121+
if ($resetTransStatus && $outermostTransaction) {
1122+
$this->resetTransStatus();
11101123
}
11111124

1112-
try {
1113-
$result = $callback($this);
1114-
} catch (Throwable $e) {
1125+
$attempts = $outermostTransaction ? $attempts : 1;
1126+
1127+
for ($attempt = 1; $attempt <= $attempts; $attempt++) {
1128+
if (! $this->transBegin()) {
1129+
return false;
1130+
}
1131+
11151132
try {
1116-
$this->transRollback();
1117-
} catch (Throwable $rollbackException) {
1118-
log_message('error', 'Database: Transaction callback threw an exception before rollback failed: ' . $e);
1119-
1120-
throw $rollbackException;
1121-
} finally {
1122-
if ($this->transDepth > 0) {
1123-
$this->transStatus = false;
1124-
} elseif ($this->transStrict === false) {
1125-
$this->transStatus = true;
1133+
$result = $callback($this);
1134+
} catch (Throwable $e) {
1135+
try {
1136+
$this->transRollback();
1137+
} catch (Throwable $rollbackException) {
1138+
log_message('error', 'Database: Transaction callback threw an exception before rollback failed: ' . $e);
1139+
1140+
throw $rollbackException;
1141+
} finally {
1142+
if ($this->transDepth > 0) {
1143+
$this->transStatus = false;
1144+
} elseif ($this->transStrict === false) {
1145+
$this->transStatus = true;
1146+
}
11261147
}
1127-
}
11281148

1129-
if ($this->transDepth === 0 && $e instanceof RetryableTransactionException && $attempt < $attempts) {
1130-
$this->prepareTransactionRetry();
1149+
if ($this->transDepth === 0 && $e instanceof RetryableTransactionException && $attempt < $attempts) {
1150+
$this->prepareTransactionRetry();
11311151

1132-
continue;
1152+
continue;
1153+
}
1154+
1155+
throw $e;
11331156
}
11341157

1135-
throw $e;
1136-
}
1158+
if (! $this->transComplete()) {
1159+
if ($this->transDepth === 0 && $this->transFailureException instanceof RetryableTransactionException && $attempt < $attempts) {
1160+
$this->prepareTransactionRetry();
11371161

1138-
if (! $this->transComplete()) {
1139-
if ($this->transDepth === 0 && $this->transFailureException instanceof RetryableTransactionException && $attempt < $attempts) {
1140-
$this->prepareTransactionRetry();
1162+
continue;
1163+
}
11411164

1142-
continue;
1165+
return false;
11431166
}
11441167

1145-
return false;
1168+
return $result;
11461169
}
11471170

1148-
return $result;
1171+
return false;
1172+
} finally {
1173+
if ($restoreTransException) {
1174+
$this->transException = $previousTransException;
1175+
}
11491176
}
1150-
1151-
return false;
11521177
}
11531178

11541179
/**

system/Database/ConnectionInterface.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,17 @@ public function afterRollback(callable $callback): static;
145145
*
146146
* @param callable(self): TReturn $callback
147147
* @param positive-int $attempts
148+
* @param bool|null $transException Temporarily override transaction exception mode.
149+
* @param bool $resetTransStatus Reset transaction status before an outermost transaction starts.
148150
*
149151
* @return false|TReturn
150152
*/
151-
public function transaction(callable $callback, int $attempts = 1): mixed;
153+
public function transaction(
154+
callable $callback,
155+
int $attempts = 1,
156+
?bool $transException = null,
157+
bool $resetTransStatus = false,
158+
): mixed;
152159

153160
/**
154161
* Returns an instance of the query builder for this connection.

tests/system/Database/Live/TransactionClosureTest.php

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace CodeIgniter\Database\Live;
1515

1616
use CodeIgniter\Database\BaseConnection;
17+
use CodeIgniter\Database\Exceptions\DatabaseException;
1718
use CodeIgniter\Database\Exceptions\RetryableTransactionException;
1819
use CodeIgniter\Test\CIUnitTestCase;
1920
use CodeIgniter\Test\DatabaseTestTrait;
@@ -102,6 +103,25 @@ public function testTransactionRetriesRetryableExceptionAndCommits(): void
102103
$this->seeInDatabase('job', ['name' => 'Retried Job 2']);
103104
}
104105

106+
public function testTransactionScopedTransExceptionRestoresAfterRetryAttempts(): void
107+
{
108+
$attempts = 0;
109+
110+
$result = $this->db->transaction(static function () use (&$attempts): string {
111+
$attempts++;
112+
113+
if ($attempts === 1) {
114+
throw new RetryableTransactionException('Deadlock found when trying to get lock.', 1213);
115+
}
116+
117+
return 'committed';
118+
}, attempts: 2, transException: true);
119+
120+
$this->assertSame('committed', $result);
121+
$this->assertSame(2, $attempts);
122+
$this->assertFalse($this->getPrivateProperty($this->db, 'transException'));
123+
}
124+
105125
public function testRollbackCallbacksRunForFailedRetryAttempts(): void
106126
{
107127
$attempts = 0;
@@ -243,6 +263,121 @@ public function testTransactionReturnsFalseAndRollsBackWhenTransactionStatusFail
243263
$this->enableDBDebug();
244264
}
245265

266+
public function testTransactionScopedTransExceptionRestoresAfterSuccess(): void
267+
{
268+
$result = $this->db->transaction(static function (BaseConnection $db): string {
269+
$db->table('job')->insert([
270+
'name' => 'Scoped Exception Mode Job',
271+
'description' => 'The transaction should commit.',
272+
]);
273+
274+
return 'committed';
275+
}, transException: true);
276+
277+
$this->assertSame('committed', $result);
278+
$this->assertFalse($this->getPrivateProperty($this->db, 'transException'));
279+
}
280+
281+
public function testTransactionScopedTransExceptionRestoresAfterQueryFailure(): void
282+
{
283+
try {
284+
$this->db->transaction(static function (BaseConnection $db): void {
285+
$db->table('job')->insert([
286+
'id' => 1,
287+
'name' => 'Duplicate Job',
288+
'description' => 'This should fail.',
289+
]);
290+
}, transException: true);
291+
$this->fail('Expected database exception.');
292+
} catch (DatabaseException) {
293+
// The scoped transaction exception mode should be restored after failure.
294+
}
295+
296+
$this->assertFalse($this->getPrivateProperty($this->db, 'transException'));
297+
}
298+
299+
public function testTransactionScopedTransExceptionCanTemporarilyDisableExistingMode(): void
300+
{
301+
$this->db->transException(true);
302+
303+
$result = $this->db->transaction(static function (BaseConnection $db): string {
304+
$db->table('job')->insert([
305+
'id' => 1,
306+
'name' => 'Duplicate Job',
307+
'description' => 'This should fail.',
308+
]);
309+
310+
return 'not returned';
311+
}, transException: false);
312+
313+
$this->assertFalse($result);
314+
$this->assertTrue($this->getPrivateProperty($this->db, 'transException'));
315+
}
316+
317+
public function testTransactionResetTransStatusRestartsAfterStrictModeFailure(): void
318+
{
319+
$this->disableDBDebug();
320+
321+
$failed = $this->db->transaction(static function (BaseConnection $db): string {
322+
$db->table('job')->insert([
323+
'id' => 1,
324+
'name' => 'Duplicate Job',
325+
'description' => 'This should fail.',
326+
]);
327+
328+
return 'not returned';
329+
});
330+
331+
$this->assertFalse($failed);
332+
$this->assertFalse($this->db->transStatus());
333+
334+
$result = $this->db->transaction(static function (BaseConnection $db): string {
335+
$db->table('job')->insert([
336+
'name' => 'Restarted Job',
337+
'description' => 'The transaction should commit.',
338+
]);
339+
340+
return 'committed';
341+
}, resetTransStatus: true);
342+
343+
$this->assertSame('committed', $result);
344+
$this->assertTrue($this->db->transStatus());
345+
$this->seeInDatabase('job', ['name' => 'Restarted Job']);
346+
347+
$this->enableDBDebug();
348+
}
349+
350+
public function testTransactionWithoutResetTransStatusPreservesStrictModeFailure(): void
351+
{
352+
$this->disableDBDebug();
353+
354+
$failed = $this->db->transaction(static function (BaseConnection $db): string {
355+
$db->table('job')->insert([
356+
'id' => 1,
357+
'name' => 'Duplicate Job',
358+
'description' => 'This should fail.',
359+
]);
360+
361+
return 'not returned';
362+
});
363+
364+
$this->assertFalse($failed);
365+
366+
$result = $this->db->transaction(static function (BaseConnection $db): string {
367+
$db->table('job')->insert([
368+
'name' => 'Still Rolled Back Job',
369+
'description' => 'The strict-mode failure should still apply.',
370+
]);
371+
372+
return 'not returned';
373+
});
374+
375+
$this->assertFalse($result);
376+
$this->dontSeeInDatabase('job', ['name' => 'Still Rolled Back Job']);
377+
378+
$this->enableDBDebug();
379+
}
380+
246381
public function testTransactionCallbackExceptionDoesNotPreventNonStrictStatusReset(): void
247382
{
248383
$this->disableDBDebug();
@@ -412,6 +547,52 @@ public function testNestedTransactionCallbackExceptionMarksOuterTransactionForRo
412547
$this->dontSeeInDatabase('job', ['name' => 'Inner Job']);
413548
}
414549

550+
public function testNestedTransactionResetTransStatusDoesNotClearOuterFailure(): void
551+
{
552+
$this->disableDBDebug();
553+
554+
$this->db->transStart();
555+
$this->db->table('job')->insert([
556+
'id' => 1,
557+
'name' => 'Duplicate Job',
558+
'description' => 'This should fail.',
559+
]);
560+
561+
$result = $this->db->transaction(static fn (): string => 'not returned', resetTransStatus: true);
562+
563+
$this->assertFalse($result);
564+
$this->assertFalse($this->db->transStatus());
565+
566+
$this->db->transComplete();
567+
568+
$this->enableDBDebug();
569+
}
570+
571+
public function testNestedTransactionScopedTransExceptionQueryFailureRollsBackOuterTransaction(): void
572+
{
573+
$this->db->transStart();
574+
$this->db->table('job')->insert([
575+
'name' => 'Outer Job',
576+
'description' => 'The outer transaction should roll back.',
577+
]);
578+
579+
try {
580+
$this->db->transaction(static function (BaseConnection $db): void {
581+
$db->table('job')->insert([
582+
'id' => 1,
583+
'name' => 'Duplicate Job',
584+
'description' => 'This should fail.',
585+
]);
586+
}, transException: true);
587+
$this->fail('Expected database exception.');
588+
} catch (DatabaseException) {
589+
// Existing transaction exception handling rolls back the full stack.
590+
}
591+
592+
$this->assertSame(0, $this->db->transDepth);
593+
$this->dontSeeInDatabase('job', ['name' => 'Outer Job']);
594+
}
595+
415596
public function testNestedTransactionRetryAttemptsRunOnce(): void
416597
{
417598
$attempts = 0;

user_guide_src/source/changelogs/v4.8.0.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ Database
212212
- Added ``inTransaction()`` to database connections to check whether the connection is inside an active CodeIgniter-managed transaction. See :ref:`transactions-checking-transaction-state`.
213213
- Prepared query execution failures now throw or store typed database exceptions such as ``UniqueConstraintViolationException`` and ``RetryableTransactionException`` when applicable, matching normal query failures.
214214
- Added ``RetryableTransactionException`` for driver-specific retryable transaction failures such as deadlocks and serialization failures. See :ref:`transactions-retryable-exceptions`.
215-
- Added the ``transaction()`` method to database connections to run a callback inside a transaction, with optional retry attempts for retryable transaction failures. See :ref:`transactions-closure`.
215+
- Added the ``transaction()`` method to database connections to run a callback inside a transaction, with optional retry attempts for retryable transaction failures and scoped ``transException`` and ``resetTransStatus`` options. See :ref:`transactions-closure`.
216216
- 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.
217217

218218
Query Builder

user_guide_src/source/database/transactions.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,35 @@ Callbacks registered with ``afterCommit()`` or ``afterRollback()`` inside the
8282
transaction callback follow the same rules as other transaction callbacks: they
8383
run only after the outermost transaction commits or rolls back.
8484

85+
Scoped Transaction Options
86+
--------------------------
87+
88+
The ``transaction()`` method can temporarily override existing transaction
89+
options for the duration of the helper call:
90+
91+
.. literalinclude:: transactions/017.php
92+
93+
Set ``transException`` to temporarily enable or disable the same transaction
94+
exception mode configured by ``transException()``. The previous mode is restored
95+
after ``transaction()`` finishes, even if the callback throws an exception.
96+
If ``transaction()`` is called inside an active transaction, the temporary mode
97+
applies while the nested callback runs, then the previous mode is restored for
98+
the outer transaction. When ``transException`` is set to ``true`` in a nested
99+
transaction and a query fails, CodeIgniter's existing transaction exception
100+
handling rolls back the outer transaction as well.
101+
102+
Set ``resetTransStatus`` to reset the transaction status before the helper starts
103+
an outermost transaction. This is equivalent to calling ``resetTransStatus()``
104+
before the transaction, and is useful when strict mode has marked the connection
105+
as failed from an earlier transaction.
106+
107+
``resetTransStatus`` only applies when ``transaction()`` starts the outermost
108+
transaction. If ``transaction()`` is called inside an active transaction, it does
109+
not reset the outer transaction status.
110+
111+
The ``transException`` option follows CodeIgniter's existing transaction
112+
exception behavior, including the current ``DBDebug`` setting.
113+
85114
Retrying Transactions
86115
---------------------
87116

0 commit comments

Comments
 (0)