Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/adapter-dbconnection/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <cause>', 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: <cause>', previous: $e)` (same for readers).

## FACTORY SIGNATURES
```php
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <cause>` (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: <cause>` (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
Expand Down
73 changes: 59 additions & 14 deletions src/Connection/SqLite.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

/**
Expand Down Expand Up @@ -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');
}

/**
Expand Down
7 changes: 4 additions & 3 deletions src/Factory/ConnectionFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
27 changes: 24 additions & 3 deletions tests/Integration/SqLiteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand All @@ -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());
}
Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)) {
Expand Down Expand Up @@ -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();

Expand Down
121 changes: 121 additions & 0 deletions tests/Unit/LazyConnectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading