diff --git a/composer.json b/composer.json index 73bc1a211b..59b2c444b6 100644 --- a/composer.json +++ b/composer.json @@ -250,7 +250,8 @@ ], "config": { "allow-plugins": { - "carthage-software/mago": true + "carthage-software/mago": true, + "php-http/discovery": true }, "audit": { "ignore": { diff --git a/packages/container/src/Container.php b/packages/container/src/Container.php index cacfe160bc..01a99fb705 100644 --- a/packages/container/src/Container.php +++ b/packages/container/src/Container.php @@ -39,4 +39,12 @@ public function invoke(ClassReflector|MethodReflector|FunctionReflector|callable public function addInitializer(ClassReflector|string $initializerClass): self; public function addDecorator(ClassReflector|string $decoratorClass, ClassReflector|string $decoratedClass): self; + + /** + * @template T of \Tempest\Container\Resettable + * @param ClassReflector|class-string $resettableClass + */ + public function addResettable(ClassReflector|string $resettableClass): self; + + public function reset(): self; } diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index ffbc47ae00..02d8bdae80 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -22,6 +22,8 @@ use Throwable; use UnitEnum; +use const ARRAY_FILTER_USE_BOTH; + final class GenericContainer implements Container { use HasInstance; @@ -30,8 +32,11 @@ public function __construct( /** @var ArrayIterator $definitions */ private(set) ArrayIterator $definitions = new ArrayIterator(), - /** @var ArrayIterator $singletons */ - private(set) ArrayIterator $singletons = new ArrayIterator(), + /** @var ArrayIterator $singletonDefinitions */ + private(set) ArrayIterator $singletonDefinitions = new ArrayIterator(), + + /** @var ArrayIterator $resolvedSingletons */ + private(set) ArrayIterator $resolvedSingletons = new ArrayIterator(), /** @var ArrayIterator $initializers */ private(set) ArrayIterator $initializers = new ArrayIterator(), @@ -41,6 +46,10 @@ public function __construct( /** @var ArrayIterator $decorators */ private(set) ArrayIterator $decorators = new ArrayIterator(), + + /** @var ArrayIterator> $resettables */ + private(set) ArrayIterator $resettables = new ArrayIterator(), + private(set) ?DependencyChain $chain = null, ) { $this->singleton(Container::class, $this); @@ -57,7 +66,7 @@ public function setDefinitions(array $definitions): self public function setSingletons(array $singletons): self { - $this->singletons = new ArrayIterator($singletons); + $this->singletonDefinitions = new ArrayIterator($singletons); return $this; } @@ -93,7 +102,7 @@ public function getDefinitions(): array */ public function getSingletons(?string $interface = null): array { - $singletons = $this->singletons->getArrayCopy(); + $singletons = $this->singletonDefinitions->getArrayCopy(); if (is_null($interface)) { return $singletons; @@ -102,7 +111,7 @@ public function getSingletons(?string $interface = null): array return array_filter( array: $singletons, callback: static fn (mixed $_, string $key) => str_starts_with($key, "{$interface}#") || $key === $interface, - mode: \ARRAY_FILTER_USE_BOTH, + mode: ARRAY_FILTER_USE_BOTH, ); } @@ -130,16 +139,26 @@ public function register(string $className, callable $definition): self public function unregister(string $className, bool $tagged = false): self { - unset($this->definitions[$className], $this->singletons[$className]); + unset($this->definitions[$className]); + unset($this->singletonDefinitions[$className]); + unset($this->resolvedSingletons[$className]); if ($tagged) { - $singletons = array_filter( - array: $this->getSingletons(), - callback: static fn (mixed $_, string $key) => ! str_starts_with($key, "{$className}#"), - mode: \ARRAY_FILTER_USE_BOTH, - ); + foreach ($this->singletonDefinitions as $key => $definition) { + if (! str_starts_with($key, "{$className}#")) { + continue; + } + + unset($this->singletonDefinitions[$key]); + } + + foreach ($this->resolvedSingletons as $key => $definition) { + if (! str_starts_with($key, "{$className}#")) { + continue; + } - $this->setSingletons($singletons); + unset($this->resolvedSingletons[$key]); + } } return $this; @@ -147,7 +166,7 @@ public function unregister(string $className, bool $tagged = false): self public function has(string $className, null|string|UnitEnum $tag = null): bool { - return isset($this->definitions[$className]) || isset($this->singletons[$this->resolveTaggedName($className, $tag)]); + return isset($this->definitions[$className]) || isset($this->singletonDefinitions[$this->resolveTaggedName($className, $tag)]); } public function singleton(string $className, mixed $definition, null|string|UnitEnum $tag = null): self @@ -156,9 +175,10 @@ public function singleton(string $className, mixed $definition, null|string|Unit $tag = $definition->tag; } - $className = $this->resolveTaggedName($className, $tag); + $dependencyName = $this->resolveTaggedName($className, $tag); - $this->singletons[$className] = $definition; + $this->singletonDefinitions[$dependencyName] = $definition; + unset($this->resolvedSingletons[$dependencyName]); return $this; } @@ -346,12 +366,18 @@ private function resolveDependency(string $className, null|string|UnitEnum $tag $dependencyName = $this->resolveTaggedName($className, $tag); + // Check if a resolved singleton is present + if ($instance = $this->resolvedSingletons[$dependencyName] ?? null) { + $this->resolveChain()->add($class); + + return $instance; + } + // Check if the class has been registered as a singleton. - if ($instance = $this->singletons[$dependencyName] ?? null) { - if ($instance instanceof Closure) { - $instance = $instance($this); - $this->singletons[$className] = $instance; - } + if ($singletonDefinition = $this->singletonDefinitions[$dependencyName] ?? null) { + $instance = $singletonDefinition instanceof Closure ? $singletonDefinition($this) : $singletonDefinition; + + $this->resolvedSingletons[$dependencyName] = $instance; $this->resolveChain()->add($class); @@ -678,4 +704,41 @@ private function resolveDecorator(string $className, mixed $instance, null|strin return $instance; } + + public function addResettable(string|ClassReflector $resettableClass): Container + { + if ($resettableClass instanceof ClassReflector) { + $resettableClass = $resettableClass->getName(); + } + + $this->resettables[$resettableClass] = $resettableClass; + + return $this; + } + + public function reset(): self + { + $this->resolvedSingletons = new ArrayIterator(); + + foreach ($this->resettables as $resettableClass) { + /** @var Resettable $resettable */ + $resettable = $this->get($resettableClass); + + $resettable->reset(); + } + + return $this; + } + + public function getResettables(): array + { + return $this->resettables->getArrayCopy(); + } + + public function setResettables(array $resettables): self + { + $this->resettables = new ArrayIterator($resettables); + + return $this; + } } diff --git a/packages/container/src/Resettable.php b/packages/container/src/Resettable.php new file mode 100644 index 0000000000..b2309497d5 --- /dev/null +++ b/packages/container/src/Resettable.php @@ -0,0 +1,8 @@ +implements(Resettable::class)) { + $this->discoveryItems->add($location, $class); + } + } + + public function apply(): void + { + foreach ($this->discoveryItems as $class) { + $this->container->addResettable($class); + } + } +} diff --git a/packages/container/tests/ContainerTest.php b/packages/container/tests/ContainerTest.php index effa97435e..a3e22824b4 100644 --- a/packages/container/tests/ContainerTest.php +++ b/packages/container/tests/ContainerTest.php @@ -53,6 +53,7 @@ use Tempest\Container\Tests\Fixtures\InvokableClassWithDependencies; use Tempest\Container\Tests\Fixtures\InvokableClassWithParameters; use Tempest\Container\Tests\Fixtures\OptionalTypesClass; +use Tempest\Container\Tests\Fixtures\ResettableDependency; use Tempest\Container\Tests\Fixtures\SingletonClass; use Tempest\Container\Tests\Fixtures\SingletonInitializer; use Tempest\Container\Tests\Fixtures\SlowDependency; @@ -73,6 +74,12 @@ */ final class ContainerTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + SingletonClass::$count = 0; + } + public function test_get_with_autowire(): void { $container = new GenericContainer(); @@ -690,4 +697,24 @@ public function test_returns_decorator_without_constructor(): void $this->assertInstanceOf(DecoratorWithoutConstructor::class, $instance); } + + public function test_reset(): void + { + ResettableDependency::$reset = false; + + $container = new GenericContainer(); + + $container->addResettable(ResettableDependency::class); + $container->singleton(SingletonClass::class, fn () => new SingletonClass()); + $container->get(SingletonClass::class); + $container->get(SingletonClass::class); + $this->assertSame(1, SingletonClass::$count); + + $container->reset(); + + $container->get(SingletonClass::class); + $container->get(SingletonClass::class); + $this->assertSame(2, SingletonClass::$count); // constructed twice, once before and once after reset + $this->assertTrue(ResettableDependency::$reset); + } } diff --git a/packages/container/tests/Fixtures/ResettableDependency.php b/packages/container/tests/Fixtures/ResettableDependency.php new file mode 100644 index 0000000000..0bfdde2776 --- /dev/null +++ b/packages/container/tests/Fixtures/ResettableDependency.php @@ -0,0 +1,15 @@ +assertSame( ['cost' => 10], - (new BcryptConfig(cost: 10))->options, + new BcryptConfig(cost: 10)->options, ); } diff --git a/packages/discovery/src/BootDiscovery.php b/packages/discovery/src/BootDiscovery.php index 7e005c7fb3..61231688c0 100644 --- a/packages/discovery/src/BootDiscovery.php +++ b/packages/discovery/src/BootDiscovery.php @@ -5,6 +5,7 @@ namespace Tempest\Discovery; use AssertionError; +use Pest\Exceptions\InvalidPestCommand; use Psr\Container\ContainerInterface; use Tempest\Container\GenericContainer; use Tempest\Reflection\ClassReflector; @@ -200,7 +201,7 @@ private function discoverPath(string $input, DiscoveryLocation $location, array } elseif (class_exists($className)) { $input = new ClassReflector($className); } - } catch (AssertionError|\Pest\Exceptions\InvalidPestCommand) { // @phpstan-ignore class.notFound + } catch (AssertionError|InvalidPestCommand) { // @phpstan-ignore class.notFound // Workaround for Pest test files autoloading. // @mago-expect lint:no-empty-catch-clause } diff --git a/packages/discovery/src/DiscoveryConfig.php b/packages/discovery/src/DiscoveryConfig.php index a8b44622a2..86ec4b0f23 100644 --- a/packages/discovery/src/DiscoveryConfig.php +++ b/packages/discovery/src/DiscoveryConfig.php @@ -33,13 +33,7 @@ public function shouldSkip(string $input): bool return true; } - foreach ($this->skipUsing as $closure) { - if ($closure($input) === true) { - return true; - } - } - - return false; + return array_any($this->skipUsing, fn ($closure) => $closure($input) === true); } /** @param (Closure(string): bool) $closure */ diff --git a/tests/Integration/Container/Commands/ContainerShowCommandTest.php b/tests/Integration/Container/Commands/ContainerShowCommandTest.php index 2e88c1a96d..55253b5fce 100644 --- a/tests/Integration/Container/Commands/ContainerShowCommandTest.php +++ b/tests/Integration/Container/Commands/ContainerShowCommandTest.php @@ -4,6 +4,7 @@ use Tempest\Container\Commands\ContainerShowCommand; use Tempest\Container\Container; +use Tempest\Reflection\ClassReflector; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use UnitEnum; @@ -83,6 +84,20 @@ public function addDecorator(mixed $decoratorClass, mixed $decoratedClass): self return $this; } + + public function addResettable(string|ClassReflector $resettableClass): Container + { + $this->container->addResettable($resettableClass); + + return $this; + } + + public function reset(): Container + { + $this->container->reset(); + + return $this; + } }, );