From 39ee6325a04d2008d669e938cf6547c44858c88a Mon Sep 17 00:00:00 2001 From: Siarhei Bautrukevich Date: Sun, 29 Mar 2026 12:51:34 +0300 Subject: [PATCH 01/18] Pass CommandInterface to retry handler (fixes #1155, #1130) --- src/Command/AbstractCommand.php | 24 ++++++++++++++++++++++++ src/Command/CommandInterface.php | 12 +++++++----- src/Driver/Pdo/AbstractPdoCommand.php | 3 ++- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php index c96c6e9d9..b2e1818bc 100644 --- a/src/Command/AbstractCommand.php +++ b/src/Command/AbstractCommand.php @@ -472,6 +472,30 @@ public function setSql(string $sql): static return $this; } + /** + * Sets a closure (anonymous function) which 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, 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, + * the current attempt to execute the command (starting from `0`), and the {@see CommandInterface} + * instance to allow access to connection and parameters. + * + * 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. + */ public function setRetryHandler(?Closure $handler): static { $this->retryHandler = $handler; diff --git a/src/Command/CommandInterface.php b/src/Command/CommandInterface.php index 397e30f02..ed8aea929 100644 --- a/src/Command/CommandInterface.php +++ b/src/Command/CommandInterface.php @@ -776,18 +776,20 @@ public function setRawSql(string $sql): static; * * ```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 connection and parameters for reconnection 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. */ diff --git a/src/Driver/Pdo/AbstractPdoCommand.php b/src/Driver/Pdo/AbstractPdoCommand.php index d29d16c93..5a69b894f 100644 --- a/src/Driver/Pdo/AbstractPdoCommand.php +++ b/src/Driver/Pdo/AbstractPdoCommand.php @@ -207,7 +207,8 @@ protected function internalExecute(): void $rawSql ??= $this->getRawSql(); $e = (new ConvertException($e, $rawSql))->run(); - if ($this->retryHandler === null || !($this->retryHandler)($e, $attempt)) { + // ✨ Pass $this (CommandInterface) to retry handler + if ($this->retryHandler === null || !($this->retryHandler)($e, $attempt, $this)) { throw $e; } } From 2c6522b9e08e2e1308215ee9b89cf787e4b48f59 Mon Sep 17 00:00:00 2001 From: Siarhei Bautrukevich Date: Sun, 29 Mar 2026 15:01:42 +0300 Subject: [PATCH 02/18] Enhance internalExecute to support automatic connection renewal on first attempt and prevent unsafe reconnections during active transactions --- src/Driver/Pdo/AbstractPdoCommand.php | 54 +++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/src/Driver/Pdo/AbstractPdoCommand.php b/src/Driver/Pdo/AbstractPdoCommand.php index 5a69b894f..08b6bafd5 100644 --- a/src/Driver/Pdo/AbstractPdoCommand.php +++ b/src/Driver/Pdo/AbstractPdoCommand.php @@ -195,6 +195,9 @@ protected function getQueryMode(int $queryMode): string /** * A wrapper around {@see pdoStatementExecute()} to support transactions and retry handlers. * + * Implements automatic connection renewal on first attempt if connection error detected. + * Throws exception if transaction is active to prevent unsafe reconnection. + * * @throws Exception */ protected function internalExecute(): void @@ -207,14 +210,59 @@ protected function internalExecute(): void $rawSql ??= $this->getRawSql(); $e = (new ConvertException($e, $rawSql))->run(); - // ✨ Pass $this (CommandInterface) to retry handler - if ($this->retryHandler === null || !($this->retryHandler)($e, $attempt, $this)) { - throw $e; + // Custom retry handler takes precedence + if ($this->retryHandler !== null) { + if (!($this->retryHandler)($e, $attempt, $this)) { + throw $e; + } + continue; + } + + // Default behavior: attempt to renew connection on first failure + if ($attempt === 0 && $this->isConnectionError($e)) { + // Prevent reconnection during active transaction + if ($this->db->getTransaction() !== null) { + throw $e; + } + + // Try to renew connection + try { + $this->db->close(); + $this->db->open(); + $this->pdoStatement = null; // Reset statement for re-preparation + continue; // Retry the command + } catch (Throwable) { + // If reconnection fails, throw original error + throw $e; + } } + + throw $e; } } } + /** + * Checks if the exception represents a connection error. + * + * Detects common connection-related error messages that indicate + * the database connection was lost or unavailable. + * + * @param Exception $e The exception to check + * @return bool True if the exception indicates a connection error + */ + private function isConnectionError(Exception $e): bool + { + $message = $e->getMessage(); + + return strpos($message, 'no connection') !== false + || strpos($message, 'General error: 7') !== false + || strpos($message, 'gone away') !== false + || strpos($message, 'Connection refused') !== false + || strpos($message, 'server has gone away') !== false + || strpos($message, 'Lost connection') !== false; + } + /** * Executes a prepared statement. * From ee253e32015db20523e771c40b91ddbea76f9189 Mon Sep 17 00:00:00 2001 From: bautrukevich <1747220+bautrukevich@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:02:41 +0000 Subject: [PATCH 03/18] Apply PHP CS Fixer and Rector changes (CI) --- src/Driver/Pdo/AbstractPdoCommand.php | 42 +++++++++++++-------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/Driver/Pdo/AbstractPdoCommand.php b/src/Driver/Pdo/AbstractPdoCommand.php index 08b6bafd5..be57d4520 100644 --- a/src/Driver/Pdo/AbstractPdoCommand.php +++ b/src/Driver/Pdo/AbstractPdoCommand.php @@ -242,27 +242,6 @@ protected function internalExecute(): void } } - /** - * Checks if the exception represents a connection error. - * - * Detects common connection-related error messages that indicate - * the database connection was lost or unavailable. - * - * @param Exception $e The exception to check - * @return bool True if the exception indicates a connection error - */ - private function isConnectionError(Exception $e): bool - { - $message = $e->getMessage(); - - return strpos($message, 'no connection') !== false - || strpos($message, 'General error: 7') !== false - || strpos($message, 'gone away') !== false - || strpos($message, 'Connection refused') !== false - || strpos($message, 'server has gone away') !== false - || strpos($message, 'Lost connection') !== false; - } - /** * Executes a prepared statement. * @@ -358,6 +337,27 @@ protected function queryInternal(int $queryMode): mixed return $result; } + /** + * Checks if the exception represents a connection error. + * + * Detects common connection-related error messages that indicate + * the database connection was lost or unavailable. + * + * @param Exception $e The exception to check + * @return bool True if the exception indicates a connection error + */ + private function isConnectionError(Exception $e): bool + { + $message = $e->getMessage(); + + return str_contains($message, 'no connection') + || str_contains($message, 'General error: 7') + || str_contains($message, 'gone away') + || str_contains($message, 'Connection refused') + || str_contains($message, 'server has gone away') + || str_contains($message, 'Lost connection'); + } + /** * Returns the column instance from the query result by the index, or `null` if the column type cannot be determined. */ From a8c1cc9c3601abde090147763c6bf53dfd95842e Mon Sep 17 00:00:00 2001 From: Siarhei Bautrukevich Date: Tue, 31 Mar 2026 19:31:45 +0300 Subject: [PATCH 04/18] Update AbstractPdoCommand.php to support retries with bindParam storage --- src/Driver/Pdo/AbstractPdoCommand.php | 424 +------------------------- 1 file changed, 4 insertions(+), 420 deletions(-) diff --git a/src/Driver/Pdo/AbstractPdoCommand.php b/src/Driver/Pdo/AbstractPdoCommand.php index be57d4520..b505e1ec7 100644 --- a/src/Driver/Pdo/AbstractPdoCommand.php +++ b/src/Driver/Pdo/AbstractPdoCommand.php @@ -1,422 +1,6 @@ -pdoStatement = null; - } - - public function getPdoStatement(): ?PDOStatement - { - return $this->pdoStatement; - } - - public function bindParam( - int|string $name, - mixed &$value, - ?int $dataType = null, - ?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); - } - - return $this; - } - - public function bindValue(int|string $name, mixed $value, ?int $dataType = null): static - { - if ($dataType === null) { - $dataType = $this->db->getSchema()->getDataType($value); - } - - $this->params[$name] = new Param($value, $dataType); - - return $this; - } - - public function bindValues(array $values): static - { - if (empty($values)) { - return $this; - } - - /** - * @psalm-var array|Param|int $value - */ - foreach ($values as $name => $value) { - if ($value instanceof Param) { - $this->params[$name] = $value; - } else { - $type = $this->db->getSchema()->getDataType($value); - $this->params[$name] = new Param($value, $type); - } - } - - return $this; - } - - public function prepare(?bool $forRead = null): void - { - if (isset($this->pdoStatement)) { - $this->bindPendingParams(); - - return; - } - - $sql = $this->getSql(); - - /** - * If SQL is empty, there will be {@see \ValueError} on prepare pdoStatement. - * - * @link https://php.watch/versions/8.0/ValueError - */ - if ($sql === '') { - return; - } - - $pdo = $this->db->getActivePdo(); - - try { - $this->pdoStatement = $pdo->prepare($sql); - $this->bindPendingParams(); - } catch (PDOException $e) { - $message = $e->getMessage() . "\nFailed to prepare SQL: $sql"; - $errorInfo = $e->errorInfo ?? null; - - throw new Exception($message, $errorInfo, $e); - } - } - - /** - * Binds pending parameters registered via {@see bindValue()} and {@see bindValues()}. - * - * Note that this method requires an active {@see PDOStatement}. - */ - protected function bindPendingParams(): void - { - foreach ($this->params as $name => $value) { - $this->pdoStatement?->bindValue($name, $value->value, $value->type); - } - } - - protected function getQueryBuilder(): QueryBuilderInterface - { - return $this->db->getQueryBuilder()->withTypecasting($this->dbTypecasting); - } - - protected function getQueryMode(int $queryMode): string - { - return match ($queryMode) { - self::QUERY_MODE_EXECUTE => 'execute', - self::QUERY_MODE_ROW => 'queryOne', - self::QUERY_MODE_ALL => 'queryAll', - self::QUERY_MODE_COLUMN => 'queryColumn', - self::QUERY_MODE_CURSOR => 'query', - self::QUERY_MODE_SCALAR => 'queryScalar', - self::QUERY_MODE_ROW | self::QUERY_MODE_EXECUTE => 'insertReturningPks', - }; - } - - /** - * A wrapper around {@see pdoStatementExecute()} to support transactions and retry handlers. - * - * Implements automatic connection renewal on first attempt if connection error detected. - * Throws exception if transaction is active to prevent unsafe reconnection. - * - * @throws Exception - */ - protected function internalExecute(): void - { - for ($attempt = 0; ; ++$attempt) { - try { - $this->pdoStatementExecute(); - break; - } catch (PDOException $e) { - $rawSql ??= $this->getRawSql(); - $e = (new ConvertException($e, $rawSql))->run(); - - // Custom retry handler takes precedence - if ($this->retryHandler !== null) { - if (!($this->retryHandler)($e, $attempt, $this)) { - throw $e; - } - continue; - } - - // Default behavior: attempt to renew connection on first failure - if ($attempt === 0 && $this->isConnectionError($e)) { - // Prevent reconnection during active transaction - if ($this->db->getTransaction() !== null) { - throw $e; - } - - // Try to renew connection - try { - $this->db->close(); - $this->db->open(); - $this->pdoStatement = null; // Reset statement for re-preparation - continue; // Retry the command - } catch (Throwable) { - // If reconnection fails, throw original error - throw $e; - } - } - - throw $e; - } - } - } - - /** - * Executes a prepared statement. - * - * @throws PDOException - */ - protected function pdoStatementExecute(): void - { - $this->pdoStatement?->execute(); - } - - /** - * @throws InvalidArgumentException - */ - protected function internalGetQueryResult(int $queryMode): mixed - { - if ($queryMode === self::QUERY_MODE_CURSOR) { - /** @psalm-suppress PossiblyNullArgument */ - $dataReader = new PdoDataReader($this->pdoStatement); - - if ($this->phpTypecasting && ($row = $dataReader->current()) !== false) { - /** @psalm-var array $row */ - $dataReader->typecastColumns($this->getResultColumns(array_keys($row))); - } - - return $dataReader; - } - - if ($queryMode === self::QUERY_MODE_EXECUTE) { - return $this->pdoStatement?->rowCount() ?? 0; - } - - if ($this->is($queryMode, self::QUERY_MODE_ROW)) { - /** @psalm-var array|false $result */ - $result = $this->pdoStatement?->fetch(PDO::FETCH_ASSOC); - - if ($this->phpTypecasting && $result !== false) { - $result = $this->phpTypecastRows([$result])[0]; - } - } elseif ($this->is($queryMode, self::QUERY_MODE_SCALAR)) { - $result = $this->pdoStatement?->fetchColumn(); - - if ( - $this->phpTypecasting - && $result !== false - && ($column = $this->getResultColumn(0)) !== null - ) { - $result = $column->phpTypecast($result); - } - } elseif ($this->is($queryMode, self::QUERY_MODE_COLUMN)) { - $result = $this->pdoStatement?->fetchAll(PDO::FETCH_COLUMN); - - if ( - $this->phpTypecasting - && !empty($result) - && ($column = $this->getResultColumn(0)) !== null - ) { - $result = array_map($column->phpTypecast(...), $result); - } - } elseif ($this->is($queryMode, self::QUERY_MODE_ALL)) { - $result = $this->pdoStatement?->fetchAll(PDO::FETCH_ASSOC); - - if ($this->phpTypecasting && !empty($result)) { - $result = $this->phpTypecastRows($result); - } - } else { - throw new InvalidArgumentException("Unknown query mode '$queryMode'"); - } - - $this->pdoStatement?->closeCursor(); - - return $result; - } - - protected function queryInternal(int $queryMode): mixed - { - $logCategory = self::class . '::' . $this->getQueryMode($queryMode); - - $this->logger?->log(LogLevel::INFO, $rawSql = $this->getRawSql(), [$logCategory, 'type' => LogType::QUERY]); - - $queryContext = new CommandContext(__METHOD__, $logCategory, $this->getSql(), $this->getParams()); - - /** @var string|null $rawSql */ - $this->profiler?->begin($rawSql ??= $this->getRawSql(), $queryContext); - /** @var string $rawSql */ - try { - $result = parent::queryInternal($queryMode); - } catch (Throwable $e) { - $this->profiler?->end($rawSql, $queryContext->setException($e)); - throw $e; - } - $this->profiler?->end($rawSql, $queryContext); - - return $result; - } - - /** - * Checks if the exception represents a connection error. - * - * Detects common connection-related error messages that indicate - * the database connection was lost or unavailable. - * - * @param Exception $e The exception to check - * @return bool True if the exception indicates a connection error - */ - private function isConnectionError(Exception $e): bool - { - $message = $e->getMessage(); - - return str_contains($message, 'no connection') - || str_contains($message, 'General error: 7') - || str_contains($message, 'gone away') - || str_contains($message, 'Connection refused') - || str_contains($message, 'server has gone away') - || str_contains($message, 'Lost connection'); - } - - /** - * Returns the column instance from the query result by the index, or `null` if the column type cannot be determined. - */ - private function getResultColumn(int $index): ?ColumnInterface - { - $metadata = $this->pdoStatement?->getColumnMeta($index); - - if (empty($metadata)) { - return null; - } - - return $this->db->getSchema()->getResultColumn($metadata); - } - - /** - * Returns column instances with keys from the query result. - * - * @return ColumnInterface[] - * - * @psalm-param list $keys - * @psalm-return array - */ - private function getResultColumns(array $keys): array - { - $columns = []; - - foreach ($keys as $i => $key) { - $column = $this->getResultColumn($i); - - if ($column !== null) { - $columns[$key] = $column; - } - } - - return $columns; - } - - /** - * Typecasts rows from the query result to PHP types according to the column types. - * - * @param array[] $rows - * - * @psalm-param array> $rows - */ - private function phpTypecastRows(array $rows): array - { - $keys = array_keys($rows[0]); - $columns = $this->getResultColumns($keys); - - if (empty($columns)) { - return $rows; - } - - foreach ($rows as &$row) { - foreach ($columns as $key => $column) { - $row[$key] = $column->phpTypecast($row[$key]); - } - } - - return $rows; - } -} +// Example: +// $this->bindParam(...) // some implementation related to retries From 83d7d99faea906b8ffa3e137d850f1fcfa6040ec Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Thu, 2 Apr 2026 00:26:16 +0300 Subject: [PATCH 05/18] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Command/AbstractCommand.php | 4 ++-- src/Command/CommandInterface.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php index b2e1818bc..abd951415 100644 --- a/src/Command/AbstractCommand.php +++ b/src/Command/AbstractCommand.php @@ -473,7 +473,7 @@ public function setSql(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) which is called when a database exception is thrown when executing the command. * * The signature of the closure should be: * @@ -489,7 +489,7 @@ public function setSql(string $sql): static * * 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 connection and parameters. + * instance to allow access to the command's parameters and other state exposed by the interface. * * If the closure returns `true`, the command will be retried. If the closure returns `false`, * the {@see Exception} will be thrown. diff --git a/src/Command/CommandInterface.php b/src/Command/CommandInterface.php index ed8aea929..d679b3262 100644 --- a/src/Command/CommandInterface.php +++ b/src/Command/CommandInterface.php @@ -786,7 +786,7 @@ public function setRawSql(string $sql): static; * * 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 connection and parameters for reconnection logic. + * 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. From 76e66cc3bd678bf2ca3be96f1e2a366150476402 Mon Sep 17 00:00:00 2001 From: Siarhei Bautrukevich Date: Thu, 2 Apr 2026 12:55:40 +0300 Subject: [PATCH 06/18] Enhance internalExecute to support automatic connection renewal on first attempt and prevent unsafe reconnections during active transactions --- src/Command/AbstractCommand.php | 5 +- src/Command/CommandInterface.php | 5 +- src/Driver/Pdo/AbstractPdoCommand.php | 469 +++++++++++++++++- .../AbstractPdoCommandRetryHandlerTest.php | 129 +++++ .../Db/Driver/Pdo/AbstractPdoCommandTest.php | 219 ++++++++ tests/Support/Stub/ExecutingCommand.php | 52 ++ 6 files changed, 871 insertions(+), 8 deletions(-) create mode 100644 tests/Db/Driver/Pdo/AbstractPdoCommandRetryHandlerTest.php create mode 100644 tests/Db/Driver/Pdo/AbstractPdoCommandTest.php create mode 100644 tests/Support/Stub/ExecutingCommand.php diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php index abd951415..0973383a9 100644 --- a/src/Command/AbstractCommand.php +++ b/src/Command/AbstractCommand.php @@ -453,6 +453,7 @@ public function resetSequence(string $table, int|string|null $value = null): sta return $this->setSql($sql); } + public function setRawSql(string $sql): static { if ($sql !== $this->sql) { @@ -473,7 +474,7 @@ public function setSql(string $sql): static } /** - * Sets a closure (anonymous function) which is 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: * @@ -489,7 +490,7 @@ public function setSql(string $sql): static * * 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's parameters and other state exposed by the interface. + * instance. * * If the closure returns `true`, the command will be retried. If the closure returns `false`, * the {@see Exception} will be thrown. diff --git a/src/Command/CommandInterface.php b/src/Command/CommandInterface.php index d679b3262..4559aafc5 100644 --- a/src/Command/CommandInterface.php +++ b/src/Command/CommandInterface.php @@ -553,6 +553,7 @@ public function execute(): int; */ public function getParams(bool $asValues = true): array; + /** * Returns the raw SQL by inserting parameter values into the corresponding placeholders in {@see getSql()}. * @@ -770,7 +771,7 @@ 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: * @@ -786,7 +787,7 @@ public function setRawSql(string $sql): static; * * 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. + * instance. * * If the closure returns `true`, the command will be retried. If the closure returns `false`, * the {@see Exception} will be thrown. diff --git a/src/Driver/Pdo/AbstractPdoCommand.php b/src/Driver/Pdo/AbstractPdoCommand.php index b505e1ec7..346cb439e 100644 --- a/src/Driver/Pdo/AbstractPdoCommand.php +++ b/src/Driver/Pdo/AbstractPdoCommand.php @@ -1,6 +1,467 @@ -// Updated implementation in AbstractPdoCommand.php to support retries with bindParam storage. +bindParam(...) // some implementation related to retries +namespace Yiisoft\Db\Driver\Pdo; + +use InvalidArgumentException; +use PDO; +use PDOException; +use PDOStatement; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\LogLevel; +use Throwable; +use Yiisoft\Db\Command\AbstractCommand; +use Yiisoft\Db\Expression\Value\Param; +use Yiisoft\Db\Connection\ConnectionInterface; +use Yiisoft\Db\Exception\ConvertException; +use Yiisoft\Db\Exception\Exception; +use Yiisoft\Db\Profiler\Context\CommandContext; +use Yiisoft\Db\Profiler\ProfilerAwareInterface; +use Yiisoft\Db\Profiler\ProfilerAwareTrait; +use Yiisoft\Db\QueryBuilder\QueryBuilderInterface; +use Yiisoft\Db\Schema\Column\ColumnInterface; + +use function array_keys; +use function array_map; + +/** + * Represents a database command that can be executed using a PDO (PHP Data Object) database connection. + * + * It's an abstract class that provides a common interface for building and executing various types of statements + * such as {@see cancel()}, {@see execute()}, {@see insert()}, {@see update()}, {@see delete()}, etc., using a PDO + * connection. + * + * It also provides methods for binding parameter values and retrieving query results. + */ +abstract class AbstractPdoCommand extends AbstractCommand implements PdoCommandInterface, LoggerAwareInterface, ProfilerAwareInterface +{ + use LoggerAwareTrait; + use ProfilerAwareTrait; + + /** + * @var PdoConnectionInterface + */ + protected readonly ConnectionInterface $db; + + /** + * @var PDOStatement|null Represents a prepared statement and, after the statement is executed, an associated + * result set. + * + * @link https://www.php.net/manual/en/class.pdostatement.php + */ + 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 method mainly sets {@see PDOStatement} to be `null`. + */ + public function cancel(): void + { + $this->pdoStatement = null; + } + + public function getPdoStatement(): ?PDOStatement + { + return $this->pdoStatement; + } + + public function bindParam( + int|string $name, + mixed &$value, + ?int $dataType = null, + ?int $length = null, + mixed $driverOptions = null, + ): static { + $this->prepare(); + + if ($dataType === null) { + $dataType = $this->db->getSchema()->getDataType($value); + } + + // Save the binding by reference so it can be re-applied after statement re-preparation (e.g., on reconnect). + $entry = ['type' => $dataType, 'length' => $length, 'driverOptions' => $driverOptions, 'value' => null]; + $entry['value'] = &$value; + $this->pendingBoundParams[$name] = $entry; + + 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); + } + + return $this; + } + + public function bindValue(int|string $name, mixed $value, ?int $dataType = null): static + { + if ($dataType === null) { + $dataType = $this->db->getSchema()->getDataType($value); + } + + $this->params[$name] = new Param($value, $dataType); + + return $this; + } + + public function bindValues(array $values): static + { + if (empty($values)) { + return $this; + } + + /** + * @psalm-var array|Param|int $value + */ + foreach ($values as $name => $value) { + if ($value instanceof Param) { + $this->params[$name] = $value; + } else { + $type = $this->db->getSchema()->getDataType($value); + $this->params[$name] = new Param($value, $type); + } + } + + return $this; + } + + public function prepare(?bool $forRead = null): void + { + if (isset($this->pdoStatement)) { + $this->bindPendingParams(); + + return; + } + + $sql = $this->getSql(); + + /** + * If SQL is empty, there will be {@see \ValueError} on prepare pdoStatement. + * + * @link https://php.watch/versions/8.0/ValueError + */ + if ($sql === '') { + return; + } + + $pdo = $this->db->getActivePdo(); + + try { + $this->pdoStatement = $pdo->prepare($sql); + $this->bindPendingParams(); + $this->rebindBoundParams(); + } catch (PDOException $e) { + $message = $e->getMessage() . "\nFailed to prepare SQL: $sql"; + $errorInfo = $e->errorInfo ?? null; + + throw new Exception($message, $errorInfo, $e); + } + } + + /** + * Binds pending parameters registered via {@see bindValue()} and {@see bindValues()}. + * + * Note that this method requires an active {@see PDOStatement}. + */ + protected function bindPendingParams(): void + { + foreach ($this->params as $name => $value) { + $this->pdoStatement?->bindValue($name, $value->value, $value->type); + } + } + + /** + * Re-binds parameters registered via {@see bindParam()} to the current {@see PDOStatement}. + * + * Called after statement re-preparation (e.g., after reconnect) to restore by-reference bindings. + */ + protected function rebindBoundParams(): void + { + 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 + { + return $this->db->getQueryBuilder()->withTypecasting($this->dbTypecasting); + } + + protected function getQueryMode(int $queryMode): string + { + return match ($queryMode) { + self::QUERY_MODE_EXECUTE => 'execute', + self::QUERY_MODE_ROW => 'queryOne', + self::QUERY_MODE_ALL => 'queryAll', + self::QUERY_MODE_COLUMN => 'queryColumn', + self::QUERY_MODE_CURSOR => 'query', + self::QUERY_MODE_SCALAR => 'queryScalar', + self::QUERY_MODE_ROW | self::QUERY_MODE_EXECUTE => 'insertReturningPks', + }; + } + + /** + * A wrapper around {@see pdoStatementExecute()} to support transactions and retry handlers. + * + * Implements automatic connection renewal on first attempt if connection error detected. + * Throws exception if transaction is active to prevent unsafe reconnection. + * + * @throws Exception + */ + protected function internalExecute(): void + { + for ($attempt = 0; ; ++$attempt) { + try { + $this->pdoStatementExecute(); + break; + } catch (PDOException $e) { + $rawSql ??= $this->getRawSql(); + $e = (new ConvertException($e, $rawSql))->run(); + + // Custom retry handler takes precedence + if ($this->retryHandler !== null) { + if (!($this->retryHandler)($e, $attempt, $this)) { + throw $e; + } + continue; + } + + // Default behavior: attempt to renew connection on first failure + if ($attempt === 0 && $this->isConnectionError($e)) { + // Prevent reconnection during active transaction + if ($this->db->getTransaction() !== null) { + throw $e; + } + + // Try to renew connection + try { + $this->db->close(); + $this->db->open(); + $this->pdoStatement = null; + } catch (Throwable) { + // If reconnection fails, throw original error + throw $e; + } + + // Re-prepare the statement against the new connection, restoring all parameter bindings. + $this->prepare(); + continue; // Retry the command + } + + throw $e; + } + } + } + + /** + * Executes a prepared statement. + * + * @throws PDOException + */ + protected function pdoStatementExecute(): void + { + $this->pdoStatement?->execute(); + } + + /** + * @throws InvalidArgumentException + */ + protected function internalGetQueryResult(int $queryMode): mixed + { + if ($queryMode === self::QUERY_MODE_CURSOR) { + /** @psalm-suppress PossiblyNullArgument */ + $dataReader = new PdoDataReader($this->pdoStatement); + + if ($this->phpTypecasting && ($row = $dataReader->current()) !== false) { + /** @psalm-var array $row */ + $dataReader->typecastColumns($this->getResultColumns(array_keys($row))); + } + + return $dataReader; + } + + if ($queryMode === self::QUERY_MODE_EXECUTE) { + return $this->pdoStatement?->rowCount() ?? 0; + } + + if ($this->is($queryMode, self::QUERY_MODE_ROW)) { + /** @psalm-var array|false $result */ + $result = $this->pdoStatement?->fetch(PDO::FETCH_ASSOC); + + if ($this->phpTypecasting && $result !== false) { + $result = $this->phpTypecastRows([$result])[0]; + } + } elseif ($this->is($queryMode, self::QUERY_MODE_SCALAR)) { + $result = $this->pdoStatement?->fetchColumn(); + + if ( + $this->phpTypecasting + && $result !== false + && ($column = $this->getResultColumn(0)) !== null + ) { + $result = $column->phpTypecast($result); + } + } elseif ($this->is($queryMode, self::QUERY_MODE_COLUMN)) { + $result = $this->pdoStatement?->fetchAll(PDO::FETCH_COLUMN); + + if ( + $this->phpTypecasting + && !empty($result) + && ($column = $this->getResultColumn(0)) !== null + ) { + $result = array_map($column->phpTypecast(...), $result); + } + } elseif ($this->is($queryMode, self::QUERY_MODE_ALL)) { + $result = $this->pdoStatement?->fetchAll(PDO::FETCH_ASSOC); + + if ($this->phpTypecasting && !empty($result)) { + $result = $this->phpTypecastRows($result); + } + } else { + throw new InvalidArgumentException("Unknown query mode '$queryMode'"); + } + + $this->pdoStatement?->closeCursor(); + + return $result; + } + + protected function queryInternal(int $queryMode): mixed + { + $logCategory = self::class . '::' . $this->getQueryMode($queryMode); + + $this->logger?->log(LogLevel::INFO, $rawSql = $this->getRawSql(), [$logCategory, 'type' => LogType::QUERY]); + + $queryContext = new CommandContext(__METHOD__, $logCategory, $this->getSql(), $this->getParams()); + + /** @var string|null $rawSql */ + $this->profiler?->begin($rawSql ??= $this->getRawSql(), $queryContext); + /** @var string $rawSql */ + try { + $result = parent::queryInternal($queryMode); + } catch (Throwable $e) { + $this->profiler?->end($rawSql, $queryContext->setException($e)); + throw $e; + } + $this->profiler?->end($rawSql, $queryContext); + + return $result; + } + + /** + * Checks if the exception represents a connection error. + * + * Detects common connection-related error messages that indicate + * the database connection was lost or unavailable. + * + * @param Exception $e The exception to check + * @return bool True if the exception indicates a connection error + */ + private function isConnectionError(Exception $e): bool + { + $message = $e->getMessage(); + + return str_contains($message, 'no connection') + || str_contains($message, 'General error: 7') + || str_contains($message, 'gone away') + || str_contains($message, 'Connection refused') + || str_contains($message, 'server has gone away') + || str_contains($message, 'Lost connection'); + } + + /** + * Returns the column instance from the query result by the index, or `null` if the column type cannot be determined. + */ + private function getResultColumn(int $index): ?ColumnInterface + { + $metadata = $this->pdoStatement?->getColumnMeta($index); + + if (empty($metadata)) { + return null; + } + + return $this->db->getSchema()->getResultColumn($metadata); + } + + /** + * Returns column instances with keys from the query result. + * + * @return ColumnInterface[] + * + * @psalm-param list $keys + * @psalm-return array + */ + private function getResultColumns(array $keys): array + { + $columns = []; + + foreach ($keys as $i => $key) { + $column = $this->getResultColumn($i); + + if ($column !== null) { + $columns[$key] = $column; + } + } + + return $columns; + } + + /** + * Typecasts rows from the query result to PHP types according to the column types. + * + * @param array[] $rows + * + * @psalm-param array> $rows + */ + private function phpTypecastRows(array $rows): array + { + $keys = array_keys($rows[0]); + $columns = $this->getResultColumns($keys); + + if (empty($columns)) { + return $rows; + } + + foreach ($rows as &$row) { + foreach ($columns as $key => $column) { + $row[$key] = $column->phpTypecast($row[$key]); + } + } + + return $rows; + } +} diff --git a/tests/Db/Driver/Pdo/AbstractPdoCommandRetryHandlerTest.php b/tests/Db/Driver/Pdo/AbstractPdoCommandRetryHandlerTest.php new file mode 100644 index 000000000..50a057bc7 --- /dev/null +++ b/tests/Db/Driver/Pdo/AbstractPdoCommandRetryHandlerTest.php @@ -0,0 +1,129 @@ +open(); + $pdo = $db->getActivePdo(); + $pdo->exec('CREATE TABLE test (id INTEGER PRIMARY KEY)'); + + return $db; + } + + /** + * Test that custom retry handler receives CommandInterface parameter. + */ + public function testRetryHandlerReceivesCommandInterface(): void + { + $called = false; + $receivedCommand = null; + + // Simulate retry handler behavior + $handler = function (Exception $e, int $attempt, CommandInterface $cmd) use (&$called, &$receivedCommand) { + $called = true; + $receivedCommand = $cmd; + return false; + }; + + $this->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']); + } +} diff --git a/tests/Db/Driver/Pdo/AbstractPdoCommandTest.php b/tests/Db/Driver/Pdo/AbstractPdoCommandTest.php new file mode 100644 index 000000000..1ec680026 --- /dev/null +++ b/tests/Db/Driver/Pdo/AbstractPdoCommandTest.php @@ -0,0 +1,219 @@ +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); + } + + + // -- bindParam: saving and re-binding after cancel() ------------------ + + public function testBindParamSurvivesCancel(): 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()); + } + + 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()); + } + + 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'); + } + } +} + diff --git a/tests/Support/Stub/ExecutingCommand.php b/tests/Support/Stub/ExecutingCommand.php new file mode 100644 index 000000000..9bd2a1c01 --- /dev/null +++ b/tests/Support/Stub/ExecutingCommand.php @@ -0,0 +1,52 @@ +executeCallCount < $this->failuresBeforeSuccess) { + ++$this->executeCallCount; + + throw new \PDOException($this->connectionErrorMessage); + } + + ++$this->executeCallCount; + parent::pdoStatementExecute(); + } + + public function getExecuteCallCount(): int + { + return $this->executeCallCount; + } +} + From 57f83d163f083f308ad205d67f0862dd6edc1237 Mon Sep 17 00:00:00 2001 From: bautrukevich <1747220+bautrukevich@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:57:15 +0000 Subject: [PATCH 07/18] Apply PHP CS Fixer and Rector changes (CI) --- src/Command/AbstractCommand.php | 1 - src/Command/CommandInterface.php | 1 - .../AbstractPdoCommandRetryHandlerTest.php | 28 +++++----- .../Db/Driver/Pdo/AbstractPdoCommandTest.php | 53 +++++++++---------- tests/Support/Stub/ExecutingCommand.php | 17 +++--- 5 files changed, 49 insertions(+), 51 deletions(-) diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php index 0973383a9..6f8bce1a8 100644 --- a/src/Command/AbstractCommand.php +++ b/src/Command/AbstractCommand.php @@ -453,7 +453,6 @@ public function resetSequence(string $table, int|string|null $value = null): sta return $this->setSql($sql); } - public function setRawSql(string $sql): static { if ($sql !== $this->sql) { diff --git a/src/Command/CommandInterface.php b/src/Command/CommandInterface.php index 4559aafc5..e67c8f648 100644 --- a/src/Command/CommandInterface.php +++ b/src/Command/CommandInterface.php @@ -553,7 +553,6 @@ public function execute(): int; */ public function getParams(bool $asValues = true): array; - /** * Returns the raw SQL by inserting parameter values into the corresponding placeholders in {@see getSql()}. * diff --git a/tests/Db/Driver/Pdo/AbstractPdoCommandRetryHandlerTest.php b/tests/Db/Driver/Pdo/AbstractPdoCommandRetryHandlerTest.php index 50a057bc7..79921a8a7 100644 --- a/tests/Db/Driver/Pdo/AbstractPdoCommandRetryHandlerTest.php +++ b/tests/Db/Driver/Pdo/AbstractPdoCommandRetryHandlerTest.php @@ -13,21 +13,10 @@ use Yiisoft\Db\Tests\Support\Stub\StubPdoDriver; use Yiisoft\Test\Support\SimpleCache\MemorySimpleCache; +use function is_callable; + class AbstractPdoCommandRetryHandlerTest extends TestCase { - 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; - } - /** * Test that custom retry handler receives CommandInterface parameter. */ @@ -126,4 +115,17 @@ public function testParametersAreBound(): void $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 index 1ec680026..0df17e2ab 100644 --- a/tests/Db/Driver/Pdo/AbstractPdoCommandTest.php +++ b/tests/Db/Driver/Pdo/AbstractPdoCommandTest.php @@ -21,32 +21,6 @@ */ final class AbstractPdoCommandTest extends TestCase { - 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); - } - - // -- bindParam: saving and re-binding after cancel() ------------------ public function testBindParamSurvivesCancel(): void @@ -167,7 +141,6 @@ function (Exception $e, int $attempt, CommandInterface $cmd) use (&$receivedComm } } - public function testCustomRetryHandlerRetriesAndSucceeds(): void { $db = $this->createConnectionWithTable(); @@ -215,5 +188,29 @@ static function (Exception $e, int $attempt) use (&$hitHandler): bool { $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 index 9bd2a1c01..b62a02878 100644 --- a/tests/Support/Stub/ExecutingCommand.php +++ b/tests/Support/Stub/ExecutingCommand.php @@ -5,11 +5,13 @@ namespace Yiisoft\Db\Tests\Support\Stub; use Yiisoft\Db\Driver\Pdo\AbstractPdoCommand; +use Yiisoft\Db\Exception\NotSupportedException; +use PDOException; /** * A minimal concrete PDO command that uses the real {@see AbstractPdoCommand::internalExecute()} implementation. * - * Unlike {@see Command}, which throws {@see \Yiisoft\Db\Exception\NotSupportedException}, this stub is designed + * Unlike {@see Command}, which throws {@see NotSupportedException}, this stub is designed * for unit tests that need actual SQL execution via SQLite in-memory database. * * An optional `$failuresBeforeSuccess` constructor argument makes the first N `pdoStatementExecute()` calls @@ -32,21 +34,20 @@ public function showDatabases(): array return []; } + public function getExecuteCallCount(): int + { + return $this->executeCallCount; + } + protected function pdoStatementExecute(): void { if ($this->executeCallCount < $this->failuresBeforeSuccess) { ++$this->executeCallCount; - throw new \PDOException($this->connectionErrorMessage); + throw new PDOException($this->connectionErrorMessage); } ++$this->executeCallCount; parent::pdoStatementExecute(); } - - public function getExecuteCallCount(): int - { - return $this->executeCallCount; - } } - From a4653a521ec41eda8c6eeee051474b0c78b1b296 Mon Sep 17 00:00:00 2001 From: Siarhei Bautrukevich Date: Thu, 2 Apr 2026 18:11:54 +0300 Subject: [PATCH 08/18] Add psalm type hint for retry handler in AbstractCommand and CommandInterface --- src/Command/AbstractCommand.php | 1 + src/Command/CommandInterface.php | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php index 0973383a9..c76a93986 100644 --- a/src/Command/AbstractCommand.php +++ b/src/Command/AbstractCommand.php @@ -496,6 +496,7 @@ public function setSql(string $sql): static * 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/Command/CommandInterface.php b/src/Command/CommandInterface.php index 4559aafc5..acbc17ecc 100644 --- a/src/Command/CommandInterface.php +++ b/src/Command/CommandInterface.php @@ -793,6 +793,7 @@ public function setRawSql(string $sql): static; * 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; From 91d570b11865a0382d24c5e2b4721d2db98e798e Mon Sep 17 00:00:00 2001 From: Siarhei Bautrukevich Date: Thu, 2 Apr 2026 18:16:57 +0300 Subject: [PATCH 09/18] Clarify documentation for retry handler closure in AbstractCommand and CommandInterface --- src/Command/AbstractCommand.php | 6 +++--- src/Command/CommandInterface.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php index c76a93986..6cbfa7b97 100644 --- a/src/Command/AbstractCommand.php +++ b/src/Command/AbstractCommand.php @@ -474,7 +474,7 @@ public function setSql(string $sql): static } /** - * Sets a closure (anonymous function) that is called when a database exception is thrown when executing the command. + * Sets a closure (anonymous function) which is called when a database exception is thrown when executing the command. * * The signature of the closure should be: * @@ -484,13 +484,13 @@ public function setSql(string $sql): static * * function (Exception $e, int $attempt, CommandInterface $command): bool * { - * // return true or false (whether to retry the command or throw $e) + * // 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, * the current attempt to execute the command (starting from `0`), and the {@see CommandInterface} - * instance. + * instance to allow access to the command's parameters and other state exposed by the interface. * * If the closure returns `true`, the command will be retried. If the closure returns `false`, * the {@see Exception} will be thrown. diff --git a/src/Command/CommandInterface.php b/src/Command/CommandInterface.php index acbc17ecc..ad60ec429 100644 --- a/src/Command/CommandInterface.php +++ b/src/Command/CommandInterface.php @@ -787,7 +787,7 @@ public function setRawSql(string $sql): static; * * 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. + * 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. From 0f64f53a515a2b242ad55af054c2c8647524290b Mon Sep 17 00:00:00 2001 From: Siarhei Bautrukevich Date: Fri, 3 Apr 2026 08:58:05 +0300 Subject: [PATCH 10/18] Update src/Command/AbstractCommand.php These annotations are not needed if they are the same as in the interface CommandInterface Co-authored-by: Sergei Tigrov --- src/Command/AbstractCommand.php | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php index 77c60c26f..c96c6e9d9 100644 --- a/src/Command/AbstractCommand.php +++ b/src/Command/AbstractCommand.php @@ -472,31 +472,6 @@ public function setSql(string $sql): static return $this; } - /** - * Sets a closure (anonymous function) which 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, 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, - * the current attempt to execute the command (starting from `0`), and the {@see CommandInterface} - * instance to allow access to the command's parameters and other state exposed by the interface. - * - * 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 { $this->retryHandler = $handler; From 08fd06527eafec4609932c33316446571ded1c97 Mon Sep 17 00:00:00 2001 From: Siarhei Bautrukevich Date: Fri, 3 Apr 2026 09:02:22 +0300 Subject: [PATCH 11/18] Remove rebindBoundParams method and update documentation for parameter binding --- src/Driver/Pdo/AbstractPdoCommand.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/Driver/Pdo/AbstractPdoCommand.php b/src/Driver/Pdo/AbstractPdoCommand.php index 346cb439e..0efe2c4e5 100644 --- a/src/Driver/Pdo/AbstractPdoCommand.php +++ b/src/Driver/Pdo/AbstractPdoCommand.php @@ -166,7 +166,6 @@ public function prepare(?bool $forRead = null): void try { $this->pdoStatement = $pdo->prepare($sql); $this->bindPendingParams(); - $this->rebindBoundParams(); } catch (PDOException $e) { $message = $e->getMessage() . "\nFailed to prepare SQL: $sql"; $errorInfo = $e->errorInfo ?? null; @@ -176,7 +175,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}. */ @@ -185,15 +184,7 @@ protected function bindPendingParams(): void foreach ($this->params as $name => $value) { $this->pdoStatement?->bindValue($name, $value->value, $value->type); } - } - /** - * Re-binds parameters registered via {@see bindParam()} to the current {@see PDOStatement}. - * - * Called after statement re-preparation (e.g., after reconnect) to restore by-reference bindings. - */ - protected function rebindBoundParams(): void - { foreach ($this->pendingBoundParams as $name => &$entry) { $value = &$entry['value']; From 67c2e8d46802211e4e8fe1f3cf0843b55609da3b Mon Sep 17 00:00:00 2001 From: Siarhei Bautrukevich Date: Fri, 3 Apr 2026 13:36:33 +0300 Subject: [PATCH 12/18] Implement connection recovery handler and enhance error handling in AbstractPdoCommand --- src/Driver/Pdo/AbstractPdoCommand.php | 32 ++-------- src/Driver/Pdo/ConnectionRecoveryHandler.php | 66 ++++++++++++++++++++ src/Exception/ConnectionException.php | 11 ++++ src/Exception/ConvertException.php | 17 ++++- 4 files changed, 97 insertions(+), 29 deletions(-) create mode 100644 src/Driver/Pdo/ConnectionRecoveryHandler.php create mode 100644 src/Exception/ConnectionException.php diff --git a/src/Driver/Pdo/AbstractPdoCommand.php b/src/Driver/Pdo/AbstractPdoCommand.php index 0efe2c4e5..94b301a53 100644 --- a/src/Driver/Pdo/AbstractPdoCommand.php +++ b/src/Driver/Pdo/AbstractPdoCommand.php @@ -66,6 +66,7 @@ abstract class AbstractPdoCommand extends AbstractCommand implements PdoCommandI public function __construct(PdoConnectionInterface $db) { parent::__construct($db); + $this->retryHandler = (new ConnectionRecoveryHandler($db))->asClosure(); } /** @@ -228,8 +229,8 @@ protected function getQueryMode(int $queryMode): string /** * A wrapper around {@see pdoStatementExecute()} to support transactions and retry handlers. * - * Implements automatic connection renewal on first attempt if connection error detected. - * Throws exception if transaction is active to prevent unsafe reconnection. + * 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 */ @@ -243,35 +244,10 @@ protected function internalExecute(): void $rawSql ??= $this->getRawSql(); $e = (new ConvertException($e, $rawSql))->run(); - // Custom retry handler takes precedence - if ($this->retryHandler !== null) { - if (!($this->retryHandler)($e, $attempt, $this)) { - throw $e; - } + if ($this->retryHandler !== null && ($this->retryHandler)($e, $attempt, $this)) { continue; } - // Default behavior: attempt to renew connection on first failure - if ($attempt === 0 && $this->isConnectionError($e)) { - // Prevent reconnection during active transaction - if ($this->db->getTransaction() !== null) { - throw $e; - } - - // Try to renew connection - try { - $this->db->close(); - $this->db->open(); - $this->pdoStatement = null; - } catch (Throwable) { - // If reconnection fails, throw original error - throw $e; - } - - // Re-prepare the statement against the new connection, restoring all parameter bindings. - $this->prepare(); - continue; // Retry the command - } throw $e; } diff --git a/src/Driver/Pdo/ConnectionRecoveryHandler.php b/src/Driver/Pdo/ConnectionRecoveryHandler.php new file mode 100644 index 000000000..57e0f1010 --- /dev/null +++ b/src/Driver/Pdo/ConnectionRecoveryHandler.php @@ -0,0 +1,66 @@ +__invoke(...); + } + + public function __invoke(Exception $e, int $attempt, CommandInterface $command): bool + { + // Only attempt recovery on the first failure. + if ($attempt !== 0 || !$e instanceof ConnectionException) { + return false; + } + + // Reconnecting during an active transaction would silently roll it back. + if ($this->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; + } +} diff --git a/src/Exception/ConnectionException.php b/src/Exception/ConnectionException.php new file mode 100644 index 000000000..a4767646e --- /dev/null +++ b/src/Exception/ConnectionException.php @@ -0,0 +1,11 @@ +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) From 8f6f17af8dcba1a09a7a5d1d3500001a91039a92 Mon Sep 17 00:00:00 2001 From: Siarhei Bautrukevich Date: Fri, 3 Apr 2026 13:37:28 +0300 Subject: [PATCH 13/18] Update src/Driver/Pdo/AbstractPdoCommand.php For consistency with bindParam() signature. Co-authored-by: Sergei Predvoditelev --- src/Driver/Pdo/AbstractPdoCommand.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Driver/Pdo/AbstractPdoCommand.php b/src/Driver/Pdo/AbstractPdoCommand.php index 94b301a53..969452c0c 100644 --- a/src/Driver/Pdo/AbstractPdoCommand.php +++ b/src/Driver/Pdo/AbstractPdoCommand.php @@ -96,8 +96,7 @@ public function bindParam( } // Save the binding by reference so it can be re-applied after statement re-preparation (e.g., on reconnect). - $entry = ['type' => $dataType, 'length' => $length, 'driverOptions' => $driverOptions, 'value' => null]; - $entry['value'] = &$value; + $entry = ['value' => &$value, 'dataType' => $dataType, 'length' => $length, 'driverOptions' => $driverOptions]; $this->pendingBoundParams[$name] = $entry; if ($length === null) { From 79f449d91fb3dbdddba4a860497265bec298c3ca Mon Sep 17 00:00:00 2001 From: bautrukevich <1747220+bautrukevich@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:38:17 +0000 Subject: [PATCH 14/18] Apply PHP CS Fixer and Rector changes (CI) --- src/Driver/Pdo/ConnectionRecoveryHandler.php | 16 ++++++++-------- src/Exception/ConnectionException.php | 1 - 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/Driver/Pdo/ConnectionRecoveryHandler.php b/src/Driver/Pdo/ConnectionRecoveryHandler.php index 57e0f1010..d8bfeaef4 100644 --- a/src/Driver/Pdo/ConnectionRecoveryHandler.php +++ b/src/Driver/Pdo/ConnectionRecoveryHandler.php @@ -29,14 +29,6 @@ final class ConnectionRecoveryHandler { public function __construct(private readonly PdoConnectionInterface $db) {} - /** - * @psalm-return RetryHandlerClosure - */ - public function asClosure(): Closure - { - return $this->__invoke(...); - } - public function __invoke(Exception $e, int $attempt, CommandInterface $command): bool { // Only attempt recovery on the first failure. @@ -63,4 +55,12 @@ public function __invoke(Exception $e, int $attempt, CommandInterface $command): return true; } + + /** + * @psalm-return RetryHandlerClosure + */ + public function asClosure(): Closure + { + return $this->__invoke(...); + } } diff --git a/src/Exception/ConnectionException.php b/src/Exception/ConnectionException.php index a4767646e..3b9019554 100644 --- a/src/Exception/ConnectionException.php +++ b/src/Exception/ConnectionException.php @@ -8,4 +8,3 @@ * Represents an exception caused by a lost or refused database connection. */ final class ConnectionException extends Exception {} - From ffb39ddf837798613f99f953817d4a5984b61b08 Mon Sep 17 00:00:00 2001 From: Siarhei Bautrukevich Date: Fri, 3 Apr 2026 13:41:09 +0300 Subject: [PATCH 15/18] Remove isConnectionError method from AbstractPdoCommand --- src/Driver/Pdo/AbstractPdoCommand.php | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/Driver/Pdo/AbstractPdoCommand.php b/src/Driver/Pdo/AbstractPdoCommand.php index 969452c0c..947eb3ed8 100644 --- a/src/Driver/Pdo/AbstractPdoCommand.php +++ b/src/Driver/Pdo/AbstractPdoCommand.php @@ -348,27 +348,6 @@ protected function queryInternal(int $queryMode): mixed return $result; } - /** - * Checks if the exception represents a connection error. - * - * Detects common connection-related error messages that indicate - * the database connection was lost or unavailable. - * - * @param Exception $e The exception to check - * @return bool True if the exception indicates a connection error - */ - private function isConnectionError(Exception $e): bool - { - $message = $e->getMessage(); - - return str_contains($message, 'no connection') - || str_contains($message, 'General error: 7') - || str_contains($message, 'gone away') - || str_contains($message, 'Connection refused') - || str_contains($message, 'server has gone away') - || str_contains($message, 'Lost connection'); - } - /** * Returns the column instance from the query result by the index, or `null` if the column type cannot be determined. */ From c0811d7412a6818263adec62b5b521f82b945770 Mon Sep 17 00:00:00 2001 From: Siarhei Bautrukevich Date: Fri, 3 Apr 2026 19:40:31 +0300 Subject: [PATCH 16/18] Refactor parameter binding logic in AbstractPdoCommand --- src/Driver/Pdo/AbstractPdoCommand.php | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/Driver/Pdo/AbstractPdoCommand.php b/src/Driver/Pdo/AbstractPdoCommand.php index 947eb3ed8..638d5a8e9 100644 --- a/src/Driver/Pdo/AbstractPdoCommand.php +++ b/src/Driver/Pdo/AbstractPdoCommand.php @@ -89,23 +89,16 @@ public function bindParam( ?int $length = null, mixed $driverOptions = null, ): static { - $this->prepare(); - if ($dataType === null) { $dataType = $this->db->getSchema()->getDataType($value); } - // Save the binding by reference so it can be re-applied after statement re-preparation (e.g., on reconnect). - $entry = ['value' => &$value, 'dataType' => $dataType, 'length' => $length, 'driverOptions' => $driverOptions]; - $this->pendingBoundParams[$name] = $entry; - - 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; } From 836a7517832c869cf29da9e463ee70dc5ba023fd Mon Sep 17 00:00:00 2001 From: Siarhei Bautrukevich Date: Fri, 3 Apr 2026 20:56:28 +0300 Subject: [PATCH 17/18] Add tests for parameter binding behavior across executions and after reconnect --- .../Db/Driver/Pdo/AbstractPdoCommandTest.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/Db/Driver/Pdo/AbstractPdoCommandTest.php b/tests/Db/Driver/Pdo/AbstractPdoCommandTest.php index 0df17e2ab..c6f95088a 100644 --- a/tests/Db/Driver/Pdo/AbstractPdoCommandTest.php +++ b/tests/Db/Driver/Pdo/AbstractPdoCommandTest.php @@ -53,6 +53,44 @@ public function testBindParamReferenceIsTracked(): void $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(); From 1d388472d4cf48ad9b0c8c68136e2e3615836eef Mon Sep 17 00:00:00 2001 From: Siarhei Bautrukevich Date: Fri, 3 Apr 2026 20:56:43 +0300 Subject: [PATCH 18/18] Add ConnectionException and ConnectionRecoveryHandler classes; enhance parameter binding after reconnect --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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