From 28f0739c41c03620778a4f0e9d04ed7479a73107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Wed, 21 Oct 2026 13:04:00 +0200 Subject: [PATCH 1/5] Add TagRegistry and InvalidTagNameException for service tagging functionality --- src/Container.php | 265 +++++++++++++++++++++- src/Exception/InvalidTagNameException.php | 32 +++ src/Internal/TagRegistry.php | 106 +++++++++ src/Resolver.php | 110 ++++++++- src/Scope.php | 173 +++++++++++++- 5 files changed, 678 insertions(+), 8 deletions(-) create mode 100644 src/Exception/InvalidTagNameException.php create mode 100644 src/Internal/TagRegistry.php diff --git a/src/Container.php b/src/Container.php index 0f227c5..2ad969e 100644 --- a/src/Container.php +++ b/src/Container.php @@ -21,9 +21,12 @@ Alias, Cls, Factory, Instance }; use FireHub\Container\Internal\ { - ContextualBindingRegistry, InstanceStore, ParameterBindingRegistry + ContextualBindingRegistry, InstanceStore, ParameterBindingRegistry, ResolutionContext, TagRegistry }; -use FireHub\Container\Exception\ContainerFrozenException; +use FireHub\Container\Exception\ { + ContainerFrozenException, InvalidTagNameException, ServiceNotFoundException +}; +use FireHub\Runtime; use Closure; /** @@ -68,6 +71,12 @@ final class Container implements ContainerBoundary, Registrar { */ private readonly ContextualBindingRegistry $contextual; + /** + * ### Service tag registry + * @since 1.0.0 + */ + private readonly TagRegistry $tags; + /** * ### Container freeze state * @var bool @@ -85,12 +94,14 @@ public function __construct () { $this->registry = new Registry; $this->parameters = new ParameterBindingRegistry; $this->contextual = new ContextualBindingRegistry; + $this->tags = new TagRegistry; $this->resolver = new Resolver( $this->registry, new InstanceStore, $this->parameters, - $this->contextual + $this->contextual, + $this->tags ); } @@ -166,6 +177,21 @@ public function register (string $id, Definition $definition):self { * Contextual bindings apply only to the exact consumer class and do not affect global service registrations. * * The target service retains its configured lifetime. + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->bindContextual( + * FrontendService::class, + * LoggerInterface::class, + * FileLogger::class + * ); + * + * $frontend = $container->get(FrontendService::class); + * + * * @since 1.0.0 * * @uses \FireHub\Container\Container::assertMutable() @@ -535,6 +561,239 @@ public function bindParameters (string $class, array $parameters):self { } + /** + * ### Associates registered services with a tag + * + * Groups explicitly registered service identifiers under the specified tag. + * + * Only services registered in the container can be tagged. Implicitly constructible classes are not considered + * registered services. + * + * Service identifiers are stored in registration order, and duplicate identifiers within the same tag are + * ignored. + * + * Registration is atomic: all service identifiers are validated before any tag association is created. + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->singleton(AuthenticationMiddleware::class); + * $container->singleton(AuthorizationMiddleware::class); + * $container->singleton(LoggingMiddleware::class); + * + * $container->tag( + * 'http.middleware', + * AuthenticationMiddleware::class, + * AuthorizationMiddleware::class, + * LoggingMiddleware::class + * ); + * + * + * You can also tag services with multiple calls to the `tag()` method: + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->singleton(AuthenticationMiddleware::class); + * $container->singleton(AuthorizationMiddleware::class); + * $container->singleton(LoggingMiddleware::class); + * + * $container->tag( + * 'http.middleware', + * AuthenticationMiddleware::class + * ); + * + * $container->tag( + * 'http.middleware', + * AuthorizationMiddleware::class, + * LoggingMiddleware::class + * ); + * + * + * @since 1.0.0 + * + * @uses \FireHub\Container\Container::assertMutable() To ensure the container is not frozen. + * @uses \FireHub\Container\Container::has() To verify explicit service registrations. + * @uses \FireHub\Container\Internal\TagRegistry::register() To register service tag associations. + * @uses \FireHub\Runtime\Str\SB\Transform::trim() To validate tag names. + * + * @param non-empty-string $tag

+ * Tag name. + *

+ * @param class-string ...$services

+ * Explicitly registered service identifiers. + *

+ * + * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. + * @throws \FireHub\Container\Exception\ServiceNotFoundException If a service is not explicitly registered. + * @throws \FireHub\Container\Exception\InvalidTagNameException If the tag name is empty. + * + * @return $this Container instance. + */ + public function tag (string $tag, string ...$services):self { + + $this->assertMutable(); + + if (Runtime\Str\SB\Transform::trim($tag) === '') + throw new InvalidTagNameException; + + foreach ($services as $service) + if (!$this->has($service)) + throw new ServiceNotFoundException; + + $this->tags->register($tag, ...$services); + + return $this; + + } + + /** + * ### Checks whether a service tag exists + * + * Determines whether the specified tag contains at least one registered service identifier. + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->singleton(AuthenticationMiddleware::class); + * $container->singleton(AuthorizationMiddleware::class); + * $container->singleton(LoggingMiddleware::class); + * + * $container->tag( + * 'http.middleware', + * AuthenticationMiddleware::class, + * AuthorizationMiddleware::class, + * LoggingMiddleware::class + * ); + * + * $container->hasTag('http.middleware'); + * + * // true + * + * + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\TagRegistry::has() To check tag registration. + * + * @param non-empty-string $tag

+ * Tag name. + *

+ * + * @return bool True if the tag contains registered services, otherwise false. + */ + public function hasTag (string $tag):bool { + + return $this->tags->has($tag); + + } + + /** + * ### Retrieves service identifiers associated with a tag + * + * Returns explicitly registered service identifiers in their original registration order without resolving or + * instantiating services. + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->singleton(AuthenticationMiddleware::class); + * $container->singleton(AuthorizationMiddleware::class); + * $container->singleton(LoggingMiddleware::class); + * + * $container->tag( + * 'http.middleware', + * AuthenticationMiddleware::class, + * AuthorizationMiddleware::class, + * LoggingMiddleware::class + * ); + * + * $container->taggedIds('http.middleware'); + * + * // [ + * // AuthenticationMiddleware::class, + * // AuthorizationMiddleware::class, + * // LoggingMiddleware::class + * // ] + * + * + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\TagRegistry::get() To retrieve tagged service identifiers. + * + * @param non-empty-string $tag

+ * Tag name. + *

+ * + * @return list Tagged service identifiers. + */ + public function taggedIds (string $tag):array { + + return $this->tags->get($tag); + + } + + /** + * ### Resolves all services associated with a tag + * + * Returns tagged service instances in their registration order. + * + * Each service retains its configured lifetime. Scoped services require an active scope. + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->singleton(AuthenticationMiddleware::class); + * $container->singleton(AuthorizationMiddleware::class); + * $container->singleton(LoggingMiddleware::class); + * + * $container->tag( + * 'http.middleware', + * AuthenticationMiddleware::class, + * AuthorizationMiddleware::class, + * LoggingMiddleware::class + * ); + * + * $container->tagged('http.middleware'); + * + * + * @since 1.0.0 + * + * @uses \FireHub\Container\Resolver::tagged() To resolve tagged services. + * + * @param non-empty-string $tag

+ * Service tag name. + *

+ * + * @throws \FireHub\Container\Exception\ServiceNotFoundException If a tagged service is no longer registered. + * @throws \FireHub\Container\Exception\ScopeRequiredException If a scoped service is resolved without a scope. + * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered. + * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible + * with its registered identifier. + * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported. + * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided. + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * + * @return list Resolved service instances. + */ + public function tagged (string $tag):array { + + return $this->resolver->tagged( + $tag, + new ResolutionContext + ); + + } + /** * ### Resolves a registered service * diff --git a/src/Exception/InvalidTagNameException.php b/src/Exception/InvalidTagNameException.php new file mode 100644 index 0000000..e1431bd --- /dev/null +++ b/src/Exception/InvalidTagNameException.php @@ -0,0 +1,32 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.1 + * @package Container + */ + +namespace FireHub\Container\Exception; + +use FireHub\Core\Exception\Runtime\System\InvariantException; + +/** + * ### Thrown when a service tag name is invalid + * + * Indicates that a service tag name is empty or contains only whitespace characters. + * @since 1.0.0 + */ +final class InvalidTagNameException extends InvariantException { + + /** + * ### Default exception message + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'Service tag name must not be empty or contain only whitespace'; + +} \ No newline at end of file diff --git a/src/Internal/TagRegistry.php b/src/Internal/TagRegistry.php new file mode 100644 index 0000000..6519712 --- /dev/null +++ b/src/Internal/TagRegistry.php @@ -0,0 +1,106 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.4 + * @package Container + */ + +namespace FireHub\Container\Internal; + +use FireHub\Runtime; + +/** + * ### Stores service tag registrations + * + * Maintains ordered associations between tag names and explicitly registered service identifiers. + * + * Each service identifier can belong to multiple tags, while duplicate registrations within the same tag are + * ignored. Registration order is preserved for deterministic service collection resolution. + * + * This registry stores only service identifiers and does not instantiate services, manage lifetimes, or resolve + * dependencies. + * @since 1.0.0 + * + * @internal + */ +final class TagRegistry { + + /** + * ### Registered service tags + * + * @var array> + */ + private array $tags = []; + + /** + * ### Registers service identifiers under a tag + * + * Associates the specified service identifiers with the given tag while preserving registration order. + * Duplicate identifiers within the same tag are ignored. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Arr\Inspection::inArray() To check if the service identifier is already registered. + * + * @param non-empty-string $tag

+ * Tag name. + *

+ * @param class-string ...$services

+ * Registered service identifiers. + *

+ * + * @return void + */ + public function register (string $tag, string ...$services):void { + + foreach ($services as $service) { + + if (Runtime\Arr\Inspection::inArray($this->tags[$tag] ?? [], $service)) + continue; + + $this->tags[$tag][] = $service; + + } + + } + + /** + * ### Checks whether a tag contains registered services + * @since 1.0.0 + * + * @param non-empty-string $tag

+ * Tag name. + *

+ * + * @return bool True if the tag contains at least one service, otherwise false. + */ + public function has (string $tag):bool { + + return isset($this->tags[$tag]); + + } + + /** + * ### Retrieves service identifiers associated with a tag + * + * Returns registered service identifiers in their original registration order. + * @since 1.0.0 + * + * @param non-empty-string $tag

+ * Tag name. + *

+ * + * @return list Registered service identifiers. + */ + public function get (string $tag):array { + + return $this->tags[$tag] ?? []; + + } + +} \ No newline at end of file diff --git a/src/Resolver.php b/src/Resolver.php index 27e8a05..27c4140 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -21,7 +21,7 @@ use FireHub\Container\Internal\ { Autowirer, CandidateSelector, ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector, ContextualResolver, DependencyPlanCache, InstanceStore, ParameterBindingRegistry, ResolutionContext, ScopeState, - TypeMatcher + TagRegistry, TypeMatcher }; use FireHub\Container\Exception\ { ScopeRequiredException, ServiceNotFoundException, ServiceTypeMismatchException, UnsupportedDefinitionException @@ -78,6 +78,9 @@ * @param \FireHub\Container\Internal\ContextualBindingRegistry $contextual

* Contextual binding registry. *

+ * @param \FireHub\Container\Internal\TagRegistry $tags

+ * Service tag registry. + *

* * @return void */ @@ -85,7 +88,8 @@ public function __construct ( private Registry $registry, private InstanceStore $singletons, ParameterBindingRegistry $parameters, - ContextualBindingRegistry $contextual + ContextualBindingRegistry $contextual, + private TagRegistry $tags ) { $this->metadata = new ConstructorMetadataCache; @@ -212,6 +216,108 @@ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope) } + /** + * ### Resolves services associated with a tag + * + * Resolves all service identifiers registered under the specified tag in their original registration order. + * + * Each service is resolved through the active resolution context, preserving singleton, scoped, and transient + * lifetime semantics. + * + * An unknown tag produces an empty collection. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\TagRegistry::get() To retrieve tagged service identifiers. + * @uses \FireHub\Container\Resolver::resolve() To resolve each tagged service. + * + * @param non-empty-string $tag

+ * Service tag name. + *

+ * @param \FireHub\Container\Internal\ResolutionContext $context

+ * Active dependency resolution context. + *

+ * + * @throws \FireHub\Container\Exception\ServiceNotFoundException If a tagged service is no longer registered. + * @throws \FireHub\Container\Exception\ScopeRequiredException If a scoped service is resolved without a scope. + * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered. + * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible + * with its registered identifier. + * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported. + * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided. + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * + * @return list Resolved service instances. + */ + public function tagged (string $tag, ResolutionContext $context):array { + + $services = []; + + foreach ($this->tags->get($tag) as $id) + $services[] = $this->resolve($id, $context); + + return $services; + + } + + /** + * ### Resolves tagged services within a scope + * + * Resolves all services associated with a tag using the provided scoped instance store and lifecycle state. + * + * All tagged services share the same resolution context, preserving scoped instance caching and circular + * dependency detection. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ScopeState::enter() To enter the scope. + * @uses \FireHub\Container\Internal\ScopeState::leave() To leave the scope. + * @uses \FireHub\Container\Resolver::tagged() To resolve tagged services. + * + * @param non-empty-string $tag

+ * Service tag name. + *

+ * @param \FireHub\Container\Internal\InstanceStore $scoped

+ * Scoped instance storage. + *

+ * @param \FireHub\Container\Internal\ScopeState $scope

+ * Scope lifecycle state. + *

+ * + * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. + * @throws \FireHub\Container\Exception\ServiceNotFoundException If a tagged service is no longer registered. + * @throws \FireHub\Container\Exception\ScopeRequiredException If a scoped service is resolved without a scope. + * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered. + * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible + * with its registered identifier. + * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported. + * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided. + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * + * @return list Resolved service instances. + */ + public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $scope):array { + + $scope->enter(); + + try { + + return $this->tagged( + $tag, + new ResolutionContext( + scoped: $scoped, + scope: $scope + ) + ); + + } finally { + + $scope->leave(); + + } + + } + /** * ### Checks whether a service is registered * diff --git a/src/Scope.php b/src/Scope.php index 6ddf816..de5ad84 100644 --- a/src/Scope.php +++ b/src/Scope.php @@ -70,6 +70,78 @@ public function __construct ( } + /** + * ### Resolves tagged services within the current scope + * + * Resolves all services associated with the specified tag using the current scope. + * + * Scoped services are reused within this scope, singleton services are shared with the parent container, and + * transient services are constructed on each resolution. + * + * Resolution is prohibited after the scope has been closed. + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->singleton(AuthenticationMiddleware::class); + * $container->scoped(AuthorizationMiddleware::class); + * $container->transient(LoggingMiddleware::class); + * + * $container->tag( + * 'http.middleware', + * AuthenticationMiddleware::class, + * AuthorizationMiddleware::class, + * LoggingMiddleware::class + * ); + * + * $scope = $container->createScope(); + * + * $first = $scope->tagged('http.middleware'); + * $second = $scope->tagged('http.middleware'); + * + * // Singleton and scoped instances are reused. + * $first[0] === $second[0]; // true + * $first[1] === $second[1]; // true + * + * // Transient instances are created on each resolution. + * $first[2] !== $second[2]; // true + * + * $scope->close(); + * + * + * @since 1.0.0 + * + * @uses \FireHub\Container\Resolver::taggedScoped() To resolve tagged services within the scope. + * + * @param non-empty-string $tag

+ * Service tag name. + *

+ * + * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. + * @throws \FireHub\Container\Exception\ServiceNotFoundException If a tagged service is no longer registered. + * @throws \FireHub\Container\Exception\ScopeRequiredException If a scoped service is resolved without a scope. + * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered. + * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible + * with its registered identifier. + * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported. + * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided. + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * + * @return list Resolved service instances. + */ + public function tagged (string $tag):array { + + return $this->resolver->taggedScoped( + $tag, + $this->instances, + $this->state + ); + + } + /** * ### Resolves a registered service * @@ -79,6 +151,25 @@ public function __construct ( * transient services are created on each resolution. * * Resolution is prohibited after the scope has been closed. + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->scoped(DatabaseConnection::class); + * + * $scope = $container->createScope(); + * + * $first = $scope->get(DatabaseConnection::class); + * $second = $scope->get(DatabaseConnection::class); + * + * // Scoped services are reused within the same scope. + * $first === $second; // true + * + * $scope->close(); + * + * * @since 1.0.0 * * @uses \FireHub\Container\Resolver::getScoped() To resolve a scoped service. @@ -89,12 +180,13 @@ public function __construct ( * Service identifier. *

* - * @throws \FireHub\Container\Exception\ScopeClosedException If the scope has been closed. * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered. - * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible - * with its registered identifier. + * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible with its registered identifier. * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported. * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided. + * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. * * @return TService Resolved service. */ @@ -116,6 +208,22 @@ public function get (string $id):object { * The operation does not instantiate the service. * * Registration checks are prohibited after the scope has been closed. + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->singleton(Logger::class); + * + * $scope = $container->createScope(); + * + * $scope->has(Logger::class); // true + * $scope->has(Cache::class); // false + * + * $scope->close(); + * + * * @since 1.0.0 * * @uses \FireHub\Container\Internal\ScopeState::assertOpen() To check if the scope is open. @@ -145,6 +253,31 @@ public function has (string $id):bool { * The scope is automatically closed after callback execution, including when the callback throws an exception. * * Nested execution of the same scope is not permitted. + * + * + * use FireHub\Container\Container; + * use FireHub\Core\Boundary\Resolver; + * + * $container = new Container; + * + * $container->scoped(DatabaseConnection::class); + * + * $scope = $container->createScope(); + * + * $scope->run( + * function (Resolver $resolver):void { + * + * $connection = $resolver->get(DatabaseConnection::class); + * + * $connection->connect(); + * + * } + * ); + * + * // The scope is automatically closed after execution. + * $scope->isClosed(); // true + * + * * @since 1.0.0 * * @uses \FireHub\Container\Internal\ScopeState::beginRun() To begin scope execution. @@ -188,6 +321,21 @@ public function run (Closure $callback):mixed { /** * ### Checks whether the scope is closed + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $scope = $container->createScope(); + * + * $scope->isClosed(); // false + * + * $scope->close(); + * + * $scope->isClosed(); // true + * + * * @since 1.0.0 * * @uses \FireHub\Container\Internal\ScopeState::isClosed() To check the scope lifecycle state. @@ -209,6 +357,25 @@ public function isClosed ():bool { * * Closing a scope does not affect singleton instances maintained by the parent container or instances stored in * other scopes. + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->scoped(DatabaseConnection::class); + * + * $scope = $container->createScope(); + * + * $connection = $scope->get(DatabaseConnection::class); + * + * // Release cached scoped instances. + * $scope->close(); + * + * // The scope can no longer resolve services. + * $scope->isClosed(); // true + * + * * @since 1.0.0 * * @uses \FireHub\Container\Internal\ScopeState::close() To close the scope. From eb6a428ca68613be4a964142bebaf4fe931847b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Wed, 21 Oct 2026 14:37:29 +0200 Subject: [PATCH 2/5] Add new stub classes for container testing and enhance existing ones --- src/Attribute/Tagged.php | 69 ++ src/Internal/Autowirer.php | 7 + src/Internal/DependencyPlanCache.php | 146 ++- src/Internal/ParameterPlan.php | 8 +- src/Resolver.php | 1 + tests/Stubs/AbstractLogger.php | 19 + tests/Stubs/AnotherTestLogger.php | 19 + tests/Stubs/BoundConnection.php | 28 + tests/Stubs/BoundDefaultService.php | 25 + tests/Stubs/BoundMixedService.php | 27 + tests/Stubs/BoundNullableService.php | 25 + tests/Stubs/BoundObjectService.php | 25 + tests/Stubs/BoundOtherConnection.php | 25 + tests/Stubs/CircularA.php | 25 + tests/Stubs/CircularB.php | 25 + tests/Stubs/ContextualCache.php | 19 + tests/Stubs/ContextualCacheService.php | 19 + tests/Stubs/ContextualCircularA.php | 25 + tests/Stubs/ContextualCircularB.php | 25 + tests/Stubs/ContextualConsoleLogger.php | 19 + tests/Stubs/ContextualDatabaseLogger.php | 19 + tests/Stubs/ContextualDefaultConsumer.php | 25 + tests/Stubs/ContextualDnfConsumer.php | 25 + tests/Stubs/ContextualFileLogger.php | 19 + .../Stubs/ContextualIntersectionConsumer.php | 25 + tests/Stubs/ContextualLogger.php | 19 + tests/Stubs/ContextualNamedConsumer.php | 25 + tests/Stubs/ContextualNestedConsumer.php | 25 + tests/Stubs/ContextualOtherConsumer.php | 25 + tests/Stubs/ContextualResettable.php | 19 + tests/Stubs/ContextualScopedConsumer.php | 25 + tests/Stubs/ContextualSerializable.php | 19 + tests/Stubs/ContextualSerializableCache.php | 19 + tests/Stubs/ContextualSingletonConsumer.php | 25 + .../ContextualTwoIntersectionsConsumer.php | 25 + tests/Stubs/ContextualUnionConsumer.php | 25 + tests/Stubs/ContextualUniversalService.php | 23 + tests/Stubs/DnfCache.php | 19 + tests/Stubs/DnfCacheService.php | 19 + tests/Stubs/DnfConsoleLogger.php | 19 + tests/Stubs/DnfDefaultConsumer.php | 25 + tests/Stubs/DnfFactoryConsumer.php | 25 + tests/Stubs/DnfFileLogger.php | 19 + tests/Stubs/DnfFirstBranchConsumer.php | 25 + tests/Stubs/DnfLogger.php | 19 + tests/Stubs/DnfLoggerCache.php | 19 + tests/Stubs/DnfMultipleNamedConsumer.php | 25 + tests/Stubs/DnfNestedConsumer.php | 25 + tests/Stubs/DnfNoCandidateConsumer.php | 25 + tests/Stubs/DnfRequiredNullableConsumer.php | 25 + tests/Stubs/DnfResettable.php | 19 + tests/Stubs/DnfScopedConsumer.php | 25 + tests/Stubs/DnfSecondBranchConsumer.php | 25 + tests/Stubs/DnfSerializable.php | 19 + tests/Stubs/DnfSerializableCache.php | 19 + tests/Stubs/DnfSingletonConsumer.php | 25 + tests/Stubs/DnfTwoIntersectionsConsumer.php | 25 + tests/Stubs/DnfUniversalService.php | 19 + tests/Stubs/IntersectionConsumer.php | 25 + tests/Stubs/Logger.php | 19 + tests/Stubs/LoggerInterface.php | 19 + tests/Stubs/MixedDefault.php | 25 + tests/Stubs/MixedRequired.php | 25 + tests/Stubs/NullableConcrete.php | 25 + tests/Stubs/NullableDefault.php | 25 + tests/Stubs/NullableRequired.php | 25 + tests/Stubs/Repository.php | 25 + tests/Stubs/ScalarDefault.php | 26 + tests/Stubs/ScalarRequired.php | 25 + tests/Stubs/ScopedConsumer.php | 25 + tests/Stubs/ScopedDependency.php | 19 + tests/Stubs/SingletonConsumer.php | 25 + tests/Stubs/TaggedConsoleLogger.php | 19 + tests/Stubs/TaggedDatabaseLogger.php | 19 + tests/Stubs/TaggedFactoryService.php | 19 + tests/Stubs/TaggedFileLogger.php | 19 + tests/Stubs/TaggedInjectionCircularA.php | 28 + tests/Stubs/TaggedInjectionCircularB.php | 25 + tests/Stubs/TaggedInjectionConsumer.php | 31 + .../Stubs/TaggedInjectionDefaultConsumer.php | 28 + .../Stubs/TaggedInjectionEmptyTagConsumer.php | 26 + tests/Stubs/TaggedInjectionNestedConsumer.php | 25 + .../Stubs/TaggedInjectionNullableConsumer.php | 26 + tests/Stubs/TaggedInjectionScalarConsumer.php | 26 + .../Stubs/TaggedInjectionUnknownConsumer.php | 28 + .../Stubs/TaggedInjectionUntypedConsumer.php | 26 + .../TaggedInjectionWhitespaceTagConsumer.php | 26 + tests/Stubs/TaggedLogger.php | 19 + tests/Stubs/TaggedScopedService.php | 19 + tests/Stubs/TaggedSingletonService.php | 19 + tests/Stubs/TaggedTransientService.php | 19 + tests/Stubs/TaggedUnregisteredService.php | 19 + tests/Stubs/TestConsoleLogger.php | 19 + tests/Stubs/TestFileLogger.php | 19 + tests/Stubs/TestLogger.php | 19 + tests/Stubs/TestResettable.php | 19 + tests/Stubs/UnionA.php | 19 + tests/Stubs/UnionAmbiguous.php | 25 + tests/Stubs/UnionB.php | 19 + tests/Stubs/UnionInterface.php | 19 + tests/Stubs/UnionNullable.php | 25 + tests/Stubs/UnionOnlyA.php | 25 + tests/Stubs/UserService.php | 25 + tests/Stubs/VariadicService.php | 30 + tests/Support/ExceptionAssertions.php | 53 + tests/Unit/AutowiringTest.php | 580 ++++++++++ tests/Unit/ContextualBindingTest.php | 1018 +++++++++++++++++ tests/Unit/DnfResolutionTest.php | 777 +++++++++++++ tests/Unit/ParameterBindingTest.php | 272 +++++ tests/Unit/TaggedInjectionTest.php | 524 +++++++++ tests/Unit/TaggedServicesTest.php | 831 ++++++++++++++ 111 files changed, 6525 insertions(+), 25 deletions(-) create mode 100644 src/Attribute/Tagged.php create mode 100644 tests/Stubs/AbstractLogger.php create mode 100644 tests/Stubs/AnotherTestLogger.php create mode 100644 tests/Stubs/BoundConnection.php create mode 100644 tests/Stubs/BoundDefaultService.php create mode 100644 tests/Stubs/BoundMixedService.php create mode 100644 tests/Stubs/BoundNullableService.php create mode 100644 tests/Stubs/BoundObjectService.php create mode 100644 tests/Stubs/BoundOtherConnection.php create mode 100644 tests/Stubs/CircularA.php create mode 100644 tests/Stubs/CircularB.php create mode 100644 tests/Stubs/ContextualCache.php create mode 100644 tests/Stubs/ContextualCacheService.php create mode 100644 tests/Stubs/ContextualCircularA.php create mode 100644 tests/Stubs/ContextualCircularB.php create mode 100644 tests/Stubs/ContextualConsoleLogger.php create mode 100644 tests/Stubs/ContextualDatabaseLogger.php create mode 100644 tests/Stubs/ContextualDefaultConsumer.php create mode 100644 tests/Stubs/ContextualDnfConsumer.php create mode 100644 tests/Stubs/ContextualFileLogger.php create mode 100644 tests/Stubs/ContextualIntersectionConsumer.php create mode 100644 tests/Stubs/ContextualLogger.php create mode 100644 tests/Stubs/ContextualNamedConsumer.php create mode 100644 tests/Stubs/ContextualNestedConsumer.php create mode 100644 tests/Stubs/ContextualOtherConsumer.php create mode 100644 tests/Stubs/ContextualResettable.php create mode 100644 tests/Stubs/ContextualScopedConsumer.php create mode 100644 tests/Stubs/ContextualSerializable.php create mode 100644 tests/Stubs/ContextualSerializableCache.php create mode 100644 tests/Stubs/ContextualSingletonConsumer.php create mode 100644 tests/Stubs/ContextualTwoIntersectionsConsumer.php create mode 100644 tests/Stubs/ContextualUnionConsumer.php create mode 100644 tests/Stubs/ContextualUniversalService.php create mode 100644 tests/Stubs/DnfCache.php create mode 100644 tests/Stubs/DnfCacheService.php create mode 100644 tests/Stubs/DnfConsoleLogger.php create mode 100644 tests/Stubs/DnfDefaultConsumer.php create mode 100644 tests/Stubs/DnfFactoryConsumer.php create mode 100644 tests/Stubs/DnfFileLogger.php create mode 100644 tests/Stubs/DnfFirstBranchConsumer.php create mode 100644 tests/Stubs/DnfLogger.php create mode 100644 tests/Stubs/DnfLoggerCache.php create mode 100644 tests/Stubs/DnfMultipleNamedConsumer.php create mode 100644 tests/Stubs/DnfNestedConsumer.php create mode 100644 tests/Stubs/DnfNoCandidateConsumer.php create mode 100644 tests/Stubs/DnfRequiredNullableConsumer.php create mode 100644 tests/Stubs/DnfResettable.php create mode 100644 tests/Stubs/DnfScopedConsumer.php create mode 100644 tests/Stubs/DnfSecondBranchConsumer.php create mode 100644 tests/Stubs/DnfSerializable.php create mode 100644 tests/Stubs/DnfSerializableCache.php create mode 100644 tests/Stubs/DnfSingletonConsumer.php create mode 100644 tests/Stubs/DnfTwoIntersectionsConsumer.php create mode 100644 tests/Stubs/DnfUniversalService.php create mode 100644 tests/Stubs/IntersectionConsumer.php create mode 100644 tests/Stubs/Logger.php create mode 100644 tests/Stubs/LoggerInterface.php create mode 100644 tests/Stubs/MixedDefault.php create mode 100644 tests/Stubs/MixedRequired.php create mode 100644 tests/Stubs/NullableConcrete.php create mode 100644 tests/Stubs/NullableDefault.php create mode 100644 tests/Stubs/NullableRequired.php create mode 100644 tests/Stubs/Repository.php create mode 100644 tests/Stubs/ScalarDefault.php create mode 100644 tests/Stubs/ScalarRequired.php create mode 100644 tests/Stubs/ScopedConsumer.php create mode 100644 tests/Stubs/ScopedDependency.php create mode 100644 tests/Stubs/SingletonConsumer.php create mode 100644 tests/Stubs/TaggedConsoleLogger.php create mode 100644 tests/Stubs/TaggedDatabaseLogger.php create mode 100644 tests/Stubs/TaggedFactoryService.php create mode 100644 tests/Stubs/TaggedFileLogger.php create mode 100644 tests/Stubs/TaggedInjectionCircularA.php create mode 100644 tests/Stubs/TaggedInjectionCircularB.php create mode 100644 tests/Stubs/TaggedInjectionConsumer.php create mode 100644 tests/Stubs/TaggedInjectionDefaultConsumer.php create mode 100644 tests/Stubs/TaggedInjectionEmptyTagConsumer.php create mode 100644 tests/Stubs/TaggedInjectionNestedConsumer.php create mode 100644 tests/Stubs/TaggedInjectionNullableConsumer.php create mode 100644 tests/Stubs/TaggedInjectionScalarConsumer.php create mode 100644 tests/Stubs/TaggedInjectionUnknownConsumer.php create mode 100644 tests/Stubs/TaggedInjectionUntypedConsumer.php create mode 100644 tests/Stubs/TaggedInjectionWhitespaceTagConsumer.php create mode 100644 tests/Stubs/TaggedLogger.php create mode 100644 tests/Stubs/TaggedScopedService.php create mode 100644 tests/Stubs/TaggedSingletonService.php create mode 100644 tests/Stubs/TaggedTransientService.php create mode 100644 tests/Stubs/TaggedUnregisteredService.php create mode 100644 tests/Stubs/TestConsoleLogger.php create mode 100644 tests/Stubs/TestFileLogger.php create mode 100644 tests/Stubs/TestLogger.php create mode 100644 tests/Stubs/TestResettable.php create mode 100644 tests/Stubs/UnionA.php create mode 100644 tests/Stubs/UnionAmbiguous.php create mode 100644 tests/Stubs/UnionB.php create mode 100644 tests/Stubs/UnionInterface.php create mode 100644 tests/Stubs/UnionNullable.php create mode 100644 tests/Stubs/UnionOnlyA.php create mode 100644 tests/Stubs/UserService.php create mode 100644 tests/Stubs/VariadicService.php create mode 100644 tests/Support/ExceptionAssertions.php create mode 100644 tests/Unit/AutowiringTest.php create mode 100644 tests/Unit/ContextualBindingTest.php create mode 100644 tests/Unit/DnfResolutionTest.php create mode 100644 tests/Unit/ParameterBindingTest.php create mode 100644 tests/Unit/TaggedInjectionTest.php create mode 100644 tests/Unit/TaggedServicesTest.php diff --git a/src/Attribute/Tagged.php b/src/Attribute/Tagged.php new file mode 100644 index 0000000..6457c52 --- /dev/null +++ b/src/Attribute/Tagged.php @@ -0,0 +1,69 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container + */ + +namespace FireHub\Container\Attribute; + +use Attribute; + +/** + * ### Marks a constructor parameter for tagged service injection + * + * Declares that a constructor parameter should receive all services associated with the specified tag. + * + * Tagged services are resolved in their registration order and retain their configured singleton, scoped, or + * transient lifetimes. + * + * The attribute is intended for array-typed constructor parameters. + * + * + * use FireHub\Container\Attribute\Tagged; + * + * final readonly class MiddlewarePipeline { + * + * public function __construct ( + * #[Tagged('http.middleware')] + * public array $middlewares + * ) {} + * + * } + * + * + * @since 1.0.0 + */ +#[Attribute(Attribute::TARGET_PARAMETER)] +final readonly class Tagged { + + /** + * ### Constructor + * + * + * use FireHub\Container\Attribute\Tagged; + * + * $attribute = new Tagged('http.middleware'); + * + * $attribute->tag; // 'http.middleware' + * + * + * @since 1.0.0 + * + * @param non-empty-string $tag

+ * Name of the service tag to resolve. + *

+ * + * @return void + */ + public function __construct ( + public string $tag + ) {} + +} \ No newline at end of file diff --git a/src/Internal/Autowirer.php b/src/Internal/Autowirer.php index 14bf5d0..fe1370f 100644 --- a/src/Internal/Autowirer.php +++ b/src/Internal/Autowirer.php @@ -38,6 +38,9 @@ * @param Closure(class-string, \FireHub\Container\Internal\ResolutionContext):object $resolve

* Context-aware service resolver. *

+ * @param Closure(non-empty-string, \FireHub\Container\Internal\ResolutionContext):list $resolve_tagged

+ * Context-aware tagged service collection resolver. + *

* @param Closure(class-string):bool $has

* Explicit service registration checker. *

@@ -61,6 +64,7 @@ */ public function __construct ( private Closure $resolve, + private Closure $resolve_tagged, private Closure $has, private TypeMatcher $matcher, private CandidateSelector $selector, @@ -159,6 +163,9 @@ private function resolveParameter (string $class, ParameterPlan $plan, Resolutio if ($this->bindings->has($class, $name)) return $this->bindings->get($class, $name); + if ($plan->tag !== null) + return ($this->resolve_tagged)($plan->tag, $context); + $id = $this->contextual_selector->select($class, $plan); if ($id !== null) diff --git a/src/Internal/DependencyPlanCache.php b/src/Internal/DependencyPlanCache.php index 3617655..8ba1f86 100644 --- a/src/Internal/DependencyPlanCache.php +++ b/src/Internal/DependencyPlanCache.php @@ -13,7 +13,10 @@ namespace FireHub\Container\Internal; -use ReflectionNamedType, ReflectionUnionType, ReflectionIntersectionType; +use FireHub\Container\Attribute\Tagged; +use FireHub\Container\Exception\ServiceInstantiationException; +use FireHub\Runtime; +use ReflectionNamedType, ReflectionParameter, ReflectionUnionType, ReflectionIntersectionType; /** * ### Compiles and caches dependency construction plans @@ -60,13 +63,17 @@ public function __construct( * Returns the cached dependency plan or compiles one from constructor reflection metadata. * @since 1.0.0 * - * @uses \FireHub\Container\Internal\TypeMatcher::classId() To resolve named dependency identifiers. + * @uses \FireHub\Container\Internal\DependencyPlanCache::compileParameter() To compile constructor parameter + * metadata. * @uses \FireHub\Container\Internal\ConstructorMetadataCache::get() To retrieve constructor metadata. * * @param class-string $class

* Service implementation class. *

* + * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple tagged attributes are declared, + * the parameter type is unsupported, or the tag name is empty. + * * @return \FireHub\Container\Internal\DependencyPlan Compiled dependency plan. */ public function get (string $class):DependencyPlan { @@ -83,28 +90,7 @@ public function get (string $class):DependencyPlan { if ($parameter->isVariadic()) continue; - $type = $parameter->getType(); - - $id = $type instanceof ReflectionNamedType - ? $this->matcher->classId($parameter, $type) - : null; - - $composite = $type instanceof ReflectionUnionType - || $type instanceof ReflectionIntersectionType - ? $type - : null; - - $hasDefault = $parameter->isDefaultValueAvailable(); - - $parameters[] = new ParameterPlan( - $parameter, - $id, - $composite, - $hasDefault, - $hasDefault - ? $parameter->getDefaultValue() - : null - ); + $parameters[] = $this->compileParameter($parameter); } @@ -115,4 +101,116 @@ public function get (string $class):DependencyPlan { } + /** + * ### Compiles constructor parameter metadata + * + * Extracts dependency type information, default values, and tagged service injection metadata. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\TypeMatcher::classId() To resolve named dependency identifiers. + * @uses \FireHub\Container\Internal\DependencyPlanCache::resolveTag() To resolve tagged injection metadata. + * + * @param ReflectionParameter $parameter

+ * Constructor parameter reflection. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple tagged attributes are declared, + * the parameter type is unsupported, or the tag name is empty. + * + * @return \FireHub\Container\Internal\ParameterPlan Compiled parameter plan. + */ + private function compileParameter (ReflectionParameter $parameter):ParameterPlan { + + $type = $parameter->getType(); + + $id = $type instanceof ReflectionNamedType + ? $this->matcher->classId($parameter, $type) + : null; + + $composite = $type instanceof ReflectionUnionType + || $type instanceof ReflectionIntersectionType + ? $type + : null; + + $has_default = $parameter->isDefaultValueAvailable(); + + return new ParameterPlan( + $parameter, + $id, + $composite, + $has_default, + $has_default + ? $parameter->getDefaultValue() + : null, + $this->resolveTag($parameter) + ); + + } + + /** + * ### Resolves tagged injection metadata + * + * Extracts and validates the tagged service injection attribute associated with a constructor parameter. + * + * Tagged injection requires a non-nullable array parameter and a non-empty tag name. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Arr\Inspection::count() To count the number of declared tagged attributes. + * @uses \FireHub\Runtime\Str\SB\Transform::trim() To trim whitespace from tag names. + * + * @param \ReflectionParameter $parameter

+ * Constructor parameter reflection. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple tagged attributes are declared, + * the parameter type is unsupported, or the tag name is empty. + * + * @return null|non-empty-string Tag name, or null if the parameter is not tagged. + */ + private function resolveTag (ReflectionParameter $parameter):?string { + + $attributes = $parameter->getAttributes(Tagged::class); + + if ($attributes === []) + return null; + + if (Runtime\Arr\Inspection::count($attributes) > 1) + throw new ServiceInstantiationException( + 'Multiple tagged attributes are not supported', + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + ] + ); + + $type = $parameter->getType(); + + if ( + !$type instanceof ReflectionNamedType + || $type->getName() !== 'array' + || $type->allowsNull() + ) throw new ServiceInstantiationException( + 'Tagged injection requires a non-nullable array parameter.', + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + ] + ); + + /** @var \FireHub\Container\Attribute\Tagged $attribute */ + $attribute = $attributes[0]->newInstance(); + + if (Runtime\Str\SB\Transform::trim($attribute->tag) === '') + throw new ServiceInstantiationException( + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + 'reason' => 'Tagged injection requires a non-empty tag name.' + ] + ); + + return $attribute->tag; + + } + } \ No newline at end of file diff --git a/src/Internal/ParameterPlan.php b/src/Internal/ParameterPlan.php index 66e463a..6c9b51e 100644 --- a/src/Internal/ParameterPlan.php +++ b/src/Internal/ParameterPlan.php @@ -21,6 +21,8 @@ * Stores the statically determined information required to resolve a constructor parameter. * * Dependency availability and candidate selection remain dynamic and are evaluated during plan execution. + * + * Tagged service injection metadata is extracted during plan compilation and reused for later dependency resolutions. * @since 1.0.0 * * @internal @@ -46,6 +48,9 @@ * @param mixed $default

* Default parameter value. *

+ * @param null|non-empty-string $tag [optional]

+ * Service tag associated with the constructor parameter. + *

* * @return void */ @@ -54,7 +59,8 @@ public function __construct ( public ?string $id, public ReflectionUnionType|ReflectionIntersectionType|null $composite, public bool $hasDefault, - public mixed $default + public mixed $default, + public ?string $tag = null ) {} } \ No newline at end of file diff --git a/src/Resolver.php b/src/Resolver.php index 27c4140..baec675 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -111,6 +111,7 @@ public function __construct ( $this->autowirer = new Autowirer( fn (string $id, ResolutionContext $context):object => $this->resolve($id, $context), + fn (string $tag, ResolutionContext $context):array => $this->tagged($tag, $context), fn (string $id):bool => $this->has($id), $matcher, $selector, diff --git a/tests/Stubs/AbstractLogger.php b/tests/Stubs/AbstractLogger.php new file mode 100644 index 0000000..d5b3799 --- /dev/null +++ b/tests/Stubs/AbstractLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +abstract class AbstractLogger {} diff --git a/tests/Stubs/AnotherTestLogger.php b/tests/Stubs/AnotherTestLogger.php new file mode 100644 index 0000000..e086d3c --- /dev/null +++ b/tests/Stubs/AnotherTestLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.4 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class AnotherTestLogger implements TestLogger, TestResettable {} \ No newline at end of file diff --git a/tests/Stubs/BoundConnection.php b/tests/Stubs/BoundConnection.php new file mode 100644 index 0000000..b87dffe --- /dev/null +++ b/tests/Stubs/BoundConnection.php @@ -0,0 +1,28 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * ### BoundConnection + * @since 1.0.0 + */ +final readonly class BoundConnection { + + public function __construct ( + public string $host, + public int $port, + public string $database + ) {} + +} diff --git a/tests/Stubs/BoundDefaultService.php b/tests/Stubs/BoundDefaultService.php new file mode 100644 index 0000000..8e2d498 --- /dev/null +++ b/tests/Stubs/BoundDefaultService.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class BoundDefaultService { + + public function __construct ( + public int $port = 8080 + ) {} + +} diff --git a/tests/Stubs/BoundMixedService.php b/tests/Stubs/BoundMixedService.php new file mode 100644 index 0000000..7716af8 --- /dev/null +++ b/tests/Stubs/BoundMixedService.php @@ -0,0 +1,27 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class BoundMixedService { + + public function __construct ( + public Logger $logger, + public string $host, + public int $port = 3306 + ) {} + +} diff --git a/tests/Stubs/BoundNullableService.php b/tests/Stubs/BoundNullableService.php new file mode 100644 index 0000000..6cd4813 --- /dev/null +++ b/tests/Stubs/BoundNullableService.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class BoundNullableService { + + public function __construct ( + public ?Logger $logger + ) {} + +} diff --git a/tests/Stubs/BoundObjectService.php b/tests/Stubs/BoundObjectService.php new file mode 100644 index 0000000..bfa0658 --- /dev/null +++ b/tests/Stubs/BoundObjectService.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class BoundObjectService { + + public function __construct ( + public Logger $logger + ) {} + +} diff --git a/tests/Stubs/BoundOtherConnection.php b/tests/Stubs/BoundOtherConnection.php new file mode 100644 index 0000000..6c0b20e --- /dev/null +++ b/tests/Stubs/BoundOtherConnection.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class BoundOtherConnection { + + public function __construct ( + public string $host + ) {} + +} diff --git a/tests/Stubs/CircularA.php b/tests/Stubs/CircularA.php new file mode 100644 index 0000000..2c60cf9 --- /dev/null +++ b/tests/Stubs/CircularA.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class CircularA { + + public function __construct ( + public CircularB $dependency + ) {} + +} diff --git a/tests/Stubs/CircularB.php b/tests/Stubs/CircularB.php new file mode 100644 index 0000000..85ea091 --- /dev/null +++ b/tests/Stubs/CircularB.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class CircularB { + + public function __construct ( + public CircularA $dependency + ) {} + +} diff --git a/tests/Stubs/ContextualCache.php b/tests/Stubs/ContextualCache.php new file mode 100644 index 0000000..bf3abfc --- /dev/null +++ b/tests/Stubs/ContextualCache.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface ContextualCache {} diff --git a/tests/Stubs/ContextualCacheService.php b/tests/Stubs/ContextualCacheService.php new file mode 100644 index 0000000..7e4f50d --- /dev/null +++ b/tests/Stubs/ContextualCacheService.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualCacheService implements ContextualCache {} diff --git a/tests/Stubs/ContextualCircularA.php b/tests/Stubs/ContextualCircularA.php new file mode 100644 index 0000000..207456e --- /dev/null +++ b/tests/Stubs/ContextualCircularA.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualCircularA { + + public function __construct ( + public ContextualCircularB $dependency + ) {} + +} diff --git a/tests/Stubs/ContextualCircularB.php b/tests/Stubs/ContextualCircularB.php new file mode 100644 index 0000000..e61e347 --- /dev/null +++ b/tests/Stubs/ContextualCircularB.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualCircularB { + + public function __construct ( + public ContextualCircularA $dependency + ) {} + +} diff --git a/tests/Stubs/ContextualConsoleLogger.php b/tests/Stubs/ContextualConsoleLogger.php new file mode 100644 index 0000000..7eb4b23 --- /dev/null +++ b/tests/Stubs/ContextualConsoleLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualConsoleLogger implements ContextualLogger {} diff --git a/tests/Stubs/ContextualDatabaseLogger.php b/tests/Stubs/ContextualDatabaseLogger.php new file mode 100644 index 0000000..7f25c9a --- /dev/null +++ b/tests/Stubs/ContextualDatabaseLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualDatabaseLogger implements ContextualLogger {} diff --git a/tests/Stubs/ContextualDefaultConsumer.php b/tests/Stubs/ContextualDefaultConsumer.php new file mode 100644 index 0000000..8773d1f --- /dev/null +++ b/tests/Stubs/ContextualDefaultConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualDefaultConsumer { + + public function __construct ( + public ?ContextualLogger $logger = null + ) {} + +} diff --git a/tests/Stubs/ContextualDnfConsumer.php b/tests/Stubs/ContextualDnfConsumer.php new file mode 100644 index 0000000..9fae622 --- /dev/null +++ b/tests/Stubs/ContextualDnfConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualDnfConsumer { + + public function __construct ( + public (ContextualLogger&ContextualResettable)|ContextualCache $dependency + ) {} + +} diff --git a/tests/Stubs/ContextualFileLogger.php b/tests/Stubs/ContextualFileLogger.php new file mode 100644 index 0000000..f9af963 --- /dev/null +++ b/tests/Stubs/ContextualFileLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualFileLogger implements ContextualLogger, ContextualResettable {} diff --git a/tests/Stubs/ContextualIntersectionConsumer.php b/tests/Stubs/ContextualIntersectionConsumer.php new file mode 100644 index 0000000..5af38b8 --- /dev/null +++ b/tests/Stubs/ContextualIntersectionConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualIntersectionConsumer { + + public function __construct ( + public ContextualLogger&ContextualResettable $dependency + ) {} + +} diff --git a/tests/Stubs/ContextualLogger.php b/tests/Stubs/ContextualLogger.php new file mode 100644 index 0000000..014439c --- /dev/null +++ b/tests/Stubs/ContextualLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface ContextualLogger {} diff --git a/tests/Stubs/ContextualNamedConsumer.php b/tests/Stubs/ContextualNamedConsumer.php new file mode 100644 index 0000000..57ab77e --- /dev/null +++ b/tests/Stubs/ContextualNamedConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualNamedConsumer { + + public function __construct ( + public ContextualLogger $logger + ) {} + +} diff --git a/tests/Stubs/ContextualNestedConsumer.php b/tests/Stubs/ContextualNestedConsumer.php new file mode 100644 index 0000000..08f43f6 --- /dev/null +++ b/tests/Stubs/ContextualNestedConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualNestedConsumer { + + public function __construct ( + public ContextualNamedConsumer $consumer + ) {} + +} diff --git a/tests/Stubs/ContextualOtherConsumer.php b/tests/Stubs/ContextualOtherConsumer.php new file mode 100644 index 0000000..f1b550b --- /dev/null +++ b/tests/Stubs/ContextualOtherConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualOtherConsumer { + + public function __construct ( + public ContextualLogger $logger + ) {} + +} diff --git a/tests/Stubs/ContextualResettable.php b/tests/Stubs/ContextualResettable.php new file mode 100644 index 0000000..a8140d4 --- /dev/null +++ b/tests/Stubs/ContextualResettable.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface ContextualResettable {} diff --git a/tests/Stubs/ContextualScopedConsumer.php b/tests/Stubs/ContextualScopedConsumer.php new file mode 100644 index 0000000..fc97347 --- /dev/null +++ b/tests/Stubs/ContextualScopedConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualScopedConsumer { + + public function __construct ( + public ContextualLogger $logger + ) {} + +} diff --git a/tests/Stubs/ContextualSerializable.php b/tests/Stubs/ContextualSerializable.php new file mode 100644 index 0000000..3fc73b1 --- /dev/null +++ b/tests/Stubs/ContextualSerializable.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface ContextualSerializable {} diff --git a/tests/Stubs/ContextualSerializableCache.php b/tests/Stubs/ContextualSerializableCache.php new file mode 100644 index 0000000..6df395b --- /dev/null +++ b/tests/Stubs/ContextualSerializableCache.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualSerializableCache implements ContextualCache, ContextualSerializable {} diff --git a/tests/Stubs/ContextualSingletonConsumer.php b/tests/Stubs/ContextualSingletonConsumer.php new file mode 100644 index 0000000..9c4ce5e --- /dev/null +++ b/tests/Stubs/ContextualSingletonConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualSingletonConsumer { + + public function __construct ( + public ContextualLogger $logger + ) {} + +} diff --git a/tests/Stubs/ContextualTwoIntersectionsConsumer.php b/tests/Stubs/ContextualTwoIntersectionsConsumer.php new file mode 100644 index 0000000..109758e --- /dev/null +++ b/tests/Stubs/ContextualTwoIntersectionsConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualTwoIntersectionsConsumer { + + public function __construct ( + public (ContextualLogger&ContextualResettable)|(ContextualCache&ContextualSerializable) $dependency + ) {} + +} diff --git a/tests/Stubs/ContextualUnionConsumer.php b/tests/Stubs/ContextualUnionConsumer.php new file mode 100644 index 0000000..3bf2120 --- /dev/null +++ b/tests/Stubs/ContextualUnionConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualUnionConsumer { + + public function __construct ( + public ContextualLogger|ContextualCache $dependency + ) {} + +} diff --git a/tests/Stubs/ContextualUniversalService.php b/tests/Stubs/ContextualUniversalService.php new file mode 100644 index 0000000..2bbf837 --- /dev/null +++ b/tests/Stubs/ContextualUniversalService.php @@ -0,0 +1,23 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ContextualUniversalService implements + ContextualLogger, + ContextualResettable, + ContextualCache, + ContextualSerializable {} diff --git a/tests/Stubs/DnfCache.php b/tests/Stubs/DnfCache.php new file mode 100644 index 0000000..47bcd0c --- /dev/null +++ b/tests/Stubs/DnfCache.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface DnfCache {} diff --git a/tests/Stubs/DnfCacheService.php b/tests/Stubs/DnfCacheService.php new file mode 100644 index 0000000..a4f25d0 --- /dev/null +++ b/tests/Stubs/DnfCacheService.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfCacheService implements DnfCache {} diff --git a/tests/Stubs/DnfConsoleLogger.php b/tests/Stubs/DnfConsoleLogger.php new file mode 100644 index 0000000..57f1225 --- /dev/null +++ b/tests/Stubs/DnfConsoleLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfConsoleLogger implements DnfLogger {} diff --git a/tests/Stubs/DnfDefaultConsumer.php b/tests/Stubs/DnfDefaultConsumer.php new file mode 100644 index 0000000..cc6b884 --- /dev/null +++ b/tests/Stubs/DnfDefaultConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfDefaultConsumer { + + public function __construct ( + public (DnfLogger&DnfResettable)|DnfCache|null $dependency = null + ) {} + +} diff --git a/tests/Stubs/DnfFactoryConsumer.php b/tests/Stubs/DnfFactoryConsumer.php new file mode 100644 index 0000000..9bd19df --- /dev/null +++ b/tests/Stubs/DnfFactoryConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfFactoryConsumer { + + public function __construct ( + public (DnfLogger&DnfResettable)|DnfCache $dependency + ) {} + +} diff --git a/tests/Stubs/DnfFileLogger.php b/tests/Stubs/DnfFileLogger.php new file mode 100644 index 0000000..eac86c3 --- /dev/null +++ b/tests/Stubs/DnfFileLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfFileLogger implements DnfLogger, DnfResettable {} diff --git a/tests/Stubs/DnfFirstBranchConsumer.php b/tests/Stubs/DnfFirstBranchConsumer.php new file mode 100644 index 0000000..df7bee5 --- /dev/null +++ b/tests/Stubs/DnfFirstBranchConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfFirstBranchConsumer { + + public function __construct ( + public (DnfLogger&DnfResettable)|DnfCache $dependency + ) {} + +} diff --git a/tests/Stubs/DnfLogger.php b/tests/Stubs/DnfLogger.php new file mode 100644 index 0000000..a16ff1d --- /dev/null +++ b/tests/Stubs/DnfLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface DnfLogger {} diff --git a/tests/Stubs/DnfLoggerCache.php b/tests/Stubs/DnfLoggerCache.php new file mode 100644 index 0000000..ca3bec2 --- /dev/null +++ b/tests/Stubs/DnfLoggerCache.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfLoggerCache implements DnfLogger, DnfCache {} diff --git a/tests/Stubs/DnfMultipleNamedConsumer.php b/tests/Stubs/DnfMultipleNamedConsumer.php new file mode 100644 index 0000000..d615c56 --- /dev/null +++ b/tests/Stubs/DnfMultipleNamedConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfMultipleNamedConsumer { + + public function __construct ( + public (DnfLogger&DnfResettable)|DnfCache|DnfSerializable $dependency + ) {} + +} diff --git a/tests/Stubs/DnfNestedConsumer.php b/tests/Stubs/DnfNestedConsumer.php new file mode 100644 index 0000000..bc2db67 --- /dev/null +++ b/tests/Stubs/DnfNestedConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfNestedConsumer { + + public function __construct ( + public DnfFirstBranchConsumer $consumer + ) {} + +} diff --git a/tests/Stubs/DnfNoCandidateConsumer.php b/tests/Stubs/DnfNoCandidateConsumer.php new file mode 100644 index 0000000..eee3bb8 --- /dev/null +++ b/tests/Stubs/DnfNoCandidateConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfNoCandidateConsumer { + + public function __construct ( + public (DnfLogger&DnfResettable)|(DnfCache&DnfSerializable) $dependency + ) {} + +} diff --git a/tests/Stubs/DnfRequiredNullableConsumer.php b/tests/Stubs/DnfRequiredNullableConsumer.php new file mode 100644 index 0000000..339f89a --- /dev/null +++ b/tests/Stubs/DnfRequiredNullableConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfRequiredNullableConsumer { + + public function __construct ( + public (DnfLogger&DnfResettable)|DnfCache|null $dependency + ) {} + +} diff --git a/tests/Stubs/DnfResettable.php b/tests/Stubs/DnfResettable.php new file mode 100644 index 0000000..1572ae7 --- /dev/null +++ b/tests/Stubs/DnfResettable.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface DnfResettable {} diff --git a/tests/Stubs/DnfScopedConsumer.php b/tests/Stubs/DnfScopedConsumer.php new file mode 100644 index 0000000..4792138 --- /dev/null +++ b/tests/Stubs/DnfScopedConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfScopedConsumer { + + public function __construct ( + public (DnfLogger&DnfResettable)|DnfCache $dependency + ) {} + +} diff --git a/tests/Stubs/DnfSecondBranchConsumer.php b/tests/Stubs/DnfSecondBranchConsumer.php new file mode 100644 index 0000000..1fd0fde --- /dev/null +++ b/tests/Stubs/DnfSecondBranchConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfSecondBranchConsumer { + + public function __construct ( + public (DnfLogger&DnfResettable)|DnfCache $dependency + ) {} + +} diff --git a/tests/Stubs/DnfSerializable.php b/tests/Stubs/DnfSerializable.php new file mode 100644 index 0000000..e9afb37 --- /dev/null +++ b/tests/Stubs/DnfSerializable.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface DnfSerializable {} diff --git a/tests/Stubs/DnfSerializableCache.php b/tests/Stubs/DnfSerializableCache.php new file mode 100644 index 0000000..c725e42 --- /dev/null +++ b/tests/Stubs/DnfSerializableCache.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfSerializableCache implements DnfCache, DnfSerializable {} diff --git a/tests/Stubs/DnfSingletonConsumer.php b/tests/Stubs/DnfSingletonConsumer.php new file mode 100644 index 0000000..2094968 --- /dev/null +++ b/tests/Stubs/DnfSingletonConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfSingletonConsumer { + + public function __construct ( + public (DnfLogger&DnfResettable)|DnfCache $dependency + ) {} + +} diff --git a/tests/Stubs/DnfTwoIntersectionsConsumer.php b/tests/Stubs/DnfTwoIntersectionsConsumer.php new file mode 100644 index 0000000..4163f9a --- /dev/null +++ b/tests/Stubs/DnfTwoIntersectionsConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfTwoIntersectionsConsumer { + + public function __construct ( + public (DnfLogger&DnfResettable)|(DnfCache&DnfSerializable) $dependency + ) {} + +} diff --git a/tests/Stubs/DnfUniversalService.php b/tests/Stubs/DnfUniversalService.php new file mode 100644 index 0000000..a244202 --- /dev/null +++ b/tests/Stubs/DnfUniversalService.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class DnfUniversalService implements DnfLogger, DnfResettable, DnfCache, DnfSerializable {} diff --git a/tests/Stubs/IntersectionConsumer.php b/tests/Stubs/IntersectionConsumer.php new file mode 100644 index 0000000..b5c5730 --- /dev/null +++ b/tests/Stubs/IntersectionConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class IntersectionConsumer { + + public function __construct ( + public TestLogger&TestResettable $logger + ) {} + +} diff --git a/tests/Stubs/Logger.php b/tests/Stubs/Logger.php new file mode 100644 index 0000000..3e3b3c1 --- /dev/null +++ b/tests/Stubs/Logger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class Logger {} diff --git a/tests/Stubs/LoggerInterface.php b/tests/Stubs/LoggerInterface.php new file mode 100644 index 0000000..f4ebba0 --- /dev/null +++ b/tests/Stubs/LoggerInterface.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface LoggerInterface {} diff --git a/tests/Stubs/MixedDefault.php b/tests/Stubs/MixedDefault.php new file mode 100644 index 0000000..e159e30 --- /dev/null +++ b/tests/Stubs/MixedDefault.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class MixedDefault { + + public function __construct ( + public mixed $value = null + ) {} + +} diff --git a/tests/Stubs/MixedRequired.php b/tests/Stubs/MixedRequired.php new file mode 100644 index 0000000..721b6ae --- /dev/null +++ b/tests/Stubs/MixedRequired.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class MixedRequired { + + public function __construct ( + public mixed $value + ) {} + +} diff --git a/tests/Stubs/NullableConcrete.php b/tests/Stubs/NullableConcrete.php new file mode 100644 index 0000000..f815dd3 --- /dev/null +++ b/tests/Stubs/NullableConcrete.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class NullableConcrete { + + public function __construct ( + public ?Logger $logger = null + ) {} + +} diff --git a/tests/Stubs/NullableDefault.php b/tests/Stubs/NullableDefault.php new file mode 100644 index 0000000..7c34ccc --- /dev/null +++ b/tests/Stubs/NullableDefault.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class NullableDefault { + + public function __construct ( + public ?LoggerInterface $logger = null + ) {} + +} diff --git a/tests/Stubs/NullableRequired.php b/tests/Stubs/NullableRequired.php new file mode 100644 index 0000000..6aba9ea --- /dev/null +++ b/tests/Stubs/NullableRequired.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class NullableRequired { + + public function __construct ( + public ?LoggerInterface $logger + ) {} + +} diff --git a/tests/Stubs/Repository.php b/tests/Stubs/Repository.php new file mode 100644 index 0000000..1bc9873 --- /dev/null +++ b/tests/Stubs/Repository.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class Repository { + + public function __construct ( + public Logger $logger + ) {} + +} diff --git a/tests/Stubs/ScalarDefault.php b/tests/Stubs/ScalarDefault.php new file mode 100644 index 0000000..7e054f1 --- /dev/null +++ b/tests/Stubs/ScalarDefault.php @@ -0,0 +1,26 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ScalarDefault { + + public function __construct ( + public int $port = 8080, + public string $host = 'localhost' + ) {} + +} diff --git a/tests/Stubs/ScalarRequired.php b/tests/Stubs/ScalarRequired.php new file mode 100644 index 0000000..5008e23 --- /dev/null +++ b/tests/Stubs/ScalarRequired.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ScalarRequired { + + public function __construct ( + public int $port + ) {} + +} diff --git a/tests/Stubs/ScopedConsumer.php b/tests/Stubs/ScopedConsumer.php new file mode 100644 index 0000000..00867fa --- /dev/null +++ b/tests/Stubs/ScopedConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ScopedConsumer { + + public function __construct ( + public ScopedDependency $dependency + ) {} + +} diff --git a/tests/Stubs/ScopedDependency.php b/tests/Stubs/ScopedDependency.php new file mode 100644 index 0000000..def3efe --- /dev/null +++ b/tests/Stubs/ScopedDependency.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class ScopedDependency {} diff --git a/tests/Stubs/SingletonConsumer.php b/tests/Stubs/SingletonConsumer.php new file mode 100644 index 0000000..ca030a9 --- /dev/null +++ b/tests/Stubs/SingletonConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class SingletonConsumer { + + public function __construct ( + public Logger $logger + ) {} + +} diff --git a/tests/Stubs/TaggedConsoleLogger.php b/tests/Stubs/TaggedConsoleLogger.php new file mode 100644 index 0000000..e244de2 --- /dev/null +++ b/tests/Stubs/TaggedConsoleLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TaggedConsoleLogger implements TaggedLogger {} diff --git a/tests/Stubs/TaggedDatabaseLogger.php b/tests/Stubs/TaggedDatabaseLogger.php new file mode 100644 index 0000000..42a70a0 --- /dev/null +++ b/tests/Stubs/TaggedDatabaseLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TaggedDatabaseLogger implements TaggedLogger {} diff --git a/tests/Stubs/TaggedFactoryService.php b/tests/Stubs/TaggedFactoryService.php new file mode 100644 index 0000000..20d3a66 --- /dev/null +++ b/tests/Stubs/TaggedFactoryService.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TaggedFactoryService {} diff --git a/tests/Stubs/TaggedFileLogger.php b/tests/Stubs/TaggedFileLogger.php new file mode 100644 index 0000000..0714453 --- /dev/null +++ b/tests/Stubs/TaggedFileLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TaggedFileLogger implements TaggedLogger {} diff --git a/tests/Stubs/TaggedInjectionCircularA.php b/tests/Stubs/TaggedInjectionCircularA.php new file mode 100644 index 0000000..0576045 --- /dev/null +++ b/tests/Stubs/TaggedInjectionCircularA.php @@ -0,0 +1,28 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\Tagged; + +/** + * @since 1.0.0 + */ +final readonly class TaggedInjectionCircularA { + + public function __construct ( + #[Tagged('circular')] + public array $dependencies + ) {} + +} diff --git a/tests/Stubs/TaggedInjectionCircularB.php b/tests/Stubs/TaggedInjectionCircularB.php new file mode 100644 index 0000000..1148b0b --- /dev/null +++ b/tests/Stubs/TaggedInjectionCircularB.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TaggedInjectionCircularB { + + public function __construct ( + public TaggedInjectionCircularA $dependency + ) {} + +} diff --git a/tests/Stubs/TaggedInjectionConsumer.php b/tests/Stubs/TaggedInjectionConsumer.php new file mode 100644 index 0000000..9a157b0 --- /dev/null +++ b/tests/Stubs/TaggedInjectionConsumer.php @@ -0,0 +1,31 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\Tagged; + +/** + * @since 1.0.0 + */ +final readonly class TaggedInjectionConsumer { + + /** + * @param list $loggers + */ + public function __construct ( + #[Tagged('logging')] + public array $loggers + ) {} + +} diff --git a/tests/Stubs/TaggedInjectionDefaultConsumer.php b/tests/Stubs/TaggedInjectionDefaultConsumer.php new file mode 100644 index 0000000..8ad0490 --- /dev/null +++ b/tests/Stubs/TaggedInjectionDefaultConsumer.php @@ -0,0 +1,28 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\Tagged; + +/** + * @since 1.0.0 + */ +final readonly class TaggedInjectionDefaultConsumer { + + public function __construct ( + #[Tagged('logging')] + public array $loggers = [] + ) {} + +} diff --git a/tests/Stubs/TaggedInjectionEmptyTagConsumer.php b/tests/Stubs/TaggedInjectionEmptyTagConsumer.php new file mode 100644 index 0000000..0c15f43 --- /dev/null +++ b/tests/Stubs/TaggedInjectionEmptyTagConsumer.php @@ -0,0 +1,26 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TaggedInjectionEmptyTagConsumer { + + public function __construct ( + #[Tagged('')] + public array $services + ) {} + +} diff --git a/tests/Stubs/TaggedInjectionNestedConsumer.php b/tests/Stubs/TaggedInjectionNestedConsumer.php new file mode 100644 index 0000000..9fd2312 --- /dev/null +++ b/tests/Stubs/TaggedInjectionNestedConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TaggedInjectionNestedConsumer { + + public function __construct ( + public TaggedInjectionConsumer $consumer + ) {} + +} diff --git a/tests/Stubs/TaggedInjectionNullableConsumer.php b/tests/Stubs/TaggedInjectionNullableConsumer.php new file mode 100644 index 0000000..881022a --- /dev/null +++ b/tests/Stubs/TaggedInjectionNullableConsumer.php @@ -0,0 +1,26 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TaggedInjectionNullableConsumer { + + public function __construct ( + #[Tagged('logging')] + public ?array $loggers + ) {} + +} diff --git a/tests/Stubs/TaggedInjectionScalarConsumer.php b/tests/Stubs/TaggedInjectionScalarConsumer.php new file mode 100644 index 0000000..03d83a7 --- /dev/null +++ b/tests/Stubs/TaggedInjectionScalarConsumer.php @@ -0,0 +1,26 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TaggedInjectionScalarConsumer { + + public function __construct ( + #[Tagged('logging')] + public string $loggers + ) {} + +} diff --git a/tests/Stubs/TaggedInjectionUnknownConsumer.php b/tests/Stubs/TaggedInjectionUnknownConsumer.php new file mode 100644 index 0000000..cbd872f --- /dev/null +++ b/tests/Stubs/TaggedInjectionUnknownConsumer.php @@ -0,0 +1,28 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\Tagged; + +/** + * @since 1.0.0 + */ +final readonly class TaggedInjectionUnknownConsumer { + + public function __construct ( + #[Tagged('unknown')] + public array $services + ) {} + +} diff --git a/tests/Stubs/TaggedInjectionUntypedConsumer.php b/tests/Stubs/TaggedInjectionUntypedConsumer.php new file mode 100644 index 0000000..8011df9 --- /dev/null +++ b/tests/Stubs/TaggedInjectionUntypedConsumer.php @@ -0,0 +1,26 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final class TaggedInjectionUntypedConsumer { + + public function __construct ( + #[Tagged('logging')] + public $loggers + ) {} + +} diff --git a/tests/Stubs/TaggedInjectionWhitespaceTagConsumer.php b/tests/Stubs/TaggedInjectionWhitespaceTagConsumer.php new file mode 100644 index 0000000..a89230f --- /dev/null +++ b/tests/Stubs/TaggedInjectionWhitespaceTagConsumer.php @@ -0,0 +1,26 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TaggedInjectionWhitespaceTagConsumer { + + public function __construct ( + #[Tagged(' ')] + public array $services + ) {} + +} diff --git a/tests/Stubs/TaggedLogger.php b/tests/Stubs/TaggedLogger.php new file mode 100644 index 0000000..cd22d5d --- /dev/null +++ b/tests/Stubs/TaggedLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface TaggedLogger {} diff --git a/tests/Stubs/TaggedScopedService.php b/tests/Stubs/TaggedScopedService.php new file mode 100644 index 0000000..1535fe7 --- /dev/null +++ b/tests/Stubs/TaggedScopedService.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TaggedScopedService {} diff --git a/tests/Stubs/TaggedSingletonService.php b/tests/Stubs/TaggedSingletonService.php new file mode 100644 index 0000000..1097014 --- /dev/null +++ b/tests/Stubs/TaggedSingletonService.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TaggedSingletonService {} diff --git a/tests/Stubs/TaggedTransientService.php b/tests/Stubs/TaggedTransientService.php new file mode 100644 index 0000000..f6997f4 --- /dev/null +++ b/tests/Stubs/TaggedTransientService.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TaggedTransientService {} diff --git a/tests/Stubs/TaggedUnregisteredService.php b/tests/Stubs/TaggedUnregisteredService.php new file mode 100644 index 0000000..e01d3a5 --- /dev/null +++ b/tests/Stubs/TaggedUnregisteredService.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TaggedUnregisteredService {} diff --git a/tests/Stubs/TestConsoleLogger.php b/tests/Stubs/TestConsoleLogger.php new file mode 100644 index 0000000..2706f59 --- /dev/null +++ b/tests/Stubs/TestConsoleLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TestConsoleLogger implements TestLogger {} diff --git a/tests/Stubs/TestFileLogger.php b/tests/Stubs/TestFileLogger.php new file mode 100644 index 0000000..27cf9c6 --- /dev/null +++ b/tests/Stubs/TestFileLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class TestFileLogger implements TestLogger, TestResettable {} diff --git a/tests/Stubs/TestLogger.php b/tests/Stubs/TestLogger.php new file mode 100644 index 0000000..334fe70 --- /dev/null +++ b/tests/Stubs/TestLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface TestLogger {} diff --git a/tests/Stubs/TestResettable.php b/tests/Stubs/TestResettable.php new file mode 100644 index 0000000..ac70612 --- /dev/null +++ b/tests/Stubs/TestResettable.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface TestResettable {} diff --git a/tests/Stubs/UnionA.php b/tests/Stubs/UnionA.php new file mode 100644 index 0000000..418483a --- /dev/null +++ b/tests/Stubs/UnionA.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class UnionA {} diff --git a/tests/Stubs/UnionAmbiguous.php b/tests/Stubs/UnionAmbiguous.php new file mode 100644 index 0000000..4309043 --- /dev/null +++ b/tests/Stubs/UnionAmbiguous.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class UnionAmbiguous { + + public function __construct ( + public UnionA|UnionB $dependency + ) {} + +} diff --git a/tests/Stubs/UnionB.php b/tests/Stubs/UnionB.php new file mode 100644 index 0000000..d2cd3da --- /dev/null +++ b/tests/Stubs/UnionB.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class UnionB {} diff --git a/tests/Stubs/UnionInterface.php b/tests/Stubs/UnionInterface.php new file mode 100644 index 0000000..ab0adb6 --- /dev/null +++ b/tests/Stubs/UnionInterface.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface UnionInterface {} diff --git a/tests/Stubs/UnionNullable.php b/tests/Stubs/UnionNullable.php new file mode 100644 index 0000000..5cbc812 --- /dev/null +++ b/tests/Stubs/UnionNullable.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class UnionNullable { + + public function __construct ( + public UnionInterface|UnionB|null $dependency = null + ) {} + +} diff --git a/tests/Stubs/UnionOnlyA.php b/tests/Stubs/UnionOnlyA.php new file mode 100644 index 0000000..a8c0e29 --- /dev/null +++ b/tests/Stubs/UnionOnlyA.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class UnionOnlyA { + + public function __construct ( + public UnionA|UnionInterface $dependency + ) {} + +} diff --git a/tests/Stubs/UserService.php b/tests/Stubs/UserService.php new file mode 100644 index 0000000..2727e51 --- /dev/null +++ b/tests/Stubs/UserService.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class UserService { + + public function __construct ( + public Repository $repository + ) {} + +} diff --git a/tests/Stubs/VariadicService.php b/tests/Stubs/VariadicService.php new file mode 100644 index 0000000..26b5067 --- /dev/null +++ b/tests/Stubs/VariadicService.php @@ -0,0 +1,30 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class VariadicService { + + /** @var list */ + public array $loggers; + + public function __construct (Logger ...$loggers) { + + $this->loggers = $loggers; + + } + +} diff --git a/tests/Support/ExceptionAssertions.php b/tests/Support/ExceptionAssertions.php new file mode 100644 index 0000000..45fd3bf --- /dev/null +++ b/tests/Support/ExceptionAssertions.php @@ -0,0 +1,53 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.4 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Support; + +use Closure, Throwable; + +/** + * ### Assertions for exceptions inside nested callbacks + * + * Allows tests to validate expected failures and continue asserting state afterward. + * @since 1.0.0 + */ +trait ExceptionAssertions { + + /** + * ### Asserts that a callback throws an exception of the expected type + * @since 1.0.0 + * + * @param class-string $expected Expected exception class. + * @param Closure():mixed $callback Callback expected to throw. + * + * @return void + */ + private function assertThrows (string $expected, Closure $callback):void { + + try { + + $callback(); + + } catch (Throwable $exception) { + + self::assertInstanceOf($expected, $exception); + + return; + + } + + self::fail("Expected exception {$expected} was not thrown."); + + } + +} diff --git a/tests/Unit/AutowiringTest.php b/tests/Unit/AutowiringTest.php new file mode 100644 index 0000000..70d8214 --- /dev/null +++ b/tests/Unit/AutowiringTest.php @@ -0,0 +1,580 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Unit; + +use FireHub\Testing\FireHubTestCase; +use FireHub\Container\Container; +use FireHub\Container\Exception\ { + AmbiguousDependencyException, CircularDependencyException, ServiceInstantiationException, + ServiceNotFoundException, ScopeRequiredException +}; +use FireHub\Tests\Container\Support\ExceptionAssertions; +use FireHub\Tests\Container\Stubs\ { + AbstractLogger, AnotherTestLogger, CircularA, IntersectionConsumer, Logger, LoggerInterface, MixedDefault, + MixedRequired, NullableConcrete, NullableDefault, NullableRequired, Repository, ScalarDefault, ScalarRequired, + ScopedConsumer, ScopedDependency, SingletonConsumer, TestFileLogger, TestLogger, UnionA, UnionAmbiguous, UnionB, + UnionInterface, UnionNullable, UnionOnlyA, UserService, VariadicService +}; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests constructor dependency autowiring + * @since 1.0.0 + */ +#[Small] +#[Group('container')] +#[CoversClass(Container::class)] +final class AutowiringTest extends FireHubTestCase { + + use ExceptionAssertions; + + /** + * ### Implicit autowiring resolves nested dependencies + * @since 1.0.0 + * + * @return void + */ + public function testImplicitAutowiringResolvesNestedDependencies ():void { + + $container = new Container(); + + $service = $container->get(UserService::class); + + self::assertInstanceOf(UserService::class, $service); + self::assertInstanceOf(Repository::class, $service->repository); + self::assertInstanceOf(Logger::class, $service->repository->logger); + + } + + /** + * ### Implicit services are transient + * @since 1.0.0 + * + * @return void + */ + public function testImplicitServicesAreTransient ():void { + + $container = new Container(); + + $first = $container->get(UserService::class); + $second = $container->get(UserService::class); + + self::assertNotSame($first, $second); + self::assertNotSame($first->repository, $second->repository); + self::assertNotSame($first->repository->logger, $second->repository->logger); + + } + + /** + * ### Implicit resolution does not register services + * @since 1.0.0 + * + * @return void + */ + public function testImplicitResolutionDoesNotRegisterServices ():void { + + $container = new Container(); + + self::assertNotTrue($container->has(UserService::class)); + + $container->get(UserService::class); + + self::assertNotTrue($container->has(UserService::class)); + + } + + /** + * ### Unregistered interfaces cannot be instantiated + * @since 1.0.0 + * + * @return void + */ + public function testUnregisteredInterfacesCannotBeInstantiated ():void { + + $this->assertThrows(ServiceNotFoundException::class, function ():void { + + new Container()->get(LoggerInterface::class); + + }); + + } + + /** + * ### Abstract classes cannot be instantiated + * @since 1.0.0 + * + * @return void + */ + public function testAbstractClassesCannotBeInstantiated ():void { + + $this->assertThrows(ServiceNotFoundException::class, function ():void { + + new Container()->get(AbstractLogger::class); + + }); + + } + + /** + * ### Required scalar parameters fail + * @since 1.0.0 + * + * @return void + */ + public function testRequiredScalarParametersFail ():void { + + $this->assertThrows(ServiceInstantiationException::class, function ():void { + + new Container()->get(ScalarRequired::class); + + }); + + } + + /** + * ### Scalar parameters use default values + * @since 1.0.0 + * + * @return void + */ + public function testScalarParametersUseDefaultValues ():void { + + $service = new Container()->get(ScalarDefault::class); + + self::assertSame(8080, $service->port); + self::assertSame('localhost', $service->host); + + } + + /** + * ### Nullable dependencies without defaults remain required + * @since 1.0.0 + * + * @return void + */ + public function testNullableDependenciesWithoutDefaultsRemainRequired ():void { + + $this->assertThrows(ServiceInstantiationException::class, function ():void { + + new Container()->get(NullableRequired::class); + + }); + + } + + /** + * ### Nullable dependencies use null defaults + * @since 1.0.0 + * + * @return void + */ + public function testNullableDependenciesUseNullDefaults ():void { + + $service = new Container()->get(NullableDefault::class); + + self::assertNull($service->logger); + + } + + /** + * ### Nullable concrete dependencies are autowired + * @since 1.0.0 + * + * @return void + */ + public function testNullableConcreteDependenciesAreAutowired ():void { + + $service = new Container()->get(NullableConcrete::class); + + self::assertInstanceOf(Logger::class, $service->logger); + + } + + /** + * ### Variadic parameters accept zero dependencies + * @since 1.0.0 + * + * @return void + */ + public function testVariadicParametersAcceptZeroDependencies ():void { + + $service = new Container()->get(VariadicService::class); + + self::assertSame($service->loggers, []); + + } + + /** + * ### Required mixed parameters fail + * @since 1.0.0 + * + * @return void + */ + public function testRequiredMixedParametersFail ():void { + + $this->assertThrows(ServiceInstantiationException::class, function ():void { + + new Container()->get(MixedRequired::class); + + }); + + } + + /** + * ### Mixed parameters use defaults + * @since 1.0.0 + * + * @return void + */ + public function testMixedParametersUseDefaults ():void { + + $service = new Container()->get(MixedDefault::class); + + self::assertNull($service->value); + + } + + /** + * ### Circular dependencies are detected + * @since 1.0.0 + * + * @return void + */ + public function testCircularDependenciesAreDetected ():void { + + $this->assertThrows(CircularDependencyException::class, function ():void { + + new Container()->get(CircularA::class); + + }); + + } + + /** + * ### Explicit singletons are reused inside implicit graphs + * @since 1.0.0 + * + * @return void + */ + public function testExplicitSingletonsAreReusedInsideImplicitGraphs ():void { + + $container = new Container(); + + $container->singleton(Logger::class); + + $first = $container->get(UserService::class); + $second = $container->get(UserService::class); + + self::assertNotSame($first, $second); + self::assertSame($first->repository->logger, $second->repository->logger); + + } + + /** + * ### Explicit singleton consumers retain transient dependencies + * @since 1.0.0 + * + * @return void + */ + public function testExplicitSingletonConsumersRetainTransientDependencies ():void { + + $container = new Container(); + + $container->singleton(SingletonConsumer::class); + + $first = $container->get(SingletonConsumer::class); + $second = $container->get(SingletonConsumer::class); + + self::assertSame($first, $second); + self::assertSame($first->logger, $second->logger); + + } + + /** + * ### Scoped dependencies require an active scope + * @since 1.0.0 + * + * @return void + */ + public function testScopedDependenciesRequireAnActiveScope ():void { + + $container = new Container(); + + $container->scoped(ScopedDependency::class); + + $this->assertThrows(ScopeRequiredException::class, function () use ($container):void { + + $container->get(ScopedConsumer::class); + + }); + + } + + /** + * ### Scoped dependencies resolve within a scope + * @since 1.0.0 + * + * @return void + */ + public function testScopedDependenciesResolveWithinAScope ():void { + + $container = new Container(); + + $container->scoped(ScopedDependency::class); + + $scope = $container->createScope(); + + $first = $scope->get(ScopedConsumer::class); + $second = $scope->get(ScopedConsumer::class); + + self::assertNotSame($first, $second); + self::assertSame($first->dependency, $second->dependency); + + $scope->close(); + + } + + /** + * ### Implicit autowiring works after freezing the container + * @since 1.0.0 + * + * @return void + */ + public function testImplicitAutowiringWorksAfterFreezingTheContainer ():void { + + $container = new Container(); + + $container->freeze(); + + $service = $container->get(UserService::class); + + self::assertInstanceOf(UserService::class, $service); + self::assertTrue($container->isFrozen()); + + } + + /** + * ### Union resolves a single implicit candidate + * @since 1.0.0 + * + * @return void + */ + public function testUnionResolvesASingleImplicitCandidate ():void { + + $service = new Container()->get(UnionOnlyA::class); + + self::assertInstanceOf(UnionA::class, $service->dependency); + + } + + /** + * ### Union rejects multiple implicit candidates + * @since 1.0.0 + * + * @return void + */ + public function testUnionRejectsMultipleImplicitCandidates ():void { + + $this->assertThrows(AmbiguousDependencyException::class, function ():void { + + new Container()->get(UnionAmbiguous::class); + + }); + + } + + /** + * ### Union prefers an explicit registration + * @since 1.0.0 + * + * @return void + */ + public function testUnionPrefersAnExplicitRegistration ():void { + + $container = new Container(); + + $container->singleton(UnionB::class); + + $service = $container->get(UnionAmbiguous::class); + + self::assertInstanceOf(UnionB::class, $service->dependency); + + } + + /** + * ### Union rejects multiple explicit registrations + * @since 1.0.0 + * + * @return void + */ + public function testUnionRejectsMultipleExplicitRegistrations ():void { + + $container = new Container(); + + $container->singleton(UnionA::class); + $container->singleton(UnionB::class); + + $this->assertThrows(AmbiguousDependencyException::class, function () use ($container):void { + + $container->get(UnionAmbiguous::class); + + }); + + } + + /** + * ### Union resolves an implicit candidate before using null default + * @since 1.0.0 + * + * @return void + */ + public function testUnionResolvesAnImplicitCandidateBeforeUsingNullDefault ():void { + + $service = new Container()->get(UnionNullable::class); + + self::assertInstanceOf(UnionB::class, $service->dependency); + + } + + /** + * ### Intersection resolves a unique registered implementation + * @since 1.0.0 + * + * @return void + */ + public function testIntersectionResolvesAUniqueRegisteredImplementation ():void { + + $container = new Container(); + + $container->bind( + TestLogger::class, + TestFileLogger::class + ); + + $service = $container->get(IntersectionConsumer::class); + + self::assertInstanceOf(TestFileLogger::class, $service->logger); + + } + + /** + * ### Intersection fails without matching implementations + * @since 1.0.0 + * + * @return void + */ + public function testIntersectionFailsWithoutMatchingImplementations ():void { + + $this->assertThrows(ServiceInstantiationException::class, function ():void { + + new Container()->get(IntersectionConsumer::class); + + }); + + } + + /** + * ### Intersection rejects multiple matching implementations + * @since 1.0.0 + * + * @return void + */ + public function testIntersectionRejectsMultipleMatchingImplementations ():void { + + $container = new Container(); + + $container->bind(TestFileLogger::class, TestFileLogger::class); + $container->bind(AnotherTestLogger::class, AnotherTestLogger::class); + + $this->assertThrows(AmbiguousDependencyException::class, function () use ($container):void { + + $container->get(IntersectionConsumer::class); + + }); + + } + + /** + * ### Intersection does not execute factories during candidate discovery + * @since 1.0.0 + * + * @return void + */ + public function testIntersectionDoesNotExecuteFactoriesDuringCandidateDiscovery ():void { + + $container = new Container(); + + $executed = false; + + $container->factory( + TestLogger::class, + function ($resolver) use (&$executed):object { + + $executed = true; + + return new TestFileLogger(); + + } + ); + + $this->assertThrows(ServiceInstantiationException::class, function () use ($container):void { + + $container->get(IntersectionConsumer::class); + + }); + + self::assertFalse($executed); + + } + + /** + * ### Metadata caching preserves transient dependencies + * @since 1.0.0 + * + * @return void + */ + public function testMetadataCachingPreservesTransientDependencies ():void { + + $container = new Container(); + + $first = $container->get(UserService::class); + $second = $container->get(UserService::class); + + self::assertNotSame($first, $second); + self::assertNotSame($first->repository, $second->repository); + self::assertNotSame($first->repository->logger, $second->repository->logger); + + } + + /** + * ### Metadata caching respects new registrations + * @since 1.0.0 + * + * @return void + */ + public function testMetadataCachingRespectsNewRegistrations ():void { + + $container = new Container(); + + $first = $container->get(UnionOnlyA::class); + + self::assertInstanceOf(UnionA::class, $first->dependency); + + $container->singleton(UnionInterface::class, UnionA::class); + + } + +} diff --git a/tests/Unit/ContextualBindingTest.php b/tests/Unit/ContextualBindingTest.php new file mode 100644 index 0000000..24a4834 --- /dev/null +++ b/tests/Unit/ContextualBindingTest.php @@ -0,0 +1,1018 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Unit; + +use FireHub\Testing\FireHubTestCase; +use FireHub\Container\Container; +use FireHub\Container\Exception\ { + AmbiguousDependencyException, CircularDependencyException, ServiceInstantiationException, ScopeRequiredException +}; +use FireHub\Tests\Container\Support\ExceptionAssertions; +use FireHub\Tests\Container\Stubs\ { + ContextualCache, ContextualCacheService, ContextualCircularA, ContextualCircularB, ContextualConsoleLogger, + ContextualDefaultConsumer, ContextualDnfConsumer, ContextualFileLogger, ContextualIntersectionConsumer, + ContextualLogger, ContextualNamedConsumer, ContextualNestedConsumer, ContextualOtherConsumer, ContextualResettable, + ContextualScopedConsumer, ContextualSerializableCache, ContextualSingletonConsumer, + ContextualTwoIntersectionsConsumer, ContextualUnionConsumer, ContextualUniversalService +}; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests contextual dependency bindings + * @since 1.0.0 + */ +#[Small] +#[Group('container')] +#[CoversClass(Container::class)] +final class ContextualBindingTest extends FireHubTestCase { + + use ExceptionAssertions; + + /** + * ### Contextual binding resolves a named dependency + * @since 1.0.0 + * + * @return void + */ + public function testContextualBindingResolvesANamedDependency ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $service = $container->get(ContextualNamedConsumer::class); + + self::assertInstanceOf(ContextualFileLogger::class, $service->logger); + + } + + /** + * ### Contextual bindings are isolated by consumer class + * @since 1.0.0 + * + * @return void + */ + public function testContextualBindingsAreIsolatedByConsumerClass ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $this->assertThrows( + ServiceInstantiationException::class, + function () use ($container):void { + + $container->get(ContextualOtherConsumer::class); + + } + ); + + } + + /** + * ### Different consumers resolve different contextual implementations + * @since 1.0.0 + * + * @return void + */ + public function testDifferentConsumersResolveDifferentContextualImplementations ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $container->bindContextual( + ContextualOtherConsumer::class, + ContextualLogger::class, + ContextualConsoleLogger::class + ); + + $first = $container->get(ContextualNamedConsumer::class); + $second = $container->get(ContextualOtherConsumer::class); + + self::assertInstanceOf(ContextualFileLogger::class, $first->logger); + self::assertInstanceOf(ContextualConsoleLogger::class, $second->logger); + + } + + /** + * ### Contextual binding overrides global dependency registration + * @since 1.0.0 + * + * @return void + */ + public function testContextualBindingOverridesGlobalDependencyRegistration ():void { + + $container = new Container(); + + $container->bind( + ContextualLogger::class, + ContextualConsoleLogger::class + ); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $service = $container->get(ContextualNamedConsumer::class); + + self::assertInstanceOf(ContextualFileLogger::class, $service->logger); + + } + + /** + * ### Explicit parameter binding overrides contextual binding + * @since 1.0.0 + * + * @return void + */ + public function testExplicitParameterBindingOverridesContextualBinding ():void { + + $container = new Container(); + + $logger = new ContextualConsoleLogger(); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $container->bindParameters( + ContextualNamedConsumer::class, + [ + 'logger' => $logger + ] + ); + + $service = $container->get(ContextualNamedConsumer::class); + + self::assertSame($service->logger, $logger); + + } + + /** + * ### Contextual binding resolves inside nested dependency graphs + * @since 1.0.0 + * + * @return void + */ + public function testContextualBindingResolvesInsideNestedDependencyGraphs ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $service = $container->get(ContextualNestedConsumer::class); + + self::assertInstanceOf(ContextualNamedConsumer::class, $service->consumer); + self::assertInstanceOf(ContextualFileLogger::class, $service->consumer->logger); + + } + + /** + * ### Contextual union resolves the first alternative + * @since 1.0.0 + * + * @return void + */ + public function testContextualUnionResolvesTheFirstAlternative ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualUnionConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $service = $container->get(ContextualUnionConsumer::class); + + self::assertInstanceOf(ContextualFileLogger::class, $service->dependency); + + } + + /** + * ### Contextual union resolves the second alternative + * @since 1.0.0 + * + * @return void + */ + public function testContextualUnionResolvesTheSecondAlternative ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualUnionConsumer::class, + ContextualCache::class, + ContextualCacheService::class + ); + + $service = $container->get(ContextualUnionConsumer::class); + + self::assertInstanceOf(ContextualCacheService::class, $service->dependency); + + } + + /** + * ### Contextual union rejects different implementations + * @since 1.0.0 + * + * @return void + */ + public function testContextualUnionRejectsDifferentImplementations ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualUnionConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $container->bindContextual( + ContextualUnionConsumer::class, + ContextualCache::class, + ContextualCacheService::class + ); + + $this->assertThrows( + AmbiguousDependencyException::class, + function () use ($container):void { + + $container->get(ContextualUnionConsumer::class); + + } + ); + + } + + /** + * ### Contextual union deduplicates identical implementations + * @since 1.0.0 + * + * @return void + */ + public function testContextualUnionDeduplicatesIdenticalImplementations ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualUnionConsumer::class, + ContextualLogger::class, + ContextualUniversalService::class + ); + + $container->bindContextual( + ContextualUnionConsumer::class, + ContextualCache::class, + ContextualUniversalService::class + ); + + $service = $container->get(ContextualUnionConsumer::class); + + self::assertInstanceOf(ContextualUniversalService::class, $service->dependency); + + } + + /** + * ### Contextual union takes precedence over standard candidates + * @since 1.0.0 + * + * @return void + */ + public function testContextualUnionTakesPrecedenceOverStandardCandidates ():void { + + $container = new Container(); + + $container->singleton(ContextualCacheService::class); + + $container->bindContextual( + ContextualUnionConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $service = $container->get(ContextualUnionConsumer::class); + + self::assertInstanceOf(ContextualFileLogger::class, $service->dependency); + + } + + /** + * ### Contextual intersection resolves a compatible implementation + * @since 1.0.0 + * + * @return void + */ + public function testContextualIntersectionResolvesACompatibleImplementation ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualIntersectionConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $service = $container->get(ContextualIntersectionConsumer::class); + + self::assertInstanceOf(ContextualLogger::class, $service->dependency); + self::assertInstanceOf(ContextualResettable::class, $service->dependency); + + } + + /** + * ### Contextual intersection deduplicates identical implementations + * @since 1.0.0 + * + * @return void + */ + public function testContextualIntersectionDeduplicatesIdenticalImplementations ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualIntersectionConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $container->bindContextual( + ContextualIntersectionConsumer::class, + ContextualResettable::class, + ContextualFileLogger::class + ); + + $service = $container->get(ContextualIntersectionConsumer::class); + + self::assertInstanceOf(ContextualFileLogger::class, $service->dependency); + + } + + /** + * ### Contextual intersection rejects different implementations + * @since 1.0.0 + * + * @return void + */ + public function testContextualIntersectionRejectsDifferentImplementations ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualIntersectionConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $container->bindContextual( + ContextualIntersectionConsumer::class, + ContextualResettable::class, + ContextualUniversalService::class + ); + + $this->assertThrows( + AmbiguousDependencyException::class, + function () use ($container):void { + + $container->get(ContextualIntersectionConsumer::class); + + } + ); + + } + + /** + * ### Contextual intersection rejects incompatible implementations + * @since 1.0.0 + * + * @return void + */ + public function testContextualIntersectionRejectsIncompatibleImplementations ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualIntersectionConsumer::class, + ContextualLogger::class, + ContextualConsoleLogger::class + ); + + $this->assertThrows( + ServiceInstantiationException::class, + function () use ($container):void { + + $container->get(ContextualIntersectionConsumer::class); + + } + ); + + } + + /** + * ### Invalid contextual intersection does not fall back to global candidates + * @since 1.0.0 + * + * @return void + */ + public function testInvalidContextualIntersectionDoesNotFallBackToGlobalCandidates ():void { + + $container = new Container(); + + $container->bind( + ContextualFileLogger::class, + ContextualFileLogger::class + ); + + $container->bindContextual( + ContextualIntersectionConsumer::class, + ContextualLogger::class, + ContextualConsoleLogger::class + ); + + $this->assertThrows( + ServiceInstantiationException::class, + function () use ($container):void { + + $container->get(ContextualIntersectionConsumer::class); + + } + ); + + } + + /** + * ### Contextual DNF resolves an intersection branch + * @since 1.0.0 + * + * @return void + */ + public function testContextualDnfResolvesAnIntersectionBranch ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualDnfConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $service = $container->get(ContextualDnfConsumer::class); + + self::assertInstanceOf(ContextualFileLogger::class, $service->dependency); + + } + + /** + * ### Contextual DNF resolves a named branch + * @since 1.0.0 + * + * @return void + */ + public function testContextualDnfResolvesANamedBranch ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualDnfConsumer::class, + ContextualCache::class, + ContextualCacheService::class + ); + + $service = $container->get(ContextualDnfConsumer::class); + + self::assertInstanceOf(ContextualCacheService::class, $service->dependency); + + } + + /** + * ### Contextual DNF resolves the second intersection branch + * @since 1.0.0 + * + * @return void + */ + public function testContextualDnfResolvesTheSecondIntersectionBranch ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualTwoIntersectionsConsumer::class, + ContextualCache::class, + ContextualSerializableCache::class + ); + + $service = $container->get(ContextualTwoIntersectionsConsumer::class); + + self::assertInstanceOf(ContextualSerializableCache::class, $service->dependency); + + } + + /** + * ### Contextual DNF rejects multiple distinct implementations + * @since 1.0.0 + * + * @return void + */ + public function testContextualDnfRejectsMultipleDistinctImplementations ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualDnfConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $container->bindContextual( + ContextualDnfConsumer::class, + ContextualCache::class, + ContextualCacheService::class + ); + + $this->assertThrows( + AmbiguousDependencyException::class, + function () use ($container):void { + + $container->get(ContextualDnfConsumer::class); + + } + ); + + } + + /** + * ### Contextual DNF deduplicates a universal implementation + * @since 1.0.0 + * + * @return void + */ + public function testContextualDnfDeduplicatesAUniversalImplementation ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualTwoIntersectionsConsumer::class, + ContextualLogger::class, + ContextualUniversalService::class + ); + + $container->bindContextual( + ContextualTwoIntersectionsConsumer::class, + ContextualCache::class, + ContextualUniversalService::class + ); + + $service = $container->get(ContextualTwoIntersectionsConsumer::class); + + self::assertInstanceOf(ContextualUniversalService::class, $service->dependency); + + } + + /** + * ### Contextual DNF rejects implementations incompatible with all branches + * @since 1.0.0 + * + * @return void + */ + public function testContextualDnfRejectsImplementationsIncompatibleWithAllBranches ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualTwoIntersectionsConsumer::class, + ContextualLogger::class, + ContextualConsoleLogger::class + ); + + $this->assertThrows( + ServiceInstantiationException::class, + function () use ($container):void { + + $container->get(ContextualTwoIntersectionsConsumer::class); + + } + ); + + } + + /** + * ### Contextual dependencies are transient by default + * @since 1.0.0 + * + * @return void + */ + public function testContextualDependenciesAreTransientByDefault ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $first = $container->get(ContextualNamedConsumer::class); + $second = $container->get(ContextualNamedConsumer::class); + + self::assertNotSame($first, $second); + self::assertNotSame($first->logger, $second->logger); + + } + + /** + * ### Contextual bindings preserve singleton lifetimes + * @since 1.0.0 + * + * @return void + */ + public function testContextualBindingsPreserveSingletonLifetimes ():void { + + $container = new Container(); + + $container->singleton(ContextualFileLogger::class); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $first = $container->get(ContextualNamedConsumer::class); + $second = $container->get(ContextualNamedConsumer::class); + + self::assertNotSame($first, $second); + self::assertSame($first->logger, $second->logger); + + } + + /** + * ### Contextual scoped dependencies require an active scope + * @since 1.0.0 + * + * @return void + */ + public function testContextualScopedDependenciesRequireAnActiveScope ():void { + + $container = new Container(); + + $container->scoped(ContextualFileLogger::class); + + $container->bindContextual( + ContextualScopedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $this->assertThrows( + ScopeRequiredException::class, + function () use ($container):void { + + $container->get(ContextualScopedConsumer::class); + + } + ); + + } + + /** + * ### Contextual scoped dependencies are reused within a scope + * @since 1.0.0 + * + * @return void + */ + public function testContextualScopedDependenciesAreReusedWithinAScope ():void { + + $container = new Container(); + + $container->scoped(ContextualFileLogger::class); + + $container->bindContextual( + ContextualScopedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $scope = $container->createScope(); + + $first = $scope->get(ContextualScopedConsumer::class); + $second = $scope->get(ContextualScopedConsumer::class); + + self::assertNotSame($first, $second); + self::assertSame($first->logger, $second->logger); + + $scope->close(); + + } + + /** + * ### Contextual scoped dependencies are isolated between scopes + * @since 1.0.0 + * + * @return void + */ + public function testContextualScopedDependenciesAreIsolatedBetweenScopes ():void { + + $container = new Container(); + + $container->scoped(ContextualFileLogger::class); + + $container->bindContextual( + ContextualScopedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $firstScope = $container->createScope(); + $secondScope = $container->createScope(); + + $first = $firstScope->get(ContextualScopedConsumer::class); + $second = $secondScope->get(ContextualScopedConsumer::class); + + self::assertNotSame($first->logger, $second->logger); + + $firstScope->close(); + $secondScope->close(); + + } + + /** + * ### Contextual singleton consumers retain their dependencies + * @since 1.0.0 + * + * @return void + */ + public function testContextualSingletonConsumersRetainTheirDependencies ():void { + + $container = new Container(); + + $container->singleton(ContextualSingletonConsumer::class); + + $container->bindContextual( + ContextualSingletonConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $first = $container->get(ContextualSingletonConsumer::class); + $second = $container->get(ContextualSingletonConsumer::class); + + self::assertSame($first, $second); + self::assertSame($first->logger, $second->logger); + + } + + /** + * ### Contextual binding overrides a nullable default + * @since 1.0.0 + * + * @return void + */ + public function testContextualBindingOverridesANullableDefault ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualDefaultConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $service = $container->get(ContextualDefaultConsumer::class); + + self::assertInstanceOf(ContextualFileLogger::class, $service->logger); + + } + + /** + * ### Contextual bindings added after resolution are recognized + * @since 1.0.0 + * + * @return void + */ + public function testContextualBindingsAddedAfterResolutionAreRecognized ():void { + + $container = new Container(); + + $first = $container->get(ContextualDefaultConsumer::class); + + self::assertTrue($first->logger === null); + + $container->bindContextual( + ContextualDefaultConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $second = $container->get(ContextualDefaultConsumer::class); + + self::assertInstanceOf(ContextualFileLogger::class, $second->logger); + + } + + /** + * ### Contextual union detects ambiguity after new registrations + * @since 1.0.0 + * + * @return void + */ + public function testContextualUnionDetectsAmbiguityAfterNewRegistrations ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualUnionConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $first = $container->get(ContextualUnionConsumer::class); + + self::assertInstanceOf(ContextualFileLogger::class, $first->dependency); + + $container->bindContextual( + ContextualUnionConsumer::class, + ContextualCache::class, + ContextualCacheService::class + ); + + $this->assertThrows( + AmbiguousDependencyException::class, + function () use ($container):void { + + $container->get(ContextualUnionConsumer::class); + + } + ); + + } + + /** + * ### Duplicate contextual bindings are rejected + * @since 1.0.0 + * + * @return void + */ + public function testDuplicateContextualBindingsAreRejected ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $this->assertThrows( + \FireHub\Container\Exception\DuplicateContextualBindingException::class, + function () use ($container):void { + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualConsoleLogger::class + ); + + } + ); + + } + + /** + * ### Contextual bindings cannot be registered after freezing + * @since 1.0.0 + * + * @return void + */ + public function testContextualBindingsCannotBeRegisteredAfterFreezing ():void { + + $container = new Container(); + + $container->freeze(); + + $this->assertThrows( + \FireHub\Container\Exception\ContainerFrozenException::class, + function () use ($container):void { + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + } + ); + + } + + /** + * ### Contextual bindings resolve after freezing + * @since 1.0.0 + * + * @return void + */ + public function testContextualBindingsResolveAfterFreezing ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $container->freeze(); + + $service = $container->get(ContextualNamedConsumer::class); + + self::assertInstanceOf(ContextualFileLogger::class, $service->logger); + self::assertTrue($container->isFrozen()); + + } + + /** + * ### Contextual resolution preserves circular dependency detection + * @since 1.0.0 + * + * @return void + */ + public function testContextualResolutionPreservesCircularDependencyDetection ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualCircularA::class, + ContextualCircularB::class, + ContextualCircularB::class + ); + + $this->assertThrows( + CircularDependencyException::class, + function () use ($container):void { + + $container->get(ContextualCircularA::class); + + } + ); + + } + + /** + * ### Contextual named binding rejects incompatible implementations + * @since 1.0.0 + * + * @return void + */ + public function testContextualNamedBindingRejectsIncompatibleImplementations ():void { + + $container = new Container(); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualCacheService::class + ); + + $this->assertThrows( + ServiceInstantiationException::class, + function () use ($container):void { + + $container->get(ContextualNamedConsumer::class); + + } + ); + + } + +} diff --git a/tests/Unit/DnfResolutionTest.php b/tests/Unit/DnfResolutionTest.php new file mode 100644 index 0000000..eb74cdb --- /dev/null +++ b/tests/Unit/DnfResolutionTest.php @@ -0,0 +1,777 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Unit; + +use FireHub\Testing\FireHubTestCase; +use FireHub\Container\Container; +use FireHub\Container\Exception\ { + AmbiguousDependencyException, ServiceInstantiationException, ScopeRequiredException, ServiceTypeMismatchException +}; +use FireHub\Tests\Container\Support\ExceptionAssertions; +use FireHub\Tests\Container\Stubs\ { + DnfCache, DnfCacheService, DnfConsoleLogger, DnfDefaultConsumer, DnfFactoryConsumer, DnfFileLogger, + DnfFirstBranchConsumer, DnfLogger, DnfMultipleNamedConsumer, DnfNestedConsumer, DnfNoCandidateConsumer, + DnfRequiredNullableConsumer, DnfResettable, DnfScopedConsumer, DnfSecondBranchConsumer, DnfSingletonConsumer, + DnfSerializableCache, DnfTwoIntersectionsConsumer, DnfUniversalService +}; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests DNF and composite dependency resolution + * @since 1.0.0 + */ +#[Small] +#[Group('container')] +#[CoversClass(Container::class)] +final class DnfResolutionTest extends FireHubTestCase { + + use ExceptionAssertions; + + /** + * ### DNF resolves the first intersection branch + * @since 1.0.0 + * + * @return void + */ + public function testDnfResolvesTheFirstIntersectionBranch ():void { + + $container = new Container(); + + $container->bind( + DnfFileLogger::class, + DnfFileLogger::class + ); + + $service = $container->get(DnfFirstBranchConsumer::class); + + self::assertInstanceOf(DnfFileLogger::class, $service->dependency); + self::assertInstanceOf(DnfLogger::class, $service->dependency); + self::assertInstanceOf(DnfResettable::class, $service->dependency); + + } + + /** + * ### DNF resolves the second named branch + * @since 1.0.0 + * + * @return void + */ + public function testDnfResolvesTheSecondNamedBranch ():void { + + $container = new Container(); + + $container->bind( + DnfCacheService::class, + DnfCacheService::class + ); + + $service = $container->get(DnfSecondBranchConsumer::class); + + self::assertInstanceOf(DnfCacheService::class, $service->dependency); + + } + + /** + * ### DNF resolves the first of two intersection branches + * @since 1.0.0 + * + * @return void + */ + public function testDnfResolvesTheFirstOfTwoIntersectionBranches ():void { + + $container = new Container(); + + $container->bind( + DnfFileLogger::class, + DnfFileLogger::class + ); + + $service = $container->get(DnfTwoIntersectionsConsumer::class); + + self::assertInstanceOf(DnfFileLogger::class, $service->dependency); + + } + + /** + * ### DNF resolves the second of two intersection branches + * @since 1.0.0 + * + * @return void + */ + public function testDnfResolvesTheSecondOfTwoIntersectionBranches ():void { + + $container = new Container(); + + $container->bind( + DnfSerializableCache::class, + DnfSerializableCache::class + ); + + $service = $container->get(DnfTwoIntersectionsConsumer::class); + + self::assertInstanceOf(DnfSerializableCache::class, $service->dependency); + + } + + /** + * ### DNF resolves a service satisfying multiple branches + * @since 1.0.0 + * + * @return void + */ + public function testDnfResolvesAServiceSatisfyingMultipleBranches ():void { + + $container = new Container(); + + $container->bind( + DnfUniversalService::class, + DnfUniversalService::class + ); + + $service = $container->get(DnfTwoIntersectionsConsumer::class); + + self::assertInstanceOf(DnfUniversalService::class, $service->dependency); + + } + + /** + * ### DNF resolves a named alternative among multiple branches + * @since 1.0.0 + * + * @return void + */ + public function testDnfResolvesANamedAlternativeAmongMultipleBranches ():void { + + $container = new Container(); + + $container->bind( + DnfCacheService::class, + DnfCacheService::class + ); + + $service = $container->get(DnfMultipleNamedConsumer::class); + + self::assertInstanceOf(DnfCacheService::class, $service->dependency); + + } + + /** + * ### DNF rejects multiple matching implementations + * @since 1.0.0 + * + * @return void + */ + public function testDnfRejectsMultipleMatchingImplementations ():void { + + $container = new Container(); + + $container->bind( + DnfFileLogger::class, + DnfFileLogger::class + ); + + $container->bind( + DnfCacheService::class, + DnfCacheService::class + ); + + $this->assertThrows( + AmbiguousDependencyException::class, + function () use ($container):void { + + $container->get(DnfFirstBranchConsumer::class); + + } + ); + + } + + /** + * ### DNF rejects multiple implementations satisfying the same branch + * @since 1.0.0 + * + * @return void + */ + public function testDnfRejectsMultipleImplementationsSatisfyingTheSameBranch ():void { + + $container = new Container(); + + $container->bind( + DnfFileLogger::class, + DnfFileLogger::class + ); + + $container->bind( + DnfUniversalService::class, + DnfUniversalService::class + ); + + $this->assertThrows( + AmbiguousDependencyException::class, + function () use ($container):void { + + $container->get(DnfFirstBranchConsumer::class); + + } + ); + + } + + /** + * ### DNF rejects multiple implementations across intersection branches + * @since 1.0.0 + * + * @return void + */ + public function testDnfRejectsMultipleImplementationsAcrossIntersectionBranches ():void { + + $container = new Container(); + + $container->bind( + DnfFileLogger::class, + DnfFileLogger::class + ); + + $container->bind( + DnfSerializableCache::class, + DnfSerializableCache::class + ); + + $this->assertThrows( + AmbiguousDependencyException::class, + function () use ($container):void { + + $container->get(DnfTwoIntersectionsConsumer::class); + + } + ); + + } + + /** + * ### DNF ignores implementations satisfying only part of an intersection + * @since 1.0.0 + * + * @return void + */ + public function testDnfIgnoresImplementationsSatisfyingOnlyPartOfAnIntersection ():void { + + $container = new Container(); + + $container->bind( + DnfConsoleLogger::class, + DnfConsoleLogger::class + ); + + $this->assertThrows( + ServiceInstantiationException::class, + function () use ($container):void { + + $container->get(DnfFirstBranchConsumer::class); + + } + ); + + } + + /** + * ### DNF ignores implementations satisfying neither branch + * @since 1.0.0 + * + * @return void + */ + public function testDnfIgnoresImplementationsSatisfyingNeitherBranch ():void { + + $container = new Container(); + + $container->bind( + DnfConsoleLogger::class, + DnfConsoleLogger::class + ); + + $this->assertThrows( + ServiceInstantiationException::class, + function () use ($container):void { + + $container->get(DnfNoCandidateConsumer::class); + + } + ); + + } + + /** + * ### DNF resolves a single candidate despite matching multiple branches + * @since 1.0.0 + * + * @return void + */ + public function testDnfResolvesASingleCandidateDespiteMatchingMultipleBranches ():void { + + $container = new Container(); + + $container->bind( + DnfUniversalService::class, + DnfUniversalService::class + ); + + $service = $container->get(DnfMultipleNamedConsumer::class); + + self::assertInstanceOf(DnfUniversalService::class, $service->dependency); + + } + + /** + * ### DNF uses null default when no candidate exists + * @since 1.0.0 + * + * @return void + */ + public function testDnfUsesNullDefaultWhenNoCandidateExists ():void { + + $service = new Container()->get(DnfDefaultConsumer::class); + + self::assertSame($service->dependency, null); + + } + + /** + * ### DNF resolves a candidate before using null default + * @since 1.0.0 + * + * @return void + */ + public function testDnfResolvesACandidateBeforeUsingNullDefault ():void { + + $container = new Container(); + + $container->bind( + DnfFileLogger::class, + DnfFileLogger::class + ); + + $service = $container->get(DnfDefaultConsumer::class); + + self::assertInstanceOf(DnfFileLogger::class, $service->dependency); + + } + + /** + * ### DNF nullable dependency without default remains required + * @since 1.0.0 + * + * @return void + */ + public function testDnfNullableDependencyWithoutDefaultRemainsRequired ():void { + + $this->assertThrows( + ServiceInstantiationException::class, + function ():void { + + new Container()->get(DnfRequiredNullableConsumer::class); + + } + ); + + } + + /** + * ### DNF nullable dependency resolves when candidate exists + * @since 1.0.0 + * + * @return void + */ + public function testDnfNullableDependencyResolvesWhenCandidateExists ():void { + + $container = new Container(); + + $container->bind( + DnfCacheService::class, + DnfCacheService::class + ); + + $service = $container->get(DnfRequiredNullableConsumer::class); + + self::assertInstanceOf(DnfCacheService::class, $service->dependency); + + } + + /** + * ### DNF resolves dependencies inside nested graphs + * @since 1.0.0 + * + * @return void + */ + public function testDnfResolvesDependenciesInsideNestedGraphs ():void { + + $container = new Container(); + + $container->bind( + DnfFileLogger::class, + DnfFileLogger::class + ); + + $service = $container->get(DnfNestedConsumer::class); + + self::assertInstanceOf(DnfFirstBranchConsumer::class, $service->consumer); + self::assertInstanceOf(DnfFileLogger::class, $service->consumer->dependency); + + } + + /** + * ### DNF dependencies remain transient by default + * @since 1.0.0 + * + * @return void + */ + public function testDnfDependenciesRemainTransientByDefault ():void { + + $container = new Container(); + + $container->bind( + DnfFileLogger::class, + DnfFileLogger::class + ); + + $first = $container->get(DnfFirstBranchConsumer::class); + $second = $container->get(DnfFirstBranchConsumer::class); + + self::assertNotSame($first, $second); + self::assertNotSame($first->dependency, $second->dependency); + + } + + /** + * ### DNF reuses singleton dependencies + * @since 1.0.0 + * + * @return void + */ + public function testDnfReusesSingletonDependencies ():void { + + $container = new Container(); + + $container->singleton(DnfFileLogger::class); + + $first = $container->get(DnfFirstBranchConsumer::class); + $second = $container->get(DnfFirstBranchConsumer::class); + + self::assertNotSame($first, $second); + self::assertSame($first->dependency, $second->dependency); + + } + + /** + * ### DNF singleton consumers retain their dependencies + * @since 1.0.0 + * + * @return void + */ + public function testDnfSingletonConsumersRetainTheirDependencies ():void { + + $container = new Container(); + + $container->bind( + DnfFileLogger::class, + DnfFileLogger::class + ); + + $container->singleton(DnfSingletonConsumer::class); + + $first = $container->get(DnfSingletonConsumer::class); + $second = $container->get(DnfSingletonConsumer::class); + + self::assertSame($first, $second); + self::assertSame($first->dependency, $second->dependency); + + } + + /** + * ### DNF scoped dependencies require an active scope + * @since 1.0.0 + * + * @return void + */ + public function testDnfScopedDependenciesRequireAnActiveScope ():void { + + $container = new Container(); + + $container->scoped(DnfFileLogger::class); + + $this->assertThrows( + ScopeRequiredException::class, + function () use ($container):void { + + $container->get(DnfScopedConsumer::class); + + } + ); + + } + + /** + * ### DNF scoped dependencies are reused within the same scope + * @since 1.0.0 + * + * @return void + */ + public function testDnfScopedDependenciesAreReusedWithinTheSameScope ():void { + + $container = new Container(); + + $container->scoped(DnfFileLogger::class); + + $scope = $container->createScope(); + + $first = $scope->get(DnfScopedConsumer::class); + $second = $scope->get(DnfScopedConsumer::class); + + self::assertNotSame($first, $second); + self::assertSame($first->dependency, $second->dependency); + + $scope->close(); + + } + + /** + * ### DNF scoped dependencies are isolated between scopes + * @since 1.0.0 + * + * @return void + */ + public function testDnfScopedDependenciesAreIsolatedBetweenScopes ():void { + + $container = new Container(); + + $container->scoped(DnfFileLogger::class); + + $firstScope = $container->createScope(); + $secondScope = $container->createScope(); + + $first = $firstScope->get(DnfScopedConsumer::class); + $second = $secondScope->get(DnfScopedConsumer::class); + + self::assertNotSame($first->dependency, $second->dependency); + + $firstScope->close(); + $secondScope->close(); + + } + + /** + * ### DNF resolves a factory registered under a named alternative + * @since 1.0.0 + * + * @return void + */ + public function testDnfResolvesAFactoryRegisteredUnderANamedAlternative ():void { + + $container = new Container(); + + $executed = false; + + $container->factory( + DnfCache::class, + function ($resolver) use (&$executed):object { + + $executed = true; + + return new DnfCacheService(); + + } + ); + + $service = $container->get(DnfFactoryConsumer::class); + + self::assertTrue($executed); + self::assertInstanceOf(DnfCacheService::class, $service->dependency); + + } + + /** + * ### DNF does not execute factories during intersection candidate discovery + * @since 1.0.0 + * + * @return void + */ + public function testDnfDoesNotExecuteFactoriesDuringIntersectionCandidateDiscovery ():void { + + $container = new Container(); + + $executed = false; + + $container->factory( + DnfLogger::class, + function ($resolver) use (&$executed):object { + + $executed = true; + + return new DnfFileLogger(); + + } + ); + + $this->assertThrows( + ServiceInstantiationException::class, + function () use ($container):void { + + $container->get(DnfFactoryConsumer::class); + + } + ); + + self::assertSame($executed, false); + + } + + /** + * ### DNF rejects an incompatible factory result + * @since 1.0.0 + * + * @return void + */ + public function testDnfRejectsAnIncompatibleFactoryResult ():void { + + $container = new Container(); + + $container->factory( + DnfCache::class, + function ($resolver):object { + + return new DnfConsoleLogger(); + + } + ); + + $this->assertThrows( + ServiceTypeMismatchException::class, + function () use ($container):void { + + $container->get(DnfFactoryConsumer::class); + + } + ); + + } + + /** + * ### DNF dependency plans preserve transient semantics + * @since 1.0.0 + * + * @return void + */ + public function testDnfDependencyPlansPreserveTransientSemantics ():void { + + $container = new Container(); + + $container->bind( + DnfFileLogger::class, + DnfFileLogger::class + ); + + $first = $container->get(DnfFirstBranchConsumer::class); + $second = $container->get(DnfFirstBranchConsumer::class); + + self::assertNotSame($first, $second); + self::assertNotSame($first->dependency, $second->dependency); + + } + + /** + * ### DNF dependency plans respect registrations added after first resolution + * @since 1.0.0 + * + * @return void + */ + public function testDnfDependencyPlansRespectRegistrationsAddedAfterFirstResolution ():void { + + $container = new Container(); + + $first = $container->get(DnfDefaultConsumer::class); + + self::assertNull($first->dependency); + + $container->bind( + DnfFileLogger::class, + DnfFileLogger::class + ); + + $second = $container->get(DnfDefaultConsumer::class); + + self::assertInstanceOf(DnfFileLogger::class, $second->dependency); + + } + + /** + * ### DNF dependency plans detect new ambiguities after registration + * @since 1.0.0 + * + * @return void + */ + public function testDnfDependencyPlansDetectNewAmbiguitiesAfterRegistration ():void { + + $container = new Container(); + + $container->bind( + DnfFileLogger::class, + DnfFileLogger::class + ); + + $first = $container->get(DnfFirstBranchConsumer::class); + + self::assertInstanceOf(DnfFileLogger::class, $first->dependency); + + $container->bind( + DnfCacheService::class, + DnfCacheService::class + ); + + $this->assertThrows( + AmbiguousDependencyException::class, + function () use ($container):void { + + $container->get(DnfFirstBranchConsumer::class); + + } + ); + + } + + /** + * ### DNF autowiring works after freezing the container + * @since 1.0.0 + * + * @return void + */ + public function testDnfAutowiringWorksAfterFreezingTheContainer ():void { + + $container = new Container(); + + $container->bind( + DnfFileLogger::class, + DnfFileLogger::class + ); + + $container->freeze(); + + $service = $container->get(DnfFirstBranchConsumer::class); + + self::assertInstanceOf(DnfFileLogger::class, $service->dependency); + self::assertTrue($container->isFrozen()); + + } + +} diff --git a/tests/Unit/ParameterBindingTest.php b/tests/Unit/ParameterBindingTest.php new file mode 100644 index 0000000..d71a5a5 --- /dev/null +++ b/tests/Unit/ParameterBindingTest.php @@ -0,0 +1,272 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Unit; + +use FireHub\Testing\FireHubTestCase; +use FireHub\Container\Container; +use FireHub\Container\Exception\ServiceInstantiationException; +use FireHub\Tests\Container\Support\ExceptionAssertions; +use FireHub\Tests\Container\Stubs\ { + BoundConnection, BoundDefaultService, BoundMixedService, BoundNullableService, BoundObjectService, + BoundOtherConnection, Logger +}; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests explicit parameter bindings + * @since 1.0.0 + */ +#[Small] +#[Group('container')] +#[CoversClass(Container::class)] +final class ParameterBindingTest extends FireHubTestCase { + + use ExceptionAssertions; + + /** + * ### Explicit parameter bindings resolve scalar dependencies + * @since 1.0.0 + * + * @return void + */ + public function testExplicitParameterBindingsResolveScalarDependencies ():void { + + $container = new Container(); + + $container->bindParameters( + BoundConnection::class, + [ + 'host' => 'localhost', + 'port' => 3306, + 'database' => 'firehub' + ] + ); + + $service = $container->get(BoundConnection::class); + + self::assertSame('localhost', $service->host); + self::assertSame(3306, $service->port); + self::assertSame('firehub', $service->database); + + } + + /** + * ### Explicit bindings combine with automatic dependency resolution + * @since 1.0.0 + * + * @return void + */ + public function testExplicitBindingsCombineWithAutomaticDependencyResolution ():void { + + $container = new Container(); + + $container->bindParameters( + BoundMixedService::class, + [ + 'host' => 'localhost' + ] + ); + + $service = $container->get(BoundMixedService::class); + + self::assertInstanceOf(Logger::class, $service->logger); + self::assertSame('localhost', $service->host); + self::assertSame(3306, $service->port); + + } + + /** + * ### Explicit bindings override constructor defaults + * @since 1.0.0 + * + * @return void + */ + public function testExplicitBindingsOverrideConstructorDefaults ():void { + + $container = new Container(); + + $container->bindParameters( + BoundDefaultService::class, + [ + 'port' => 9000 + ] + ); + + $service = $container->get(BoundDefaultService::class); + + self::assertSame(9000, $service->port); + + } + + /** + * ### Explicit bindings support null values + * @since 1.0.0 + * + * @return void + */ + public function testExplicitBindingsSupportNullValues ():void { + + $container = new Container(); + + $container->bindParameters( + BoundNullableService::class, + [ + 'logger' => null + ] + ); + + $service = $container->get(BoundNullableService::class); + + self::assertNull($service->logger); + + } + + /** + * ### Explicit bindings support object instances + * @since 1.0.0 + * + * @return void + */ + public function testExplicitBindingsSupportObjectInstances ():void { + + $container = new Container(); + + $logger = new Logger(); + + $container->bindParameters( + BoundObjectService::class, + [ + 'logger' => $logger + ] + ); + + $service = $container->get(BoundObjectService::class); + + self::assertSame($service->logger, $logger); + + } + + /** + * ### Explicit bindings are isolated by implementation class + * @since 1.0.0 + * + * @return void + */ + public function testExplicitBindingsAreIsolatedByImplementationClass ():void { + + $container = new Container(); + + $container->bindParameters( + BoundConnection::class, + [ + 'host' => 'localhost', + 'port' => 3306, + 'database' => 'firehub' + ] + ); + + $this->assertThrows( + ServiceInstantiationException::class, + function () use ($container):void { + + $container->get(BoundOtherConnection::class); + + } + ); + + } + + /** + * ### Explicit bindings preserve transient lifetimes + * @since 1.0.0 + * + * @return void + */ + public function testExplicitBindingsPreserveTransientLifetimes ():void { + + $container = new Container(); + + $container->bindParameters( + BoundDefaultService::class, + [ + 'port' => 9000 + ] + ); + + $first = $container->get(BoundDefaultService::class); + $second = $container->get(BoundDefaultService::class); + + self::assertNotSame($first, $second); + self::assertSame($first->port, $second->port); + + } + + /** + * ### Explicit bindings work with singleton services + * @since 1.0.0 + * + * @return void + */ + public function testExplicitBindingsWorkWithSingletonServices ():void { + + $container = new Container(); + + $container->singleton(BoundDefaultService::class); + + $container->bindParameters( + BoundDefaultService::class, + [ + 'port' => 9000 + ] + ); + + $first = $container->get(BoundDefaultService::class); + $second = $container->get(BoundDefaultService::class); + + self::assertSame($first, $second); + self::assertSame(9000, $first->port); + + } + + /** + * ### Explicit bindings respect container freezing + * @since 1.0.0 + * + * @return void + */ + public function testExplicitBindingsRespectContainerFreezing ():void { + + $container = new Container(); + + $container->freeze(); + + $this->assertThrows( + \FireHub\Container\Exception\ContainerFrozenException::class, + function () use ($container):void { + + $container->bindParameters( + BoundDefaultService::class, + [ + 'port' => 9000 + ] + ); + + } + ); + + } + +} diff --git a/tests/Unit/TaggedInjectionTest.php b/tests/Unit/TaggedInjectionTest.php new file mode 100644 index 0000000..c08a879 --- /dev/null +++ b/tests/Unit/TaggedInjectionTest.php @@ -0,0 +1,524 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Unit; + +use FireHub\Testing\FireHubTestCase; +use FireHub\Container\Container; +use FireHub\Container\Exception\ { + CircularDependencyException, ServiceInstantiationException, ScopeRequiredException +}; +use FireHub\Tests\Container\Support\ExceptionAssertions; +use FireHub\Tests\Container\Stubs\ { + TaggedConsoleLogger, TaggedFileLogger, TaggedInjectionCircularA, TaggedInjectionCircularB, TaggedInjectionConsumer, + TaggedInjectionDefaultConsumer, TaggedInjectionEmptyTagConsumer, TaggedInjectionNestedConsumer, + TaggedInjectionNullableConsumer, TaggedInjectionScalarConsumer, TaggedInjectionUnknownConsumer, + TaggedInjectionUntypedConsumer, TaggedInjectionWhitespaceTagConsumer, TaggedLogger +}; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests tagged constructor injection + * @since 1.0.0 + */ +#[Small] +#[Group('container')] +#[CoversClass(Container::class)] +final class TaggedInjectionTest extends FireHubTestCase { + + use ExceptionAssertions; + + /** + * ### Tagged attribute injects services in registration order + * @since 1.0.0 + * + * @return void + */ + public function testTaggedAttributeInjectsServicesInRegistrationOrder ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + $container->singleton(TaggedConsoleLogger::class); + + $container->tag( + 'logging', + TaggedConsoleLogger::class, + TaggedFileLogger::class + ); + + $consumer = $container->get(TaggedInjectionConsumer::class); + + self::assertCount(2, $consumer->loggers); + self::assertInstanceOf(TaggedConsoleLogger::class, $consumer->loggers[0]); + self::assertInstanceOf(TaggedFileLogger::class, $consumer->loggers[1]); + + } + + /** + * ### Tagged attribute injects an empty array for unknown tags + * @since 1.0.0 + * + * @return void + */ + public function testTaggedAttributeInjectsAnEmptyArrayForUnknownTags ():void { + + $consumer = (new Container)->get(TaggedInjectionUnknownConsumer::class); + + self::assertSame([], $consumer->services); + + } + + /** + * ### Tagged attribute overrides an array constructor default + * @since 1.0.0 + * + * @return void + */ + public function testTaggedAttributeOverridesAnArrayConstructorDefault ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + $container->tag('logging', TaggedFileLogger::class); + + $consumer = $container->get(TaggedInjectionDefaultConsumer::class); + + self::assertCount(1, $consumer->loggers); + self::assertInstanceOf(TaggedFileLogger::class, $consumer->loggers[0]); + + } + + /** + * ### Tagged attribute works in nested dependency graphs + * @since 1.0.0 + * + * @return void + */ + public function testTaggedAttributeWorksInNestedDependencyGraphs ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + $container->tag('logging', TaggedFileLogger::class); + + $nested = $container->get(TaggedInjectionNestedConsumer::class); + + self::assertInstanceOf(TaggedInjectionConsumer::class, $nested->consumer); + self::assertInstanceOf(TaggedFileLogger::class, $nested->consumer->loggers[0]); + + } + + /** + * ### Explicit parameter bindings override tagged injection + * @since 1.0.0 + * + * @return void + */ + public function testExplicitParameterBindingsOverrideTaggedInjection ():void { + + $container = new Container; + + $override = [new TaggedConsoleLogger]; + + $container->bindParameters( + TaggedInjectionConsumer::class, + ['loggers' => $override] + ); + + $consumer = $container->get(TaggedInjectionConsumer::class); + + self::assertSame($consumer->loggers, $override); + + } + + /** + * ### Tagged injection uses singleton instances + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionUsesSingletonInstances ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + $container->tag('logging', TaggedFileLogger::class); + + $first = $container->get(TaggedInjectionConsumer::class); + $second = $container->get(TaggedInjectionConsumer::class); + + self::assertNotSame($first, $second); + self::assertSame($first->loggers[0], $second->loggers[0]); + self::assertSame($first->loggers[0], $container->get(TaggedFileLogger::class)); + + } + + /** + * ### Tagged injection creates new transient instances + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionCreatesNewTransientInstances ():void { + + $container = new Container; + + $container->transient(TaggedFileLogger::class); + $container->tag('logging', TaggedFileLogger::class); + + $first = $container->get(TaggedInjectionConsumer::class); + $second = $container->get(TaggedInjectionConsumer::class); + + self::assertNotSame($first->loggers[0], $second->loggers[0]); + + } + + /** + * ### Tagged injection requires a scope for scoped services + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionRequiresAScopeForScopedServices ():void { + + $container = new Container; + + $container->scoped(TaggedFileLogger::class); + $container->tag('logging', TaggedFileLogger::class); + + $this->assertThrows( + ScopeRequiredException::class, + function () use ($container):void { + + $container->get(TaggedInjectionConsumer::class); + + } + ); + + } + + /** + * ### Tagged injection reuses scoped services within a scope + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionReusesScopedServicesWithinAScope ():void { + + $container = new Container; + + $container->scoped(TaggedFileLogger::class); + $container->tag('logging', TaggedFileLogger::class); + + $scope = $container->createScope(); + + try { + + $first = $scope->get(TaggedInjectionConsumer::class); + $second = $scope->get(TaggedInjectionConsumer::class); + + self::assertNotSame($first, $second); + self::assertSame($first->loggers[0], $second->loggers[0]); + self::assertSame($first->loggers[0], $scope->get(TaggedFileLogger::class)); + + } finally { + + $scope->close(); + + } + + } + + /** + * ### Tagged injection isolates scoped services between scopes + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionIsolatesScopedServicesBetweenScopes ():void { + + $container = new Container; + + $container->scoped(TaggedFileLogger::class); + $container->tag('logging', TaggedFileLogger::class); + + $firstScope = $container->createScope(); + $secondScope = $container->createScope(); + + try { + + $first = $firstScope->get(TaggedInjectionConsumer::class); + $second = $secondScope->get(TaggedInjectionConsumer::class); + + self::assertTrue($first->loggers[0] !== $second->loggers[0]); + + } finally { + + $firstScope->close(); + $secondScope->close(); + + } + + } + + /** + * ### Tagged injection uses the active scope in nested graphs + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionUsesTheActiveScopeInNestedGraphs ():void { + + $container = new Container; + + $container->scoped(TaggedFileLogger::class); + $container->tag('logging', TaggedFileLogger::class); + + $scope = $container->createScope(); + + try { + + $nested = $scope->get(TaggedInjectionNestedConsumer::class); + + self::assertSame($nested->consumer->loggers[0], $scope->get(TaggedFileLogger::class)); + + } finally { + + $scope->close(); + + } + + } + + /** + * ### Tagged injection resolves aliases + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionResolvesAliases ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + $container->alias(TaggedLogger::class, TaggedFileLogger::class); + $container->tag('logging', TaggedLogger::class); + + $consumer = $container->get(TaggedInjectionConsumer::class); + + self::assertCount(1, $consumer->loggers); + self::assertSame($consumer->loggers[0], $container->get(TaggedFileLogger::class)); + + } + + /** + * ### Tagged injection resolves factories + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionResolvesFactories ():void { + + $container = new Container; + + $executions = 0; + + $container->factory( + TaggedFileLogger::class, + static function ($resolver) use (&$executions):object { + + $executions++; + + return new TaggedFileLogger; + + } + ); + + $container->tag('logging', TaggedFileLogger::class); + + self::assertSame($executions, 0); + + $consumer = $container->get(TaggedInjectionConsumer::class); + + self::assertInstanceOf(TaggedFileLogger::class, $consumer->loggers[0]); + self::assertSame($executions, 1); + + } + + /** + * ### Tagged injection respects dynamically added tag associations + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionRespectsDynamicallyAddedTagAssociations ():void { + + $container = new Container; + + $first = $container->get(TaggedInjectionConsumer::class); + + self::assertSame($first->loggers, []); + + $container->singleton(TaggedFileLogger::class); + $container->tag('logging', TaggedFileLogger::class); + + $second = $container->get(TaggedInjectionConsumer::class); + + self::assertCount(1, $second->loggers); + self::assertInstanceOf(TaggedFileLogger::class, $second->loggers[0]); + + } + + /** + * ### Tagged injection works after container freezing + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionWorksAfterContainerFreezing ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + $container->tag('logging', TaggedFileLogger::class); + $container->freeze(); + + $consumer = $container->get(TaggedInjectionConsumer::class); + + self::assertInstanceOf(TaggedFileLogger::class, $consumer->loggers[0]); + + } + + /** + * ### Tagged injection rejects nullable array parameters + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionRejectsNullableArrayParameters ():void { + + $this->assertThrows( + ServiceInstantiationException::class, + static function ():void { + + (new Container)->get(TaggedInjectionNullableConsumer::class); + + } + ); + + } + + /** + * ### Tagged injection rejects scalar parameters + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionRejectsScalarParameters ():void { + + $this->assertThrows( + ServiceInstantiationException::class, + static function ():void { + + (new Container)->get(TaggedInjectionScalarConsumer::class); + + } + ); + + } + + /** + * ### Tagged injection rejects untyped parameters + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionRejectsUntypedParameters ():void { + + $this->assertThrows( + ServiceInstantiationException::class, + static function ():void { + + (new Container)->get(TaggedInjectionUntypedConsumer::class); + + } + ); + + } + + /** + * ### Tagged injection rejects empty tag names + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionRejectsEmptyTagNames ():void { + + $this->assertThrows( + ServiceInstantiationException::class, + static function ():void { + + (new Container)->get(TaggedInjectionEmptyTagConsumer::class); + + } + ); + + } + + /** + * ### Tagged injection rejects whitespace-only tag names + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionRejectsWhitespaceOnlyTagNames ():void { + + $this->assertThrows( + ServiceInstantiationException::class, + static function ():void { + + (new Container)->get(TaggedInjectionWhitespaceTagConsumer::class); + + } + ); + + } + + /** + * ### Tagged injection preserves circular dependency detection + * @since 1.0.0 + * + * @return void + */ + public function testTaggedInjectionPreservesCircularDependencyDetection ():void { + + $container = new Container; + + $container->transient(TaggedInjectionCircularB::class); + $container->tag('circular', TaggedInjectionCircularB::class); + + $this->assertThrows( + CircularDependencyException::class, + function () use ($container):void { + + $container->get(TaggedInjectionCircularA::class); + + } + ); + + } + +} diff --git a/tests/Unit/TaggedServicesTest.php b/tests/Unit/TaggedServicesTest.php new file mode 100644 index 0000000..bd23a80 --- /dev/null +++ b/tests/Unit/TaggedServicesTest.php @@ -0,0 +1,831 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Unit; + +use FireHub\Testing\FireHubTestCase; +use FireHub\Container\Container; +use FireHub\Container\Exception\ { + ServiceNotFoundException, ScopeRequiredException +}; +use FireHub\Tests\Container\Support\ExceptionAssertions; +use FireHub\Tests\Container\Stubs\ { + TaggedConsoleLogger, TaggedDatabaseLogger, TaggedFactoryService, TaggedFileLogger, TaggedLogger, TaggedScopedService, + TaggedSingletonService, TaggedTransientService, TaggedUnregisteredService +}; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests tagged services + * @since 1.0.0 + */ +#[Small] +#[Group('container')] +#[CoversClass(Container::class)] +final class TaggedServicesTest extends FireHubTestCase { + + use ExceptionAssertions; + + /** + * ### Tags can be registered for explicit services + * @since 1.0.0 + * + * @return void + */ + public function testTagsCanBeRegisteredForExplicitServices ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + $container->singleton(TaggedConsoleLogger::class); + + $container->tag( + 'logging', + TaggedFileLogger::class, + TaggedConsoleLogger::class + ); + + self::assertTrue($container->hasTag('logging')); + + self::assertSame( + [ + TaggedFileLogger::class, + TaggedConsoleLogger::class + ], $container->taggedIds('logging') + ); + + } + + /** + * ### Unknown tags return empty collections + * @since 1.0.0 + * + * @return void + */ + public function testUnknownTagsReturnEmptyCollections ():void { + + $container = new Container; + + self::assertNotTrue($container->hasTag('unknown')); + self::assertSame([], $container->taggedIds('unknown')); + self::assertSame([], $container->tagged('unknown')); + + $scope = $container->createScope(); + + self::assertSame([], $scope->tagged('unknown')); + + $scope->close(); + + } + + /** + * ### Tagged services preserve association order + * @since 1.0.0 + * + * @return void + */ + public function testTaggedServicesPreserveAssociationOrder ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + $container->singleton(TaggedConsoleLogger::class); + $container->singleton(TaggedDatabaseLogger::class); + + $container->tag( + 'logging', + TaggedDatabaseLogger::class, + TaggedFileLogger::class, + TaggedConsoleLogger::class + ); + + self::assertSame( + [ + TaggedDatabaseLogger::class, + TaggedFileLogger::class, + TaggedConsoleLogger::class + ], $container->taggedIds('logging') + ); + + $services = $container->tagged('logging'); + + self::assertCount(3, $services); + self::assertInstanceOf(TaggedDatabaseLogger::class, $services[0]); + self::assertInstanceOf(TaggedFileLogger::class, $services[1]); + self::assertInstanceOf(TaggedConsoleLogger::class, $services[2]); + + } + + /** + * ### Tags support multiple registration calls + * @since 1.0.0 + * + * @return void + */ + public function testTagsSupportMultipleRegistrationCalls ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + $container->singleton(TaggedConsoleLogger::class); + + $container->tag('logging', TaggedFileLogger::class); + $container->tag('logging', TaggedConsoleLogger::class); + + self::assertSame( + [ + TaggedFileLogger::class, + TaggedConsoleLogger::class + ], $container->taggedIds('logging') + ); + + } + + /** + * ### Duplicate tagged services are ignored + * @since 1.0.0 + * + * @return void + */ + public function testDuplicateTaggedServicesAreIgnored ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + + $container->tag( + 'logging', + TaggedFileLogger::class, + TaggedFileLogger::class + ); + + $container->tag('logging', TaggedFileLogger::class); + + self::assertSame( + [ + TaggedFileLogger::class + ], $container->taggedIds('logging') + ); + + self::assertCount(1, $container->tagged('logging')); + + } + + /** + * ### Services can belong to multiple tags + * @since 1.0.0 + * + * @return void + */ + public function testServicesCanBelongToMultipleTags ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + + $container->tag('logging', TaggedFileLogger::class); + $container->tag('monitoring', TaggedFileLogger::class); + + self::assertTrue($container->hasTag('logging')); + self::assertTrue($container->hasTag('monitoring')); + + $logging = $container->tagged('logging'); + $monitoring = $container->tagged('monitoring'); + + self::assertSame($logging[0], $monitoring[0]); + + } + + /** + * ### Tag registration does not instantiate services + * @since 1.0.0 + * + * @return void + */ + public function testTagRegistrationDoesNotInstantiateServices ():void { + + $container = new Container; + + $executed = false; + + $container->factory( + TaggedFactoryService::class, + function ($resolver) use (&$executed):object { + + $executed = true; + + return new TaggedFactoryService; + + } + ); + + $container->tag('services', TaggedFactoryService::class); + + self::assertFalse($executed); + + $container->hasTag('services'); + $container->taggedIds('services'); + + self::assertFalse($executed); + + $container->tagged('services'); + + self::assertTrue($executed); + + } + + /** + * ### Tag registration requires explicitly registered services + * @since 1.0.0 + * + * @return void + */ + public function testTagRegistrationRequiresExplicitlyRegisteredServices ():void { + + $container = new Container; + + $this->assertThrows( + ServiceNotFoundException::class, + function () use ($container):void { + + $container->tag( + 'services', + TaggedUnregisteredService::class + ); + + } + ); + + self::assertNotTrue($container->hasTag('services')); + + } + + /** + * ### Tag registration is atomic + * @since 1.0.0 + * + * @return void + */ + public function testTagRegistrationIsAtomic ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + + $this->assertThrows( + ServiceNotFoundException::class, + function () use ($container):void { + + $container->tag( + 'logging', + TaggedFileLogger::class, + TaggedUnregisteredService::class + ); + + } + ); + + self::assertNotTrue($container->hasTag('logging')); + self::assertSame([], $container->taggedIds('logging')); + + } + + /** + * ### Failed tag registration preserves existing associations + * @since 1.0.0 + * + * @return void + */ + public function testFailedTagRegistrationPreservesExistingAssociations ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + $container->singleton(TaggedConsoleLogger::class); + + $container->tag('logging', TaggedFileLogger::class); + + $this->assertThrows( + ServiceNotFoundException::class, + function () use ($container):void { + + $container->tag( + 'logging', + TaggedConsoleLogger::class, + TaggedUnregisteredService::class + ); + + } + ); + + self::assertSame( + [ + TaggedFileLogger::class + ], $container->taggedIds('logging') + ); + + } + + /** + * ### Empty tag names are rejected + * @since 1.0.0 + * + * @return void + */ + public function testEmptyTagNamesAreRejected ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + + $this->assertThrows( + \FireHub\Container\Exception\InvalidTagNameException::class, + function () use ($container):void { + + $container->tag('', TaggedFileLogger::class); + + } + ); + + } + + /** + * ### Whitespace-only tag names are rejected + * @since 1.0.0 + * + * @return void + */ + public function testWhitespaceOnlyTagNamesAreRejected ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + + $this->assertThrows( + \FireHub\Container\Exception\InvalidTagNameException::class, + function () use ($container):void { + + $container->tag(' ', TaggedFileLogger::class); + + } + ); + + } + + /** + * ### Tagged singleton services are reused + * @since 1.0.0 + * + * @return void + */ + public function testTaggedSingletonServicesAreReused ():void { + + $container = new Container; + + $container->singleton(TaggedSingletonService::class); + + $container->tag('services', TaggedSingletonService::class); + + $first = $container->tagged('services'); + $second = $container->tagged('services'); + + self::assertCount(1, $first); + self::assertSame($first[0], $second[0]); + self::assertSame($first[0], $container->get(TaggedSingletonService::class)); + + } + + /** + * ### Tagged transient services are recreated + * @since 1.0.0 + * + * @return void + */ + public function testTaggedTransientServicesAreRecreated ():void { + + $container = new Container; + + $container->transient(TaggedTransientService::class); + + $container->tag('services', TaggedTransientService::class); + + $first = $container->tagged('services'); + $second = $container->tagged('services'); + + self::assertInstanceOf(TaggedTransientService::class, $first[0]); + self::assertInstanceOf(TaggedTransientService::class, $second[0]); + self::assertNotSame($first[0], $second[0]); + + } + + /** + * ### Tagged scoped services require an active scope + * @since 1.0.0 + * + * @return void + */ + public function testTaggedScopedServicesRequireAnActiveScope ():void { + + $container = new Container; + + $container->scoped(TaggedScopedService::class); + + $container->tag('services', TaggedScopedService::class); + + $this->assertThrows( + ScopeRequiredException::class, + function () use ($container):void { + + $container->tagged('services'); + + } + ); + + } + + /** + * ### Tagged scoped services are reused within the same scope + * @since 1.0.0 + * + * @return void + */ + public function testTaggedScopedServicesAreReusedWithinTheSameScope ():void { + + $container = new Container; + + $container->scoped(TaggedScopedService::class); + + $container->tag('services', TaggedScopedService::class); + + $scope = $container->createScope(); + + $first = $scope->tagged('services'); + $second = $scope->tagged('services'); + + self::assertInstanceOf(TaggedScopedService::class, $first[0]); + self::assertSame($first[0], $second[0]); + self::assertSame($first[0], $scope->get(TaggedScopedService::class)); + + $scope->close(); + + } + + /** + * ### Tagged scoped services are isolated between scopes + * @since 1.0.0 + * + * @return void + */ + public function testTaggedScopedServicesAreIsolatedBetweenScopes ():void { + + $container = new Container; + + $container->scoped(TaggedScopedService::class); + + $container->tag('services', TaggedScopedService::class); + + $firstScope = $container->createScope(); + $secondScope = $container->createScope(); + + $first = $firstScope->tagged('services'); + $second = $secondScope->tagged('services'); + + self::assertNotSame($first[0], $second[0]); + + $firstScope->close(); + $secondScope->close(); + + } + + /** + * ### Tagged services preserve mixed lifetimes + * @since 1.0.0 + * + * @return void + */ + public function testTaggedServicesPreserveMixedLifetimes ():void { + + $container = new Container; + + $container->singleton(TaggedSingletonService::class); + $container->scoped(TaggedScopedService::class); + $container->transient(TaggedTransientService::class); + + $container->tag( + 'services', + TaggedSingletonService::class, + TaggedScopedService::class, + TaggedTransientService::class + ); + + $firstScope = $container->createScope(); + $secondScope = $container->createScope(); + + $first = $firstScope->tagged('services'); + $second = $firstScope->tagged('services'); + $third = $secondScope->tagged('services'); + + self::assertCount(3, $first); + + // Singleton instances are shared across scopes. + self::assertSame($first[0], $second[0]); + self::assertSame($first[0], $third[0]); + + // Scoped instances are shared only within their scope. + self::assertSame($first[1], $second[1]); + self::assertNotSame($first[1], $third[1]); + + // Transient instances are always recreated. + self::assertNotSame($first[2], $second[2]); + self::assertNotSame($first[2], $third[2]); + + $firstScope->close(); + $secondScope->close(); + + } + + /** + * ### Tagged singleton instances are shared between container and scope + * @since 1.0.0 + * + * @return void + */ + public function testTaggedSingletonInstancesAreSharedBetweenContainerAndScope ():void { + + $container = new Container; + + $container->singleton(TaggedSingletonService::class); + + $container->tag('services', TaggedSingletonService::class); + + $scope = $container->createScope(); + + $fromContainer = $container->tagged('services'); + $fromScope = $scope->tagged('services'); + + self::assertSame($fromContainer[0], $fromScope[0]); + + $scope->close(); + + } + + /** + * ### Tagged aliases resolve their target services + * @since 1.0.0 + * + * @return void + */ + public function testTaggedAliasesResolveTheirTargetServices ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + + $container->alias( + TaggedLogger::class, + TaggedFileLogger::class + ); + + $container->tag('logging', TaggedLogger::class); + + self::assertSame( + [ + TaggedLogger::class + ], $container->taggedIds('logging') + ); + + $services = $container->tagged('logging'); + + self::assertCount(1, $services); + self::assertInstanceOf(TaggedFileLogger::class, $services[0]); + self::assertSame($services[0], $container->get(TaggedFileLogger::class)); + + } + + /** + * ### Tagged factory services are resolved + * @since 1.0.0 + * + * @return void + */ + public function testTaggedFactoryServicesAreResolved ():void { + + $container = new Container; + + $container->factory( + TaggedFactoryService::class, + static fn ($resolver):object => new TaggedFactoryService + ); + + $container->tag('services', TaggedFactoryService::class); + + $first = $container->tagged('services'); + $second = $container->tagged('services'); + + self::assertInstanceOf(TaggedFactoryService::class, $first[0]); + self::assertNotSame($first[0], $second[0]); + + } + + /** + * ### Tagged factory services preserve singleton lifetime + * @since 1.0.0 + * + * @return void + */ + public function testTaggedFactoryServicesPreserveSingletonLifetime ():void { + + $container = new Container; + + $container->factory( + TaggedFactoryService::class, + static fn ($resolver):object => new TaggedFactoryService, + \FireHub\Core\Meta\Enum\Container\Lifetime::SINGLETON + ); + + $container->tag('services', TaggedFactoryService::class); + + $first = $container->tagged('services'); + $second = $container->tagged('services'); + + self::assertSame($first[0], $second[0]); + + } + + /** + * ### Tags cannot be registered after freezing + * @since 1.0.0 + * + * @return void + */ + public function testTagsCannotBeRegisteredAfterFreezing ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + + $container->freeze(); + + $this->assertThrows( + \FireHub\Container\Exception\ContainerFrozenException::class, + function () use ($container):void { + + $container->tag('logging', TaggedFileLogger::class); + + } + ); + + } + + /** + * ### Tagged services remain resolvable after freezing + * @since 1.0.0 + * + * @return void + */ + public function testTaggedServicesRemainResolvableAfterFreezing ():void { + + $container = new Container; + + $container->singleton(TaggedFileLogger::class); + + $container->tag('logging', TaggedFileLogger::class); + + $container->freeze(); + + self::assertTrue($container->hasTag('logging')); + + self::assertSame( + [ + TaggedFileLogger::class + ], $container->taggedIds('logging') + ); + + $services = $container->tagged('logging'); + + self::assertInstanceOf(TaggedFileLogger::class, $services[0]); + + } + + /** + * ### Tagged scoped services remain resolvable after freezing + * @since 1.0.0 + * + * @return void + */ + public function testTaggedScopedServicesRemainResolvableAfterFreezing ():void { + + $container = new Container; + + $container->scoped(TaggedScopedService::class); + + $container->tag('services', TaggedScopedService::class); + + $container->freeze(); + + $scope = $container->createScope(); + + $services = $scope->tagged('services'); + + self::assertInstanceOf(TaggedScopedService::class, $services[0]); + + $scope->close(); + + } + + /** + * ### Tagged resolution fails after closing the scope + * @since 1.0.0 + * + * @return void + */ + public function testTaggedResolutionFailsAfterClosingTheScope ():void { + + $container = new Container; + + $container->scoped(TaggedScopedService::class); + + $container->tag('services', TaggedScopedService::class); + + $scope = $container->createScope(); + + $scope->close(); + + $this->assertThrows( + \FireHub\Container\Exception\ScopeClosedException::class, + function () use ($scope):void { + + $scope->tagged('services'); + + } + ); + + } + + /** + * ### Tagged resolution works inside scope run + * @since 1.0.0 + * + * @return void + */ + public function testTaggedResolutionWorksInsideScopeRun ():void { + + $container = new Container; + + $container->scoped(TaggedScopedService::class); + + $container->tag('services', TaggedScopedService::class); + + $scope = $container->createScope(); + + $scope->run( + function ($resolver) use ($scope):void { + + $services = $scope->tagged('services'); + + self::assertCount(1, $services); + self::assertInstanceOf(TaggedScopedService::class, $services[0]); + + } + ); + + self::assertTrue($scope->isClosed()); + + } + + /** + * ### Tags are isolated between containers + * @since 1.0.0 + * + * @return void + */ + public function testTagsAreIsolatedBetweenContainers ():void { + + $firstContainer = new Container; + $secondContainer = new Container; + + $firstContainer->singleton(TaggedFileLogger::class); + $secondContainer->singleton(TaggedFileLogger::class); + + $firstContainer->tag('logging', TaggedFileLogger::class); + + self::assertTrue($firstContainer->hasTag('logging')); + self::assertNotTrue($secondContainer->hasTag('logging')); + + self::assertSame([], $secondContainer->taggedIds('logging')); + + } + +} From cbb2db7ab5e0b0297aaa8fd6f57607e57815d12e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Wed, 21 Oct 2026 15:39:03 +0200 Subject: [PATCH 3/5] Add collection logger interfaces and services for dependency injection --- src/Attribute/InjectAll.php | 62 ++ src/Container.php | 49 ++ src/Internal/Autowirer.php | 7 + src/Internal/DependencyPlanCache.php | 105 +++- src/Internal/ParameterPlan.php | 6 +- src/Resolver.php | 264 ++++++++- src/Scope.php | 56 ++ tests/Stubs/CollectionAbstractLogger.php | 19 + .../CollectionAlternateImplementation.php | 19 + tests/Stubs/CollectionAlternateLogger.php | 19 + .../CollectionConcreteAbstractLogger.php | 19 + tests/Stubs/CollectionConsoleLogger.php | 19 + tests/Stubs/CollectionDatabaseLogger.php | 19 + tests/Stubs/CollectionFileLogger.php | 19 + tests/Stubs/CollectionInjectionCircularA.php | 26 + tests/Stubs/CollectionInjectionCircularB.php | 21 + ...InjectionConflictingAttributesConsumer.php | 29 + tests/Stubs/CollectionInjectionConsumer.php | 27 + .../CollectionInjectionDefaultConsumer.php | 27 + .../CollectionInjectionEmptyTypeConsumer.php | 26 + ...ionInjectionMultipleAttributesConsumer.php | 27 + .../CollectionInjectionNestedConsumer.php | 21 + .../CollectionInjectionNullableConsumer.php | 26 + .../CollectionInjectionScalarConsumer.php | 26 + ...CollectionInjectionUnknownTypeConsumer.php | 26 + .../CollectionInjectionUntypedConsumer.php | 26 + tests/Stubs/CollectionLogger.php | 19 + tests/Stubs/CollectionOtherService.php | 19 + tests/Stubs/CollectionUnrelatedService.php | 19 + tests/Unit/CollectionInjectionTest.php | 521 +++++++++++++++++ tests/Unit/CollectionServicesTest.php | 532 ++++++++++++++++++ 31 files changed, 2068 insertions(+), 32 deletions(-) create mode 100644 src/Attribute/InjectAll.php create mode 100644 tests/Stubs/CollectionAbstractLogger.php create mode 100644 tests/Stubs/CollectionAlternateImplementation.php create mode 100644 tests/Stubs/CollectionAlternateLogger.php create mode 100644 tests/Stubs/CollectionConcreteAbstractLogger.php create mode 100644 tests/Stubs/CollectionConsoleLogger.php create mode 100644 tests/Stubs/CollectionDatabaseLogger.php create mode 100644 tests/Stubs/CollectionFileLogger.php create mode 100644 tests/Stubs/CollectionInjectionCircularA.php create mode 100644 tests/Stubs/CollectionInjectionCircularB.php create mode 100644 tests/Stubs/CollectionInjectionConflictingAttributesConsumer.php create mode 100644 tests/Stubs/CollectionInjectionConsumer.php create mode 100644 tests/Stubs/CollectionInjectionDefaultConsumer.php create mode 100644 tests/Stubs/CollectionInjectionEmptyTypeConsumer.php create mode 100644 tests/Stubs/CollectionInjectionMultipleAttributesConsumer.php create mode 100644 tests/Stubs/CollectionInjectionNestedConsumer.php create mode 100644 tests/Stubs/CollectionInjectionNullableConsumer.php create mode 100644 tests/Stubs/CollectionInjectionScalarConsumer.php create mode 100644 tests/Stubs/CollectionInjectionUnknownTypeConsumer.php create mode 100644 tests/Stubs/CollectionInjectionUntypedConsumer.php create mode 100644 tests/Stubs/CollectionLogger.php create mode 100644 tests/Stubs/CollectionOtherService.php create mode 100644 tests/Stubs/CollectionUnrelatedService.php create mode 100644 tests/Unit/CollectionInjectionTest.php create mode 100644 tests/Unit/CollectionServicesTest.php diff --git a/src/Attribute/InjectAll.php b/src/Attribute/InjectAll.php new file mode 100644 index 0000000..9e8d85c --- /dev/null +++ b/src/Attribute/InjectAll.php @@ -0,0 +1,62 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container + */ + +namespace FireHub\Container\Attribute; + +use Attribute; + +/** + * ### Marks a constructor parameter for collection injection + * + * Automatically injects all explicitly registered services compatible with the specified type. + * + * Services are resolved in registration order while preserving their configured lifetimes and the active resolution + * context. + * + * If no matching services are registered, an empty array is injected. + * + * Only non-nullable array constructor parameters are supported. + * + * + * use FireHub\Container\Attribute\InjectAll; + * + * final readonly class LoggerManager { + * + * public function __construct( + * #[InjectAll(LoggerInterface::class)] + * public array $loggers + * ) {} + * + * } + * + * + * @since 1.0.0 + */ +#[Attribute(Attribute::TARGET_PARAMETER)] +final readonly class InjectAll { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param class-string $type

+ * Required service type. + *

+ * + * @return void + */ + public function __construct( + public string $type + ) {} + +} \ No newline at end of file diff --git a/src/Container.php b/src/Container.php index 2ad969e..a7f9328 100644 --- a/src/Container.php +++ b/src/Container.php @@ -561,6 +561,55 @@ public function bindParameters (string $class, array $parameters):self { } + /** + * ### Resolves all registered services matching a type + * + * Resolves all explicitly registered services compatible with the specified class or interface. + * + * Matching services are returned in registration order while preserving their configured lifetimes. + * + * Scoped services require an active scope and cannot be resolved directly through the container. + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->singleton(FileLogger::class); + * $container->singleton(ConsoleLogger::class); + * + * $loggers = $container->all(LoggerInterface::class); + * + * + * @since 1.0.0 + * + * @uses \FireHub\Container\Resolver::all() To resolve matching services. + * + * @template TService of object + * + * @param class-string $type

+ * Required service type. + *

+ * + * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered. + * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible + * with its registered identifier. + * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported. + * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided. + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * + * @return list Matching service instances. + */ + public function all (string $type):array { + + return $this->resolver->all( + $type, + new ResolutionContext + ); + + } + /** * ### Associates registered services with a tag * diff --git a/src/Internal/Autowirer.php b/src/Internal/Autowirer.php index fe1370f..6b8bf9b 100644 --- a/src/Internal/Autowirer.php +++ b/src/Internal/Autowirer.php @@ -41,6 +41,9 @@ * @param Closure(non-empty-string, \FireHub\Container\Internal\ResolutionContext):list $resolve_tagged

* Context-aware tagged service collection resolver. *

+ * @param Closure(class-string, \FireHub\Container\Internal\ResolutionContext):list $resolve_all

+ * Context-aware service collection resolver. + *

* @param Closure(class-string):bool $has

* Explicit service registration checker. *

@@ -65,6 +68,7 @@ public function __construct ( private Closure $resolve, private Closure $resolve_tagged, + private Closure $resolve_all, private Closure $has, private TypeMatcher $matcher, private CandidateSelector $selector, @@ -166,6 +170,9 @@ private function resolveParameter (string $class, ParameterPlan $plan, Resolutio if ($plan->tag !== null) return ($this->resolve_tagged)($plan->tag, $context); + if ($plan->collection !== null) + return ($this->resolve_all)($plan->collection, $context); + $id = $this->contextual_selector->select($class, $plan); if ($id !== null) diff --git a/src/Internal/DependencyPlanCache.php b/src/Internal/DependencyPlanCache.php index 8ba1f86..962b6d0 100644 --- a/src/Internal/DependencyPlanCache.php +++ b/src/Internal/DependencyPlanCache.php @@ -13,7 +13,9 @@ namespace FireHub\Container\Internal; -use FireHub\Container\Attribute\Tagged; +use FireHub\Container\Attribute\ { + InjectAll, Tagged +}; use FireHub\Container\Exception\ServiceInstantiationException; use FireHub\Runtime; use ReflectionNamedType, ReflectionParameter, ReflectionUnionType, ReflectionIntersectionType; @@ -116,6 +118,10 @@ public function get (string $class):DependencyPlan { * * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple tagged attributes are declared, * the parameter type is unsupported, or the tag name is empty. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If tagged and collection injection are + * combined. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple collection attributes are + * declared, the parameter type is unsupported, or the requested service type does not exist. * * @return \FireHub\Container\Internal\ParameterPlan Compiled parameter plan. */ @@ -124,16 +130,26 @@ private function compileParameter (ReflectionParameter $parameter):ParameterPlan $type = $parameter->getType(); $id = $type instanceof ReflectionNamedType - ? $this->matcher->classId($parameter, $type) - : null; + ? $this->matcher->classId($parameter, $type) : null; $composite = $type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType - ? $type - : null; + ? $type : null; $has_default = $parameter->isDefaultValueAvailable(); + $tag = $this->resolveTag($parameter); + $collection = $this->resolveCollection($parameter); + + if ($tag !== null && $collection !== null) + throw new ServiceInstantiationException( + 'Tagged and collection injection cannot be combined.', + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + ] + ); + return new ParameterPlan( $parameter, $id, @@ -142,7 +158,8 @@ private function compileParameter (ReflectionParameter $parameter):ParameterPlan $has_default ? $parameter->getDefaultValue() : null, - $this->resolveTag($parameter) + $tag, + $collection ); } @@ -213,4 +230,80 @@ private function resolveTag (ReflectionParameter $parameter):?string { } + /** + * ### Resolves collection injection metadata + * + * Extracts and validates the collection injection attribute associated with a constructor parameter. + * + * Collection injection requires a non-nullable array parameter and an existing class or interface as its element + * type. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Arr\Inspection::count() To count the number of declared collection attributes. + * @uses \FireHub\Runtime\ObjectModel\Inspection::isClass() To check if the collection type is a class. + * @uses \FireHub\Runtime\ObjectModel\Inspection::isInterface() To check if the collection type is an interface. + * + * @param \ReflectionParameter $parameter

+ * Constructor parameter reflection. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple collection attributes are + * declared, the parameter type is unsupported, or the requested service type does not exist. + * + * @return null|class-string Requested collection element type. + */ + private function resolveCollection (ReflectionParameter $parameter):?string { + + $attributes = $parameter->getAttributes(InjectAll::class); + + if ($attributes === []) + return null; + + if (Runtime\Arr\Inspection::count($attributes) > 1) + throw new ServiceInstantiationException( + 'Multiple collection injection attributes are not supported.', + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + ] + ); + + $type = $parameter->getType(); + + if ( + !$type instanceof ReflectionNamedType + || $type->getName() !== 'array' + || $type->allowsNull() + ) throw new ServiceInstantiationException( + 'Collection injection requires a non-nullable array parameter.', + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + ] + ); + + /** @var \FireHub\Container\Attribute\InjectAll $attribute */ + $attribute = $attributes[0]->newInstance(); + + $collection = $attribute->type; + + if ( + $collection === '' + || ( + !Runtime\ObjectModel\Inspection::isClass($collection) + && !Runtime\ObjectModel\Inspection::isInterface($collection) + ) + ) throw new ServiceInstantiationException( + 'Collection injection requires an existing class or interface.', + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + 'type' => $collection, + ] + ); + + return $collection; + + } + } \ No newline at end of file diff --git a/src/Internal/ParameterPlan.php b/src/Internal/ParameterPlan.php index 6c9b51e..392a2ef 100644 --- a/src/Internal/ParameterPlan.php +++ b/src/Internal/ParameterPlan.php @@ -51,6 +51,9 @@ * @param null|non-empty-string $tag [optional]

* Service tag associated with the constructor parameter. *

+ * @param null|class-string $collection [optional]

+ * Service type requested for collection injection. + *

* * @return void */ @@ -60,7 +63,8 @@ public function __construct ( public ReflectionUnionType|ReflectionIntersectionType|null $composite, public bool $hasDefault, public mixed $default, - public ?string $tag = null + public ?string $tag = null, + public ?string $collection = null ) {} } \ No newline at end of file diff --git a/src/Resolver.php b/src/Resolver.php index baec675..7d2a1f8 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -24,7 +24,8 @@ TagRegistry, TypeMatcher }; use FireHub\Container\Exception\ { - ScopeRequiredException, ServiceNotFoundException, ServiceTypeMismatchException, UnsupportedDefinitionException + CircularDependencyException, ScopeRequiredException, ServiceNotFoundException, ServiceTypeMismatchException, + UnsupportedDefinitionException }; use FireHub\Runtime; @@ -52,6 +53,15 @@ */ private ConstructorMetadataCache $metadata; + /** + * ### Dependency type matcher + * + * Provides shared type compatibility checks for dependency resolution, candidate selection, and collection + * injection. + * @since 1.0.0 + */ + private TypeMatcher $matcher; + /** * ### Reusable dependency autowirer * @since 1.0.0 @@ -94,26 +104,27 @@ public function __construct ( $this->metadata = new ConstructorMetadataCache; - $matcher = new TypeMatcher( + $this->matcher = new TypeMatcher( $this->metadata ); $selector = new CandidateSelector( - $matcher, + $this->matcher, fn (string $id):bool => $this->has($id), fn ():iterable => $this->registry->implementations() ); $plans = new DependencyPlanCache( $this->metadata, - $matcher + $this->matcher ); $this->autowirer = new Autowirer( fn (string $id, ResolutionContext $context):object => $this->resolve($id, $context), fn (string $tag, ResolutionContext $context):array => $this->tagged($tag, $context), + fn (string $type, ResolutionContext $context):array => $this->all($type, $context), fn (string $id):bool => $this->has($id), - $matcher, + $this->matcher, $selector, $plans, $parameters, @@ -122,6 +133,26 @@ public function __construct ( } + /** + * ### Checks whether a service is registered + * + * Checks whether a service identifier exists without constructing or resolving its instance. + * @since 1.0.0 + * + * @uses \FireHub\Container\Registry::has() To check for service existence. + * + * @param class-string $id

+ * Service identifier. + *

+ * + * @return bool True if the service is registered. + */ + public function has (string $id):bool { + + return $this->registry->has($id); + + } + /** * ### Gets a registered service * @@ -217,6 +248,128 @@ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope) } + /** + * ### Resolves all registered services matching a type + * + * Resolves explicitly registered services compatible with the specified class or interface. + * + * Services are returned in registration order while preserving their configured lifetimes and the active + * resolution context. + * + * Service aliases are resolved to their underlying targets and do not produce duplicate collection entries. + * @since 1.0.0 + * + * @uses \FireHub\Container\Registry::all() To retrieve all registered service identifiers. + * @uses \FireHub\Container\Resolver::canonicalId() To resolve service aliases. + * @uses \FireHub\Container\Resolver::matchesCollectionType() To check whether a service matches the specified type. + * + * @template TService of object + * + * @param class-string $type

+ * Required service type. + *

+ * @param \FireHub\Container\Internal\ResolutionContext $context

+ * Active resolution context. + *

+ * + * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered. + * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible + * with its registered identifier. + * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported. + * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided. + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * + * @return list Matching service instances. + */ + public function all (string $type, ResolutionContext $context):array { + + $services = []; $resolved = []; + foreach ($this->registry->all() as $id => $definition) { + + $target = $this->canonicalId($id); + + if (isset($resolved[$target])) + continue; + + if (!$this->matchesCollectionType($id, $target, $type)) + continue; + + $instance = $this->resolve($id, $context); + + if (!$instance instanceof $type) + throw new ServiceTypeMismatchException( + context: [ + 'service' => $id, + 'implementation' => $instance::class, + ] + ); + + $services[] = $instance; + + $resolved[$target] = true; + + } + + return $services; + + } + + /** + * ### Resolves all matching services within a scope + * + * Resolves collection services using the supplied scoped instance store and scope state. + * @since 1.0.0 + * + * @uses \FireHub\Container\Resolver::all() To resolve all matching services. + * @uses \FireHub\Container\Internal\ScopeState::enter() To enter the scope. + * @uses \FireHub\Container\Internal\ScopeState::leave() To leave the scope. + * + * @template TService of object + * + * @param class-string $type

+ * Required service type. + *

+ * @param \FireHub\Container\Internal\InstanceStore $scoped

+ * Scoped instance store. + *

+ * @param \FireHub\Container\Internal\ScopeState $scope

+ * Scope state. + *

+ * + * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered. + * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible + * with its registered identifier. + * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported. + * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided. + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. + * + * @return list Matching service instances. + */ + public function allScoped (string $type, InstanceStore $scoped, ScopeState $scope):array { + + $scope->enter(); + + try { + + return $this->all( + $type, + new ResolutionContext( + scoped: $scoped, + scope: $scope + ) + ); + + } finally { + + $scope->leave(); + + } + + } + /** * ### Resolves services associated with a tag * @@ -319,26 +472,6 @@ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $sc } - /** - * ### Checks whether a service is registered - * - * Checks whether a service identifier exists without constructing or resolving its instance. - * @since 1.0.0 - * - * @uses \FireHub\Container\Registry::has() To check for service existence. - * - * @param class-string $id

- * Service identifier. - *

- * - * @return bool True if the service is registered. - */ - public function has (string $id):bool { - - return $this->registry->has($id); - - } - /** * ### Resolves a service within the specified context * @since 1.0.0 @@ -596,6 +729,87 @@ function (string $id) use ($context):bool { } + /** + * ### Resolves the canonical service identifier + * + * Follows registered service aliases until a non-alias definition is reached. + * @since 1.0.0 + * + * @uses \FireHub\Container\Registry::find() To retrieve a service definition. + * + * @param class-string $id

+ * Service identifier. + *

+ * + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * + * @return class-string Canonical service identifier. + */ + private function canonicalId (string $id):string { + + $visited = []; $path = []; + while (true) { + + if (isset($visited[$id])) + throw new CircularDependencyException( + context: [ + 'path' => [...$path, $id], + ] + ); + + $visited[$id] = true; + $path[] = $id; + + $definition = $this->registry->find($id); + + if (!$definition instanceof Alias) + return $id; + + $id = $definition->target; + + } + + } + + /** + * ### Checks collection candidate compatibility + * + * Determines whether a registered service is compatible with the requested collection element type. + * @since 1.0.0 + * + * @uses \FireHub\Container\Registry::find() To retrieve the service definition. + * @uses \FireHub\Container\Internal\TypeMatcher::matches() To check if the service identifier matches the type. + * + * @param class-string $id

+ * Service identifier. + *

+ * @param class-string $target

+ * Canonical service identifier. + *

+ * @param class-string $type

+ * Required collection element type. + *

+ * + * @return bool True if the service matches the requested type. + */ + private function matchesCollectionType (string $id, string $target, string $type):bool { + + if ($this->matcher->matches($id, $type)) + return true; + + if ($this->matcher->matches($target, $type)) + return true; + + $definition = $this->registry->find($target); + + return match (true) { + $definition instanceof Cls => $this->matcher->matches($definition->class, $type), + $definition instanceof Instance => $definition->instance instanceof $type, + default => false + }; + + } + /** * ### Creates a service instance * diff --git a/src/Scope.php b/src/Scope.php index de5ad84..f1be407 100644 --- a/src/Scope.php +++ b/src/Scope.php @@ -245,6 +245,62 @@ public function has (string $id):bool { } + /** + * ### Resolves all matching services within the current scope + * + * Resolves all explicitly registered services compatible with the specified class or interface. + * + * Scoped services are reused within the current scope, singleton services are shared with the parent container, + * and transient services are created for each resolution. + * + * Resolution is prohibited after the scope has been closed. + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->scoped(FileLogger::class); + * $container->singleton(ConsoleLogger::class); + * + * $scope = $container->createScope(); + * + * $loggers = $scope->all(LoggerInterface::class); + * + * $scope->close(); + * + * + * @since 1.0.0 + * + * @uses \FireHub\Container\Resolver::allScoped() To resolve matching services within the scope. + * + * @template TService of object + * + * @param class-string $type

+ * Required service type. + *

+ * + * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered. + * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible + * with its registered identifier. + * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported. + * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided. + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. + * + * @return list Matching service instances. + */ + public function all (string $type):array { + + return $this->resolver->allScoped( + $type, + $this->instances, + $this->state + ); + + } + /** * ### Executes a callback within the scope * diff --git a/tests/Stubs/CollectionAbstractLogger.php b/tests/Stubs/CollectionAbstractLogger.php new file mode 100644 index 0000000..4809620 --- /dev/null +++ b/tests/Stubs/CollectionAbstractLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +abstract class CollectionAbstractLogger {} diff --git a/tests/Stubs/CollectionAlternateImplementation.php b/tests/Stubs/CollectionAlternateImplementation.php new file mode 100644 index 0000000..a5e7e14 --- /dev/null +++ b/tests/Stubs/CollectionAlternateImplementation.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final class CollectionAlternateImplementation implements CollectionAlternateLogger {} diff --git a/tests/Stubs/CollectionAlternateLogger.php b/tests/Stubs/CollectionAlternateLogger.php new file mode 100644 index 0000000..5ea4368 --- /dev/null +++ b/tests/Stubs/CollectionAlternateLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface CollectionAlternateLogger extends CollectionLogger {} diff --git a/tests/Stubs/CollectionConcreteAbstractLogger.php b/tests/Stubs/CollectionConcreteAbstractLogger.php new file mode 100644 index 0000000..d0d549e --- /dev/null +++ b/tests/Stubs/CollectionConcreteAbstractLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final class CollectionConcreteAbstractLogger extends CollectionAbstractLogger {} diff --git a/tests/Stubs/CollectionConsoleLogger.php b/tests/Stubs/CollectionConsoleLogger.php new file mode 100644 index 0000000..4eb1969 --- /dev/null +++ b/tests/Stubs/CollectionConsoleLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final class CollectionConsoleLogger implements CollectionLogger {} diff --git a/tests/Stubs/CollectionDatabaseLogger.php b/tests/Stubs/CollectionDatabaseLogger.php new file mode 100644 index 0000000..c4e378d --- /dev/null +++ b/tests/Stubs/CollectionDatabaseLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final class CollectionDatabaseLogger implements CollectionLogger {} diff --git a/tests/Stubs/CollectionFileLogger.php b/tests/Stubs/CollectionFileLogger.php new file mode 100644 index 0000000..05f2040 --- /dev/null +++ b/tests/Stubs/CollectionFileLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final class CollectionFileLogger implements CollectionLogger {} diff --git a/tests/Stubs/CollectionInjectionCircularA.php b/tests/Stubs/CollectionInjectionCircularA.php new file mode 100644 index 0000000..945dd38 --- /dev/null +++ b/tests/Stubs/CollectionInjectionCircularA.php @@ -0,0 +1,26 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\InjectAll; + +/** + * @since 1.0.0 + */ +final readonly class CollectionInjectionCircularA { + public function __construct ( + #[InjectAll(CollectionLogger::class)] + public array $loggers + ) {} +} diff --git a/tests/Stubs/CollectionInjectionCircularB.php b/tests/Stubs/CollectionInjectionCircularB.php new file mode 100644 index 0000000..7ea9611 --- /dev/null +++ b/tests/Stubs/CollectionInjectionCircularB.php @@ -0,0 +1,21 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class CollectionInjectionCircularB implements CollectionLogger { + public function __construct (public CollectionInjectionCircularA $dependency) {} +} diff --git a/tests/Stubs/CollectionInjectionConflictingAttributesConsumer.php b/tests/Stubs/CollectionInjectionConflictingAttributesConsumer.php new file mode 100644 index 0000000..9f9db85 --- /dev/null +++ b/tests/Stubs/CollectionInjectionConflictingAttributesConsumer.php @@ -0,0 +1,29 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\ { + InjectAll, Tagged +}; + +/** + * @since 1.0.0 + */ +final readonly class CollectionInjectionConflictingAttributesConsumer { + public function __construct ( + #[InjectAll(CollectionLogger::class)] + #[Tagged('logging')] + public array $loggers + ) {} +} diff --git a/tests/Stubs/CollectionInjectionConsumer.php b/tests/Stubs/CollectionInjectionConsumer.php new file mode 100644 index 0000000..9ccde8c --- /dev/null +++ b/tests/Stubs/CollectionInjectionConsumer.php @@ -0,0 +1,27 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\InjectAll; + +/** + * @since 1.0.0 + */ +final readonly class CollectionInjectionConsumer { + /** @param list $loggers */ + public function __construct ( + #[InjectAll(CollectionLogger::class)] + public array $loggers + ) {} +} diff --git a/tests/Stubs/CollectionInjectionDefaultConsumer.php b/tests/Stubs/CollectionInjectionDefaultConsumer.php new file mode 100644 index 0000000..8de04d8 --- /dev/null +++ b/tests/Stubs/CollectionInjectionDefaultConsumer.php @@ -0,0 +1,27 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\InjectAll; + +/** + * @since 1.0.0 + */ +final readonly class CollectionInjectionDefaultConsumer { + /** @param list $loggers */ + public function __construct ( + #[InjectAll(CollectionLogger::class)] + public array $loggers = [] + ) {} +} diff --git a/tests/Stubs/CollectionInjectionEmptyTypeConsumer.php b/tests/Stubs/CollectionInjectionEmptyTypeConsumer.php new file mode 100644 index 0000000..7e35d96 --- /dev/null +++ b/tests/Stubs/CollectionInjectionEmptyTypeConsumer.php @@ -0,0 +1,26 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\InjectAll; + +/** + * @since 1.0.0 + */ +final readonly class CollectionInjectionEmptyTypeConsumer { + public function __construct ( + #[InjectAll('')] + public array $loggers + ) {} +} diff --git a/tests/Stubs/CollectionInjectionMultipleAttributesConsumer.php b/tests/Stubs/CollectionInjectionMultipleAttributesConsumer.php new file mode 100644 index 0000000..779a04c --- /dev/null +++ b/tests/Stubs/CollectionInjectionMultipleAttributesConsumer.php @@ -0,0 +1,27 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\InjectAll; + +/** + * @since 1.0.0 + */ +final readonly class CollectionInjectionMultipleAttributesConsumer { + public function __construct ( + #[InjectAll(CollectionLogger::class)] + #[InjectAll(CollectionLogger::class)] + public array $loggers + ) {} +} diff --git a/tests/Stubs/CollectionInjectionNestedConsumer.php b/tests/Stubs/CollectionInjectionNestedConsumer.php new file mode 100644 index 0000000..87519c5 --- /dev/null +++ b/tests/Stubs/CollectionInjectionNestedConsumer.php @@ -0,0 +1,21 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class CollectionInjectionNestedConsumer { + public function __construct (public CollectionInjectionConsumer $consumer) {} +} diff --git a/tests/Stubs/CollectionInjectionNullableConsumer.php b/tests/Stubs/CollectionInjectionNullableConsumer.php new file mode 100644 index 0000000..8a6e5dd --- /dev/null +++ b/tests/Stubs/CollectionInjectionNullableConsumer.php @@ -0,0 +1,26 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\InjectAll; + +/** + * @since 1.0.0 + */ +final readonly class CollectionInjectionNullableConsumer { + public function __construct ( + #[InjectAll(CollectionLogger::class)] + public ?array $loggers + ) {} +} diff --git a/tests/Stubs/CollectionInjectionScalarConsumer.php b/tests/Stubs/CollectionInjectionScalarConsumer.php new file mode 100644 index 0000000..2ba327f --- /dev/null +++ b/tests/Stubs/CollectionInjectionScalarConsumer.php @@ -0,0 +1,26 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\InjectAll; + +/** + * @since 1.0.0 + */ +final readonly class CollectionInjectionScalarConsumer { + public function __construct ( + #[InjectAll(CollectionLogger::class)] + public string $loggers + ) {} +} diff --git a/tests/Stubs/CollectionInjectionUnknownTypeConsumer.php b/tests/Stubs/CollectionInjectionUnknownTypeConsumer.php new file mode 100644 index 0000000..4035dab --- /dev/null +++ b/tests/Stubs/CollectionInjectionUnknownTypeConsumer.php @@ -0,0 +1,26 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\InjectAll; + +/** + * @since 1.0.0 + */ +final readonly class CollectionInjectionUnknownTypeConsumer { + public function __construct ( + #[InjectAll('FireHub\Tests\Container\Stubs\UnknownCollectionType')] + public array $loggers + ) {} +} diff --git a/tests/Stubs/CollectionInjectionUntypedConsumer.php b/tests/Stubs/CollectionInjectionUntypedConsumer.php new file mode 100644 index 0000000..1e81e97 --- /dev/null +++ b/tests/Stubs/CollectionInjectionUntypedConsumer.php @@ -0,0 +1,26 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\InjectAll; + +/** + * @since 1.0.0 + */ +final class CollectionInjectionUntypedConsumer { + public function __construct ( + #[InjectAll(CollectionLogger::class)] + public $loggers + ) {} +} diff --git a/tests/Stubs/CollectionLogger.php b/tests/Stubs/CollectionLogger.php new file mode 100644 index 0000000..b226b4b --- /dev/null +++ b/tests/Stubs/CollectionLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface CollectionLogger {} diff --git a/tests/Stubs/CollectionOtherService.php b/tests/Stubs/CollectionOtherService.php new file mode 100644 index 0000000..c57f64e --- /dev/null +++ b/tests/Stubs/CollectionOtherService.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final class CollectionOtherService {} diff --git a/tests/Stubs/CollectionUnrelatedService.php b/tests/Stubs/CollectionUnrelatedService.php new file mode 100644 index 0000000..2bb7455 --- /dev/null +++ b/tests/Stubs/CollectionUnrelatedService.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final class CollectionUnrelatedService {} diff --git a/tests/Unit/CollectionInjectionTest.php b/tests/Unit/CollectionInjectionTest.php new file mode 100644 index 0000000..a8198fb --- /dev/null +++ b/tests/Unit/CollectionInjectionTest.php @@ -0,0 +1,521 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Unit; + +use FireHub\Testing\FireHubTestCase; +use FireHub\Container\Container; +use FireHub\Container\Exception\ { + CircularDependencyException, ScopeRequiredException, ServiceInstantiationException}; +use FireHub\Tests\Container\Support\ExceptionAssertions; +use FireHub\Tests\Container\Stubs\ { + CollectionConsoleLogger, CollectionFileLogger, CollectionInjectionCircularA, CollectionInjectionCircularB, + CollectionInjectionConflictingAttributesConsumer, CollectionInjectionConsumer, CollectionInjectionDefaultConsumer, + CollectionInjectionEmptyTypeConsumer, CollectionInjectionMultipleAttributesConsumer, + CollectionInjectionNestedConsumer, CollectionInjectionNullableConsumer, CollectionInjectionScalarConsumer, + CollectionInjectionUnknownTypeConsumer, CollectionInjectionUntypedConsumer, CollectionUnrelatedService +}; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests collection constructor injection + * @since 1.0.0 + */ +#[Small] +#[Group('container')] +#[CoversClass(Container::class)] +final class CollectionInjectionTest extends FireHubTestCase { + + /** + * @since 1.0.0 + */ + use ExceptionAssertions; + + /** + * ### Collection attribute injects services in registration order + * @since 1.0.0 + * + * @return void + */ + public function testInjectsRegisteredImplementationsInRegistrationOrder ():void { + + $container = new Container; + + $container->singleton(CollectionConsoleLogger::class); + $container->singleton(CollectionUnrelatedService::class); + $container->singleton(CollectionFileLogger::class); + + $consumer = $container->get(CollectionInjectionConsumer::class); + + self::assertCount(2, $consumer->loggers); + self::assertInstanceOf(CollectionConsoleLogger::class, $consumer->loggers[0]); + self::assertInstanceOf(CollectionFileLogger::class, $consumer->loggers[1]); + + } + + /** + * ### Collection attribute injects an empty array for no registrations + * @since 1.0.0 + * + * @return void + */ + public function testNoRegistrationsInjectEmptyArray ():void { + + $consumer = (new Container)->get(CollectionInjectionConsumer::class); + + self::assertSame([], $consumer->loggers); + + } + + /** + * ### Collection attribute ignores unregistered implementations + * @since 1.0.0 + * + * @return void + */ + public function testUnregisteredConstructibleImplementationIsNotDiscovered ():void { + + $container = new Container; + + $consumer = $container->get(CollectionInjectionConsumer::class); + + self::assertSame([], $consumer->loggers); + + } + + /** + * ### Collection attribute overrides constructor default + * @since 1.0.0 + * + * @return void + */ + public function testInjectionOverridesConstructorDefault ():void { + + $container = new Container; + + $container->singleton(CollectionFileLogger::class); + + $consumer = $container->get(CollectionInjectionDefaultConsumer::class); + + self::assertCount(1, $consumer->loggers); + + } + + /** + * ### Collection attribute overrides parameter binding + * @since 1.0.0 + * + * @return void + */ + public function testExplicitParameterBindingOverridesInjection ():void { + + $container = new Container; + + $override = [new CollectionConsoleLogger]; + + $container->bindParameters( + CollectionInjectionConsumer::class, + ['loggers' => $override] + ); + + $consumer = $container->get(CollectionInjectionConsumer::class); + + self::assertSame($override, $consumer->loggers); + + } + + /** + * ### Collection attribute works in nested dependency graphs + * @since 1.0.0 + * + * @return void + */ + public function testNestedGraphUsesCollectionInjection ():void { + + $container = new Container; + + $container->singleton(CollectionFileLogger::class); + + $nested = $container->get(CollectionInjectionNestedConsumer::class); + + self::assertInstanceOf( + CollectionFileLogger::class, + $nested->consumer->loggers[0] + ); + + } + + /** + * ### Collection attribute uses singleton instances + * @since 1.0.0 + * + * @return void + */ + public function testInjectionUsesSingletonInstances ():void { + + $container = new Container; + + $container->singleton(CollectionFileLogger::class); + + $first = $container->get(CollectionInjectionConsumer::class); + $second = $container->get(CollectionInjectionConsumer::class); + + self::assertSame($first->loggers[0], $second->loggers[0]); + self::assertSame( + $first->loggers[0], + $container->get(CollectionFileLogger::class) + ); + + } + + /** + * ### Collection attribute creates transient instances + * @since 1.0.0 + * + * @return void + */ + public function testInjectionCreatesTransientInstances ():void { + + $container = new Container; + + $container->transient(CollectionFileLogger::class); + + $first = $container->get(CollectionInjectionConsumer::class); + $second = $container->get(CollectionInjectionConsumer::class); + + self::assertNotSame($first->loggers[0], $second->loggers[0]); + + } + + /** + * ### Collection attribute requires a scope for scoped services + * @since 1.0.0 + * + * @return void + */ + public function testInjectionRequiresScopeForScopedServices ():void { + + $container = new Container; + + $container->scoped(CollectionFileLogger::class); + + $this->assertThrows( + ScopeRequiredException::class, + static function () use ($container):void { + + $container->get(CollectionInjectionConsumer::class); + + } + ); + + } + + /** + * ### Collection attribute reuses scoped instances + * @since 1.0.0 + * + * @return void + */ + public function testInjectionReusesScopedInstances ():void { + + $container = new Container; + + $container->scoped(CollectionFileLogger::class); + + $scope = $container->createScope(); + + try { + + $first = $scope->get(CollectionInjectionConsumer::class); + $second = $scope->get(CollectionInjectionConsumer::class); + + self::assertSame($first->loggers[0], $second->loggers[0]); + self::assertSame( + $first->loggers[0], + $scope->get(CollectionFileLogger::class) + ); + + } finally { + + $scope->close(); + + } + + } + + /** + * ### Collection attribute isolates different scopes + * @since 1.0.0 + * + * @return void + */ + public function testInjectionIsolatesDifferentScopes ():void { + + $container = new Container; + + $container->scoped(CollectionFileLogger::class); + + $firstScope = $container->createScope(); + $secondScope = $container->createScope(); + + try { + + $first = $firstScope->get(CollectionInjectionConsumer::class); + $second = $secondScope->get(CollectionInjectionConsumer::class); + + self::assertNotSame($first->loggers[0], $second->loggers[0]); + + } finally { + + $firstScope->close(); + $secondScope->close(); + + } + + } + + /** + * ### Collection attribute supports factories + * @since 1.0.0 + * + * @return void + */ + public function testInjectionSupportsFactories ():void { + + $container = new Container; + + $executions = 0; + + $container->factory( + CollectionFileLogger::class, + static function ($resolver) use (&$executions):object { + + $executions++; + + return new CollectionFileLogger; + + } + ); + + self::assertSame(0, $executions); + + $consumer = $container->get(CollectionInjectionConsumer::class); + + self::assertInstanceOf(CollectionFileLogger::class, $consumer->loggers[0]); + self::assertSame(1, $executions); + + } + + /** + * ### Collection attribute sees registrations added after first resolution + * @since 1.0.0 + * + * @return void + */ + public function testInjectionSeesRegistrationsAddedAfterFirstResolution ():void { + + $container = new Container; + + self::assertSame( + [], + $container->get(CollectionInjectionConsumer::class)->loggers + ); + + $container->singleton(CollectionFileLogger::class); + + self::assertCount( + 1, + $container->get(CollectionInjectionConsumer::class)->loggers + ); + + } + + /** + * ### Collection attribute works after container freezing + * @since 1.0.0 + * + * @return void + */ + public function testInjectionWorksAfterFreeze ():void { + + $container = new Container; + + $container->singleton(CollectionFileLogger::class); + $container->freeze(); + + self::assertCount( + 1, + $container->get(CollectionInjectionConsumer::class)->loggers + ); + + } + + /** + * ### Collection attribute preserves circular dependency detection + * @since 1.0.0 + * + * @return void + */ + public function testInjectionPreservesCircularDependencyDetection ():void { + + $container = new Container; + + $container->transient(CollectionInjectionCircularB::class); + + $this->assertThrows( + CircularDependencyException::class, + static function () use ($container):void { + + $container->get(CollectionInjectionCircularA::class); + + } + ); + + } + + /** + * ### Collection attribute rejects nullable array + * @since 1.0.0 + * + * @return void + */ + public function testRejectsNullableArray ():void { + + $this->assertThrows( + ServiceInstantiationException::class, + static function ():void { + + (new Container)->get(CollectionInjectionNullableConsumer::class); + + } + ); + + } + + /** + * ### Collection attribute rejects scalar types + * @since 1.0.0 + * + * @return void + */ + public function testRejectsScalar ():void { + + $this->assertThrows( + ServiceInstantiationException::class, + static function ():void { + + (new Container)->get(CollectionInjectionScalarConsumer::class); + + } + ); + + } + + /** + * ### Collection attribute rejects untyped elements + * @since 1.0.0 + * + * @return void + */ + public function testRejectsUntyped ():void { + + $this->assertThrows( + ServiceInstantiationException::class, + static function ():void { + + (new Container)->get(CollectionInjectionUntypedConsumer::class); + + } + ); + + } + + /** + * ### Collection attribute rejects unknown element types + * @since 1.0.0 + * + * @return void + */ + public function testRejectsUnknownElementType ():void { + + $this->assertThrows( + ServiceInstantiationException::class, + static function ():void { + + (new Container)->get(CollectionInjectionUnknownTypeConsumer::class); + + } + ); + + } + + /** + * ### Collection attribute rejects empty element types + * @since 1.0.0 + * + * @return void + */ + public function testRejectsEmptyElementType ():void { + + $this->assertThrows( + ServiceInstantiationException::class, + static function ():void { + + (new Container)->get(CollectionInjectionEmptyTypeConsumer::class); + + } + ); + + } + + /** + * ### Collection attribute rejects duplicate attributes + * @since 1.0.0 + * + * @return void + */ + public function testRejectsDuplicateAttributes ():void { + + $this->assertThrows( + ServiceInstantiationException::class, + static function ():void { + + (new Container)->get(CollectionInjectionMultipleAttributesConsumer::class); + + } + ); + + } + + /** + * ### Collection attribute rejects conflicting tagged and inject all + * @since 1.0.0 + * + * @return void + */ + public function testRejectsConflictingTaggedAndInjectAll ():void { + + $this->assertThrows( + ServiceInstantiationException::class, + static function ():void { + + (new Container)->get(CollectionInjectionConflictingAttributesConsumer::class); + + } + ); + + } + +} \ No newline at end of file diff --git a/tests/Unit/CollectionServicesTest.php b/tests/Unit/CollectionServicesTest.php new file mode 100644 index 0000000..e4c2768 --- /dev/null +++ b/tests/Unit/CollectionServicesTest.php @@ -0,0 +1,532 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Unit; + +use FireHub\Testing\FireHubTestCase; +use FireHub\Container\Container; +use FireHub\Container\Exception\ { + CircularDependencyException, ScopeClosedException, ScopeRequiredException, ServiceTypeMismatchException +}; +use FireHub\Tests\Container\Support\ExceptionAssertions; +use FireHub\Tests\Container\Stubs\ { + CollectionAbstractLogger, CollectionAlternateImplementation, CollectionConsoleLogger, + CollectionConcreteAbstractLogger, CollectionDatabaseLogger, CollectionFileLogger, CollectionLogger, + CollectionOtherService, CollectionUnrelatedService +}; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests collection service resolution + * @since 1.0.0 + */ +#[Small] +#[Group('container')] +#[CoversClass(Container::class)] +final class CollectionServicesTest extends FireHubTestCase { + + /** + * @since 1.0.0 + */ + use ExceptionAssertions; + + /** + * ### All returns empty for unknown implementations + * @since 1.0.0 + * + * @return void + */ + public function testAllReturnsEmptyForUnknownImplementations ():void { + + $container = new Container; + + self::assertSame([], $container->all(CollectionLogger::class)); + + $scope = $container->createScope(); + + try { + + self::assertSame([], $scope->all(CollectionLogger::class)); + + } finally { + + $scope->close(); + + } + + } + + /** + * ### All preserves registration order and filters types + * @since 1.0.0 + * + * @return void + */ + public function testAllPreservesRegistrationOrderAndFiltersTypes ():void { + + $container = new Container; + + $container->singleton(CollectionDatabaseLogger::class); + $container->singleton(CollectionUnrelatedService::class); + $container->singleton(CollectionFileLogger::class); + + $services = $container->all(CollectionLogger::class); + + self::assertCount(2, $services); + self::assertInstanceOf(CollectionDatabaseLogger::class, $services[0]); + self::assertInstanceOf(CollectionFileLogger::class, $services[1]); + + } + + /** + * ### All does not discover unregistered classes + * @since 1.0.0 + * + * @return void + */ + public function testAllDoesNotDiscoverUnregisteredClasses ():void { + + self::assertSame( + [], + (new Container)->all(CollectionLogger::class) + ); + + } + + /** + * ### All supports concrete types + * @since 1.0.0 + * + * @return void + */ + public function testAllSupportsConcreteType ():void { + + $container = new Container; + + $container->singleton(CollectionFileLogger::class); + + self::assertCount( + 1, + $container->all(CollectionFileLogger::class) + ); + + } + + /** + * ### All supports abstract types + * @since 1.0.0 + * + * @return void + */ + public function testAllSupportsAbstractType ():void { + + $container = new Container; + + $container->singleton(CollectionConcreteAbstractLogger::class); + + $services = $container->all(CollectionAbstractLogger::class); + + self::assertCount(1, $services); + self::assertInstanceOf( + CollectionConcreteAbstractLogger::class, + $services[0] + ); + + } + + /** + * ### All supports inherited interfaces + * @since 1.0.0 + * + * @return void + */ + public function testAllSupportsInheritedInterfaces ():void { + + $container = new Container; + + $container->singleton(CollectionAlternateImplementation::class); + + self::assertCount( + 1, + $container->all(CollectionLogger::class) + ); + + } + + /** + * ### All reuses singletons + * @since 1.0.0 + * + * @return void + */ + public function testAllReusesSingletons ():void { + + $container = new Container; + + $container->singleton(CollectionFileLogger::class); + + self::assertSame( + $container->all(CollectionLogger::class)[0], + $container->all(CollectionLogger::class)[0] + ); + + } + + /** + * ### All recreates transients + * @since 1.0.0 + * + * @return void + */ + public function testAllRecreatesTransients ():void { + + $container = new Container; + + $container->transient(CollectionFileLogger::class); + + self::assertNotSame( + $container->all(CollectionLogger::class)[0], + $container->all(CollectionLogger::class)[0] + ); + + } + + /** + * ### All requires scope for scoped services + * @since 1.0.0 + * + * @return void + */ + public function testAllRequiresScopeForScopedServices ():void { + + $container = new Container; + + $container->scoped(CollectionFileLogger::class); + + $this->assertThrows( + ScopeRequiredException::class, + static function () use ($container):void { + + $container->all(CollectionLogger::class); + + } + ); + + } + + /** + * ### Scope::all preserves mixed lifetimes + * @since 1.0.0 + * + * @return void + */ + public function testScopeAllPreservesMixedLifetimes ():void { + + $container = new Container; + + $container->singleton(CollectionFileLogger::class); + $container->scoped(CollectionConsoleLogger::class); + $container->transient(CollectionDatabaseLogger::class); + + $firstScope = $container->createScope(); + $secondScope = $container->createScope(); + + try { + + $first = $firstScope->all(CollectionLogger::class); + $second = $firstScope->all(CollectionLogger::class); + $third = $secondScope->all(CollectionLogger::class); + + self::assertCount(3, $first); + + // Singleton instances are shared across scopes. + self::assertSame($first[0], $second[0]); + self::assertSame($first[0], $third[0]); + + // Scoped instances are shared within the same scope. + self::assertSame($first[1], $second[1]); + self::assertNotSame($first[1], $third[1]); + + // Transient instances are recreated. + self::assertNotSame($first[2], $second[2]); + self::assertNotSame($first[2], $third[2]); + + } finally { + + $firstScope->close(); + $secondScope->close(); + + } + + } + + /** + * ### Scope::all rejects closed scope + * @since 1.0.0 + * + * @return void + */ + public function testScopeAllRejectsClosedScope ():void { + + $scope = (new Container)->createScope(); + + $scope->close(); + + $this->assertThrows( + ScopeClosedException::class, + static function () use ($scope):void { + + $scope->all(CollectionLogger::class); + + } + ); + + } + + /** + * ### All deduplicates alias after target + * @since 1.0.0 + * + * @return void + */ + public function testAllDeduplicatesAliasAfterTarget ():void { + + $container = new Container; + + $container->singleton(CollectionFileLogger::class); + $container->alias(CollectionLogger::class, CollectionFileLogger::class); + + $services = $container->all(CollectionLogger::class); + + self::assertCount(1, $services); + self::assertSame( + $container->get(CollectionFileLogger::class), + $services[0] + ); + + } + + /** + * ### All deduplicates alias before target + * @since 1.0.0 + * + * @return void + */ + public function testAllDeduplicatesAliasBeforeTarget ():void { + + $container = new Container; + + $container->alias(CollectionLogger::class, CollectionFileLogger::class); + $container->singleton(CollectionFileLogger::class); + + $services = $container->all(CollectionLogger::class); + + self::assertCount(1, $services); + self::assertSame( + $container->get(CollectionFileLogger::class), + $services[0] + ); + + } + + /** + * ### All resolves factory without eager discovery + * @since 1.0.0 + * + * @return void + */ + public function testAllResolvesFactoryWithoutEagerDiscovery ():void { + + $container = new Container; + + $calls = 0; + + $container->factory( + CollectionFileLogger::class, + static function ($resolver) use (&$calls):object { + + $calls++; + + return new CollectionFileLogger; + + } + ); + + self::assertSame(0, $calls); + + $services = $container->all(CollectionLogger::class); + + self::assertCount(1, $services); + self::assertSame(1, $calls); + + } + + /** + * ### All rejects factory returning incompatible type + * @since 1.0.0 + * + * @return void + */ + public function testAllRejectsFactoryReturningIncompatibleType ():void { + + $container = new Container; + + $container->factory( + CollectionFileLogger::class, + static fn ($resolver):object => new CollectionOtherService + ); + + $this->assertThrows( + ServiceTypeMismatchException::class, + static function () use ($container):void { + + $container->all(CollectionLogger::class); + + } + ); + + } + + /** + * ### All works after a freeze + * @since 1.0.0 + * + * @return void + */ + public function testAllWorksAfterFreeze ():void { + + $container = new Container; + + $container->singleton(CollectionFileLogger::class); + $container->freeze(); + + self::assertCount( + 1, + $container->all(CollectionLogger::class) + ); + + } + + /** + * ### All detects self-referential alias + * @since 1.0.0 + * + * @return void + */ + public function testAllDetectsSelfReferentialAlias ():void { + + $container = new Container; + + $container->alias( + CollectionLogger::class, + CollectionLogger::class + ); + + $this->assertThrows( + CircularDependencyException::class, + static function () use ($container):void { + + $container->all(CollectionLogger::class); + + } + ); + + } + + /** + * ### All detects multi-step alias cycle + * @since 1.0.0 + * + * @return void + */ + public function testAllDetectsMultiStepAliasCycle ():void { + + $container = new Container; + + $container->alias( + CollectionLogger::class, + CollectionFileLogger::class + ); + + $container->alias( + CollectionFileLogger::class, + CollectionConsoleLogger::class + ); + + $container->alias( + CollectionConsoleLogger::class, + CollectionLogger::class + ); + + $this->assertThrows( + CircularDependencyException::class, + static function () use ($container):void { + + $container->all(CollectionLogger::class); + + } + ); + + } + + /** + * ### All matches instance definitions + * @since 1.0.0 + * + * @return void + */ + public function testAllMatchesInstanceDefinitions ():void { + + $container = new Container; + + $instance = new CollectionFileLogger; + + $container->instance( + CollectionFileLogger::class, + $instance + ); + + self::assertSame( + $instance, + $container->all(CollectionLogger::class)[0] + ); + + } + + /** + * ### All reflects registrations after earlier lookup + * @since 1.0.0 + * + * @return void + */ + public function testAllReflectsRegistrationsAfterEarlierLookup ():void { + + $container = new Container; + + self::assertSame( + [], + $container->all(CollectionLogger::class) + ); + + $container->singleton(CollectionFileLogger::class); + + self::assertCount( + 1, + $container->all(CollectionLogger::class) + ); + + } + +} \ No newline at end of file From a4b55a081a8a210f5c84d2126db080ce67ac0d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Wed, 21 Oct 2026 15:47:15 +0200 Subject: [PATCH 4/5] Update changelog for version 0.2.0 and README for version badge --- CHANGELOG.md | 19 ++++++++++++++++++- README.md | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f7a7a4..40b0c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[v0.2.0](https://github.com/The-FireHub-Project/Container/compare/v0.1.0...v0.2.0)] – 2026-10-21 + +### Added +* Contextual bindings by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2 +* Union i intersection dependency resolution by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2 +* Tagged services by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2 +* Tagged constructor injection by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2 +* Collection injection by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2 +* Circular dependency detection by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2 +* Alias deduplication by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2 + ## [v0.1.0] – 2026-10-21 ### Added -* Implement initial functionality by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1 \ No newline at end of file +* Service registration i resolution by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1 +* Singleton, scoped, transient lifetimes by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1 +* Constructor autowiring by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1 +* Parameter binding by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1 +* Service aliases i factories by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1 +* Scope management by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1 +* Container freezing by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1 \ No newline at end of file diff --git a/README.md b/README.md index 5be8a96..cdd9560 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ /> version From d6b999bd1bd10583b13feb09de27edad4033c2d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= <56673130+dieselxxx@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:48:32 +0200 Subject: [PATCH 5/5] Update README to reflect current repository status Removed outdated badges and updated repository purpose section. --- README.md | 86 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 52 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index cdd9560..a6976a8 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,6 @@ /> -[![PHPStan](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPStan.yml/badge.svg?branch=develop)](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPStan.yml) -[![PHPUnit](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPUnit.yml/badge.svg?branch=develop)](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPUnit.yml) -[![Codecov](https://codecov.io/gh/The-FireHub-Project/Container/branch/develop/graph/badge.svg?token=XW2YEONF51)](https://app.codecov.io/gh/The-FireHub-Project/Container/tree/develop) -

FireHub Icon Development Branch +## FireHub Icon Repository Purpose + +FireHub Container is the dependency injection and service resolution component of the FireHub ecosystem. + +Built on the contracts provided by FireHub Core, it supplies the mechanisms required to register, construct, resolve, and manage application services. + +The purpose of this repository is to centralize dependency management while preserving separation of concerns between service definitions, runtime implementation, and application execution. -⚠️ **This is the `develop` branch** +The Container architecture is designed to support: -- Unstable -- APIs may change without notice -- Not intended for production use +- Service registration and dependency resolution +- Interface-to-implementation bindings +- Instance, factory, and class-based service definitions +- Singleton and transient service lifetimes +- Dependency graph resolution and circular dependency detection +- Immutable service registrations after container construction +- Future support for autowiring and execution-scoped services -## FireHub Icon Branch Purpose +FireHub Container follows the architectural principles of The FireHub Project by separating responsibilities between contracts, dependency management, and application lifecycle orchestration. -The `develop` branch is the **primary integration branch** for all ongoing development. +The Container does not control application execution or kernel lifecycle transitions. Instead, it provides dependency management services that can be integrated into applications through the appropriate bootstrap and composition mechanisms. -It serves as the staging area where: -- Feature branches are merged -- Bug fixes are integrated -- Experimental work is stabilized -- Code is prepared for upcoming releases +## FireHub Icon Security -All **release branches** are created **from `develop`**. +For security reporting and guidelines, please refer to our [SECURITY.md](https://github.com/The-FireHub-Project/.github/blob/master/.github/SECURITY.md) file. -## Stability Guarantee +## FireHub Icon Community -❌ No backward compatibility guarantee -❌ APIs may change without notice -❌ Behavior may be incomplete or inconsistent -❌ Breaking changes are expected +![GitHub watchers](https://img.shields.io/github/watchers/The-FireHub-Project/Container?style=social) +![GitHub Repo stars](https://img.shields.io/github/stars/The-FireHub-Project/Container?style=social) +![GitHub forks](https://img.shields.io/github/forks/The-FireHub-Project/Container?style=social) -This branch is intended **only for contributors and advanced testers**. +- Be sure to review the [Code of Conduct](https://github.com/The-FireHub-Project/.github/blob/master/.github/CODE_OF_CONDUCT.md). +- Read a [Contributing](https://github.com/The-FireHub-Project/.github/blob/master/.github/CONTRIBUTING.md) file to see how you can contribute to this project. +- Read our [Security policy](https://github.com/The-FireHub-Project/.github/blob/master/.github/SECURITY.md) file to see how you can report a vulnerability. +- Review the project [Governance model](https://github.com/The-FireHub-Project/.github/blob/master/.github/GOVERNANCE.md), including roles, decision-making, RFC process, and releases. -## FireHub Icon Composer Usage (Not Recommended) +## FireHub Icon Support + +

+ + GitHub in-progress issues + + GitHub total issues + + GitHub opened pull requests +

-For internal testing only: +For help with FireHub products, please refer to the following resources: -```json -{ - "require": { - "the-firehub-project/container": "dev-develop" - } -} -``` -⚠️ Never use dev-develop in production. +- **General questions & discussions**: [GitHub Discussions](https://github.com/orgs/The-FireHub-Project/discussions) +- **Bug reports & issues**: [Support](https://github.com/The-FireHub-Project/Container/issues) +- **Documentation & guides**: [FireHub Docs](https://the-firehub-project.github.io) +- **Commercial support** (Pro/Enterprise): contact your account manager ## FireHub Icon Authors and Contributors @@ -134,4 +152,4 @@ Architecture guidelines, design principles, and ecosystem documentation are avai This software is licensed under the Apache-2.0 License. -For more details, read the full license [here](./LICENSE). \ No newline at end of file +For more details, read the full license [here](./LICENSE).