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
1 change: 1 addition & 0 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
ClassPropertyAssignToConstructorPromotionRector::class => [
__DIR__ . '/system/Database/BaseResult.php',
__DIR__ . '/system/Database/RawSql.php',
__DIR__ . '/system/Database/Exceptions/DatabaseException.php',
Copy link
Member

Choose a reason for hiding this comment

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

Is this related to the $databaseCode property? If yes, why not proceed to promote it as it is new?

__DIR__ . '/system/Debug/BaseExceptionHandler.php',
__DIR__ . '/system/Filters/Filters.php',
__DIR__ . '/system/HTTP/CURLRequest.php',
Expand Down
30 changes: 23 additions & 7 deletions system/Database/BaseConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
*
Expand Down
25 changes: 25 additions & 0 deletions system/Database/Exceptions/DatabaseException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions system/Database/Exceptions/UniqueConstraintViolationException.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 UniqueConstraintViolationException extends DatabaseException
{
}
10 changes: 9 additions & 1 deletion system/Database/MySQLi/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
54 changes: 40 additions & 14 deletions system/Database/OCI8/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
66 changes: 62 additions & 4 deletions system/Database/Postgre/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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

Expand All @@ -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;
Expand Down Expand Up @@ -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
*/
Expand Down
Loading
Loading