diff --git a/rector.php b/rector.php index 29755dc2647a..ca19dcebc12b 100644 --- a/rector.php +++ b/rector.php @@ -144,6 +144,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/Filters/Filters.php', __DIR__ . '/system/HTTP/CURLRequest.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..5dbdbbf30d64 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; @@ -88,6 +89,12 @@ public function connect(bool $persistent = false) throw new DatabaseException($error); } + // Use verbose errors so that pg_last_error() always includes the + // 5-character SQLSTATE code, enabling reliable error classification + // (e.g. '23505' for unique constraint violations) without needing + // to parse ambiguous English-language message strings. + pg_set_error_verbosity($this->connID, PGSQL_ERRORS_VERBOSE); + if (! empty($this->schema)) { $this->simpleQuery("SET search_path TO {$this->schema},public"); } @@ -203,7 +210,27 @@ public function getVersion(): string protected function execute(string $sql) { try { - return pg_query($this->connID, $sql); + $result = pg_query($this->connID, $sql); + + if ($result === false) { + $error = $this->error(); + + log_message('error', (string) $error['message']); + + $exception = $error['code'] === '23505' + ? new UniqueConstraintViolationException((string) $error['message'], $error['code']) + : new DatabaseException((string) $error['message'], $error['code']); + + if ($this->DBDebug) { + throw $exception; + } + + $this->lastException = $exception; + + return false; + } + + return $result; } catch (ErrorException $e) { $trace = array_slice($e->getTrace(), 2); // remove the call to error handler @@ -214,9 +241,19 @@ protected function execute(string $sql) 'trace' => render_backtrace($trace), ]); + // pg_last_error() is still populated after an ErrorException, + // so we can use error() here to get the reliable SQLSTATE code. + $error = $this->error(); + + $exception = $error['code'] === '23505' + ? 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; @@ -479,12 +516,33 @@ protected function _enableForeignKeyChecks() */ public function error(): array { + // pg_set_error_verbosity(PGSQL_ERRORS_VERBOSE) is set during connect(), + // so pg_last_error() includes the SQLSTATE code in the format: + // "ERROR: 23505: duplicate key value violates unique constraint ..." + $message = pg_last_error($this->connID); + return [ - 'code' => '', - 'message' => pg_last_error($this->connID), + 'code' => $this->extractSqlState($message), + 'message' => $message, ]; } + /** + * Extracts the 5-character SQLSTATE code from a PostgreSQL error message. + * + * With pg_set_error_verbosity(PGSQL_ERRORS_VERBOSE) set in connect(), + * pg_last_error() always returns the verbose format: + * "ERROR: 23505: duplicate key value violates unique constraint ..." + */ + private function extractSqlState(string $message): string + { + if (preg_match('/\bERROR:\s*([0-9A-Z]{5})\s*:/', $message, $match) === 1) { + return $match[1]; + } + + return ''; + } + /** * @return int|string */ 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 3865669a9e2d..4f2faa0c82eb 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..7e12c1f48c50 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' => '/pg_query\(\): Query failed: ERROR: 42P01: 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\'/', @@ -59,7 +59,7 @@ public function testLogMessageWhenExecuteFailsShowFullStructuredBacktrace(): voi $this->assertMatchesRegularExpression($pattern, array_shift($messageFromLogs)); if ($db->DBDriver === 'Postgre') { - $messageFromLogs = array_slice($messageFromLogs, 2); + $messageFromLogs = array_slice($messageFromLogs, 3); } elseif ($db->DBDriver === 'OCI8') { $messageFromLogs = array_slice($messageFromLogs, 1); } diff --git a/tests/system/Database/Live/Postgre/ConnectTest.php b/tests/system/Database/Live/Postgre/ConnectTest.php index d616a60b968c..b15944187e98 100644 --- a/tests/system/Database/Live/Postgre/ConnectTest.php +++ b/tests/system/Database/Live/Postgre/ConnectTest.php @@ -40,7 +40,7 @@ public function testShowErrorMessageWhenSettingInvalidCharset(): void $this->expectException(DatabaseException::class); $this->expectExceptionMessage( 'Unable to connect to the database. -Main connection [Postgre]: ERROR: invalid value for parameter "client_encoding": "utf8mb4"', +Main connection [Postgre]: ERROR: 22023: invalid value for parameter "client_encoding": "utf8mb4"', ); $config = config('Database'); 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 95e306063380..2f8b7071a56f 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 now sets ``PGSQL_ERRORS_VERBOSE`` on connect. Error messages returned by ``$db->error()['message']``, thrown exceptions, and log entries now include the 5-character SQLSTATE code (e.g., ``ERROR: 42P01: relation "x" does not exist`` instead of ``ERROR: relation "x" does not exist``). Code that matches against exact Postgre error message strings may need updating. Interface Changes ================= @@ -123,6 +124,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 +}