From 2749e08e80fdb280dcdc1aa37d4e4c264f3cd8f7 Mon Sep 17 00:00:00 2001 From: dimarik82 Date: Wed, 27 May 2026 08:17:40 +0000 Subject: [PATCH 1/2] fix: raise PrepareException when PDO::exec returns false Connection::exec() and Statement::exec() previously discarded the return value of PDO::exec() in the no-params branch, so a failure under ERRMODE_SILENT would pass silently. Check the return and route through prepareException() to surface the error. Cover both call sites with a Testo test that forces the branch via an ERRMODE_SILENT handle and invalid SQL. Document the remaining $statement->execute($params) asymmetry as relying on the default ERRMODE_EXCEPTION. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/internal/Connection.php | 6 +++++- src/internal/Statement.php | 6 +++++- tests/workflow/ConnectionTest.php | 29 +++++++++++++++++++++++++---- tests/workflow/StatementTest.php | 21 +++++++++++++++++++++ 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/internal/Connection.php b/src/internal/Connection.php index c1817f2..a2761dc 100644 --- a/src/internal/Connection.php +++ b/src/internal/Connection.php @@ -53,7 +53,10 @@ public function fetchRecord(string $query, array $params = []): array public function exec(string $query, array $params = []): void { if ($params === []) { - $this->connection->exec($query); + if ($this->connection->exec($query) === false) { + $this->prepareException($this->connection); + } + return; } @@ -62,6 +65,7 @@ public function exec(string $query, array $params = []): void $this->prepareException($this->connection); } + // $statement->execute() throws under PDO::ERRMODE_EXCEPTION (PHP 8 default). $statement->execute($params); } } diff --git a/src/internal/Statement.php b/src/internal/Statement.php index 570b260..7fedb7b 100644 --- a/src/internal/Statement.php +++ b/src/internal/Statement.php @@ -43,7 +43,10 @@ public function fetchRecord(string $query, array $params = []): array public function exec(string $query, array $params = []): void { if ($params === []) { - $this->connection->exec($query); + if ($this->connection->exec($query) === false) { + $this->prepareException($this->connection); + } + return; } @@ -52,6 +55,7 @@ public function exec(string $query, array $params = []): void $this->prepareException($this->connection); } + // $statement->execute() throws under PDO::ERRMODE_EXCEPTION (PHP 8 default). $statement->execute($params); } } diff --git a/tests/workflow/ConnectionTest.php b/tests/workflow/ConnectionTest.php index 8c04d58..00c433a 100644 --- a/tests/workflow/ConnectionTest.php +++ b/tests/workflow/ConnectionTest.php @@ -4,16 +4,18 @@ namespace dbschemix\pdo\tests\workflow; -// Note: the branch `return []` in fetchRecord (when execute() returns false) and the -// `$this->prepareException()` branch are not reachable through a real in-memory SQLite -// PDO handle under normal conditions. These two edges are therefore intentionally left -// without direct coverage here, consistent with the strategy used across this test suite. +// Note: the `return []` branch in fetchRecord() (when execute() returns false) and its +// `prepareException()` branch (when prepare() returns false) are not reachable through an +// in-memory SQLite PDO handle under the default ERRMODE_EXCEPTION, and are intentionally +// left without direct coverage here. use Throwable; use PDO; use Testo\Assert; +use Testo\Expect; use Testo\Lifecycle\BeforeTest; use Testo\Test; +use dbschemix\core\exception\PrepareException; use dbschemix\pdo\internal\Connection; use dbschemix\pdo\internal\Transaction; @@ -147,6 +149,25 @@ public function fetchRecordReturnsEmptyArrayOnNoMatch(): void Assert::blank($data); } + /** + * exec() without params raises PrepareException when the underlying PDO::exec() + * returns false. Forces the failure by switching the handle to ERRMODE_SILENT and + * passing syntactically invalid SQL. + * + * @throws Throwable + */ + public function execRaisesPrepareExceptionOnFailure(): void + { + Expect::exception(PrepareException::class); + + $pdo = new PDO(dsn: 'sqlite::memory:', options: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT, + ]); + $connection = new Connection($pdo, Transaction::class); + + $connection->exec('THIS IS NOT VALID SQL'); + } + /** * Three consecutive exec()-with-params calls each use a fresh prepare()+execute() cycle. * fetchRecord() must return all three rows, proving no statement handle is reused diff --git a/tests/workflow/StatementTest.php b/tests/workflow/StatementTest.php index 99a3131..a2f7894 100644 --- a/tests/workflow/StatementTest.php +++ b/tests/workflow/StatementTest.php @@ -7,10 +7,12 @@ use Throwable; use PDO; use Testo\Assert; +use Testo\Expect; use Testo\Lifecycle\AfterTest; use Testo\Lifecycle\BeforeTest; use Testo\Test; use dbschemix\core\connection\TransactionInterface; +use dbschemix\core\exception\PrepareException; use dbschemix\pdo\internal\Transaction; #[Test] @@ -132,6 +134,25 @@ public function execParamsAreNotInterpolated(): void Assert::array($data)->hasKeys($maliciousName); } + /** + * exec() without params raises PrepareException when the underlying PDO::exec() + * returns false. Forces the failure by switching the handle to ERRMODE_SILENT and + * passing syntactically invalid SQL. + * + * @throws Throwable + */ + public function execRaisesPrepareExceptionOnFailure(): void + { + Expect::exception(PrepareException::class); + + $pdo = new PDO(dsn: 'sqlite::memory:', options: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT, + ]); + $transaction = Transaction::begin($pdo); + + $transaction->exec('THIS IS NOT VALID SQL'); + } + /** * @throws Throwable */ From 22d3f01084a8ab4e9af9f391e80110faf2da9a3b Mon Sep 17 00:00:00 2001 From: dimarik82 Date: Wed, 27 May 2026 08:25:10 +0000 Subject: [PATCH 2/2] test: pin ErrorInfo message format to kill infection survivors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execRaisesPrepareExceptionOnFailure asserted only the exception class, leaving three Infection survivors in ErrorInfo::__construct that swapped $errorInfo[0]/[1]/[2] indices undetected. Tighten both tests with a withMessagePattern that requires a non-numeric SQLSTATE token and a non-empty multi-word driver message — kills all three mutants while staying resilient to libsqlite wording changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/workflow/ConnectionTest.php | 6 +++++- tests/workflow/StatementTest.php | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/workflow/ConnectionTest.php b/tests/workflow/ConnectionTest.php index 00c433a..41a9feb 100644 --- a/tests/workflow/ConnectionTest.php +++ b/tests/workflow/ConnectionTest.php @@ -158,7 +158,11 @@ public function fetchRecordReturnsEmptyArrayOnNoMatch(): void */ public function execRaisesPrepareExceptionOnFailure(): void { - Expect::exception(PrepareException::class); + // Pattern locks the ErrorInfo format: SQLSTATE (non-numeric token) and a non-empty + // multi-word driver message — keeps the assertion stable across libsqlite versions + // while still distinguishing $errorInfo[0]/[1]/[2] should those indices be swapped. + Expect::exception(PrepareException::class) + ->withMessagePattern('/^SQLSTATE\[[A-Z]\w+\]: error: .+ .+/'); $pdo = new PDO(dsn: 'sqlite::memory:', options: [ PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT, diff --git a/tests/workflow/StatementTest.php b/tests/workflow/StatementTest.php index a2f7894..1b1539b 100644 --- a/tests/workflow/StatementTest.php +++ b/tests/workflow/StatementTest.php @@ -143,7 +143,11 @@ public function execParamsAreNotInterpolated(): void */ public function execRaisesPrepareExceptionOnFailure(): void { - Expect::exception(PrepareException::class); + // Pattern locks the ErrorInfo format: SQLSTATE (non-numeric token) and a non-empty + // multi-word driver message — keeps the assertion stable across libsqlite versions + // while still distinguishing $errorInfo[0]/[1]/[2] should those indices be swapped. + Expect::exception(PrepareException::class) + ->withMessagePattern('/^SQLSTATE\[[A-Z]\w+\]: error: .+ .+/'); $pdo = new PDO(dsn: 'sqlite::memory:', options: [ PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT,