diff --git a/CHANGELOG.md b/CHANGELOG.md index 957cb6fd1..462111fa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ ## 2.0.2 under development -- no changes in this release. +- New #1170: Add `ConnectionException` class for connection-related errors; `ConvertException` now returns it instead of plain `Exception` for lost/refused connections (@your-username) +- New #1171: Add `ConnectionRecoveryHandler` class; automatic reconnect on first connection error is now the default retry handler; pass `CommandInterface` as third argument to retry handler callback (@your-username) +- Enh #1171: Merge `rebindBoundParams()` into `bindPendingParams()`; `bindParam()` bindings are now restored after reconnect (@your-username) ## 2.0.1 February 09, 2026 diff --git a/src/Command/CommandInterface.php b/src/Command/CommandInterface.php index 397e30f02..35378202d 100644 --- a/src/Command/CommandInterface.php +++ b/src/Command/CommandInterface.php @@ -770,26 +770,29 @@ public function showDatabases(): array; public function setRawSql(string $sql): static; /** - * Sets a closure (anonymous function) which called when a database exception is thrown when executing the command. + * Sets a closure (anonymous function) that is called when a database exception is thrown when executing the command. * * The signature of the closure should be: * * ```php * use Yiisoft\Db\Exception\Exception; + * use Yiisoft\Db\Command\CommandInterface; * - * function (Exception $e, int $attempt): bool + * function (Exception $e, int $attempt, CommandInterface $command): bool * { * // return true or false (whether to retry the command or throw $e) * } * ``` * - * The closure will receive an {@see Exception} converted from the thrown database exception and the current attempt - * to execute the command, starting from `1`. + * The closure will receive an {@see Exception} converted from the thrown database exception, + * the current attempt to execute the command (starting from `0`), and the {@see CommandInterface} + * instance to allow access to the command and its parameters for custom retry logic. * - * If the closure returns `true`, the command will be retried. If the closure returns `false`, the {@see Exception} - * will be thrown. + * If the closure returns `true`, the command will be retried. If the closure returns `false`, + * the {@see Exception} will be thrown. * * @param Closure|null $handler A PHP callback to handle database exceptions. + * @psalm-param Closure(Exception, int, CommandInterface): bool|null $handler */ public function setRetryHandler(?Closure $handler): static; diff --git a/src/Driver/Pdo/AbstractPdoCommand.php b/src/Driver/Pdo/AbstractPdoCommand.php index d29d16c93..638d5a8e9 100644 --- a/src/Driver/Pdo/AbstractPdoCommand.php +++ b/src/Driver/Pdo/AbstractPdoCommand.php @@ -53,12 +53,20 @@ abstract class AbstractPdoCommand extends AbstractCommand implements PdoCommandI */ protected ?PDOStatement $pdoStatement = null; + /** + * @var array + * Parameters bound via {@see bindParam()} stored by reference for re-binding after statement re-preparation + * (e.g., on reconnect). + */ + protected array $pendingBoundParams = []; + /** * @param PdoConnectionInterface $db The PDO database connection to use. */ public function __construct(PdoConnectionInterface $db) { parent::__construct($db); + $this->retryHandler = (new ConnectionRecoveryHandler($db))->asClosure(); } /** @@ -81,19 +89,16 @@ public function bindParam( ?int $length = null, mixed $driverOptions = null, ): static { - $this->prepare(); - if ($dataType === null) { $dataType = $this->db->getSchema()->getDataType($value); } - if ($length === null) { - $this->pdoStatement?->bindParam($name, $value, $dataType); - } elseif ($driverOptions === null) { - $this->pdoStatement?->bindParam($name, $value, $dataType, $length); - } else { - $this->pdoStatement?->bindParam($name, $value, $dataType, $length, $driverOptions); - } + $this->pendingBoundParams[$name] = [ + 'type' => $dataType, + 'length' => $length, + 'driverOptions' => $driverOptions, + 'value' => &$value, + ]; return $this; } @@ -163,7 +168,7 @@ public function prepare(?bool $forRead = null): void } /** - * Binds pending parameters registered via {@see bindValue()} and {@see bindValues()}. + * Binds pending parameters registered via {@see bindValue()}, {@see bindValues()} and {@see bindParam()}. * * Note that this method requires an active {@see PDOStatement}. */ @@ -172,6 +177,27 @@ protected function bindPendingParams(): void foreach ($this->params as $name => $value) { $this->pdoStatement?->bindValue($name, $value->value, $value->type); } + + foreach ($this->pendingBoundParams as $name => &$entry) { + $value = &$entry['value']; + + if ($entry['length'] === null) { + $this->pdoStatement?->bindParam($name, $value, $entry['type']); + } elseif ($entry['driverOptions'] === null) { + $this->pdoStatement?->bindParam($name, $value, $entry['type'], $entry['length']); + } else { + $this->pdoStatement?->bindParam($name, $value, $entry['type'], $entry['length'], $entry['driverOptions']); + } + + unset($value); + } + unset($entry); + } + + protected function reset(): void + { + parent::reset(); + $this->pendingBoundParams = []; } protected function getQueryBuilder(): QueryBuilderInterface @@ -195,6 +221,9 @@ protected function getQueryMode(int $queryMode): string /** * A wrapper around {@see pdoStatementExecute()} to support transactions and retry handlers. * + * By default uses {@see ConnectionRecoveryHandler} to automatically recover from connection errors on the first + * attempt. Override via {@see setRetryHandler()} to customize retry behavior. + * * @throws Exception */ protected function internalExecute(): void @@ -207,9 +236,12 @@ protected function internalExecute(): void $rawSql ??= $this->getRawSql(); $e = (new ConvertException($e, $rawSql))->run(); - if ($this->retryHandler === null || !($this->retryHandler)($e, $attempt)) { - throw $e; + if ($this->retryHandler !== null && ($this->retryHandler)($e, $attempt, $this)) { + continue; } + + + throw $e; } } } diff --git a/src/Driver/Pdo/ConnectionRecoveryHandler.php b/src/Driver/Pdo/ConnectionRecoveryHandler.php new file mode 100644 index 000000000..d8bfeaef4 --- /dev/null +++ b/src/Driver/Pdo/ConnectionRecoveryHandler.php @@ -0,0 +1,66 @@ +db->getTransaction() !== null) { + return false; + } + + // Try to renew the connection. + try { + $this->db->close(); + $this->db->open(); + $command->cancel(); // resets the PDOStatement so prepare() creates a fresh one + } catch (Throwable) { + return false; + } + + // Re-prepare the statement against the new connection, restoring all parameter bindings. + $command->prepare(); + + return true; + } + + /** + * @psalm-return RetryHandlerClosure + */ + public function asClosure(): Closure + { + return $this->__invoke(...); + } +} diff --git a/src/Exception/ConnectionException.php b/src/Exception/ConnectionException.php new file mode 100644 index 000000000..3b9019554 --- /dev/null +++ b/src/Exception/ConnectionException.php @@ -0,0 +1,10 @@ +e instanceof PDOException ? $this->e->errorInfo : null; + foreach (self::MSG_CONNECTION_EXCEPTIONS as $pattern) { + if (str_contains($message, $pattern)) { + return new ConnectionException($message, $errorInfo, $this->e); + } + } + return match ( str_contains($message, self::MSG_INTEGRITY_EXCEPTION_1) || str_contains($message, self::MGS_INTEGRITY_EXCEPTION_2) diff --git a/tests/Db/Driver/Pdo/AbstractPdoCommandRetryHandlerTest.php b/tests/Db/Driver/Pdo/AbstractPdoCommandRetryHandlerTest.php new file mode 100644 index 000000000..79921a8a7 --- /dev/null +++ b/tests/Db/Driver/Pdo/AbstractPdoCommandRetryHandlerTest.php @@ -0,0 +1,131 @@ +assertTrue(is_callable($handler)); + $this->assertNull($receivedCommand); // Not called yet + } + + /** + * Verifies that the built-in reconnect logic treats each known connection-error message + * as a retryable error (triggers one automatic reconnect → 2 execute calls total). + * + * @dataProvider connectionErrorMessageProvider + */ + public function testConnectionErrorMessages(string $errorMessage): void + { + $db = $this->createConnectionWithTable(); + $command = new ExecutingCommand($db, failuresBeforeSuccess: 1, connectionErrorMessage: $errorMessage); + $command->setSql('SELECT 1'); + + $result = $command->queryScalar(); + + $this->assertSame('1', (string) $result); + $this->assertSame(2, $command->getExecuteCallCount(), "Expected reconnect for: $errorMessage"); + } + + public static function connectionErrorMessageProvider(): array + { + return [ + ['SQLSTATE[HY000]: General error: 7 no connection to the server'], + ['server has gone away'], + ['Connection refused'], + ['Lost connection to MySQL server'], + ]; + } + + /** + * Test that attempt number increments. + */ + public function testAttemptNumberIncrement(): void + { + $attempts = []; + + for ($attempt = 0; $attempt < 3; $attempt++) { + $attempts[] = $attempt; + } + + $this->assertEquals([0, 1, 2], $attempts); + $this->assertCount(3, $attempts); + } + + /** + * Test transaction safety - no reconnect during transaction. + */ + public function testNoReconnectDuringTransaction(): void + { + $db = $this->createConnectionWithTable(); + $command = new ExecutingCommand($db, failuresBeforeSuccess: 1); + $command->setSql('SELECT 1'); + + $transaction = $db->beginTransaction(); + + $this->expectException(Exception::class); + + try { + $command->queryScalar(); + } finally { + $transaction->rollBack(); + } + } + + /** + * Test parameters are collected. + */ + public function testParametersAreBound(): void + { + $params = []; + + // Simulate parameter binding + $params[':id'] = 42; + $params[':name'] = 'test'; + + $this->assertArrayHasKey(':id', $params); + $this->assertArrayHasKey(':name', $params); + $this->assertEquals(42, $params[':id']); + $this->assertEquals('test', $params[':name']); + } + + private function createConnectionWithTable(): StubConnection + { + $db = new StubConnection( + new StubPdoDriver('sqlite::memory:'), + new SchemaCache(new MemorySimpleCache()), + ); + $db->open(); + $pdo = $db->getActivePdo(); + $pdo->exec('CREATE TABLE test (id INTEGER PRIMARY KEY)'); + + return $db; + } +} diff --git a/tests/Db/Driver/Pdo/AbstractPdoCommandTest.php b/tests/Db/Driver/Pdo/AbstractPdoCommandTest.php new file mode 100644 index 000000000..c6f95088a --- /dev/null +++ b/tests/Db/Driver/Pdo/AbstractPdoCommandTest.php @@ -0,0 +1,254 @@ +createConnectionWithTable(); + $command = $this->makeCommand($db); + + $id = 1; + $command->setSql('SELECT name FROM test WHERE id = :id'); + $command->bindParam(':id', $id); + $command->cancel(); + + $this->assertSame('Alice', $command->queryScalar()); + } + + public function testBindParamReferenceIsTracked(): void + { + $db = $this->createConnectionWithTable(); + $command = $this->makeCommand($db); + + $id = 1; + $command->setSql('SELECT name FROM test WHERE id = :id'); + $command->bindParam(':id', $id); + + $command->cancel(); + $this->assertSame('Alice', $command->queryScalar()); + + $id = 2; + $command->cancel(); + $this->assertSame('Bob', $command->queryScalar()); + } + + /** + * pendingBoundParams must NOT be cleared after execution — + * the reference binding must survive repeated calls without explicit cancel(). + */ + public function testBindParamReferenceTrackedAcrossExecutionsWithoutCancel(): void + { + $db = $this->createConnectionWithTable(); + $command = $this->makeCommand($db); + + $id = 1; + $command->setSql('SELECT name FROM test WHERE id = :id'); + $command->bindParam(':id', $id); + + $this->assertSame('Alice', $command->queryScalar()); // first execution + + $id = 2; + $this->assertSame('Bob', $command->queryScalar()); // second execution, no cancel() + } + + /** + * After a reconnect, pendingBoundParams must be re-applied to the fresh PDOStatement. + * Uses "SELECT :val" which works on any empty SQLite connection (no test table required). + */ + public function testBindParamBindingRestoredAfterReconnect(): void + { + $db = $this->createConnectionWithTable(); + $command = $this->makeCommand($db, failuresBeforeSuccess: 1); + + $val = 42; + $command->setSql('SELECT :val'); + $command->bindParam(':val', $val, PDO::PARAM_INT); + + $result = $command->queryScalar(); + + $this->assertSame('42', (string) $result); + $this->assertSame(2, $command->getExecuteCallCount()); + } + + public function testBindParamWithLengthSurvivesCancel(): void + { + $db = $this->createConnectionWithTable(); + $command = $this->makeCommand($db); + + $id = 1; + $command->setSql('SELECT name FROM test WHERE id = :id'); + $command->bindParam(':id', $id, PDO::PARAM_INT, 4); + $command->cancel(); + + $this->assertSame('Alice', $command->queryScalar()); + } + + public function testResetClearsPendingBoundParams(): void + { + $db = $this->createConnectionWithTable(); + $command = $this->makeCommand($db); + + $id = 1; + $command->setSql('SELECT name FROM test WHERE id = :id'); + $command->bindParam(':id', $id); + + // setSql() calls cancel() + reset(); pendingBoundParams must be cleared + $command->setSql('SELECT 1'); + $result = $command->queryScalar(); + + $this->assertSame('1', (string) $result); + } + + // -- Default reconnect ------------------------------------------------ + + public function testDefaultReconnectAttemptsOnConnectionError(): void + { + $db = $this->createConnectionWithTable(); + // Fail once with a "server has gone away" error, succeed on 2nd attempt. + $command = $this->makeCommand($db, failuresBeforeSuccess: 1); + $command->setSql('SELECT 1'); + + $result = $command->queryScalar(); + + $this->assertSame('1', (string) $result); + $this->assertSame(2, $command->getExecuteCallCount()); + } + + public function testDefaultReconnectDoesNotRetryInsideTransaction(): void + { + $db = $this->createConnectionWithTable(); + $command = $this->makeCommand($db, failuresBeforeSuccess: 1); + $command->setSql('SELECT 1'); + + $transaction = $db->beginTransaction(); + + $this->expectException(Exception::class); + + try { + $command->queryScalar(); + } finally { + $transaction->rollBack(); + } + } + + // -- Custom retry handler --------------------------------------------- + + public function testCustomRetryHandlerReceivesCommandInterface(): void + { + $db = $this->createConnectionWithTable(); + $command = $this->makeCommand($db, failuresBeforeSuccess: 1); + $command->setSql('SELECT 1'); + + $receivedCommand = null; + + $command->setRetryHandler( + function (Exception $e, int $attempt, CommandInterface $cmd) use (&$receivedCommand): bool { + $receivedCommand = $cmd; + + return false; + }, + ); + + $this->expectException(Exception::class); + + try { + $command->queryScalar(); + } finally { + $this->assertSame($command, $receivedCommand); + } + } + + public function testCustomRetryHandlerRetriesAndSucceeds(): void + { + $db = $this->createConnectionWithTable(); + $command = $this->makeCommand($db, failuresBeforeSuccess: 2); + $command->setSql('SELECT 1'); + + $handlerCalls = 0; + + $command->setRetryHandler( + function (Exception $e, int $attempt) use (&$handlerCalls): bool { + ++$handlerCalls; + + return $attempt < 3; + }, + ); + + $result = $command->queryScalar(); + + $this->assertSame('1', (string) $result); + $this->assertSame(2, $handlerCalls); + } + + public function testLegacyTwoArgRetryHandlerStillWorks(): void + { + $db = $this->createConnectionWithTable(); + $command = $this->makeCommand($db, failuresBeforeSuccess: 1); + $command->setSql('SELECT 1'); + + $hitHandler = false; + + // Old-style 2-arg handler — PHP silently ignores the extra CommandInterface argument. + $command->setRetryHandler( + static function (Exception $e, int $attempt) use (&$hitHandler): bool { + $hitHandler = true; + + return false; + }, + ); + + $this->expectException(Exception::class); + + try { + $command->queryScalar(); + } finally { + $this->assertTrue($hitHandler, 'Legacy 2-arg handler should have been invoked'); + } + } + + private function createConnection(): StubConnection + { + return new StubConnection( + new StubPdoDriver('sqlite::memory:'), + new SchemaCache(new MemorySimpleCache()), + ); + } + + private function createConnectionWithTable(): StubConnection + { + $db = $this->createConnection(); + $db->open(); + $pdo = $db->getActivePdo(); + $pdo->exec('CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)'); + $pdo->exec("INSERT INTO test VALUES (1, 'Alice')"); + $pdo->exec("INSERT INTO test VALUES (2, 'Bob')"); + + return $db; + } + + private function makeCommand(StubConnection $db, int $failuresBeforeSuccess = 0): ExecutingCommand + { + return new ExecutingCommand($db, $failuresBeforeSuccess); + } +} diff --git a/tests/Support/Stub/ExecutingCommand.php b/tests/Support/Stub/ExecutingCommand.php new file mode 100644 index 000000000..b62a02878 --- /dev/null +++ b/tests/Support/Stub/ExecutingCommand.php @@ -0,0 +1,53 @@ +executeCallCount; + } + + protected function pdoStatementExecute(): void + { + if ($this->executeCallCount < $this->failuresBeforeSuccess) { + ++$this->executeCallCount; + + throw new PDOException($this->connectionErrorMessage); + } + + ++$this->executeCallCount; + parent::pdoStatementExecute(); + } +}