diff --git a/CHANGELOG.md b/CHANGELOG.md index 40b0c6c..ba319e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ 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.3.0](https://github.com/The-FireHub-Project/Container/compare/v0.2.0...v0.3.0)] – 2026-10-22 + +### Added +* Disposable service lifecycle management by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3 +* Automatic disposal of singleton and scoped services by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3 +* Container and scope shutdown support by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3 +* Disposable resource tracking with identity deduplication and reverse disposal order by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3 +* Active scope tracking to prevent premature container shutdown by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3 +* Shared container lifecycle state management by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3 + +### Changed +* Centralized container freezing and closure state management in `ContainerState`. +* Enforced container lifecycle restrictions across dependency resolution operations. +* Improved scope cleanup and resource release during shutdown. + +### Fixed +* Prevented dependency resolution after container closure, including cached singleton instances. +* Ensured container shutdown remains idempotent. +* Ensured disposable resources are released even when individual disposal operations fail. +* Prevented container shutdown while active scopes remain registered. + + ## [[v0.2.0](https://github.com/The-FireHub-Project/Container/compare/v0.1.0...v0.2.0)] – 2026-10-21 ### Added diff --git a/README.md b/README.md index cdd9560..2e44657 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ /> version @@ -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). diff --git a/src/Container.php b/src/Container.php index a7f9328..ba31a08 100644 --- a/src/Container.php +++ b/src/Container.php @@ -21,10 +21,11 @@ Alias, Cls, Factory, Instance }; use FireHub\Container\Internal\ { - ContextualBindingRegistry, InstanceStore, ParameterBindingRegistry, ResolutionContext, TagRegistry + ContainerState, ContextualBindingRegistry, DisposalTracker, InstanceStore, ParameterBindingRegistry, + ResolutionContext, ScopeRegistry, TagRegistry }; use FireHub\Container\Exception\ { - ContainerFrozenException, InvalidTagNameException, ServiceNotFoundException + ContainerActiveScopesException, InvalidTagNameException, ServiceNotFoundException }; use FireHub\Runtime; use Closure; @@ -45,43 +46,61 @@ * resolution scope. * @since 1.0.0 */ -final class Container implements ContainerBoundary, Registrar { +final readonly class Container implements ContainerBoundary, Registrar { /** * ### Service registry * @since 1.0.0 */ - private readonly Registry $registry; + private Registry $registry; /** * ### Service resolver * @since 1.0.0 */ - private readonly Resolver $resolver; + private Resolver $resolver; /** * ### Constructor parameter binding registry * @since 1.0.0 */ - private readonly ParameterBindingRegistry $parameters; + private ParameterBindingRegistry $parameters; /** * ### Contextual dependency binding registry * @since 1.0.0 */ - private readonly ContextualBindingRegistry $contextual; + private ContextualBindingRegistry $contextual; + + /** + * ### Singleton instance store + * @since 1.0.0 + */ + private InstanceStore $singletons; /** * ### Service tag registry * @since 1.0.0 */ - private readonly TagRegistry $tags; + private TagRegistry $tags; + + /** + * ### Active scope registry + * @since 1.0.0 + */ + private ScopeRegistry $scopes; /** - * ### Container freeze state - * @var bool + * ### Singleton disposable resource tracker + * @since 1.0.0 + */ + private DisposalTracker $disposables; + + /** + * ### Container lifecycle state + * @since 1.0.0 */ - private bool $frozen = false; + private ContainerState $state; /** * ### Constructor @@ -94,14 +113,20 @@ public function __construct () { $this->registry = new Registry; $this->parameters = new ParameterBindingRegistry; $this->contextual = new ContextualBindingRegistry; + $this->singletons = new InstanceStore; $this->tags = new TagRegistry; + $this->scopes = new ScopeRegistry; + $this->disposables = new DisposalTracker; + $this->state = new ContainerState; $this->resolver = new Resolver( $this->registry, - new InstanceStore, + $this->singletons, $this->parameters, $this->contextual, - $this->tags + $this->tags, + $this->disposables, + $this->state ); } @@ -118,10 +143,24 @@ public function __construct () { * * * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution. + * @uses \FireHub\Container\Internal\ScopeRegistry::register() To register a new scope. + * + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. */ public function createScope ():ScopeBoundary { - return new Scope($this->resolver); + $this->state->assertOpen(); + + $scope = new Scope( + $this->resolver, + $this->scopes + ); + + $this->scopes->register($scope); + + return $scope; } @@ -142,7 +181,7 @@ public function createScope ():ScopeBoundary { * * @since 1.0.0 * - * @uses \FireHub\Container\Container::assertMutable() To check if the container is frozen. + * @uses \FireHub\Container\Internal\ContainerState::assertMutable() To check if the container is frozen. * @uses \FireHub\Container\Registry::register() To register a service definition. * * @template TService of object @@ -156,12 +195,13 @@ public function createScope ():ScopeBoundary { * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return $this Container instance. */ public function register (string $id, Definition $definition):self { - $this->assertMutable(); + $this->state->assertMutable(); $this->registry->register($id, $definition); @@ -194,8 +234,8 @@ public function register (string $id, Definition $definition):self { * * @since 1.0.0 * - * @uses \FireHub\Container\Container::assertMutable() - * @uses \FireHub\Container\Internal\ContextualBindingRegistry::register() + * @uses \FireHub\Container\Internal\ContainerState::assertMutable() To check if the container is frozen. + * @uses \FireHub\Container\Internal\ContextualBindingRegistry::register() To register a contextual binding. * * @param class-string $consumer

* Consumer implementation class. @@ -209,12 +249,13 @@ public function register (string $id, Definition $definition):self { * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateContextualBindingException If the binding already exists. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return $this Container instance. */ public function bindContextual (string $consumer, string $dependency, string $implementation):self { - $this->assertMutable(); + $this->state->assertMutable(); $this->contextual->register( $consumer, @@ -243,6 +284,7 @@ public function bindContextual (string $consumer, string $dependency, string $im * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. */ public function bind (string $id, string $class, Lifetime $lifetime = Lifetime::TRANSIENT):self { @@ -288,6 +330,7 @@ public function bind (string $id, string $class, Lifetime $lifetime = Lifetime:: * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return $this Container instance. */ @@ -342,6 +385,7 @@ public function singleton (string $id, ?string $class = null):self { * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return $this Container instance. */ @@ -387,6 +431,7 @@ public function scoped (string $id, ?string $class = null):self { * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return $this Container instance. */ @@ -436,6 +481,7 @@ public function transient (string $id, ?string $class = null):self { * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. */ public function factory (string $id, Closure $factory, Lifetime $lifetime = Lifetime::TRANSIENT):self { @@ -469,6 +515,7 @@ public function factory (string $id, Closure $factory, Lifetime $lifetime = Life * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. */ public function instance (string $id, object $instance):self { @@ -499,6 +546,7 @@ public function instance (string $id, object $instance):self { * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. */ public function alias (string $id, string $target):self { @@ -537,7 +585,8 @@ public function alias (string $id, string $target):self { * * @since 1.0.0 * - * @uses \FireHub\Container\Container::assertMutable() To ensure the container accepts configuration changes. + * @uses \FireHub\Container\Internal\ContainerState::assertMutable() To ensure the container accepts + * configuration changes. * @uses \FireHub\Container\Internal\ParameterBindingRegistry::register() To register parameter bindings. * * @param class-string $class

@@ -548,12 +597,13 @@ public function alias (string $id, string $target):self { *

* * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return $this Container instance. */ public function bindParameters (string $class, array $parameters):self { - $this->assertMutable(); + $this->state->assertMutable(); $this->parameters->register($class, $parameters); @@ -583,6 +633,7 @@ public function bindParameters (string $class, array $parameters):self { * * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution. * @uses \FireHub\Container\Resolver::all() To resolve matching services. * * @template TService of object @@ -598,11 +649,14 @@ public function bindParameters (string $class, array $parameters):self { * @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\ContainerClosedException If the container is closed. * * @return list Matching service instances. */ public function all (string $type):array { + $this->state->assertOpen(); + return $this->resolver->all( $type, new ResolutionContext @@ -664,7 +718,7 @@ public function all (string $type):array { * * @since 1.0.0 * - * @uses \FireHub\Container\Container::assertMutable() To ensure the container is not frozen. + * @uses \FireHub\Container\Internal\ContainerState::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. @@ -677,6 +731,7 @@ public function all (string $type):array { *

* * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * @throws \FireHub\Container\Exception\ServiceNotFoundException If a service is not explicitly registered. * @throws \FireHub\Container\Exception\InvalidTagNameException If the tag name is empty. * @@ -684,7 +739,7 @@ public function all (string $type):array { */ public function tag (string $tag, string ...$services):self { - $this->assertMutable(); + $this->state->assertMutable(); if (Runtime\Str\SB\Transform::trim($tag) === '') throw new InvalidTagNameException; @@ -775,15 +830,20 @@ public function hasTag (string $tag):bool { * @since 1.0.0 * * @uses \FireHub\Container\Internal\TagRegistry::get() To retrieve tagged service identifiers. + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution. * * @param non-empty-string $tag

* Tag name. *

* + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * * @return list Tagged service identifiers. */ public function taggedIds (string $tag):array { + $this->state->assertOpen(); + return $this->tags->get($tag); } @@ -817,6 +877,7 @@ public function taggedIds (string $tag):array { * @since 1.0.0 * * @uses \FireHub\Container\Resolver::tagged() To resolve tagged services. + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution. * * @param non-empty-string $tag

* Service tag name. @@ -831,11 +892,14 @@ public function taggedIds (string $tag):array { * @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\ContainerClosedException If the container is closed. * * @return list Resolved service instances. */ public function tagged (string $tag):array { + $this->state->assertOpen(); + return $this->resolver->tagged( $tag, new ResolutionContext @@ -843,6 +907,62 @@ public function tagged (string $tag):array { } + /** + * ### Invokes a callable using dependency injection + * + * Executes the specified callable by combining explicitly supplied arguments with dependencies automatically + * resolved from the container. + * + * The callable may be a closure, named function, instance method, static method, or invokable object. + * + * Scoped dependencies require invocation through an active resolution scope. + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->singleton(LoggerInterface::class, FileLogger::class); + * + * $service = new UserService; + * + * $result = $container->invoke( + * [$service, 'create'], + * ['username' => 'Danijel'] + * ); + * + * + * @since 1.0.0 + * + * @uses \FireHub\Container\Resolver::invoke() To execute the callable using dependency injection. + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution. + * + * @param callable $callback

+ * Callable to execute. + *

+ * @param array $arguments [optional]

+ * Explicit positional and named arguments. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * + * @return mixed Value returned by the invoked callable. + */ + public function invoke (callable $callback, array $arguments = []):mixed { + + $this->state->assertOpen(); + + return $this->resolver->invoke( + $callback, + $arguments + ); + + } + /** * ### Resolves a registered service * @@ -864,6 +984,9 @@ public function tagged (string $tag):array { * * @since 1.0.0 * + * @uses \FireHub\Container\Resolver::get() To resolve the service. + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. + * * @template TService of object * * @param class-string $id

@@ -878,11 +1001,14 @@ public function tagged (string $tag):array { * @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. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return TService Resolved service. */ public function get (string $id):object { + $this->state->assertOpen(); + return $this->resolver->get($id); } @@ -908,18 +1034,119 @@ public function get (string $id):object { * * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. + * @uses \FireHub\Container\Internal\ServiceRegistry::has() To check service registration. + * * @param class-string $id

* Service identifier. *

* + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * * @return bool True if registered, false otherwise. */ public function has (string $id):bool { + $this->state->assertOpen(); + return $this->registry->has($id); } + /** + * ### Closes the dependency injection container + * + * Permanently closes the container and releases all owned singleton disposable + * service instances. + * + * The container cannot be closed while active scopes exist. + * + * Disposable services are released in reverse registration order. + * + * Singleton instances are cleared even if resource disposal fails. + * + * Closing an already closed container has no additional effect. + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->singleton(DatabaseConnection::class); + * + * $connection = $container->get(DatabaseConnection::class); + * + * try { + * + * $connection->connect(); + * + * } finally { + * + * $container->close(); + * + * } + * + * $container->isClosed(); // true + * + * + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\DisposalTracker::dispose() To release owned singleton resources. + * @uses \FireHub\Container\Internal\DisposableRegistry::clear() To clear owned singleton instances. + * @uses \FireHub\Container\Internal\ScopeRegistry::hasActive() To check for active scopes. + * @uses \FireHub\Container\Internal\ContainerState::close() To mark the container as closed. + * @uses \FireHub\Container\Internal\ContainerState::isClosed() To check if the container is closed. + * + * @throws \FireHub\Container\Exception\ServiceDisposalException If one or more services fail during disposal. + * @throws \FireHub\Container\Exception\ContainerActiveScopesException If a scope is still active. + * + * @return void + */ + public function close ():void { + + if ($this->state->isClosed()) return; + + if ($this->scopes->hasActive()) + throw new ContainerActiveScopesException; + + $this->state->close(); + + try { + + $this->disposables->dispose(); + + } finally { + + $this->singletons->clear(); + + } + + } + + /** + * ### Checks whether the container is closed + * + * + * $container = new Container; + * + * $container->isClosed(); // false + * + * $container->close(); + * + * $container->isClosed(); // true + * + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ContainerState::isClosed() To check if the container is closed. + * + * @return bool True if the container has been shut down. + */ + public function isClosed ():bool { + + return $this->state->isClosed(); + + } + /** * ### Freezes the container * @@ -947,11 +1174,15 @@ public function has (string $id):bool { * * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::freeze() To mark the container as frozen. + * + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * * @return void */ public function freeze ():void { - $this->frozen = true; + $this->state->freeze(); } @@ -972,28 +1203,13 @@ public function freeze ():void { * * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::isFrozen() To check if the container is frozen. + * * @return bool True if frozen, false otherwise. */ public function isFrozen ():bool { - return $this->frozen; - - } - - /** - * ### Ensures the container accepts registrations - * - * Prevents modification of service definitions after the container has been frozen. - * @since 1.0.0 - * - * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. - * - * @return void - */ - private function assertMutable ():void { - - if ($this->frozen) - throw new ContainerFrozenException; + return $this->state->isFrozen(); } diff --git a/src/Exception/AmbiguousDependencyException.php b/src/Exception/AmbiguousDependencyException.php index 24a9093..95f83fa 100644 --- a/src/Exception/AmbiguousDependencyException.php +++ b/src/Exception/AmbiguousDependencyException.php @@ -24,6 +24,11 @@ */ final class AmbiguousDependencyException extends RuntimeException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Multiple services match the requested dependency.'; } \ No newline at end of file diff --git a/src/Exception/CircularDependencyException.php b/src/Exception/CircularDependencyException.php index 183e044..406a556 100644 --- a/src/Exception/CircularDependencyException.php +++ b/src/Exception/CircularDependencyException.php @@ -21,6 +21,11 @@ */ final class CircularDependencyException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Circular dependency detected'; } \ No newline at end of file diff --git a/src/Exception/ContainerActiveScopesException.php b/src/Exception/ContainerActiveScopesException.php new file mode 100644 index 0000000..31a793f --- /dev/null +++ b/src/Exception/ContainerActiveScopesException.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.1 + * @package Container + */ + +namespace FireHub\Container\Exception; + +use FireHub\Core\Exception\Runtime\System\InvariantException; + +/** + * ### Thrown when a container is attempted to be modified while it is active + * @since 1.0.0 + */ +final class ContainerActiveScopesException extends InvariantException { + + /** + * @inheritDoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'The dependency injection container is currently active.'; + +} \ No newline at end of file diff --git a/src/Exception/ContainerClosedException.php b/src/Exception/ContainerClosedException.php new file mode 100644 index 0000000..0147dcc --- /dev/null +++ b/src/Exception/ContainerClosedException.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.1 + * @package Container + */ + +namespace FireHub\Container\Exception; + +use FireHub\Core\Exception\Runtime\System\InvariantException; + +/** + * ### Indicates that a container is closed + * @since 1.0.0 + */ +final class ContainerClosedException extends InvariantException { + + /** + * @inheritDoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'The container is closed'; + +} \ No newline at end of file diff --git a/src/Exception/ContainerFrozenException.php b/src/Exception/ContainerFrozenException.php index 703317e..5a840c1 100644 --- a/src/Exception/ContainerFrozenException.php +++ b/src/Exception/ContainerFrozenException.php @@ -21,6 +21,11 @@ */ final class ContainerFrozenException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Cannot register services in a frozen container'; } \ No newline at end of file diff --git a/src/Exception/DisposalTrackerClosedException.php b/src/Exception/DisposalTrackerClosedException.php new file mode 100644 index 0000000..c190945 --- /dev/null +++ b/src/Exception/DisposalTrackerClosedException.php @@ -0,0 +1,36 @@ + + * @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; + +/** + * ### Indicates that a disposal tracker is closed + * + * Thrown when a disposable instance is registered after the disposal process has started. + * + * A closed disposal tracker cannot accept additional instances, regardless of whether resource disposal is completed + * successfully or failed. + * @since 1.0.0 + */ +final class DisposalTrackerClosedException extends InvariantException { + + /** + * @inheritDoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'The disposal tracker is closed'; + +} \ No newline at end of file diff --git a/src/Exception/DuplicateContextualBindingException.php b/src/Exception/DuplicateContextualBindingException.php index 0309a6d..6ee477e 100644 --- a/src/Exception/DuplicateContextualBindingException.php +++ b/src/Exception/DuplicateContextualBindingException.php @@ -24,6 +24,11 @@ */ final class DuplicateContextualBindingException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'A contextual binding for the specified consumer and dependency is already registered'; } \ No newline at end of file diff --git a/src/Exception/DuplicateServiceException.php b/src/Exception/DuplicateServiceException.php index 282a8eb..233cb78 100644 --- a/src/Exception/DuplicateServiceException.php +++ b/src/Exception/DuplicateServiceException.php @@ -24,6 +24,11 @@ */ final class DuplicateServiceException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'A service with the specified identifier is already registered'; } \ No newline at end of file diff --git a/src/Exception/InvalidMethodArgumentException.php b/src/Exception/InvalidMethodArgumentException.php new file mode 100644 index 0000000..a77517b --- /dev/null +++ b/src/Exception/InvalidMethodArgumentException.php @@ -0,0 +1,36 @@ + + * @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\InvalidArgumentException; + +/** + * ### Indicates invalid explicit method invocation arguments + * + * Thrown when explicitly supplied callable arguments cannot be mapped to the declared parameter signature. + * + * This includes unknown parameter names, invalid positional indexes, duplicate parameter assignments, and + * invalid variadic argument collections. + * @since 1.0.0 + */ +final class InvalidMethodArgumentException extends InvalidArgumentException { + + /** + * @inheritDoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'Invalid method argument(s)'; + +} \ No newline at end of file diff --git a/src/Exception/InvalidTagNameException.php b/src/Exception/InvalidTagNameException.php index e1431bd..9356488 100644 --- a/src/Exception/InvalidTagNameException.php +++ b/src/Exception/InvalidTagNameException.php @@ -24,7 +24,8 @@ final class InvalidTagNameException extends InvariantException { /** - * ### Default exception message + * @inheritDoc + * * @since 1.0.0 */ protected const string DEFAULT_MESSAGE = 'Service tag name must not be empty or contain only whitespace'; diff --git a/src/Exception/ScopeActiveException.php b/src/Exception/ScopeActiveException.php index 9ed842c..2fa5997 100644 --- a/src/Exception/ScopeActiveException.php +++ b/src/Exception/ScopeActiveException.php @@ -24,6 +24,11 @@ */ final class ScopeActiveException extends RuntimeException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'The dependency injection scope is currently active.'; } \ No newline at end of file diff --git a/src/Exception/ScopeClosedException.php b/src/Exception/ScopeClosedException.php index 95482c7..1b82b24 100644 --- a/src/Exception/ScopeClosedException.php +++ b/src/Exception/ScopeClosedException.php @@ -24,6 +24,11 @@ */ final class ScopeClosedException extends RuntimeException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Cannot access a closed dependency injection scope.'; } \ No newline at end of file diff --git a/src/Exception/ScopeRequiredException.php b/src/Exception/ScopeRequiredException.php index 083c50e..1c5e859 100644 --- a/src/Exception/ScopeRequiredException.php +++ b/src/Exception/ScopeRequiredException.php @@ -24,6 +24,11 @@ */ final class ScopeRequiredException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Scoped service requires an active scope'; } \ No newline at end of file diff --git a/src/Exception/ServiceDisposalException.php b/src/Exception/ServiceDisposalException.php new file mode 100644 index 0000000..deafdd3 --- /dev/null +++ b/src/Exception/ServiceDisposalException.php @@ -0,0 +1,36 @@ + + * @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\RuntimeException; + +/** + * ### Indicates failure during service resource disposal + * + * Thrown when one or more disposable service instances fail to release their resources. + * + * The exception preserves all encountered disposal failures while exposing the first failure as the previous + * exception. + * @since 1.0.0 + */ +final class ServiceDisposalException extends RuntimeException { + + /** + * @inheritDoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'Failed to dispose service'; + +} \ No newline at end of file diff --git a/src/Exception/ServiceInstantiationException.php b/src/Exception/ServiceInstantiationException.php index bd23fe3..5e0c98d 100644 --- a/src/Exception/ServiceInstantiationException.php +++ b/src/Exception/ServiceInstantiationException.php @@ -24,6 +24,11 @@ */ final class ServiceInstantiationException extends RuntimeException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Failed to instantiate service'; } \ No newline at end of file diff --git a/src/Exception/ServiceNotFoundException.php b/src/Exception/ServiceNotFoundException.php index 3f9d4c4..c543c25 100644 --- a/src/Exception/ServiceNotFoundException.php +++ b/src/Exception/ServiceNotFoundException.php @@ -23,6 +23,11 @@ */ final class ServiceNotFoundException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'No service definition found for the specified identifier'; } \ No newline at end of file diff --git a/src/Exception/ServiceTypeMismatchException.php b/src/Exception/ServiceTypeMismatchException.php index 1dbc31b..f1d3d8f 100644 --- a/src/Exception/ServiceTypeMismatchException.php +++ b/src/Exception/ServiceTypeMismatchException.php @@ -24,6 +24,11 @@ */ final class ServiceTypeMismatchException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Resolved service is not compatible with its registered identifier'; } \ No newline at end of file diff --git a/src/Exception/UnsupportedDefinitionException.php b/src/Exception/UnsupportedDefinitionException.php index c40dbfa..8fd9723 100644 --- a/src/Exception/UnsupportedDefinitionException.php +++ b/src/Exception/UnsupportedDefinitionException.php @@ -24,6 +24,11 @@ */ final class UnsupportedDefinitionException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Unsupported service definition'; } \ No newline at end of file diff --git a/src/Internal/Autowirer.php b/src/Internal/Autowirer.php index 6b8bf9b..d025509 100644 --- a/src/Internal/Autowirer.php +++ b/src/Internal/Autowirer.php @@ -14,17 +14,17 @@ namespace FireHub\Container\Internal; use FireHub\Container\Exception\ServiceInstantiationException; -use Closure, ReflectionIntersectionType, ReflectionUnionType; /** * ### Constructs services using compiled dependency plans * - * Executes cached dependency construction plans to instantiate services and resolve their constructor dependencies. + * Executes cached constructor dependency plans to instantiate services using the shared parameter resolver. * - * Static parameter analysis is performed by the dependency plan cache, while service availability, contextual - * bindings, and candidate selection are evaluated dynamically within the active resolution context. + * Static constructor analysis is performed by the dependency plan cache, while parameter bindings, contextual + * dependencies, service availability, and candidate selection are evaluated dynamically during construction. * - * Explicit parameter bindings take precedence over contextual bindings and automatic dependency resolution. + * Constructor dependencies are resolved within the active resolution context, preserving service lifetimes and + * circular dependency detection. * @since 1.0.0 * * @internal @@ -35,70 +35,54 @@ * ### Constructor * @since 1.0.0 * - * @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, \FireHub\Container\Internal\ResolutionContext):list $resolve_all

- * Context-aware service collection resolver. - *

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

- * Explicit service registration checker. - *

- * @param \FireHub\Container\Internal\TypeMatcher $matcher

- * Dependency type matcher. - *

- * @param \FireHub\Container\Internal\CandidateSelector $selector

- * Dependency candidate selector. - *

* @param \FireHub\Container\Internal\DependencyPlanCache $plans

- * Shared dependency plan cache. + * Shared dependency plan cache containing compiled constructor dependency information. *

- * @param \FireHub\Container\Internal\ParameterBindingRegistry $bindings

- * Shared parameter binding registry. - *

- * @param \FireHub\Container\Internal\ContextualCandidateSelector $contextual_selector

- * Shared contextual dependency selector. + * @param \FireHub\Container\Internal\ParameterResolver $parameters

+ * Shared parameter resolver responsible for resolving constructor dependencies. *

* * @return void */ public function __construct ( - private Closure $resolve, - private Closure $resolve_tagged, - private Closure $resolve_all, - private Closure $has, - private TypeMatcher $matcher, - private CandidateSelector $selector, private DependencyPlanCache $plans, - private ParameterBindingRegistry $bindings, - private ContextualCandidateSelector $contextual_selector + private ParameterResolver $parameters ) {} /** * ### Constructs a service using its compiled dependency plan * - * Resolves constructor dependencies dynamically while reusing previously compiled parameter information. + * Retrieves the compiled constructor dependency plan and creates a new instance of the specified implementation + * class. + * + * Classes without constructors are instantiated directly. For classes with constructors, each compiled parameter + * is resolved through the shared parameter resolver using the active dependency resolution context. + * + * The implementation class must be instantiable. Abstract classes and interfaces cannot be constructed. * @since 1.0.0 * - * @uses \FireHub\Container\Internal\DependencyPlanCache::get() To retrieve the compiled construction plan. - * @uses \FireHub\Container\Internal\ConstructorMetadataCache::get() To get constructor metadata. + * @uses \FireHub\Container\Internal\DependencyPlanCache::get() To retrieve the compiled constructor dependency + * plan. + * @uses \FireHub\Container\Internal\ParameterResolver::resolve() To resolve constructor parameter dependencies. * * @param class-string $class

- * Concrete service class. + * Concrete service implementation class to instantiate. *

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

- * Active dependency resolution context. + * Active dependency resolution context used to resolve constructor dependencies. *

* - * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter is not optional and cannot - * be resolved. - * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service is not registered. - * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the implementation class is not + * instantiable or a required constructor dependency cannot be resolved. + * @throws \FireHub\Container\Exception\ServiceNotFoundException If a required dependency is not registered. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are + * eligible. + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * @throws \FireHub\Container\Exception\ScopeRequiredException If a dependency requires an active scope. + * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If a resolved dependency does not satisfy + * its registered service type. * - * @return object Constructed service instance. + * @return object Newly constructed service instance. */ public function construct (string $class, ResolutionContext $context):object { @@ -115,7 +99,7 @@ public function construct (string $class, ResolutionContext $context):object { $arguments = []; foreach ($plan->parameters as $parameter) - $arguments[] = $this->resolveParameter( + $arguments[] = $this->parameters->resolve( $class, $parameter, $context @@ -125,222 +109,4 @@ public function construct (string $class, ResolutionContext $context):object { } - /** - * ### Resolves a compiled constructor parameter - * - * Resolves constructor arguments according to the following precedence: - * - * 1. Explicit parameter binding. - * 2. Contextual dependency binding. - * 3. Registered or implicitly constructible dependency. - * 4. Constructor default value. - * - * Contextual bindings apply only to named dependency types and the exact consumer implementation class. - * @since 1.0.0 - * - * @uses \FireHub\Container\Internal\ParameterBindingRegistry::has() To check explicit parameter bindings. - * @uses \FireHub\Container\Internal\ParameterBindingRegistry::get() To retrieve explicitly bound values. - * @uses \FireHub\Container\Internal\ContextualBindingRegistry::find() To find contextual dependency bindings. - * @uses \FireHub\Container\Internal\Autowirer::selectDependency() To select a dependency identifier. - * @uses \FireHub\Container\Internal\Autowirer::resolveDependency() To resolve selected dependencies. - * @uses \FireHub\Container\Internal\Autowirer::unresolvedParameter() To create resolution exceptions. - * - * @param class-string $class

- * Concrete service class. - *

- * @param \FireHub\Container\Internal\ParameterPlan $plan

- * Compiled constructor parameter plan. - *

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

- * Active resolution context. - *

- * - * @throws \FireHub\Container\Exception\ServiceInstantiationException If the dependency cannot be resolved. - * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. - * - * @return mixed Resolved argument. - */ - private function resolveParameter (string $class, ParameterPlan $plan, ResolutionContext $context):mixed { - - $name = $plan->parameter->getName(); - - if ($this->bindings->has($class, $name)) - return $this->bindings->get($class, $name); - - 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) - return $this->resolveDependency($id, $plan, $context, true); - - $id = $this->selectDependency($plan); - - if ($id !== null && ( - ($this->has)($id) - || $this->matcher->isConstructible($id) - ) - ) return $this->resolveDependency($id, $plan, $context); - - if ($plan->hasDefault) - return $plan->default; - - throw $this->unresolvedParameter($plan); - - } - - /** - * ### Selects a dependency identifier - * - * Determines the service identifier required by a constructor parameter. - * - * Named dependency types use their precompiled identifiers. Union and intersection dependency types are - * evaluated dynamically against the currently registered service implementations. - * @since 1.0.0 - * - * @uses \FireHub\Container\Internal\CandidateSelector::union() To select a union dependency. - * @uses \FireHub\Container\Internal\CandidateSelector::intersection() To select an intersection dependency. - * - * @param \FireHub\Container\Internal\ParameterPlan $plan

- * Compiled constructor parameter plan. - *

- * - * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. - * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. - * @throws \FireHub\Container\Exception\ServiceInstantiationException If the union contains an unsupported - * intersection member. - * - * @return null|class-string Selected dependency identifier. - */ - private function selectDependency (ParameterPlan $plan):?string { - - return match (true) { - $plan->composite instanceof ReflectionUnionType => - $this->selector->union( - $plan->parameter, - $plan->composite - ), - $plan->composite instanceof ReflectionIntersectionType => - $this->selector->intersection( - $plan->parameter, - $plan->composite - ), - default => $plan->id - }; - - } - - /** - * ### Resolves a selected dependency - * - * Resolves the selected service identifier within the active resolution context. - * - * For union and intersection dependency types, validates that the resolved instance satisfies the complete - * declared parameter type. - * @since 1.0.0 - * - * @uses \FireHub\Container\Resolver::resolve() To resolve the selected dependency. - * @uses \FireHub\Container\Internal\CandidateSelector::matchesType() To validate composite dependency types. - * - * @param class-string $id

- * Selected dependency identifier. - *

- * @param \FireHub\Container\Internal\ParameterPlan $plan

- * Compiled constructor parameter plan. - *

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

- * Active resolution context. - *

- * @param bool $contextual [optional]

- * Whether the dependency was selected through contextual binding. - *

- * - * @throws \FireHub\Container\Exception\ServiceInstantiationException If the resolved dependency does not satisfy - * its declared composite type. - * - * @return object Resolved dependency. - */ - private function resolveDependency (string $id, ParameterPlan $plan, ResolutionContext $context, bool $contextual = false):object { - - $instance = ($this->resolve)($id, $context); - - if ( - $plan->composite !== null - && !$this->selector->matchesType( - $instance::class, - $plan->parameter, - $plan->composite - ) - ) throw new ServiceInstantiationException( - context: [ - 'class' => $plan->parameter->getDeclaringClass()?->getName(), - 'parameter' => $plan->parameter->getName(), - 'type' => (string)$plan->composite, - 'instance' => $instance::class, - ] - ); - - if ( - $contextual - && $plan->composite === null - && $plan->id !== null - ) { - - $expected = $plan->id; - - if (!$instance instanceof $expected) - throw new ServiceInstantiationException( - context: [ - 'class' => $plan->parameter->getDeclaringClass()?->getName(), - 'parameter' => $plan->parameter->getName(), - 'type' => $expected, - 'implementation' => $id, - 'instance' => $instance::class, - ] - ); - - } - - return $instance; - - } - - /** - * ### Creates an unresolved parameter exception - * - * Creates an exception containing diagnostic information about a constructor parameter that could not be - * resolved through explicit bindings, contextual bindings, registered services, implicit construction, or - * default values. - * @since 1.0.0 - * - * @param \FireHub\Container\Internal\ParameterPlan $plan

- * Compiled constructor parameter plan. - *

- * - * @return \FireHub\Container\Exception\ServiceInstantiationException Parameter resolution exception. - */ - private function unresolvedParameter (ParameterPlan $plan):ServiceInstantiationException { - - $parameter = $plan->parameter; - - $type = $parameter->getType(); - - return new ServiceInstantiationException( - context: [ - 'class' => $parameter->getDeclaringClass()?->getName(), - 'parameter' => $parameter->getName(), - 'position' => $parameter->getPosition(), - 'type' => $type?->__toString(), - 'nullable' => $type?->allowsNull(), - 'optional' => $parameter->isOptional(), - 'has_default' => $plan->hasDefault - ] - ); - - } - } \ No newline at end of file diff --git a/src/Internal/ContainerState.php b/src/Internal/ContainerState.php new file mode 100644 index 0000000..02ff9d7 --- /dev/null +++ b/src/Internal/ContainerState.php @@ -0,0 +1,158 @@ + + * @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 + */ + +namespace FireHub\Container\Internal; + +use FireHub\Container\Exception\ { + ContainerClosedException, ContainerFrozenException +}; + +/** + * ### Manages dependency injection container lifecycle state + * + * Maintains the shared lifecycle state of a dependency injection container. + * + * The container supports three logical states: + * - Open: Registration and resolution are permitted. + * - Frozen: Registration is prohibited, but resolution remains permitted. + * - Closed: Registration and resolution are permanently prohibited. + * + * Freezing is irreversible and does not affect existing service instances. + * + * Closing permanently terminates the container lifecycle regardless of whether the container was previously frozen. + * + * The state is shared between the container and its resolver to enforce consistent lifecycle restrictions. + * @since 1.0.0 + * + * @internal + */ +final class ContainerState { + + /** + * ### Container freeze state + * @since 1.0.0 + */ + private bool $frozen = false; + + /** + * ### Container closure state + * @since 1.0.0 + */ + private bool $closed = false; + + /** + * ### Ensures the container is open + * + * Prevents operations requiring an active container after closure. + * @since 1.0.0 + * + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * + * @return void + */ + public function assertOpen ():void { + + if ($this->closed) + throw new ContainerClosedException; + + } + + /** + * ### Ensures the container accepts configuration changes + * + * Prevents service registration and configuration changes when the container is frozen or closed. + * + * Closure takes precedence over freezing when both conditions apply. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() + * + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. + * + * @return void + */ + public function assertMutable ():void { + + $this->assertOpen(); + + if ($this->frozen) + throw new ContainerFrozenException; + + } + + /** + * ### Freezes the container configuration + * + * Permanently prevents further registration and configuration changes. + * + * Existing services remain available for resolution. + * + * Repeated calls have no additional effect. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. + * + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * + * @return void + */ + public function freeze ():void { + + $this->assertOpen(); + + $this->frozen = true; + + } + + /** + * ### Checks whether the container is frozen + * @since 1.0.0 + * + * @return bool True if the container configuration has been frozen. + */ + public function isFrozen ():bool { + + return $this->frozen; + + } + + /** + * ### Closes the container lifecycle + * + * Permanently marks the container as closed. + * + * Repeated calls have no additional effect. + * @since 1.0.0 + * + * @return void + */ + public function close ():void { + + $this->closed = true; + + } + + /** + * ### Checks whether the container is closed + * @since 1.0.0 + * + * @return bool True if the container has been closed, false otherwise. + */ + public function isClosed ():bool { + + return $this->closed; + + } + +} \ No newline at end of file diff --git a/src/Internal/DependencyPlanCache.php b/src/Internal/DependencyPlanCache.php index 962b6d0..9f2a022 100644 --- a/src/Internal/DependencyPlanCache.php +++ b/src/Internal/DependencyPlanCache.php @@ -7,26 +7,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 + * @php-version >=8.1 * @package Container */ namespace FireHub\Container\Internal; -use FireHub\Container\Attribute\ { - InjectAll, Tagged -}; -use FireHub\Container\Exception\ServiceInstantiationException; -use FireHub\Runtime; -use ReflectionNamedType, ReflectionParameter, ReflectionUnionType, ReflectionIntersectionType; - /** - * ### Compiles and caches dependency construction plans + * ### Compiles and caches constructor dependency plans + * + * Analyzes constructor parameters once per implementation class and caches the resulting dependency plans. * - * Analyzes constructor parameters once per implementation class and stores the resulting dependency plans. + * Parameter compilation is delegated to the shared parameter plan compiler, allowing constructor and method + * injection to use identical parameter metadata and validation rules. * - * Compiled plans contain only static constructor information. Registered service availability, union and - * intersection candidate selection, and dependency instantiation remain dynamic. + * Registered service availability, contextual bindings, and dependency candidate selection remain dynamic. * @since 1.0.0 * * @internal @@ -48,35 +43,38 @@ final class DependencyPlanCache { * @param \FireHub\Container\Internal\ConstructorMetadataCache $metadata

* Shared constructor metadata cache. *

- * @param \FireHub\Container\Internal\TypeMatcher $matcher

- * Dependency type matcher. + * @param \FireHub\Container\Internal\ParameterPlanCompiler $compiler

+ * Shared parameter plan compiler responsible for analyzing dependency parameters. *

* * @return void */ - public function __construct( + public function __construct ( private readonly ConstructorMetadataCache $metadata, - private readonly TypeMatcher $matcher + private readonly ParameterPlanCompiler $compiler ) {} /** - * ### Gets a compiled dependency plan + * ### Gets a compiled constructor dependency plan + * + * Returns a previously compiled dependency plan or creates one by analyzing the constructor parameters of the + * specified implementation class. * - * Returns the cached dependency plan or compiles one from constructor reflection metadata. + * Variadic constructor parameters are excluded from dependency injection, preserving existing constructor + * autowiring behavior. * @since 1.0.0 * - * @uses \FireHub\Container\Internal\DependencyPlanCache::compileParameter() To compile constructor parameter - * metadata. * @uses \FireHub\Container\Internal\ConstructorMetadataCache::get() To retrieve constructor metadata. + * @uses \FireHub\Container\Internal\ParameterPlanCompiler::compile() To compile constructor parameter metadata. * * @param class-string $class

- * Service implementation class. + * Service implementation class whose constructor dependency plan should be retrieved. *

* - * @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 a constructor parameter contains invalid + * injection attributes or unsupported dependency metadata. * - * @return \FireHub\Container\Internal\DependencyPlan Compiled dependency plan. + * @return \FireHub\Container\Internal\DependencyPlan Compiled constructor dependency plan. */ public function get (string $class):DependencyPlan { @@ -92,7 +90,7 @@ public function get (string $class):DependencyPlan { if ($parameter->isVariadic()) continue; - $parameters[] = $this->compileParameter($parameter); + $parameters[] = $this->compiler->compile($parameter); } @@ -103,207 +101,4 @@ 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. - * @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. - */ - 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(); - - $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, - $composite, - $has_default, - $has_default - ? $parameter->getDefaultValue() - : null, - $tag, - $collection - ); - - } - - /** - * ### 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; - - } - - /** - * ### 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/DisposalTracker.php b/src/Internal/DisposalTracker.php new file mode 100644 index 0000000..e7cf52e --- /dev/null +++ b/src/Internal/DisposalTracker.php @@ -0,0 +1,147 @@ + + * @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 + */ + +namespace FireHub\Container\Internal; + +use FireHub\Core\Boundary\Lifecycle\Disposable; +use FireHub\Container\Exception\ { + DisposalTrackerClosedException, ServiceDisposalException +}; +use FireHub\Runtime; +use Throwable; + +/** + * ### Tracks and disposes of owned resources + * + * Maintains a registry of disposable service instances owned by a dependency injection lifecycle. + * + * Each instance is registered only once using object identity. Resources are disposed of in reverse registration + * order, allowing dependent services to release their resources before the services they depend on. + * + * Disposal attempts to continue even when individual instances fail to release their resources. The first encountered + * exception is propagated after all registered instances have been processed. + * + * The tracker does not determine service ownership or lifetime. These responsibilities belong to the container or + * scope managing the tracker. + * + * Once disposal begins, the tracker cannot accept additional instances. + * @since 1.0.0 + * + * @internal + */ +final class DisposalTracker { + + /** + * ### Registered disposable instances + * @since 1.0.0 + * + * @var list<\FireHub\Core\Boundary\Lifecycle\Disposable> + */ + private array $instances = []; + + /** + * ### Registered instance identities + * @since 1.0.0 + * + * @var array + */ + private array $identities = []; + + /** + * ### Whether disposal has started + * @since 1.0.0 + */ + private bool $disposed = false; + + /** + * ### Registers a disposable instance + * + * Adds an instance to the disposal registry unless the same object has already been registered. + * + * Registration is rejected after disposal has started. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\ObjectModel\Identity::id() To get the object identity. + * + * @param \FireHub\Core\Boundary\Lifecycle\Disposable $instance

+ * Disposable instance to register. + *

+ * + * @throws \FireHub\Container\Exception\DisposalTrackerClosedException If disposal has already started. + * + * @return void + */ + public function track (Disposable $instance):void { + + if ($this->disposed) + throw new DisposalTrackerClosedException; + + $id = Runtime\ObjectModel\Identity::id($instance); + + if (isset($this->identities[$id])) return; + + $this->identities[$id] = true; + + $this->instances[] = $instance; + + } + + /** + * ### Disposes of all tracked instances + * + * Releases registered resources in reverse registration order. + * + * Every instance is attempted even when disposal of another instance fails. + * + * Repeated calls have no effect. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Arr\Inspection::count() To get the number of instances. + * + * @uses \FireHub\Core\Boundary\Lifecycle\Disposable::dispose() To release instance resources. + * + * @throws \FireHub\Container\Exception\ServiceDisposalException If an instance fails to release its resources. + * + * @return void + */ + public function dispose ():void { + + if ($this->disposed) return; + + $this->disposed = true; + + $failure = null; + + for ($index = Runtime\Arr\Inspection::count($this->instances) - 1; $index >= 0; $index--) { + + try { + + $this->instances[$index]->dispose(); // @phpstan-ignore offsetAccess.notFound + + } catch (Throwable $exception) { + + $failure ??= $exception; + + } + + } + + $this->instances = []; + $this->identities = []; + + if ($failure !== null) + throw new ServiceDisposalException(previous: $failure); + + } + +} \ No newline at end of file diff --git a/src/Internal/MethodInvoker.php b/src/Internal/MethodInvoker.php new file mode 100644 index 0000000..c88ff17 --- /dev/null +++ b/src/Internal/MethodInvoker.php @@ -0,0 +1,487 @@ + + * @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\Internal; + +use FireHub\Container\Exception\InvalidMethodArgumentException; +use FireHub\Runtime; +use Closure, ReflectionFunction, ReflectionMethod, ReflectionParameter; + +/** + * ### Invokes callables using dependency injection + * + * Executes closures, named functions, instance methods, static methods, and invokable objects by combining + * explicitly supplied arguments with dependencies resolved through the shared parameter resolver. + * + * Positional arguments are associated with their declared parameter positions, while named arguments are + * associated with their corresponding parameter names. + * + * Explicit arguments take precedence over dependency injection. Missing arguments are resolved within the active + * resolution context, preserving service lifetimes and circular dependency detection. + * + * Constructor parameter bindings are excluded from method invocation. + * @since 1.0.0 + * + * @internal + */ +final readonly class MethodInvoker { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Container\Internal\MethodPlanCache $plans

+ * Shared cache containing compiled callable invocation plans. + *

+ * @param \FireHub\Container\Internal\ParameterResolver $parameters

+ * Shared parameter resolver responsible for resolving missing callable arguments. + *

+ * + * @return void + */ + public function __construct ( + private MethodPlanCache $plans, + private ParameterResolver $parameters + ) {} + + /** + * ### Invokes a callable using dependency injection + * + * Retrieves the compiled invocation plan, normalizes explicit arguments, resolves missing dependencies, and + * executes the callable. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodInvoker::plan() To retrieve the callable invocation plan. + * @uses \FireHub\Container\Internal\MethodInvoker::normalizeArguments() To normalize explicit arguments. + * @uses \FireHub\Container\Internal\MethodInvoker::resolveArguments() To resolve callable arguments. + * + * @param callable $callback

+ * Callable to execute using dependency injection. + *

+ * @param array $arguments

+ * Explicit positional and named arguments. + *

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

+ * Active dependency resolution context. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * + * @return mixed Value returned by the callable. + */ + public function invoke (callable $callback, array $arguments, ResolutionContext $context):mixed { + + $plan = $this->plan($callback); + + [$positional, $named] = $this->normalizeArguments($plan, $arguments); + + return $callback( + ...$this->resolveArguments($plan, $positional, $named, $context) + ); + + } + + /** + * ### Resolves callable arguments + * + * Combines explicit positional and named arguments with automatically resolved dependencies. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodInvoker::resolveParameter() To resolve individual parameters. + * @uses \FireHub\Container\Internal\MethodInvoker::resolveVariadic() To resolve variadic parameters. + * + * @param \FireHub\Container\Internal\MethodPlan $plan

+ * Compiled callable invocation plan. + *

+ * @param array $positional

+ * Explicit positional arguments. + *

+ * @param array $named

+ * Explicit named arguments. + *

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

+ * Active dependency resolution context. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * + * @return list Resolved callable arguments. + */ + private function resolveArguments (MethodPlan $plan, array $positional, array $named, ResolutionContext $context):array { + + $resolved = []; + + foreach ($plan->parameters as $parameter) { + + if ($parameter->parameter->isVariadic()) { + + foreach ($this->resolveVariadic($parameter, $positional, $named) as $value) + $resolved[] = $value; + + continue; + + } + + $resolved[] = $this->resolveParameter( + $plan, + $parameter, + $positional, + $named, + $context + ); + + } + + return $resolved; + + } + + /** + * ### Resolves an individual callable parameter + * + * Returns an explicitly supplied named or positional argument when available. Otherwise, resolves the parameter + * through the shared dependency resolver. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ParameterResolver::resolve() To resolve missing dependencies. + * @uses \FireHub\Runtime\Arr\Access::keyExists() To check for array key existence. + * + * @param \FireHub\Container\Internal\MethodPlan $plan

+ * Compiled callable invocation plan. + *

+ * @param \FireHub\Container\Internal\ParameterPlan $parameter

+ * Compiled parameter metadata. + *

+ * @param array $positional

+ * Explicit positional arguments. + *

+ * @param array $named

+ * Explicit named arguments. + *

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

+ * Active dependency resolution context. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * + * @return mixed Resolved parameter value. + */ + private function resolveParameter (MethodPlan $plan, ParameterPlan $parameter, array $positional, array $named, ResolutionContext $context):mixed { + + $reflection = $parameter->parameter; + + $name = $reflection->getName(); + $position = $reflection->getPosition(); + + if (Runtime\Arr\Access::keyExists($named, $name)) + return $named[$name]; // @phpstan-ignore offsetAccess.notFound + + if (Runtime\Arr\Access::keyExists($positional, $position)) + return $positional[$position]; // @phpstan-ignore offsetAccess.notFound + + return $this->parameters->resolve( + $plan->consumer, + $parameter, + $context, + use_bindings: false + ); + + } + + /** + * ### Resolves variadic callable arguments + * + * Retrieves variadic values from an explicitly supplied named list or from remaining positional arguments. + * + * Variadic parameters are not automatically resolved through dependency injection. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Arr\Access::keyExists() To check for array key existence. + * + * @param \FireHub\Container\Internal\ParameterPlan $parameter

+ * Compiled variadic parameter metadata. + *

+ * @param array $positional

+ * Explicit positional arguments. + *

+ * @param array $named

+ * Explicit named arguments. + *

+ * + * @return list Explicit variadic argument values. + */ + private function resolveVariadic (ParameterPlan $parameter, array $positional, array $named):array { + + $reflection = $parameter->parameter; + + $name = $reflection->getName(); + + if (Runtime\Arr\Access::keyExists($named, $name)) { + + /** @var list $values */ + $values = $named[$name]; // @phpstan-ignore offsetAccess.notFound + + return $values; + + } + + $values = []; + + for ( + $index = $reflection->getPosition(); + Runtime\Arr\Access::keyExists($positional, $index); + $index++ + ) $values[] = $positional[$index]; // @phpstan-ignore offsetAccess.notFound + + return $values; + + } + + /** + * ### Normalizes explicit callable arguments + * + * Separates positional and named arguments and delegates signature validation to the argument validator. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodInvoker::validateArguments() To validate argument assignments. + * @uses \FireHub\Runtime\DataIs::int() To check if the argument is an integer. + * + * @param \FireHub\Container\Internal\MethodPlan $plan

+ * Compiled callable invocation plan. + *

+ * @param array $arguments

+ * Explicit positional and named arguments. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If arguments are invalid. + * + * @return array{ + * 0: array, + * 1: array + * } Normalized argument collections. + */ + private function normalizeArguments (MethodPlan $plan, array $arguments):array { + + $positional = []; + $named = []; + + $next = 0; + $named_started = false; + + foreach ($arguments as $key => $value) { + + if (Runtime\DataIs::int($key)) { + + if ($named_started || $key !== $next) + throw new InvalidMethodArgumentException( + 'Positional arguments must be consecutive and precede named arguments.' + ); + + $positional[$key] = $value; + + $next++; + + continue; + + } + + $named_started = true; + + $named[$key] = $value; + + } + + $this->validateArguments($plan, $positional, $named); + + return [$positional, $named]; + + } + + /** + * ### Validates explicit callable arguments + * + * Ensures positional arguments do not exceed the callable signature and validates each named argument. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodInvoker::validateNamedArgument() To validate named arguments. + * @uses \FireHub\Runtime\Arr\Inspection::count() To check if the callable has variadic parameters. + * + * @param \FireHub\Container\Internal\MethodPlan $plan

+ * Compiled callable invocation plan. + *

+ * @param array $positional

+ * Explicit positional arguments. + *

+ * @param array $named

+ * Explicit named arguments. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If argument assignments are invalid. + * + * @return void + */ + private function validateArguments (MethodPlan $plan, array $positional, array $named):void { + + $names = []; + $variadic = null; + + foreach ($plan->parameters as $parameter) { + + $reflection = $parameter->parameter; + + $names[$reflection->getName()] = $reflection->getPosition(); + + if ($reflection->isVariadic()) + $variadic = $reflection; + + } + + if ( + $variadic === null + && Runtime\Arr\Inspection::count($positional) > Runtime\Arr\Inspection::count($plan->parameters) + ) throw new InvalidMethodArgumentException( + 'Too many positional arguments were supplied.' + ); + + foreach ($named as $name => $value) + $this->validateNamedArgument( + $name, + $value, + $names, + $positional, + $variadic + ); + + } + + /** + * ### Validates an explicitly named argument + * + * Checks that the argument references an existing parameter, has not yet been supplied positionally, and + * satisfies variadic argument requirements. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Arr\Access::keyExists() To check for array key existence. + * @uses \FireHub\Runtime\Arr\Inspection::count() To check if the callable has variadic parameters. + * @uses \FireHub\Runtime\Arr\Inspection::isList() To check if a value is a list. + * @uses \FireHub\Runtime\DataIs::array() To check if the value is an array. + * + * @param string $name

+ * Explicit argument name. + *

+ * @param mixed $value

+ * Explicit argument value. + *

+ * @param array $names

+ * Declared parameter names mapped to their positions. + *

+ * @param array $positional

+ * Explicit positional arguments. + *

+ * @param null|\ReflectionParameter $variadic

+ * Variadic parameter metadata, or null when the callable has no variadic parameter. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If the named argument is invalid. + * + * @return void + */ + private function validateNamedArgument (string $name, mixed $value, array $names, array $positional, ?ReflectionParameter $variadic):void { + + if (!Runtime\Arr\Access::keyExists($names, $name)) + throw new InvalidMethodArgumentException( + "Unknown callable argument: {$name}." + ); + + $position = $names[$name]; // @phpstan-ignore offsetAccess.notFound + + if (Runtime\Arr\Access::keyExists($positional, $position)) + throw new InvalidMethodArgumentException( + "Callable argument '{$name}' was supplied more than once." + ); + + if ($variadic === null || $name !== $variadic->getName()) + return; + + if (!Runtime\DataIs::array($value) || !Runtime\Arr\Inspection::isList($value)) + throw new InvalidMethodArgumentException( + "Named variadic argument '{$name}' must be a list." + ); + + if (Runtime\Arr\Inspection::count($positional) > $position) + throw new InvalidMethodArgumentException( + "Variadic argument '{$name}' cannot be supplied both positionally and by name." + ); + + } + + /** + * ### Retrieves a callable invocation plan + * + * Determines the callable category and retrieves its compiled invocation plan. + * + * Method callables retain their declaring class for contextual dependency resolution. Standalone functions and + * closures do not have a consumer class. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodPlanCache::method() To retrieve compiled method plans. + * @uses \FireHub\Container\Internal\MethodPlanCache::function() To retrieve compiled function plans. + * @uses \FireHub\Runtime\Str\SB\Search::contains() To check for class method syntax. + * @uses \FireHub\Runtime\DataIs::array() To check if the callable is an array. + * @uses \FireHub\Runtime\DataIs::object() To check if the callable is an object. + * @uses \FireHub\Runtime\DataIs::string() To check if the callable is a string. + * + * @param callable $callback

+ * Callable whose invocation metadata should be retrieved. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the callable cannot be compiled. + * + * @return \FireHub\Container\Internal\MethodPlan Compiled callable invocation plan. + */ + private function plan (callable $callback):MethodPlan { + + if (Runtime\DataIs::array($callback)) + return $this->plans->method( + new ReflectionMethod($callback[0], $callback[1]) + ); + + if (Runtime\DataIs::string($callback) && Runtime\Str\SB\Search::contains('::', $callback)) + return $this->plans->method( + new ReflectionMethod($callback) + ); + + if (Runtime\DataIs::object($callback) && !$callback instanceof Closure) + return $this->plans->method( + new ReflectionMethod($callback, '__invoke') + ); + + if (Runtime\DataIs::string($callback)) + return $this->plans->function( + new ReflectionFunction($callback) + ); + + return $this->plans->function( + new ReflectionFunction(Closure::fromCallable($callback)) + ); + + } + +} \ No newline at end of file diff --git a/src/Internal/MethodPlan.php b/src/Internal/MethodPlan.php new file mode 100644 index 0000000..94032f6 --- /dev/null +++ b/src/Internal/MethodPlan.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 >=8.2 + * @package Container + */ + +namespace FireHub\Container\Internal; + +use ReflectionFunctionAbstract; + +/** + * ### Represents a compiled callable invocation plan + * + * Stores the reflection metadata and compiled dependency parameters required to invoke a method or closure. + * + * The plan contains only static callable information. Explicit arguments, contextual bindings, dependency + * availability, and service resolution are evaluated dynamically during invocation. + * @since 1.0.0 + * + * @internal + */ +final readonly class MethodPlan { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \ReflectionFunctionAbstract $reflection

+ * Reflection metadata describing the callable associated with this invocation plan. + *

+ * @param list<\FireHub\Container\Internal\ParameterPlan> $parameters

+ * Compiled dependency parameter plans in their original declaration order. + *

+ * @param null|class-string $consumer

+ * Declaring class used for contextual dependency resolution, or null for standalone functions and closures. + *

+ * + * @return void + */ + public function __construct ( + public ReflectionFunctionAbstract $reflection, + public array $parameters, + public ?string $consumer + ) {} + +} \ No newline at end of file diff --git a/src/Internal/MethodPlanCache.php b/src/Internal/MethodPlanCache.php new file mode 100644 index 0000000..f82550c --- /dev/null +++ b/src/Internal/MethodPlanCache.php @@ -0,0 +1,160 @@ + + * @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\Internal; + +use ReflectionFunction, ReflectionFunctionAbstract, ReflectionMethod; + +/** + * ### Compiles and caches callable invocation plans + * + * Analyzes callable reflection metadata and compiles dependency parameter plans using the shared parameter + * compiler. + * + * Method plans are cached by their declaring class and method name. Named function caches by function name, while + * closures are compiled independently to avoid retaining closure instances. + * + * Registered service availability, contextual bindings, and dependency candidate selection remain dynamic. + * @since 1.0.0 + * + * @internal + */ +final class MethodPlanCache { + + /** + * ### Cached method invocation plans + * @since 1.0.0 + * + * @var array + */ + private array $plans = []; + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Container\Internal\ParameterPlanCompiler $compiler

+ * Shared parameter plan compiler used to analyze callable parameters. + *

+ * + * @return void + */ + public function __construct ( + private readonly ParameterPlanCompiler $compiler + ) {} + + /** + * ### Gets a compiled method invocation plan + * + * Retrieves a cached invocation plan for the specified method or compiles one when no cached plan exists. + * + * The cache key uses the declaring class and canonical method name, allowing inherited methods to reuse their + * compiled metadata. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodPlanCache::compile() To compile callable reflection metadata. + * + * @param \ReflectionMethod $method

+ * Reflected instance or static method to analyze. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a parameter contains unsupported + * injection metadata. + * + * @return \FireHub\Container\Internal\MethodPlan Compiled method invocation plan. + */ + public function method (ReflectionMethod $method):MethodPlan { + + $key = $method->getDeclaringClass()->getName() + . '::' + . $method->getName(); + + return $this->plans[$key] ??= $this->compile( + $method, + $method->getDeclaringClass()->getName() + ); + + } + + /** + * ### Gets a compiled function invocation plan + * + * Retrieves a cached plan for a named function or compiles a new plan for a closure. + * + * Closure invocation plans are not stored in the shared cache. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodPlanCache::compile() To compile callable reflection metadata. + * + * @param \ReflectionFunction $function

+ * Reflected named function or closure to analyze. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a parameter contains unsupported + * injection metadata. + * + * @return \FireHub\Container\Internal\MethodPlan Compiled function invocation plan. + */ + public function function (ReflectionFunction $function):MethodPlan { + + if ($function->isClosure()) + return $this->compile($function, null); + + $key = 'function:' . $function->getName(); + + return $this->plans[$key] ??= $this->compile( + $function, + null + ); + + } + + /** + * ### Compiles callable invocation metadata + * + * Analyzes callable parameters and creates an immutable invocation plan. + * + * All parameters are retained in their declaration order, including variadic parameters. Variadic argument + * expansion is handled by the method invoker rather than the parameter compiler. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ParameterPlanCompiler::compile() To compile callable parameter metadata. + * + * @param \ReflectionFunctionAbstract $reflection

+ * Reflected callable whose parameters should be compiled. + *

+ * @param null|class-string $consumer

+ * Declaring class used for contextual dependency resolution, or null for standalone functions and closures. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a parameter contains unsupported + * injection metadata. + * + * @return \FireHub\Container\Internal\MethodPlan Compiled callable invocation plan. + */ + private function compile (ReflectionFunctionAbstract $reflection, ?string $consumer):MethodPlan { + + $parameters = []; + + foreach ($reflection->getParameters() as $parameter) + $parameters[] = $this->compiler->compile($parameter); + + return new MethodPlan( + $reflection, + $parameters, + $consumer + ); + + } + +} \ No newline at end of file diff --git a/src/Internal/ParameterPlanCompiler.php b/src/Internal/ParameterPlanCompiler.php new file mode 100644 index 0000000..381f242 --- /dev/null +++ b/src/Internal/ParameterPlanCompiler.php @@ -0,0 +1,257 @@ + + * @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.0 + * @package Container + */ + +namespace FireHub\Container\Internal; + +use FireHub\Container\Attribute\ { + InjectAll, Tagged +}; +use FireHub\Container\Exception\ServiceInstantiationException; +use FireHub\Runtime; +use ReflectionNamedType, ReflectionParameter, ReflectionUnionType, ReflectionIntersectionType; + +/** + * ### Compiles dependency parameter metadata + * + * Analyzes reflected parameters and produces immutable parameter plans used by constructor and method injection. + * + * Compiled plans contain statically determined dependency identifiers, composite dependency types, default values, + * tagged service injection metadata, and collection injection metadata. + * + * Service availability, contextual bindings, and dependency candidate selection are evaluated dynamically during + * parameter resolution rather than compilation. + * @since 1.0.0 + * + * @internal + */ +final readonly class ParameterPlanCompiler { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Container\Internal\TypeMatcher $matcher

+ * Shared dependency type matcher used to resolve named dependency identifiers. + *

+ * + * @return void + */ + public function __construct ( + private TypeMatcher $matcher + ) {} + + /** + * ### Compiles dependency parameter metadata + * + * Extracts the declared parameter type, default value, tagged injection metadata, and collection injection + * metadata into an immutable parameter plan. + * + * Named dependency types are converted into service identifiers. Union and intersection types retain their + * reflection metadata for dynamic dependency candidate selection. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\TypeMatcher::classId() To resolve named dependency identifiers. + * @uses \FireHub\Container\Internal\ParameterPlanCompiler::resolveTag() To resolve tagged injection metadata. + * @uses \FireHub\Container\Internal\ParameterPlanCompiler::resolveCollection() To resolve collection injection + * metadata. + * + * @param \ReflectionParameter $parameter

+ * Reflected constructor or method parameter to compile. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If tagged and collection injection + * attributes are combined or an injection attribute is invalid. + * + * @return \FireHub\Container\Internal\ParameterPlan Compiled dependency parameter plan. + */ + public function compile (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(); + + $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, + $composite, + $has_default, + $has_default ? $parameter->getDefaultValue() : null, + $tag, + $collection + ); + + } + + /** + * ### Resolves tagged injection metadata + * + * Extracts and validates the tagged injection attribute associated with a reflected parameter. + * + * Tagged injection requires a non-nullable array parameter and a non-empty tag name. Multiple tagged injection + * attributes on the same parameter are not supported. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Arr\Inspection::count() To count declared tagged injection attributes. + * @uses \FireHub\Runtime\Str\SB\Transform::trim() To validate the tag name. + * + * @param \ReflectionParameter $parameter

+ * Reflected parameter to inspect for tagged injection metadata. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple tagged injection attributes + * are declared, the parameter type is unsupported, or the tag name is empty. + * + * @return null|non-empty-string Tag name, or null when tagged injection is not declared. + */ + 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; + + } + + /** + * ### Resolves collection injection metadata + * + * Extracts and validates the collection injection attribute associated with a reflected parameter. + * + * Collection injection requires a non-nullable array parameter and an existing class or interface as its element + * type. Multiple collection injection attributes on the same parameter are not supported. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Arr\Inspection::count() To count declared collection injection attributes. + * @uses \FireHub\Runtime\ObjectModel\Inspection::isClass() To check whether the element type is a class. + * @uses \FireHub\Runtime\ObjectModel\Inspection::isInterface() To check whether the element type is an interface. + * + * @param \ReflectionParameter $parameter

+ * Reflected parameter to inspect for collection injection metadata. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple collection injection + * attributes are declared, the parameter type is unsupported, or the requested element type does not exist. + * + * @return null|class-string Requested collection element type, or null when collection injection is not declared. + */ + 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/ParameterResolver.php b/src/Internal/ParameterResolver.php new file mode 100644 index 0000000..003369e --- /dev/null +++ b/src/Internal/ParameterResolver.php @@ -0,0 +1,312 @@ +=8.0 + * @package Container + */ + +namespace FireHub\Container\Internal; + +use FireHub\Container\Exception\ServiceInstantiationException; +use Closure, ReflectionIntersectionType, ReflectionUnionType; + +/** + * ### Resolves compiled dependency parameters + * + * Resolves parameter dependencies using explicit bindings, contextual bindings, tagged services, collection + * injection, and automatic dependency selection. + * + * The resolver operates within an existing dependency resolution context and does not manage service lifetimes + * directly. + * @since 1.0.0 + * + * @internal + */ +final readonly class ParameterResolver { + + /** + * ### Constructor + * @since 1.0.0 + * + * @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, \FireHub\Container\Internal\ResolutionContext):list $resolve_all

+ * Context-aware service collection resolver. + *

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

+ * Explicit service registration checker. + *

+ * @param \FireHub\Container\Internal\TypeMatcher $matcher

+ * Dependency type matcher. + *

+ * @param \FireHub\Container\Internal\CandidateSelector $selector

+ * Dependency candidate selector. + *

+ * @param \FireHub\Container\Internal\ParameterBindingRegistry $bindings

+ * Shared parameter binding registry. + *

+ * @param \FireHub\Container\Internal\ContextualCandidateSelector $contextual_selector

+ * Contextual dependency candidate selector. + *

+ * + * @return void + */ + public function __construct ( + private Closure $resolve, + private Closure $resolve_tagged, + private Closure $resolve_all, + private Closure $has, + private TypeMatcher $matcher, + private CandidateSelector $selector, + private ParameterBindingRegistry $bindings, + private ContextualCandidateSelector $contextual_selector + ) {} + + /** + * ### Resolves a compiled dependency parameter + * + * Resolves a parameter using explicit values, configured parameter bindings, injection attributes, contextual + * bindings, and automatic dependency resolution. + * + * Explicit values take precedence over all other resolution mechanisms. Constructor parameter bindings can be + * disabled for method invocation while preserving contextual dependency resolution. + * + * Dependency resolution is performed within the supplied context, preserving service lifetimes and circular + * dependency detection. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ParameterBindingRegistry::has() To check explicit parameter bindings. + * @uses \FireHub\Container\Internal\ParameterBindingRegistry::get() To retrieve explicitly bound values. + * @uses \FireHub\Container\Internal\ContextualCandidateSelector::select() To select contextual dependencies. + * @uses \FireHub\Container\Internal\ParameterResolver::selectDependency() To select dependency identifiers. + * @uses \FireHub\Container\Internal\ParameterResolver::resolveDependency() To resolve selected dependencies. + * @uses \FireHub\Container\Internal\ParameterResolver::unresolvedParameter() To create resolution exceptions. + * + * @param null|class-string $consumer

+ * Consumer implementation class used for contextual and parameter bindings, or null when no consumer is available. + *

+ * @param \FireHub\Container\Internal\ParameterPlan $plan

+ * Compiled dependency parameter plan. + *

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

+ * Active dependency resolution context. + *

+ * @param bool $has_explicit [optional]

+ * Whether an explicit argument value has been supplied for the parameter. + *

+ * @param mixed $explicit [optional]

+ * Explicit argument value to use when provided. + *

+ * @param bool $use_bindings [optional]

+ * Whether configured parameter bindings should be considered during resolution. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * + * @return mixed Resolved parameter value. + */ + public function resolve (?string $consumer, ParameterPlan $plan, ResolutionContext $context, bool $has_explicit = false, mixed $explicit = null, bool $use_bindings = true):mixed { + + if ($has_explicit) return $explicit; + + $name = $plan->parameter->getName(); + + if ( + $use_bindings + && $consumer !== null + && $this->bindings->has($consumer, $name) + ) return $this->bindings->get($consumer, $name); + + if ($plan->tag !== null) + return ($this->resolve_tagged)($plan->tag, $context); + + if ($plan->collection !== null) + return ($this->resolve_all)($plan->collection, $context); + + if ($consumer !== null) { + + $id = $this->contextual_selector->select($consumer, $plan); + + if ($id !== null) + return $this->resolveDependency($id, $plan, $context, true); + + } + + $id = $this->selectDependency($plan); + + if ( + $id !== null + && ( + ($this->has)($id) + || $this->matcher->isConstructible($id) + ) + ) return $this->resolveDependency($id, $plan, $context); + + if ($plan->hasDefault) + return $plan->default; + + throw $this->unresolvedParameter($plan); + + } + + /** + * ### Selects an eligible dependency + * + * Determines the service identifier required by a compiled dependency parameter. + * + * Named dependency types use their precompiled identifiers. Union and intersection dependency types are evaluated + * dynamically against currently registered service implementations. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\CandidateSelector::union() To select a union dependency. + * @uses \FireHub\Container\Internal\CandidateSelector::intersection() To select an intersection dependency. + * + * @param \FireHub\Container\Internal\ParameterPlan $plan

+ * Compiled dependency parameter plan. + *

+ * + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the dependency type contains unsupported + * members. + * + * @return null|class-string Selected dependency identifier, or null if no eligible dependency exists. + */ + private function selectDependency (ParameterPlan $plan):?string { + + return match (true) { + $plan->composite instanceof ReflectionUnionType => + $this->selector->union( + $plan->parameter, + $plan->composite + ), + $plan->composite instanceof ReflectionIntersectionType => + $this->selector->intersection( + $plan->parameter, + $plan->composite + ), + default => $plan->id + }; + + } + + /** + * ### Resolves and validates a selected dependency + * + * Resolves the selected service identifier within the active dependency resolution context. + * + * Union and intersection dependencies are validated against their complete declared parameter types. + * Contextually selected named dependencies are additionally validated against their expected contracts. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\CandidateSelector::matchesType() To validate composite dependency types. + * + * @param class-string $id

+ * Selected service identifier. + *

+ * @param \FireHub\Container\Internal\ParameterPlan $plan

+ * Compiled dependency parameter plan. + *

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

+ * Active dependency resolution context. + *

+ * @param bool $contextual [optional]

+ * Whether the dependency was selected through contextual binding. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the resolved dependency does not satisfy + * its declared parameter type. + * + * @return object Resolved dependency instance. + */ + private function resolveDependency (string $id, ParameterPlan $plan, ResolutionContext $context, bool $contextual = false):object { + + $instance = ($this->resolve)($id, $context); + + if ( + $plan->composite !== null + && !$this->selector->matchesType( + $instance::class, + $plan->parameter, + $plan->composite + ) + ) throw new ServiceInstantiationException( + context: [ + 'class' => $plan->parameter->getDeclaringClass()?->getName(), + 'parameter' => $plan->parameter->getName(), + 'type' => (string)$plan->composite, + 'instance' => $instance::class, + ] + ); + + if ( + $contextual + && $plan->composite === null + && $plan->id !== null + ) { + + $expected = $plan->id; + + if (!$instance instanceof $expected) + throw new ServiceInstantiationException( + context: [ + 'class' => $plan->parameter->getDeclaringClass()?->getName(), + 'parameter' => $plan->parameter->getName(), + 'type' => $expected, + 'implementation' => $id, + 'instance' => $instance::class, + ] + ); + + } + + return $instance; + + } + + /** + * ### Creates an unresolved parameter exception + * + * Creates an exception containing diagnostic information about a dependency parameter that could not be resolved + * through explicit bindings, contextual bindings, registered services, implicit construction, or default values. + * + * The diagnostic context includes the declaring class, parameter name, position, declared type, nullability, + * optionality, and default value availability. + * @since 1.0.0 + * + * @param \FireHub\Container\Internal\ParameterPlan $plan

+ * Compiled dependency parameter plan that could not be resolved. + *

+ * + * @return \FireHub\Container\Exception\ServiceInstantiationException Parameter resolution exception. + */ + private function unresolvedParameter (ParameterPlan $plan):ServiceInstantiationException { + + $parameter = $plan->parameter; + $type = $parameter->getType(); + + return new ServiceInstantiationException( + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + 'position' => $parameter->getPosition(), + 'type' => $type?->__toString(), + 'nullable' => $type?->allowsNull(), + 'optional' => $parameter->isOptional(), + 'has_default' => $plan->hasDefault + ] + ); + + } + +} \ No newline at end of file diff --git a/src/Internal/ResolutionContext.php b/src/Internal/ResolutionContext.php index d71208b..2866a64 100644 --- a/src/Internal/ResolutionContext.php +++ b/src/Internal/ResolutionContext.php @@ -41,13 +41,17 @@ * @param null|\FireHub\Container\Internal\ScopeState $scope

* Associated scope lifecycle state. *

+ * @param null|\FireHub\Container\Internal\DisposalTracker $disposables

+ * Scoped disposable resource tracker. + *

* * @return void */ public function __construct ( public ResolutionStack $stack = new ResolutionStack, public ?InstanceStore $scoped = null, - public ?ScopeState $scope = null + public ?ScopeState $scope = null, + public ?DisposalTracker $disposables = null ) {} /** @@ -61,7 +65,7 @@ public function __construct ( */ public function root ():self { - if ($this->scoped === null) + if ($this->scoped === null && $this->disposables === null) return $this; return new self( diff --git a/src/Internal/ScopeRegistry.php b/src/Internal/ScopeRegistry.php new file mode 100644 index 0000000..5a5ea15 --- /dev/null +++ b/src/Internal/ScopeRegistry.php @@ -0,0 +1,111 @@ + + * @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 + */ + +namespace FireHub\Container\Internal; + +use FireHub\Container\Scope; +use SplObjectStorage; + +/** + * ### Tracks active dependency injection scopes + * + * Maintains a registry of scopes belonging to a dependency injection container. + * + * Scopes are registered when created and removed when closed. + * + * The registry maintains strong references to registered scopes, ensuring that + * active scopes remain tracked until they are explicitly closed. + * + * The registry does not control the scope lifecycle or dispose of scoped services. + * @since 1.0.0 + */ +final class ScopeRegistry { + + /** + * ### Registered scopes + * @since 1.0.0 + * + * @var SplObjectStorage<\FireHub\Container\Scope, null> + */ + private SplObjectStorage $scopes; + + /** + * ### Constructor + * @since 1.0.0 + * + * @return void + */ + public function __construct () { + + $this->scopes = new SplObjectStorage; + + } + + /** + * ### Registers a scope + * @since 1.0.0 + * + * @param \FireHub\Container\Scope $scope

+ * Scope to register. + *

+ * + * @return void + */ + public function register (Scope $scope):void { + + $this->scopes->offsetSet($scope); + + } + + /** + * ### Removes a scope from the registry + * + * @since 1.0.0 + * + * @param \FireHub\Container\Scope $scope

+ * Scope to remove. + *

+ * + * @return void + */ + public function unregister (Scope $scope):void { + + $this->scopes->offsetUnset($scope); + + } + + /** + * ### Counts registered scopes + * @since 1.0.0 + * + * @return int Number of registered scopes. + */ + public function count ():int { + + return $this->scopes->count(); + + } + + /** + * ### Checks whether active scopes exist + * @since 1.0.0 + * + * @return bool True if at least one scope is registered. + */ + public function hasActive ():bool { + + return $this->scopes->count() > 0; + + } + +} \ No newline at end of file diff --git a/src/Resolver.php b/src/Resolver.php index 7d2a1f8..3214784 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -14,14 +14,16 @@ namespace FireHub\Container; use FireHub\Core\Boundary\Resolver as ResolverBoundary; +use FireHub\Core\Boundary\Lifecycle\Disposable; use FireHub\Core\Meta\Enum\Container\Lifetime; use FireHub\Container\Definition\ { Alias, Cls, Factory, Instance, Service }; use FireHub\Container\Internal\ { - Autowirer, CandidateSelector, ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector, - ContextualResolver, DependencyPlanCache, InstanceStore, ParameterBindingRegistry, ResolutionContext, ScopeState, - TagRegistry, TypeMatcher + Autowirer, CandidateSelector, ConstructorMetadataCache, ContainerState, ContextualBindingRegistry, + ContextualCandidateSelector, ContextualResolver, DependencyPlanCache, DisposalTracker, InstanceStore, + MethodInvoker, MethodPlanCache, ParameterBindingRegistry, ParameterPlanCompiler, ParameterResolver, + ResolutionContext, ScopeState, TagRegistry, TypeMatcher }; use FireHub\Container\Exception\ { CircularDependencyException, ScopeRequiredException, ServiceNotFoundException, ServiceTypeMismatchException, @@ -68,12 +70,23 @@ */ private Autowirer $autowirer; + /** + * ### Reusable callable invoker + * + * Executes callable invocation plans using the shared dependency resolution infrastructure. + * @since 1.0.0 + */ + private MethodInvoker $invoker; + /** * ### Constructor * @since 1.0.0 * * @uses \FireHub\Container\Resolver::has() To check for service existence. * @uses \FireHub\Container\Resolver::get() To retrieve a service definition. + * @uses \FireHub\Container\Resolver::resolve() To resolve a service. + * @uses \FireHub\Container\Resolver::tagged() To retrieve tagged service definitions. + * @uses \FireHub\Container\Resolver::all() To retrieve all service definitions. * @uses \FireHub\Container\Registry::implementations() To get all registered implementations. * * @param \FireHub\Container\Registry $registry

@@ -91,6 +104,12 @@ * @param \FireHub\Container\Internal\TagRegistry $tags

* Service tag registry. *

+ * @param \FireHub\Container\Internal\DisposalTracker $disposables

+ * Disposable service tracker. + *

+ * @param \FireHub\Container\Internal\ContainerState $state

+ * Container state. + *

* * @return void */ @@ -99,7 +118,9 @@ public function __construct ( private InstanceStore $singletons, ParameterBindingRegistry $parameters, ContextualBindingRegistry $contextual, - private TagRegistry $tags + private TagRegistry $tags, + private DisposalTracker $disposables, + private ContainerState $state ) { $this->metadata = new ConstructorMetadataCache; @@ -114,23 +135,36 @@ public function __construct ( fn ():iterable => $this->registry->implementations() ); + $compiler = new ParameterPlanCompiler( + $this->matcher + ); + $plans = new DependencyPlanCache( $this->metadata, - $this->matcher + $compiler ); - $this->autowirer = new Autowirer( + $parameters_resolver = new ParameterResolver( 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), $this->matcher, $selector, - $plans, $parameters, new ContextualCandidateSelector($contextual) ); + $this->autowirer = new Autowirer( + $plans, + $parameters_resolver + ); + + $this->invoker = new MethodInvoker( + new MethodPlanCache($compiler), + $parameters_resolver + ); + } /** @@ -139,16 +173,21 @@ public function __construct ( * Checks whether a service identifier exists without constructing or resolving its instance. * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. * @uses \FireHub\Container\Registry::has() To check for service existence. * * @param class-string $id

* Service identifier. *

* + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * * @return bool True if the service is registered. */ public function has (string $id):bool { + $this->state->assertOpen(); + return $this->registry->has($id); } @@ -161,6 +200,7 @@ public function has (string $id):bool { * Aliases are followed until their underlying service definition is reached. * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. * @uses \FireHub\Container\Resolver::resolve() To resolve a service. * @uses \FireHub\Container\Internal\InstanceStore::get() To retrieve a previously resolved instance. * @@ -178,11 +218,14 @@ public function has (string $id):bool { * @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. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return TService Resolved service instance. */ public function get (string $id):object { + $this->state->assertOpen(); + $instance = $this->singletons->get($id); if ($instance !== null) @@ -215,6 +258,9 @@ public function get (string $id):object { * @param \FireHub\Container\Internal\ScopeState $scope

* Scope lifecycle state. *

+ * @param \FireHub\Container\Internal\DisposalTracker $disposables

+ * Disposable service tracker. + * * * @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. @@ -223,10 +269,11 @@ public function get (string $id):object { * @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. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return TService Resolved service. */ - public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope):object { + public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope, DisposalTracker $disposables):object { $scope->enter(); @@ -236,7 +283,8 @@ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope) $id, new ResolutionContext( scoped: $scoped, - scope: $scope + scope: $scope, + disposables: $disposables ) ); @@ -259,6 +307,7 @@ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope) * Service aliases are resolved to their underlying targets and do not produce duplicate collection entries. * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. * @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. @@ -279,11 +328,14 @@ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope) * @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\ContainerClosedException If the container is closed. * * @return list Matching service instances. */ public function all (string $type, ResolutionContext $context):array { + $this->state->assertOpen(); + $services = []; $resolved = []; foreach ($this->registry->all() as $id => $definition) { @@ -336,6 +388,9 @@ public function all (string $type, ResolutionContext $context):array { * @param \FireHub\Container\Internal\ScopeState $scope

* Scope state. *

+ * @param \FireHub\Container\Internal\DisposalTracker $disposables

+ * Disposable service tracker. + * * * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered. * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible @@ -345,10 +400,11 @@ public function all (string $type, ResolutionContext $context):array { * @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. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return list Matching service instances. */ - public function allScoped (string $type, InstanceStore $scoped, ScopeState $scope):array { + public function allScoped (string $type, InstanceStore $scoped, ScopeState $scope, DisposalTracker $disposables):array { $scope->enter(); @@ -358,7 +414,8 @@ public function allScoped (string $type, InstanceStore $scoped, ScopeState $scop $type, new ResolutionContext( scoped: $scoped, - scope: $scope + scope: $scope, + disposables: $disposables ) ); @@ -381,6 +438,7 @@ public function allScoped (string $type, InstanceStore $scoped, ScopeState $scop * An unknown tag produces an empty collection. * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. * @uses \FireHub\Container\Internal\TagRegistry::get() To retrieve tagged service identifiers. * @uses \FireHub\Container\Resolver::resolve() To resolve each tagged service. * @@ -400,11 +458,14 @@ public function allScoped (string $type, InstanceStore $scoped, ScopeState $scop * @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\ContainerClosedException If the container is closed. * * @return list Resolved service instances. */ public function tagged (string $tag, ResolutionContext $context):array { + $this->state->assertOpen(); + $services = []; foreach ($this->tags->get($tag) as $id) @@ -436,6 +497,9 @@ public function tagged (string $tag, ResolutionContext $context):array { * @param \FireHub\Container\Internal\ScopeState $scope

* Scope lifecycle state. *

+ * @param \FireHub\Container\Internal\DisposalTracker $disposables

+ * Disposable service tracker. + * * * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. * @throws \FireHub\Container\Exception\ServiceNotFoundException If a tagged service is no longer registered. @@ -447,10 +511,11 @@ public function tagged (string $tag, ResolutionContext $context):array { * @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\ContainerClosedException If the container is closed. * * @return list Resolved service instances. */ - public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $scope):array { + public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $scope, DisposalTracker $disposables):array { $scope->enter(); @@ -460,7 +525,108 @@ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $sc $tag, new ResolutionContext( scoped: $scoped, - scope: $scope + scope: $scope, + disposables: $disposables + ) + ); + + } finally { + + $scope->leave(); + + } + + } + + /** + * ### Invokes a callable using dependency injection + * + * Executes the specified callable using explicit arguments and automatically resolved dependencies. + * + * Service dependencies are resolved using a root resolution context. Scoped services cannot be injected unless + * invocation is performed through an active scope. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. + * @uses \FireHub\Container\Internal\MethodInvoker::invoke() To execute the callable. + * + * @param callable $callback

+ * Callable to execute using dependency injection. + *

+ * @param array $arguments [optional]

+ * Explicit positional and named arguments. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * + * @return mixed Value returned by the invoked callable. + */ + public function invoke (callable $callback, array $arguments = []):mixed { + + $this->state->assertOpen(); + + return $this->invoker->invoke( + $callback, + $arguments, + new ResolutionContext + ); + + } + + /** + * ### Invokes a callable within a resolution scope + * + * Executes the specified callable using the provided scoped instance store and scope lifecycle state. + * + * Scoped dependencies are resolved and reused within the supplied scope. The scope remains open after invocation. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ScopeState::enter() To enter the active scope. + * @uses \FireHub\Container\Internal\MethodInvoker::invoke() To execute the callable. + * @uses \FireHub\Container\Internal\ScopeState::leave() To leave the active scope. + * + * @param callable $callback

+ * Callable to execute using scoped dependency injection. + *

+ * @param array $arguments

+ * Explicit positional and named arguments. + *

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

+ * Scoped service instance storage. + *

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

+ * Active scope lifecycle state. + *

+ * @param \FireHub\Container\Internal\DisposalTracker $disposables

+ * Disposable service tracker. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. + * + * @return mixed Value returned by the invoked callable. + */ + public function invokeScoped (callable $callback, array $arguments, InstanceStore $scoped, ScopeState $scope, DisposalTracker $disposables) + :mixed { + + $scope->enter(); + + try { + + return $this->invoker->invoke( + $callback, + $arguments, + new ResolutionContext( + scoped: $scoped, + scope: $scope, + disposables: $disposables ) ); @@ -476,6 +642,7 @@ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $sc * ### Resolves a service within the specified context * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. * @uses \FireHub\Container\Registry::find() To retrieve a service definition. * @uses \FireHub\Container\Internal\ResolutionStack::enter() To mark the service as being currently being resolved. * @uses \FireHub\Container\Internal\ResolutionStack::leave() To remove the service from the resolution stack. @@ -498,11 +665,14 @@ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $sc * @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\ContainerClosedException If the container is closed. * * @return TService Resolved service. */ private function resolve (string $id, ResolutionContext $context):object { + $this->state->assertOpen(); + $definition = $this->registry->find($id); if ($definition === null) @@ -551,6 +721,7 @@ private function resolve (string $id, ResolutionContext $context):object { * @uses \FireHub\Container\Internal\ResolutionStack::enter() To mark the service as being currently being resolved. * @uses \FireHub\Container\Internal\ResolutionStack::leave() To remove the service from the resolution stack. * @uses \FireHub\Container\Resolver::create() To construct a service instance. + * @uses \FireHub\Container\Internal\DisposalTracker::track() To track a disposable service. * * @template TService of object * @@ -581,7 +752,6 @@ private function resolveService (string $id, Service $definition, ResolutionCont Lifetime::SCOPED => $context->scoped ?? throw new ScopeRequiredException, Lifetime::TRANSIENT => null - }; if ($store !== null) { @@ -603,6 +773,19 @@ private function resolveService (string $id, Service $definition, ResolutionCont if (!$instance instanceof $id) throw new ServiceTypeMismatchException; + if ( + $instance instanceof Disposable + && !$definition instanceof Instance + ) { + + match ($lifetime) { + Lifetime::SINGLETON => $this->disposables->track($instance), + Lifetime::SCOPED => $context->disposables?->track($instance), + Lifetime::TRANSIENT => null + }; + + } + $store?->set($id, $instance); return $instance; @@ -823,11 +1006,16 @@ private function matchesCollectionType (string $id, string $target, string $type * Service definition. *

* + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the implementation class is not + * instantiable or a required constructor dependency cannot be resolved. + * @throws \FireHub\Container\Exception\ServiceNotFoundException If a required dependency is not registered. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are + * eligible. + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * @throws \FireHub\Container\Exception\ScopeRequiredException If a dependency requires an active scope. + * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If a resolved dependency does not satisfy + * its registered service type. * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported. - * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter is not optional and cannot - * be resolved. - * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service is not registered. - * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. * * @return object Created service instance. */ @@ -862,10 +1050,15 @@ private function create (Service $definition, ResolutionContext $context):object * Active resolution context. *

* - * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter is not optional and cannot - * be resolved. - * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service is not registered. - * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the implementation class is not + * instantiable or a required constructor dependency cannot be resolved. + * @throws \FireHub\Container\Exception\ServiceNotFoundException If a required dependency is not registered. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are + * eligible. + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * @throws \FireHub\Container\Exception\ScopeRequiredException If a dependency requires an active scope. + * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If a resolved dependency does not satisfy + * its registered service type. * * @return object Constructed service instance. */ diff --git a/src/Scope.php b/src/Scope.php index f1be407..07b0adb 100644 --- a/src/Scope.php +++ b/src/Scope.php @@ -15,7 +15,7 @@ use FireHub\Core\Boundary\Container\Scope as ScopeBoundary; use FireHub\Container\Internal\ { - InstanceStore, ScopeState + DisposalTracker, InstanceStore, ScopeRegistry, ScopeState }; use Closure; @@ -50,6 +50,12 @@ */ private ScopeState $state; + /** + * ### Scoped disposable resource tracker + * @since 1.0.0 + */ + private DisposalTracker $disposables; + /** * ### Constructor * @since 1.0.0 @@ -57,17 +63,23 @@ * @param \FireHub\Container\Resolver $resolver

* Parent container's service resolver. *

+ * @param \FireHub\Container\Internal\ScopeRegistry $registry

+ * Parent container's scope registry. + *

* * @return void */ public function __construct ( - private Resolver $resolver + private Resolver $resolver, + private ScopeRegistry $registry ) { $this->instances = new InstanceStore; $this->state = new ScopeState; + $this->disposables = new DisposalTracker; + } /** @@ -137,7 +149,75 @@ public function tagged (string $tag):array { return $this->resolver->taggedScoped( $tag, $this->instances, - $this->state + $this->state, + $this->disposables + ); + + } + + /** + * ### Invokes a callable within the current scope + * + * Executes the specified callable by combining explicitly supplied arguments with dependencies resolved from the + * current scope. + * + * Singleton services are shared with the parent container, scoped services are reused within this scope, and + * transient services are constructed for each resolution. + * + * The scope remains open after invocation and may be reused until explicitly closed. + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->scoped(DatabaseConnection::class); + * + * $scope = $container->createScope(); + * + * try { + * + * $service = new UserService; + * + * $result = $scope->invoke( + * [$service, 'create'], + * ['username' => 'Danijel'] + * ); + * + * } finally { + * + * $scope->close(); + * + * } + * + * + * @since 1.0.0 + * + * @uses \FireHub\Container\Resolver::invokeScoped() To execute the callable within the current scope. + * + * @param callable $callback

+ * Callable to execute. + *

+ * @param array $arguments [optional]

+ * Explicit positional and named arguments. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. + * + * @return mixed Value returned by the invoked callable. + */ + public function invoke (callable $callback, array $arguments = []):mixed { + + return $this->resolver->invokeScoped( + $callback, + $arguments, + $this->instances, + $this->state, + $this->disposables ); } @@ -195,7 +275,8 @@ public function get (string $id):object { return $this->resolver->getScoped( $id, $this->instances, - $this->state + $this->state, + $this->disposables ); } @@ -296,7 +377,8 @@ public function all (string $type):array { return $this->resolver->allScoped( $type, $this->instances, - $this->state + $this->state, + $this->disposables ); } @@ -347,7 +429,8 @@ public function all (string $type):array { *

* * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. - * @throws \FireHub\Container\Exception\ScopeActiveException If the scope is already executing. + * @throws \FireHub\Container\Exception\ScopeActiveException If service resolution is active. + * @throws \FireHub\Container\Exception\ServiceDisposalException If an instance fails to release its resources. * * @return TResult Callback result. */ @@ -407,12 +490,15 @@ public function isClosed ():bool { /** * ### Closes the scope * - * Releases all cached scoped service instances and prevents further service resolution through this scope. + * Prevents further service resolution and disposes of all owned scoped service instances. + * + * Disposable services are released in reverse registration order. + * + * The instance store is cleared even when resource disposal fails. * * Closing an already closed scope has no additional effect. * - * Closing a scope does not affect singleton instances maintained by the parent container or instances stored in - * other scopes. + * Singleton instances and resources owned by other scopes remain unaffected. * * * use FireHub\Container\Container; @@ -425,10 +511,8 @@ public function isClosed ():bool { * * $connection = $scope->get(DatabaseConnection::class); * - * // Release cached scoped instances. * $scope->close(); * - * // The scope can no longer resolve services. * $scope->isClosed(); // true * * @@ -436,8 +520,11 @@ public function isClosed ():bool { * * @uses \FireHub\Container\Internal\ScopeState::close() To close the scope. * @uses \FireHub\Container\Internal\InstanceStore::clear() To clear the instance store. + * @uses \FireHub\Container\Internal\DisposalTracker::dispose() To dispose of disposable resources. + * @uses \FireHub\Container\Internal\ScopeRegistry::unregister() To unregister the scope from the registry. * * @throws \FireHub\Container\Exception\ScopeActiveException If service resolution is active. + * @throws \FireHub\Container\Exception\ServiceDisposalException If an instance fails to release its resources. * * @return void */ @@ -445,7 +532,23 @@ public function close ():void { $this->state->close(); - $this->instances->clear(); + try { + + $this->disposables->dispose(); + + } finally { + + try { + + $this->instances->clear(); + + } finally { + + $this->registry->unregister($this); + + } + + } } diff --git a/tests/Stubs/DisposableTestService.php b/tests/Stubs/DisposableTestService.php new file mode 100644 index 0000000..cc521c5 --- /dev/null +++ b/tests/Stubs/DisposableTestService.php @@ -0,0 +1,42 @@ + + * @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; + +use FireHub\Core\Boundary\Lifecycle\Disposable; + +/** + * ### Disposable test service + * @since 1.0.0 + */ +final class DisposableTestService implements Disposable { + + /** + * ### Number of disposal calls + * @since 1.0.0 + */ + public int $disposals = 0; + + /** + * ### Releases the service + * @since 1.0.0 + * + * @return void + */ + public function dispose ():void { + + $this->disposals++; + + } + +} \ No newline at end of file diff --git a/tests/Stubs/FailingDisposableTestService.php b/tests/Stubs/FailingDisposableTestService.php new file mode 100644 index 0000000..b536028 --- /dev/null +++ b/tests/Stubs/FailingDisposableTestService.php @@ -0,0 +1,47 @@ + + * @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; + +use FireHub\Core\Boundary\Lifecycle\Disposable; +use RuntimeException; + +/** + * ### Failing disposable test service + * + * Throws an exception during disposal. + * @since 1.0.0 + */ +final class FailingDisposableTestService implements Disposable { + + /** + * ### Number of disposal calls + * @since 1.0.0 + */ + public int $disposals = 0; + + /** + * ### Releases the service + * @since 1.0.0 + * + * @return void + */ + public function dispose ():void { + + $this->disposals++; + + throw new RuntimeException('Disposal failed'); + + } + +} \ No newline at end of file diff --git a/tests/Stubs/FirstOrderedDisposableService.php b/tests/Stubs/FirstOrderedDisposableService.php new file mode 100644 index 0000000..cd07385 --- /dev/null +++ b/tests/Stubs/FirstOrderedDisposableService.php @@ -0,0 +1,20 @@ + + * @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; + +/** + * ### First ordered disposable service + * @since 1.0.0 + */ +final class FirstOrderedDisposableService extends OrderedDisposableTestService {} \ No newline at end of file diff --git a/tests/Stubs/OrderedDisposableTestService.php b/tests/Stubs/OrderedDisposableTestService.php new file mode 100644 index 0000000..1a3bd99 --- /dev/null +++ b/tests/Stubs/OrderedDisposableTestService.php @@ -0,0 +1,50 @@ + + * @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; + +use FireHub\Core\Boundary\Lifecycle\Disposable; + +/** + * ### Ordered disposable test service + * + * Records disposal order using a shared event collection. + * @since 1.0.0 + */ +class OrderedDisposableTestService implements Disposable { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param string $name Service name. + * @param array $events Shared disposal events. + */ + public function __construct ( + public string $name, + private array &$events + ) {} + + /** + * ### Records disposal + * @since 1.0.0 + * + * @return void + */ + public function dispose ():void { + + $this->events[] = $this->name; + + } + +} \ No newline at end of file diff --git a/tests/Stubs/ResolvingDisposableTestService.php b/tests/Stubs/ResolvingDisposableTestService.php new file mode 100644 index 0000000..3925e63 --- /dev/null +++ b/tests/Stubs/ResolvingDisposableTestService.php @@ -0,0 +1,49 @@ + + * @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; + +use FireHub\Core\Boundary\Lifecycle\Disposable; +use FireHub\Container\Container; + +/** + * ### Disposable service that attempts resolution during disposal + * @since 1.0.0 + */ +final class ResolvingDisposableTestService implements Disposable { + + /** + * ### Constructor + * @param Container $container Parent container. + * @since 1.0.0 + * + */ + public function __construct( + private Container $container + ) {} + + /** + * ### Attempts resolution during disposal + * @return void + * @throws ContainerClosedException If the container is closed. + * + * @since 1.0.0 + * + */ + public function dispose(): void { + + $this->container->get(DisposableTestService::class); + + } + +} \ No newline at end of file diff --git a/tests/Stubs/SecondOrderedDisposableService.php b/tests/Stubs/SecondOrderedDisposableService.php new file mode 100644 index 0000000..e08363a --- /dev/null +++ b/tests/Stubs/SecondOrderedDisposableService.php @@ -0,0 +1,20 @@ + + * @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; + +/** + * ### Second ordered disposable service + * @since 1.0.0 + */ +final class SecondOrderedDisposableService extends OrderedDisposableTestService {} \ No newline at end of file diff --git a/tests/Unit/ContainerLifecycleTest.php b/tests/Unit/ContainerLifecycleTest.php new file mode 100644 index 0000000..7ce9572 --- /dev/null +++ b/tests/Unit/ContainerLifecycleTest.php @@ -0,0 +1,505 @@ + + * @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\ { + ContainerActiveScopesException, ContainerClosedException, ContainerFrozenException, ScopeActiveException, + ScopeClosedException +}; +use FireHub\Tests\Container\Stubs\Logger; +use FireHub\Tests\Container\Support\ExceptionAssertions; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests dependency injection container lifecycle + * @since 1.0.0 + */ +#[Small] +#[Group('container')] +#[Group('lifecycle')] +#[CoversClass(Container::class)] +final class ContainerLifecycleTest extends FireHubTestCase { + + use ExceptionAssertions; + + /** + * ### New containers are open and mutable + * @since 1.0.0 + * + * @return void + */ + public function testNewContainerIsOpenAndMutable ():void { + + $container = new Container; + + self::assertFalse($container->isFrozen()); + self::assertFalse($container->isClosed()); + + $container->singleton(Logger::class); + + self::assertTrue($container->has(Logger::class)); + + } + + /** + * ### Freezing prevents further registrations + * @since 1.0.0 + * + * @return void + */ + public function testFreezePreventsRegistrations ():void { + + $container = new Container; + + $container->freeze(); + + self::assertTrue($container->isFrozen()); + self::assertFalse($container->isClosed()); + + $this->assertThrows( + ContainerFrozenException::class, + function () use ($container):void { + + $container->singleton(Logger::class); + + } + ); + + } + + /** + * ### Frozen containers continue resolving services + * @since 1.0.0 + * + * @return void + */ + public function testFrozenContainerAllowsResolution ():void { + + $container = new Container; + + $container->singleton(Logger::class); + + $container->freeze(); + + $first = $container->get(Logger::class); + $second = $container->get(Logger::class); + + self::assertInstanceOf(Logger::class, $first); + self::assertSame($first, $second); + + } + + /** + * ### Freezing is idempotent + * @since 1.0.0 + * + * @return void + */ + public function testFreezeIsIdempotent ():void { + + $container = new Container; + + $container->freeze(); + $container->freeze(); + + self::assertTrue($container->isFrozen()); + + } + + /** + * ### Closing an open container changes its lifecycle state + * @since 1.0.0 + * + * @return void + */ + public function testCloseChangesContainerState ():void { + + $container = new Container; + + $container->close(); + + self::assertTrue($container->isClosed()); + + } + + /** + * ### Closing is idempotent + * @since 1.0.0 + * + * @return void + */ + public function testCloseIsIdempotent ():void { + + $container = new Container; + + $container->close(); + $container->close(); + + self::assertTrue($container->isClosed()); + + } + + /** + * ### Closed containers reject resolution + * @since 1.0.0 + * + * @return void + */ + public function testClosedContainerRejectsResolution ():void { + + $container = new Container; + + $container->singleton(Logger::class); + + $container->get(Logger::class); + + $container->close(); + + $this->assertThrows( + ContainerClosedException::class, + function () use ($container):void { + + $container->get(Logger::class); + + } + ); + + } + + /** + * ### Closed containers reject registrations + * @since 1.0.0 + * + * @return void + */ + public function testClosedContainerRejectsRegistrations ():void { + + $container = new Container; + + $container->close(); + + $this->assertThrows( + ContainerClosedException::class, + function () use ($container):void { + + $container->singleton(Logger::class); + + } + ); + + } + + /** + * ### Closed containers reject freezing + * @since 1.0.0 + * + * @return void + */ + public function testClosedContainerRejectsFreeze ():void { + + $container = new Container; + + $container->close(); + + $this->assertThrows( + ContainerClosedException::class, + function () use ($container):void { + + $container->freeze(); + + } + ); + + } + + /** + * ### Closed containers reject scope creation + * @since 1.0.0 + * + * @return void + */ + public function testClosedContainerRejectsScopeCreation ():void { + + $container = new Container; + + $container->close(); + + $this->assertThrows( + ContainerClosedException::class, + function () use ($container):void { + + $container->createScope(); + + } + ); + + } + + /** + * ### Closed containers reject service queries + * @since 1.0.0 + * + * @return void + */ + public function testClosedContainerRejectsServiceQueries ():void { + + $container = new Container; + + $container->close(); + + $this->assertThrows( + ContainerClosedException::class, + function () use ($container):void { + + $container->has(Logger::class); + + } + ); + + } + + /** + * ### Frozen containers can be closed + * @since 1.0.0 + * + * @return void + */ + public function testFrozenContainerCanBeClosed ():void { + + $container = new Container; + + $container->freeze(); + $container->close(); + + self::assertTrue($container->isFrozen()); + self::assertTrue($container->isClosed()); + + } + + /** + * ### Active scopes prevent container closure + * @since 1.0.0 + * + * @return void + */ + public function testActiveScopePreventsContainerClosure ():void { + + $container = new Container; + + $scope = $container->createScope(); + + $this->assertThrows( + ContainerActiveScopesException::class, + function () use ($container):void { + + $container->close(); + + } + ); + + self::assertFalse($container->isClosed()); + self::assertFalse($scope->isClosed()); + + $scope->close(); + + $container->close(); + + self::assertTrue($container->isClosed()); + + } + + /** + * ### Every active scope must close before its container + * @since 1.0.0 + * + * @return void + */ + public function testMultipleScopesPreventContainerClosure ():void { + + $container = new Container; + + $first = $container->createScope(); + $second = $container->createScope(); + + $first->close(); + + $this->assertThrows( + ContainerActiveScopesException::class, + function () use ($container):void { + + $container->close(); + + } + ); + + $second->close(); + + $container->close(); + + self::assertTrue($container->isClosed()); + + } + + /** + * ### Closing a scope is idempotent + * @since 1.0.0 + * + * @return void + */ + public function testScopeCloseIsIdempotent ():void { + + $container = new Container; + + $scope = $container->createScope(); + + $scope->close(); + $scope->close(); + + self::assertTrue($scope->isClosed()); + + $container->close(); + + self::assertTrue($container->isClosed()); + + } + + /** + * ### Closed scopes reject resolution + * @since 1.0.0 + * + * @return void + */ + public function testClosedScopeRejectsResolution ():void { + + $container = new Container; + + $scope = $container->createScope(); + + $scope->close(); + + $this->assertThrows( + ScopeClosedException::class, + function () use ($scope):void { + + $scope->get(Logger::class); + + } + ); + + } + + /** + * ### A scope cannot close while its run callback is active + * @since 1.0.0 + * + * @return void + */ + public function testScopeCannotCloseDuringRun ():void { + + $container = new Container; + + $scope = $container->createScope(); + + $scope->run( + function () use ($scope):void { + + $this->assertThrows( + ScopeActiveException::class, + function () use ($scope):void { + + $scope->close(); + + } + ); + + } + ); + + self::assertTrue($scope->isClosed()); + + $container->close(); + + } + + /** + * ### Scope run automatically closes the scope + * @since 1.0.0 + * + * @return void + */ + public function testScopeRunAutomaticallyClosesScope ():void { + + $container = new Container; + + $scope = $container->createScope(); + + $executed = false; + + $scope->run( + function () use (&$executed):void { + + $executed = true; + + } + ); + + self::assertTrue($executed); + self::assertTrue($scope->isClosed()); + + $container->close(); + + self::assertTrue($container->isClosed()); + + } + + /** + * ### Scope run closes the scope when its callback fails + * @since 1.0.0 + * + * @return void + */ + public function testScopeRunClosesScopeOnException ():void { + + $container = new Container; + + $scope = $container->createScope(); + + $this->assertThrows( + \RuntimeException::class, + function () use ($scope):void { + + $scope->run( + function ():void { + + throw new \RuntimeException('Execution failed'); + + } + ); + + } + ); + + self::assertTrue($scope->isClosed()); + + $container->close(); + + self::assertTrue($container->isClosed()); + + } + +} \ No newline at end of file diff --git a/tests/Unit/DisposableServicesTest.php b/tests/Unit/DisposableServicesTest.php new file mode 100644 index 0000000..f0036d7 --- /dev/null +++ b/tests/Unit/DisposableServicesTest.php @@ -0,0 +1,426 @@ + + * @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\Core\Meta\Enum\Container\Lifetime; +use FireHub\Container\Container; +use FireHub\Container\Exception\ServiceDisposalException; +use FireHub\Tests\Container\Stubs\{ + DisposableTestService, FailingDisposableTestService, FirstOrderedDisposableService, ResolvingDisposableTestService, + SecondOrderedDisposableService +}; +use FireHub\Tests\Container\Support\ExceptionAssertions; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests disposable service lifecycle + * @since 1.0.0 + */ +#[Small] +#[Group('container')] +#[Group('lifecycle')] +#[Group('disposal')] +#[CoversClass(Container::class)] +final class DisposableServicesTest extends FireHubTestCase { + + use ExceptionAssertions; + + /** + * ### Singleton disposable services are released on container closure + * @since 1.0.0 + * + * @return void + */ + public function testSingletonDisposedOnContainerClose ():void { + + $container = new Container; + + $container->singleton(DisposableTestService::class); + + $service = $container->get(DisposableTestService::class); + + self::assertSame(0, $service->disposals); + + $container->close(); + + self::assertSame(1, $service->disposals); + + } + + /** + * ### Singleton services are disposed of only once + * @since 1.0.0 + * + * @return void + */ + public function testSingletonDisposedOnlyOnce ():void { + + $container = new Container; + + $container->singleton(DisposableTestService::class); + + $service = $container->get(DisposableTestService::class); + + $container->close(); + $container->close(); + + self::assertSame(1, $service->disposals); + + } + + /** + * ### Unresolved singleton services are not disposed + * @since 1.0.0 + * + * @return void + */ + public function testUnresolvedSingletonIsNotInstantiatedDuringClose ():void { + + $container = new Container; + + $created = 0; + + $container->factory( + DisposableTestService::class, + function () use (&$created):DisposableTestService { + + $created++; + + return new DisposableTestService; + + }, + Lifetime::SINGLETON + ); + + $container->close(); + + self::assertSame(0, $created); + + } + + /** + * ### Scoped services are released on scope closure + * @since 1.0.0 + * + * @return void + */ + public function testScopedServiceDisposedOnScopeClose ():void { + + $container = new Container; + + $container->scoped(DisposableTestService::class); + + $scope = $container->createScope(); + + $service = $scope->get(DisposableTestService::class); + + self::assertSame(0, $service->disposals); + + $scope->close(); + + self::assertSame(1, $service->disposals); + + $container->close(); + + self::assertSame(1, $service->disposals); + + } + + /** + * ### Independent scopes dispose of their own instances + * @since 1.0.0 + * + * @return void + */ + public function testIndependentScopesDisposeTheirOwnInstances ():void { + + $container = new Container; + + $container->scoped(DisposableTestService::class); + + $firstScope = $container->createScope(); + $secondScope = $container->createScope(); + + $first = $firstScope->get(DisposableTestService::class); + $second = $secondScope->get(DisposableTestService::class); + + self::assertNotSame($first, $second); + + $firstScope->close(); + + self::assertSame(1, $first->disposals); + self::assertSame(0, $second->disposals); + + $secondScope->close(); + + self::assertSame(1, $second->disposals); + + $container->close(); + + } + + /** + * ### Closing a scope does not dispose of singleton services + * @since 1.0.0 + * + * @return void + */ + public function testScopeCloseDoesNotDisposeSingleton ():void { + + $container = new Container; + + $container->singleton(DisposableTestService::class); + + $scope = $container->createScope(); + + $service = $scope->get(DisposableTestService::class); + + $scope->close(); + + self::assertSame(0, $service->disposals); + + $container->close(); + + self::assertSame(1, $service->disposals); + + } + + /** + * ### Transient services are not automatically disposed + * @since 1.0.0 + * + * @return void + */ + public function testTransientServicesAreNotAutomaticallyDisposed ():void { + + $container = new Container; + + $container->transient(DisposableTestService::class); + + $first = $container->get(DisposableTestService::class); + $second = $container->get(DisposableTestService::class); + + self::assertNotSame($first, $second); + + $container->close(); + + self::assertSame(0, $first->disposals); + self::assertSame(0, $second->disposals); + + } + + /** + * ### Externally supplied instances remain caller-owned + * @since 1.0.0 + * + * @return void + */ + public function testExternalInstancesAreNotDisposed ():void { + + $container = new Container; + + $service = new DisposableTestService; + + $container->instance( + DisposableTestService::class, + $service + ); + + self::assertSame( + $service, + $container->get(DisposableTestService::class) + ); + + $container->close(); + + self::assertSame(0, $service->disposals); + + } + + /** + * ### Disposable singleton resources are released in reverse creation order + * @since 1.0.0 + * + * @return void + */ + public function testSingletonDisposalUsesReverseCreationOrder ():void { + + $container = new Container; + + $events = []; + + $container->factory( + FirstOrderedDisposableService::class, + function () use (&$events):FirstOrderedDisposableService { + + return new FirstOrderedDisposableService('first', $events); + + }, + Lifetime::SINGLETON + ); + + $container->factory( + SecondOrderedDisposableService::class, + function () use (&$events):SecondOrderedDisposableService { + + return new SecondOrderedDisposableService('second', $events); + + }, + Lifetime::SINGLETON + ); + + $container->get(FirstOrderedDisposableService::class); + $container->get(SecondOrderedDisposableService::class); + + $container->close(); + + self::assertSame( + ['second', 'first'], + $events + ); + + } + + /** + * ### Disposal failures do not prevent container closure + * @since 1.0.0 + * + * @return void + */ + public function testDisposalFailureDoesNotPreventContainerClosure ():void { + + $container = new Container; + + $container->singleton(FailingDisposableTestService::class); + + $service = $container->get(FailingDisposableTestService::class); + + $this->assertThrows( + ServiceDisposalException::class, + function () use ($container):void { + + $container->close(); + + } + ); + + self::assertTrue($container->isClosed()); + self::assertSame(1, $service->disposals); + + $container->close(); + + self::assertSame(1, $service->disposals); + + } + + /** + * ### Disposal failures do not prevent remaining resources from being released + * @since 1.0.0 + * + * @return void + */ + public function testDisposalContinuesAfterFailure ():void { + + $container = new Container; + + $container->singleton(DisposableTestService::class); + $container->singleton(FailingDisposableTestService::class); + + $successful = $container->get(DisposableTestService::class); + $failing = $container->get(FailingDisposableTestService::class); + + $this->assertThrows( + ServiceDisposalException::class, + function () use ($container):void { + + $container->close(); + + } + ); + + self::assertSame(1, $successful->disposals); + self::assertSame(1, $failing->disposals); + + } + + /** + * ### Scoped disposal failures do not leave active scope registrations + * @since 1.0.0 + * + * @return void + */ + public function testScopeDisposalFailureUnregistersScope ():void { + + $container = new Container; + + $container->scoped(FailingDisposableTestService::class); + + $scope = $container->createScope(); + + $scope->get(FailingDisposableTestService::class); + + $this->assertThrows( + ServiceDisposalException::class, + function () use ($scope):void { + + $scope->close(); + + } + ); + + self::assertTrue($scope->isClosed()); + + $container->close(); + + self::assertTrue($container->isClosed()); + + } + + /** + * ### Resolution is prohibited during container disposal + * @since 1.0.0 + * + * @return void + */ + public function testResolutionDuringDisposalIsRejected ():void { + + $container = new Container; + + $service = new ResolvingDisposableTestService($container); + + $container->factory( + ResolvingDisposableTestService::class, + fn ():ResolvingDisposableTestService => $service, + Lifetime::SINGLETON + ); + + $container->get(ResolvingDisposableTestService::class); + + $this->assertThrows( + ServiceDisposalException::class, + function () use ($container):void { + + $container->close(); + + } + ); + + self::assertTrue($container->isClosed()); + + } + +} \ No newline at end of file diff --git a/tests/Unit/MethodInjectionTest.php b/tests/Unit/MethodInjectionTest.php new file mode 100644 index 0000000..f79f0aa --- /dev/null +++ b/tests/Unit/MethodInjectionTest.php @@ -0,0 +1,864 @@ + + * @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\Container\Attribute\ { + InjectAll, Tagged +}; +use FireHub\Container\Container; +use FireHub\Container\Exception\ { + InvalidMethodArgumentException, ScopeClosedException, ScopeRequiredException, ServiceInstantiationException +}; +use FireHub\Testing\FireHubTestCase; +use FireHub\Tests\Container\Stubs\ { + CollectionConsoleLogger, CollectionFileLogger, CollectionLogger, ContextualFileLogger, ContextualLogger, Logger, + ScopedDependency, TaggedConsoleLogger, TaggedFileLogger +}; +use FireHub\Tests\Container\Support\ExceptionAssertions; +use LogicException; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests callable dependency injection and invocation arguments + * + * Covers supported callback forms, explicit argument precedence, signature validation, contextual bindings, + * collection attributes, scopes, and exception propagation. + * @since 1.0.0 + */ +#[Small] +#[Group('container')] +#[CoversClass(Container::class)] +final class MethodInjectionTest extends FireHubTestCase { + + /** + * @since 1.0.0 + */ + use ExceptionAssertions; + + /** + * ### Closure receives an implicitly constructed dependency + * @since 1.0.0 + * + * @return void + */ + public function testClosureReceivesImplicitDependency ():void { + + $result = (new Container)->invoke( + static fn (Logger $logger):object => $logger + ); + + self::assertInstanceOf(Logger::class, $result); + + } + + /** + * ### Singleton dependency is reused across invocations + * @since 1.0.0 + * + * @return void + */ + public function testSingletonDependencyIsReused ():void { + + $container = new Container; + $container->singleton(Logger::class); + + $first = $container->invoke(static fn (Logger $logger):Logger => $logger); + $second = $container->invoke(static fn (Logger $logger):Logger => $logger); + + self::assertSame($first, $second); + self::assertSame($first, $container->get(Logger::class)); + + } + + /** + * ### Explicit named value overrides dependency injection + * @since 1.0.0 + * + * @return void + */ + public function testExplicitNamedDependencyOverridesAutowiring ():void { + + $explicit = new Logger; + + $result = (new Container)->invoke( + static fn (Logger $logger):Logger => $logger, + ['logger' => $explicit] + ); + + self::assertSame($explicit, $result); + + } + + /** + * ### Explicit positional value overrides dependency injection + * @since 1.0.0 + * + * @return void + */ + public function testExplicitPositionalDependencyOverridesAutowiring ():void { + + $explicit = new Logger; + + $result = (new Container)->invoke( + static fn (Logger $logger):Logger => $logger, + [$explicit] + ); + + self::assertSame($explicit, $result); + + } + + /** + * ### Named scalar argument is combined with an injected service + * @since 1.0.0 + * + * @return void + */ + public function testNamedScalarArgumentWithInjectedDependency ():void { + + $result = (new Container)->invoke( + static fn (Logger $logger, string $message):array => [$logger, $message], + ['message' => 'FireHub'] + ); + + self::assertInstanceOf(Logger::class, $result[0]); + self::assertSame('FireHub', $result[1]); + + } + + /** + * ### Multiple named arguments may be supplied out of declaration order + * @since 1.0.0 + * + * @return void + */ + public function testNamedArgumentsCanBeOutOfOrder ():void { + + $result = (new Container)->invoke( + static fn (string $first, string $second):array => [$first, $second], + ['second' => 'B', 'first' => 'A'] + ); + + self::assertSame(['A', 'B'], $result); + + } + + /** + * ### Positional and named arguments can target different parameters + * @since 1.0.0 + * + * @return void + */ + public function testPositionalAndNamedArgumentsCanBeCombined ():void { + + $result = (new Container)->invoke( + static fn (string $first, string $second, string $third):array => [$first, $second, $third], + [0 => 'A', 'third' => 'C', 'second' => 'B'] + ); + + self::assertSame(['A', 'B', 'C'], $result); + + } + + /** + * ### Missing optional argument uses its declared default + * @since 1.0.0 + * + * @return void + */ + public function testDefaultArgumentIsApplied ():void { + + self::assertSame( + 'default', + (new Container)->invoke(static fn (string $value = 'default'):string => $value) + ); + + } + + /** + * ### Explicit null is not mistaken for an omitted argument + * @since 1.0.0 + * + * @return void + */ + public function testExplicitNullOverridesDefault ():void { + + self::assertNull( + (new Container)->invoke( + static fn (?string $value = 'default'):?string => $value, + ['value' => null] + ) + ); + + } + + /** + * ### Required unbound scalar dependency fails + * @since 1.0.0 + * + * @return void + */ + public function testMissingRequiredScalarThrows ():void { + + $this->assertThrows( + ServiceInstantiationException::class, + static function ():void { + (new Container)->invoke(static fn (string $required):string => $required); + } + ); + + } + + /** + * ### Instance method receives dependencies and explicit arguments + * @since 1.0.0 + * + * @return void + */ + public function testInstanceMethodInjection ():void { + + $fixture = new MethodInvocationFixture; + + $result = (new Container)->invoke( + [$fixture, 'execute'], + ['message' => 'instance'] + ); + + self::assertSame('instance', $result); + + } + + /** + * ### Static method can be invoked through array notation + * @since 1.0.0 + * + * @return void + */ + public function testStaticMethodArrayCallable ():void { + + self::assertSame( + Logger::class, + (new Container)->invoke([MethodInvocationFixture::class, 'staticExecute']) + ); + + } + + /** + * ### Static method can be invoked through string notation + * @since 1.0.0 + * + * @return void + */ + public function testStaticMethodStringCallable ():void { + + self::assertSame( + Logger::class, + (new Container)->invoke(MethodInvocationFixture::class . '::staticExecute') + ); + + } + + /** + * ### Invokable object receives dependencies + * @since 1.0.0 + * + * @return void + */ + public function testInvokableObjectInjection ():void { + + self::assertSame( + Logger::class, + (new Container)->invoke(new MethodInvocationFixture) + ); + + } + + /** + * ### Named function can receive dependencies + * @since 1.0.0 + * + * @return void + */ + public function testNamedFunctionInjection ():void { + + self::assertSame( + Logger::class, + (new Container)->invoke(__NAMESPACE__ . '\\methodInjectionFunction') + ); + + } + + /** + * ### Empty variadic parameter produces an empty list + * @since 1.0.0 + * + * @return void + */ + public function testEmptyVariadicArgumentList ():void { + + self::assertSame( + [], + (new Container)->invoke(static fn (Logger $logger, string ...$messages):array => $messages) + ); + + } + + /** + * ### Named variadic list is expanded into individual values + * @since 1.0.0 + * + * @return void + */ + public function testNamedVariadicArguments ():void { + + self::assertSame( + ['one', 'two', 'three'], + (new Container)->invoke( + static fn (Logger $logger, string ...$messages):array => $messages, + ['messages' => ['one', 'two', 'three']] + ) + ); + + } + + /** + * ### Remaining positional arguments are assigned to variadic parameter + * @since 1.0.0 + * + * @return void + */ + public function testPositionalVariadicArguments ():void { + + self::assertSame( + ['one', 'two'], + (new Container)->invoke( + static fn (string ...$messages):array => $messages, + ['one', 'two'] + ) + ); + + } + + /** + * ### Unknown named argument is rejected + * @since 1.0.0 + * + * @return void + */ + public function testUnknownNamedArgumentThrows ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke(static fn ():bool => true, ['unknown' => 1]); + } + ); + + } + + /** + * ### Duplicate positional and named assignment is rejected + * @since 1.0.0 + * + * @return void + */ + public function testDuplicateArgumentAssignmentThrows ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke( + static fn (string $name):string => $name, + [0 => 'first', 'name' => 'second'] + ); + } + ); + + } + + /** + * ### Named arguments cannot precede positional arguments + * @since 1.0.0 + * + * @return void + */ + public function testPositionalAfterNamedThrows ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke( + static fn (string $first, string $second):array => [$first, $second], + ['second' => 'B', 0 => 'A'] + ); + } + ); + + } + + /** + * ### Nonconsecutive positional indexes are rejected + * @since 1.0.0 + * + * @return void + */ + public function testNonconsecutivePositionalIndexesThrow ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke( + static fn (string $first, string $second):array => [$first, $second], + [1 => 'B'] + ); + } + ); + + } + + /** + * ### Excess positional arguments are rejected + * @since 1.0.0 + * + * @return void + */ + public function testTooManyPositionalArgumentsThrow ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke(static fn ():bool => true, ['extra']); + } + ); + + } + + /** + * ### Named variadic argument requires a list + * @since 1.0.0 + * + * @return void + */ + public function testNamedVariadicScalarThrows ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke( + static fn (string ...$messages):array => $messages, + ['messages' => 'not-a-list'] + ); + } + ); + + } + + /** + * ### Associative array is not accepted as a named variadic list + * @since 1.0.0 + * + * @return void + */ + public function testNamedVariadicAssociativeArrayThrows ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke( + static fn (string ...$messages):array => $messages, + ['messages' => ['label' => 'value']] + ); + } + ); + + } + + /** + * ### Named and positional variadic values cannot be combined + * @since 1.0.0 + * + * @return void + */ + public function testDuplicateVariadicSourcesThrow ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke( + static fn (string ...$messages):array => $messages, + [0 => 'first', 'messages' => ['second']] + ); + } + ); + + } + + /** + * ### Explicit constructor bindings do not apply to method injection + * @since 1.0.0 + * + * @return void + */ + public function testConstructorParameterBindingsDoNotApplyToMethods ():void { + + $container = new Container; + $container->bindParameters(MethodInvocationFixture::class, ['message' => 'constructor-only']); + + $fixture = new MethodInvocationFixture; + + self::assertSame('default', $container->invoke([$fixture, 'execute'])); + + } + + /** + * ### Contextual binding is applied to declaring a method class + * @since 1.0.0 + * + * @return void + */ + public function testContextualBindingOnInstanceMethod ():void { + + $container = new Container; + $container->bindContextual( + MethodInvocationFixture::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + self::assertInstanceOf( + ContextualFileLogger::class, + $container->invoke([new MethodInvocationFixture, 'contextual']) + ); + + } + + /** + * ### Tagged injection returns services in tag registration order + * @since 1.0.0 + * + * @return void + */ + public function testTaggedMethodInjection ():void { + + $container = new Container; + $container->singleton(TaggedFileLogger::class); + $container->singleton(TaggedConsoleLogger::class); + $container->tag('logging', TaggedConsoleLogger::class, TaggedFileLogger::class); + + $result = $container->invoke( + static fn (#[Tagged('logging')] array $loggers):array => $loggers + ); + + self::assertCount(2, $result); + self::assertInstanceOf(TaggedConsoleLogger::class, $result[0]); + self::assertInstanceOf(TaggedFileLogger::class, $result[1]); + + } + + /** + * ### Collection injection uses the registered matching services + * @since 1.0.0 + * + * @return void + */ + public function testCollectionMethodInjection ():void { + + $container = new Container; + $container->singleton(CollectionFileLogger::class); + $container->singleton(CollectionConsoleLogger::class); + + $result = $container->invoke( + static fn (#[InjectAll(CollectionLogger::class)] array $loggers):array => $loggers + ); + + self::assertCount(2, $result); + self::assertInstanceOf(CollectionFileLogger::class, $result[0]); + self::assertInstanceOf(CollectionConsoleLogger::class, $result[1]); + + } + + /** + * ### Scoped dependency cannot be resolved directly from the container + * @since 1.0.0 + * + * @return void + */ + public function testScopedDependencyRequiresScope ():void { + + $container = new Container; + $container->scoped(ScopedDependency::class); + + $this->assertThrows( + ScopeRequiredException::class, + static function () use ($container):void { + $container->invoke(static fn (ScopedDependency $service):object => $service); + } + ); + + } + + /** + * ### Scoped service instance is reused across invocations in the same scope + * @since 1.0.0 + * + * @return void + */ + public function testScopedInvocationReusesScopedDependency ():void { + + $container = new Container; + $container->scoped(ScopedDependency::class); + $scope = $container->createScope(); + + try { + + $first = $scope->invoke(static fn (ScopedDependency $service):object => $service); + $second = $scope->invoke(static fn (ScopedDependency $service):object => $service); + + self::assertSame($first, $second); + self::assertFalse($scope->isClosed()); + + } finally { + + $scope->close(); + + } + + } + + /** + * ### Different scopes receive different scoped instances + * @since 1.0.0 + * + * @return void + */ + public function testScopedInstancesAreIsolated ():void { + + $container = new Container; + $container->scoped(ScopedDependency::class); + $first_scope = $container->createScope(); + $second_scope = $container->createScope(); + + try { + + $first = $first_scope->invoke(static fn (ScopedDependency $service):object => $service); + $second = $second_scope->invoke(static fn (ScopedDependency $service):object => $service); + + self::assertNotSame($first, $second); + + } finally { + + $first_scope->close(); + $second_scope->close(); + + } + + } + + /** + * ### Invocation cannot use a closed scope + * @since 1.0.0 + * + * @return void + */ + public function testInvokingClosedScopeThrows ():void { + + $scope = (new Container)->createScope(); + $scope->close(); + + $this->assertThrows( + ScopeClosedException::class, + static function () use ($scope):void { + $scope->invoke(static fn ():bool => true); + } + ); + + } + + /** + * ### Callback failure propagates without being wrapped + * @since 1.0.0 + * + * @return void + */ + public function testCallbackExceptionIsNotWrapped ():void { + + $expected = new LogicException('callback failure'); + + try { + + (new Container)->invoke(static function () use ($expected):never { + throw $expected; + }); + + self::fail('Expected callback exception was not thrown.'); + + } catch (LogicException $exception) { + + self::assertSame($expected, $exception); + + } + + } + + /** + * ### Scoped callback failure does not close the scope + * @since 1.0.0 + * + * @return void + */ + public function testScopedCallbackExceptionLeavesScopeUsable ():void { + + $scope = (new Container)->createScope(); + $expected = new LogicException('callback failure'); + + try { + + try { + + $scope->invoke(static function () use ($expected):never { + throw $expected; + }); + + self::fail('Expected callback exception was not thrown.'); + + } catch (LogicException $exception) { + + self::assertSame($expected, $exception); + + } + + self::assertFalse($scope->isClosed()); + self::assertTrue($scope->invoke(static fn ():bool => true)); + + } finally { + + $scope->close(); + + } + + } + + /** + * ### A frozen container still supports invocation + * @since 1.0.0 + * + * @return void + */ + public function testFrozenContainerCanInvoke ():void { + + $container = new Container; + $container->singleton(Logger::class); + $container->freeze(); + + self::assertInstanceOf( + Logger::class, + $container->invoke(static fn (Logger $logger):Logger => $logger) + ); + + } + +} + +/** + * ### Provides callable fixtures for method injection tests + * @since 1.0.0 + */ +final class MethodInvocationFixture { + + /** + * ### Executes an instance method with an injected dependency + * @since 1.0.0 + * + * @param \FireHub\Tests\Container\Stubs\Logger $logger

+ * Logger dependency. + *

+ * @param string $message [optional]

+ * Message to return. + *

+ * + * @return string Explicit message. + */ + public function execute (Logger $logger, string $message = 'default'):string { + + return $message; + + } + + /** + * ### Resolves a dependency in a static method + * @since 1.0.0 + * + * @param \FireHub\Tests\Container\Stubs\Logger $logger

+ * Logger dependency. + *

+ * + * @return class-string<\FireHub\Tests\Container\Stubs\Logger> Logger class name. + */ + public static function staticExecute (Logger $logger):string { + + return $logger::class; + + } + + /** + * ### Resolves a contextual dependency + * @since 1.0.0 + * + * @param \FireHub\Tests\Container\Stubs\ContextualLogger $logger

+ * Contextually selected logger. + *

+ * + * @return \FireHub\Tests\Container\Stubs\ContextualLogger Selected logger. + */ + public function contextual (ContextualLogger $logger):ContextualLogger { + + return $logger; + + } + + /** + * ### Invokes an object with an injected logger + * @since 1.0.0 + * + * @param \FireHub\Tests\Container\Stubs\Logger $logger

+ * Logger dependency. + *

+ * + * @return class-string<\FireHub\Tests\Container\Stubs\Logger> Logger class name. + */ + public function __invoke (Logger $logger):string { + + return $logger::class; + + } + +} + +/** + * ### Named function fixture for callable injection + * @since 1.0.0 + * + * @param \FireHub\Tests\Container\Stubs\Logger $logger

+ * Logger dependency. + *

+ * + * @return class-string<\FireHub\Tests\Container\Stubs\Logger> Logger class name. + */ +function methodInjectionFunction (Logger $logger):string { + + return $logger::class; + +} diff --git a/tests/Unit/ParameterBindingTest.php b/tests/Unit/ParameterBindingTest.php index d71a5a5..1b3833b 100644 --- a/tests/Unit/ParameterBindingTest.php +++ b/tests/Unit/ParameterBindingTest.php @@ -34,6 +34,9 @@ #[CoversClass(Container::class)] final class ParameterBindingTest extends FireHubTestCase { + /** + * @since 1.0.0 + */ use ExceptionAssertions; /**