diff --git a/packages/auth/src/Authentication/SessionAuthenticator.php b/packages/auth/src/Authentication/SessionAuthenticator.php index fc5d98d1d1..572b4fcfef 100644 --- a/packages/auth/src/Authentication/SessionAuthenticator.php +++ b/packages/auth/src/Authentication/SessionAuthenticator.php @@ -4,11 +4,10 @@ namespace Tempest\Auth\Authentication; -use Tempest\Container\Resettable; use Tempest\Http\Session\Session; use Tempest\Http\Session\SessionManager; -final class SessionAuthenticator implements Authenticator, Resettable +final class SessionAuthenticator implements Authenticator { public const string AUTHENTICATABLE_KEY = '#authenticatable:id'; @@ -77,12 +76,7 @@ public function current(): ?Authenticatable return $this->current; } - public function reset(): void - { - $this->clearCurrent(); - } - - private function clearCurrent(): void + public function clearCurrent(): void { $this->currentId = null; $this->currentClass = null; diff --git a/packages/auth/src/Authentication/SessionAuthenticatorReset.php b/packages/auth/src/Authentication/SessionAuthenticatorReset.php new file mode 100644 index 0000000000..992b5e72bd --- /dev/null +++ b/packages/auth/src/Authentication/SessionAuthenticatorReset.php @@ -0,0 +1,17 @@ +sessionAuthenticator->clearCurrent(); + } +} diff --git a/packages/auth/tests/SessionAuthenticatorTest.php b/packages/auth/tests/SessionAuthenticatorTest.php index c4aa78a3f4..6e791e796d 100644 --- a/packages/auth/tests/SessionAuthenticatorTest.php +++ b/packages/auth/tests/SessionAuthenticatorTest.php @@ -9,7 +9,7 @@ use Tempest\Auth\Authentication\Authenticatable; use Tempest\Auth\Authentication\AuthenticatableResolver; use Tempest\Auth\Authentication\SessionAuthenticator; -use Tempest\Container\Resettable; +use Tempest\Auth\Authentication\SessionAuthenticatorReset; use Tempest\DateTime\DateTime; use Tempest\Http\Session\Session; use Tempest\Http\Session\SessionId; @@ -100,10 +100,9 @@ public function reset_clears_the_cached_current_authenticatable(): void authenticatableResolver: $resolver, ); - $this->assertInstanceOf(Resettable::class, $authenticator); $this->assertSame($authenticatable, $authenticator->current()); - $authenticator->reset(); + new SessionAuthenticatorReset($authenticator)->reset(); $this->assertSame($authenticatable, $authenticator->current()); $this->assertSame(2, $resolver->resolveCalls); diff --git a/packages/console/src/ConsoleApplication.php b/packages/console/src/ConsoleApplication.php index 7036f9c6b4..fed1dfdf72 100644 --- a/packages/console/src/ConsoleApplication.php +++ b/packages/console/src/ConsoleApplication.php @@ -48,7 +48,7 @@ public static function boot( return $container->get(ConsoleApplication::class); } - public function run(): never + public function run(): void { $exitCode = $this->container->get(ExecuteConsoleCommand::class)($this->argumentBag->getCommandName()); diff --git a/packages/core/src/DeferredTasksReset.php b/packages/core/src/DeferredTasksReset.php new file mode 100644 index 0000000000..cc069a01d3 --- /dev/null +++ b/packages/core/src/DeferredTasksReset.php @@ -0,0 +1,18 @@ +container->unregister(DeferredTasks::class); + } +} diff --git a/packages/core/src/FrameworkKernel.php b/packages/core/src/FrameworkKernel.php index 527ea68557..9f43adb6e2 100644 --- a/packages/core/src/FrameworkKernel.php +++ b/packages/core/src/FrameworkKernel.php @@ -35,9 +35,10 @@ final class FrameworkKernel implements Kernel public function __construct( public string $root, /** @var DiscoveryLocation[] */ - private array $discoveryLocations = [], + private readonly array $discoveryLocations = [], ?Container $container = null, ?string $internalStorage = null, + private readonly bool $longRunning = false, ) { $this->container = $container ?? $this->createContainer(); @@ -51,6 +52,7 @@ public static function boot( array $discoveryLocations = [], ?Container $container = null, ?string $internalStorage = null, + bool $longRunning = false, ): self { if (! defined('TEMPEST_START')) { define('TEMPEST_START', value: hrtime(as_number: true)); @@ -61,6 +63,7 @@ public static function boot( discoveryLocations: $discoveryLocations, container: $container, internalStorage: $internalStorage, + longRunning: $longRunning, ) ->registerKernel() ->validateRoot() @@ -98,12 +101,23 @@ public function validateRoot(): self return $this; } - public function shutdown(int|string $status = ''): never + public function shutdown(int|string $status = ''): void { - $this->finishDeferredTasks() - ->event(KernelEvent::SHUTDOWN); + $this->event(KernelEvent::SHUTTING_DOWN) + ->finishDeferredTasks(); + + if ($this->longRunning) { + $this + ->event(KernelEvent::RESETTING) + ->resetContainer() + ->event(KernelEvent::RESET); + } + + $this->event(KernelEvent::SHUTDOWN); - exit($status); + if (! $this->longRunning) { + exit($status); + } } public function loadComposer(): self @@ -236,6 +250,13 @@ public function finishDeferredTasks(): self return $this; } + public function resetContainer(): self + { + $this->container->reset(); + + return $this; + } + public function event(object $event): self { if (interface_exists(EventBus::class)) { diff --git a/packages/core/src/Kernel.php b/packages/core/src/Kernel.php index 41cef0af59..3c098a4d49 100644 --- a/packages/core/src/Kernel.php +++ b/packages/core/src/Kernel.php @@ -23,5 +23,5 @@ public static function boot( ?string $internalStorage = null, ): self; - public function shutdown(int|string $status = ''): never; + public function shutdown(int|string $status = ''): void; } diff --git a/packages/core/src/KernelEvent.php b/packages/core/src/KernelEvent.php index 0f91a34de2..0a0ce9f2a7 100644 --- a/packages/core/src/KernelEvent.php +++ b/packages/core/src/KernelEvent.php @@ -6,6 +6,9 @@ enum KernelEvent { + case SHUTTING_DOWN; + case RESETTING; + case RESET; case BOOTED; case SHUTDOWN; } diff --git a/packages/core/src/Tempest.php b/packages/core/src/Tempest.php index 370eacb6ef..256cd9d4fd 100644 --- a/packages/core/src/Tempest.php +++ b/packages/core/src/Tempest.php @@ -9,12 +9,17 @@ final readonly class Tempest { /** @param \Tempest\Discovery\DiscoveryLocation[] $discoveryLocations */ - public static function boot(?string $root = null, array $discoveryLocations = [], ?string $internalStorage = null): Container - { + public static function boot( + ?string $root = null, + array $discoveryLocations = [], + ?string $internalStorage = null, + bool $longRunning = false, + ): Container { $kernel = FrameworkKernel::boot( root: $root ?? getcwd(), discoveryLocations: $discoveryLocations, internalStorage: $internalStorage, + longRunning: $longRunning, ); return $kernel->container; diff --git a/packages/database/src/Connection/Connection.php b/packages/database/src/Connection/Connection.php index afcb58aab6..2bd4ac7e2e 100644 --- a/packages/database/src/Connection/Connection.php +++ b/packages/database/src/Connection/Connection.php @@ -10,6 +10,8 @@ interface Connection { public function beginTransaction(): bool; + public function inTransaction(): bool; + public function commit(): bool; public function rollback(): bool; @@ -21,4 +23,8 @@ public function prepare(string $sql): PDOStatement; public function close(): void; public function connect(): void; + + public function reconnect(): void; + + public function ping(): bool; } diff --git a/packages/database/src/Connection/ConnectionInitializer.php b/packages/database/src/Connection/ConnectionInitializer.php index 11a08f128e..bf36c0bba9 100644 --- a/packages/database/src/Connection/ConnectionInitializer.php +++ b/packages/database/src/Connection/ConnectionInitializer.php @@ -11,14 +11,38 @@ final class ConnectionInitializer implements Initializer { + /** @var Connection[] */ + private static array $connections = []; + #[Singleton] public function initialize(Container $container): Connection { - $databaseConfig = $container->get(DatabaseConfig::class); + $config = $container->get(DatabaseConfig::class); + $connectionKey = $this->getConnectionKey($config); + + $connection = $config->usePersistentConnection + ? self::$connections[$connectionKey] ?? null + : null; - $connection = new PDOConnection($databaseConfig); - $connection->connect(); + if (! $connection instanceof Connection) { + $connection = new PDOConnection($config); + $connection->connect(); + self::$connections[$connectionKey] = $connection; + } elseif ($connection->ping() === false) { + $connection->reconnect(); + } return $connection; } + + 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/Connection/ConnectionReset.php b/packages/database/src/Connection/ConnectionReset.php new file mode 100644 index 0000000000..bb6f72df94 --- /dev/null +++ b/packages/database/src/Connection/ConnectionReset.php @@ -0,0 +1,35 @@ +container instanceof GenericContainer) { + $connections = $this->container->getSingletons(Connection::class); + + foreach ($connections as $connection) { + if (! $connection instanceof Connection) { + continue; + } + + if ($connection->inTransaction()) { + throw new CouldNotResetConnection("There's still an active transaction, make sure to close it before ending the request"); + } + } + } + + $this->container->unregister(Connection::class, tagged: true); + } +} diff --git a/packages/database/src/Connection/PDOConnection.php b/packages/database/src/Connection/PDOConnection.php index 16a5cd9e79..fed6d12d42 100644 --- a/packages/database/src/Connection/PDOConnection.php +++ b/packages/database/src/Connection/PDOConnection.php @@ -69,6 +69,15 @@ public function prepare(string $sql): PDOStatement return $statement; } + public function inTransaction(): bool + { + if (! $this->pdo instanceof PDO) { + return false; + } + + return $this->pdo->inTransaction(); + } + public function ping(): bool { try { diff --git a/packages/database/src/DatabaseInitializer.php b/packages/database/src/DatabaseInitializer.php index c3f59bbad4..f0eb6bad39 100644 --- a/packages/database/src/DatabaseInitializer.php +++ b/packages/database/src/DatabaseInitializer.php @@ -16,8 +16,11 @@ use Tempest\Reflection\ClassReflector; use UnitEnum; -final readonly class DatabaseInitializer implements DynamicInitializer +final class DatabaseInitializer implements DynamicInitializer { + /** @var Connection[] */ + private static array $connections = []; + public function canInitialize(ClassReflector $class, null|string|UnitEnum $tag): bool { return $class->getType()->matches(Database::class); @@ -26,21 +29,27 @@ public function canInitialize(ClassReflector $class, null|string|UnitEnum $tag): #[Singleton] public function initialize(ClassReflector $class, null|string|UnitEnum $tag, Container $container): Database { - $container->singleton( - className: Connection::class, - definition: function () use ($tag, $container) { - $config = $container->get(DatabaseConfig::class, $tag); + $config = $container->get(DatabaseConfig::class, $tag); + $connectionKey = $this->getConnectionKey($config); - $connection = new PDOConnection($config); - $connection->connect(); + $connection = $config->usePersistentConnection + ? self::$connections[$connectionKey] ?? null + : null; - return $connection; - }, + 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), @@ -48,4 +57,15 @@ className: Connection::class, eventBus: $container->get(EventBus::class), ); } + + 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/DatabaseReset.php b/packages/database/src/DatabaseReset.php new file mode 100644 index 0000000000..f6d8171610 --- /dev/null +++ b/packages/database/src/DatabaseReset.php @@ -0,0 +1,18 @@ +container->unregister(Database::class, tagged: true); + } +} diff --git a/packages/database/src/Exceptions/CouldNotResetConnection.php b/packages/database/src/Exceptions/CouldNotResetConnection.php new file mode 100644 index 0000000000..e352b0e404 --- /dev/null +++ b/packages/database/src/Exceptions/CouldNotResetConnection.php @@ -0,0 +1,13 @@ +container->unregister(CookieManager::class); + } +} diff --git a/packages/http/src/Session/SessionReset.php b/packages/http/src/Session/SessionReset.php new file mode 100644 index 0000000000..c27bcfa896 --- /dev/null +++ b/packages/http/src/Session/SessionReset.php @@ -0,0 +1,18 @@ +container->unregister(Session::class); + } +} diff --git a/packages/router/src/HttpApplication.php b/packages/router/src/HttpApplication.php index 965655a34a..a9e5cc2de5 100644 --- a/packages/router/src/HttpApplication.php +++ b/packages/router/src/HttpApplication.php @@ -24,7 +24,7 @@ public static function boot(string $root, array $discoveryLocations = []): self return Tempest::boot($root, $discoveryLocations)->get(HttpApplication::class); } - public function run(): never + public function run(): void { $router = $this->container->get(Router::class); $psrRequest = $this->container->get(RequestFactory::class)->make(); diff --git a/packages/router/src/WorkerModeApplication.php b/packages/router/src/WorkerModeApplication.php new file mode 100644 index 0000000000..827200f48e --- /dev/null +++ b/packages/router/src/WorkerModeApplication.php @@ -0,0 +1,45 @@ +get(WorkerModeApplication::class); + } + + public function run(): void + { + $router = $this->container->get(Router::class); + $psrRequest = $this->container->get(RequestFactory::class)->make(); + $responseSender = $this->container->get(ResponseSender::class); + + $responseSender->send( + response: $router->dispatch($psrRequest), + ); + + $kernel = $this->container->get(Kernel::class); + + $kernel->shutdown(); + } +} diff --git a/packages/upgrade/config/sets/level/up-to-tempest-312.php b/packages/upgrade/config/sets/level/up-to-tempest-312.php new file mode 100644 index 0000000000..53892e6615 --- /dev/null +++ b/packages/upgrade/config/sets/level/up-to-tempest-312.php @@ -0,0 +1,17 @@ +sets([ + TempestSetList::TEMPEST_20, + TempestSetList::TEMPEST_28, + TempestSetList::TEMPEST_30, + TempestSetList::TEMPEST_34, + TempestSetList::TEMPEST_310, + TempestSetList::TEMPEST_312, + ]); +}; diff --git a/packages/upgrade/config/sets/tempest312.php b/packages/upgrade/config/sets/tempest312.php new file mode 100644 index 0000000000..2864e2972f --- /dev/null +++ b/packages/upgrade/config/sets/tempest312.php @@ -0,0 +1,15 @@ +rule(UpdateConnectionImplementationsRector::class); + $config->rule(UpdateKernelImplementationsRector::class); +}; diff --git a/packages/upgrade/src/Set/TempestLevelSetList.php b/packages/upgrade/src/Set/TempestLevelSetList.php index 5b3a714c12..4baad2e83d 100644 --- a/packages/upgrade/src/Set/TempestLevelSetList.php +++ b/packages/upgrade/src/Set/TempestLevelSetList.php @@ -15,4 +15,6 @@ final class TempestLevelSetList public const string UP_TO_TEMPEST_34 = __DIR__ . '/../../config/sets/level/up-to-tempest-34.php'; public const string UP_TO_TEMPEST_310 = __DIR__ . '/../../config/sets/level/up-to-tempest-310.php'; + + public const string UP_TO_TEMPEST_312 = __DIR__ . '/../../config/sets/level/up-to-tempest-312.php'; } diff --git a/packages/upgrade/src/Set/TempestSetList.php b/packages/upgrade/src/Set/TempestSetList.php index 62833a2036..e3f4abf683 100644 --- a/packages/upgrade/src/Set/TempestSetList.php +++ b/packages/upgrade/src/Set/TempestSetList.php @@ -15,4 +15,6 @@ final class TempestSetList public const string TEMPEST_34 = __DIR__ . '/../../config/sets/tempest34.php'; public const string TEMPEST_310 = __DIR__ . '/../../config/sets/tempest310.php'; + + public const string TEMPEST_312 = __DIR__ . '/../../config/sets/tempest312.php'; } diff --git a/packages/upgrade/src/Tempest312/UpdateConnectionImplementationsRector.php b/packages/upgrade/src/Tempest312/UpdateConnectionImplementationsRector.php new file mode 100644 index 0000000000..f8506c70bf --- /dev/null +++ b/packages/upgrade/src/Tempest312/UpdateConnectionImplementationsRector.php @@ -0,0 +1,93 @@ + 'bool', + 'ping' => 'bool', + 'reconnect' => 'void', + ]; + + public function getNodeTypes(): array + { + return [ + Class_::class, + ]; + } + + public function refactor(Node $node): ?Node + { + if (! $node instanceof Class_) { + return null; + } + + if (! $this->implementsConnection($node)) { + return null; + } + + $hasChanged = false; + + foreach (self::METHODS as $methodName => $returnType) { + if ($node->getMethod($methodName) instanceof ClassMethod) { + continue; + } + + $node->stmts[] = $this->createMethod($methodName, $returnType); + $hasChanged = true; + } + + return $hasChanged ? $node : null; + } + + private function implementsConnection(Class_ $class): bool + { + return array_any( + $class->implements, + fn (Name $name) => $this->isInterfaceName($name, Connection::class, 'Connection'), + ); + } + + private function isInterfaceName(Name $name, string $interfaceName, string $shortName): bool + { + $names = [ + ltrim($name->toString(), '\\'), + ]; + + $resolvedName = $name->getAttribute('resolvedName'); + + if ($resolvedName instanceof Name) { + $names[] = ltrim($resolvedName->toString(), '\\'); + } + + return array_any( + $names, + static fn (string $name) => in_array($name, [$interfaceName, $shortName], strict: true), + ); + } + + private function createMethod(string $methodName, string $returnType): ClassMethod + { + $statements = $returnType === 'bool' + ? [new Return_(new ConstFetch(new Name('false')))] + : []; + + return new ClassMethod($methodName, [ + 'flags' => Modifiers::PUBLIC, + 'returnType' => new Identifier($returnType), + 'stmts' => $statements, + ]); + } +} diff --git a/packages/upgrade/src/Tempest312/UpdateKernelImplementationsRector.php b/packages/upgrade/src/Tempest312/UpdateKernelImplementationsRector.php new file mode 100644 index 0000000000..22dcacfb4a --- /dev/null +++ b/packages/upgrade/src/Tempest312/UpdateKernelImplementationsRector.php @@ -0,0 +1,107 @@ +implementsKernel($node)) { + return null; + } + + $hasChanged = false; + $shutdown = $node->getMethod('shutdown'); + + if ($shutdown instanceof ClassMethod && ! $this->isVoidReturnType($shutdown)) { + $shutdown->returnType = new Identifier('void'); + $this->removeReturnValues($shutdown); + $hasChanged = true; + } + + return $hasChanged ? $node : null; + } + + private function implementsKernel(Class_ $class): bool + { + return array_any( + $class->implements, + fn (Name $name) => $this->isInterfaceName($name, Kernel::class, 'Kernel'), + ); + } + + private function isInterfaceName(Name $name, string $interfaceName, string $shortName): bool + { + $names = [ + ltrim($name->toString(), '\\'), + ]; + + $resolvedName = $name->getAttribute('resolvedName'); + + if ($resolvedName instanceof Name) { + $names[] = ltrim($resolvedName->toString(), '\\'); + } + + return array_any( + $names, + static fn (string $name) => in_array($name, [$interfaceName, $shortName], strict: true), + ); + } + + private function isVoidReturnType(ClassMethod $method): bool + { + return $method->returnType instanceof Identifier && $method->returnType->toString() === 'void'; + } + + private function removeReturnValues(ClassMethod $method): void + { + if ($method->stmts === null) { + return; + } + + $traverser = new NodeTraverser(); + $traverser->addVisitor(new class extends NodeVisitorAbstract { + public function leaveNode(Node $node): ?array + { + if (! $node instanceof Return_ || ! $node->expr instanceof Expr) { + return null; + } + + return [ + new Expression($node->expr), + new Return_(), + ]; + } + }); + + $method->stmts = array_filter( + $traverser->traverse($method->stmts), + static fn (Node $node): bool => $node instanceof Stmt, + ); + } +} diff --git a/packages/upgrade/tests/Tempest312/Fixtures/AliasedImplementations.input.php b/packages/upgrade/tests/Tempest312/Fixtures/AliasedImplementations.input.php new file mode 100644 index 0000000000..2285f52a47 --- /dev/null +++ b/packages/upgrade/tests/Tempest312/Fixtures/AliasedImplementations.input.php @@ -0,0 +1,67 @@ +wasShutDown = true; + } +} + +final class ExistingConnection implements Connection +{ + public function beginTransaction(): bool + { + return true; + } + + public function inTransaction(): bool + { + return true; + } + + public function commit(): bool + { + return true; + } + + public function rollback(): bool + { + return true; + } + + public function lastInsertId(): false|string + { + return 'existing-id'; + } + + public function prepare(string $sql): PDOStatement + { + return new PDOStatement(); + } + + public function close(): void + { + $this->disconnect(); + } + + public function connect(): void + { + $this->bootConnection(); + } + + public function reconnect(): void + { + $this->close(); + $this->connect(); + } + + public function ping(): bool + { + return true; + } + + private function disconnect(): void + { + } + + private function bootConnection(): void + { + } +} diff --git a/packages/upgrade/tests/Tempest312/Fixtures/KernelImplementation.input.php b/packages/upgrade/tests/Tempest312/Fixtures/KernelImplementation.input.php new file mode 100644 index 0000000000..9f59c126d8 --- /dev/null +++ b/packages/upgrade/tests/Tempest312/Fixtures/KernelImplementation.input.php @@ -0,0 +1,29 @@ + new RectorTester(__DIR__ . '/tempest312_rector.php'); + } + + public function test_connection_implementation_methods_are_added(): void + { + $this->rector + ->runFixture(__DIR__ . '/Fixtures/ConnectionImplementation.input.php') + ->assertContains('public function inTransaction(): bool') + ->assertContains('public function ping(): bool') + ->assertContains('public function reconnect(): void') + ->assertContains('return false;'); + } + + public function test_kernel_implementation_is_updated(): void + { + $this->rector + ->runFixture(__DIR__ . '/Fixtures/KernelImplementation.input.php') + ->assertContains('public function shutdown(int|string $status = \'\'): void') + ->assertContains('return;') + ->assertNotContains('return $this;') + ->assertNotContains('public function shutdown(): self'); + } + + public function test_aliased_interface_implementations_are_updated(): void + { + $this->rector + ->runFixture(__DIR__ . '/Fixtures/AliasedImplementations.input.php') + ->assertContains('public function inTransaction(): bool') + ->assertContains('public function ping(): bool') + ->assertContains('public function reconnect(): void'); + } + + public function test_existing_interface_methods_are_not_overwritten(): void + { + $this->assertSame( + '', + $this->rector + ->runFixture(__DIR__ . '/Fixtures/ExistingImplementations.input.php') + ->actual, + ); + } +} diff --git a/packages/upgrade/tests/Tempest312/tempest312_rector.php b/packages/upgrade/tests/Tempest312/tempest312_rector.php new file mode 100644 index 0000000000..4dff2f73f9 --- /dev/null +++ b/packages/upgrade/tests/Tempest312/tempest312_rector.php @@ -0,0 +1,9 @@ +withSets([TempestSetList::TEMPEST_312]); diff --git a/tests/Integration/Core/DeferredTasksTest.php b/tests/Integration/Core/DeferredTasksTest.php index 129040906b..5a68a06210 100644 --- a/tests/Integration/Core/DeferredTasksTest.php +++ b/tests/Integration/Core/DeferredTasksTest.php @@ -4,6 +4,7 @@ namespace Tests\Tempest\Integration\Core; +use PHPUnit\Framework\Attributes\Test; use Tempest\Container\Container; use Tempest\Core\DeferredTasks; use Tempest\Core\Kernel\FinishDeferredTasks; @@ -47,4 +48,16 @@ public function test_deferred_tasks_are_executed_with_container_parameters(): vo $this->assertTrue($executed); $this->assertEmpty($this->container->get(DeferredTasks::class)->getTasks()); } + + #[Test] + public function test_tasks_are_reset(): void + { + $first = $this->container->get(DeferredTasks::class); + + $this->container->reset(); + + $second = $this->container->get(DeferredTasks::class); + + $this->assertNotSame($first, $second); + } } diff --git a/tests/Integration/Database/Connection/ConnectionResetTest.php b/tests/Integration/Database/Connection/ConnectionResetTest.php new file mode 100644 index 0000000000..26966cfb27 --- /dev/null +++ b/tests/Integration/Database/Connection/ConnectionResetTest.php @@ -0,0 +1,60 @@ +container->get(Connection::class); + + $connection->beginTransaction(); + + $this->assertException(CouldNotResetConnection::class, function () { + $this->container->reset(); + }); + + $connection->close(); + } + + #[Test] + public function test_properly_closed_transaction_can_reset_connection(): void + { + /** @var Connection $connection */ + $connection = $this->container->get(Connection::class); + + $connection->beginTransaction(); + $connection->commit(); + + $this->container->reset(); + + $newConnection = $this->container->get(Connection::class); + $this->assertNotSame($connection, $newConnection); + } + + #[Test] + public function test_reset_with_uninstantiated_singletons(): void + { + $this->container->singleton( + Connection::class, + fn () => throw new Exception('Should not happen'), + tag: 'other', + ); + + /** @var Connection $connection */ + $connection = $this->container->get(Connection::class); + + $this->container->reset(); + + $newConnection = $this->container->get(Connection::class); + $this->assertNotSame($connection, $newConnection); + } +} diff --git a/tests/Integration/Database/DatabaseInitializerTest.php b/tests/Integration/Database/DatabaseInitializerTest.php new file mode 100644 index 0000000000..534c764582 --- /dev/null +++ b/tests/Integration/Database/DatabaseInitializerTest.php @@ -0,0 +1,188 @@ +container + ->removeInitializer(TestingDatabaseInitializer::class) + ->addInitializer(DatabaseInitializer::class); + } + + #[Test] + public function test_it_resolves_multiple_persistent_connections_by_tag(): void + { + $this->configureSqliteDatabase('main', 'multi-main.sqlite'); + $this->configureSqliteDatabase('backup', 'multi-backup.sqlite'); + + $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->assertNotSame($main->connection, $backup->connection); + // @phpstan-ignore-next-line + $this->assertSame($this->databasePath('multi-main.sqlite'), $main->connection->config->path); + // @phpstan-ignore-next-line + $this->assertSame($this->databasePath('multi-backup.sqlite'), $backup->connection->config->path); + + $this->assertSame($main->connection, $this->container->get(Connection::class, 'main')); + $this->assertSame($backup->connection, $this->container->get(Connection::class, 'backup')); + } + + #[Test] + public function test_it_reuses_a_persistent_connection_for_the_same_connection_config(): void + { + $this->configureSqliteDatabase('main', 'persistent-main.sqlite'); + + $first = $this->container->get(Database::class, 'main'); + + $this->container->unregister(Database::class, tagged: true); + $this->container->unregister(Connection::class, tagged: true); + + $second = $this->container->get(Database::class, 'main'); + + $this->assertInstanceOf(GenericDatabase::class, $first); + $this->assertInstanceOf(GenericDatabase::class, $second); + + $this->assertSame($first->connection, $second->connection); + $this->assertSame($second->connection, $this->container->get(Connection::class, 'main')); + } + + #[Test] + public function test_it_does_not_reuse_a_non_persistent_connection_for_the_same_connection_config(): void + { + $this->configureSqliteDatabase('main', 'non-persistent-main.sqlite', persistent: false); + + $first = $this->container->get(Database::class, 'main'); + + $this->container->unregister(Database::class, tagged: true); + $this->container->unregister(Connection::class, tagged: true); + + $second = $this->container->get(Database::class, 'main'); + + $this->assertInstanceOf(GenericDatabase::class, $first); + $this->assertInstanceOf(GenericDatabase::class, $second); + + $this->assertNotSame($first->connection, $second->connection); + // @phpstan-ignore-next-line + $this->assertSame($this->databasePath('non-persistent-main.sqlite'), $first->connection->config->path); + // @phpstan-ignore-next-line + $this->assertSame($this->databasePath('non-persistent-main.sqlite'), $second->connection->config->path); + $this->assertSame($second->connection, $this->container->get(Connection::class, 'main')); + } + + #[Test] + public function test_it_does_not_reuse_a_persistent_connection_for_the_same_tag_with_a_different_connection_config(): void + { + $this->configureSqliteDatabase('main', 'first-main.sqlite'); + + $first = $this->container->get(Database::class, 'main'); + + $this->container->unregister(Database::class, tagged: true); + $this->container->unregister(Connection::class, tagged: true); + + $this->configureSqliteDatabase('main', 'second-main.sqlite'); + + $second = $this->container->get(Database::class, 'main'); + + $this->assertInstanceOf(GenericDatabase::class, $first); + $this->assertInstanceOf(GenericDatabase::class, $second); + + $this->assertNotSame($first->connection, $second->connection); + // @phpstan-ignore-next-line + $this->assertSame($this->databasePath('first-main.sqlite'), $first->connection->config->path); + // @phpstan-ignore-next-line + $this->assertSame($this->databasePath('second-main.sqlite'), $second->connection->config->path); + $this->assertSame($second->connection, $this->container->get(Connection::class, 'main')); + } + + #[Test] + public function test_it_does_not_reuse_a_persistent_connection_when_only_the_password_differs(): void + { + $initializer = new DatabaseInitializer(); + $method = new ReflectionMethod($initializer, 'getConnectionKey'); + + $first = new MysqlConfig( + host: 'localhost', + username: 'tempest', + password: 'first-password', // @mago-expect lint:no-literal-password + database: 'tempest', + persistent: true, + tag: 'main', + ); + + $second = new MysqlConfig( + host: 'localhost', + username: 'tempest', + password: 'second-password', // @mago-expect lint:no-literal-password + database: 'tempest', + persistent: true, + tag: 'main', + ); + + $this->assertNotSame( + $method->invoke($initializer, $first), + $method->invoke($initializer, $second), + ); + } + + #[Test] + public function test_it_reconnects_a_stale_persistent_connection_when_reusing_it(): void + { + $this->configureSqliteDatabase('main', 'stale-persistent-main.sqlite'); + + $first = $this->container->get(Database::class, 'main'); + + $this->assertInstanceOf(GenericDatabase::class, $first); + $this->assertInstanceOf(PDOConnection::class, $first->connection); + + $first->connection->close(); + + $this->container->unregister(Database::class, tagged: true); + $this->container->unregister(Connection::class, tagged: true); + + $second = $this->container->get(Database::class, 'main'); + + $this->assertInstanceOf(GenericDatabase::class, $second); + $this->assertSame($first->connection, $second->connection); + $this->assertTrue($second->connection->ping()); + } + + private function configureSqliteDatabase(string $tag, string $filename, bool $persistent = true): void + { + $path = $this->databasePath($filename); + + if (is_file($path)) { + unlink($path); + } + + $this->container->config(new SQLiteConfig( + path: $path, + persistent: $persistent, + tag: $tag, + )); + } + + private function databasePath(string $filename): string + { + return $this->internalStorage . '/' . $filename; + } +} diff --git a/tests/Integration/Database/DatabaseResetTest.php b/tests/Integration/Database/DatabaseResetTest.php new file mode 100644 index 0000000000..bf9a533807 --- /dev/null +++ b/tests/Integration/Database/DatabaseResetTest.php @@ -0,0 +1,53 @@ +container->config(new SQLiteConfig( + path: __DIR__ . '/db-main.sqlite', + tag: 'sqlite-main', + )); + + /** @var \Tempest\Database\GenericDatabase $database */ + $database = $this->container->get(Database::class, 'sqlite-main'); + + $database->connection->beginTransaction(); + + $this->assertException(CouldNotResetConnection::class, function () { + $this->container->reset(); + }); + + $database->connection->close(); + } + + #[Test] + public function test_properly_closed_transaction_allows_database_reset(): void + { + $this->container->config(new SQLiteConfig( + path: __DIR__ . '/db-main.sqlite', + tag: 'sqlite-main', + )); + + /** @var \Tempest\Database\GenericDatabase $database */ + $database = $this->container->get(Database::class, 'sqlite-main'); + + $database->connection->beginTransaction(); + $database->connection->close(); + + $this->container->reset(); + + $new = $this->container->get(Database::class, 'sqlite-main'); + + $this->assertNotSame($database, $new); + } +} diff --git a/tests/Integration/Http/CookieManagerTest.php b/tests/Integration/Http/CookieManagerTest.php index 108b97095c..2d6066dc1e 100644 --- a/tests/Integration/Http/CookieManagerTest.php +++ b/tests/Integration/Http/CookieManagerTest.php @@ -4,6 +4,7 @@ namespace Tests\Tempest\Integration\Http; +use PHPUnit\Framework\Attributes\Test; use Tempest\Core\AppConfig; use Tempest\Http\Cookie\Cookie; use Tempest\Http\Cookie\CookieManager; @@ -86,4 +87,16 @@ public function test_manually_adding_a_cookie(): void ->assertOk() ->assertHeaderMatches('set-cookie', 'key=%s; Expires=Sun, 01-Jan-2023 00:00:01 GMT; Max-Age=1; Domain=test.com; Path=/test; Secure; HttpOnly; SameSite=Strict'); } + + #[Test] + public function test_cookie_manager_is_reset(): void + { + $originalCookieManager = $this->container->get(CookieManager::class); + + $this->container->reset(); + + $newCookieManager = $this->container->get(CookieManager::class); + + $this->assertNotSame($originalCookieManager, $newCookieManager); + } } diff --git a/tests/Integration/Http/SessionTest.php b/tests/Integration/Http/SessionTest.php index 3ee186f965..5e6ab62f47 100644 --- a/tests/Integration/Http/SessionTest.php +++ b/tests/Integration/Http/SessionTest.php @@ -120,4 +120,16 @@ public function clear(): void $this->assertEmpty($this->session->all()); } + + #[Test] + public function test_session_is_reset(): void + { + $originalSession = $this->container->get(Session::class); + + $this->container->reset(); + + $newSession = $this->container->get(Session::class); + + $this->assertNotSame($originalSession, $newSession); + } } diff --git a/tests/Integration/Router/WorkerModeApplicationTest.php b/tests/Integration/Router/WorkerModeApplicationTest.php new file mode 100644 index 0000000000..b338b7c6de --- /dev/null +++ b/tests/Integration/Router/WorkerModeApplicationTest.php @@ -0,0 +1,49 @@ +eventBus->preventEventHandling(); + + $container = new GenericContainer(); + + $container->singleton(EventBus::class, $this->container->get(EventBus::class)); + $container->singleton(Router::class, $this->container->get(Router::class)); + $container->singleton(RequestFactory::class, $this->container->get(RequestFactory::class)); + $container->singleton(ResponseSender::class, $this->container->get(ResponseSender::class)); + + $container->singleton(Kernel::class, new FrameworkKernel( + root: $this->kernel->root, + discoveryLocations: $this->discoveryLocations, + container: $container, + longRunning: true, + )); + + $application = new WorkerModeApplication($container); + + ob_start(); + $application->run(); + ob_get_clean(); + + $this->eventBus->assertDispatched(KernelEvent::SHUTTING_DOWN); + $this->eventBus->assertDispatched(KernelEvent::SHUTDOWN); + $this->eventBus->assertDispatched(KernelEvent::RESETTING); + $this->eventBus->assertDispatched(KernelEvent::RESET); + } +}