Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions src/Exception/ConnectionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Db\Exception;

/**
* Represents an exception caused by a database connection failure.
*/
final class ConnectionException extends Exception {}
73 changes: 63 additions & 10 deletions src/Exception/ConvertException.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,23 @@
*/
final class ConvertException
{
private const MSG_INTEGRITY_EXCEPTION_1 = 'SQLSTATE[23';
private const MGS_INTEGRITY_EXCEPTION_2 = 'ORA-00001: unique constraint';
private const MSG_INTEGRITY_EXCEPTION_3 = 'SQLSTATE[HY';
private const MSG_CONNECTION_EXCEPTION = 'SQLSTATE[08';
private const MSG_INTEGRITY_EXCEPTION = 'SQLSTATE[23';
private const MYSQL_RECONNECT_EXCEPTIONS = [
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why only MYSQL? It could be PostgreSQL also

'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:',
'ORA-01407:',
'ORA-02290:',
'ORA-02291:',
'ORA-02292:',
];

public function __construct(
private readonly \Exception $e,
Expand All @@ -36,13 +50,52 @@ public function run(): Exception

$errorInfo = $this->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;
}
}
32 changes: 32 additions & 0 deletions tests/Common/CommonCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
109 changes: 109 additions & 0 deletions tests/Common/CommonPdoCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@
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;
use Yiisoft\Db\QueryBuilder\QueryBuilderInterface;
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')]
Expand Down Expand Up @@ -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);
Expand All @@ -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();
}
}
64 changes: 64 additions & 0 deletions tests/Db/Exception/ConvertExceptionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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');
Expand All @@ -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.',
],
Comment on lines +80 to +83
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These also should be converted to ConnectionException

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I agree, these cases should become ConnectionException. The problem is that the current CI matrix still installs yiisoft/db-mysql and yiisoft/db-migration, and they rely on the current IntegrityException behavior for these paths. If I switch HY000 2006/4031 here now, the MySQL and MariaDB jobs fail. Could you please advise what would be the preferred way to proceed?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It requires to add changes in these packages too. The branch name should be the same

'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'],
];
}
}
Loading