diff --git a/src/Container/Container.php b/src/Container/Container.php index a6c2143..a62698a 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -10,7 +10,7 @@ interface Container { public function register(string $className, callable $definition): self; - public function singleton(string $className, object|callable $definition): self; + public function singleton(string $className, object|callable $definition, ?string $tag = null): self; public function config(object $config): self; @@ -19,7 +19,7 @@ public function config(object $config): self; * @param class-string $className * @return TClassName */ - public function get(string $className, mixed ...$params): object; + public function get(string $className, ?string $tag = null, mixed ...$params): object; public function call(object $object, string $methodName, mixed ...$params): mixed; diff --git a/src/Container/Dependency.php b/src/Container/Dependency.php index 22c7b70..a8079b8 100644 --- a/src/Container/Dependency.php +++ b/src/Container/Dependency.php @@ -41,6 +41,12 @@ public function getTypeName(): string { $dependency = $this->dependency; + if (is_string($dependency)) { + $parts = explode('\\', $dependency); + + return $parts[array_key_last($parts)]; + } + return match($dependency::class) { ReflectionClass::class => $dependency->getShortName(), ReflectionMethod::class => $dependency->getDeclaringClass()->getShortName(), diff --git a/src/Container/Exceptions/CannotResolveTaggedDependency.php b/src/Container/Exceptions/CannotResolveTaggedDependency.php new file mode 100644 index 0000000..ea3f0b5 --- /dev/null +++ b/src/Container/Exceptions/CannotResolveTaggedDependency.php @@ -0,0 +1,57 @@ +all(); + $stack[] = $brokenDependency; + + $message = PHP_EOL . PHP_EOL. "Could not resolve tagged dependency {$brokenDependency->getName()}#{$tag}, did you forget to define an initializer for it?" . PHP_EOL; + + if (count($stack) < 2) { + parent::__construct($message); + + return; + } + + $i = 0; + + foreach ($stack as $currentDependency) { + $pipe = match ($i) { + 0 => '┌──', + count($stack) - 1 => '└──', + default => '├──', + }; + + $message .= PHP_EOL . "\t{$pipe} " . $currentDependency->getShortName(); + + $i++; + } + + $selectionLine = preg_replace_callback( + pattern: '/(?(.*))(?'. $brokenDependency->getTypeName() .'\s\$\w+)(.*)/', + callback: function ($matches) { + return str_repeat(' ', strlen($matches['prefix']) + 4) + . str_repeat('▒', strlen($matches['selection'])); + }, + subject: $chain->last()->getShortName(), + ); + + $message .= PHP_EOL; + $message .= "\t{$selectionLine}"; + $message .= PHP_EOL; + $message .= "Originally called in {$chain->getOrigin()}"; + $message .= PHP_EOL; + + parent::__construct($message); + } +} diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 1335a08..d44eb35 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -14,6 +14,7 @@ use ReflectionUnionType; use Tempest\Container\Exceptions\CannotAutowireException; use Tempest\Container\Exceptions\CannotInstantiateDependencyException; +use Tempest\Container\Exceptions\CannotResolveTaggedDependency; use Tempest\Support\Reflection\Attributes; use Throwable; @@ -56,8 +57,10 @@ public function register(string $className, callable $definition): self return $this; } - public function singleton(string $className, object|callable $definition): self + public function singleton(string $className, object|callable $definition, ?string $tag = null): self { + $className = $this->resolveTaggedName($className, $tag); + $this->singletons[$className] = $definition; return $this; @@ -70,11 +73,15 @@ public function config(object $config): self return $this; } - public function get(string $className, mixed ...$params): object + public function get(string $className, ?string $tag = null, mixed ...$params): object { $this->resolveChain(); - $dependency = $this->resolve($className, ...$params); + $dependency = $this->resolve( + className: $className, + tag: $tag, + params: $params, + ); $this->stopChain(); @@ -110,9 +117,15 @@ public function addInitializer(ReflectionClass|string $initializerClass): Contai return $this; } + $initializeMethod = $initializerClass->getMethod('initialize'); + + // We resolve the optional Tag attribute from this initializer class + $tag = Attributes::find(Tag::class)->in($initializerClass)->first() + ?? Attributes::find(Tag::class)->in($initializeMethod)->first(); + // For normal Initializers, we'll use the return type // to determine which dependency they resolve - $returnTypes = $initializerClass->getMethod('initialize')->getReturnType(); + $returnTypes = $initializeMethod->getReturnType(); $returnTypes = match ($returnTypes::class) { ReflectionNamedType::class => [$returnTypes], @@ -121,16 +134,18 @@ public function addInitializer(ReflectionClass|string $initializerClass): Contai /** @var ReflectionNamedType[] $returnTypes */ foreach ($returnTypes as $returnType) { - $this->initializers[$returnType->getName()] = $initializerClass->getName(); + $this->initializers[$this->resolveTaggedName($returnType->getName(), $tag?->name)] = $initializerClass->getName(); } return $this; } - private function resolve(string $className, mixed ...$params): object + private function resolve(string $className, ?string $tag = null, mixed ...$params): object { + $dependencyName = $this->resolveTaggedName($className, $tag); + // Check if the class has been registered as a singleton. - if ($instance = $this->singletons[$className] ?? null) { + if ($instance = $this->singletons[$dependencyName] ?? null) { if (is_callable($instance)) { $instance = $instance($this); $this->singletons[$className] = $instance; @@ -142,14 +157,14 @@ private function resolve(string $className, mixed ...$params): object } // Check if a callable has been registered to resolve this class. - if ($definition = $this->definitions[$className] ?? null) { + if ($definition = $this->definitions[$dependencyName] ?? null) { $this->resolveChain()->add(new ReflectionFunction($definition)); return $definition($this); } // Next we check if any of our default initializers can initialize this class. - if ($initializer = $this->initializerFor($className)) { + if ($initializer = $this->initializerFor($className, $tag)) { $this->resolveChain()->add(new ReflectionClass($initializer)); $object = match (true) { @@ -158,29 +173,34 @@ private function resolve(string $className, mixed ...$params): object }; // Check whether the initializer's result should be registered as a singleton - if (Attributes::find(Singleton::class)->in($initializer::class)->first() !== null) { - $this->singleton($className, $object); + $singleton = Attributes::find(Singleton::class)->in($initializer::class)->first() + ?? Attributes::find(Singleton::class)->in((new ReflectionClass($initializer))->getMethod('initialize'))->first(); + + if ($singleton !== null) { + $this->singleton($className, $object, $tag); } return $object; } + // If we're requesting a tagged dependency and haven't resolved it at this point, something's wrong + if ($tag) { + throw new CannotResolveTaggedDependency($this->chain, new Dependency($className), $tag); + } + // Finally, autowire the class. return $this->autowire($className, ...$params); } - private function initializerFor(string $className): null|Initializer|DynamicInitializer + private function initializerFor(string $className, ?string $tag = null): null|Initializer|DynamicInitializer { // Initializers themselves can't be initialized, // otherwise you'd end up with infinite loops - if ( - is_a($className, Initializer::class, true) - || is_a($className, DynamicInitializer::class, true) - ) { + if (is_a($className, Initializer::class, true) || is_a($className, DynamicInitializer::class, true)) { return null; } - if ($initializerClass = $this->initializers[$className] ?? null) { + if ($initializerClass = $this->initializers[$this->resolveTaggedName($className, $tag)] ?? null) { return $this->resolve($initializerClass); } @@ -233,8 +253,11 @@ private function autowireDependencies(ReflectionMethod $method, array $parameter // Build the class by iterating through its // dependencies and resolving them. foreach ($method->getParameters() as $parameter) { + $tag = Attributes::find(Tag::class)->in($parameter)->first(); + $dependencies[] = $this->clone()->autowireDependency( parameter: $parameter, + tag: $tag?->name, providedValue: $parameters[$parameter->getName()] ?? null, ); } @@ -242,7 +265,7 @@ private function autowireDependencies(ReflectionMethod $method, array $parameter return $dependencies; } - private function autowireDependency(ReflectionParameter $parameter, mixed $providedValue = null): mixed + private function autowireDependency(ReflectionParameter $parameter, ?string $tag, mixed $providedValue = null): mixed { $parameterType = $parameter->getType(); @@ -262,7 +285,12 @@ private function autowireDependency(ReflectionParameter $parameter, mixed $provi // Loop through each type until we hit a match. foreach ($types as $type) { try { - return $this->autowireObjectDependency($type, $providedValue); + return $this->autowireObjectDependency( + /** @phpstan-ignore-next-line */ + type: $type, + tag: $tag, + providedValue: $providedValue + ); } catch (Throwable $throwable) { // We were unable to resolve the dependency for the last union // type, so we are moving on to the next one. We hang onto @@ -282,7 +310,7 @@ private function autowireDependency(ReflectionParameter $parameter, mixed $provi throw $lastThrowable ?? new CannotAutowireException($this->chain, new Dependency($parameter)); } - private function autowireObjectDependency(ReflectionNamedType $type, mixed $providedValue): mixed + private function autowireObjectDependency(ReflectionNamedType $type, ?string $tag, mixed $providedValue): mixed { // If the provided value is of the right type, // don't waste time autowiring, return it! @@ -292,7 +320,7 @@ private function autowireObjectDependency(ReflectionNamedType $type, mixed $prov // If we can successfully retrieve an instance // of the necessary dependency, return it. - if ($instance = $this->resolve($type->getName())) { + if ($instance = $this->resolve(className: $type->getName(), tag: $tag)) { return $instance; } @@ -361,4 +389,11 @@ public function __clone(): void { $this->chain = $this->chain?->clone(); } + + private function resolveTaggedName(string $className, ?string $tag): string + { + return $tag + ? "{$className}#{$tag}" + : $className; + } } diff --git a/src/Container/Tag.php b/src/Container/Tag.php new file mode 100644 index 0000000..322bac9 --- /dev/null +++ b/src/Container/Tag.php @@ -0,0 +1,15 @@ + */ - public function in(ReflectionClass|ReflectionMethod|ReflectionProperty|string $reflector): self + public function in(ReflectionClass|ReflectionMethod|ReflectionProperty|ReflectionParameter|string $reflector): self { if (is_string($reflector)) { $reflector = new ReflectionClass($reflector); diff --git a/tests/Unit/Container/ContainerTest.php b/tests/Unit/Container/ContainerTest.php index 29ea0a9..f3c2883 100644 --- a/tests/Unit/Container/ContainerTest.php +++ b/tests/Unit/Container/ContainerTest.php @@ -5,6 +5,7 @@ namespace Tests\Tempest\Unit\Container; use PHPUnit\Framework\TestCase; +use Tempest\Container\Exceptions\CannotResolveTaggedDependency; use Tempest\Container\Exceptions\CircularDependencyException; use Tempest\Container\GenericContainer; use Tests\Tempest\Unit\Container\Fixtures\BuiltinArrayClass; @@ -19,10 +20,14 @@ use Tests\Tempest\Unit\Container\Fixtures\ContainerObjectDInitializer; use Tests\Tempest\Unit\Container\Fixtures\ContainerObjectE; use Tests\Tempest\Unit\Container\Fixtures\ContainerObjectEInitializer; +use Tests\Tempest\Unit\Container\Fixtures\DependencyWithTaggedDependency; use Tests\Tempest\Unit\Container\Fixtures\IntersectionInitializer; use Tests\Tempest\Unit\Container\Fixtures\OptionalTypesClass; use Tests\Tempest\Unit\Container\Fixtures\SingletonClass; use Tests\Tempest\Unit\Container\Fixtures\SingletonInitializer; +use Tests\Tempest\Unit\Container\Fixtures\TaggedDependency; +use Tests\Tempest\Unit\Container\Fixtures\TaggedDependencyCliInitializer; +use Tests\Tempest\Unit\Container\Fixtures\TaggedDependencyWebInitializer; use Tests\Tempest\Unit\Container\Fixtures\UnionImplementation; use Tests\Tempest\Unit\Container\Fixtures\UnionInitializer; use Tests\Tempest\Unit\Container\Fixtures\UnionInterfaceA; @@ -208,4 +213,69 @@ public function test_circular_with_initializer_log(): void $this->assertStringContainsString(__FILE__, $e->getMessage()); } } + + public function test_tagged_singleton(): void + { + $container = new GenericContainer(); + + $container->singleton( + TaggedDependency::class, + new TaggedDependency('web'), + tag: 'web', + ); + + $container->singleton( + TaggedDependency::class, + new TaggedDependency('cli'), + tag: 'cli', + ); + + $this->assertSame('web', $container->get(TaggedDependency::class, 'web')->name); + $this->assertSame('cli', $container->get(TaggedDependency::class, 'cli')->name); + } + + public function test_tagged_singleton_with_initializer(): void + { + $container = new GenericContainer(); + $container->addInitializer(TaggedDependencyWebInitializer::class); + $container->addInitializer(TaggedDependencyCliInitializer::class); + + $this->assertSame('web', $container->get(TaggedDependency::class, 'web')->name); + $this->assertSame('cli', $container->get(TaggedDependency::class, 'cli')->name); + } + + public function test_tagged_singleton_exception(): void + { + $container = new GenericContainer(); + + $this->expectException(CannotResolveTaggedDependency::class); + + $container->get(TaggedDependency::class, 'web'); + } + + public function test_autowired_tagged_dependency(): void + { + $container = new GenericContainer(); + $container->addInitializer(TaggedDependencyWebInitializer::class); + + $dependency = $container->get(DependencyWithTaggedDependency::class); + $this->assertSame('web', $dependency->dependency->name); + } + + public function test_autowired_tagged_dependency_exception(): void + { + $container = new GenericContainer(); + + try { + $container->get(DependencyWithTaggedDependency::class); + } catch (CannotResolveTaggedDependency $exception) { + $this->assertStringContainsString( + <<<'TXT' + ┌── DependencyWithTaggedDependency::__construct(TaggedDependency $dependency) + └── Tests\Tempest\Unit\Container\Fixtures\TaggedDependency +TXT, + $exception->getMessage() + ); + } + } } diff --git a/tests/Unit/Container/Fixtures/DependencyWithTaggedDependency.php b/tests/Unit/Container/Fixtures/DependencyWithTaggedDependency.php new file mode 100644 index 0000000..f0cb679 --- /dev/null +++ b/tests/Unit/Container/Fixtures/DependencyWithTaggedDependency.php @@ -0,0 +1,16 @@ +