From ec5d8f175966fde8ddb93b056fb4262e807582a5 Mon Sep 17 00:00:00 2001 From: Headgent Date: Fri, 16 Oct 2026 01:43:16 +0200 Subject: [PATCH] feat(sqlite): lazy connection - open file and apply PRAGMAs on first access (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SqLite no longer validates the path, opens the file or runs PRAGMAs in the constructor. The first pdo()/connect() does all three inside createPdoConnection(), directly on the fresh handle (no pdo() re-entry), so the PRAGMAs also run after every reconnect(). Path/connection errors surface at the first access. reconnect() keeps an open ':memory:' handle (a new handle would be an empty database). ConnectionFactory docblock corrected: sqlite() is lazy like mysql()/postgres(). Bescheid Rolf 2026-10-15: alle Dienste lazy. Paket-Pendant: PdoConnection (src/Connection/PdoConnection.php:89-93) als Lazy-Vorbild — kein neues Konstrukt außerhalb des Pakets --- .claude/skills/adapter-dbconnection/SKILL.md | 2 +- README.md | 2 +- src/Connection/SqLite.php | 73 ++++++++--- src/Factory/ConnectionFactory.php | 7 +- tests/Integration/SqLiteTest.php | 27 ++++- tests/Unit/LazyConnectionTest.php | 121 +++++++++++++++++++ 6 files changed, 210 insertions(+), 22 deletions(-) diff --git a/.claude/skills/adapter-dbconnection/SKILL.md b/.claude/skills/adapter-dbconnection/SKILL.md index 9521f07..6a6a051 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()` open no socket. 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 opens the file at creation (intended: local file); mysql/pgsql connect on first access. 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()`; 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/README.md b/README.md index 636aed2..a6b05e7 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ A PDO connection pool with read/write splitting for PHP — round-robin load bal - **Health Checks** — `SELECT 1` validation with positive and negative result caching - **Transaction Support** — `beginTransaction()`, `commit()`, `rollback()`, `inTransaction()` on every connection - **Savepoints** — `savepoint()`, `releaseSavepoint()`, `rollbackToSavepoint()` for nested transaction brackets (`SavepointInterface`, MySQL / MariaDB / PostgreSQL / SQLite) -- **Lazy Connections** — `mysql()`/`postgres()` open no socket; the connection opens on first access, so a request without DB access opens none. Connection errors surface at first access; SQLite opens its (local) file at creation by design, mysql/pgsql connect on first access. A failed pool health check names its cause: `Writer connection health check failed: ` (the connection error is also `getPrevious()`) +- **Lazy Connections** — `mysql()`/`postgres()`/`sqlite()` open no socket and no file; the connection opens on first access, so a request without DB access opens none. Connection errors (and the SQLite path check) surface at first access. SQLite applies its PRAGMAs right after each open (first access and `reconnect()`); an in-memory database is kept by `reconnect()` while its handle is open. A failed pool health check names its cause: `Writer connection health check failed: ` (the connection error is also `getPrevious()`) - **Reconnect** — `reconnect()` rebuilds the PDO connection from config on failure - **MySQL / PostgreSQL / SQLite** — Dedicated factory methods per driver with typed config - **External PDO Wrapping** — `fromPdo()` integrates existing connections without refactoring diff --git a/src/Connection/SqLite.php b/src/Connection/SqLite.php index 9067104..5518456 100644 --- a/src/Connection/SqLite.php +++ b/src/Connection/SqLite.php @@ -9,27 +9,66 @@ /** * SQLite database connection. + * + * Lazy like every PdoConnection: construction touches neither the file system + * nor the database. The first call that needs the PDO handle (pdo(), + * a transaction, a query) validates the path, opens the file and applies the + * PRAGMAs; errors surface there. The PRAGMAs run inside createPdoConnection() + * directly on the fresh handle (no pdo() round trip), so they are applied + * after the first open and after every reopen (connect() after disconnect(), + * reconnect()). */ final class SqLite extends PdoConnection { + private const IN_MEMORY = ':memory:'; + + /** The path precondition is checked once, on the first open; later reopens fail as connection errors. */ + private bool $pathValidated = false; + /** * @param SqliteConfig $config The SQLite connection configuration - * @throws RuntimeException On connection error */ public function __construct(SqliteConfig $config) { parent::__construct($config); - - $this->validateDatabasePath($config->path); - $this->createPdoConnection(); - $this->applySqliteOptimizations(); } + /** + * An in-memory database lives exactly as long as its PDO handle: while one + * is open, reconnect() keeps it (a new handle would be a new, empty + * database). File databases are reopened as usual. + * + * @throws RuntimeException On reconnection error + */ public function reconnect(): void { - $this->disconnect(); - $this->createPdoConnection(); - $this->applySqliteOptimizations(); + if ($this->getDatabasePath() === self::IN_MEMORY && $this->isConnected()) { + return; + } + + parent::reconnect(); + } + + /** + * Validates the path, opens the file and applies the PRAGMAs. + * + * @throws RuntimeException On invalid path or connection error + */ + protected function createPdoConnection(): void + { + if (!$this->pathValidated) { + $this->validateDatabasePath($this->getDatabasePath()); + $this->pathValidated = true; + } + + parent::createPdoConnection(); + + try { + $this->applySqliteOptimizations(); + } catch (\Throwable $e) { + $this->pdo = null; + throw $e; + } } /** @@ -59,15 +98,21 @@ private function validateDatabasePath(string $dbPath): void } /** - * Applies SQLite-specific performance optimizations. + * Applies SQLite-specific performance optimizations on the freshly opened + * handle. Works on $this->pdo directly: pdo() would re-enter the lazy open. */ private function applySqliteOptimizations(): void { - $this->pdo()->exec('PRAGMA foreign_keys = ON'); - $this->pdo()->exec('PRAGMA journal_mode = WAL'); - $this->pdo()->exec('PRAGMA synchronous = NORMAL'); - $this->pdo()->exec('PRAGMA temp_store = MEMORY'); - $this->pdo()->exec('PRAGMA mmap_size = 30000000000'); + $pdo = $this->pdo; + if ($pdo === null) { + return; + } + + $pdo->exec('PRAGMA foreign_keys = ON'); + $pdo->exec('PRAGMA journal_mode = WAL'); + $pdo->exec('PRAGMA synchronous = NORMAL'); + $pdo->exec('PRAGMA temp_store = MEMORY'); + $pdo->exec('PRAGMA mmap_size = 30000000000'); } /** diff --git a/src/Factory/ConnectionFactory.php b/src/Factory/ConnectionFactory.php index 3269b0e..ec561d1 100644 --- a/src/Factory/ConnectionFactory.php +++ b/src/Factory/ConnectionFactory.php @@ -17,9 +17,10 @@ /** * Factory for creating database connections for all supported drivers. * - * mysql()/postgres() return lazy connections: nothing is opened until the - * first access (pdo(), a query, a transaction), so a connection error surfaces - * there, not at creation. sqlite() opens its local file at creation. + * mysql()/postgres()/sqlite() return lazy connections: nothing is opened until + * the first access (pdo(), a query, a transaction), so a connection error + * surfaces there, not at creation. sqlite() touches neither the file system nor + * the database file before that first access. */ final class ConnectionFactory { diff --git a/tests/Integration/SqLiteTest.php b/tests/Integration/SqLiteTest.php index 1e4ab65..8c72768 100644 --- a/tests/Integration/SqLiteTest.php +++ b/tests/Integration/SqLiteTest.php @@ -43,6 +43,9 @@ public function testConnectionCanBeEstablished(): void $config = new SqliteConfig(path: $this->testDbPath); $this->connection = new SqLite($config); + // Bescheid Rolf 2026-10-15: alle Dienste lazy - Konstruktion oeffnet nichts, pdo()/connect() oeffnet. + $this->assertFalse($this->connection->isConnected()); + $this->connection->connect(); $this->assertTrue($this->connection->isConnected()); $this->assertSame('sqlite', $this->connection->getDriverName()); } @@ -52,6 +55,9 @@ public function testInMemoryDatabaseWorks(): void $config = new SqliteConfig(path: ':memory:'); $this->connection = new SqLite($config); + // Bescheid Rolf 2026-10-15: alle Dienste lazy - Konstruktion oeffnet nichts, pdo()/connect() oeffnet. + $this->assertFalse($this->connection->isConnected()); + $this->connection->connect(); $this->assertTrue($this->connection->isConnected()); $this->assertSame('sqlite', $this->connection->getDriverName()); } @@ -70,6 +76,8 @@ public function testDisconnectClearsConnection(): void { $config = new SqliteConfig(path: $this->testDbPath); $this->connection = new SqLite($config); + // Bescheid Rolf 2026-10-15: alle Dienste lazy - Konstruktion oeffnet nichts, pdo()/connect() oeffnet. + $this->connection->connect(); $this->assertTrue($this->connection->isConnected()); $this->connection->disconnect(); @@ -175,10 +183,14 @@ public function testInvalidPathThrowsException(): void $invalidPath = '/non/existent/path/database.db'; $config = new SqliteConfig(path: $invalidPath); + // Bescheid Rolf 2026-10-15: alle Dienste lazy - der Pfadfehler erscheint erst beim ersten Zugriff. + $this->connection = new SqLite($config); + $this->assertFalse($this->connection->isConnected()); + $this->expectException(RuntimeException::class); $this->expectExceptionMessage('SQLite database directory is not writable'); - $this->connection = new SqLite($config); + $this->connection->pdo(); } public function testCustomPdoOptions(): void @@ -259,10 +271,14 @@ public function testValidateDatabasePathWithNonWritableDirectory(): void $invalidPath = '/root/readonly/database.db'; $config = new SqliteConfig(path: $invalidPath); + // Bescheid Rolf 2026-10-15: alle Dienste lazy - der Pfadfehler erscheint erst beim ersten Zugriff. + $this->connection = new SqLite($config); + $this->assertFalse($this->connection->isConnected()); + $this->expectException(RuntimeException::class); $this->expectExceptionMessage('SQLite database directory is not writable'); - $this->connection = new SqLite($config); + $this->connection->pdo(); } public function testValidateDatabasePathWithNonReadableFile(): void @@ -274,10 +290,13 @@ public function testValidateDatabasePathWithNonReadableFile(): void $config = new SqliteConfig(path: $readOnlyPath); try { + // Bescheid Rolf 2026-10-15: alle Dienste lazy - die Pfadpruefung greift beim ersten Zugriff. + $this->connection = new SqLite($config); + $this->expectException(RuntimeException::class); $this->expectExceptionMessage('SQLite database file is not readable'); - $this->connection = new SqLite($config); + $this->connection->pdo(); } finally { chmod($readOnlyPath, 0644); if (file_exists($readOnlyPath)) { @@ -365,6 +384,8 @@ public function testReconnectFailureWithInvalidPath(): void { $config = new SqliteConfig(path: $this->testDbPath); $this->connection = new SqLite($config); + // Bescheid Rolf 2026-10-15: alle Dienste lazy - Ausgangslage "war verbunden" jetzt explizit herstellen. + $this->connection->connect(); $this->connection->disconnect(); diff --git a/tests/Unit/LazyConnectionTest.php b/tests/Unit/LazyConnectionTest.php index 1592a2f..f711fa9 100644 --- a/tests/Unit/LazyConnectionTest.php +++ b/tests/Unit/LazyConnectionTest.php @@ -114,4 +114,125 @@ public function testIsConnectedBecomesTrueAfterFirstAccess(): void $this->assertTrue($connection->isConnected()); } + + // Bescheid Rolf 2026-10-15: alle Dienste lazy - SQLite oeffnet erst beim ersten pdo(). + + public function testSqliteFactoryDoesNotOpenOrCreateFile(): void + { + $dir = sys_get_temp_dir() . '/lazy_sqlite_' . uniqid(); + mkdir($dir); + $path = $dir . '/app.db'; + + try { + $connection = (new ConnectionFactory())->sqlite($path); + + $this->assertFalse($connection->isConnected()); + $this->assertSame([], glob($dir . '/*') ?: []); + $this->assertFileDoesNotExist($path); + $this->assertFileDoesNotExist($path . '-wal'); + } finally { + $this->removeDir($dir); + } + } + + public function testSqliteFirstPdoOpensFileAndAppliesPragmas(): void + { + $dir = sys_get_temp_dir() . '/lazy_sqlite_' . uniqid(); + mkdir($dir); + $path = $dir . '/app.db'; + + try { + $connection = (new ConnectionFactory())->sqlite($path); + $pdo = $connection->pdo(); + + $this->assertTrue($connection->isConnected()); + $this->assertFileExists($path); + $this->assertSame(1, (int) $pdo->query('PRAGMA foreign_keys')->fetchColumn()); + $this->assertSame('wal', $pdo->query('PRAGMA journal_mode')->fetchColumn()); + $this->assertSame(1, (int) $pdo->query('PRAGMA synchronous')->fetchColumn()); + } finally { + $this->removeDir($dir); + } + } + + public function testSqlitePragmasAreAppliedAgainAfterReconnect(): void + { + $dir = sys_get_temp_dir() . '/lazy_sqlite_' . uniqid(); + mkdir($dir); + + try { + $connection = (new ConnectionFactory())->sqlite($dir . '/app.db'); + $connection->pdo(); + $connection->reconnect(); + $pdo = $connection->pdo(); + + $this->assertSame(1, (int) $pdo->query('PRAGMA foreign_keys')->fetchColumn()); + $this->assertSame('wal', $pdo->query('PRAGMA journal_mode')->fetchColumn()); + } finally { + $this->removeDir($dir); + } + } + + public function testSqliteImpossiblePathThrowsOnlyOnFirstAccess(): void + { + $connection = (new ConnectionFactory())->sqlite('/non/existent/dir/app.db'); + + $this->assertFalse($connection->isConnected()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('SQLite database directory is not writable'); + + $connection->pdo(); + } + + public function testSqliteMemoryReconnectKeepsTheExistingDatabase(): void + { + $connection = (new ConnectionFactory())->sqlite(); + $connection->pdo()->exec('CREATE TABLE t (id INTEGER)'); + $connection->pdo()->exec('INSERT INTO t VALUES (1)'); + + $connection->reconnect(); + + $this->assertSame(1, (int) $connection->pdo()->query('SELECT COUNT(*) FROM t')->fetchColumn()); + } + + public function testSqliteMemoryPoolHealthCheckKeepsTheDatabase(): void + { + $writer = (new ConnectionFactory())->sqlite(); + $pool = new ConnectionPool($writer, config: new ConnectionPoolConfig( + validateConnections: true, + healthCheckCacheTtl: 0, + )); + + $this->assertFalse($writer->isConnected()); + $pool->getWriter()->pdo()->exec('CREATE TABLE t (id INTEGER)'); + $pool->getWriter()->pdo()->exec('INSERT INTO t VALUES (1)'); + + $this->assertSame(1, (int) $pool->getWriter()->pdo()->query('SELECT COUNT(*) FROM t')->fetchColumn()); + } + + public function testSqliteMemoryReconnectAfterExplicitDisconnectOpensFreshDatabase(): void + { + $connection = (new ConnectionFactory())->sqlite(); + $connection->pdo()->exec('CREATE TABLE t (id INTEGER)'); + $connection->disconnect(); + + $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()); + } + + private function removeDir(string $dir): void + { + foreach (glob($dir . '/*') ?: [] as $file) { + @unlink($file); + } + @rmdir($dir); + } }