From ef710313b4559bbf6c1fc362f20fe0cefe19fe4f Mon Sep 17 00:00:00 2001 From: WarLikeLaux Date: Sun, 29 Mar 2026 15:11:33 +0600 Subject: [PATCH 1/6] Fix #1169: Improve `ConvertException` error matching --- CHANGELOG.md | 3 +- src/Exception/ConnectionException.php | 10 ++++ src/Exception/ConvertException.php | 42 +++++++++++---- tests/Common/CommonCommandTest.php | 31 +++++++++++ tests/Db/Exception/ConvertExceptionTest.php | 60 +++++++++++++++++++++ 5 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 src/Exception/ConnectionException.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 957cb6fd1..930954b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## 2.0.2 under development -- no changes in this release. +- Bug #1170: Fix `ConvertException` incorrectly detecting `SQLSTATE[HY000]` errors as `IntegrityException` (@WarLikeLaux) +- Enh #1170: Add `ConnectionException` for `SQLSTATE[08xxx]` errors and Oracle integrity error detection (@WarLikeLaux) ## 2.0.1 February 09, 2026 diff --git a/src/Exception/ConnectionException.php b/src/Exception/ConnectionException.php new file mode 100644 index 000000000..baf400f80 --- /dev/null +++ b/src/Exception/ConnectionException.php @@ -0,0 +1,10 @@ +e instanceof PDOException ? $this->e->errorInfo : null; - return match ( - str_contains($message, self::MSG_INTEGRITY_EXCEPTION_1) - || str_contains($message, self::MGS_INTEGRITY_EXCEPTION_2) - || str_contains($message, self::MSG_INTEGRITY_EXCEPTION_3) + if ( + str_contains($message, self::MSG_INTEGRITY_EXCEPTION) + || $this->isOracleIntegrityException($message) ) { - true => new IntegrityException($message, $errorInfo, $this->e), - default => new Exception($message, $errorInfo, $this->e), - }; + return new IntegrityException($message, $errorInfo, $this->e); + } + + if (str_contains($message, self::MSG_CONNECTION_EXCEPTION)) { + return new ConnectionException($message, $errorInfo, $this->e); + } + + return new Exception($message, $errorInfo, $this->e); + } + + private function isOracleIntegrityException(string $message): bool + { + foreach (self::ORACLE_INTEGRITY_EXCEPTIONS as $oracleIntegrityException) { + if (str_contains($message, $oracleIntegrityException)) { + return true; + } + } + + return false; } } diff --git a/tests/Common/CommonCommandTest.php b/tests/Common/CommonCommandTest.php index 456bf64f4..b538d1a56 100644 --- a/tests/Common/CommonCommandTest.php +++ b/tests/Common/CommonCommandTest.php @@ -1626,6 +1626,37 @@ public function testIntegrityViolation(): void $db->close(); } + public function testIntegrityViolationOnForeignKey(): void + { + $db = $this->getSharedConnection(); + $command = $db->createCommand(); + $schema = $db->getSchema(); + + if ($schema->getTableSchema('{{test_int_child}}') !== null) { + $command->dropTable('{{test_int_child}}')->execute(); + } + if ($schema->getTableSchema('{{test_int_parent}}') !== null) { + $command->dropTable('{{test_int_parent}}')->execute(); + } + + $command->createTable('{{test_int_parent}}', ['id' => 'integer not null unique'])->execute(); + $command->createTable( + '{{test_int_child}}', + ['id' => 'integer not null unique', 'parent_id' => 'integer not null'], + )->execute(); + $command->addForeignKey( + '{{test_int_child}}', + '{{test_int_fk}}', + 'parent_id', + '{{test_int_parent}}', + 'id', + )->execute(); + + $this->expectException(IntegrityException::class); + + $command->insert('{{test_int_child}}', ['id' => 1, 'parent_id' => 999])->execute(); + } + public function testNoTablenameReplacement(): void { $db = $this->getSharedConnection(); diff --git a/tests/Db/Exception/ConvertExceptionTest.php b/tests/Db/Exception/ConvertExceptionTest.php index bb742331a..34a445de2 100644 --- a/tests/Db/Exception/ConvertExceptionTest.php +++ b/tests/Db/Exception/ConvertExceptionTest.php @@ -5,8 +5,11 @@ namespace Yiisoft\Db\Tests\Db\Exception; use Exception; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Yiisoft\Db\Exception\ConnectionException; use Yiisoft\Db\Exception\ConvertException; +use Yiisoft\Db\Exception\IntegrityException; use const PHP_EOL; @@ -17,6 +20,14 @@ */ final class ConvertExceptionTest extends TestCase { + #[DataProvider('integrityExceptionMessages')] + public function testIntegrityException(string $message): void + { + $exception = (new ConvertException(new Exception($message), 'INSERT INTO test'))->run(); + + $this->assertInstanceOf(IntegrityException::class, $exception); + } + public function testRun(): void { $e = new Exception('test'); @@ -27,4 +38,53 @@ public function testRun(): void $this->assertSame($e, $exception->getPrevious()); $this->assertSame('test' . PHP_EOL . 'The SQL being executed was: ' . $rawSql, $exception->getMessage()); } + + #[DataProvider('connectionExceptionMessages')] + public function testConnectionException(string $message): void + { + $exception = (new ConvertException(new Exception($message), 'SELECT 1'))->run(); + + $this->assertInstanceOf(ConnectionException::class, $exception); + } + + #[DataProvider('generalExceptionMessages')] + public function testGeneralException(string $message): void + { + $exception = (new ConvertException(new Exception($message), 'SELECT 1'))->run(); + + $this->assertNotInstanceOf(IntegrityException::class, $exception); + $this->assertNotInstanceOf(ConnectionException::class, $exception); + } + + public static function connectionExceptionMessages(): array + { + return [ + 'connection exception' => ['SQLSTATE[08000]: Connection exception'], + 'sqlclient unable to establish connection' => ['SQLSTATE[08001]: SQL-client unable to establish SQL-connection'], + 'connection does not exist' => ['SQLSTATE[08003]: Connection does not exist'], + 'sqlserver rejected connection' => ['SQLSTATE[08004]: SQL server rejected establishment of SQL-connection'], + 'connection failure' => ['SQLSTATE[08006]: Connection failure: 7 no connection to the server'], + ]; + } + + public static function generalExceptionMessages(): array + { + return [ + 'general error' => ['SQLSTATE[HY000]: General error: 7 no connection to the server'], + 'oracle table does not exist' => ['ORA-00942: table or view does not exist'], + ]; + } + + public static function integrityExceptionMessages(): array + { + return [ + 'sqlstate class 23' => ['SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed'], + 'oracle unique constraint' => ['ORA-00001: unique constraint (SYS.PK_ID) violated'], + 'oracle cannot insert null' => ['ORA-01400: cannot insert NULL into ("SYS"."PROFILE"."DESCRIPTION")'], + 'oracle cannot update null' => ['ORA-01407: cannot update ("SYS"."PROFILE"."DESCRIPTION") to NULL'], + 'oracle check constraint' => ['ORA-02290: check constraint (SYS.CK_PROFILE_DESCRIPTION) violated'], + 'oracle parent key not found' => ['ORA-02291: integrity constraint (SYS.FK_PROFILE_CUSTOMER) violated - parent key not found'], + 'oracle child record found' => ['ORA-02292: integrity constraint (SYS.FK_PROFILE_CUSTOMER) violated - child record found'], + ]; + } } From 1f8ae52a1bf339d71c06926d01886bdf706450f2 Mon Sep 17 00:00:00 2001 From: WarLikeLaux Date: Sun, 29 Mar 2026 15:17:59 +0600 Subject: [PATCH 2/6] Fix SQLite foreign key integration test --- tests/Common/CommonCommandTest.php | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/tests/Common/CommonCommandTest.php b/tests/Common/CommonCommandTest.php index b538d1a56..2e694f18f 100644 --- a/tests/Common/CommonCommandTest.php +++ b/tests/Common/CommonCommandTest.php @@ -1642,14 +1642,11 @@ public function testIntegrityViolationOnForeignKey(): void $command->createTable('{{test_int_parent}}', ['id' => 'integer not null unique'])->execute(); $command->createTable( '{{test_int_child}}', - ['id' => 'integer not null unique', 'parent_id' => 'integer not null'], - )->execute(); - $command->addForeignKey( - '{{test_int_child}}', - '{{test_int_fk}}', - 'parent_id', - '{{test_int_parent}}', - 'id', + [ + 'id' => 'integer not null unique', + 'parent_id' => 'integer not null', + 'CONSTRAINT [[test_int_fk]] FOREIGN KEY ([[parent_id]]) REFERENCES {{test_int_parent}} ([[id]])', + ], )->execute(); $this->expectException(IntegrityException::class); From a4831b72c72e8d63cbdea664f5d9324f4f2d1c88 Mon Sep 17 00:00:00 2001 From: WarLikeLaux Date: Sun, 29 Mar 2026 15:22:47 +0600 Subject: [PATCH 3/6] Add PDO command coverage for `ConvertException` --- tests/Common/CommonPdoCommandTest.php | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/Common/CommonPdoCommandTest.php b/tests/Common/CommonPdoCommandTest.php index d38b8d84c..9474d088d 100644 --- a/tests/Common/CommonPdoCommandTest.php +++ b/tests/Common/CommonPdoCommandTest.php @@ -5,9 +5,13 @@ namespace Yiisoft\Db\Tests\Common; use PDO; +use PDOException; use PHPUnit\Framework\Attributes\DataProviderExternal; use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; +use Yiisoft\Db\Exception\ConnectionException; +use Yiisoft\Db\Exception\Exception; +use Yiisoft\Db\Exception\IntegrityException; use Yiisoft\Db\Expression\Value\Param; use Yiisoft\Db\Driver\Pdo\AbstractPdoCommand; use InvalidArgumentException; @@ -15,6 +19,8 @@ use Yiisoft\Db\Tests\Provider\CommandPdoProvider; use Yiisoft\Db\Tests\Support\IntegrationTestCase; +use const PHP_EOL; + abstract class CommonPdoCommandTest extends IntegrationTestCase { #[DataProviderExternal(CommandPdoProvider::class, 'bindParam')] @@ -213,6 +219,40 @@ protected function internalExecute(): void {} $command->testExecute(); } + public function testInternalExecuteConvertsConnectionException(): void + { + $e = $this->executeCommandThrowingPdoException( + 'SELECT 1', + 'SQLSTATE[08006]: Connection failure: 7 no connection to the server', + ); + + $this->assertInstanceOf(ConnectionException::class, $e); + $this->assertInstanceOf(PDOException::class, $e->getPrevious()); + $this->assertSame( + 'SQLSTATE[08006]: Connection failure: 7 no connection to the server' + . PHP_EOL + . 'The SQL being executed was: SELECT 1', + $e->getMessage(), + ); + } + + public function testInternalExecuteConvertsOracleIntegrityException(): void + { + $e = $this->executeCommandThrowingPdoException( + 'INSERT INTO test', + 'ORA-02291: integrity constraint (SYS.FK_PROFILE_CUSTOMER) violated - parent key not found', + ); + + $this->assertInstanceOf(IntegrityException::class, $e); + $this->assertInstanceOf(PDOException::class, $e->getPrevious()); + $this->assertSame( + 'ORA-02291: integrity constraint (SYS.FK_PROFILE_CUSTOMER) violated - parent key not found' + . PHP_EOL + . 'The SQL being executed was: INSERT INTO test', + $e->getMessage(), + ); + } + protected function createQueryLogger(string $sql, array $params = []): LoggerInterface { $logger = $this->createMock(LoggerInterface::class); @@ -226,4 +266,34 @@ protected function createQueryLogger(string $sql, array $params = []): LoggerInt ); return $logger; } + + private function executeCommandThrowingPdoException(string $sql, string $message): Exception + { + $command = new class ($this->getSharedConnection(), $message) extends AbstractPdoCommand { + public function __construct($db, private string $message) + { + parent::__construct($db); + } + + public function testExecute(): void + { + $this->internalExecute(); + } + + protected function pdoStatementExecute(): void + { + throw new PDOException($this->message); + } + }; + + $command->setSql($sql); + + try { + $command->testExecute(); + } catch (Exception $e) { + return $e; + } + + $this->fail(); + } } From 48aa8b9df7e8ba70882038d0af42f42fb5dc9144 Mon Sep 17 00:00:00 2001 From: WarLikeLaux Date: Sun, 29 Mar 2026 15:24:26 +0600 Subject: [PATCH 4/6] Fix anonymous PDO command test stub --- tests/Common/CommonPdoCommandTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Common/CommonPdoCommandTest.php b/tests/Common/CommonPdoCommandTest.php index 9474d088d..4622697fe 100644 --- a/tests/Common/CommonPdoCommandTest.php +++ b/tests/Common/CommonPdoCommandTest.php @@ -275,6 +275,11 @@ public function __construct($db, private string $message) parent::__construct($db); } + public function showDatabases(): array + { + return $this->showDatabases(); + } + public function testExecute(): void { $this->internalExecute(); From c089b4e089246fb1d0316765e8db9ec445c715a3 Mon Sep 17 00:00:00 2001 From: WarLikeLaux Date: Sun, 29 Mar 2026 15:35:46 +0600 Subject: [PATCH 5/6] Fix #1169: Restore MySQL timeout reconnect mapping --- src/Exception/ConvertException.php | 16 ++++++++++++++++ tests/Common/CommonCommandTest.php | 6 +++++- tests/Common/CommonPdoCommandTest.php | 17 +++++++++++++++++ tests/Db/Exception/ConvertExceptionTest.php | 4 ++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/Exception/ConvertException.php b/src/Exception/ConvertException.php index 8d6b27bc3..0c65b85ad 100644 --- a/src/Exception/ConvertException.php +++ b/src/Exception/ConvertException.php @@ -18,6 +18,10 @@ final class ConvertException { private const MSG_CONNECTION_EXCEPTION = 'SQLSTATE[08'; private const MSG_INTEGRITY_EXCEPTION = 'SQLSTATE[23'; + private const MYSQL_RECONNECT_EXCEPTIONS = [ + 'SQLSTATE[HY000]: General error: 2006 ', + 'SQLSTATE[HY000]: General error: 4031 ', + ]; private const ORACLE_INTEGRITY_EXCEPTIONS = [ 'ORA-00001:', 'ORA-01400:', @@ -45,6 +49,7 @@ public function run(): Exception if ( str_contains($message, self::MSG_INTEGRITY_EXCEPTION) + || $this->isMysqlReconnectException($message) || $this->isOracleIntegrityException($message) ) { return new IntegrityException($message, $errorInfo, $this->e); @@ -57,6 +62,17 @@ public function run(): Exception return new Exception($message, $errorInfo, $this->e); } + private function isMysqlReconnectException(string $message): bool + { + foreach (self::MYSQL_RECONNECT_EXCEPTIONS as $mysqlReconnectException) { + if (str_contains($message, $mysqlReconnectException)) { + return true; + } + } + + return false; + } + private function isOracleIntegrityException(string $message): bool { foreach (self::ORACLE_INTEGRITY_EXCEPTIONS as $oracleIntegrityException) { diff --git a/tests/Common/CommonCommandTest.php b/tests/Common/CommonCommandTest.php index 2e694f18f..436a9f58f 100644 --- a/tests/Common/CommonCommandTest.php +++ b/tests/Common/CommonCommandTest.php @@ -1628,10 +1628,14 @@ public function testIntegrityViolation(): void public function testIntegrityViolationOnForeignKey(): void { - $db = $this->getSharedConnection(); + $db = $this->createConnection(); $command = $db->createCommand(); $schema = $db->getSchema(); + if ($db->getDriverName() === 'sqlite') { + $db->createCommand('PRAGMA foreign_keys = ON')->execute(); + } + if ($schema->getTableSchema('{{test_int_child}}') !== null) { $command->dropTable('{{test_int_child}}')->execute(); } diff --git a/tests/Common/CommonPdoCommandTest.php b/tests/Common/CommonPdoCommandTest.php index 4622697fe..a4fa19fad 100644 --- a/tests/Common/CommonPdoCommandTest.php +++ b/tests/Common/CommonPdoCommandTest.php @@ -253,6 +253,23 @@ public function testInternalExecuteConvertsOracleIntegrityException(): void ); } + public function testInternalExecuteKeepsMysqlReconnectExceptionAsIntegrityException(): void + { + $e = $this->executeCommandThrowingPdoException( + 'SELECT 1', + 'SQLSTATE[HY000]: General error: 2006 MySQL server has gone away', + ); + + $this->assertInstanceOf(IntegrityException::class, $e); + $this->assertInstanceOf(PDOException::class, $e->getPrevious()); + $this->assertSame( + 'SQLSTATE[HY000]: General error: 2006 MySQL server has gone away' + . PHP_EOL + . 'The SQL being executed was: SELECT 1', + $e->getMessage(), + ); + } + protected function createQueryLogger(string $sql, array $params = []): LoggerInterface { $logger = $this->createMock(LoggerInterface::class); diff --git a/tests/Db/Exception/ConvertExceptionTest.php b/tests/Db/Exception/ConvertExceptionTest.php index 34a445de2..0a65110c4 100644 --- a/tests/Db/Exception/ConvertExceptionTest.php +++ b/tests/Db/Exception/ConvertExceptionTest.php @@ -78,6 +78,10 @@ public static function generalExceptionMessages(): array public static function integrityExceptionMessages(): array { return [ + 'mysql server has gone away' => ['SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'], + 'mysql server disconnected inactive client' => [ + 'SQLSTATE[HY000]: General error: 4031 The client was disconnected by the server because of inactivity.', + ], 'sqlstate class 23' => ['SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed'], 'oracle unique constraint' => ['ORA-00001: unique constraint (SYS.PK_ID) violated'], 'oracle cannot insert null' => ['ORA-01400: cannot insert NULL into ("SYS"."PROFILE"."DESCRIPTION")'], From 2c06a9d56c160e0dd84f1e732817463d7879c671 Mon Sep 17 00:00:00 2001 From: WarLikeLaux Date: Sun, 29 Mar 2026 15:45:34 +0600 Subject: [PATCH 6/6] Keep Oracle migration compatibility in `ConvertException` --- src/Exception/ConvertException.php | 15 +++++++++++++++ tests/Common/CommonPdoCommandTest.php | 17 +++++++++++++++++ tests/Db/Exception/ConvertExceptionTest.php | 2 +- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/Exception/ConvertException.php b/src/Exception/ConvertException.php index 0c65b85ad..1eee3f93d 100644 --- a/src/Exception/ConvertException.php +++ b/src/Exception/ConvertException.php @@ -22,6 +22,9 @@ final class ConvertException 'SQLSTATE[HY000]: General error: 2006 ', 'SQLSTATE[HY000]: General error: 4031 ', ]; + private const ORACLE_COMPATIBILITY_EXCEPTIONS = [ + 'ORA-00942:', + ]; private const ORACLE_INTEGRITY_EXCEPTIONS = [ 'ORA-00001:', 'ORA-01400:', @@ -50,6 +53,7 @@ public function run(): Exception if ( str_contains($message, self::MSG_INTEGRITY_EXCEPTION) || $this->isMysqlReconnectException($message) + || $this->isOracleCompatibilityException($message) || $this->isOracleIntegrityException($message) ) { return new IntegrityException($message, $errorInfo, $this->e); @@ -73,6 +77,17 @@ private function isMysqlReconnectException(string $message): bool return false; } + private function isOracleCompatibilityException(string $message): bool + { + foreach (self::ORACLE_COMPATIBILITY_EXCEPTIONS as $oracleCompatibilityException) { + if (str_contains($message, $oracleCompatibilityException)) { + return true; + } + } + + return false; + } + private function isOracleIntegrityException(string $message): bool { foreach (self::ORACLE_INTEGRITY_EXCEPTIONS as $oracleIntegrityException) { diff --git a/tests/Common/CommonPdoCommandTest.php b/tests/Common/CommonPdoCommandTest.php index a4fa19fad..0ef8ed235 100644 --- a/tests/Common/CommonPdoCommandTest.php +++ b/tests/Common/CommonPdoCommandTest.php @@ -253,6 +253,23 @@ public function testInternalExecuteConvertsOracleIntegrityException(): void ); } + public function testInternalExecuteKeepsOracleMigrationExceptionAsIntegrityException(): void + { + $e = $this->executeCommandThrowingPdoException( + 'DROP TABLE test', + 'ORA-00942: table or view does not exist', + ); + + $this->assertInstanceOf(IntegrityException::class, $e); + $this->assertInstanceOf(PDOException::class, $e->getPrevious()); + $this->assertSame( + 'ORA-00942: table or view does not exist' + . PHP_EOL + . 'The SQL being executed was: DROP TABLE test', + $e->getMessage(), + ); + } + public function testInternalExecuteKeepsMysqlReconnectExceptionAsIntegrityException(): void { $e = $this->executeCommandThrowingPdoException( diff --git a/tests/Db/Exception/ConvertExceptionTest.php b/tests/Db/Exception/ConvertExceptionTest.php index 0a65110c4..2f69cca47 100644 --- a/tests/Db/Exception/ConvertExceptionTest.php +++ b/tests/Db/Exception/ConvertExceptionTest.php @@ -71,7 +71,6 @@ public static function generalExceptionMessages(): array { return [ 'general error' => ['SQLSTATE[HY000]: General error: 7 no connection to the server'], - 'oracle table does not exist' => ['ORA-00942: table or view does not exist'], ]; } @@ -89,6 +88,7 @@ public static function integrityExceptionMessages(): array 'oracle check constraint' => ['ORA-02290: check constraint (SYS.CK_PROFILE_DESCRIPTION) violated'], 'oracle parent key not found' => ['ORA-02291: integrity constraint (SYS.FK_PROFILE_CUSTOMER) violated - parent key not found'], 'oracle child record found' => ['ORA-02292: integrity constraint (SYS.FK_PROFILE_CUSTOMER) violated - child record found'], + 'oracle table does not exist for migration compatibility' => ['ORA-00942: table or view does not exist'], ]; } }