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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/Driver/Driver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
}
94 changes: 81 additions & 13 deletions src/Driver/MySQL/MySQLDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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);
}
}
42 changes: 30 additions & 12 deletions src/Driver/Postgres/PostgresDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
41 changes: 24 additions & 17 deletions src/Driver/SQLServer/SQLServerDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Comment thread
roxblnfk marked this conversation as resolved.
$config,
new SQLServerHandler(),
new SQLServerCompiler('[]'),
Expand All @@ -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
Expand Down Expand Up @@ -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) ?? '';
Comment thread
roxblnfk marked this conversation as resolved.

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);
}

Expand Down
2 changes: 1 addition & 1 deletion src/Driver/SQLite/SQLiteDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
24 changes: 24 additions & 0 deletions tests/Database/Functional/Driver/MySQL/Query/ExceptionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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')],
];
}
}
Loading
Loading