diff --git a/.claude/skills/adapter-dbconnection/SKILL.md b/.claude/skills/adapter-dbconnection/SKILL.md index 1d57bb4..bd6a784 100644 --- a/.claude/skills/adapter-dbconnection/SKILL.md +++ b/.claude/skills/adapter-dbconnection/SKILL.md @@ -18,6 +18,8 @@ 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 its local file at creation. + ## FACTORY SIGNATURES ```php $factory = new ConnectionFactory(); @@ -31,7 +33,7 @@ $factory->fromPdo(PDO $pdo, bool $manageLifecycle = false); ```php $conn->pdo(); // PDO instance $conn->isConnected(); // bool -$conn->connect(); +$conn->connect(); // eager connect (optional — pdo() connects lazily) $conn->disconnect(); $conn->reconnect(); $conn->getDatabaseName(); // string diff --git a/README.md b/README.md index 7dd9129..7ca4c07 100644 --- a/README.md +++ b/README.md @@ -21,6 +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 - **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 @@ -49,6 +50,7 @@ $connection = $factory->mysql( database: 'mydb' ); +// Nothing is connected yet: the connection opens on the first access below. $pdo = $connection->pdo(); $users = $pdo->query('SELECT * FROM users')->fetchAll(); ``` diff --git a/src/Connection/PdoConnection.php b/src/Connection/PdoConnection.php index 613a58f..d4a9780 100644 --- a/src/Connection/PdoConnection.php +++ b/src/Connection/PdoConnection.php @@ -15,12 +15,25 @@ /** * PDO connection management. * Base class for all database drivers providing common PDO functionality. + * + * Lazy: constructing the object opens no connection. The first call that + * needs the PDO handle (pdo(), transactions, queries via pdo()) connects; + * isConnected() is false until then. connect() stays available to connect + * eagerly and is idempotent. */ class PdoConnection implements DbConnectionInterface, SavepointInterface { protected ?PDO $pdo = null; protected DatabaseConfigInterface $config; + /** + * True until the first access (lazy initialization): the connection is + * opened by the first call that needs the PDO handle. An explicit + * disconnect() clears it, so a deliberately closed connection is never + * reopened implicitly (only connect()/reconnect() do that). + */ + private bool $connectOnDemand = true; + public function __construct(DatabaseConfigInterface $config) { $this->config = $config; @@ -67,8 +80,18 @@ protected function createPdoConnection(): void } } + /** + * Returns the PDO handle. The first call on a never-connected instance + * opens the connection (lazy); a connection error surfaces here as the + * same RuntimeException connect() throws. + * + */ public function pdo(): PDO { + if ($this->pdo === null && $this->connectOnDemand) { + $this->connect(); + } + if ($this->pdo === null) { throw new RuntimeException('No active database connection'); } @@ -83,6 +106,7 @@ public function isConnected(): bool public function disconnect(): void { + $this->connectOnDemand = false; $this->pdo = null; } diff --git a/src/Factory/ConnectionFactory.php b/src/Factory/ConnectionFactory.php index c4f1bce..3269b0e 100644 --- a/src/Factory/ConnectionFactory.php +++ b/src/Factory/ConnectionFactory.php @@ -16,6 +16,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. */ final class ConnectionFactory { @@ -40,9 +44,7 @@ public function mysql( charset: $charset, options: $options, ); - $connection = new PdoConnection($config); - $connection->connect(); - return $connection; + return new PdoConnection($config); } /** @@ -64,9 +66,7 @@ public function postgres( port: $port, options: $options, ); - $connection = new PdoConnection($config); - $connection->connect(); - return $connection; + return new PdoConnection($config); } /** diff --git a/tests/Integration/ExternalTest.php b/tests/Integration/ExternalTest.php index a554ea2..1df3c77 100644 --- a/tests/Integration/ExternalTest.php +++ b/tests/Integration/ExternalTest.php @@ -281,6 +281,7 @@ private function isMySqlAvailable(): bool $this->database, $this->port ); + $connection->connect(); $available = $connection->isConnected(); $connection->disconnect(); return $available; diff --git a/tests/Integration/MySqlTest.php b/tests/Integration/MySqlTest.php index 615c75d..2d9cbcb 100644 --- a/tests/Integration/MySqlTest.php +++ b/tests/Integration/MySqlTest.php @@ -176,6 +176,7 @@ public function testInvalidCredentialsThrowException(): void $this->database, $this->port ); + $this->connection->connect(); } public function testInvalidDatabaseThrowsException(): void @@ -191,6 +192,7 @@ public function testInvalidDatabaseThrowsException(): void 'non_existent_database', $this->port ); + $this->connection->connect(); } public function testCustomCharset(): void @@ -204,6 +206,7 @@ public function testCustomCharset(): void $this->port, charset: 'utf8' ); + $this->connection->connect(); $pdo = $this->connection->pdo(); $stmt = $pdo->query("SHOW VARIABLES LIKE 'character_set_client'"); $result = $stmt->fetch(\PDO::FETCH_ASSOC); @@ -237,6 +240,7 @@ public function testCustomPdoOptions(): void $this->port, options: $customOptions ); + $this->connection->connect(); $pdo = $this->connection->pdo(); $errorMode = $pdo->getAttribute(\PDO::ATTR_ERRMODE); @@ -246,19 +250,23 @@ public function testCustomPdoOptions(): void private function createConnection(): DbConnectionInterface { $factory = new ConnectionFactory(); - return $factory->mysql( + $connection = $factory->mysql( $this->host, $this->user, $this->password, $this->database, $this->port ); + $connection->connect(); + + return $connection; } private function isMySqlAvailable(): bool { try { $connection = $this->createConnection(); + $connection->connect(); $available = $connection->isConnected(); $connection->disconnect(); return $available; diff --git a/tests/Integration/PostgresTest.php b/tests/Integration/PostgresTest.php index a7941d8..932b247 100644 --- a/tests/Integration/PostgresTest.php +++ b/tests/Integration/PostgresTest.php @@ -176,6 +176,7 @@ public function testInvalidCredentialsThrowException(): void $this->database, $this->port ); + $this->connection->connect(); } public function testInvalidDatabaseThrowsException(): void @@ -191,6 +192,7 @@ public function testInvalidDatabaseThrowsException(): void 'non_existent_database', $this->port ); + $this->connection->connect(); } public function testCustomPdoOptions(): void @@ -208,6 +210,7 @@ public function testCustomPdoOptions(): void $this->port, options: $customOptions ); + $this->connection->connect(); $pdo = $this->connection->pdo(); $errorMode = $pdo->getAttribute(\PDO::ATTR_ERRMODE); @@ -217,19 +220,23 @@ public function testCustomPdoOptions(): void private function createConnection(): DbConnectionInterface { $factory = new ConnectionFactory(); - return $factory->postgres( + $connection = $factory->postgres( $this->host, $this->user, $this->password, $this->database, $this->port ); + $connection->connect(); + + return $connection; } private function isPostgresAvailable(): bool { try { $connection = $this->createConnection(); + $connection->connect(); $available = $connection->isConnected(); $connection->disconnect(); return $available; diff --git a/tests/Integration/SavepointTest.php b/tests/Integration/SavepointTest.php index 89d7bcb..0091b0b 100644 --- a/tests/Integration/SavepointTest.php +++ b/tests/Integration/SavepointTest.php @@ -283,7 +283,10 @@ private function open(string $driver): DbConnectionInterface private function tryOpen(callable $open, string $label): DbConnectionInterface { try { - return $open(); + $connection = $open(); + $connection->connect(); + + return $connection; } catch (\Throwable $e) { $this->markTestSkipped($label . ' server is not available: ' . $e->getMessage()); } diff --git a/tests/Unit/LazyConnectionTest.php b/tests/Unit/LazyConnectionTest.php new file mode 100644 index 0000000..a7ad7ad --- /dev/null +++ b/tests/Unit/LazyConnectionTest.php @@ -0,0 +1,100 @@ +mysql('127.0.0.1', 'u', 'p', 'db', 1); + + $this->assertFalse($connection->isConnected()); + $this->assertSame('mysql', $connection->getDriverName()); + } + + public function testPostgresFactoryDoesNotConnect(): void + { + $connection = (new ConnectionFactory())->postgres('127.0.0.1', 'u', 'p', 'db', 1); + + $this->assertFalse($connection->isConnected()); + } + + public function testFirstAccessThrowsTheConnectionError(): void + { + $connection = (new ConnectionFactory())->mysql('127.0.0.1', 'u', 'p', 'db', 1); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not connect to mysql database'); + + $connection->pdo(); + } + + public function testBeginTransactionConnectsLazilyAndThrowsOnUnreachableServer(): void + { + $connection = (new ConnectionFactory())->mysql('127.0.0.1', 'u', 'p', 'db', 1); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not connect to mysql database'); + + $connection->beginTransaction(); + } + + public function testPoolBuildsWithoutConnectingAndFailsOnFirstAccess(): void + { + $writer = (new ConnectionFactory())->mysql('127.0.0.1', 'u', 'p', 'db', 1); + $pool = new ConnectionPool($writer); + + $this->assertFalse($writer->isConnected()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Writer connection health check failed'); + + $pool->getWriter(); + } + + public function testPoolWithoutHealthCheckDoesNotConnectOnGetWriter(): void + { + $writer = (new ConnectionFactory())->mysql('127.0.0.1', 'u', 'p', 'db', 1); + $pool = new ConnectionPool($writer, config: new ConnectionPoolConfig(validateConnections: false)); + + $this->assertSame($writer, $pool->getWriter()); + $this->assertFalse($writer->isConnected()); + } + + public function testExplicitDisconnectIsNotReopenedImplicitly(): void + { + $connection = (new ConnectionFactory())->fromPdo(new \PDO('sqlite::memory:'), manageLifecycle: true); + $connection->disconnect(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No active database connection'); + + $connection->pdo(); + } + + public function testIsConnectedBecomesTrueAfterFirstAccess(): void + { + $connection = new PdoConnection(new SqliteConfig(path: ':memory:')); + + $this->assertFalse($connection->isConnected()); + + $connection->pdo(); + + $this->assertTrue($connection->isConnected()); + } +}