From 62081eaeb4bd59c5d48d93ec417d8f37253302d2 Mon Sep 17 00:00:00 2001 From: Headgent Date: Fri, 16 Oct 2026 02:23:51 +0200 Subject: [PATCH] fix(sqlite): wrap PRAGMA failure as RuntimeException, sharpen lazy-connection tests (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SqLite::createPdoConnection wraps a PDOException from the PRAGMAs in a RuntimeException (previous preserved), the handle stays reset; same error form as PdoConnection::createPdoConnection and the documented contract. - LazyConnectionTest: remove the tautological COUNT()+1 assertion; rename the healthy-handle pool test to what it checks and add a test that really runs the pool health check's reconnect branch (explicit disconnect); add a test for the PRAGMA failure (non-database file). - Skill adapter-dbconnection: document the PRAGMA failure form. Paket-Pendant: PdoConnection-Fehlerform (src/Connection/PdoConnection.php) — kein neues Konstrukt --- .claude/skills/adapter-dbconnection/SKILL.md | 2 +- src/Connection/SqLite.php | 11 +++- tests/Unit/LazyConnectionTest.php | 61 ++++++++++++++++++-- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/.claude/skills/adapter-dbconnection/SKILL.md b/.claude/skills/adapter-dbconnection/SKILL.md index 6a6a051..a4e4c92 100644 --- a/.claude/skills/adapter-dbconnection/SKILL.md +++ b/.claude/skills/adapter-dbconnection/SKILL.md @@ -18,7 +18,7 @@ ConnectionPool → writer + readers[], health checks, load balancing, failove ``` Connections always injected — never created internally by consumers. -**Lazy:** `mysql()`/`postgres()`/`sqlite()` open no socket and no file. The connection opens on first access (`pdo()`, a query, a transaction); a request without DB access opens no connection. A connection error is thrown there (same `RuntimeException`), not at creation. `isConnected()` is `false` until the first access; `connect()` connects eagerly; an explicit `disconnect()` is not reopened implicitly. SQLite validates the path and applies its PRAGMAs (foreign_keys, WAL, …) at the first open and after every `reconnect()`; for `:memory:` `reconnect()` keeps an open handle (a new handle would be an empty database), only an explicit `disconnect()` followed by `reconnect()` yields a fresh one. A failed pool health check names its cause: `RuntimeException('Writer connection health check failed: ', previous: $e)` (same for readers). +**Lazy:** `mysql()`/`postgres()`/`sqlite()` open no socket and no file. The connection opens on first access (`pdo()`, a query, a transaction); a request without DB access opens no connection. A connection error is thrown there (same `RuntimeException`), not at creation. `isConnected()` is `false` until the first access; `connect()` connects eagerly; an explicit `disconnect()` is not reopened implicitly. SQLite validates the path and applies its PRAGMAs (foreign_keys, WAL, …) at the first open and after every `reconnect()` (a failing PRAGMA, e.g. a file that is not a database, is a `RuntimeException('Could not apply SQLite PRAGMAs: …', previous: PDOException)` and leaves the connection closed); for `:memory:` `reconnect()` keeps an open handle (a new handle would be an empty database), only an explicit `disconnect()` followed by `reconnect()` yields a fresh one. A failed pool health check names its cause: `RuntimeException('Writer connection health check failed: ', previous: $e)` (same for readers). ## FACTORY SIGNATURES ```php diff --git a/src/Connection/SqLite.php b/src/Connection/SqLite.php index 5518456..032e5d7 100644 --- a/src/Connection/SqLite.php +++ b/src/Connection/SqLite.php @@ -52,7 +52,8 @@ public function reconnect(): void /** * Validates the path, opens the file and applies the PRAGMAs. * - * @throws RuntimeException On invalid path or connection error + * @throws RuntimeException On invalid path, connection error or PRAGMA failure (previous: the PDOException); + * the handle is discarded on a PRAGMA failure */ protected function createPdoConnection(): void { @@ -65,9 +66,13 @@ protected function createPdoConnection(): void try { $this->applySqliteOptimizations(); - } catch (\Throwable $e) { + } catch (\PDOException $e) { $this->pdo = null; - throw $e; + throw new RuntimeException( + 'Could not apply SQLite PRAGMAs: ' . $e->getMessage(), + (int) $e->getCode(), + $e + ); } } diff --git a/tests/Unit/LazyConnectionTest.php b/tests/Unit/LazyConnectionTest.php index f711fa9..610319b 100644 --- a/tests/Unit/LazyConnectionTest.php +++ b/tests/Unit/LazyConnectionTest.php @@ -196,7 +196,12 @@ public function testSqliteMemoryReconnectKeepsTheExistingDatabase(): void $this->assertSame(1, (int) $connection->pdo()->query('SELECT COUNT(*) FROM t')->fetchColumn()); } - public function testSqliteMemoryPoolHealthCheckKeepsTheDatabase(): void + /** + * Healthy path only: a connected :memory: handle passes `SELECT 1`, so the + * health check never reaches its reconnect branch and the database stays. + * The reconnect branch itself is covered by the next test. + */ + public function testSqliteMemoryPoolHealthCheckOnHealthyHandleKeepsTheDatabase(): void { $writer = (new ConnectionFactory())->sqlite(); $pool = new ConnectionPool($writer, config: new ConnectionPoolConfig( @@ -211,6 +216,57 @@ public function testSqliteMemoryPoolHealthCheckKeepsTheDatabase(): void $this->assertSame(1, (int) $pool->getWriter()->pdo()->query('SELECT COUNT(*) FROM t')->fetchColumn()); } + /** + * An explicitly disconnected connection makes pdo() throw, which sends the + * health check into its reconnect branch (ConnectionPool::performHealthCheck). + * For :memory: that reconnect opens a fresh, empty database: the old one + * died with the explicit disconnect, so nothing is lost by the check itself. + */ + public function testSqliteMemoryPoolHealthCheckReconnectsAfterExplicitDisconnect(): void + { + $writer = (new ConnectionFactory())->sqlite(); + $pool = new ConnectionPool($writer, config: new ConnectionPoolConfig( + validateConnections: true, + healthCheckCacheTtl: 0, + )); + + $pool->getWriter()->pdo()->exec('CREATE TABLE t (id INTEGER)'); + $writer->disconnect(); + $this->assertFalse($writer->isConnected()); + + $healthy = $pool->getWriter(); + + $this->assertSame($writer, $healthy); + $this->assertTrue($writer->isConnected()); + $this->assertSame(0, (int) $healthy->pdo()->query( + "SELECT COUNT(*) FROM sqlite_master WHERE name = 't'" + )->fetchColumn()); + } + + public function testSqlitePragmaFailureIsWrappedAsRuntimeExceptionAndLeavesNoHandle(): void + { + $dir = sys_get_temp_dir() . '/lazy_sqlite_' . uniqid(); + mkdir($dir); + $file = $dir . '/garbage.db'; + file_put_contents($file, str_repeat('this is not a sqlite database ', 200)); + + try { + $connection = (new ConnectionFactory())->sqlite($file); + + try { + $connection->pdo(); + $this->fail('Expected RuntimeException for a PRAGMA failure'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('Could not apply SQLite PRAGMAs', $e->getMessage()); + $this->assertInstanceOf(\PDOException::class, $e->getPrevious()); + } + + $this->assertFalse($connection->isConnected()); + } finally { + $this->removeDir($dir); + } + } + public function testSqliteMemoryReconnectAfterExplicitDisconnectOpensFreshDatabase(): void { $connection = (new ConnectionFactory())->sqlite(); @@ -220,9 +276,6 @@ public function testSqliteMemoryReconnectAfterExplicitDisconnectOpensFreshDataba $connection->reconnect(); $this->assertTrue($connection->isConnected()); - $this->assertSame(1, (int) $connection->pdo()->query( - "SELECT COUNT(*) FROM sqlite_master WHERE name = 'nothing'" - )->fetchColumn() + 1); $this->assertSame(0, (int) $connection->pdo()->query( "SELECT COUNT(*) FROM sqlite_master WHERE name = 't'" )->fetchColumn());