From c4fa06c861fa0ce599cce20009a6c61cf598d7d1 Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 20 Feb 2026 22:03:57 +0100 Subject: [PATCH] feat: add UniqueConstraintViolationException and getLastException() fix rector add docs fix sqlite3 fix SQLSRV fix Postgre update changelog fix Postgre test refactor postgre update Postgre handling fix test --- rector.php | 1 + system/Database/BaseConnection.php | 30 +++- .../Database/Exceptions/DatabaseException.php | 25 +++ .../UniqueConstraintViolationException.php | 18 ++ system/Database/MySQLi/Connection.php | 10 +- system/Database/OCI8/Connection.php | 54 ++++-- system/Database/Postgre/Connection.php | 156 +++++++++++++++++- system/Database/SQLSRV/Connection.php | 36 +++- system/Database/SQLite3/Connection.php | 19 ++- .../system/Database/DatabaseExceptionTest.php | 49 ++++++ .../Live/ExecuteLogMessageFormatTest.php | 6 +- .../Live/UniqueConstraintViolationTest.php | 85 ++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 4 + user_guide_src/source/database/queries.rst | 56 ++++++- .../source/database/queries/030.php | 12 ++ .../source/database/queries/031.php | 9 + user_guide_src/source/general/errors.rst | 12 ++ user_guide_src/source/general/errors/019.php | 9 + 18 files changed, 549 insertions(+), 42 deletions(-) create mode 100644 system/Database/Exceptions/UniqueConstraintViolationException.php create mode 100644 tests/system/Database/DatabaseExceptionTest.php create mode 100644 tests/system/Database/Live/UniqueConstraintViolationTest.php create mode 100644 user_guide_src/source/database/queries/030.php create mode 100644 user_guide_src/source/database/queries/031.php create mode 100644 user_guide_src/source/general/errors/019.php diff --git a/rector.php b/rector.php index 277256ad851f..a9848766eefa 100644 --- a/rector.php +++ b/rector.php @@ -129,6 +129,7 @@ ClassPropertyAssignToConstructorPromotionRector::class => [ __DIR__ . '/system/Database/BaseResult.php', __DIR__ . '/system/Database/RawSql.php', + __DIR__ . '/system/Database/Exceptions/DatabaseException.php', __DIR__ . '/system/Debug/BaseExceptionHandler.php', __DIR__ . '/system/Debug/Exceptions.php', __DIR__ . '/system/Filters/Filters.php', diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index ca1acd1d6be7..b7b94a2ae40e 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -219,6 +219,14 @@ abstract class BaseConnection implements ConnectionInterface */ protected $lastQuery; + /** + * The exception that would have been thrown on the last failed query + * if DBDebug were enabled. Null when the last query succeeded or when + * DBDebug is true (in which case the exception is thrown directly and + * this property is never set). + */ + protected ?DatabaseException $lastException = null; + /** * Connection ID * @@ -698,8 +706,9 @@ public function query(string $sql, $binds = null, bool $setEscapeFlags = true, s // Run the query for real try { - $exception = null; - $this->resultID = $this->simpleQuery($query->getQuery()); + $exception = null; + $this->lastException = null; + $this->resultID = $this->simpleQuery($query->getQuery()); } catch (DatabaseException $exception) { $this->resultID = false; } @@ -737,11 +746,7 @@ public function query(string $sql, $binds = null, bool $setEscapeFlags = true, s Events::trigger('DBQuery', $query); if ($exception instanceof DatabaseException) { - throw new DatabaseException( - $exception->getMessage(), - $exception->getCode(), - $exception, - ); + throw $exception; } return false; @@ -1847,6 +1852,17 @@ public function isWriteType($sql): bool */ abstract public function error(): array; + /** + * Returns the exception that would have been thrown on the last failed + * query if DBDebug were enabled. Returns null if the last query succeeded + * or if DBDebug is true (in which case the exception is always thrown + * directly and this method will always return null). + */ + public function getLastException(): ?DatabaseException + { + return $this->lastException; + } + /** * Insert ID * diff --git a/system/Database/Exceptions/DatabaseException.php b/system/Database/Exceptions/DatabaseException.php index 77b170e0b407..48e335095910 100644 --- a/system/Database/Exceptions/DatabaseException.php +++ b/system/Database/Exceptions/DatabaseException.php @@ -15,9 +15,34 @@ use CodeIgniter\Exceptions\HasExitCodeInterface; use CodeIgniter\Exceptions\RuntimeException; +use Throwable; class DatabaseException extends RuntimeException implements ExceptionInterface, HasExitCodeInterface { + /** + * Native code returned by the database driver. + */ + protected int|string $databaseCode = 0; + + /** + * @param int|string $code Native database code (e.g. 1062, 23505, 23000/2601) + */ + public function __construct(string $message = '', int|string $code = 0, ?Throwable $previous = null) + { + $this->databaseCode = $code; + + // Keep Throwable::getCode() behavior BC-friendly for non-int DB codes. + parent::__construct($message, is_int($code) ? $code : 0, $previous); + } + + /** + * Returns the native code from the database driver. + */ + public function getDatabaseCode(): int|string + { + return $this->databaseCode; + } + public function getExitCode(): int { return EXIT_DATABASE; diff --git a/system/Database/Exceptions/UniqueConstraintViolationException.php b/system/Database/Exceptions/UniqueConstraintViolationException.php new file mode 100644 index 000000000000..148a0e0bc9bf --- /dev/null +++ b/system/Database/Exceptions/UniqueConstraintViolationException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Exceptions; + +class UniqueConstraintViolationException extends DatabaseException +{ +} diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 2339469dfc71..3dbc29e4d442 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Database\TableName; use CodeIgniter\Exceptions\LogicException; use mysqli; @@ -317,9 +318,16 @@ protected function execute(string $sql) 'trace' => render_backtrace($e->getTrace()), ]); + // 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); + if ($this->DBDebug) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + throw $exception; } + + $this->lastException = $exception; } return false; diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php index bbed63526336..2e28ddca501f 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Database\Query; use CodeIgniter\Database\TableName; use ErrorException; @@ -236,10 +237,27 @@ protected function execute(string $sql) oci_set_prefetch($this->stmtId, 1000); - $result = oci_execute($this->stmtId, $this->commitMode) ? $this->stmtId : false; + $result = oci_execute($this->stmtId, $this->commitMode) ? $this->stmtId : false; + + if ($result === false) { + // ORA-00001: unique constraint violated + $error = $this->error(); + $exception = $error['code'] === 1 + ? new UniqueConstraintViolationException((string) $error['message'], $error['code']) + : new DatabaseException((string) $error['message'], $error['code']); + + if ($this->DBDebug) { + throw $exception; + } + + $this->lastException = $exception; + + return false; + } + $insertTableName = $this->parseInsertTableName($sql); - if ($result && $insertTableName !== '') { + if ($insertTableName !== '') { $this->lastInsertedTableName = $insertTableName; } @@ -254,9 +272,17 @@ protected function execute(string $sql) 'trace' => render_backtrace($trace), ]); + // ORA-00001: unique constraint violated + $error = $this->error(); + $exception = $error['code'] === 1 + ? new UniqueConstraintViolationException((string) $error['message'], $error['code'], $e) + : new DatabaseException((string) $error['message'], $error['code'], $e); + if ($this->DBDebug) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + throw $exception; } + + $this->lastException = $exception; } return false; @@ -615,25 +641,25 @@ protected function bindParams($params) */ public function error(): array { - // oci_error() returns an array that already contains - // 'code' and 'message' keys, but it can return false - // if there was no error .... - $error = oci_error(); + // oci_error() is resource-specific: check each resource in priority order + // and return the first one that actually has an error. This ensures that + // e.g. oci_parse() failures (error on connID) are found even when stmtId + // holds a stale valid resource from the previous successful query. $resources = [$this->cursorId, $this->stmtId, $this->connID]; foreach ($resources as $resource) { if (is_resource($resource)) { $error = oci_error($resource); - break; + + if (is_array($error)) { + return $error; + } } } - return is_array($error) - ? $error - : [ - 'code' => '', - 'message' => '', - ]; + $error = oci_error(); + + return is_array($error) ? $error : ['code' => '', 'message' => '']; } public function insertID(): int diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index 11b48c474ee5..e2e796e209c6 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Database\RawSql; use CodeIgniter\Database\TableName; use ErrorException; @@ -56,6 +57,12 @@ class Connection extends BaseConnection protected $sslmode; protected $service; + /** + * Last failed query result, used by error() to extract the SQLSTATE + * via pg_result_error_field() without string parsing. + */ + private ?PgSqlResult $lastFailedResult = null; + /** * Connect to the database. * @@ -202,8 +209,10 @@ public function getVersion(): string */ protected function execute(string $sql) { + $this->lastFailedResult = null; + try { - return pg_query($this->connID, $sql); + $sent = pg_send_query($this->connID, $sql); } catch (ErrorException $e) { $trace = array_slice($e->getTrace(), 2); // remove the call to error handler @@ -214,11 +223,96 @@ protected function execute(string $sql) 'trace' => render_backtrace($trace), ]); + $exception = new DatabaseException($e->getMessage(), 0, $e); + if ($this->DBDebug) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + throw $exception; } + + $this->lastException = $exception; + + return false; + } + + if ($sent === false) { + return $this->handleConnectionError(); + } + + $result = pg_get_result($this->connID); + + if ($result === false) { + return $this->handleConnectionError(); + } + + // Drain all results; return the last one (pg_query() semantics) or the first error. + $lastResult = $result; + $failedResult = pg_result_status($result) === PGSQL_FATAL_ERROR ? $result : null; + + while (($next = pg_get_result($this->connID)) !== false) { + $lastResult = $next; + + if (! $failedResult instanceof PgSqlResult && pg_result_status($next) === PGSQL_FATAL_ERROR) { + $failedResult = $next; + } + } + + if ($failedResult instanceof PgSqlResult) { + $sqlstate = (string) pg_result_error_field($failedResult, PGSQL_DIAG_SQLSTATE); + $message = (string) pg_result_error($failedResult); + $trace = debug_backtrace(); + $first = array_shift($trace); + + $this->lastFailedResult = $failedResult; + + // Log only the first line; pg_result_error() appends "LINE N: ..." context. + log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [ + 'message' => explode("\n", $message)[0], + 'exFile' => clean_path($first['file']), + 'exLine' => $first['line'], + 'trace' => render_backtrace($trace), + ]); + + $exception = $sqlstate === '23505' + ? new UniqueConstraintViolationException($message, $sqlstate) + : new DatabaseException($message, $sqlstate); + + if ($this->DBDebug) { + throw $exception; + } + + $this->lastException = $exception; + + return false; + } + + return $lastResult; + } + + /** + * Logs a connection-level error from pg_last_error(), stores or throws a + * DatabaseException, and returns false. + */ + private function handleConnectionError(): false + { + $message = pg_last_error($this->connID); + $trace = debug_backtrace(); + $first = array_shift($trace); + + log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [ + 'message' => $message, + 'exFile' => clean_path($first['file']), + 'exLine' => $first['line'], + 'trace' => render_backtrace($trace), + ]); + + $exception = new DatabaseException($message); + + if ($this->DBDebug) { + throw $exception; } + $this->lastException = $exception; + return false; } @@ -479,9 +573,19 @@ protected function _enableForeignKeyChecks() */ public function error(): array { + if ($this->lastFailedResult instanceof PgSqlResult) { + return [ + 'code' => (string) pg_result_error_field($this->lastFailedResult, PGSQL_DIAG_SQLSTATE), + 'message' => (string) pg_result_error($this->lastFailedResult), + ]; + } + + // Fallback for connection-level errors: no SQLSTATE outside a result resource. + $message = pg_last_error($this->connID); + return [ - 'code' => '', - 'message' => pg_last_error($this->connID), + 'code' => $message !== '' ? '08006' : 0, + 'message' => $message, ]; } @@ -584,12 +688,50 @@ protected function setClientEncoding(string $charset): bool return pg_set_client_encoding($this->connID, $charset) === 0; } + /** + * Executes a transaction control command (BEGIN, COMMIT, ROLLBACK). + * + * Captures the result resource so SQLSTATE is available via error(), + * resets $lastFailedResult to prevent stale state, and wraps any + * ErrorException into a DatabaseException for consistent error semantics. + */ + private function executeTransactionCommand(string $sql): bool + { + $this->lastFailedResult = null; + + try { + $result = pg_query($this->connID, $sql); + } catch (ErrorException $e) { + $this->lastException = new DatabaseException($e->getMessage(), 0, $e); + + return false; + } + + if ($result === false) { + // Connection-level failure: no result resource, SQLSTATE unavailable. + // error() will fall back to pg_last_error() with code '08006'. + return false; + } + + if (pg_result_status($result) === PGSQL_FATAL_ERROR) { + $this->lastFailedResult = $result; + + $sqlstate = (string) pg_result_error_field($result, PGSQL_DIAG_SQLSTATE); + $message = (string) pg_result_error($result); + $this->lastException = new DatabaseException($message, $sqlstate); + + return false; + } + + return true; + } + /** * Begin Transaction */ protected function _transBegin(): bool { - return (bool) pg_query($this->connID, 'BEGIN'); + return $this->executeTransactionCommand('BEGIN'); } /** @@ -597,7 +739,7 @@ protected function _transBegin(): bool */ protected function _transCommit(): bool { - return (bool) pg_query($this->connID, 'COMMIT'); + return $this->executeTransactionCommand('COMMIT'); } /** @@ -605,6 +747,6 @@ protected function _transCommit(): bool */ protected function _transRollback(): bool { - return (bool) pg_query($this->connID, 'ROLLBACK'); + return $this->executeTransactionCommand('ROLLBACK'); } } diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 76dd80bdb09f..98b9d77b09c4 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Database\TableName; use stdClass; @@ -519,24 +520,51 @@ protected function execute(string $sql) : sqlsrv_query($this->connID, $sql, [], ['Scrollable' => $this->scrollable]); if ($stmt === false) { - $trace = debug_backtrace(); - $first = array_shift($trace); + $trace = debug_backtrace(); + $first = array_shift($trace); + $message = $this->getAllErrorMessages(); log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [ - 'message' => $this->getAllErrorMessages(), + 'message' => $message, 'exFile' => clean_path($first['file']), 'exLine' => $first['line'], 'trace' => render_backtrace($trace), ]); + $error = $this->error(); + $exception = $this->isUniqueConstraintViolation() + ? new UniqueConstraintViolationException($message, $error['code']) + : new DatabaseException($message, $error['code']); + if ($this->DBDebug) { - throw new DatabaseException($this->getAllErrorMessages()); + throw $exception; } + + $this->lastException = $exception; } return $stmt; } + private function isUniqueConstraintViolation(): bool + { + $errors = sqlsrv_errors(SQLSRV_ERR_ERRORS); + if (! is_array($errors)) { + return false; + } + + foreach ($errors as $error) { + // SQLSTATE 23000 (integrity constraint violation) with SQL Server error + // 2627 (UNIQUE CONSTRAINT or PRIMARY KEY violation) or 2601 (UNIQUE INDEX violation). + if (($error['SQLSTATE'] ?? '') === '23000' + && in_array($error['code'] ?? 0, [2627, 2601], true)) { + return true; + } + } + + return false; + } + /** * Returns the last error encountered by this connection. * diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index 9f015b8e9cb6..c338a97ca7d3 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Database\TableName; use CodeIgniter\Exceptions\InvalidArgumentException; use Exception; @@ -170,14 +171,30 @@ protected function execute(string $sql) 'trace' => render_backtrace($e->getTrace()), ]); + $error = $this->error(); + $exception = $this->isUniqueConstraintViolation($e->getMessage()) + ? new UniqueConstraintViolationException($e->getMessage(), $error['code'], $e) + : new DatabaseException($e->getMessage(), $error['code'], $e); + if ($this->DBDebug) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + throw $exception; } + + $this->lastException = $exception; } return false; } + private function isUniqueConstraintViolation(string $message): bool + { + // SQLite3 reports unique violations in two formats depending on version: + // Modern: "UNIQUE constraint failed: table.column" + // Legacy: "column X is not unique" + return str_contains($message, 'UNIQUE constraint failed') + || str_contains($message, 'is not unique'); + } + /** * Returns the total number of rows affected by this query. */ diff --git a/tests/system/Database/DatabaseExceptionTest.php b/tests/system/Database/DatabaseExceptionTest.php new file mode 100644 index 000000000000..43dfca1b92e8 --- /dev/null +++ b/tests/system/Database/DatabaseExceptionTest.php @@ -0,0 +1,49 @@ + + * + * 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\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class DatabaseExceptionTest extends CIUnitTestCase +{ + public function testIntCodeIsAvailableViaGetCodeAndGetDatabaseCode(): void + { + $exception = new DatabaseException('error', 1062); + + $this->assertSame(1062, $exception->getCode()); + $this->assertSame(1062, $exception->getDatabaseCode()); + } + + public function testStringCodeIsAvailableViaGetDatabaseCodeWithoutAffectingGetCode(): void + { + $exception = new DatabaseException('error', '23505'); + + $this->assertSame(0, $exception->getCode()); + $this->assertSame('23505', $exception->getDatabaseCode()); + } + + public function testStringCodeWithSlashIsAvailableViaGetDatabaseCode(): void + { + $exception = new DatabaseException('error', '23000/2601'); + + $this->assertSame(0, $exception->getCode()); + $this->assertSame('23000/2601', $exception->getDatabaseCode()); + } +} diff --git a/tests/system/Database/Live/ExecuteLogMessageFormatTest.php b/tests/system/Database/Live/ExecuteLogMessageFormatTest.php index ca79e4f46fcc..45014bbfcdd2 100644 --- a/tests/system/Database/Live/ExecuteLogMessageFormatTest.php +++ b/tests/system/Database/Live/ExecuteLogMessageFormatTest.php @@ -48,7 +48,7 @@ public function testLogMessageWhenExecuteFailsShowFullStructuredBacktrace(): voi $pattern = match ($db->DBDriver) { 'MySQLi' => '/Table \'test\.some_table\' doesn\'t exist/', - 'Postgre' => '/pg_query\(\): Query failed: ERROR: relation "some_table" does not exist/', + 'Postgre' => '/ERROR: relation "some_table" does not exist/', 'SQLite3' => '/Unable to prepare statement:\s(\d+,\s)?no such table: some_table/', 'OCI8' => '/oci_execute\(\): ORA-00942: table or view does not exist/', 'SQLSRV' => '/\[Microsoft\]\[ODBC Driver \d+ for SQL Server\]\[SQL Server\]Invalid object name \'some_table\'/', @@ -58,9 +58,7 @@ public function testLogMessageWhenExecuteFailsShowFullStructuredBacktrace(): voi $this->assertMatchesRegularExpression($pattern, array_shift($messageFromLogs)); - if ($db->DBDriver === 'Postgre') { - $messageFromLogs = array_slice($messageFromLogs, 2); - } elseif ($db->DBDriver === 'OCI8') { + if ($db->DBDriver === 'OCI8') { $messageFromLogs = array_slice($messageFromLogs, 1); } diff --git a/tests/system/Database/Live/UniqueConstraintViolationTest.php b/tests/system/Database/Live/UniqueConstraintViolationTest.php new file mode 100644 index 000000000000..0a436f079b88 --- /dev/null +++ b/tests/system/Database/Live/UniqueConstraintViolationTest.php @@ -0,0 +1,85 @@ + + * + * 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\UniqueConstraintViolationException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class UniqueConstraintViolationTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $refresh = true; + protected $seed = CITestSeeder::class; + + protected function tearDown(): void + { + $this->enableDBDebug(); + + parent::tearDown(); + } + + public function testThrowsUniqueConstraintViolationExceptionWithDebugEnabled(): void + { + $this->enableDBDebug(); + + $this->expectException(UniqueConstraintViolationException::class); + + // 'derek@world.com' is already seeded in the user table + $this->db->table('user')->insert([ + 'name' => 'Duplicate', + 'email' => 'derek@world.com', + 'country' => 'US', + ]); + } + + public function testReturnsFalseAndErrorIsPopulatedWithDebugDisabled(): void + { + $this->disableDBDebug(); + + // 'derek@world.com' is already seeded in the user table + $result = $this->db->table('user')->insert([ + 'name' => 'Duplicate', + 'email' => 'derek@world.com', + 'country' => 'US', + ]); + + $this->assertFalse($result); + + $error = $this->db->error(); + + $expectedCode = match ($this->db->DBDriver) { + 'MySQLi' => 1062, + 'Postgre' => '23505', + 'SQLite3' => 19, + 'SQLSRV' => '23000/2627', + 'OCI8' => 1, + default => $this->fail('No expected error code defined for DB driver: ' . $this->db->DBDriver), + }; + + $this->assertSame($expectedCode, $error['code']); + $this->assertNotEmpty($error['message']); + + $exception = $this->db->getLastException(); + $this->assertInstanceOf(UniqueConstraintViolationException::class, $exception); + $this->assertSame($expectedCode, $exception->getDatabaseCode()); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index ee6b70d4fb68..c85d0065ca66 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -24,6 +24,7 @@ Behavior Changes ================ - **Commands:** The ``filter:check`` command now requires the HTTP method argument to be uppercase (e.g., ``spark filter:check GET /`` instead of ``spark filter:check get /``). +- **Database:** The Postgre driver's ``$db->error()['code']`` previously always returned ``''``. It now returns the 5-character SQLSTATE string for query and transaction failures (e.g., ``'42P01'``), or ``'08006'`` for connection-level failures. Code that relied on ``$db->error()['code'] === ''`` will need updating. - **Filters:** HTTP method matching for method-based filters is now case-sensitive. The keys in ``Config\Filters::$methods`` must exactly match the request method (e.g., ``GET``, ``POST``). Lowercase method names (e.g., ``post``) will no longer match. @@ -139,6 +140,9 @@ Others ------ - Added new ``timezone`` option to connection array in ``Config\Database`` config. This ensures consistent timestamps between model operations and database functions like ``NOW()``. Supported drivers: **MySQLi**, **Postgre**, and **OCI8**. See :ref:`database-config-timezone` for details. +- Added :php:class:`UniqueConstraintViolationException ` which extends ``DatabaseException`` and is thrown on duplicate key (unique constraint) violations across all database drivers. See :ref:`database-unique-constraint-violation`. +- Added ``$db->getLastException()`` which returns the typed exception even when ``DBDebug`` is ``false``. See :ref:`database-get-last-exception`. +- Added ``DatabaseException::getDatabaseCode()`` returning the native driver error code as ``int|string``; ``getCode()`` is constrained to ``int`` by PHP's ``Throwable`` interface and cannot carry string SQLSTATE codes. Model ===== diff --git a/user_guide_src/source/database/queries.rst b/user_guide_src/source/database/queries.rst index 4a07b4ba8295..8109399ee25c 100644 --- a/user_guide_src/source/database/queries.rst +++ b/user_guide_src/source/database/queries.rst @@ -203,15 +203,63 @@ placeholders in the query: Handling Errors *************** +.. note:: It is strongly recommended to keep ``DBDebug`` set to ``true`` + (the default). This ensures all query failures surface immediately as + exceptions, preventing silent data corruption. Setting it to ``false`` + is considered legacy behaviour and may be deprecated in a future version. + +DBDebug Enabled (Recommended) +============================= + +When :ref:`DBDebug ` is ``true`` +(the default), any query failure throws a ``DatabaseException`` or one of +its subclasses, which you can catch and handle: + +.. literalinclude:: queries/030.php + +.. _database-unique-constraint-violation: + +UniqueConstraintViolationException +---------------------------------- + +.. versionadded:: 4.8.0 + +``UniqueConstraintViolationException`` extends ``DatabaseException`` and is +thrown specifically when a query fails due to a duplicate key or unique +constraint violation. Catching it separately allows you to handle this case +without inspecting raw driver-specific error codes. + +DBDebug Disabled +================ + +When ``DBDebug`` is ``false``, query failures return ``false`` instead of +throwing. Two methods are available to inspect what went wrong. + $db->error() -============ +------------ -If you need to get the last error that has occurred, the ``error()`` method -will return an array containing its code and message. Here's a quick -example: +The ``error()`` method returns an array with ``code`` and ``message`` keys +describing the last error. Error codes are driver-specific (an **int** for +MySQLi, SQLite3, and OCI8; a SQLSTATE **string** for Postgre and SQLSRV): .. literalinclude:: queries/015.php +.. _database-get-last-exception: + +$db->getLastException() +----------------------- + +.. versionadded:: 4.8.0 + +``getLastException()`` returns the typed exception that would have been +thrown had ``DBDebug`` been ``true``. This is the recommended way to +distinguish between failure types (e.g., a unique constraint violation vs. +another database error) without enabling ``DBDebug``: + +.. literalinclude:: queries/031.php + +.. note:: ``getLastException()`` is reset to ``null`` at the start of every + query. Inspect it immediately after the failed operation. **************** Prepared Queries diff --git a/user_guide_src/source/database/queries/030.php b/user_guide_src/source/database/queries/030.php new file mode 100644 index 000000000000..a4fea00892f0 --- /dev/null +++ b/user_guide_src/source/database/queries/030.php @@ -0,0 +1,12 @@ +table('users')->insert(['email' => 'duplicate@example.com']); +} catch (UniqueConstraintViolationException $e) { + // Duplicate key — handle gracefully +} catch (DatabaseException $e) { + // Other database error +} diff --git a/user_guide_src/source/database/queries/031.php b/user_guide_src/source/database/queries/031.php new file mode 100644 index 000000000000..4dafb880cd5f --- /dev/null +++ b/user_guide_src/source/database/queries/031.php @@ -0,0 +1,9 @@ +table('users')->insert(['email' => 'duplicate@example.com']); + +if (! $inserted && $db->getLastException() instanceof UniqueConstraintViolationException) { + // Handle duplicate key violation +} diff --git a/user_guide_src/source/general/errors.rst b/user_guide_src/source/general/errors.rst index 2a79f5a33425..46bc3773f531 100644 --- a/user_guide_src/source/general/errors.rst +++ b/user_guide_src/source/general/errors.rst @@ -204,6 +204,18 @@ or when it is temporarily lost: This provides an exit code of 8. +UniqueConstraintViolationException +---------------------------------- + +.. versionadded:: 4.8.0 + +``UniqueConstraintViolationException`` extends ``DatabaseException`` and is thrown when a query +fails due to a duplicate key or unique constraint violation. It is supported by all database drivers. + +.. literalinclude:: errors/019.php + +See :ref:`database-unique-constraint-violation` for full usage details. + RedirectException ----------------- diff --git a/user_guide_src/source/general/errors/019.php b/user_guide_src/source/general/errors/019.php new file mode 100644 index 000000000000..d30592009bfe --- /dev/null +++ b/user_guide_src/source/general/errors/019.php @@ -0,0 +1,9 @@ +table('users')->insert(['email' => 'duplicate@example.com']); +} catch (UniqueConstraintViolationException $e) { + // Handle duplicate key violation +}