From 2a3a417891e607a76942c852cb711e4c0dfb6fd4 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 17 Sep 2026 20:13:05 +0400 Subject: [PATCH 1/6] fix(Driver): classify exceptions by SQLSTATE before the message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(MySQL): report the integrity violations mysql files under HY000 fix(SQLServer): classify a failed connection in create() test(Driver): cover exception mapping across all four drivers Every driver read its needle list over the message before looking at the error code, so any value the server echoes back could decide the class: a uuid containing `0800` or a table named `connections` turned a constraint violation into a ConnectionException, which made `Driver::statement()` drop the connection and re-run the statement while application code catching ConstrainException saw nothing. SQLSTATE now decides, and the needles are consulted only for the states PDO invents itself (`HY`, `IM`) — a dropped socket arrives as HY000 with the reason only in the text. This also repairs reconnect on a localized ODBC install, where the English needles matched nothing at all. Comparing the state as a string rather than casting it to an int fixes Postgres `23P01`: `(int) '23P01'` is 23, which fell below the `>= 23000` test, so exclusion violations arrived as the base StatementException. Mysql leaves two integrity violations outside class 23 — a CHECK the row failed and a required column left out of the statement — which Postgres reports as 23514 and 23502 and SQL Server as 23000. They are now matched by error number, listed one by one because their HY000 neighbours are DDL errors and coercion failures. `SQLServerDriver::create()` reaches the server to reject a version it cannot compile for, which makes it the one place a connection failure happens outside `Driver::statement()`; it let the raw PDOException escape instead of classifying it. Closes #268 Assisted-By: Claude Opus 5 --- src/Driver/Driver.php | 50 +++ src/Driver/MySQL/MySQLDriver.php | 75 +++- src/Driver/Postgres/PostgresDriver.php | 43 ++- src/Driver/SQLServer/SQLServerDriver.php | 44 ++- src/Driver/SQLite/SQLiteDriver.php | 2 +- .../Driver/Common/Query/ExceptionsTest.php | 25 ++ .../Driver/MySQL/Query/ExceptionsTest.php | 25 ++ .../Connection/ConnectionExceptionTest.php | 6 +- .../Driver/Postgres/Query/ExceptionsTest.php | 16 + .../Connection/ConnectionExceptionTest.php | 5 +- .../Driver/SQLServer/Driver/DriverTest.php | 20 ++ .../Database/Unit/Driver/MapExceptionTest.php | 327 ++++++++++++++++++ 12 files changed, 597 insertions(+), 41 deletions(-) create mode 100644 tests/Database/Unit/Driver/MapExceptionTest.php diff --git a/src/Driver/Driver.php b/src/Driver/Driver.php index 852239ac..fc63450d 100644 --- a/src/Driver/Driver.php +++ b/src/Driver/Driver.php @@ -446,6 +446,47 @@ public function __destruct() $this->disconnect(); } + /** + * Read the SQLSTATE of a driver exception, or `null` when it carries none. + * + * PDO reports the state in three places that do not agree with each other: connection-time + * failures put the driver-specific number in `getCode()` and the SQLSTATE only in `errorInfo` + * and the message prefix, while statement failures put the SQLSTATE in all three. Reading + * `errorInfo` first and falling back to the prefix covers both. + */ + protected static function getSqlState(\Throwable $exception): ?string + { + $errorInfo = $exception instanceof \PDOException ? $exception->errorInfo : null; + + if (\is_array($errorInfo)) { + $state = self::toSqlState($errorInfo[0] ?? null); + + if ($state !== null) { + return $state; + } + } + + if (\preg_match('/^SQLSTATE\[([0-9A-Za-z]{5})]/', $exception->getMessage(), $matches) === 1) { + return $matches[1]; + } + + return self::toSqlState($exception->getCode()); + } + + /** + * Whether a SQLSTATE is one PDO invented rather than one the server reported. + * + * Classes `HY` and `IM` come from the ODBC/PDO layer — a dropped socket surfaces as `HY000` + * with no server classification behind it, so these are the only states a caller may second + * guess by inspecting the message. + */ + protected static function isGenericSqlState(?string $sqlState): bool + { + return $sqlState === null + || \str_starts_with($sqlState, 'HY') + || \str_starts_with($sqlState, 'IM'); + } + /** * Create instance of PDOStatement using provided SQL query and set of parameters and execute * it. Will attempt singular reconnect. @@ -706,4 +747,13 @@ protected function defineLoggerContext(float $queryStart, \PDOStatement|PDOState return $context; } + + /** + * A SQLSTATE is five alphanumerics; anything else in the slot is a driver-specific number that + * happens to share it. + */ + private static function toSqlState(mixed $value): ?string + { + return \is_string($value) && \strlen($value) === 5 && \ctype_alnum($value) ? $value : null; + } } diff --git a/src/Driver/MySQL/MySQLDriver.php b/src/Driver/MySQL/MySQLDriver.php index 01d02eb1..fbb449c5 100644 --- a/src/Driver/MySQL/MySQLDriver.php +++ b/src/Driver/MySQL/MySQLDriver.php @@ -26,6 +26,19 @@ */ class MySQLDriver extends Driver { + /** + * Integrity violations mysql files under the generic HY000 instead of class 23: a required + * column left out of the statement, and a row the CHECK rejected. Postgres reports the same + * two as 23502 and 23514, and SQL Server as 23000. + * + * Listed one by one rather than as a range, because their HY000 neighbours are DDL errors and + * type coercion failures that are not constraint violations at all. + */ + private const CONSTRAINT_ERRNOS = [ + 1364, // ER_NO_DEFAULT_FOR_FIELD + 3819, // ER_CHECK_CONSTRAINT_VIOLATED + ]; + /** * @param MySQLDriverConfig $config */ @@ -64,29 +77,63 @@ public function getTransactionLevel(): int } /** - * - * - * @see https://dev.mysql.com/doc/refman/5.6/en/error-messages-client.html#error_cr_conn_host_error + * @see https://dev.mysql.com/doc/refman/8.4/en/client-error-reference.html */ protected function mapException(\Throwable $exception, string $query): StatementException { - if ((int) $exception->getCode() === 23000) { - return new StatementException\ConstrainException($exception, $query); + $sqlState = self::getSqlState($exception); + + if ($sqlState !== null) { + // 08S01 — communication link failure. + if (\str_starts_with($sqlState, '08')) { + return new StatementException\ConnectionException($exception, $query); + } + + if (\str_starts_with($sqlState, '23')) { + return new StatementException\ConstrainException($exception, $query); + } } - $message = \strtolower($exception->getMessage()); + $errno = self::getErrno($exception); - if ( - \str_contains($message, 'server has gone away') - || \str_contains($message, 'broken pipe') - || \str_contains($message, 'connection') - || \str_contains($message, 'packets out of order') - || \str_contains($message, 'disconnected by the server because of inactivity') - || ((int) $exception->getCode() > 2000 && (int) $exception->getCode() < 2100) - ) { + if (\in_array($errno, self::CONSTRAINT_ERRNOS, true)) { + return new StatementException\ConstrainException($exception, $query); + } + + // 2000-2100 is the CR_* range the client library raises when it loses the socket itself, + // and it never overlaps the server's own error numbers. + if ($errno > 2000 && $errno < 2100) { return new StatementException\ConnectionException($exception, $query); } + // Last resort, and only for the states PDO made up: a server error the driver did classify + // prints user data (a duplicate key value, a table name) that these needles would match. + if (self::isGenericSqlState($sqlState)) { + $message = \strtolower($exception->getMessage()); + + if ( + \str_contains($message, 'server has gone away') + || \str_contains($message, 'broken pipe') + || \str_contains($message, 'connection') + || \str_contains($message, 'packets out of order') + || \str_contains($message, 'disconnected by the server because of inactivity') + ) { + return new StatementException\ConnectionException($exception, $query); + } + } + return new StatementException($exception, $query); } + + /** + * The mysql error number, which sits next to the SQLSTATE in `errorInfo` and moves into + * `getCode()` only when the failure predates any statement — a connect attempt that never + * reached a server has no SQLSTATE to put there instead. + */ + private static function getErrno(\Throwable $exception): int + { + $errorInfo = $exception instanceof \PDOException ? $exception->errorInfo : null; + + return (int) (\is_array($errorInfo) ? $errorInfo[1] ?? $exception->getCode() : $exception->getCode()); + } } diff --git a/src/Driver/Postgres/PostgresDriver.php b/src/Driver/Postgres/PostgresDriver.php index 64d00eb5..eae2a7be 100644 --- a/src/Driver/Postgres/PostgresDriver.php +++ b/src/Driver/Postgres/PostgresDriver.php @@ -304,20 +304,39 @@ protected function createPDO(): \PDO|PDOInterface protected function mapException(\Throwable $exception, string $query): StatementException { - $message = \strtolower($exception->getMessage()); - - if ( - \str_contains($message, 'eof detected') - || \str_contains($message, 'broken pipe') - || \str_contains($message, '0800') - || \str_contains($message, '080p') - || \str_contains($message, 'connection') - ) { - return new StatementException\ConnectionException($exception, $query); + $sqlState = self::getSqlState($exception); + + if ($sqlState !== null) { + // Class 08 is `connection_exception`; the 57P0x states and `too_many_connections` are + // the server refusing or tearing down the session rather than rejecting the statement. + if ( + \str_starts_with($sqlState, '08') + || \in_array($sqlState, ['53300', '57P01', '57P02', '57P03'], true) + ) { + return new StatementException\ConnectionException($exception, $query); + } + + // Class 23 is `integrity_constraint_violation`. Compared as a string so that `23P01` + // (exclusion violation) is not truncated to 23 the way a numeric cast leaves it. + if (\str_starts_with($sqlState, '23')) { + return new StatementException\ConstrainException($exception, $query); + } } - if ((int) $exception->getCode() >= 23000 && (int) $exception->getCode() < 24000) { - return new StatementException\ConstrainException($exception, $query); + // A socket the server or a pooler dropped mid-statement arrives as HY000 with the reason + // only in the text. The message is never consulted for a state the server did classify: + // Postgres prints the offending row in DETAIL, and a uuid or an email in it would otherwise + // match these needles and turn a data error into a reconnect. + if (self::isGenericSqlState($sqlState)) { + $message = \strtolower($exception->getMessage()); + + if ( + \str_contains($message, 'eof detected') + || \str_contains($message, 'broken pipe') + || \str_contains($message, 'connection') + ) { + return new StatementException\ConnectionException($exception, $query); + } } return new StatementException($exception, $query); diff --git a/src/Driver/SQLServer/SQLServerDriver.php b/src/Driver/SQLServer/SQLServerDriver.php index 2ce79576..8b81af62 100644 --- a/src/Driver/SQLServer/SQLServerDriver.php +++ b/src/Driver/SQLServer/SQLServerDriver.php @@ -53,7 +53,16 @@ public static function create(DriverConfig $config): static ), ); - if ((int) $driver->getPDO()->getAttribute(\PDO::ATTR_SERVER_VERSION) < 12) { + // Alone among the drivers this one reaches the server before any query, to reject a version + // it cannot compile for. That makes it the only place where a bad host surfaces outside + // Driver::statement(), so the failure is classified here instead of escaping raw. + try { + $version = (int) $driver->getPDO()->getAttribute(\PDO::ATTR_SERVER_VERSION); + } catch (\Throwable $e) { + throw $driver->mapException($e, 'CONNECT'); + } + + if ($version < 12) { throw new DriverException('SQLServer driver supports only 12+ version of SQLServer'); } @@ -250,19 +259,34 @@ protected function rollbackSavepoint(int $level): void protected function mapException(\Throwable $exception, string $query): StatementException { - $message = \strtolower($exception->getMessage()); + $sqlState = self::getSqlState($exception); + if ($sqlState !== null) { + // Class 08 covers both the ODBC driver failing to reach the server (08001) and the + // link dropping under an open session (08S01). + if (\str_starts_with($sqlState, '08')) { + return new StatementException\ConnectionException($exception, $query); + } - if ( - \str_contains($message, '0800') - || \str_contains($message, '080p') - || \str_contains($message, 'connection') - ) { - return new StatementException\ConnectionException($exception, $query); + if (\str_starts_with($sqlState, '23')) { + return new StatementException\ConstrainException($exception, $query); + } } - if ((int) $exception->getCode() === 23000) { - return new StatementException\ConstrainException($exception, $query); + // The message is a last resort, not the first test: SQL Server names the conflicting table + // and prints the duplicate key value, so a table called `connections` or a uuid holding + // `0800` used to be enough to report a constraint violation as a dropped link. The ODBC + // driver also translates these strings, which leaves the needles matching nothing at all + // on a localized install. + if (self::isGenericSqlState($sqlState)) { + $message = \strtolower($exception->getMessage()); + + if ( + \str_contains($message, 'broken pipe') + || \str_contains($message, 'connection') + ) { + return new StatementException\ConnectionException($exception, $query); + } } return new StatementException($exception, $query); diff --git a/src/Driver/SQLite/SQLiteDriver.php b/src/Driver/SQLite/SQLiteDriver.php index d962f340..c059067f 100644 --- a/src/Driver/SQLite/SQLiteDriver.php +++ b/src/Driver/SQLite/SQLiteDriver.php @@ -99,7 +99,7 @@ public function cursor( protected function mapException(\Throwable $exception, string $query): StatementException { - if ((int) $exception->getCode() === 23000) { + if (\str_starts_with(self::getSqlState($exception) ?? '', '23')) { return new StatementException\ConstrainException($exception, $query); } diff --git a/tests/Database/Functional/Driver/Common/Query/ExceptionsTest.php b/tests/Database/Functional/Driver/Common/Query/ExceptionsTest.php index d9dcff2e..baac1a0f 100644 --- a/tests/Database/Functional/Driver/Common/Query/ExceptionsTest.php +++ b/tests/Database/Functional/Driver/Common/Query/ExceptionsTest.php @@ -66,4 +66,29 @@ public function testInsertNotNullable(): void $this->assertInstanceOf(StatementException\ConstrainException::class, $e); } } + + public function testConstrainExceptionWhenTheValueLooksLikeAConnectionError(): void + { + $schema = $this->database->getDriver()->getSchema('test'); + $schema->primary('id'); + $schema->string('value', 64); + $schema->index(['value'])->unique(true); + $schema->save(); + + // Every driver echoes the rejected value back in the error message, where `0800` used to + // be read as the SQLSTATE class of a dropped connection. + $value = 'f5a31835-fae5-43eb-8efa-cce00b90800a'; + + $this->database->getDriver() + ->insertQuery('', 'test') + ->values(['value' => $value]) + ->run(); + + $this->expectException(StatementException\ConstrainException::class); + + $this->database->getDriver() + ->insertQuery('', 'test') + ->values(['value' => $value]) + ->run(); + } } diff --git a/tests/Database/Functional/Driver/MySQL/Query/ExceptionsTest.php b/tests/Database/Functional/Driver/MySQL/Query/ExceptionsTest.php index 06c1e00b..b19360ce 100644 --- a/tests/Database/Functional/Driver/MySQL/Query/ExceptionsTest.php +++ b/tests/Database/Functional/Driver/MySQL/Query/ExceptionsTest.php @@ -4,6 +4,7 @@ namespace Cycle\Database\Tests\Functional\Driver\MySQL\Query; +use Cycle\Database\Exception\StatementException; // phpcs:ignore use Cycle\Database\Tests\Functional\Driver\Common\Query\ExceptionsTest as CommonClass; use Spiral\Database\Exception\StatementException\ConnectionException; @@ -31,4 +32,28 @@ public function testPacketsOutOfOrderConsideredAsConnectionException(): void return; } } + + public function testCheckViolationIsAConstrainException(): void + { + $driver = $this->database->getDriver(); + + // Raw SQL because the schema builder has no CHECK support, and mysql reports the violation + // as HY000 rather than class 23 — the pair is why this case needs a driver-specific test. + $driver->execute('CREATE TABLE test (id int PRIMARY KEY, pos int, CONSTRAINT c_pos CHECK (pos > 0))'); + + $this->expectException(StatementException\ConstrainException::class); + + $driver->execute('INSERT INTO test VALUES (1, -1)'); + } + + public function testMissingRequiredColumnIsAConstrainException(): void + { + $driver = $this->database->getDriver(); + + $driver->execute('CREATE TABLE test (id int PRIMARY KEY, value varchar(8) NOT NULL)'); + + $this->expectException(StatementException\ConstrainException::class); + + $driver->execute('INSERT INTO test (id) VALUES (1)'); + } } diff --git a/tests/Database/Functional/Driver/Postgres/Connection/ConnectionExceptionTest.php b/tests/Database/Functional/Driver/Postgres/Connection/ConnectionExceptionTest.php index cd04e6ae..4f9e7a19 100644 --- a/tests/Database/Functional/Driver/Postgres/Connection/ConnectionExceptionTest.php +++ b/tests/Database/Functional/Driver/Postgres/Connection/ConnectionExceptionTest.php @@ -23,11 +23,13 @@ public function reconnectableExceptionsProvider(): iterable return [ [new \Exception('eof detected')], [new \Exception('broken pipe')], - [new \Exception('0800')], - [new \Exception('080P')], [new \Exception('Bad connection')], /** Case from {@link https://github.com/cycle/database/issues/75} */ [new \Exception('server closed the connection unexpectedly')], + [new \Exception('SQLSTATE[08006] [7] connection to server at "127.0.0.1", port 5432 failed')], + [new \Exception('SQLSTATE[08P01]: Protocol violation: 7 ERROR: invalid message format')], + [new \Exception('SQLSTATE[57P01]: Admin shutdown: 7 FATAL: terminating connection due to administrator command')], + [new \Exception('SQLSTATE[53300]: Too many connections: 7 FATAL: sorry, too many clients already')], ]; } } diff --git a/tests/Database/Functional/Driver/Postgres/Query/ExceptionsTest.php b/tests/Database/Functional/Driver/Postgres/Query/ExceptionsTest.php index 02ea2fd1..f525b30e 100644 --- a/tests/Database/Functional/Driver/Postgres/Query/ExceptionsTest.php +++ b/tests/Database/Functional/Driver/Postgres/Query/ExceptionsTest.php @@ -4,6 +4,7 @@ namespace Cycle\Database\Tests\Functional\Driver\Postgres\Query; +use Cycle\Database\Exception\StatementException; // phpcs:ignore use Cycle\Database\Tests\Functional\Driver\Common\Query\ExceptionsTest as CommonClass; @@ -14,4 +15,19 @@ class ExceptionsTest extends CommonClass { public const DRIVER = 'postgres'; + + public function testExclusionViolationIsAConstrainException(): void + { + $driver = $this->database->getDriver(); + + // Exclusion constraints have no schema builder, and `23P01` is the one state in class 23 + // that is not a number — the pair is why this case needs a driver-specific test. A range + // column keeps it to the built-in gist opclasses, with no btree_gist to install. + $driver->execute('CREATE TABLE test (id int, span int4range, EXCLUDE USING gist (span WITH &&))'); + $driver->execute("INSERT INTO test VALUES (1, '[1,10)')"); + + $this->expectException(StatementException\ConstrainException::class); + + $driver->execute("INSERT INTO test VALUES (2, '[5,15)')"); + } } diff --git a/tests/Database/Functional/Driver/SQLServer/Connection/ConnectionExceptionTest.php b/tests/Database/Functional/Driver/SQLServer/Connection/ConnectionExceptionTest.php index b61c41c1..685706f3 100644 --- a/tests/Database/Functional/Driver/SQLServer/Connection/ConnectionExceptionTest.php +++ b/tests/Database/Functional/Driver/SQLServer/Connection/ConnectionExceptionTest.php @@ -21,9 +21,10 @@ class ConnectionExceptionTest extends CommonClass public function reconnectableExceptionsProvider(): iterable { return [ - [new \Exception('0800')], - [new \Exception('080P')], [new \Exception('Bad connection')], + [new \Exception('SQLSTATE[08001]: [Microsoft][ODBC Driver 18 for SQL Server]TCP Provider: No connection could be made')], + /** The ODBC driver translates its own strings, so only the SQLSTATE is dependable. */ + [new \Exception('SQLSTATE[08S01]: [Microsoft][ODBC Driver 18 for SQL Server]Поставщик TCP: Удаленный хост принудительно разорвал существующее подключение.')], ]; } } diff --git a/tests/Database/Functional/Driver/SQLServer/Driver/DriverTest.php b/tests/Database/Functional/Driver/SQLServer/Driver/DriverTest.php index 49bab866..264efe78 100644 --- a/tests/Database/Functional/Driver/SQLServer/Driver/DriverTest.php +++ b/tests/Database/Functional/Driver/SQLServer/Driver/DriverTest.php @@ -4,6 +4,9 @@ namespace Cycle\Database\Tests\Functional\Driver\SQLServer\Driver; +use Cycle\Database\Config\DriverConfig; +use Cycle\Database\Driver\SQLServer\SQLServerDriver; +use Cycle\Database\Exception\StatementException; // phpcs:ignore use Cycle\Database\Tests\Functional\Driver\Common\Driver\DriverTest as CommonClass; @@ -14,4 +17,21 @@ class DriverTest extends CommonClass { public const DRIVER = 'sqlserver'; + + public function testCreateReportsAFailedConnectionAsALibraryException(): void + { + $config = clone self::$config[static::DRIVER]; + \assert($config instanceof DriverConfig); + + $connection = clone $config->connection; + $connection->password = 'definitely not the password'; + $config->connection = $connection; + + // create() reaches the server to read its version, which makes it the one place a + // connection failure happens outside Driver::statement() and so the one place that has to + // classify the failure itself. + $this->expectException(StatementException::class); + + SQLServerDriver::create($config); + } } diff --git a/tests/Database/Unit/Driver/MapExceptionTest.php b/tests/Database/Unit/Driver/MapExceptionTest.php new file mode 100644 index 00000000..1f18ec7c --- /dev/null +++ b/tests/Database/Unit/Driver/MapExceptionTest.php @@ -0,0 +1,327 @@ + [ + '23514', + "SQLSTATE[23514]: Check violation: 7 ERROR: new row for relation \"pd_destruction_log\" violates check constraint \"pd_destruction_log_reason_check\"\nDETAIL: Failing row contains (f5a31835-fae5-43eb-8efa-cce00b90800a, whim).", + StatementException\ConstrainException::class, + ]; + yield 'unique violation, key holding "connection"' => [ + '23505', + "SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value violates unique constraint \"users_email_key\"\nDETAIL: Key (email)=(no.connection@example.com) already exists.", + StatementException\ConstrainException::class, + ]; + yield 'exclusion violation is class 23 but not a number' => [ + '23P01', + "SQLSTATE[23P01]: Exclusion violation: 7 ERROR: conflicting key value violates exclusion constraint \"t_r_excl\"\nDETAIL: Key (r)=([5,15)) conflicts with existing key (r)=([1,10)).", + StatementException\ConstrainException::class, + ]; + yield 'not null violation' => [ + '23502', + 'SQLSTATE[23502]: Not null violation: 7 ERROR: null value in column "value" of relation "test" violates not-null constraint', + StatementException\ConstrainException::class, + ]; + yield 'admin shutdown' => [ + '57P01', + 'SQLSTATE[57P01]: Admin shutdown: 7 FATAL: terminating connection due to administrator command', + StatementException\ConnectionException::class, + ]; + yield 'too many connections' => [ + '53300', + 'SQLSTATE[53300]: Too many connections: 7 FATAL: sorry, too many clients already', + StatementException\ConnectionException::class, + ]; + yield 'undefined table whose name contains "connections"' => [ + '42P01', + 'SQLSTATE[42P01]: Undefined table: 7 ERROR: relation "no_such_connections_table" does not exist', + StatementException::class, + ]; + yield 'invalid uuid syntax, value holding 0800' => [ + '22P02', + 'SQLSTATE[22P02]: Invalid text representation: 7 ERROR: invalid input syntax for type uuid: "f5a31835-0800-nope"', + StatementException::class, + ]; + + // Connection failures: libpq reports these before a statement exists, so the SQLSTATE lives + // in errorInfo while getCode() holds libpq's own connection status. + yield 'connection refused' => [ + '08006', + 'SQLSTATE[08006] [7] connection to server at "127.0.0.1", port 15499 failed: Connection refused', + StatementException\ConnectionException::class, + 7, + ]; + yield 'backend terminated under an open session' => [ + 'HY000', + "SQLSTATE[HY000]: General error: 7 FATAL: terminating connection due to administrator command\nserver closed the connection unexpectedly", + StatementException\ConnectionException::class, + ]; + yield 'socket already gone' => [ + 'HY000', + 'SQLSTATE[HY000]: General error: 7 no connection to the server', + StatementException\ConnectionException::class, + ]; + yield 'eof detected' => [ + 'HY000', + 'SQLSTATE[HY000]: General error: 7 EOF detected', + StatementException\ConnectionException::class, + ]; + } + + public function sqlServerProvider(): iterable + { + yield 'primary key violation printing the duplicate uuid' => [ + '23000', + "SQLSTATE[23000]: [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Violation of PRIMARY KEY constraint 'PK__t268__3213E83F'. Cannot insert duplicate key in object 'dbo.t268'. The duplicate key value is (f5a31835-fae5-43eb-8efa-cce00b90800a).", + StatementException\ConstrainException::class, + ]; + yield 'check violation naming a table called connections' => [ + '23000', + "SQLSTATE[23000]: [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]The INSERT statement conflicted with the CHECK constraint \"CK__connections__state\". The conflict occurred in database \"master\", table \"dbo.connections\", column 'state'.", + StatementException\ConstrainException::class, + ]; + yield 'invalid object name containing "connections"' => [ + '42S02', + "SQLSTATE[42S02]: [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Invalid object name 'no_such_connections_table'.", + StatementException::class, + ]; + + // The ODBC driver translates its own strings, so on a localized install the SQLSTATE is the + // only part of these that any needle could match. + yield 'connection refused, localized' => [ + '08001', + 'SQLSTATE[08001]: [Microsoft][ODBC Driver 18 for SQL Server]Поставщик TCP: Подключение не установлено, т.к. конечный компьютер отверг запрос на подключение.', + StatementException\ConnectionException::class, + ]; + yield 'link dropped, localized' => [ + '08S01', + 'SQLSTATE[08S01]: [Microsoft][ODBC Driver 18 for SQL Server]Поставщик TCP: Удаленный хост принудительно разорвал существующее подключение.', + StatementException\ConnectionException::class, + ]; + } + + public function mySQLProvider(): iterable + { + yield 'duplicate entry printing a uuid holding 0800' => [ + '23000', + "SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'f5a31835-fae5-43eb-8efa-cce00b90800a' for key 't268.PRIMARY'", + StatementException\ConstrainException::class, + null, + 1062, + ]; + // mysql files these under HY000; see MySQLDriver::CONSTRAINT_ERRNOS. + yield 'check constraint violated' => [ + 'HY000', + "SQLSTATE[HY000]: General error: 3819 Check constraint 'c_pos' is violated.", + StatementException\ConstrainException::class, + null, + 3819, + ]; + yield 'required column left out of the statement' => [ + 'HY000', + "SQLSTATE[HY000]: General error: 1364 Field 'nn' doesn't have a default value", + StatementException\ConstrainException::class, + null, + 1364, + ]; + yield 'duplicate check constraint name is a DDL error, not a violation' => [ + 'HY000', + "SQLSTATE[HY000]: General error: 3822 Duplicate check constraint name 'c_pos'.", + StatementException::class, + null, + 3822, + ]; + yield 'incorrect integer value is a coercion failure, not a violation' => [ + 'HY000', + "SQLSTATE[HY000]: General error: 1366 Incorrect integer value: 'zz' for column 'id' at row 1", + StatementException::class, + null, + 1366, + ]; + yield 'table not found, name contains "connections"' => [ + '42S02', + "SQLSTATE[42S02]: Base table or view not found: 1146 Table 'spiral.no_such_connections_table' doesn't exist", + StatementException::class, + null, + 1146, + ]; + yield 'access denied' => [ + 'HY000', + "SQLSTATE[HY000] [1045] Access denied for user 'root'@'172.18.0.1' (using password: YES)", + StatementException::class, + 1045, + 1045, + ]; + yield 'server has gone away' => [ + 'HY000', + 'SQLSTATE[HY000]: General error: 2006 MySQL server has gone away', + StatementException\ConnectionException::class, + null, + 2006, + ]; + yield 'connection refused, localized' => [ + 'HY000', + 'SQLSTATE[HY000] [2002] Подключение не установлено, т.к. конечный компьютер отверг запрос на подключение', + StatementException\ConnectionException::class, + 2002, + 2002, + ]; + } + + public function sqLiteProvider(): iterable + { + yield 'unique constraint' => [ + '23000', + 'SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: t268.id', + StatementException\ConstrainException::class, + null, + 19, + ]; + yield 'no such table containing "connections"' => [ + 'HY000', + 'SQLSTATE[HY000]: General error: 1 no such table: no_such_connections_table', + StatementException::class, + null, + 1, + ]; + } + + /** + * @dataProvider postgresProvider + */ + public function testPostgres( + string $sqlState, + string $message, + string $expected, + int|string|null $code = null, + ?int $driverCode = null, + ): void { + $this->assertMapsTo(PostgresDriver::class, $sqlState, $message, $expected, $code, $driverCode); + } + + /** + * @dataProvider sqlServerProvider + */ + public function testSQLServer( + string $sqlState, + string $message, + string $expected, + int|string|null $code = null, + ?int $driverCode = null, + ): void { + $this->assertMapsTo(SQLServerDriver::class, $sqlState, $message, $expected, $code, $driverCode); + } + + /** + * @dataProvider mySQLProvider + */ + public function testMySQL( + string $sqlState, + string $message, + string $expected, + int|string|null $code = null, + ?int $driverCode = null, + ): void { + $this->assertMapsTo(MySQLDriver::class, $sqlState, $message, $expected, $code, $driverCode); + } + + /** + * @dataProvider sqLiteProvider + */ + public function testSQLite( + string $sqlState, + string $message, + string $expected, + int|string|null $code = null, + ?int $driverCode = null, + ): void { + $this->assertMapsTo(SQLiteDriver::class, $sqlState, $message, $expected, $code, $driverCode); + } + + public function testSqlStateIsReadFromTheMessageWhenErrorInfoIsMissing(): void + { + $exception = new \PDOException('SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key'); + + self::assertInstanceOf( + StatementException\ConstrainException::class, + $this->map(PostgresDriver::class, $exception), + ); + } + + public function testExceptionWithoutAnySqlStateFallsBackToTheMessage(): void + { + self::assertInstanceOf( + StatementException\ConnectionException::class, + $this->map(PostgresDriver::class, new \RuntimeException('Broken pipe')), + ); + + self::assertNotInstanceOf( + StatementException\ConnectionException::class, + $this->map(PostgresDriver::class, new \RuntimeException('Something else entirely')), + ); + } + + /** + * @param class-string $driver + * @param class-string $expected + */ + private function assertMapsTo( + string $driver, + string $sqlState, + string $message, + string $expected, + int|string|null $code, + ?int $driverCode, + ): void { + $exception = new \PDOException($message); + $exception->errorInfo = [$sqlState, $driverCode, $message]; + $this->setCode($exception, $code ?? $sqlState); + + $mapped = $this->map($driver, $exception); + + self::assertInstanceOf($expected, $mapped); + // ConstrainException and ConnectionException both extend StatementException, so an + // assertInstanceOf against the base class alone would pass for either of them. + self::assertSame($expected, $mapped::class); + } + + /** + * @param class-string $driver + */ + private function map(string $driver, \Throwable $exception): StatementException + { + $method = new \ReflectionMethod($driver, 'mapException'); + + return $method->invoke( + (new \ReflectionClass($driver))->newInstanceWithoutConstructor(), + $exception, + 'SELECT 1', + ); + } + + /** + * PDO assigns the SQLSTATE string to a property typed as int, which no constructor accepts. + */ + private function setCode(\PDOException $exception, int|string $code): void + { + (new \ReflectionProperty(\Exception::class, 'code'))->setValue($exception, $code); + } +} From 0e6c4c4451a67daae33b7a6f6547ee3a071214a8 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 17 Sep 2026 22:42:19 +0400 Subject: [PATCH 2/6] fix(MySQL): gate the message heuristics on the absence of a server errno MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(Driver): match SQLSTATE with PCRE instead of ext-ctype fix(Postgres): treat 57P04 and 57P05 as connection failures docs(SQLServer): declare the StatementException create() can now throw HY000 is a sound gate for the other drivers, whose servers always classify what they reject, but mysql files plenty of its own errors under it and their text carries table and constraint names. A DDL failure on a constraint named `connections` therefore still reached the needles and came back as a ConnectionException, disconnecting and retrying over a statement the server had already refused — the original defect, surviving inside its own fix. The server numbers its errors from 1000 upwards and leaves 2000-2999 to the client library, which is what lets the two be told apart; 4031 is listed explicitly because the server raises it to close an idle connection, and gating on the number alone would otherwise stop recognising it. `ctype_alnum()` made ext-ctype a hard requirement of every exception mapping, while composer.json asks only for ext-pdo. PCRE is already a dependency of the same method. Assisted-By: Claude Opus 5 --- src/Driver/Driver.php | 2 +- src/Driver/MySQL/MySQLDriver.php | 28 +++++++++++++--- src/Driver/Postgres/PostgresDriver.php | 4 ++- src/Driver/SQLServer/SQLServerDriver.php | 1 + .../Database/Unit/Driver/MapExceptionTest.php | 32 +++++++++++++++++++ 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/Driver/Driver.php b/src/Driver/Driver.php index fc63450d..41178e71 100644 --- a/src/Driver/Driver.php +++ b/src/Driver/Driver.php @@ -754,6 +754,6 @@ protected function defineLoggerContext(float $queryStart, \PDOStatement|PDOState */ private static function toSqlState(mixed $value): ?string { - return \is_string($value) && \strlen($value) === 5 && \ctype_alnum($value) ? $value : null; + return \is_string($value) && \preg_match('/^[0-9A-Za-z]{5}$/', $value) === 1 ? $value : null; } } diff --git a/src/Driver/MySQL/MySQLDriver.php b/src/Driver/MySQL/MySQLDriver.php index fbb449c5..c65a0e44 100644 --- a/src/Driver/MySQL/MySQLDriver.php +++ b/src/Driver/MySQL/MySQLDriver.php @@ -39,6 +39,14 @@ class MySQLDriver extends Driver 3819, // ER_CHECK_CONSTRAINT_VIOLATED ]; + /** + * Connection losses the server reports itself, so they arrive numbered rather than in the + * CR_* range the client library uses for a socket it lost on its own. + */ + private const CONNECTION_ERRNOS = [ + 4031, // ER_CLIENT_INTERACTION_TIMEOUT — the server closed an idle connection + ]; + /** * @param MySQLDriverConfig $config */ @@ -102,13 +110,15 @@ protected function mapException(\Throwable $exception, string $query): Statement // 2000-2100 is the CR_* range the client library raises when it loses the socket itself, // and it never overlaps the server's own error numbers. - if ($errno > 2000 && $errno < 2100) { + if (($errno > 2000 && $errno < 2100) || \in_array($errno, self::CONNECTION_ERRNOS, true)) { return new StatementException\ConnectionException($exception, $query); } - // Last resort, and only for the states PDO made up: a server error the driver did classify - // prints user data (a duplicate key value, a table name) that these needles would match. - if (self::isGenericSqlState($sqlState)) { + // Last resort, and only for a failure the server did not number itself. HY000 is not + // enough of a filter here the way it is for the other drivers: mysql files plenty of its + // own errors under that state, and their text carries table and constraint names — a + // constraint called `connections` would otherwise be read as a dropped socket. + if (!self::isServerErrno($errno)) { $message = \strtolower($exception->getMessage()); if ( @@ -136,4 +146,14 @@ private static function getErrno(\Throwable $exception): int return (int) (\is_array($errorInfo) ? $errorInfo[1] ?? $exception->getCode() : $exception->getCode()); } + + /** + * Whether the number came from the server rather than the client library. The server numbers + * its errors from 1000 upwards but leaves 2000-2999 to the client, which is what makes the + * two tellable apart at all. + */ + private static function isServerErrno(int $errno): bool + { + return $errno >= 1000 && ($errno < 2000 || $errno >= 3000); + } } diff --git a/src/Driver/Postgres/PostgresDriver.php b/src/Driver/Postgres/PostgresDriver.php index eae2a7be..37a48f8e 100644 --- a/src/Driver/Postgres/PostgresDriver.php +++ b/src/Driver/Postgres/PostgresDriver.php @@ -309,9 +309,11 @@ protected function mapException(\Throwable $exception, string $query): Statement if ($sqlState !== null) { // Class 08 is `connection_exception`; the 57P0x states and `too_many_connections` are // the server refusing or tearing down the session rather than rejecting the statement. + // Listed rather than taken as a whole class, because 57014 `query_canceled` is a + // statement timeout that leaves the session usable. if ( \str_starts_with($sqlState, '08') - || \in_array($sqlState, ['53300', '57P01', '57P02', '57P03'], true) + || \in_array($sqlState, ['53300', '57P01', '57P02', '57P03', '57P04', '57P05'], true) ) { return new StatementException\ConnectionException($exception, $query); } diff --git a/src/Driver/SQLServer/SQLServerDriver.php b/src/Driver/SQLServer/SQLServerDriver.php index 8b81af62..2f753882 100644 --- a/src/Driver/SQLServer/SQLServerDriver.php +++ b/src/Driver/SQLServer/SQLServerDriver.php @@ -38,6 +38,7 @@ class SQLServerDriver extends Driver implements CursorInterface * @param SQLServerDriverConfig $config * * @throws DriverException + * @throws StatementException The server could not be reached to check its version. */ public static function create(DriverConfig $config): static { diff --git a/tests/Database/Unit/Driver/MapExceptionTest.php b/tests/Database/Unit/Driver/MapExceptionTest.php index 1f18ec7c..b730cf24 100644 --- a/tests/Database/Unit/Driver/MapExceptionTest.php +++ b/tests/Database/Unit/Driver/MapExceptionTest.php @@ -50,6 +50,16 @@ public function postgresProvider(): iterable 'SQLSTATE[53300]: Too many connections: 7 FATAL: sorry, too many clients already', StatementException\ConnectionException::class, ]; + yield 'database dropped under the session' => [ + '57P04', + 'SQLSTATE[57P04]: Database dropped: 7 FATAL: terminating connection because the database it was connected to was dropped', + StatementException\ConnectionException::class, + ]; + yield 'query canceled leaves the session usable' => [ + '57014', + 'SQLSTATE[57014]: Query canceled: 7 ERROR: canceling statement due to statement timeout', + StatementException::class, + ]; yield 'undefined table whose name contains "connections"' => [ '42P01', 'SQLSTATE[42P01]: Undefined table: 7 ERROR: relation "no_such_connections_table" does not exist', @@ -149,6 +159,28 @@ public function mySQLProvider(): iterable null, 3822, ]; + // mysql puts its own errors under HY000 too, so the state alone cannot license the needles. + yield 'DDL error naming a constraint called connections' => [ + 'HY000', + "SQLSTATE[HY000]: General error: 3822 Duplicate check constraint name 'connections'.", + StatementException::class, + null, + 3822, + ]; + yield 'coercion failure naming a column called connection_id' => [ + 'HY000', + "SQLSTATE[HY000]: General error: 1366 Incorrect integer value: 'zz' for column 'connection_id' at row 1", + StatementException::class, + null, + 1366, + ]; + yield 'idle connection closed by the server' => [ + 'HY000', + 'SQLSTATE[HY000]: General error: 4031 The client was disconnected by the server because of inactivity. See wait_timeout and interactive_timeout for configuring this behavior.', + StatementException\ConnectionException::class, + null, + 4031, + ]; yield 'incorrect integer value is a coercion failure, not a violation' => [ 'HY000', "SQLSTATE[HY000]: General error: 1366 Incorrect integer value: 'zz' for column 'id' at row 1", From 7f6ff08c6fc3069eb4a0394f23aa9960086da082 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 17 Sep 2026 22:50:39 +0400 Subject: [PATCH 3/6] docs: trim the comments around exception mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments that restated the branch they sit above, or retold what the mapping used to do, are gone; what stays is what the code cannot carry — the int cast that truncates `23P01`, the reason the 57 states are listed instead of taken as a class, the three places PDO keeps a SQLSTATE, and the localized ODBC strings no needle can match. Assisted-By: Claude Opus 5 --- src/Driver/Driver.php | 3 +-- src/Driver/MySQL/MySQLDriver.php | 20 +++++++------------ src/Driver/Postgres/PostgresDriver.php | 15 ++++++-------- src/Driver/SQLServer/SQLServerDriver.php | 15 +++++--------- .../Driver/Common/Query/ExceptionsTest.php | 4 ++-- .../Driver/MySQL/Query/ExceptionsTest.php | 3 +-- .../Driver/Postgres/Query/ExceptionsTest.php | 5 ++--- .../Driver/SQLServer/Driver/DriverTest.php | 5 ++--- .../Database/Unit/Driver/MapExceptionTest.php | 1 - 9 files changed, 26 insertions(+), 45 deletions(-) diff --git a/src/Driver/Driver.php b/src/Driver/Driver.php index 41178e71..4bed9be3 100644 --- a/src/Driver/Driver.php +++ b/src/Driver/Driver.php @@ -451,8 +451,7 @@ public function __destruct() * * PDO reports the state in three places that do not agree with each other: connection-time * failures put the driver-specific number in `getCode()` and the SQLSTATE only in `errorInfo` - * and the message prefix, while statement failures put the SQLSTATE in all three. Reading - * `errorInfo` first and falling back to the prefix covers both. + * and the message prefix, while statement failures put the SQLSTATE in all three. */ protected static function getSqlState(\Throwable $exception): ?string { diff --git a/src/Driver/MySQL/MySQLDriver.php b/src/Driver/MySQL/MySQLDriver.php index c65a0e44..c9d28645 100644 --- a/src/Driver/MySQL/MySQLDriver.php +++ b/src/Driver/MySQL/MySQLDriver.php @@ -27,12 +27,9 @@ class MySQLDriver extends Driver { /** - * Integrity violations mysql files under the generic HY000 instead of class 23: a required - * column left out of the statement, and a row the CHECK rejected. Postgres reports the same - * two as 23502 and 23514, and SQL Server as 23000. - * - * Listed one by one rather than as a range, because their HY000 neighbours are DDL errors and - * type coercion failures that are not constraint violations at all. + * Integrity violations mysql files under HY000 instead of class 23, where Postgres reports + * them as 23502 and 23514 and SQL Server as 23000. Listed one by one rather than as a range: + * their HY000 neighbours are DDL errors and type coercion failures. */ private const CONSTRAINT_ERRNOS = [ 1364, // ER_NO_DEFAULT_FOR_FIELD @@ -92,7 +89,6 @@ protected function mapException(\Throwable $exception, string $query): Statement $sqlState = self::getSqlState($exception); if ($sqlState !== null) { - // 08S01 — communication link failure. if (\str_starts_with($sqlState, '08')) { return new StatementException\ConnectionException($exception, $query); } @@ -114,10 +110,9 @@ protected function mapException(\Throwable $exception, string $query): Statement return new StatementException\ConnectionException($exception, $query); } - // Last resort, and only for a failure the server did not number itself. HY000 is not - // enough of a filter here the way it is for the other drivers: mysql files plenty of its - // own errors under that state, and their text carries table and constraint names — a - // constraint called `connections` would otherwise be read as a dropped socket. + // Last resort, and only for a failure the server did not number itself. Unlike the other + // drivers, mysql files plenty of its own errors under HY000, and their text carries table + // and constraint names, so the state is not a usable gate here. if (!self::isServerErrno($errno)) { $message = \strtolower($exception->getMessage()); @@ -149,8 +144,7 @@ private static function getErrno(\Throwable $exception): int /** * Whether the number came from the server rather than the client library. The server numbers - * its errors from 1000 upwards but leaves 2000-2999 to the client, which is what makes the - * two tellable apart at all. + * its errors from 1000 upwards but leaves 2000-2999 to the client. */ private static function isServerErrno(int $errno): bool { diff --git a/src/Driver/Postgres/PostgresDriver.php b/src/Driver/Postgres/PostgresDriver.php index 37a48f8e..ea2a679e 100644 --- a/src/Driver/Postgres/PostgresDriver.php +++ b/src/Driver/Postgres/PostgresDriver.php @@ -307,9 +307,7 @@ protected function mapException(\Throwable $exception, string $query): Statement $sqlState = self::getSqlState($exception); if ($sqlState !== null) { - // Class 08 is `connection_exception`; the 57P0x states and `too_many_connections` are - // the server refusing or tearing down the session rather than rejecting the statement. - // Listed rather than taken as a whole class, because 57014 `query_canceled` is a + // The 57 states are listed rather than taken as a class: 57014 `query_canceled` is a // statement timeout that leaves the session usable. if ( \str_starts_with($sqlState, '08') @@ -318,17 +316,16 @@ protected function mapException(\Throwable $exception, string $query): Statement return new StatementException\ConnectionException($exception, $query); } - // Class 23 is `integrity_constraint_violation`. Compared as a string so that `23P01` - // (exclusion violation) is not truncated to 23 the way a numeric cast leaves it. + // Compared as a string: `23P01` (exclusion violation) is not a number, and a numeric + // cast truncates it to 23. if (\str_starts_with($sqlState, '23')) { return new StatementException\ConstrainException($exception, $query); } } - // A socket the server or a pooler dropped mid-statement arrives as HY000 with the reason - // only in the text. The message is never consulted for a state the server did classify: - // Postgres prints the offending row in DETAIL, and a uuid or an email in it would otherwise - // match these needles and turn a data error into a reconnect. + // A socket the server or a pooler dropped arrives as HY000, with the reason only in the + // text. A state the server did classify never reaches these needles: Postgres prints the + // offending row in DETAIL, and a uuid or an email there matches them. if (self::isGenericSqlState($sqlState)) { $message = \strtolower($exception->getMessage()); diff --git a/src/Driver/SQLServer/SQLServerDriver.php b/src/Driver/SQLServer/SQLServerDriver.php index 2f753882..a0b85782 100644 --- a/src/Driver/SQLServer/SQLServerDriver.php +++ b/src/Driver/SQLServer/SQLServerDriver.php @@ -54,9 +54,8 @@ public static function create(DriverConfig $config): static ), ); - // Alone among the drivers this one reaches the server before any query, to reject a version - // it cannot compile for. That makes it the only place where a bad host surfaces outside - // Driver::statement(), so the failure is classified here instead of escaping raw. + // The only driver that reaches the server before any query, so the only place a connection + // failure surfaces outside Driver::statement() and has to be classified by hand. try { $version = (int) $driver->getPDO()->getAttribute(\PDO::ATTR_SERVER_VERSION); } catch (\Throwable $e) { @@ -263,8 +262,6 @@ protected function mapException(\Throwable $exception, string $query): Statement $sqlState = self::getSqlState($exception); if ($sqlState !== null) { - // Class 08 covers both the ODBC driver failing to reach the server (08001) and the - // link dropping under an open session (08S01). if (\str_starts_with($sqlState, '08')) { return new StatementException\ConnectionException($exception, $query); } @@ -274,11 +271,9 @@ protected function mapException(\Throwable $exception, string $query): Statement } } - // The message is a last resort, not the first test: SQL Server names the conflicting table - // and prints the duplicate key value, so a table called `connections` or a uuid holding - // `0800` used to be enough to report a constraint violation as a dropped link. The ODBC - // driver also translates these strings, which leaves the needles matching nothing at all - // on a localized install. + // A last resort twice over: SQL Server names the conflicting table and prints the duplicate + // key value, so these needles match user data, and the ODBC driver translates its own + // strings, so on a localized install they match nothing at all. if (self::isGenericSqlState($sqlState)) { $message = \strtolower($exception->getMessage()); diff --git a/tests/Database/Functional/Driver/Common/Query/ExceptionsTest.php b/tests/Database/Functional/Driver/Common/Query/ExceptionsTest.php index baac1a0f..824c47e4 100644 --- a/tests/Database/Functional/Driver/Common/Query/ExceptionsTest.php +++ b/tests/Database/Functional/Driver/Common/Query/ExceptionsTest.php @@ -75,8 +75,8 @@ public function testConstrainExceptionWhenTheValueLooksLikeAConnectionError(): v $schema->index(['value'])->unique(true); $schema->save(); - // Every driver echoes the rejected value back in the error message, where `0800` used to - // be read as the SQLSTATE class of a dropped connection. + // Every driver echoes the rejected value back in its error message, and `0800` inside this + // uuid reads as the SQLSTATE class of a dropped connection. $value = 'f5a31835-fae5-43eb-8efa-cce00b90800a'; $this->database->getDriver() diff --git a/tests/Database/Functional/Driver/MySQL/Query/ExceptionsTest.php b/tests/Database/Functional/Driver/MySQL/Query/ExceptionsTest.php index b19360ce..2fcfd0c9 100644 --- a/tests/Database/Functional/Driver/MySQL/Query/ExceptionsTest.php +++ b/tests/Database/Functional/Driver/MySQL/Query/ExceptionsTest.php @@ -37,8 +37,7 @@ public function testCheckViolationIsAConstrainException(): void { $driver = $this->database->getDriver(); - // Raw SQL because the schema builder has no CHECK support, and mysql reports the violation - // as HY000 rather than class 23 — the pair is why this case needs a driver-specific test. + // Raw SQL because the schema builder has no CHECK support. $driver->execute('CREATE TABLE test (id int PRIMARY KEY, pos int, CONSTRAINT c_pos CHECK (pos > 0))'); $this->expectException(StatementException\ConstrainException::class); diff --git a/tests/Database/Functional/Driver/Postgres/Query/ExceptionsTest.php b/tests/Database/Functional/Driver/Postgres/Query/ExceptionsTest.php index f525b30e..118642ec 100644 --- a/tests/Database/Functional/Driver/Postgres/Query/ExceptionsTest.php +++ b/tests/Database/Functional/Driver/Postgres/Query/ExceptionsTest.php @@ -20,9 +20,8 @@ public function testExclusionViolationIsAConstrainException(): void { $driver = $this->database->getDriver(); - // Exclusion constraints have no schema builder, and `23P01` is the one state in class 23 - // that is not a number — the pair is why this case needs a driver-specific test. A range - // column keeps it to the built-in gist opclasses, with no btree_gist to install. + // A range column keeps the exclusion to the built-in gist opclasses; an `id WITH =` would + // need btree_gist installed. $driver->execute('CREATE TABLE test (id int, span int4range, EXCLUDE USING gist (span WITH &&))'); $driver->execute("INSERT INTO test VALUES (1, '[1,10)')"); diff --git a/tests/Database/Functional/Driver/SQLServer/Driver/DriverTest.php b/tests/Database/Functional/Driver/SQLServer/Driver/DriverTest.php index 264efe78..4a906a62 100644 --- a/tests/Database/Functional/Driver/SQLServer/Driver/DriverTest.php +++ b/tests/Database/Functional/Driver/SQLServer/Driver/DriverTest.php @@ -27,9 +27,8 @@ public function testCreateReportsAFailedConnectionAsALibraryException(): void $connection->password = 'definitely not the password'; $config->connection = $connection; - // create() reaches the server to read its version, which makes it the one place a - // connection failure happens outside Driver::statement() and so the one place that has to - // classify the failure itself. + // create() reaches the server to read its version, which puts this connection failure + // outside Driver::statement() and its mapping. $this->expectException(StatementException::class); SQLServerDriver::create($config); diff --git a/tests/Database/Unit/Driver/MapExceptionTest.php b/tests/Database/Unit/Driver/MapExceptionTest.php index b730cf24..4b81d54e 100644 --- a/tests/Database/Unit/Driver/MapExceptionTest.php +++ b/tests/Database/Unit/Driver/MapExceptionTest.php @@ -137,7 +137,6 @@ public function mySQLProvider(): iterable null, 1062, ]; - // mysql files these under HY000; see MySQLDriver::CONSTRAINT_ERRNOS. yield 'check constraint violated' => [ 'HY000', "SQLSTATE[HY000]: General error: 3819 Check constraint 'c_pos' is violated.", From 7bb0ce8571a69fdb1dd02c51242938167f065eb5 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 17 Sep 2026 23:50:15 +0400 Subject: [PATCH 4/6] fix(MySQL): stop reading client misuse as a lost connection The CR_* range was widened from getCode() to errorInfo, which let it match numbers a statement raises rather than a connect attempt: CR_COMMANDS_OUT_OF_SYNC and CR_PARAMS_NOT_BOUND became ConnectionException, so a driver holding an unbuffered result disconnected and reran the statement over a mistake the reconnect could only repeat. The range is back on getCode(), where a CR_* number appears only before any statement exists, and the losses that happen under an open session are named individually. The message fallback also has to see the SQLSTATE again: an exception carrying no errorInfo yields errno 0, and without the state from the message prefix a server-classified failure would reach the needles the same way it does in the other drivers. Assisted-By: Claude Opus 5 --- src/Driver/MySQL/MySQLDriver.php | 25 ++++++++++------- .../Database/Unit/Driver/MapExceptionTest.php | 27 +++++++++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/Driver/MySQL/MySQLDriver.php b/src/Driver/MySQL/MySQLDriver.php index c9d28645..85ea81ab 100644 --- a/src/Driver/MySQL/MySQLDriver.php +++ b/src/Driver/MySQL/MySQLDriver.php @@ -37,10 +37,14 @@ class MySQLDriver extends Driver ]; /** - * Connection losses the server reports itself, so they arrive numbered rather than in the - * CR_* range the client library uses for a socket it lost on its own. + * Losses of an established connection, named one by one because their neighbours in the CR_* + * range are client misuse — CR_COMMANDS_OUT_OF_SYNC, CR_PARAMS_NOT_BOUND — that a reconnect + * cannot fix and a retry would only repeat. */ private const CONNECTION_ERRNOS = [ + 2006, // CR_SERVER_GONE_ERROR + 2013, // CR_SERVER_LOST + 2055, // CR_SERVER_LOST_EXTENDED 4031, // ER_CLIENT_INTERACTION_TIMEOUT — the server closed an idle connection ]; @@ -104,16 +108,19 @@ protected function mapException(\Throwable $exception, string $query): Statement return new StatementException\ConstrainException($exception, $query); } - // 2000-2100 is the CR_* range the client library raises when it loses the socket itself, - // and it never overlaps the server's own error numbers. - if (($errno > 2000 && $errno < 2100) || \in_array($errno, self::CONNECTION_ERRNOS, true)) { + // The whole CR_* range means a lost socket only before a statement exists, which is exactly + // when getCode() carries the number instead of a SQLSTATE. Widening this to errorInfo would + // pull in the client-misuse errnos the constant above names. + $code = (int) $exception->getCode(); + + if (($code > 2000 && $code < 2100) || \in_array($errno, self::CONNECTION_ERRNOS, true)) { return new StatementException\ConnectionException($exception, $query); } - // Last resort, and only for a failure the server did not number itself. Unlike the other - // drivers, mysql files plenty of its own errors under HY000, and their text carries table - // and constraint names, so the state is not a usable gate here. - if (!self::isServerErrno($errno)) { + // Last resort, and only for a failure neither the server numbered nor PDO classified. + // Unlike the other drivers, mysql files plenty of its own errors under HY000, and their + // text carries table and constraint names, so the state alone is not a usable gate here. + if (!self::isServerErrno($errno) && self::isGenericSqlState($sqlState)) { $message = \strtolower($exception->getMessage()); if ( diff --git a/tests/Database/Unit/Driver/MapExceptionTest.php b/tests/Database/Unit/Driver/MapExceptionTest.php index 4b81d54e..4930f3fd 100644 --- a/tests/Database/Unit/Driver/MapExceptionTest.php +++ b/tests/Database/Unit/Driver/MapExceptionTest.php @@ -173,6 +173,20 @@ public function mySQLProvider(): iterable null, 1366, ]; + yield 'unbuffered result still open is client misuse, not a lost socket' => [ + 'HY000', + 'SQLSTATE[HY000]: General error: 2014 Cannot execute queries while other unbuffered queries are active.', + StatementException::class, + null, + 2014, + ]; + yield 'unbound parameters are client misuse, not a lost socket' => [ + 'HY000', + 'SQLSTATE[HY000]: General error: 2031 No data supplied for parameters in prepared statement', + StatementException::class, + null, + 2031, + ]; yield 'idle connection closed by the server' => [ 'HY000', 'SQLSTATE[HY000]: General error: 4031 The client was disconnected by the server because of inactivity. See wait_timeout and interactive_timeout for configuring this behavior.', @@ -297,6 +311,19 @@ public function testSqlStateIsReadFromTheMessageWhenErrorInfoIsMissing(): void ); } + public function testSqlStateReadFromTheMessageStillBlocksTheMySQLNeedles(): void + { + // No errorInfo, so there is no errno to gate on; the state in the prefix has to. + $exception = new \PDOException( + "SQLSTATE[42S02]: Base table or view not found: 1146 Table 'spiral.no_such_connections_table' doesn't exist", + ); + + self::assertSame( + StatementException::class, + $this->map(MySQLDriver::class, $exception)::class, + ); + } + public function testExceptionWithoutAnySqlStateFallsBackToTheMessage(): void { self::assertInstanceOf( From 63dd79a20375613ae9c7ff184853ed7a7994c434 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 18 Sep 2026 12:14:31 +0400 Subject: [PATCH 5/6] fix(SQLServer): keep create() off the server and check the version on connect docs(tests): correct the comments on PDO's code property and on SQLite echoing values Every other driver's create() is a pure factory, and a failed connection reaches the caller through Driver::statement(), where mapException() classifies it. The sqlserver driver connected inside create() to read the server version and so had to classify that one failure by hand, under a made-up 'CONNECT' query. Moving the version check into createPDO() puts the connection back on the shared path and removes the special case. Assisted-By: Claude Fable 5.1 --- src/Driver/SQLServer/SQLServerDriver.php | 36 +++++++++---------- .../Driver/Common/Query/ExceptionsTest.php | 4 +-- .../Driver/SQLServer/Driver/DriverTest.php | 11 +++--- .../Database/Unit/Driver/MapExceptionTest.php | 3 +- 4 files changed, 29 insertions(+), 25 deletions(-) diff --git a/src/Driver/SQLServer/SQLServerDriver.php b/src/Driver/SQLServer/SQLServerDriver.php index a0b85782..a5f7670f 100644 --- a/src/Driver/SQLServer/SQLServerDriver.php +++ b/src/Driver/SQLServer/SQLServerDriver.php @@ -16,6 +16,7 @@ use Cycle\Database\Driver\CursorInterface; use Cycle\Database\Driver\CursorOptions; use Cycle\Database\Driver\Driver; +use Cycle\Database\Driver\PDOInterface; use Cycle\Database\Driver\PDOStatementInterface; use Cycle\Database\Driver\SQLServer\Query\SQLServerDeleteQuery; use Cycle\Database\Driver\SQLServer\Query\SQLServerInsertQuery; @@ -36,13 +37,10 @@ class SQLServerDriver extends Driver implements CursorInterface /** * @param SQLServerDriverConfig $config - * - * @throws DriverException - * @throws StatementException The server could not be reached to check its version. */ public static function create(DriverConfig $config): static { - $driver = new static( + return new static( $config, new SQLServerHandler(), new SQLServerCompiler('[]'), @@ -53,20 +51,6 @@ public static function create(DriverConfig $config): static new SQLServerDeleteQuery(), ), ); - - // The only driver that reaches the server before any query, so the only place a connection - // failure surfaces outside Driver::statement() and has to be classified by hand. - try { - $version = (int) $driver->getPDO()->getAttribute(\PDO::ATTR_SERVER_VERSION); - } catch (\Throwable $e) { - throw $driver->mapException($e, 'CONNECT'); - } - - if ($version < 12) { - throw new DriverException('SQLServer driver supports only 12+ version of SQLServer'); - } - - return $driver; } public function getType(): string @@ -257,6 +241,22 @@ protected function rollbackSavepoint(int $level): void $this->execute('ROLLBACK TRANSACTION ' . $this->identifier("SVP{$level}")); } + /** + * @throws DriverException The server is older than SQL Server 2014 (internal version 12). + */ + protected function createPDO(): \PDO|PDOInterface + { + $pdo = parent::createPDO(); + + // Here rather than in create(): a factory must not reach the server, and a connection that + // fails here surfaces where mapException() classifies it, as it does for every other driver. + if ((int) $pdo->getAttribute(\PDO::ATTR_SERVER_VERSION) < 12) { + throw new DriverException('SQLServer driver supports only 12+ version of SQLServer'); + } + + return $pdo; + } + protected function mapException(\Throwable $exception, string $query): StatementException { $sqlState = self::getSqlState($exception); diff --git a/tests/Database/Functional/Driver/Common/Query/ExceptionsTest.php b/tests/Database/Functional/Driver/Common/Query/ExceptionsTest.php index 824c47e4..5387bc20 100644 --- a/tests/Database/Functional/Driver/Common/Query/ExceptionsTest.php +++ b/tests/Database/Functional/Driver/Common/Query/ExceptionsTest.php @@ -75,8 +75,8 @@ public function testConstrainExceptionWhenTheValueLooksLikeAConnectionError(): v $schema->index(['value'])->unique(true); $schema->save(); - // Every driver echoes the rejected value back in its error message, and `0800` inside this - // uuid reads as the SQLSTATE class of a dropped connection. + // Every server but SQLite echoes the rejected value back in its error message, and `0800` + // inside this uuid reads as the SQLSTATE class of a dropped connection. $value = 'f5a31835-fae5-43eb-8efa-cce00b90800a'; $this->database->getDriver() diff --git a/tests/Database/Functional/Driver/SQLServer/Driver/DriverTest.php b/tests/Database/Functional/Driver/SQLServer/Driver/DriverTest.php index 4a906a62..39d9d803 100644 --- a/tests/Database/Functional/Driver/SQLServer/Driver/DriverTest.php +++ b/tests/Database/Functional/Driver/SQLServer/Driver/DriverTest.php @@ -18,7 +18,7 @@ class DriverTest extends CommonClass { public const DRIVER = 'sqlserver'; - public function testCreateReportsAFailedConnectionAsALibraryException(): void + public function testCreateDoesNotReachTheServer(): void { $config = clone self::$config[static::DRIVER]; \assert($config instanceof DriverConfig); @@ -27,10 +27,13 @@ public function testCreateReportsAFailedConnectionAsALibraryException(): void $connection->password = 'definitely not the password'; $config->connection = $connection; - // create() reaches the server to read its version, which puts this connection failure - // outside Driver::statement() and its mapping. + $driver = SQLServerDriver::create($config); + + self::assertFalse($driver->isConnected()); + + // The failure arrives with the first statement, mapped like a failed connection on any driver. $this->expectException(StatementException::class); - SQLServerDriver::create($config); + $driver->query('SELECT 1'); } } diff --git a/tests/Database/Unit/Driver/MapExceptionTest.php b/tests/Database/Unit/Driver/MapExceptionTest.php index 4930f3fd..a85dcf08 100644 --- a/tests/Database/Unit/Driver/MapExceptionTest.php +++ b/tests/Database/Unit/Driver/MapExceptionTest.php @@ -376,7 +376,8 @@ private function map(string $driver, \Throwable $exception): StatementException } /** - * PDO assigns the SQLSTATE string to a property typed as int, which no constructor accepts. + * PDO stores the SQLSTATE string in the untyped `code` property, which the int-typed + * constructor parameter cannot receive. */ private function setCode(\PDOException $exception, int|string $code): void { From 01de44a5b58ec7a59ca070116794aadb8ac107ca Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 18 Sep 2026 13:59:01 +0400 Subject: [PATCH 6/6] fix(SQLServer): classify exceptions by SQLSTATE alone, without a message fallback test(Driver): cover "Connection is busy" as client misuse on SQL Server The library reaches SQL Server only through pdo_sqlsrv, and the ODBC layer beneath it files every transport failure under class 08 on its own: a dropped link is 08S01, a refused connect is 08001, and even EPIPE arrives as 08S01. The needles therefore matched nothing genuine, and the bare `connection` needle turned "Connection is busy with results for another command" into a ConnectionException. That is client misuse with MARS off, and the reconnect dropped the open result set so the retry succeeded, hiding the mistake. MySQL and Postgres keep their fallback because mysqlnd and libpq report a lost socket under HY000; the ODBC driver does not. Assisted-By: Claude Fable 5.1 --- src/Driver/SQLServer/SQLServerDriver.php | 29 +++++-------------- .../Connection/ConnectionExceptionTest.php | 1 - .../Database/Unit/Driver/MapExceptionTest.php | 12 ++++++-- 3 files changed, 18 insertions(+), 24 deletions(-) diff --git a/src/Driver/SQLServer/SQLServerDriver.php b/src/Driver/SQLServer/SQLServerDriver.php index a5f7670f..c7689e24 100644 --- a/src/Driver/SQLServer/SQLServerDriver.php +++ b/src/Driver/SQLServer/SQLServerDriver.php @@ -259,30 +259,17 @@ protected function createPDO(): \PDO|PDOInterface protected function mapException(\Throwable $exception, string $query): StatementException { - $sqlState = self::getSqlState($exception); + // No message fallback, unlike the other drivers: the ODBC layer files every transport failure + // under class 08 itself, so an HY000 that mentions a connection is client misuse such as + // "Connection is busy with results for another command", which a reconnect would only mask. + $sqlState = self::getSqlState($exception) ?? ''; - if ($sqlState !== null) { - if (\str_starts_with($sqlState, '08')) { - return new StatementException\ConnectionException($exception, $query); - } - - if (\str_starts_with($sqlState, '23')) { - return new StatementException\ConstrainException($exception, $query); - } + if (\str_starts_with($sqlState, '08')) { + return new StatementException\ConnectionException($exception, $query); } - // A last resort twice over: SQL Server names the conflicting table and prints the duplicate - // key value, so these needles match user data, and the ODBC driver translates its own - // strings, so on a localized install they match nothing at all. - if (self::isGenericSqlState($sqlState)) { - $message = \strtolower($exception->getMessage()); - - if ( - \str_contains($message, 'broken pipe') - || \str_contains($message, 'connection') - ) { - return new StatementException\ConnectionException($exception, $query); - } + if (\str_starts_with($sqlState, '23')) { + return new StatementException\ConstrainException($exception, $query); } return new StatementException($exception, $query); diff --git a/tests/Database/Functional/Driver/SQLServer/Connection/ConnectionExceptionTest.php b/tests/Database/Functional/Driver/SQLServer/Connection/ConnectionExceptionTest.php index 685706f3..33017fe1 100644 --- a/tests/Database/Functional/Driver/SQLServer/Connection/ConnectionExceptionTest.php +++ b/tests/Database/Functional/Driver/SQLServer/Connection/ConnectionExceptionTest.php @@ -21,7 +21,6 @@ class ConnectionExceptionTest extends CommonClass public function reconnectableExceptionsProvider(): iterable { return [ - [new \Exception('Bad connection')], [new \Exception('SQLSTATE[08001]: [Microsoft][ODBC Driver 18 for SQL Server]TCP Provider: No connection could be made')], /** The ODBC driver translates its own strings, so only the SQLSTATE is dependable. */ [new \Exception('SQLSTATE[08S01]: [Microsoft][ODBC Driver 18 for SQL Server]Поставщик TCP: Удаленный хост принудительно разорвал существующее подключение.')], diff --git a/tests/Database/Unit/Driver/MapExceptionTest.php b/tests/Database/Unit/Driver/MapExceptionTest.php index a85dcf08..d6364b6d 100644 --- a/tests/Database/Unit/Driver/MapExceptionTest.php +++ b/tests/Database/Unit/Driver/MapExceptionTest.php @@ -113,9 +113,17 @@ public function sqlServerProvider(): iterable "SQLSTATE[42S02]: [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Invalid object name 'no_such_connections_table'.", StatementException::class, ]; + // Raised with MARS off while another result set is still open. A reconnect would drop that + // result set and let the retry succeed, hiding the misuse. + yield 'connection busy with another result set is client misuse, not a lost link' => [ + 'HY000', + 'SQLSTATE[HY000]: [Microsoft][ODBC Driver 18 for SQL Server]Connection is busy with results for another command', + StatementException::class, + null, + 0, + ]; - // The ODBC driver translates its own strings, so on a localized install the SQLSTATE is the - // only part of these that any needle could match. + // The ODBC driver translates its own strings; the SQLSTATE is the only part that does not move. yield 'connection refused, localized' => [ '08001', 'SQLSTATE[08001]: [Microsoft][ODBC Driver 18 for SQL Server]Поставщик TCP: Подключение не установлено, т.к. конечный компьютер отверг запрос на подключение.',