Skip to content
Open
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
24 changes: 24 additions & 0 deletions system/Database/BaseConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

use Closure;
use CodeIgniter\Database\Exceptions\DatabaseException;
use CodeIgniter\Database\Exceptions\RetryableTransactionException;
use CodeIgniter\Events\Events;
use CodeIgniter\I18n\Time;
use Exception;
Expand Down Expand Up @@ -2138,6 +2139,29 @@ public function getLastException(): ?DatabaseException
return $this->lastException;
}

/**
* Checks whether the native database code represents a retryable transaction failure.
*/
protected function isRetryableTransactionErrorCode(int|string $code): bool
{
return false;
}

/**
* Creates the appropriate database exception for a native database error.
*/
protected function createDatabaseException(
string $message,
int|string $code = 0,
?Throwable $previous = null,
): DatabaseException {
if ($this->isRetryableTransactionErrorCode($code)) {
return new RetryableTransactionException($message, $code, $previous);
}

return new DatabaseException($message, $code, $previous);
}

/**
* Insert ID
*
Expand Down
18 changes: 18 additions & 0 deletions system/Database/Exceptions/RetryableTransactionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\Database\Exceptions;

class RetryableTransactionException extends DatabaseException
{
}
11 changes: 10 additions & 1 deletion system/Database/MySQLi/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ class Connection extends BaseConnection
*/
protected bool $strictOn = false;

/**
* Checks whether the native database code represents a retryable transaction failure.
*/
protected function isRetryableTransactionErrorCode(int|string $code): bool
{
// ER_LOCK_DEADLOCK: InnoDB rolls back the full transaction.
return $code === 1213;
}

/**
* Connect to the database.
*
Expand Down Expand Up @@ -320,7 +329,7 @@ protected function execute(string $sql)
// MySQL error 1062: ER_DUP_ENTRY – duplicate key value
$exception = $e->getCode() === 1062
? new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e)
: new DatabaseException($e->getMessage(), $e->getCode(), $e);
: $this->createDatabaseException($e->getMessage(), $e->getCode(), $e);
Comment on lines 330 to +332
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.

One thing I think can be improved is, let $this->createDatabaseException handle all types of exception including UniqueConstraintViolation. Similar to isRetryableTransactionErrorCode, there could be a protected function like isUniqueConstraintViolation(int|string $code): bool and this function will be used by createDatabaseException function and it returns UniqueConstraintViolationException for that particular error code. In this way, we can avoid this ternary operator in all connection implementations, and in future, if we need to add more refined exceptions, we can do so just in BaseConnection without messy ternary operators

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yes, definitely a good idea.


if ($this->DBDebug) {
throw $exception;
Expand Down
12 changes: 10 additions & 2 deletions system/Database/OCI8/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ class Connection extends BaseConnection
*/
public $lastInsertedTableName;

/**
* Checks whether the native database code represents a retryable transaction failure.
*/
protected function isRetryableTransactionErrorCode(int|string $code): bool
{
return in_array($code, [60, 8177], true);
}

/**
* confirm DSN format.
*/
Expand Down Expand Up @@ -244,7 +252,7 @@ protected function execute(string $sql)
$error = $this->error();
$exception = $error['code'] === 1
? new UniqueConstraintViolationException((string) $error['message'], $error['code'])
: new DatabaseException((string) $error['message'], $error['code']);
: $this->createDatabaseException((string) $error['message'], $error['code']);

if ($this->DBDebug) {
throw $exception;
Expand Down Expand Up @@ -276,7 +284,7 @@ protected function execute(string $sql)
$error = $this->error();
$exception = $error['code'] === 1
? new UniqueConstraintViolationException((string) $error['message'], $error['code'], $e)
: new DatabaseException((string) $error['message'], $error['code'], $e);
: $this->createDatabaseException((string) $error['message'], $error['code'], $e);

if ($this->DBDebug) {
throw $exception;
Expand Down
10 changes: 9 additions & 1 deletion system/Database/Postgre/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ class Connection extends BaseConnection
*/
private ?PgSqlResult $lastFailedResult = null;

/**
* Checks whether the native database code represents a retryable transaction failure.
*/
protected function isRetryableTransactionErrorCode(int|string $code): bool
{
return in_array($code, ['40001', '40P01'], true);
}

/**
* Connect to the database.
*
Expand Down Expand Up @@ -274,7 +282,7 @@ protected function execute(string $sql)

$exception = $sqlstate === '23505'
? new UniqueConstraintViolationException($message, $sqlstate)
: new DatabaseException($message, $sqlstate);
: $this->createDatabaseException($message, $sqlstate);

if ($this->DBDebug) {
throw $exception;
Expand Down
18 changes: 17 additions & 1 deletion system/Database/SQLSRV/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,22 @@ class Connection extends BaseConnection
*/
protected $_reserved_identifiers = ['*'];

/**
* Checks whether the native database code represents a retryable transaction failure.
*/
protected function isRetryableTransactionErrorCode(int|string $code): bool
{
$vendorCode = (string) (is_string($code) && str_contains($code, '/')
? substr($code, strrpos($code, '/') + 1)
: $code);

if (preg_match('/^\d+$/', $vendorCode) !== 1) {
return false;
}

return in_array((int) $vendorCode, [1205, 3960], true);
}

/**
* Class constructor
*/
Expand Down Expand Up @@ -540,7 +556,7 @@ protected function execute(string $sql)
$error = $this->error();
$exception = $this->isUniqueConstraintViolation()
? new UniqueConstraintViolationException($message, $error['code'])
: new DatabaseException($message, $error['code']);
: $this->createDatabaseException($message, $error['code']);

if ($this->DBDebug) {
throw $exception;
Expand Down
10 changes: 9 additions & 1 deletion system/Database/SQLite3/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ class Connection extends BaseConnection
*/
protected ?int $synchronous = null;

/**
* Checks whether the native database code represents a retryable transaction failure.
*/
protected function isRetryableTransactionErrorCode(int|string $code): bool
{
return $code === 5;
}

/**
* @return void
*/
Expand Down Expand Up @@ -174,7 +182,7 @@ protected function execute(string $sql)
$error = $this->error();
$exception = $this->isUniqueConstraintViolation($e->getMessage())
? new UniqueConstraintViolationException($e->getMessage(), $error['code'], $e)
: new DatabaseException($e->getMessage(), $error['code'], $e);
: $this->createDatabaseException($e->getMessage(), $error['code'], $e);

if ($this->DBDebug) {
throw $exception;
Expand Down
170 changes: 170 additions & 0 deletions tests/system/Database/RetryableTransactionExceptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\Database;

use CodeIgniter\Database\Exceptions\DatabaseException;
use CodeIgniter\Database\Exceptions\RetryableTransactionException;
use CodeIgniter\Database\MySQLi\Connection as MySQLiConnection;
use CodeIgniter\Database\OCI8\Connection as OCI8Connection;
use CodeIgniter\Database\Postgre\Connection as PostgreConnection;
use CodeIgniter\Database\SQLite3\Connection as SQLite3Connection;
use CodeIgniter\Database\SQLSRV\Connection as SQLSRVConnection;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\Mock\MockConnection;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use ReflectionMethod;

/**
* @internal
*/
#[Group('Others')]
final class RetryableTransactionExceptionTest extends CIUnitTestCase
{
#[DataProvider('provideCreatesRetryableTransactionExceptions')]
public function testCreatesRetryableTransactionExceptions(BaseConnection $db, int|string $code): void
{
$exception = self::createDatabaseException($db, 'Retryable transaction failure.', $code);

$this->assertInstanceOf(RetryableTransactionException::class, $exception);
$this->assertSame($code, $exception->getDatabaseCode());
}

/**
* @return iterable<string, array{BaseConnection, int|string}>
*/
public static function provideCreatesRetryableTransactionExceptions(): iterable
{
yield 'MySQLi deadlock' => [self::connection(MySQLiConnection::class, 'MySQLi'), 1213];

yield 'Postgre serialization failure' => [self::connection(PostgreConnection::class, 'Postgre'), '40001'];

yield 'Postgre deadlock' => [self::connection(PostgreConnection::class, 'Postgre'), '40P01'];

yield 'SQLite busy' => [self::connection(SQLite3Connection::class, 'SQLite3'), 5];

yield 'SQLSRV deadlock' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '40001/1205'];

yield 'SQLSRV vendor deadlock' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), 1205];

yield 'SQLSRV snapshot isolation conflict' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), 'HY000/3960'];

if (defined('OCI_COMMIT_ON_SUCCESS')) {
yield 'OCI8 deadlock' => [self::connection(OCI8Connection::class, 'OCI8'), 60];

yield 'OCI8 serialization failure' => [self::connection(OCI8Connection::class, 'OCI8'), 8177];
}
}

#[DataProvider('provideCreatesBaseDatabaseExceptionsForNonRetryableErrors')]
public function testCreatesBaseDatabaseExceptionsForNonRetryableErrors(BaseConnection $db, int|string $code): void
{
$exception = self::createDatabaseException($db, 'Non-retryable transaction failure.', $code);

$this->assertNotInstanceOf(RetryableTransactionException::class, $exception);
}

/**
* @return iterable<string, array{BaseConnection, int|string}>
*/
public static function provideCreatesBaseDatabaseExceptionsForNonRetryableErrors(): iterable
{
yield 'Base connection default' => [self::connection(MockConnection::class, 'MockDriver'), 1213];

yield 'MySQLi lock wait timeout' => [self::connection(MySQLiConnection::class, 'MySQLi'), 1205];

yield 'MySQLi duplicate key' => [self::connection(MySQLiConnection::class, 'MySQLi'), 1062];

yield 'Postgre unique violation' => [self::connection(PostgreConnection::class, 'Postgre'), '23505'];

yield 'Postgre exclusion violation' => [self::connection(PostgreConnection::class, 'Postgre'), '23P01'];

yield 'SQLite locked' => [self::connection(SQLite3Connection::class, 'SQLite3'), 6];

yield 'SQLite busy snapshot extended code' => [self::connection(SQLite3Connection::class, 'SQLite3'), 517];

yield 'SQLite constraint' => [self::connection(SQLite3Connection::class, 'SQLite3'), 19];

yield 'SQLSRV lock timeout' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), 'HYT00/1222'];

yield 'SQLSRV SQLSTATE without vendor code' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '40001'];

yield 'SQLSRV unique constraint' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '23000/2627'];

yield 'SQLSRV unique index' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '23000/2601'];

if (defined('OCI_COMMIT_ON_SUCCESS')) {
yield 'OCI8 resource busy' => [self::connection(OCI8Connection::class, 'OCI8'), 54];

yield 'OCI8 unique constraint' => [self::connection(OCI8Connection::class, 'OCI8'), 1];
}
}

public function testQueryThrowsRetryableTransactionExceptionFromDriverExecutionPath(): void
{
$db = $this->getMockBuilder(MySQLiConnection::class)
->setConstructorArgs([self::config('MySQLi')])
->onlyMethods(['connect', 'execute'])
->getMock();

$db->method('connect')->willReturn(mysqli_init());
$db->method('execute')->willThrowException(
self::createDatabaseException($db, 'Deadlock found when trying to get lock.', 1213),
);

$this->expectException(RetryableTransactionException::class);

$db->query('SELECT * FROM test');
}

/**
* @param class-string<BaseConnection> $connectionClass
*/
private static function connection(string $connectionClass, string $driver): BaseConnection
{
return new $connectionClass(self::config($driver));
}

/**
* @return array<string, mixed>
*/
private static function config(string $driver): array
{
return [
'DSN' => '',
'hostname' => 'localhost',
'username' => '',
'password' => '',
'database' => 'test',
'DBDriver' => $driver,
'DBDebug' => true,
'charset' => 'utf8',
'DBCollat' => 'utf8_general_ci',
'swapPre' => '',
'encrypt' => false,
'compress' => false,
'failover' => [],
];
}

private static function createDatabaseException(
BaseConnection $db,
string $message,
int|string $code,
): DatabaseException {
$method = new ReflectionMethod($db, 'createDatabaseException');

return $method->invoke($db, $message, $code);
}
}
5 changes: 3 additions & 2 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +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()``, ``afterRollback()``, ``inTransaction()``, and ``transaction()`` methods.
- **Database:** ``CodeIgniter\Database\ConnectionInterface`` now requires the ``afterCommit()``, ``afterRollback()``, ``inTransaction()`` and ``transaction()`` 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``.

Expand Down Expand Up @@ -201,8 +201,9 @@ 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 the ``transaction()`` method to database connections to run a callback inside a transaction. See :ref:`transactions-closure`.
- Added ``inTransaction()`` to database connections to check whether the connection is inside an active CodeIgniter-managed transaction. See :ref:`transactions-checking-transaction-state`.
- Added ``RetryableTransactionException`` for driver-specific retryable transaction failures such as deadlocks and serialization failures. See :ref:`transactions-retryable-exceptions`.
- Added the ``transaction()`` method to database connections to run a callback inside a transaction. See :ref:`transactions-closure`.
- 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
Expand Down
Loading
Loading