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..41a9feb 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,29 @@ 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 + { + // 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, + ]); + $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..1b1539b 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,29 @@ 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 + { + // 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, + ]); + $transaction = Transaction::begin($pdo); + + $transaction->exec('THIS IS NOT VALID SQL'); + } + /** * @throws Throwable */