From aee55a14dad393e5ddf431c8e4323f51cfaf3cb9 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:43:55 +0300 Subject: [PATCH 1/6] feat(database): add constraint violation exceptions - Add a ConstraintViolationException hierarchy for typed integrity errors. - Classify foreign key, not-null, check, unique, and generic constraint violations per driver. - Keep getLastException() aligned with thrown exceptions when DBDebug is disabled. - Update database docs, examples, changelog, and focused tests. Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseConnection.php | 52 +++ .../CheckConstraintViolationException.php | 21 ++ .../ConstraintViolationException.php | 21 ++ ...ForeignKeyConstraintViolationException.php | 21 ++ .../NotNullConstraintViolationException.php | 21 ++ .../UniqueConstraintViolationException.php | 5 +- system/Database/MySQLi/Connection.php | 27 ++ system/Database/OCI8/Connection.php | 27 ++ system/Database/Postgre/Connection.php | 32 ++ system/Database/SQLSRV/Connection.php | 53 ++- system/Database/SQLite3/Connection.php | 32 ++ .../ConstraintViolationExceptionTest.php | 318 ++++++++++++++++++ .../Live/ConstraintViolationExceptionTest.php | 66 ++++ .../RetryableTransactionExceptionTest.php | 69 ---- user_guide_src/source/changelogs/v4.8.0.rst | 4 +- user_guide_src/source/database/queries.rst | 29 +- .../source/database/queries/030.php | 10 +- .../source/database/queries/031.php | 6 +- 18 files changed, 718 insertions(+), 96 deletions(-) create mode 100644 system/Database/Exceptions/CheckConstraintViolationException.php create mode 100644 system/Database/Exceptions/ConstraintViolationException.php create mode 100644 system/Database/Exceptions/ForeignKeyConstraintViolationException.php create mode 100644 system/Database/Exceptions/NotNullConstraintViolationException.php create mode 100644 tests/system/Database/ConstraintViolationExceptionTest.php create mode 100644 tests/system/Database/Live/ConstraintViolationExceptionTest.php 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..cf1c3ce05796 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -106,6 +106,33 @@ 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_ROW_IS_REFERENCED_2, ER_NO_REFERENCED_ROW_2. + return in_array($code, [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: column cannot be null. + return $code === 1048; + } + + /** + * Checks whether the native database error represents a CHECK constraint violation. + */ + protected function isCheckConstraintViolation(int|string $code, string $message): bool + { + // ER_CHECK_CONSTRAINT_VIOLATED: check constraint is violated. + return $code === 3819; + } + /** * 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..05e823f9d01e 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. + return (int) $code === 1400; + } + + /** + * 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..5e515a852f5b --- /dev/null +++ b/tests/system/Database/ConstraintViolationExceptionTest.php @@ -0,0 +1,318 @@ + + * + * 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($expectedException, $exception::class); + $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 not null' => [ + self::connection(MySQLiConnection::class, 'MySQLi'), + 1048, + "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 '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 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); + } + + #[DataProvider('provideSqlsrvConstraintVendorCodesWithNonConstraintSqlstate')] + 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 provideSqlsrvConstraintVendorCodesWithNonConstraintSqlstate(): 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)); + } + + /** + * @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..b0c93c1025b5 --- /dev/null +++ b/tests/system/Database/Live/ConstraintViolationExceptionTest.php @@ -0,0 +1,66 @@ + + * + * 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\NotNullConstraintViolationException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class ConstraintViolationExceptionTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $refresh = true; + protected $seed = CITestSeeder::class; + + protected function tearDown(): void + { + $this->enableDBDebug(); + + 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 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()); + } +} 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..d10946ea8292 100644 --- a/user_guide_src/source/database/queries/031.php +++ b/user_guide_src/source/database/queries/031.php @@ -1,9 +1,9 @@ table('users')->insert(['email' => 'duplicate@example.com']); -if (! $inserted && $db->getLastException() instanceof UniqueConstraintViolationException) { - // Handle duplicate key violation +if (! $inserted && $db->getLastException() instanceof ConstraintViolationException) { + // Handle the constraint violation. } From 9692ee4beff4fd1a1041e6611008462a54baf869 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:02:18 +0300 Subject: [PATCH 2/6] fix: cs Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- tests/system/Database/ConstraintViolationExceptionTest.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/system/Database/ConstraintViolationExceptionTest.php b/tests/system/Database/ConstraintViolationExceptionTest.php index 5e515a852f5b..539303aad1de 100644 --- a/tests/system/Database/ConstraintViolationExceptionTest.php +++ b/tests/system/Database/ConstraintViolationExceptionTest.php @@ -265,7 +265,7 @@ public function testCreatesBaseDatabaseExceptionForNonConstraintError(): void $this->assertNotInstanceOf(ConstraintViolationException::class, $exception); } - #[DataProvider('provideSqlsrvConstraintVendorCodesWithNonConstraintSqlstate')] + #[DataProvider('provideCreatesBaseDatabaseExceptionForSqlsrvConstraintVendorCodeWithNonConstraintSqlstate')] public function testCreatesBaseDatabaseExceptionForSqlsrvConstraintVendorCodeWithNonConstraintSqlstate(string $code): void { $exception = self::connection(SQLSRVConnection::class, 'SQLSRV') @@ -278,11 +278,14 @@ public function testCreatesBaseDatabaseExceptionForSqlsrvConstraintVendorCodeWit /** * @return iterable */ - public static function provideSqlsrvConstraintVendorCodesWithNonConstraintSqlstate(): 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']; } From 12e51280b5a9a1b9c55f6d1ea8ed5a0114284554 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:06:30 +0300 Subject: [PATCH 3/6] fix: satisfy rector Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- tests/system/Database/ConstraintViolationExceptionTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system/Database/ConstraintViolationExceptionTest.php b/tests/system/Database/ConstraintViolationExceptionTest.php index 539303aad1de..7a34005bf698 100644 --- a/tests/system/Database/ConstraintViolationExceptionTest.php +++ b/tests/system/Database/ConstraintViolationExceptionTest.php @@ -54,7 +54,7 @@ public function testCreatesConstraintViolationExceptions( ): void { $exception = $db->createDatabaseException($message, $code); - $this->assertSame($expectedException, $exception::class); + $this->assertSame($exception::class, $expectedException); $this->assertInstanceOf(ConstraintViolationException::class, $exception); $this->assertSame($code, $exception->getDatabaseCode()); } From 77c1ddcc0838386667d28be689038a5482998012 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:52:43 +0300 Subject: [PATCH 4/6] refactor: cover additional constraint violations Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/MySQLi/Connection.php | 22 ++++-- system/Database/OCI8/Connection.php | 4 +- .../ConstraintViolationExceptionTest.php | 44 ++++++++++++ .../Live/ConstraintViolationExceptionTest.php | 68 ++++++++++++++++++- .../source/database/queries/031.php | 6 +- 5 files changed, 131 insertions(+), 13 deletions(-) diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index cf1c3ce05796..956b0153f3c6 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -111,8 +111,8 @@ protected function isUniqueConstraintViolation(int|string $code, string $message */ protected function isForeignKeyConstraintViolation(int|string $code, string $message): bool { - // ER_ROW_IS_REFERENCED_2, ER_NO_REFERENCED_ROW_2. - return in_array($code, [1451, 1452], true); + // 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); } /** @@ -120,8 +120,8 @@ protected function isForeignKeyConstraintViolation(int|string $code, string $mes */ protected function isNotNullConstraintViolation(int|string $code, string $message): bool { - // ER_BAD_NULL_ERROR: column cannot be null. - return $code === 1048; + // ER_BAD_NULL_ERROR, ER_BAD_NULL_ERROR_NOT_IGNORED: column cannot be null. + return in_array($code, [1048, 3673], true); } /** @@ -129,8 +129,18 @@ protected function isNotNullConstraintViolation(int|string $code, string $messag */ protected function isCheckConstraintViolation(int|string $code, string $message): bool { - // ER_CHECK_CONSTRAINT_VIOLATED: check constraint is violated. - return $code === 3819; + if ($code === 3819) { + return true; + } + + // MariaDB reports CHECK failures as ER_CONSTRAINT_FAILED, while MySQL uses 4025 for other errors. + if ($code !== 4025) { + return false; + } + + $message = strtolower($message); + + return str_contains($message, 'constraint') && str_contains($message, 'failed'); } /** diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php index 05e823f9d01e..d4debc2df63c 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -137,8 +137,8 @@ protected function isForeignKeyConstraintViolation(int|string $code, string $mes */ protected function isNotNullConstraintViolation(int|string $code, string $message): bool { - // ORA-01400: cannot insert NULL. - return (int) $code === 1400; + // ORA-01400: cannot insert NULL; ORA-01407: cannot update to NULL. + return in_array((int) $code, [1400, 1407], true); } /** diff --git a/tests/system/Database/ConstraintViolationExceptionTest.php b/tests/system/Database/ConstraintViolationExceptionTest.php index 7a34005bf698..9ba5c6af14b2 100644 --- a/tests/system/Database/ConstraintViolationExceptionTest.php +++ b/tests/system/Database/ConstraintViolationExceptionTest.php @@ -85,6 +85,20 @@ public static function provideCreatesConstraintViolationExceptions(): iterable 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, @@ -92,6 +106,13 @@ public static function provideCreatesConstraintViolationExceptions(): iterable 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, @@ -99,6 +120,13 @@ public static function provideCreatesConstraintViolationExceptions(): iterable CheckConstraintViolationException::class, ]; + yield 'MySQLi check MariaDB' => [ + self::connection(MySQLiConnection::class, 'MySQLi'), + 4025, + 'CONSTRAINT `positive_amount` failed.', + CheckConstraintViolationException::class, + ]; + yield 'Postgre foreign key' => [ self::connection(PostgreConnection::class, 'Postgre'), '23503', @@ -247,6 +275,13 @@ public static function provideCreatesConstraintViolationExceptions(): iterable 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, @@ -265,6 +300,15 @@ public function testCreatesBaseDatabaseExceptionForNonConstraintError(): void $this->assertNotInstanceOf(ConstraintViolationException::class, $exception); } + public function testCreatesBaseDatabaseExceptionForMySQLiNonConstraint4025(): void + { + $exception = self::connection(MySQLiConnection::class, 'MySQLi') + ->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 { diff --git a/tests/system/Database/Live/ConstraintViolationExceptionTest.php b/tests/system/Database/Live/ConstraintViolationExceptionTest.php index b0c93c1025b5..83cbb104fcdf 100644 --- a/tests/system/Database/Live/ConstraintViolationExceptionTest.php +++ b/tests/system/Database/Live/ConstraintViolationExceptionTest.php @@ -13,9 +13,12 @@ namespace CodeIgniter\Database\Live; +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; @@ -27,13 +30,19 @@ final class ConstraintViolationExceptionTest extends CIUnitTestCase { use DatabaseTestTrait; - protected $refresh = true; - protected $seed = CITestSeeder::class; + 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(); } @@ -50,6 +59,30 @@ public function testThrowsNotNullConstraintViolationExceptionWithDebugEnabled(): ]); } + public function testThrowsNotNullConstraintViolationExceptionForUpdateWithDebugEnabled(): void + { + $this->enableDBDebug(); + + $this->expectException(NotNullConstraintViolationException::class); + + $this->db->table('user') + ->where('id', 1) + ->update(['name' => null]); + } + + public function testThrowsForeignKeyConstraintViolationExceptionWithDebugEnabled(): void + { + $this->enableDBDebug(); + $this->createForeignKeyTables(); + + $this->expectException(ForeignKeyConstraintViolationException::class); + + $this->db->table('cv_child')->insert([ + 'id' => 1, + 'parent_id' => 999, + ]); + } + public function testStoresNotNullConstraintViolationExceptionWithDebugDisabled(): void { $this->disableDBDebug(); @@ -63,4 +96,35 @@ public function testStoresNotNullConstraintViolationExceptionWithDebugDisabled() $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/user_guide_src/source/database/queries/031.php b/user_guide_src/source/database/queries/031.php index d10946ea8292..b655e1bf097a 100644 --- a/user_guide_src/source/database/queries/031.php +++ b/user_guide_src/source/database/queries/031.php @@ -1,9 +1,9 @@ table('users')->insert(['email' => 'duplicate@example.com']); -if (! $inserted && $db->getLastException() instanceof ConstraintViolationException) { - // Handle the constraint violation. +if (! $inserted && $db->getLastException() instanceof UniqueConstraintViolationException) { + // Handle duplicate key violation. } From 658a40b75d2d63fb96240a2f749a0b3ea8847ae2 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:11:17 +0300 Subject: [PATCH 5/6] fix: classify `SQLSRV` foreign key violations Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/SQLSRV/Connection.php | 10 ++++++++++ .../Database/ConstraintViolationExceptionTest.php | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 04f90a2d7ead..7dac07de6bba 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -117,6 +117,16 @@ 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 $this->getVendorErrorCode($code) === 547 + && $this->hasSQLState($code, '23000') + && str_contains($message, 'FOREIGN KEY constraint'); + } + /** * Checks whether the native database error represents a NOT NULL constraint violation. */ diff --git a/tests/system/Database/ConstraintViolationExceptionTest.php b/tests/system/Database/ConstraintViolationExceptionTest.php index 9ba5c6af14b2..99a138bf3494 100644 --- a/tests/system/Database/ConstraintViolationExceptionTest.php +++ b/tests/system/Database/ConstraintViolationExceptionTest.php @@ -225,6 +225,13 @@ public static function provideCreatesConstraintViolationExceptions(): iterable UniqueConstraintViolationException::class, ]; + yield 'SQLSRV foreign key' => [ + self::connection(SQLSRVConnection::class, 'SQLSRV'), + '23000/547', + 'The INSERT statement conflicted with the FOREIGN KEY constraint.', + ForeignKeyConstraintViolationException::class, + ]; + yield 'SQLSRV generic constraint' => [ self::connection(SQLSRVConnection::class, 'SQLSRV'), '23000/547', From ffb4b0da64a7113ac4cb5f38c1aa7ca97c8813e0 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:30:00 +0300 Subject: [PATCH 6/6] refactor: avoid message-based checks Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/MySQLi/Connection.php | 8 +---- system/Database/SQLSRV/Connection.php | 10 ------- .../ConstraintViolationExceptionTest.php | 29 +++++++++++++------ .../Live/ConstraintViolationExceptionTest.php | 9 ++++-- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 956b0153f3c6..41c47c0650dd 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -134,13 +134,7 @@ protected function isCheckConstraintViolation(int|string $code, string $message) } // MariaDB reports CHECK failures as ER_CONSTRAINT_FAILED, while MySQL uses 4025 for other errors. - if ($code !== 4025) { - return false; - } - - $message = strtolower($message); - - return str_contains($message, 'constraint') && str_contains($message, 'failed'); + return $code === 4025 && str_contains(strtolower($this->getVersion()), 'mariadb'); } /** diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 7dac07de6bba..04f90a2d7ead 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -117,16 +117,6 @@ 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 $this->getVendorErrorCode($code) === 547 - && $this->hasSQLState($code, '23000') - && str_contains($message, 'FOREIGN KEY constraint'); - } - /** * Checks whether the native database error represents a NOT NULL constraint violation. */ diff --git a/tests/system/Database/ConstraintViolationExceptionTest.php b/tests/system/Database/ConstraintViolationExceptionTest.php index 99a138bf3494..b254ba638a5d 100644 --- a/tests/system/Database/ConstraintViolationExceptionTest.php +++ b/tests/system/Database/ConstraintViolationExceptionTest.php @@ -121,7 +121,7 @@ public static function provideCreatesConstraintViolationExceptions(): iterable ]; yield 'MySQLi check MariaDB' => [ - self::connection(MySQLiConnection::class, 'MySQLi'), + self::mysqliConnectionWithVersion('10.11.0-MariaDB'), 4025, 'CONSTRAINT `positive_amount` failed.', CheckConstraintViolationException::class, @@ -225,13 +225,6 @@ public static function provideCreatesConstraintViolationExceptions(): iterable UniqueConstraintViolationException::class, ]; - yield 'SQLSRV foreign key' => [ - self::connection(SQLSRVConnection::class, 'SQLSRV'), - '23000/547', - 'The INSERT statement conflicted with the FOREIGN KEY constraint.', - ForeignKeyConstraintViolationException::class, - ]; - yield 'SQLSRV generic constraint' => [ self::connection(SQLSRVConnection::class, 'SQLSRV'), '23000/547', @@ -309,7 +302,7 @@ public function testCreatesBaseDatabaseExceptionForNonConstraintError(): void public function testCreatesBaseDatabaseExceptionForMySQLiNonConstraint4025(): void { - $exception = self::connection(MySQLiConnection::class, 'MySQLi') + $exception = self::mysqliConnectionWithVersion('8.4.0') ->createDatabaseException('InnoDB autoextend size is out of range.', 4025); $this->assertInstanceOf(DatabaseException::class, $exception); @@ -348,6 +341,24 @@ private static function connection(string $connectionClass, string $driver): Bas 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 */ diff --git a/tests/system/Database/Live/ConstraintViolationExceptionTest.php b/tests/system/Database/Live/ConstraintViolationExceptionTest.php index 83cbb104fcdf..2472cbf80143 100644 --- a/tests/system/Database/Live/ConstraintViolationExceptionTest.php +++ b/tests/system/Database/Live/ConstraintViolationExceptionTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Database\Live; +use CodeIgniter\Database\Exceptions\ConstraintViolationException; use CodeIgniter\Database\Exceptions\ForeignKeyConstraintViolationException; use CodeIgniter\Database\Exceptions\NotNullConstraintViolationException; use CodeIgniter\Database\Forge; @@ -70,12 +71,16 @@ public function testThrowsNotNullConstraintViolationExceptionForUpdateWithDebugE ->update(['name' => null]); } - public function testThrowsForeignKeyConstraintViolationExceptionWithDebugEnabled(): void + public function testThrowsConstraintViolationExceptionForForeignKeyWithDebugEnabled(): void { $this->enableDBDebug(); $this->createForeignKeyTables(); - $this->expectException(ForeignKeyConstraintViolationException::class); + $expectedException = $this->db->DBDriver === 'SQLSRV' + ? ConstraintViolationException::class + : ForeignKeyConstraintViolationException::class; + + $this->expectException($expectedException); $this->db->table('cv_child')->insert([ 'id' => 1,