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
4 changes: 3 additions & 1 deletion .claude/skills/adapter-dbconnection/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
```
Expand Down
24 changes: 24 additions & 0 deletions src/Connection/PdoConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
Expand All @@ -83,6 +106,7 @@ public function isConnected(): bool

public function disconnect(): void
{
$this->connectOnDemand = false;
$this->pdo = null;
}

Expand Down
12 changes: 6 additions & 6 deletions src/Factory/ConnectionFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -40,9 +44,7 @@ public function mysql(
charset: $charset,
options: $options,
);
$connection = new PdoConnection($config);
$connection->connect();
return $connection;
return new PdoConnection($config);
}

/**
Expand All @@ -64,9 +66,7 @@ public function postgres(
port: $port,
options: $options,
);
$connection = new PdoConnection($config);
$connection->connect();
return $connection;
return new PdoConnection($config);
}

/**
Expand Down
1 change: 1 addition & 0 deletions tests/Integration/ExternalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ private function isMySqlAvailable(): bool
$this->database,
$this->port
);
$connection->connect();
$available = $connection->isConnected();
$connection->disconnect();
return $available;
Expand Down
10 changes: 9 additions & 1 deletion tests/Integration/MySqlTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ public function testInvalidCredentialsThrowException(): void
$this->database,
$this->port
);
$this->connection->connect();
}

public function testInvalidDatabaseThrowsException(): void
Expand All @@ -191,6 +192,7 @@ public function testInvalidDatabaseThrowsException(): void
'non_existent_database',
$this->port
);
$this->connection->connect();
}

public function testCustomCharset(): void
Expand All @@ -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);
Expand Down Expand Up @@ -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);

Expand All @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion tests/Integration/PostgresTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ public function testInvalidCredentialsThrowException(): void
$this->database,
$this->port
);
$this->connection->connect();
}

public function testInvalidDatabaseThrowsException(): void
Expand All @@ -191,6 +192,7 @@ public function testInvalidDatabaseThrowsException(): void
'non_existent_database',
$this->port
);
$this->connection->connect();
}

public function testCustomPdoOptions(): void
Expand All @@ -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);

Expand All @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion tests/Integration/SavepointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
100 changes: 100 additions & 0 deletions tests/Unit/LazyConnectionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

declare(strict_types=1);

namespace JardisAdapter\DbConnection\Tests\Unit;

use JardisAdapter\DbConnection\Config\ConnectionPoolConfig;
use JardisAdapter\DbConnection\ConnectionPool;
use JardisAdapter\DbConnection\Config\SqliteConfig;
use JardisAdapter\DbConnection\Connection\PdoConnection;
use JardisAdapter\DbConnection\Factory\ConnectionFactory;
use PHPUnit\Framework\TestCase;
use RuntimeException;

/**
* The factory builds mysql/postgres connections without opening a socket;
* the connection opens on first access. 127.0.0.1:1 is a closed port, so any
* real connect attempt fails fast and deterministically without a server.
*/
final class LazyConnectionTest extends TestCase
{
public function testMysqlFactoryDoesNotConnect(): void
{
$connection = (new ConnectionFactory())->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());
}
}
Loading