diff --git a/docs/1-essentials/05-container.md b/docs/1-essentials/05-container.md index 9de0854cc5..ea231fba98 100644 --- a/docs/1-essentials/05-container.md +++ b/docs/1-essentials/05-container.md @@ -224,7 +224,7 @@ final readonly class Client } ``` -Furthermore, an initializer method can be annotated as a `#[Singleton]`, meaning its return object will only ever be resolved once: +Furthermore, an initializer method can be annotated as a `#[Singleton]`, meaning its return object will be reused for the singleton’s lifetime: ```php app/MarkdownInitializer.php use Tempest\Console\ConsoleCommand; @@ -241,6 +241,55 @@ final readonly class MarkdownInitializer implements Initializer } ``` +### Singleton lifetimes + +By default, singletons use {`Tempest\Container\Lifetime::PROCESS`} as their lifetime: the container keeps the same instance for the lifetime of the process, including across requests in long-running applications. + +For objects that hold request-specific state, you can specify `Lifetime::REQUEST` instead: + +```php +use Tempest\Container\Lifetime; +use Tempest\Container\Singleton; + +#[Singleton(lifetime: Lifetime::REQUEST)] +final class RequestContext +{ + public ?string $tenantId = null; +} +``` + +The container reuses this instance within a request. When the container is reset, it clears the instance so that the next request receives a fresh one, this happens after every request in long-running applications. + +You can also set the lifetime when registering a singleton with the container. Use a factory for request-lifetime singletons so the container can create a fresh instance after each reset: + +```php +use Tempest\Container\Lifetime; + +$container->singleton( + RequestContext::class, + fn () => new RequestContext(), + lifetime: Lifetime::REQUEST, +); +``` + +Note that you can also declare the lifetime via initializers: + +```php +use Tempest\Container\Container; +use Tempest\Container\Initializer; +use Tempest\Container\Lifetime; +use Tempest\Container\Singleton; + +final class RequestContextInitializer implements Initializer +{ + #[Singleton(lifetime: Lifetime::REQUEST)] + public function initialize(Container $container): RequestContext + { + return new RequestContext(); + } +} +``` + ### Tagged singletons In some cases, you want more control over singleton definitions. diff --git a/packages/container/src/Container.php b/packages/container/src/Container.php index dba11e198a..7331cc9408 100644 --- a/packages/container/src/Container.php +++ b/packages/container/src/Container.php @@ -16,7 +16,7 @@ public function register(string $className, callable $definition): self; public function unregister(string $className, bool $tagged = false): self; - public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null): self; + public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null, ?Lifetime $lifetime = null): self; public function config(object $config): self; diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index 583ed878e9..bb24e5706b 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -21,6 +21,7 @@ use Tempest\Reflection\TypeReflector; use Throwable; use UnitEnum; +use WeakMap; use const ARRAY_FILTER_USE_BOTH; @@ -31,6 +32,12 @@ final class GenericContainer implements Container /** @var array, DynamicInitializer> */ private array $resolvedDynamicInitializers = []; + /** @var WeakMap */ + private WeakMap $singletonLifetimes; + + /** @var array */ + private array $registeredSingletonLifetimes = []; + public function __construct( /** @var ArrayIterator $definitions */ private(set) ArrayIterator $definitions = new ArrayIterator(), @@ -55,6 +62,7 @@ public function __construct( private(set) ?DependencyChain $chain = null, ) { + $this->singletonLifetimes = new WeakMap(); $this->singleton(Container::class, $this); $this->singleton(ContainerInterface::class, $this); $this->singleton(GenericContainer::class, $this); @@ -146,6 +154,7 @@ public function unregister(string $className, bool $tagged = false): self unset($this->definitions[$className]); unset($this->singletonDefinitions[$className]); unset($this->resolvedSingletons[$className]); + unset($this->registeredSingletonLifetimes[$className]); if ($tagged) { foreach ($this->singletonDefinitions as $key => $definition) { @@ -154,6 +163,7 @@ public function unregister(string $className, bool $tagged = false): self } unset($this->singletonDefinitions[$key]); + unset($this->registeredSingletonLifetimes[$key]); } foreach ($this->resolvedSingletons as $key => $definition) { @@ -173,7 +183,7 @@ public function has(string $className, string|UnitEnum|null $tag = null): bool return isset($this->definitions[$className]) || isset($this->singletonDefinitions[$this->resolveTaggedName($className, $tag)]); } - public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null): self + public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null, ?Lifetime $lifetime = null): self { if ($definition instanceof HasTag) { $tag = $definition->tag; @@ -181,6 +191,16 @@ public function singleton(string $className, mixed $definition, string|UnitEnum| $dependencyName = $this->resolveTaggedName($className, $tag); + if ($lifetime instanceof Lifetime) { + $this->registeredSingletonLifetimes[$dependencyName] = $lifetime; + + if (is_object($definition) && ! $definition instanceof Closure) { + $this->singletonLifetimes[$definition] = $lifetime; + } + } else { + unset($this->registeredSingletonLifetimes[$dependencyName]); + } + $this->singletonDefinitions[$dependencyName] = $definition; unset($this->resolvedSingletons[$dependencyName]); @@ -382,6 +402,10 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag if ($singletonDefinition = $this->singletonDefinitions[$dependencyName] ?? null) { $instance = $singletonDefinition instanceof Closure ? $singletonDefinition($this) : $singletonDefinition; + if (isset($this->registeredSingletonLifetimes[$dependencyName])) { + $this->singletonLifetimes[$instance] = $this->registeredSingletonLifetimes[$dependencyName]; + } + $this->resolvedSingletons[$dependencyName] = $instance; $this->resolveChain()->add($class); @@ -407,9 +431,12 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag $initializer instanceof DynamicInitializer => $initializer->initialize($class, $tag, $this->clone()), }; - $singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass->getMethod('initialize')->getAttribute(Singleton::class); + $singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass + ->getMethod('initialize') + ->getAttribute(Singleton::class) ?? $class->getAttribute(Singleton::class); if ($singleton !== null) { + $this->singletonLifetimes[$object] = $singleton->lifetime; $this->singleton($className, $object, $tag); } @@ -506,8 +533,9 @@ private function autowire(string $className, string|UnitEnum|null $tag, mixed .. if ( ! $classReflector->getType()->matches(Initializer::class) && ! $classReflector->getType()->matches(DynamicInitializer::class) - && $classReflector->hasAttribute(Singleton::class) + && ($singleton = $classReflector->getAttribute(Singleton::class)) !== null ) { + $this->singletonLifetimes[$instance] = $singleton->lifetime; $this->singleton($className, $instance, $tag); } @@ -744,7 +772,6 @@ public function addResettable(string|ClassReflector $resettableClass): Container public function reset(): self { - $this->resolvedSingletons = new ArrayIterator(); $this->resolvedDynamicInitializers = []; foreach ($this->resettables as $resettableClass) { @@ -754,6 +781,25 @@ public function reset(): self $resettable->reset(); } + foreach ([$this->singletonDefinitions, $this->resolvedSingletons] as $singletons) { + foreach ($singletons->getArrayCopy() as $dependencyName => $instance) { + // Factories remain registered so the next request can create a fresh instance. + if (! is_object($instance)) { + continue; + } + + if ($instance instanceof Closure) { + continue; + } + + $this->singletonLifetimes[$instance] ??= new ClassReflector($instance)->getAttribute(Singleton::class)->lifetime ?? Lifetime::PROCESS; + + if ($this->singletonLifetimes[$instance] === Lifetime::REQUEST) { + unset($singletons[$dependencyName]); + } + } + } + return $this; } diff --git a/packages/container/src/Lifetime.php b/packages/container/src/Lifetime.php new file mode 100644 index 0000000000..d07dbc34bd --- /dev/null +++ b/packages/container/src/Lifetime.php @@ -0,0 +1,14 @@ +get(ProcessLifetimeSingleton::class); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeSingleton::class); + $tagged = $container->get(RequestLifetimeSingleton::class, 'tag'); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertSame($tagged, $container->get(RequestLifetimeSingleton::class, 'tag')); + $this->assertNotSame($instance, $tagged); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertNotSame($tagged, $container->get(RequestLifetimeSingleton::class, 'tag')); + $this->assertSame($process, $container->get(ProcessLifetimeSingleton::class)); + } + } + + #[Test] + public function lifetimes_are_tracked_when_resolving_nested_dependencies(): void + { + $container = new GenericContainer(); + $instance = $container->get(RequestLifetimeDependency::class)->singleton; + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeDependency::class)->singleton); + } + + #[Test] + public function initializer_singletons_are_shared_until_reset(): void + { + foreach ([RequestLifetimeInitializer::class, RequestLifetimeClassInitializer::class, RequestLifetimeDynamicInitializer::class] as $initializer) { + $container = new GenericContainer(); + $discovery = new InitializerDiscovery($container); + $discovery->setItems(new DiscoveryItems()); + $discovery->discover(new DiscoveryLocation(__NAMESPACE__, __DIR__), new ClassReflector($initializer)); + $discovery->apply(); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeInterface::class); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class)); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeInterface::class)); + } + } + } + + #[Test] + public function tagged_initializer_singletons_are_cleared(): void + { + $container = new GenericContainer(); + $container->addInitializer(RequestLifetimeInitializer::class); + + $instance = $container->get(RequestLifetimeInterface::class, 'tag'); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class, 'tag')); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeInterface::class, 'tag')); + } + + #[Test] + public function discovered_interface_bindings_follow_the_class_lifetime(): void + { + $container = new GenericContainer(); + $discovery = new AutowireDiscovery($container); + $discovery->setItems(new DiscoveryItems()); + $discovery->discover(new DiscoveryLocation(__NAMESPACE__, __DIR__), new ClassReflector(RequestLifetimeSingleton::class)); + $discovery->apply(); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeInterface::class); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class)); + + $container->reset(); + + $next = $container->get(RequestLifetimeInterface::class); + $this->assertNotSame($instance, $next); + $this->assertSame($next, $container->get(RequestLifetimeSingleton::class)); + } + } + + #[Test] + public function registered_factories_follow_their_lifetime(): void + { + $container = new GenericContainer(); + $container->singleton(SingletonClass::class, fn () => new SingletonClass()); + $container->singleton(RequestLifetimeInterface::class, fn () => new RequestLifetimeSingleton(), 'request'); + + $process = $container->get(SingletonClass::class); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeInterface::class, 'request'); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class, 'request')); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeInterface::class, 'request')); + $this->assertSame($process, $container->get(SingletonClass::class)); + } + } + + #[Test] + public function registered_factory_can_specify_a_request_lifetime(): void + { + $container = new GenericContainer(); + $container->singleton(SingletonClass::class, fn () => new SingletonClass(), lifetime: Lifetime::REQUEST); + + $instance = $container->get(SingletonClass::class); + $this->assertSame($instance, $container->get(SingletonClass::class)); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(SingletonClass::class)); + } + + #[Test] + public function registered_instance_can_override_its_attribute_lifetime(): void + { + $container = new GenericContainer(); + $instance = new RequestLifetimeSingleton(); + $container->singleton(RequestLifetimeInterface::class, $instance, lifetime: Lifetime::PROCESS); + + $container->reset(); + + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class)); + } + + #[Test] + public function registered_instance_can_specify_a_request_lifetime(): void + { + $container = new GenericContainer(); + $container->singleton(SingletonClass::class, new SingletonClass(), lifetime: Lifetime::REQUEST); + + $container->reset(); + + $this->assertFalse($container->has(SingletonClass::class)); + } + + #[Test] + public function process_initializer_singletons_survive_reset(): void + { + $container = new GenericContainer(); + $container->addInitializer(SingletonInitializer::class); + + $instance = $container->get(ContainerObjectE::class); + + $container->reset(); + + $this->assertSame($instance, $container->get(ContainerObjectE::class)); + } + + #[Test] + public function initialized_classes_can_declare_their_own_lifetime(): void + { + $initializer = new class implements Initializer { + public function initialize(Container $container): RequestLifetimeSingleton + { + return new RequestLifetimeSingleton(); + } + }; + $container = new GenericContainer(); + $container->addInitializer($initializer::class); + + $instance = $container->get(RequestLifetimeSingleton::class); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeSingleton::class)); + } + + #[Test] + public function registered_request_instances_are_removed(): void + { + $container = new GenericContainer(); + $container->singleton(RequestLifetimeSingleton::class, $instance = new RequestLifetimeSingleton()); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + + $container->reset(); + + $this->assertFalse($container->has(RequestLifetimeSingleton::class)); + $this->assertNotSame($instance, $container->get(RequestLifetimeSingleton::class)); + } + + #[Test] + public function initializer_lifetime_is_shared_by_all_bindings_of_the_instance(): void + { + $initializer = new class implements Initializer { + #[Singleton(lifetime: Lifetime::REQUEST)] + public function initialize(Container $container): SingletonClass + { + return new SingletonClass(); + } + }; + $container = new GenericContainer(); + $container->addInitializer($initializer::class); + $container->singleton(SingletonClass::class, fn (Container $container) => $container->get(SingletonClass::class), 'alias'); + + for ($request = 0; $request < 3; $request++) { + // Parameter resolution uses an internal container clone. + $instance = $container->invoke(fn (SingletonClass $instance) => $instance); + $container->singleton(SingletonClass::class, $instance, 'instance'); + $this->assertSame($instance, $container->get(SingletonClass::class, 'alias')); + $this->assertSame($instance, $container->get(SingletonClass::class, 'instance')); + + $container->reset(); + + $this->assertFalse($container->has(SingletonClass::class, 'instance')); + $next = $container->get(SingletonClass::class, 'alias'); + $this->assertNotSame($instance, $next); + $this->assertSame($next, $container->get(SingletonClass::class)); + } + } + + #[Test] + public function initializer_lifetime_takes_precedence_over_the_class_lifetime(): void + { + $initializer = new class implements Initializer { + #[Singleton(lifetime: Lifetime::PROCESS)] + public function initialize(Container $container): RequestLifetimeSingleton + { + return new RequestLifetimeSingleton(); + } + }; + $container = new GenericContainer(); + $container->addInitializer($initializer::class); + + $instance = $container->get(RequestLifetimeSingleton::class); + $container->singleton(RequestLifetimeInterface::class, $instance); + + $container->reset(); + + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class)); + } + + #[Test] + public function replacing_a_request_singleton_updates_its_lifetime(): void + { + $container = new GenericContainer(); + $container->singleton(SingletonClass::class, new RequestLifetimeSingleton()); + $container->get(SingletonClass::class); + $container->singleton(SingletonClass::class, $process = new SingletonClass()); + + $container->reset(); + + $this->assertSame($process, $container->get(SingletonClass::class)); + } +} diff --git a/packages/container/tests/ContainerTest.php b/packages/container/tests/ContainerTest.php index b37f7e8e6b..7b833f7b34 100644 --- a/packages/container/tests/ContainerTest.php +++ b/packages/container/tests/ContainerTest.php @@ -860,7 +860,7 @@ public function reset(): void $container->get(SingletonClass::class); $container->get(SingletonClass::class); - $this->assertSame(2, SingletonClass::$count); // constructed twice, once before and once after reset + $this->assertSame(1, SingletonClass::$count); $this->assertTrue(ResettableDependency::$reset); } } diff --git a/packages/container/tests/Fixtures/ProcessLifetimeSingleton.php b/packages/container/tests/Fixtures/ProcessLifetimeSingleton.php new file mode 100644 index 0000000000..a447a0c3c7 --- /dev/null +++ b/packages/container/tests/Fixtures/ProcessLifetimeSingleton.php @@ -0,0 +1,10 @@ +getName() === RequestLifetimeInterface::class; + } + + #[Singleton(lifetime: Lifetime::REQUEST)] + public function initialize(ClassReflector $class, string|UnitEnum|null $tag, Container $container): object + { + return new RequestLifetimeSingleton(); + } +} diff --git a/packages/container/tests/Fixtures/RequestLifetimeInitializer.php b/packages/container/tests/Fixtures/RequestLifetimeInitializer.php new file mode 100644 index 0000000000..2265cceedf --- /dev/null +++ b/packages/container/tests/Fixtures/RequestLifetimeInitializer.php @@ -0,0 +1,19 @@ +sets([ + TempestSetList::TEMPEST_20, + TempestSetList::TEMPEST_28, + TempestSetList::TEMPEST_30, + TempestSetList::TEMPEST_34, + TempestSetList::TEMPEST_310, + TempestSetList::TEMPEST_314, + TempestSetList::TEMPEST_321, + ]); +}; diff --git a/packages/upgrade/config/sets/tempest321.php b/packages/upgrade/config/sets/tempest321.php new file mode 100644 index 0000000000..e4f70a37fd --- /dev/null +++ b/packages/upgrade/config/sets/tempest321.php @@ -0,0 +1,13 @@ +rule(UpdateContainerImplementationsRector::class); +}; diff --git a/packages/upgrade/src/Set/TempestLevelSetList.php b/packages/upgrade/src/Set/TempestLevelSetList.php index 458d7f992e..a9836c64f8 100644 --- a/packages/upgrade/src/Set/TempestLevelSetList.php +++ b/packages/upgrade/src/Set/TempestLevelSetList.php @@ -17,4 +17,6 @@ final class TempestLevelSetList public const string UP_TO_TEMPEST_310 = __DIR__ . '/../../config/sets/level/up-to-tempest-310.php'; public const string UP_TO_TEMPEST_314 = __DIR__ . '/../../config/sets/level/up-to-tempest-314.php'; + + public const string UP_TO_TEMPEST_321 = __DIR__ . '/../../config/sets/level/up-to-tempest-321.php'; } diff --git a/packages/upgrade/src/Set/TempestSetList.php b/packages/upgrade/src/Set/TempestSetList.php index e786902548..cf475b8265 100644 --- a/packages/upgrade/src/Set/TempestSetList.php +++ b/packages/upgrade/src/Set/TempestSetList.php @@ -17,4 +17,6 @@ final class TempestSetList public const string TEMPEST_310 = __DIR__ . '/../../config/sets/tempest310.php'; public const string TEMPEST_314 = __DIR__ . '/../../config/sets/tempest314.php'; + + public const string TEMPEST_321 = __DIR__ . '/../../config/sets/tempest321.php'; } diff --git a/packages/upgrade/src/Tempest321/UpdateContainerImplementationsRector.php b/packages/upgrade/src/Tempest321/UpdateContainerImplementationsRector.php new file mode 100644 index 0000000000..e8743d44bf --- /dev/null +++ b/packages/upgrade/src/Tempest321/UpdateContainerImplementationsRector.php @@ -0,0 +1,62 @@ +implementsContainer($node)) { + return null; + } + + $singleton = $node->getMethod('singleton'); + + if (! $singleton instanceof ClassMethod || count($singleton->params) !== 3) { + return null; + } + + $singleton->params[] = new Param( + var: new Variable('lifetime'), + default: new ConstFetch(new Name('null')), + type: new NullableType(new FullyQualified(Lifetime::class)), + ); + + return $node; + } + + private function implementsContainer(Class_ $class): bool + { + foreach ($class->implements as $interface) { + $resolvedName = $interface->getAttribute('resolvedName'); + + if ($resolvedName instanceof Name && $resolvedName->toString() === Container::class) { + return true; + } + + if ($interface->toString() === Container::class) { + return true; + } + } + + return false; + } +} diff --git a/packages/upgrade/tests/Tempest321/Fixtures/ContainerImplementation.input.php b/packages/upgrade/tests/Tempest321/Fixtures/ContainerImplementation.input.php new file mode 100644 index 0000000000..210fc7cb71 --- /dev/null +++ b/packages/upgrade/tests/Tempest321/Fixtures/ContainerImplementation.input.php @@ -0,0 +1,14 @@ + new RectorTester(__DIR__ . '/tempest321_rector.php'); + } + + #[Test] + public function container_implementations_receive_the_optional_lifetime(): void + { + $this->rector + ->runFixture(__DIR__ . '/Fixtures/ContainerImplementation.input.php') + ->assertContains('use Tempest\\Container\\Lifetime;') + ->assertContains('public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null, ?Lifetime $lifetime = null): self'); + } + + #[Test] + public function unrelated_or_already_updated_methods_are_unchanged(): void + { + $this->assertSame('', $this->rector->runFixture(__DIR__ . '/Fixtures/Unchanged.input.php')->actual); + } +} diff --git a/packages/upgrade/tests/Tempest321/tempest321_rector.php b/packages/upgrade/tests/Tempest321/tempest321_rector.php new file mode 100644 index 0000000000..1db881515c --- /dev/null +++ b/packages/upgrade/tests/Tempest321/tempest321_rector.php @@ -0,0 +1,11 @@ +withSets([TempestSetList::TEMPEST_321]) + ->withCache(cacheClass: MemoryCacheStorage::class); diff --git a/tests/Integration/Container/Commands/ContainerShowCommandTest.php b/tests/Integration/Container/Commands/ContainerShowCommandTest.php index 6de904d3e4..2e89df6628 100644 --- a/tests/Integration/Container/Commands/ContainerShowCommandTest.php +++ b/tests/Integration/Container/Commands/ContainerShowCommandTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\Test; use Tempest\Container\Commands\ContainerShowCommand; use Tempest\Container\Container; +use Tempest\Container\Lifetime; use Tempest\Reflection\ClassReflector; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use UnitEnum; @@ -45,9 +46,9 @@ public function unregister(string $className, bool $tagged = false): self return $this; } - public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null): self + public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null, ?Lifetime $lifetime = null): self { - $this->container->singleton($className, $definition, $tag); + $this->container->singleton($className, $definition, $tag, $lifetime); return $this; }