diff --git a/src/Driver/Driver.php b/src/Driver/Driver.php index 852239ac..4bed9be3 100644 --- a/src/Driver/Driver.php +++ b/src/Driver/Driver.php @@ -446,6 +446,46 @@ 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. + */ + 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 +746,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) && \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 01d02eb1..85ea81ab 100644 --- a/src/Driver/MySQL/MySQLDriver.php +++ b/src/Driver/MySQL/MySQLDriver.php @@ -26,6 +26,28 @@ */ class MySQLDriver extends Driver { + /** + * 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 + 3819, // ER_CHECK_CONSTRAINT_VIOLATED + ]; + + /** + * 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 + ]; + /** * @param MySQLDriverConfig $config */ @@ -64,29 +86,75 @@ 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) { + $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); + } + } + + $errno = self::getErrno($exception); + + if (\in_array($errno, self::CONSTRAINT_ERRNOS, true)) { return new StatementException\ConstrainException($exception, $query); } - $message = \strtolower($exception->getMessage()); + // 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 ( - \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 (($code > 2000 && $code < 2100) || \in_array($errno, self::CONNECTION_ERRNOS, true)) { return new StatementException\ConnectionException($exception, $query); } + // 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 ( + \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()); + } + + /** + * 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. + */ + 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 64d00eb5..ea2a679e 100644 --- a/src/Driver/Postgres/PostgresDriver.php +++ b/src/Driver/Postgres/PostgresDriver.php @@ -304,20 +304,38 @@ 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) { + // 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') + || \in_array($sqlState, ['53300', '57P01', '57P02', '57P03', '57P04', '57P05'], true) + ) { + return new StatementException\ConnectionException($exception, $query); + } + + // 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); + } } - if ((int) $exception->getCode() >= 23000 && (int) $exception->getCode() < 24000) { - return new StatementException\ConstrainException($exception, $query); + // 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()); + + 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..c7689e24 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,12 +37,10 @@ class SQLServerDriver extends Driver implements CursorInterface /** * @param SQLServerDriverConfig $config - * - * @throws DriverException */ public static function create(DriverConfig $config): static { - $driver = new static( + return new static( $config, new SQLServerHandler(), new SQLServerCompiler('[]'), @@ -52,12 +51,6 @@ public static function create(DriverConfig $config): static new SQLServerDeleteQuery(), ), ); - - if ((int) $driver->getPDO()->getAttribute(\PDO::ATTR_SERVER_VERSION) < 12) { - throw new DriverException('SQLServer driver supports only 12+ version of SQLServer'); - } - - return $driver; } public function getType(): string @@ -248,20 +241,34 @@ protected function rollbackSavepoint(int $level): void $this->execute('ROLLBACK TRANSACTION ' . $this->identifier("SVP{$level}")); } - protected function mapException(\Throwable $exception, string $query): StatementException + /** + * @throws DriverException The server is older than SQL Server 2014 (internal version 12). + */ + protected function createPDO(): \PDO|PDOInterface { - $message = \strtolower($exception->getMessage()); + $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 + { + // 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 ( - \str_contains($message, '0800') - || \str_contains($message, '080p') - || \str_contains($message, 'connection') - ) { + if (\str_starts_with($sqlState, '08')) { return new StatementException\ConnectionException($exception, $query); } - if ((int) $exception->getCode() === 23000) { + if (\str_starts_with($sqlState, '23')) { return new StatementException\ConstrainException($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..5387bc20 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 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() + ->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..2fcfd0c9 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,27 @@ public function testPacketsOutOfOrderConsideredAsConnectionException(): void return; } } + + public function testCheckViolationIsAConstrainException(): void + { + $driver = $this->database->getDriver(); + + // 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); + + $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..118642ec 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,18 @@ class ExceptionsTest extends CommonClass { public const DRIVER = 'postgres'; + + public function testExclusionViolationIsAConstrainException(): void + { + $driver = $this->database->getDriver(); + + // 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)')"); + + $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..33017fe1 100644 --- a/tests/Database/Functional/Driver/SQLServer/Connection/ConnectionExceptionTest.php +++ b/tests/Database/Functional/Driver/SQLServer/Connection/ConnectionExceptionTest.php @@ -21,9 +21,9 @@ 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..39d9d803 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,23 @@ class DriverTest extends CommonClass { public const DRIVER = 'sqlserver'; + + public function testCreateDoesNotReachTheServer(): void + { + $config = clone self::$config[static::DRIVER]; + \assert($config instanceof DriverConfig); + + $connection = clone $config->connection; + $connection->password = 'definitely not the password'; + $config->connection = $connection; + + $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); + + $driver->query('SELECT 1'); + } } diff --git a/tests/Database/Unit/Driver/MapExceptionTest.php b/tests/Database/Unit/Driver/MapExceptionTest.php new file mode 100644 index 00000000..d6364b6d --- /dev/null +++ b/tests/Database/Unit/Driver/MapExceptionTest.php @@ -0,0 +1,394 @@ + [ + '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 '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', + 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, + ]; + // 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; 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: Подключение не установлено, т.к. конечный компьютер отверг запрос на подключение.', + 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, + ]; + 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, + ]; + // 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 '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.', + 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", + 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 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( + 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 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 + { + (new \ReflectionProperty(\Exception::class, 'code'))->setValue($exception, $code); + } +}