diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index a5d61290bd37..0ec8fb714d5e 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -15,7 +15,11 @@ use BackedEnum; use Closure; +use CodeIgniter\Database\Exceptions\CheckConstraintViolationException; +use CodeIgniter\Database\Exceptions\ConstraintViolationException; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\ForeignKeyConstraintViolationException; +use CodeIgniter\Database\Exceptions\NotNullConstraintViolationException; use CodeIgniter\Database\Exceptions\RetryableTransactionException; use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Events\Events; @@ -2231,6 +2235,38 @@ protected function isUniqueConstraintViolation(int|string $code, string $message return false; } + /** + * Checks whether the native database error represents a foreign key constraint violation. + */ + protected function isForeignKeyConstraintViolation(int|string $code, string $message): bool + { + return false; + } + + /** + * Checks whether the native database error represents a NOT NULL constraint violation. + */ + protected function isNotNullConstraintViolation(int|string $code, string $message): bool + { + return false; + } + + /** + * Checks whether the native database error represents a CHECK constraint violation. + */ + protected function isCheckConstraintViolation(int|string $code, string $message): bool + { + return false; + } + + /** + * Checks whether the native database error represents a constraint violation. + */ + protected function isConstraintViolation(int|string $code, string $message): bool + { + return false; + } + /** * Checks whether the native database code represents a retryable transaction failure. */ @@ -2253,6 +2289,22 @@ public function createDatabaseException( return new UniqueConstraintViolationException($message, $code, $previous); } + if ($this->isForeignKeyConstraintViolation($code, $message)) { + return new ForeignKeyConstraintViolationException($message, $code, $previous); + } + + if ($this->isNotNullConstraintViolation($code, $message)) { + return new NotNullConstraintViolationException($message, $code, $previous); + } + + if ($this->isCheckConstraintViolation($code, $message)) { + return new CheckConstraintViolationException($message, $code, $previous); + } + + if ($this->isConstraintViolation($code, $message)) { + return new ConstraintViolationException($message, $code, $previous); + } + if ($this->isRetryableTransactionErrorCode($code)) { return new RetryableTransactionException($message, $code, $previous); } diff --git a/system/Database/Exceptions/CheckConstraintViolationException.php b/system/Database/Exceptions/CheckConstraintViolationException.php new file mode 100644 index 000000000000..5fa41625b452 --- /dev/null +++ b/system/Database/Exceptions/CheckConstraintViolationException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Exceptions; + +/** + * Thrown when a CHECK constraint is violated. + */ +class CheckConstraintViolationException extends ConstraintViolationException +{ +} diff --git a/system/Database/Exceptions/ConstraintViolationException.php b/system/Database/Exceptions/ConstraintViolationException.php new file mode 100644 index 000000000000..7accc786936e --- /dev/null +++ b/system/Database/Exceptions/ConstraintViolationException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Exceptions; + +/** + * Thrown when a database integrity constraint is violated. + */ +class ConstraintViolationException extends DatabaseException +{ +} diff --git a/system/Database/Exceptions/ForeignKeyConstraintViolationException.php b/system/Database/Exceptions/ForeignKeyConstraintViolationException.php new file mode 100644 index 000000000000..e057c44f8b44 --- /dev/null +++ b/system/Database/Exceptions/ForeignKeyConstraintViolationException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Exceptions; + +/** + * Thrown when a foreign key constraint is violated. + */ +class ForeignKeyConstraintViolationException extends ConstraintViolationException +{ +} diff --git a/system/Database/Exceptions/NotNullConstraintViolationException.php b/system/Database/Exceptions/NotNullConstraintViolationException.php new file mode 100644 index 000000000000..7d2c2c0c71e5 --- /dev/null +++ b/system/Database/Exceptions/NotNullConstraintViolationException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Exceptions; + +/** + * Thrown when a NOT NULL constraint is violated. + */ +class NotNullConstraintViolationException extends ConstraintViolationException +{ +} diff --git a/system/Database/Exceptions/UniqueConstraintViolationException.php b/system/Database/Exceptions/UniqueConstraintViolationException.php index 148a0e0bc9bf..cc1c5ca09a47 100644 --- a/system/Database/Exceptions/UniqueConstraintViolationException.php +++ b/system/Database/Exceptions/UniqueConstraintViolationException.php @@ -13,6 +13,9 @@ namespace CodeIgniter\Database\Exceptions; -class UniqueConstraintViolationException extends DatabaseException +/** + * Thrown when a unique constraint is violated. + */ +class UniqueConstraintViolationException extends ConstraintViolationException { } diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index d317dc163a58..41c47c0650dd 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -106,6 +106,37 @@ protected function isUniqueConstraintViolation(int|string $code, string $message return $code === 1062; } + /** + * Checks whether the native database error represents a foreign key constraint violation. + */ + protected function isForeignKeyConstraintViolation(int|string $code, string $message): bool + { + // ER_NO_REFERENCED_ROW, ER_ROW_IS_REFERENCED, ER_ROW_IS_REFERENCED_2, ER_NO_REFERENCED_ROW_2. + return in_array($code, [1216, 1217, 1451, 1452], true); + } + + /** + * Checks whether the native database error represents a NOT NULL constraint violation. + */ + protected function isNotNullConstraintViolation(int|string $code, string $message): bool + { + // ER_BAD_NULL_ERROR, ER_BAD_NULL_ERROR_NOT_IGNORED: column cannot be null. + return in_array($code, [1048, 3673], true); + } + + /** + * Checks whether the native database error represents a CHECK constraint violation. + */ + protected function isCheckConstraintViolation(int|string $code, string $message): bool + { + if ($code === 3819) { + return true; + } + + // MariaDB reports CHECK failures as ER_CONSTRAINT_FAILED, while MySQL uses 4025 for other errors. + return $code === 4025 && str_contains(strtolower($this->getVersion()), 'mariadb'); + } + /** * Checks whether the native database code represents a retryable transaction failure. */ diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php index 1fcbf9e74824..d4debc2df63c 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -123,6 +123,33 @@ protected function isUniqueConstraintViolation(int|string $code, string $message return (int) $code === 1; } + /** + * Checks whether the native database error represents a foreign key constraint violation. + */ + protected function isForeignKeyConstraintViolation(int|string $code, string $message): bool + { + // ORA-02291: parent key not found; ORA-02292: child record found. + return in_array((int) $code, [2291, 2292], true); + } + + /** + * Checks whether the native database error represents a NOT NULL constraint violation. + */ + protected function isNotNullConstraintViolation(int|string $code, string $message): bool + { + // ORA-01400: cannot insert NULL; ORA-01407: cannot update to NULL. + return in_array((int) $code, [1400, 1407], true); + } + + /** + * Checks whether the native database error represents a CHECK constraint violation. + */ + protected function isCheckConstraintViolation(int|string $code, string $message): bool + { + // ORA-02290: check constraint violated. + return (int) $code === 2290; + } + /** * Checks whether the native database code represents a retryable transaction failure. */ diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index 62416a16067a..479668bbb77c 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -71,6 +71,38 @@ protected function isUniqueConstraintViolation(int|string $code, string $message return $code === '23505'; } + /** + * Checks whether the native database error represents a foreign key constraint violation. + */ + protected function isForeignKeyConstraintViolation(int|string $code, string $message): bool + { + return $code === '23503'; + } + + /** + * Checks whether the native database error represents a NOT NULL constraint violation. + */ + protected function isNotNullConstraintViolation(int|string $code, string $message): bool + { + return $code === '23502'; + } + + /** + * Checks whether the native database error represents a CHECK constraint violation. + */ + protected function isCheckConstraintViolation(int|string $code, string $message): bool + { + return $code === '23514'; + } + + /** + * Checks whether the native database error represents a constraint violation. + */ + protected function isConstraintViolation(int|string $code, string $message): bool + { + return is_string($code) && str_starts_with($code, '23'); + } + /** * Checks whether the native database code represents a retryable transaction failure. */ diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index e953245a7695..04f90a2d7ead 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -94,14 +94,10 @@ class Connection extends BaseConnection */ protected function isUniqueConstraintViolation(int|string $code, string $message): bool { - $code = (string) $code; + $vendorCode = $this->getVendorErrorCode($code); - if (str_contains($code, '/')) { - [$sqlstate, $vendorCode] = explode('/', $code, 2); - - if ($sqlstate === '23000' && in_array((int) $vendorCode, [2627, 2601], true)) { - return true; - } + if ($vendorCode !== null && in_array($vendorCode, [2627, 2601], true)) { + return $this->hasSQLState($code, '23000'); } $errors = sqlsrv_errors(SQLSRV_ERR_ERRORS); @@ -121,20 +117,55 @@ protected function isUniqueConstraintViolation(int|string $code, string $message return false; } + /** + * Checks whether the native database error represents a NOT NULL constraint violation. + */ + protected function isNotNullConstraintViolation(int|string $code, string $message): bool + { + return $this->getVendorErrorCode($code) === 515 + && $this->hasSQLState($code, '23000'); + } + + /** + * Checks whether the native database error represents a constraint violation. + */ + protected function isConstraintViolation(int|string $code, string $message): bool + { + return $this->getSQLState($code) === '23000' + || ($this->getVendorErrorCode($code) === 547 && $this->hasSQLState($code, '23000')); + } + /** * Checks whether the native database code represents a retryable transaction failure. */ protected function isRetryableTransactionErrorCode(int|string $code): bool + { + $vendorCode = $this->getVendorErrorCode($code); + + return $vendorCode !== null && in_array($vendorCode, [1205, 3960], true); + } + + private function getVendorErrorCode(int|string $code): ?int { $vendorCode = (string) (is_string($code) && str_contains($code, '/') ? substr($code, strrpos($code, '/') + 1) : $code); - if (preg_match('/^\d+$/', $vendorCode) !== 1) { - return false; - } + return preg_match('/^\d+$/', $vendorCode) === 1 ? (int) $vendorCode : null; + } + + private function getSQLState(int|string $code): string + { + return is_string($code) && str_contains($code, '/') + ? substr($code, 0, strpos($code, '/')) + : (string) $code; + } - return in_array((int) $vendorCode, [1205, 3960], true); + private function hasSQLState(int|string $code, string $sqlstate): bool + { + return ! is_string($code) + || ! str_contains($code, '/') + || $this->getSQLState($code) === $sqlstate; } /** diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index e312fee681f0..a4794bd78525 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -78,6 +78,38 @@ protected function isUniqueConstraintViolation(int|string $code, string $message || str_contains($message, 'is not unique'); } + /** + * Checks whether the native database error represents a foreign key constraint violation. + */ + protected function isForeignKeyConstraintViolation(int|string $code, string $message): bool + { + return str_contains($message, 'FOREIGN KEY constraint failed'); + } + + /** + * Checks whether the native database error represents a NOT NULL constraint violation. + */ + protected function isNotNullConstraintViolation(int|string $code, string $message): bool + { + return str_contains($message, 'NOT NULL constraint failed'); + } + + /** + * Checks whether the native database error represents a CHECK constraint violation. + */ + protected function isCheckConstraintViolation(int|string $code, string $message): bool + { + return str_contains($message, 'CHECK constraint failed'); + } + + /** + * Checks whether the native database error represents a constraint violation. + */ + protected function isConstraintViolation(int|string $code, string $message): bool + { + return $code === 19; + } + /** * Checks whether the native database code represents a retryable transaction failure. */ diff --git a/tests/system/Database/ConstraintViolationExceptionTest.php b/tests/system/Database/ConstraintViolationExceptionTest.php new file mode 100644 index 000000000000..b254ba638a5d --- /dev/null +++ b/tests/system/Database/ConstraintViolationExceptionTest.php @@ -0,0 +1,383 @@ + + * + * 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\CheckConstraintViolationException; +use CodeIgniter\Database\Exceptions\ConstraintViolationException; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\ForeignKeyConstraintViolationException; +use CodeIgniter\Database\Exceptions\NotNullConstraintViolationException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; +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; + +/** + * @internal + */ +#[Group('Others')] +final class ConstraintViolationExceptionTest extends CIUnitTestCase +{ + public function testUniqueConstraintViolationExtendsConstraintViolation(): void + { + $exception = new UniqueConstraintViolationException(); + + $this->assertInstanceOf(ConstraintViolationException::class, $exception); + } + + /** + * @param class-string $expectedException + */ + #[DataProvider('provideCreatesConstraintViolationExceptions')] + public function testCreatesConstraintViolationExceptions( + BaseConnection $db, + int|string $code, + string $message, + string $expectedException, + ): void { + $exception = $db->createDatabaseException($message, $code); + + $this->assertSame($exception::class, $expectedException); + $this->assertInstanceOf(ConstraintViolationException::class, $exception); + $this->assertSame($code, $exception->getDatabaseCode()); + } + + /** + * @return iterable}> + */ + public static function provideCreatesConstraintViolationExceptions(): iterable + { + yield 'MySQLi unique constraint' => [ + self::connection(MySQLiConnection::class, 'MySQLi'), + 1062, + 'Duplicate entry.', + UniqueConstraintViolationException::class, + ]; + + yield 'MySQLi foreign key parent row referenced' => [ + self::connection(MySQLiConnection::class, 'MySQLi'), + 1451, + 'Cannot delete or update a parent row: a foreign key constraint fails.', + ForeignKeyConstraintViolationException::class, + ]; + + yield 'MySQLi foreign key child row missing parent' => [ + self::connection(MySQLiConnection::class, 'MySQLi'), + 1452, + 'Cannot add or update a child row: a foreign key constraint fails.', + ForeignKeyConstraintViolationException::class, + ]; + + yield 'MySQLi foreign key parent row referenced legacy code' => [ + self::connection(MySQLiConnection::class, 'MySQLi'), + 1217, + 'Cannot delete or update a parent row: a foreign key constraint fails.', + ForeignKeyConstraintViolationException::class, + ]; + + yield 'MySQLi foreign key child row missing parent legacy code' => [ + self::connection(MySQLiConnection::class, 'MySQLi'), + 1216, + 'Cannot add or update a child row: a foreign key constraint fails.', + ForeignKeyConstraintViolationException::class, + ]; + + yield 'MySQLi not null' => [ + self::connection(MySQLiConnection::class, 'MySQLi'), + 1048, + "Column 'name' cannot be null", + NotNullConstraintViolationException::class, + ]; + + yield 'MySQLi not null ignored' => [ + self::connection(MySQLiConnection::class, 'MySQLi'), + 3673, + "Column 'name' cannot be null", + NotNullConstraintViolationException::class, + ]; + + yield 'MySQLi check' => [ + self::connection(MySQLiConnection::class, 'MySQLi'), + 3819, + "Check constraint 'positive_amount' is violated.", + CheckConstraintViolationException::class, + ]; + + yield 'MySQLi check MariaDB' => [ + self::mysqliConnectionWithVersion('10.11.0-MariaDB'), + 4025, + 'CONSTRAINT `positive_amount` failed.', + CheckConstraintViolationException::class, + ]; + + yield 'Postgre foreign key' => [ + self::connection(PostgreConnection::class, 'Postgre'), + '23503', + 'Foreign key violation.', + ForeignKeyConstraintViolationException::class, + ]; + + yield 'Postgre unique constraint' => [ + self::connection(PostgreConnection::class, 'Postgre'), + '23505', + 'Unique violation.', + UniqueConstraintViolationException::class, + ]; + + yield 'Postgre not null' => [ + self::connection(PostgreConnection::class, 'Postgre'), + '23502', + 'Not-null violation.', + NotNullConstraintViolationException::class, + ]; + + yield 'Postgre check' => [ + self::connection(PostgreConnection::class, 'Postgre'), + '23514', + 'Check violation.', + CheckConstraintViolationException::class, + ]; + + yield 'Postgre generic constraint' => [ + self::connection(PostgreConnection::class, 'Postgre'), + '23P01', + 'Exclusion violation.', + ConstraintViolationException::class, + ]; + + yield 'SQLite foreign key' => [ + self::connection(SQLite3Connection::class, 'SQLite3'), + 19, + 'FOREIGN KEY constraint failed', + ForeignKeyConstraintViolationException::class, + ]; + + yield 'SQLite unique constraint' => [ + self::connection(SQLite3Connection::class, 'SQLite3'), + 19, + 'UNIQUE constraint failed: table.column', + UniqueConstraintViolationException::class, + ]; + + yield 'SQLite legacy unique constraint' => [ + self::connection(SQLite3Connection::class, 'SQLite3'), + 19, + 'column email is not unique', + UniqueConstraintViolationException::class, + ]; + + yield 'SQLite not null' => [ + self::connection(SQLite3Connection::class, 'SQLite3'), + 19, + 'NOT NULL constraint failed: user.name', + NotNullConstraintViolationException::class, + ]; + + yield 'SQLite check' => [ + self::connection(SQLite3Connection::class, 'SQLite3'), + 19, + 'CHECK constraint failed: positive_amount', + CheckConstraintViolationException::class, + ]; + + yield 'SQLite generic constraint' => [ + self::connection(SQLite3Connection::class, 'SQLite3'), + 19, + 'constraint failed', + ConstraintViolationException::class, + ]; + + yield 'SQLSRV not null' => [ + self::connection(SQLSRVConnection::class, 'SQLSRV'), + '23000/515', + 'Cannot insert the value NULL into column.', + NotNullConstraintViolationException::class, + ]; + + yield 'SQLSRV unique constraint' => [ + self::connection(SQLSRVConnection::class, 'SQLSRV'), + '23000/2627', + 'Violation of UNIQUE KEY constraint.', + UniqueConstraintViolationException::class, + ]; + + yield 'SQLSRV unique index' => [ + self::connection(SQLSRVConnection::class, 'SQLSRV'), + '23000/2601', + 'Cannot insert duplicate key row.', + UniqueConstraintViolationException::class, + ]; + + yield 'SQLSRV generic constraint' => [ + self::connection(SQLSRVConnection::class, 'SQLSRV'), + '23000/547', + 'The INSERT statement conflicted with the constraint.', + ConstraintViolationException::class, + ]; + + yield 'SQLSRV generic constraint SQLSTATE' => [ + self::connection(SQLSRVConnection::class, 'SQLSRV'), + '23000', + 'Integrity constraint violation.', + ConstraintViolationException::class, + ]; + + if (defined('OCI_COMMIT_ON_SUCCESS')) { + yield 'OCI8 unique constraint' => [ + self::connection(OCI8Connection::class, 'OCI8'), + 1, + 'Unique constraint violated.', + UniqueConstraintViolationException::class, + ]; + + yield 'OCI8 unique constraint string code' => [ + self::connection(OCI8Connection::class, 'OCI8'), + '1', + 'Unique constraint violated.', + UniqueConstraintViolationException::class, + ]; + + yield 'OCI8 foreign key parent key not found' => [ + self::connection(OCI8Connection::class, 'OCI8'), + 2291, + 'Integrity constraint violated - parent key not found.', + ForeignKeyConstraintViolationException::class, + ]; + + yield 'OCI8 foreign key child record found' => [ + self::connection(OCI8Connection::class, 'OCI8'), + 2292, + 'Integrity constraint violated - child record found.', + ForeignKeyConstraintViolationException::class, + ]; + + yield 'OCI8 not null' => [ + self::connection(OCI8Connection::class, 'OCI8'), + 1400, + 'Cannot insert NULL.', + NotNullConstraintViolationException::class, + ]; + + yield 'OCI8 not null update' => [ + self::connection(OCI8Connection::class, 'OCI8'), + 1407, + 'Cannot update to NULL.', + NotNullConstraintViolationException::class, + ]; + + yield 'OCI8 check' => [ + self::connection(OCI8Connection::class, 'OCI8'), + 2290, + 'Check constraint violated.', + CheckConstraintViolationException::class, + ]; + } + } + + public function testCreatesBaseDatabaseExceptionForNonConstraintError(): void + { + $exception = self::connection(MockConnection::class, 'MockDriver') + ->createDatabaseException('Syntax error.', 1064); + + $this->assertInstanceOf(DatabaseException::class, $exception); + $this->assertNotInstanceOf(ConstraintViolationException::class, $exception); + } + + public function testCreatesBaseDatabaseExceptionForMySQLiNonConstraint4025(): void + { + $exception = self::mysqliConnectionWithVersion('8.4.0') + ->createDatabaseException('InnoDB autoextend size is out of range.', 4025); + + $this->assertInstanceOf(DatabaseException::class, $exception); + $this->assertNotInstanceOf(ConstraintViolationException::class, $exception); + } + + #[DataProvider('provideCreatesBaseDatabaseExceptionForSqlsrvConstraintVendorCodeWithNonConstraintSqlstate')] + public function testCreatesBaseDatabaseExceptionForSqlsrvConstraintVendorCodeWithNonConstraintSqlstate(string $code): void + { + $exception = self::connection(SQLSRVConnection::class, 'SQLSRV') + ->createDatabaseException('General error.', $code); + + $this->assertInstanceOf(DatabaseException::class, $exception); + $this->assertNotInstanceOf(ConstraintViolationException::class, $exception); + } + + /** + * @return iterable + */ + public static function provideCreatesBaseDatabaseExceptionForSqlsrvConstraintVendorCodeWithNonConstraintSqlstate(): iterable + { + yield 'unique constraint' => ['HY000/2627']; + + yield 'unique index' => ['HY000/2601']; + + yield 'not null' => ['HY000/515']; + + yield 'generic constraint' => ['HY000/547']; + } + + /** + * @param class-string $connectionClass + */ + private static function connection(string $connectionClass, string $driver): BaseConnection + { + return new $connectionClass(self::config($driver)); + } + + private static function mysqliConnectionWithVersion(string $version): BaseConnection + { + return new class (self::config('MySQLi'), $version) extends MySQLiConnection { + /** + * @param array $params + */ + public function __construct(array $params, private readonly string $version) + { + parent::__construct($params); + } + + public function getVersion(): string + { + return $this->version; + } + }; + } + + /** + * @return array + */ + 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' => [], + ]; + } +} diff --git a/tests/system/Database/Live/ConstraintViolationExceptionTest.php b/tests/system/Database/Live/ConstraintViolationExceptionTest.php new file mode 100644 index 000000000000..2472cbf80143 --- /dev/null +++ b/tests/system/Database/Live/ConstraintViolationExceptionTest.php @@ -0,0 +1,135 @@ + + * + * 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\ConstraintViolationException; +use CodeIgniter\Database\Exceptions\ForeignKeyConstraintViolationException; +use CodeIgniter\Database\Exceptions\NotNullConstraintViolationException; +use CodeIgniter\Database\Forge; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use Config\Database; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class ConstraintViolationExceptionTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + private ?Forge $forge = null; + protected $refresh = true; + protected $seed = CITestSeeder::class; + + protected function tearDown(): void + { + $this->enableDBDebug(); + + if ($this->forge instanceof Forge) { + $this->forge->dropTable('cv_child', true); + $this->forge->dropTable('cv_parent', true); + } + + parent::tearDown(); + } + + public function testThrowsNotNullConstraintViolationExceptionWithDebugEnabled(): void + { + $this->enableDBDebug(); + + $this->expectException(NotNullConstraintViolationException::class); + + $this->db->table('user')->insert([ + 'name' => null, + 'email' => 'not-null@example.com', + 'country' => 'US', + ]); + } + + public function testThrowsNotNullConstraintViolationExceptionForUpdateWithDebugEnabled(): void + { + $this->enableDBDebug(); + + $this->expectException(NotNullConstraintViolationException::class); + + $this->db->table('user') + ->where('id', 1) + ->update(['name' => null]); + } + + public function testThrowsConstraintViolationExceptionForForeignKeyWithDebugEnabled(): void + { + $this->enableDBDebug(); + $this->createForeignKeyTables(); + + $expectedException = $this->db->DBDriver === 'SQLSRV' + ? ConstraintViolationException::class + : ForeignKeyConstraintViolationException::class; + + $this->expectException($expectedException); + + $this->db->table('cv_child')->insert([ + 'id' => 1, + 'parent_id' => 999, + ]); + } + + public function testStoresNotNullConstraintViolationExceptionWithDebugDisabled(): void + { + $this->disableDBDebug(); + + $result = $this->db->table('user')->insert([ + 'name' => null, + 'email' => 'not-null@example.com', + 'country' => 'US', + ]); + + $this->assertFalse($result); + $this->assertInstanceOf(NotNullConstraintViolationException::class, $this->db->getLastException()); + } + + private function createForeignKeyTables(): void + { + $this->forge = Database::forge($this->DBGroup); + + $this->forge->dropTable('cv_child', true); + $this->forge->dropTable('cv_parent', true); + + $this->forge->addField([ + 'id' => ['type' => 'INTEGER', 'constraint' => 3], + ]); + $this->forge->addKey('id', true); + $this->forge->createTable('cv_parent'); + + $this->forge->addField([ + 'id' => ['type' => 'INTEGER', 'constraint' => 3], + 'parent_id' => ['type' => 'INTEGER', 'constraint' => 3], + ]); + $this->forge->addKey('id', true); + $this->forge->addForeignKey( + 'parent_id', + 'cv_parent', + 'id', + '', + '', + $this->db->DBDriver === 'SQLite3' ? '' : 'fk_cv_child_parent', + ); + $this->forge->createTable('cv_child'); + + $this->db->enableForeignKeyChecks(); + } +} diff --git a/tests/system/Database/RetryableTransactionExceptionTest.php b/tests/system/Database/RetryableTransactionExceptionTest.php index a78f15d20142..d7dfa7e387da 100644 --- a/tests/system/Database/RetryableTransactionExceptionTest.php +++ b/tests/system/Database/RetryableTransactionExceptionTest.php @@ -15,7 +15,6 @@ use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\RetryableTransactionException; -use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Database\MySQLi\Connection as MySQLiConnection; use CodeIgniter\Database\OCI8\Connection as OCI8Connection; use CodeIgniter\Database\Postgre\Connection as PostgreConnection; @@ -35,74 +34,6 @@ #[Group('Others')] final class RetryableTransactionExceptionTest extends CIUnitTestCase { - #[DataProvider('provideCreatesUniqueConstraintViolationExceptions')] - public function testCreatesUniqueConstraintViolationExceptions( - BaseConnection $db, - int|string $code, - string $message, - ): void { - $exception = self::createDatabaseException($db, $message, $code); - - $this->assertInstanceOf(UniqueConstraintViolationException::class, $exception); - $this->assertSame($code, $exception->getDatabaseCode()); - } - - /** - * @return iterable - */ - public static function provideCreatesUniqueConstraintViolationExceptions(): iterable - { - yield 'MySQLi duplicate key' => [ - self::connection(MySQLiConnection::class, 'MySQLi'), - 1062, - 'Duplicate entry.', - ]; - - yield 'Postgre unique violation' => [ - self::connection(PostgreConnection::class, 'Postgre'), - '23505', - 'Unique violation.', - ]; - - yield 'SQLite unique constraint' => [ - self::connection(SQLite3Connection::class, 'SQLite3'), - 19, - 'UNIQUE constraint failed: table.column', - ]; - - yield 'SQLite legacy unique constraint' => [ - self::connection(SQLite3Connection::class, 'SQLite3'), - 19, - 'column email is not unique', - ]; - - yield 'SQLSRV unique constraint' => [ - self::connection(SQLSRVConnection::class, 'SQLSRV'), - '23000/2627', - 'Violation of UNIQUE KEY constraint.', - ]; - - yield 'SQLSRV unique index' => [ - self::connection(SQLSRVConnection::class, 'SQLSRV'), - '23000/2601', - 'Cannot insert duplicate key row.', - ]; - - if (defined('OCI_COMMIT_ON_SUCCESS')) { - yield 'OCI8 unique constraint' => [ - self::connection(OCI8Connection::class, 'OCI8'), - 1, - 'Unique constraint violated.', - ]; - - yield 'OCI8 unique constraint string code' => [ - self::connection(OCI8Connection::class, 'OCI8'), - '1', - 'Unique constraint violated.', - ]; - } - } - #[DataProvider('provideCreatesRetryableTransactionExceptions')] public function testCreatesRetryableTransactionExceptions(BaseConnection $db, int|string $code): void { diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index e145c14cacbc..197358c9cdfb 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -232,9 +232,10 @@ 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 ``ConstraintViolationException`` with ``UniqueConstraintViolationException``, ``ForeignKeyConstraintViolationException``, ``NotNullConstraintViolationException``, and ``CheckConstraintViolationException`` subclasses for typed database integrity errors. See :ref:`database-constraint-violation-exceptions`. - Added ``inTransaction()`` to database connections to check whether the connection is inside an active CodeIgniter-managed transaction. See :ref:`transactions-checking-transaction-state`. - Added support for PHP ``BackedEnum`` values in database escaping, query bindings, and Query Builder bound values. -- Prepared query execution failures now throw or store typed database exceptions such as ``UniqueConstraintViolationException`` and ``RetryableTransactionException`` when applicable, matching normal query failures. +- Prepared query execution failures now throw or store typed database exceptions such as ``ConstraintViolationException`` and ``RetryableTransactionException`` when applicable, matching normal query failures. - 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, with optional retry attempts for retryable transaction failures and scoped ``transException`` and ``resetTransStatus`` options. 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. @@ -260,7 +261,6 @@ 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. diff --git a/user_guide_src/source/database/queries.rst b/user_guide_src/source/database/queries.rst index 915f4f8bad60..438531d2ab4b 100644 --- a/user_guide_src/source/database/queries.rst +++ b/user_guide_src/source/database/queries.rst @@ -228,17 +228,30 @@ its subclasses, which you can catch and handle: .. literalinclude:: queries/030.php +.. _database-constraint-violation-exceptions: .. _database-unique-constraint-violation: -UniqueConstraintViolationException ----------------------------------- +Constraint Violation Exceptions +------------------------------- .. versionadded:: 4.8.0 -``UniqueConstraintViolationException`` extends ``DatabaseException`` and is -thrown specifically when a query or prepared query execution 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. +Database constraint violations are reported as ``ConstraintViolationException`` +or one of its subclasses when CodeIgniter can identify the native driver error: + +- ``UniqueConstraintViolationException`` +- ``ForeignKeyConstraintViolationException`` +- ``NotNullConstraintViolationException`` +- ``CheckConstraintViolationException`` + +These exceptions allow you to handle common integrity errors without inspecting +raw driver-specific error codes. If a driver reports a constraint violation that +CodeIgniter cannot safely classify more precisely, it throws the parent +``ConstraintViolationException``. + +Some database engines report certain constraint failures with a generic +integrity-constraint code. In those cases CodeIgniter uses the parent +``ConstraintViolationException`` rather than guessing the more specific subtype. DBDebug Disabled ================ @@ -265,8 +278,8 @@ $db->getLastException() ``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``: +between failure types (e.g., a constraint violation vs. another database error) +without enabling ``DBDebug``: .. literalinclude:: queries/031.php diff --git a/user_guide_src/source/database/queries/030.php b/user_guide_src/source/database/queries/030.php index a4fea00892f0..96310fb977ce 100644 --- a/user_guide_src/source/database/queries/030.php +++ b/user_guide_src/source/database/queries/030.php @@ -1,12 +1,18 @@ table('users')->insert(['email' => 'duplicate@example.com']); } catch (UniqueConstraintViolationException $e) { - // Duplicate key — handle gracefully + // Handle duplicate key violation. +} catch (ForeignKeyConstraintViolationException $e) { + // Handle missing or referenced parent row. +} catch (ConstraintViolationException $e) { + // Handle another known database constraint violation. } catch (DatabaseException $e) { - // Other database error + // Handle another database error. } diff --git a/user_guide_src/source/database/queries/031.php b/user_guide_src/source/database/queries/031.php index 4dafb880cd5f..b655e1bf097a 100644 --- a/user_guide_src/source/database/queries/031.php +++ b/user_guide_src/source/database/queries/031.php @@ -5,5 +5,5 @@ $inserted = $db->table('users')->insert(['email' => 'duplicate@example.com']); if (! $inserted && $db->getLastException() instanceof UniqueConstraintViolationException) { - // Handle duplicate key violation + // Handle duplicate key violation. }