diff --git a/packages/database/src/Connection/ConnectionInitializer.php b/packages/database/src/Connection/ConnectionInitializer.php index bf36c0bba9..51f0f94e5a 100644 --- a/packages/database/src/Connection/ConnectionInitializer.php +++ b/packages/database/src/Connection/ConnectionInitializer.php @@ -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 diff --git a/packages/database/src/DatabaseInitializer.php b/packages/database/src/DatabaseInitializer.php index 767cd7c132..fc0b5f455f 100644 --- a/packages/database/src/DatabaseInitializer.php +++ b/packages/database/src/DatabaseInitializer.php @@ -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; @@ -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); @@ -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, - ])); - } } diff --git a/packages/database/src/GenericDatabase.php b/packages/database/src/GenericDatabase.php index c0382568e5..7a1523e431 100644 --- a/packages/database/src/GenericDatabase.php +++ b/packages/database/src/GenericDatabase.php @@ -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; @@ -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 { @@ -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 { diff --git a/src/Tempest/Framework/Testing/TestingDatabaseInitializer.php b/src/Tempest/Framework/Testing/TestingDatabaseInitializer.php index 6ea2b7b540..09c3c0dca2 100644 --- a/src/Tempest/Framework/Testing/TestingDatabaseInitializer.php +++ b/src/Tempest/Framework/Testing/TestingDatabaseInitializer.php @@ -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, ); } } diff --git a/tests/Integration/Container/TaggedDynamicInitializerTest.php b/tests/Integration/Container/TaggedDynamicInitializerTest.php index 9554b8df6c..19b50f89d6 100644 --- a/tests/Integration/Container/TaggedDynamicInitializerTest.php +++ b/tests/Integration/Container/TaggedDynamicInitializerTest.php @@ -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; @@ -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( diff --git a/tests/Integration/Database/DatabaseInitializerTest.php b/tests/Integration/Database/DatabaseInitializerTest.php index 767a1a73fe..cfd11f0e4e 100644 --- a/tests/Integration/Database/DatabaseInitializerTest.php +++ b/tests/Integration/Database/DatabaseInitializerTest.php @@ -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; @@ -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 { @@ -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(