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
15 changes: 11 additions & 4 deletions packages/database/src/Connection/ConnectionInitializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,26 @@
namespace Tempest\Database\Connection;

use Tempest\Container\Container;
use Tempest\Container\Initializer;
use Tempest\Container\DynamicInitializer;
use Tempest\Container\Singleton;
use Tempest\Database\Config\DatabaseConfig;
use Tempest\Reflection\ClassReflector;
use UnitEnum;

final class ConnectionInitializer implements Initializer
final class ConnectionInitializer implements DynamicInitializer
{
/** @var Connection[] */
private static array $connections = [];

public function canInitialize(ClassReflector $class, string|UnitEnum|null $tag): bool
{
return $class->getName() === Connection::class;
}

#[Singleton]
public function initialize(Container $container): Connection
public function initialize(ClassReflector $class, string|UnitEnum|null $tag, Container $container): Connection
{
$config = $container->get(DatabaseConfig::class);
$config = $container->get(DatabaseConfig::class, $tag);
$connectionKey = $this->getConnectionKey($config);

$connection = $config->usePersistentConnection
Expand Down
36 changes: 2 additions & 34 deletions packages/database/src/DatabaseInitializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
use Tempest\Container\Singleton;
use Tempest\Database\Config\DatabaseConfig;
use Tempest\Database\Connection\Connection;
use Tempest\Database\Connection\PDOConnection;
use Tempest\Database\Transactions\GenericTransactionManager;
use Tempest\EventBus\EventBus;
use Tempest\Mapper\SerializerFactory;
Expand All @@ -18,9 +17,6 @@

final class DatabaseInitializer implements DynamicInitializer
{
/** @var Connection[] */
private static array $connections = [];

public function canInitialize(ClassReflector $class, string|UnitEnum|null $tag): bool
{
return $class->getType()->matches(Database::class);
Expand All @@ -30,42 +26,14 @@ public function canInitialize(ClassReflector $class, string|UnitEnum|null $tag):
public function initialize(ClassReflector $class, string|UnitEnum|null $tag, Container $container): Database
{
$config = $container->get(DatabaseConfig::class, $tag);
$connectionKey = $this->getConnectionKey($config);

$connection = $config->usePersistentConnection
? self::$connections[$connectionKey] ?? null
: null;

if (! $connection) {
$connection = new PDOConnection($config);
$connection->connect();
self::$connections[$connectionKey] = $connection;
} elseif ($connection->ping() === false) {
$connection->reconnect();
}

$container->singleton(
className: Connection::class,
definition: $connection,
tag: $tag,
);
$connection = $container->get(Connection::class, $tag);

return new GenericDatabase(
connection: $connection,
transactionManager: new GenericTransactionManager($connection),
serializerFactory: $container->get(SerializerFactory::class),
eventBus: $container->get(EventBus::class),
config: $config,
);
}

private function getConnectionKey(DatabaseConfig $config): string
{
return hash('xxh128', serialize([
$config->dsn,
$config->username,
$config->options,
$config->password,
$config->tag,
]));
}
}
16 changes: 11 additions & 5 deletions packages/database/src/GenericDatabase.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

namespace Tempest\Database;

use LogicException;
use PDO;
use PDOException;
use PDOStatement;
use Tempest\Database\Builder\QueryBuilders\BuildsQuery;
use Tempest\Database\Config\DatabaseConfig;
use Tempest\Database\Config\DatabaseDialect;
use Tempest\Database\Connection\Connection;
use Tempest\Database\Connection\PDOConnection;
Expand All @@ -20,20 +22,20 @@
use Throwable;
use UnitEnum;

// TODO: add DatabaseConnection to the Connection interface instead (4.x)
/** @property PDOConnection $connection */
final class GenericDatabase implements Database
{
private readonly ?DatabaseConfig $config;

private ?PDOStatement $lastStatement = null;

private ?Query $lastQuery = null;

public DatabaseDialect $dialect {
get => $this->connection->config->dialect;
get => $this->config->dialect ?? throw new LogicException('A database configuration is required to determine the dialect.');
}

public string|UnitEnum|null $tag {
get => $this->connection->config->tag;
get => $this->config->tag ?? null;
}

private DatabaseContext $context {
Expand All @@ -45,7 +47,11 @@ public function __construct(
private(set) readonly TransactionManager $transactionManager,
private(set) readonly SerializerFactory $serializerFactory,
private readonly EventBus $eventBus,
) {}
?DatabaseConfig $config = null,
) {
// Preserve the v3 constructor for callers that pass a PDOConnection directly.
$this->config = $config ?? ($connection instanceof PDOConnection ? $connection->config : null);
}

public function execute(BuildsQuery|Query $query): void
{
Expand Down
29 changes: 17 additions & 12 deletions src/Tempest/Framework/Testing/TestingDatabaseInitializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,29 +35,34 @@ public function canInitialize(ClassReflector $class, string|UnitEnum|null $tag):
public function initialize(ClassReflector $class, string|UnitEnum|null $tag, Container $container): Database
{
$tag = Str\parse($tag);
$config = $container->get(DatabaseConfig::class, $tag);

/** @var PDOConnection|null $connection */
$connection = self::$connections[$tag] ?? null;
if ($container->has(Connection::class, $tag)) {
$connection = $container->get(Connection::class, $tag);
} else {
/** @var PDOConnection|null $connection */
$connection = self::$connections[$tag] ?? null;

if ($connection === null) {
$config = $container->get(DatabaseConfig::class, $tag);
$connection = new PDOConnection($config);
$connection->connect();
if ($connection === null) {
$connection = new PDOConnection($config);
$connection->connect();

self::$connections[$tag] = $connection;
}
self::$connections[$tag] = $connection;
}

if ($connection->ping() === false) {
$connection->reconnect();
}
if ($connection->ping() === false) {
$connection->reconnect();
}

$container->singleton(Connection::class, $connection, $tag);
$container->singleton(Connection::class, $connection, $tag);
}

return new GenericDatabase(
$connection,
new GenericTransactionManager($connection),
$container->get(SerializerFactory::class),
$container->get(EventBus::class),
config: $config,
);
}
}
2 changes: 2 additions & 0 deletions tests/Integration/Container/TaggedDynamicInitializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use PHPUnit\Framework\TestCase;
use Tempest\Container\GenericContainer;
use Tempest\Database\Config\SQLiteConfig;
use Tempest\Database\Connection\ConnectionInitializer;
use Tempest\Database\Database;
use Tempest\Database\DatabaseInitializer;
use Tempest\EventBus\EventBus;
Expand All @@ -21,6 +22,7 @@ public function resolve(): void
$container = new GenericContainer();
$container->singleton(SerializerFactory::class, new SerializerFactory($container));
$container->singleton(EventBus::class, new GenericEventBus($container, new EventBusConfig()));
$container->addInitializer(ConnectionInitializer::class);
$container->addInitializer(DatabaseInitializer::class);

$container->config(new SQLiteConfig(
Expand Down
149 changes: 148 additions & 1 deletion tests/Integration/Database/DatabaseInitializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,22 @@

namespace Tests\Tempest\Integration\Database;

use PDO;
use PDOStatement;
use PHPUnit\Framework\Attributes\Test;
use ReflectionMethod;
use RuntimeException;
use Tempest\Database\Config\DatabaseConfig;
use Tempest\Database\Config\DatabaseDialect;
use Tempest\Database\Config\MysqlConfig;
use Tempest\Database\Config\SQLiteConfig;
use Tempest\Database\Connection\Connection;
use Tempest\Database\Connection\ConnectionInitializer;
use Tempest\Database\Connection\PDOConnection;
use Tempest\Database\Database;
use Tempest\Database\DatabaseInitializer;
use Tempest\Database\GenericDatabase;
use Tempest\Database\Query;
use Tempest\Framework\Testing\TestingDatabaseInitializer;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;

Expand Down Expand Up @@ -47,6 +54,146 @@ public function it_resolves_multiple_persistent_connections_by_tag(): void
$this->assertSame($backup->connection, $this->container->get(Connection::class, 'backup'));
}

#[Test]
public function it_resolves_tagged_connections_before_databases_without_sharing_them(): void
{
$this->configureSqliteDatabase('main', 'connection-first-main.sqlite');
$this->configureSqliteDatabase('backup', 'connection-first-backup.sqlite');

$mainConnection = $this->container->get(Connection::class, 'main');
$backupConnection = $this->container->get(Connection::class, 'backup');

$this->assertInstanceOf(PDOConnection::class, $mainConnection);
$this->assertInstanceOf(PDOConnection::class, $backupConnection);
$this->assertNotSame($mainConnection, $backupConnection);
$main = $this->container->get(Database::class, 'main');
$backup = $this->container->get(Database::class, 'backup');
$this->assertInstanceOf(GenericDatabase::class, $main);
$this->assertInstanceOf(GenericDatabase::class, $backup);
$this->assertSame($mainConnection, $main->connection);
$this->assertSame($backupConnection, $backup->connection);
$this->assertSame('main', $main->tag);
$this->assertSame('backup', $backup->tag);
}

#[Test]
public function it_honours_an_injected_connection_when_resolving_database_first(): void
{
$this->configureSqliteDatabase('main', 'injected-database-first.sqlite');
$connection = $this->createMock(Connection::class);
$this->container->singleton(Connection::class, $connection, 'main');

$database = $this->container->get(Database::class, 'main');

$this->assertInstanceOf(GenericDatabase::class, $database);
$this->assertSame($connection, $database->connection);
$this->assertSame($connection, $this->container->get(Connection::class, 'main'));
$this->assertSame(DatabaseDialect::SQLITE, $database->dialect);
$this->assertSame('main', $database->tag);
}

#[Test]
public function it_honours_an_untagged_injected_connection(): void
{
$connection = $this->createMock(Connection::class);
$this->container->unregister(Database::class);
$this->container->singleton(Connection::class, $connection);

$database = $this->container->get(Database::class);

$this->assertInstanceOf(GenericDatabase::class, $database);
$this->assertSame($connection, $database->connection);
$this->assertSame($connection, $this->container->get(Connection::class));
$this->assertSame($this->container->get(DatabaseConfig::class)->dialect, $database->dialect);
$this->assertSame($this->container->get(DatabaseConfig::class)->tag, $database->tag);
}

#[Test]
public function it_keeps_injected_tagged_connections_separate(): void
{
$this->configureSqliteDatabase('main', 'injected-main.sqlite');
$this->configureSqliteDatabase('backup', 'injected-backup.sqlite');

$mainConnection = $this->createMock(Connection::class);
$backupConnection = $this->createMock(Connection::class);
$this->container->singleton(Connection::class, $mainConnection, 'main');
$this->container->singleton(Connection::class, $backupConnection, 'backup');

$main = $this->container->get(Database::class, 'main');
$backup = $this->container->get(Database::class, 'backup');
$this->assertInstanceOf(GenericDatabase::class, $main);
$this->assertInstanceOf(GenericDatabase::class, $backup);
$this->assertSame($mainConnection, $main->connection);
$this->assertSame($backupConnection, $backup->connection);
$this->assertSame('main', $main->tag);
$this->assertSame('backup', $backup->tag);
}

#[Test]
public function it_honours_an_injected_connection_when_resolving_connection_first(): void
{
$this->configureSqliteDatabase('main', 'injected-connection-first.sqlite');
$connection = $this->createMock(Connection::class);
$this->container->singleton(Connection::class, $connection, 'main');

$this->assertSame($connection, $this->container->get(Connection::class, 'main'));

$database = $this->container->get(Database::class, 'main');

$this->assertInstanceOf(GenericDatabase::class, $database);
$this->assertSame($connection, $database->connection);
}

#[Test]
public function it_uses_the_injected_connection_in_an_externally_managed_transaction(): void
{
$this->configureSqliteDatabase('main', 'external-transaction.sqlite');

$pdo = new PDO('sqlite::memory:');
$pdo->exec('CREATE TABLE outbox (message TEXT)');

$connection = $this->createMock(Connection::class);
$connection
->method('prepare')
->willReturnCallback(
fn (string $sql): PDOStatement => $pdo->prepare($sql) ?: throw new RuntimeException('Could not prepare SQL statement.'),
);
$connection->expects($this->never())->method('connect');
$connection->expects($this->never())->method('reconnect');
$connection->expects($this->never())->method('ping');

$this->container->singleton(Connection::class, $connection, 'main');
$database = $this->container->get(Database::class, 'main');

$pdo->beginTransaction();
$database->execute(new Query("INSERT INTO outbox (message) VALUES ('application')"));
$pdo->exec("INSERT INTO outbox (message) VALUES ('messenger')");

$this->assertCount(2, $database->fetch(new Query('SELECT * FROM outbox')));

$pdo->rollBack();

$this->assertSame([], $database->fetch(new Query('SELECT * FROM outbox')));
}

#[Test]
public function the_testing_initializer_also_honours_an_injected_connection(): void
{
$this->container
->removeInitializer(DatabaseInitializer::class)
->addInitializer(TestingDatabaseInitializer::class);

$this->configureSqliteDatabase('main', 'injected-testing.sqlite');
$connection = $this->createMock(Connection::class);
$this->container->singleton(Connection::class, $connection, 'main');

$database = $this->container->get(Database::class, 'main');

$this->assertInstanceOf(GenericDatabase::class, $database);
$this->assertSame($connection, $database->connection);
$this->assertSame('main', $database->tag);
}

#[Test]
public function it_reuses_a_persistent_connection_for_the_same_connection_config(): void
{
Expand Down Expand Up @@ -117,7 +264,7 @@ public function it_does_not_reuse_a_persistent_connection_for_the_same_tag_with_
#[Test]
public function it_does_not_reuse_a_persistent_connection_when_only_the_password_differs(): void
{
$initializer = new DatabaseInitializer();
$initializer = new ConnectionInitializer();
$method = new ReflectionMethod($initializer, 'getConnectionKey');

$first = new MysqlConfig(
Expand Down
Loading