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->isMysqlReconnectException($message) + || $this->isOracleCompatibilityException($message) + || $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 isMysqlReconnectException(string $message): bool + { + foreach (self::MYSQL_RECONNECT_EXCEPTIONS as $mysqlReconnectException) { + if (str_contains($message, $mysqlReconnectException)) { + return true; + } + } + + 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) { + if (str_contains($message, $oracleIntegrityException)) { + return true; + } + } + + return false; } } diff --git a/tests/Common/CommonCommandTest.php b/tests/Common/CommonCommandTest.php index 456bf64f4..436a9f58f 100644 --- a/tests/Common/CommonCommandTest.php +++ b/tests/Common/CommonCommandTest.php @@ -1626,6 +1626,38 @@ public function testIntegrityViolation(): void $db->close(); } + public function testIntegrityViolationOnForeignKey(): void + { + $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(); + } + 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', + 'CONSTRAINT [[test_int_fk]] FOREIGN KEY ([[parent_id]]) REFERENCES {{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/Common/CommonPdoCommandTest.php b/tests/Common/CommonPdoCommandTest.php index d38b8d84c..0ef8ed235 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,74 @@ 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(), + ); + } + + 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( + '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); @@ -226,4 +300,39 @@ 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 showDatabases(): array + { + return $this->showDatabases(); + } + + 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(); + } } diff --git a/tests/Db/Exception/ConvertExceptionTest.php b/tests/Db/Exception/ConvertExceptionTest.php index bb742331a..2f69cca47 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,57 @@ 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'], + ]; + } + + 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")'], + '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'], + 'oracle table does not exist for migration compatibility' => ['ORA-00942: table or view does not exist'], + ]; + } }