diff --git a/.gitattributes b/.gitattributes index 64431fd..2cbb4fa 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,10 @@ * text=auto +/.github export-ignore +/bin export-ignore /tests export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore /phpunit.xml export-ignore +/phpcs.xml export-ignore +/README.md export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore index a7573f3..66732cb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ package-lock.json composer.lock .phpunit.result.cache phpunit-report.xml +phpcs-report.xml diff --git a/README.md b/README.md index 2dd1453..234b8fc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ # WpMVC Container -A lightweight, powerful Dependency Injection (DI) Container designed for the WpMVC framework. It provides automatic dependency resolution, singleton management, and flexible method invocation capabilities. +A lightweight, powerful, and **PSR-11 compliant** Dependency Injection (DI) Container designed for the WpMVC framework. It provides automatic dependency resolution, singleton management, flexible method invocation, and advanced autowiring capabilities. ## Installation @@ -17,12 +17,16 @@ composer require wpmvc/container ## Features - **Zero Configuration**: Automatically resolves dependencies using PHP Reflection. -- **Singleton Pattern**: Maintains single instances of services via `get()`. -- **Factory Pattern**: Creates fresh instances on demand via `make()`. -- **Method Injection**: Supports dependency injection for method calls via `call()`. -- **Circular Dependency Detection**: Prevents infinite loops during resolution errors. -- **Parameter Overrides**: Allows passing specific parameters to constructors and methods. -- **PSR-4 Compatible**: designed for modern PHP applications. +- **Singleton by Default**: Maintains single instances of services unless bound otherwise. +- **Contextual Bindings**: Inject different implementations of the same dependency based on the requesting context. +- **Flexible Bindings**: Explicitly register transient, shared, or existing instances. +- **Aliasing & Tagging**: Group and reference services using flexible identifiers. +- **Advanced Autowiring**: Supports positional/variadic parameters, nullable types, and default values. +- **Method Injection**: Supports DI for method calls via `call()`, including static methods and invokables. +- **Circular Dependency Detection**: Robust detection using terminal resolved IDs. +- **Performance Optimized**: Flat-mapped resolution reduces registry traversal overhead. +- **Fluent Interface**: Supports method chaining for configuration. +- **PSR-11 Compatible**: Implements `Psr\Container\ContainerInterface`. ## Usage @@ -34,113 +38,190 @@ use WpMVC\Container; $container = new Container(); ``` -### Retrieving Services (Singleton) +### Service Registration -The `get` method retrieves a service. If the service has not been created yet, the container will attempt to instantiate it, resolving any dependencies automatically. Subsequent calls return the *same* instance. +By default, any class requested via `get()` is treated as a **singleton**. However, you can explicitly define how services are resolved. +#### Transient Bindings +Creates a fresh instance every time it is resolved. ```php -$service = $container->get(MyService::class); +$container->bind(MyService::class, MyService::class); ``` -### Creating New Instances (Factory) +#### Shared Bindings (Singletons) +Ensures only one instance exists within the container. +```php +$container->singleton(MyLogger::class, MyLogger::class); +``` + +#### Contextual Bindings +Define different implementations based on which class is requesting the dependency. + +```php +$container->when(PhotoController::class) + ->needs(Filesystem::class) + ->give(S3Filesystem::class); + +$container->when(ProfileController::class) + ->needs(Filesystem::class) + ->give(LocalFilesystem::class); +``` -The `make` method always creates a *new* instance of the requested class, resolving dependencies afresh. +> [!TIP] +> If you are using the full **WpMVC Framework**, you can use the fluent proxy directly from the `App` class: +> `App::instance()->when(MyClass::class)->needs(...)->give(...);` + +#### Closure Bindings +Use a closure for complex instantiation logic. The container is passed as the first argument. +```php +$container->bind(Mailer::class, function($container, $params) { + return new Mailer($container->get(Config::class), $params['transport'] ?? 'smtp'); +}); +``` +#### Interface Binding +Bind an interface to a concrete implementation. ```php -$freshInstance = $container->make(MyService::class); +$container->singleton(UserRepositoryInterface::class, MysqlUserRepository::class); + +// The container will automatically resolve the concrete when the interface is requested. +$repo = $container->get(UserRepositoryInterface::class); ``` -### Manual Binding +#### Array Callback Bindings +Bind an abstract to a static method or class method callback. +```php +$container->bind('api.client', [ApiClientFactory::class, 'create']); +``` -You can manually register an existing instance into the container. +#### Primitive Bindings +The container can store and retrieve raw scalars or arrays. +```php +$container->set('database.config', ['host' => 'localhost', 'user' => 'root']); +$container->set('app.version', '1.0.0'); +``` +#### Instance Binding +Register an already-instantiated object. ```php -$container->set(MyInterface::class, new MyConcreteImplementation()); +$container->set(Configuration::class, new Configuration(['debug' => true])); ``` -### Checking Availability +### Retrieving Services -Check if a service is available (either instantiated or the class exists). +The `get` method retrieves a service. Subsequent calls return the *same* instance for singletons. ```php -if ($container->has(MyService::class)) { - // ... -} +$service = $container->get(MyService::class); ``` -## Dependency Injection +> [!NOTE] +> **Singleton Integrity**: For shared services, the container synchronizes instances across the terminal resolved ID (concrete) and all aliases. This ensures that resolving an interface or its concrete implementation always returns the exact same object. -The container uses PHP's Reflection API to inspect class constructors. +> [!IMPORTANT] +> Passing parameters to `get()` for an already-instantiated shared service will return the existing instance and ignore the new parameters to maintain singleton integrity. Use `make()` if you need a fresh instance with custom parameters. -### Automatic Injection +### Creating New Instances (Factory) -If your class looks like this: +The `make` method always creates a *new* instance, even if it was registered as a singleton. ```php -class Database { ... } +$freshInstance = $container->make(MyService::class, ['param' => 'value']); +``` -class UserRepository { - protected $db; - public function __construct(Database $db) { - $this->db = $db; - } +### Checking Availability (`has`) + +Check if a service is available. The `has()` method follows a performance-optimized logic: +1. Fast-path check for cached instances or explicit registry entries. +2. Alias resolution to check terminal IDs. +3. Fallback to `class_exists` for autowiring candidates. + +```php +if ($container->has(MyService::class)) { + // ... } ``` -Calling `$container->get(UserRepository::class)` will automatically: -1. Detect `Database` dependency. -2. Resolve `Database` via `$container->get(Database::class)`. -3. Instantiate `UserRepository` with the resolved `Database` instance. +### Aliasing & Tagging -### Primitive Parameters & Overrides +#### Aliasing +Give a service a shorter or more descriptive name. The container supports **recursive alias resolution**, meaning aliases can point to other aliases. +```php +$container->alias(UserRepository::class, 'repo.user'); +$container->alias('repo.user', 'users'); -You can pass an array of parameters to `get()` or `make()` to override dependencies or provide primitive values (strings, ints, etc.). Keys must match the constructor parameter names. +$repo = $container->get('users'); // Resolves to UserRepository +``` +#### Tagging +Group related services (e.g., for plugins or extensions). ```php -class ApiClient { - public function __construct(HttpClient $client, string $apiKey) { ... } -} +$container->tag([MyExtension::class, AnotherExtension::class], 'app.plugins'); -$client = $container->get(ApiClient::class, [ - 'apiKey' => 'secret_123', // Matches $apiKey - // 'client' => $mockClient // Optional: could also override the object dependency -]); +// Retrieve all tagged services +$plugins = $container->tagged('app.plugins'); // Returns iterable ``` -## Advanced Features +## Dependency Injection -### Method Invocation (`call`) +The container uses PHP's Reflection API to inspect constructors and automatically resolve dependencies. -The `call` method allows you to execute any callable (function, closure, object method) while automatically injecting its dependencies. +### Automatic Injection ```php -class ReportController { - public function generate(ReportService $service, $format = 'json') { - return $service->build($format); - } +class UserRepository { + public function __construct(Database $db) { ... } } -// Automatically resolves ReportService and injects it. -// $format uses the default value 'json' unless overridden in params. -$result = $container->call([ReportController::class, 'generate'], [ - 'format' => 'pdf' -]); +// Automatically resolves and injects Database +$repo = $container->get(UserRepository::class); ``` -Supported callback formats: -- `'ClassName::method'` (Static calls, or auto-resolves instance for non-static) -- `[$object, 'method']` -- `[ClassName::class, 'method']` -- Closures / Anonymous functions +### Advanced Resolution Strategies -### Circular Dependency Detection +When resolving constructor or method arguments, the container follows a strict **Precedence of Resolution**: -The container tracks currently resolving classes. If Class A depends on Class B, and Class B depends on Class A, the container will throw an `Exception` to prevent an infinite loop / stack overflow. +1. **Named Parameters**: Explicit keys in the `$params` array matching the argument name. +2. **Type Hint (Auto-Substitution)**: If a provided parameter in `$params` is an object matching the required type hint, it is used immediately. +3. **Contextual Binding**: Checks if a specific rule exists for the class currently being resolved. +4. **Type Hint (Recursive)**: Resolves the type-hinted class/interface from the container. +5. **Variadic Parameters**: Collects all remaining arguments from the `$params` array. +6. **Positional Parameters**: Uses unkeyed arguments from `$params`. These are **type-guarded**; they are only used if they match the expected type hint. +7. **Default Values & Nullable Types**: Fallback to `$param = 'default'` or `null` (if allowed). +## Advanced Method Invocation (`call`) + +Execute any callable while automatically injecting its dependencies. + +```php +// 1. Closures +$container->call(function(MailService $mailer) { ... }); + +// 2. Class methods (auto-resolves instance) +$container->call([ReportController::class, 'generate'], ['format' => 'pdf']); + +// 3. Static methods +$container->call('Utility::process'); + +// 4. Invokable objects +$container->call(new ActionHandler()); ``` -Exception: Circular dependency detected while resolving: WpMVC\ClassA -``` -## Requirements +## Lifecycle & State + +Reset or modify the container state using the following methods: + +- `$container->forget_instances()`: Clears all cached singleton instances. +- `$container->flush()`: Clears all bindings, aliases, tags, and instances. +- **Fluent Chaining**: All registration methods support method chaining. + ```php + $container->singleton(S1::class)->alias(S1::class, 's1')->tag('s1', 'group'); + ``` + +## Exceptions + +The container throws specific exceptions, all of which are **PSR-11 compliant**: -- PHP 7.4 or higher +- `WpMVC\Container\Exception\NotFoundException`: Implements `Psr\Container\NotFoundExceptionInterface`. +- `WpMVC\Container\Exception\CircularDependencyException`: Thrown when a resolution loop is detected. +- `WpMVC\Container\Exception\ContainerException`: Implements `Psr\Container\ContainerExceptionInterface`. diff --git a/src/CallbackInvoker.php b/src/CallbackInvoker.php new file mode 100644 index 0000000..b21a4c3 --- /dev/null +++ b/src/CallbackInvoker.php @@ -0,0 +1,183 @@ +container = $container; + $this->engine = $engine; + } + + /** + * Invoke a callback with dependency injection. + * + * Resolves the callback format, matches its parameters with the container/params, + * and executes the call. + * + * @param callable|array|string $callback The callback to invoke (Function, Closure, [Class, Method], etc.). + * @param array $parameters Optional manual parameters to pass to the callback. + * @return mixed The result of the callback execution. + * @throws ReflectionException If the callback cannot be introspected. + * @throws ContainerException If the callback is invalid or unresolvable. + */ + public function call( $callback, array $parameters = [] ) { + [$resolved_callback, $metadata] = $this->resolve_callback( $callback ); + + $args = $this->engine->resolve_dependencies( $metadata->getParameters(), $parameters ); + + return call_user_func_array( $resolved_callback, $args ); + } + + /** + * Resolve the callback and its reflection metadata. + * + * @param mixed $callback + * @return array [callable, \ReflectionFunctionAbstract] + * @throws ReflectionException If reflection fails. + * @throws ContainerException If the callback is not callable. + */ + protected function resolve_callback( $callback ) { + // 1. Handle Array Callbacks [class, method] or [instance, method] + if ( is_array( $callback ) ) { + return $this->resolve_array_callback( $callback ); + } + + // 2. Handle String Callbacks "Class::method" + if ( is_string( $callback ) && strpos( $callback, '::' ) !== false ) { + return $this->resolve_array_callback( explode( '::', $callback ) ); + } + + // 3. Handle Invokable Objects + if ( is_object( $callback ) && ! ( $callback instanceof Closure ) ) { + $ref = $this->get_method_reflection( $callback, '__invoke' ); + return [$callback, $ref]; + } + + // 4. Handle Closures and simple string functions + if ( ! is_callable( $callback ) ) { + throw new ContainerException( "The provided callback is not callable." ); + } + + return [$callback, new ReflectionFunction( $callback )]; + } + + /** + * Resolve an array-based callback [Class/ID/Object, Method]. + * + * If a class name or service ID is provided, it tries to resolve an instance + * from the container before reflecting on the method. + * + * @param array $callback The [target, method] array. + * @return array [callable, \ReflectionMethod] + * @throws ReflectionException If reflection fails. + * @throws ContainerException If the class, service ID, or method is invalid. + */ + protected function resolve_array_callback( array $callback ) { + [$class_or_id, $method] = $callback; + + // 1. Determine the class name and instance for reflection. + if ( is_object( $class_or_id ) ) { + $class = get_class( $class_or_id ); + $instance = $class_or_id; + } else { + // Resolve the terminal ID (in case it's an alias). + $class = $this->container->resolved_id( $class_or_id ); + $instance = null; + + if ( ! class_exists( $class ) && ! interface_exists( $class ) ) { + // Check if the identifier is a bound service that can be instantiated. + if ( $this->container->has( $class_or_id ) ) { + $instance = $this->container->get( $class_or_id ); + $class = get_class( $instance ); + } else { + throw new ContainerException( "Class or service ID {$class} not found." ); + } + } + } + + $ref = $this->get_method_reflection( $class, $method ); + + // 2. If the method is non-static and we don't have an instance yet, resolve one. + if ( ! $ref->isStatic() && ! $instance ) { + $instance = $this->container->get( $class ); + } + + return [ $instance ? [$instance, $method] : [$class, $method], $ref ]; + } + + /** + * Get reflection of a method, with caching. + * + * @param object|string $class + * @param string $method + * @return ReflectionMethod + * @throws ReflectionException If reflection fails. + * @throws ContainerException If the method is not public. + */ + protected function get_method_reflection( $class, string $method ): ReflectionMethod { + $class_name = is_object( $class ) ? get_class( $class ) : $class; + $cache_key = $class_name . '::' . $method; + + if ( $cached = $this->engine->get_cached_method( $cache_key ) ) { + return $cached; + } + + $ref = new ReflectionMethod( $class_name, $method ); + + if ( ! $ref->isPublic() ) { + throw new ContainerException( "Method {$class_name}::{$method} is not public." ); + } + + if ( $ref->isAbstract() ) { + throw new ContainerException( "Cannot call abstract method {$class_name}::{$method}." ); + } + + $this->engine->cache_method( $cache_key, $ref ); + + return $ref; + } +} diff --git a/src/Container.php b/src/Container.php index aa41bcd..58b0983 100644 --- a/src/Container.php +++ b/src/Container.php @@ -1,202 +1,389 @@ instances[$id] ) ) { - return $this->instances[$id]; - } - - $instance = $this->resolve( $id, $params ); - $this->instances[$id] = $instance; + protected $resolving = []; - return $instance; - } + /** + * The service registry instance. + * + * @var Registry + */ + protected $registry; /** - * @var array + * The resolution engine instance. + * + * @var ResolutionEngine */ - protected $resolving = []; + protected $engine; /** - * Resolve a service instance (without storing as singleton). + * The callback invoker instance. * - * @param string $id - * @param array $params - * @return mixed - * @throws NotFoundException - * @throws ContainerException - * @throws \Exception + * @var CallbackInvoker */ - protected function resolve( string $id, array $params = [] ) { - if ( ! class_exists( $id ) ) { - throw new NotFoundException( "Service not found: {$id}" ); - } + protected $invoker; - if ( isset( $this->resolving[$id] ) ) { - throw new ContainerException( "Circular dependency detected while resolving: {$id}" ); - } + /** + * Container constructor. + */ + public function __construct() { + $this->registry = new Registry(); + $this->engine = new ResolutionEngine( $this ); + $this->invoker = new CallbackInvoker( $this, $this->engine ); + } - $this->resolving[$id] = true; + /** + * Register a transient binding. + * + * @param string $abstract + * @param mixed|null $concrete + * @return $this + */ + public function bind( string $abstract, $concrete = null ): self { + $this->registry->bind( $abstract, $concrete ); + return $this; + } - try { - $ref = new ReflectionClass( $id ); + /** + * Register a shared (singleton) binding. + * + * @param string $abstract + * @param mixed|null $concrete + * @return $this + */ + public function singleton( string $abstract, $concrete = null ): self { + $this->registry->singleton( $abstract, $concrete ); + return $this; + } - if ( ! $ref->isInstantiable() ) { - throw new ContainerException( "Class is not instantiable: {$id}" ); - } + /** + * Alias a type to another name. + * + * @param string $abstract + * @param string $alias + * @return $this + */ + public function alias( string $abstract, string $alias ): self { + $this->registry->alias( $abstract, $alias ); + return $this; + } - $constructor = $ref->getConstructor(); - $args = []; + /** + * Assign a set of tags to a given binding. + * + * @param array|string $abstracts + * @param array|mixed $tags + * @return $this + */ + public function tag( $abstracts, $tags ): self { + $this->registry->tag( (array) $abstracts, (array) $tags ); + return $this; + } - if ( $constructor ) { - $args = $this->resolve_dependencies( $constructor, $params ); - } + /** + * Resolve all services associated with a given tag. + * + * @param string $tag The tag identifier. + * @return iterable A collection of resolved service instances. + */ + public function tagged( string $tag ): iterable { + return array_map( + function ( $abstract ) { + return $this->get( $abstract ); + }, $this->registry->get_tag( $tag ) + ); + } - return $ref->newInstanceArgs( $args ); - } finally { - unset( $this->resolving[$id] ); - } + /** + * Define a contextual binding for a specific class. + * + * Initiates the fluent API for context-sensitive dependency injection. + * + * @param string $concrete The class name that requires the contextual binding. + * @return ContextualBindingBuilder + */ + public function when( string $concrete ): ContextualBindingBuilder { + return new ContextualBindingBuilder( $this, $concrete ); } /** - * Set a service instance directly. + * Add a contextual binding for a given class. * - * @param string $id - * @param mixed $service + * @internal + * @param string $concrete + * @param string $abstract + * @param mixed $implementation * @return void */ - public function set( string $id, $service ): void { - $this->instances[$id] = $service; + public function add_contextual_binding( string $concrete, string $abstract, $implementation ): void { + $this->registry->add_contextual_binding( $concrete, $abstract, $implementation ); } /** - * Check if container has a service (either instance or class exists) + * Get the contextual binding for a given class. * - * @param string $id - * @return bool + * @internal + * @param string $concrete + * @param string $abstract + * @return mixed|null */ - public function has( string $id ): bool { - return isset( $this->instances[$id] ) || class_exists( $id ); + public function get_contextual_binding( string $concrete, string $abstract ) { + return $this->registry->get_contextual_binding( $concrete, $abstract ); + } + + /** + * Get a service from the container. + * + * @template T + * @param class-string|string $id Service ID or class name to resolve. + * @param array $params Parameters for resolution. + * @return T + * + * @throws NotFoundException If the service cannot be resolved. + * @throws CircularDependencyException If a circularity is detected. + * @throws ContainerException If instantiation fails. + */ + public function get( string $id, array $params = [] ) { + // 1. Resolve terminal ID immediately to ensure alias consistency. + $resolved_id = $this->registry->resolve_id( $id ); + + // 2. Direct cache hit (check if the service is already instantiated). + if ( isset( $this->instances[$resolved_id] ) ) { + return $this->instances[$resolved_id]; + } + + $concrete = $this->registry->get_concrete_internal( $resolved_id ); + $is_shared = $this->registry->is_shared_internal( $resolved_id ); + + // 3. Cross-resolution cache hit (e.g., interface resolved to already cached concrete singleton). + if ( $is_shared && is_string( $concrete ) && isset( $this->instances[$concrete] ) ) { + return $this->instances[$resolved_id] = $this->instances[$concrete]; + } + + // 4. Circular dependency detection using the terminal resolved ID. + if ( isset( $this->resolving[$resolved_id] ) ) { + throw new CircularDependencyException( "Circular dependency detected while resolving: {$id} (resolved to {$resolved_id})" ); + } + + $this->resolving[$resolved_id] = true; + + try { + // 5. Build the instance via the resolution engine. + $instance = $this->build( $concrete, $params, $id ); + + // 6. Cache the instance if the binding is shared (singleton). + if ( $is_shared ) { + $this->instances[$resolved_id] = $instance; + + // If it was an interface resolution, alias the interface to the instance too. + if ( $id !== $resolved_id ) { + $this->instances[$id] = $instance; + } + } + + return $instance; + } finally { + // Unset resolution flag to avoid false positives in subsequent calls. + unset( $this->resolving[$resolved_id] ); + } } /** * Create a new instance of the given class (Factory). - * Does not store the instance as a singleton. * - * @param string $abstract - * @param array $parameters - * @return mixed + * @template T + * @param class-string|string $abstract The class name or abstract to build. + * @param array $parameters Parameters for resolution. + * @return T + * * @throws ContainerException * @throws NotFoundException - * @throws \Exception */ public function make( string $abstract, array $parameters = [] ) { - return $this->resolve( $abstract, $parameters ); + $abstract = $this->registry->resolve_id( $abstract ); + $concrete = $this->registry->get_concrete( $abstract ); + + return $this->build( $concrete, $parameters, $abstract ); + } + + /** + * Build the concrete instance. + * + * @param mixed $concrete + * @param array $params + * @param ?string $id + * @return mixed + * @throws ContainerException If the target is not instantiable. + * @throws NotFoundException If class or alias not found. + */ + protected function build( $concrete, array $params = [], ?string $id = null ) { + // Resolve Closure closures + if ( $concrete instanceof Closure ) { + return $concrete( $this, $params ); + } + + if ( is_string( $concrete ) ) { + // Try to autowire the class + if ( class_exists( $concrete ) ) { + return $this->engine->resolve( $concrete, $params ); + } + + // Return raw string if it's an alias pointing to something else + if ( $id !== null && $concrete !== $id ) { + return $concrete; + } + + throw new NotFoundException( "Class or alias not found: {$concrete}" ); + } + + // Return provided objects + if ( is_object( $concrete ) ) { + return $concrete; + } + + // Handle array callbacks + if ( is_array( $concrete ) && is_callable( $concrete ) ) { + return $this->call( $concrete, $params ); + } + + // Return scalars/arrays + if ( is_scalar( $concrete ) || is_array( $concrete ) ) { + return $concrete; + } + + throw new ContainerException( "Target is not instantiable or callable: " . gettype( $concrete ) ); } /** * Call a callback with dependency injection. * - * @param callable|array|string $callback - * @param array $parameters + * @param callable|array|string $callback + * @param array $parameters * @return mixed * @throws \ReflectionException + * @throws ContainerException */ public function call( $callback, array $parameters = [] ) { - if ( is_array( $callback ) ) { - $class = is_object( $callback[0] ) ? get_class( $callback[0] ) : $callback[0]; - $method = $callback[1]; - $ref = new \ReflectionMethod( $class, $method ); + return $this->invoker->call( $callback, $parameters ); + } - if ( is_string( $callback[0] ) && ! $ref->isStatic() ) { - $callback[0] = $this->get( $callback[0] ); - } - } elseif ( is_string( $callback ) && strpos( $callback, '::' ) !== false ) { - $parts = explode( '::', $callback ); - $ref = new \ReflectionMethod( $parts[0], $parts[1] ); + /** + * Set a shared instance (singleton) directly into the container. + * + * @param string $id + * @param mixed $instance + * @return $this + */ + public function set( string $id, $instance ): self { + $resolved_id = $this->registry->resolve_id( $id ); + $concrete = $this->registry->get_concrete( $id ); - if ( ! $ref->isStatic() ) { - $instance = $this->get( $parts[0] ); - $callback = [$instance, $parts[1]]; - } - } elseif ( is_object( $callback ) && ! ( $callback instanceof \Closure ) ) { - $ref = new \ReflectionMethod( $callback, '__invoke' ); - } else { - $ref = new \ReflectionFunction( $callback ); + $this->instances[$id] = $instance; + + if ( is_string( $concrete ) && $concrete !== $id ) { + $this->instances[$concrete] = $instance; } - $args = $this->resolve_dependencies( $ref, $parameters ); + if ( $resolved_id !== $id && $resolved_id !== $concrete ) { + $this->instances[$resolved_id] = $instance; + } - return call_user_func_array( $callback, $args ); + return $this; } /** - * Resolve dependencies for a reflection function/method. - * - * @param \ReflectionFunctionAbstract $ref - * @param array $parameters - * @return array - */ - protected function resolve_dependencies( \ReflectionFunctionAbstract $ref, array $parameters = [] ): array { - $args = []; - foreach ( $ref->getParameters() as $param ) { - $name = $param->getName(); - - if ( array_key_exists( $name, $parameters ) ) { - $args[] = $parameters[$name]; - continue; - } + * Check if the container has a service or class registered. + * + * @param string $id + * @return bool + */ + public function has( string $id ): bool { + // Fast paths: Already instantiated or registered in the registry + if ( isset( $this->instances[$id] ) || $this->registry->has( $id ) ) { + return true; + } - $type = $param->getType(); - if ( $type instanceof ReflectionNamedType && ! $type->isBuiltin() ) { - $id = $type->getName(); + // Slow path: Resolve alias and check again, then check class_exists + $id = $this->registry->resolve_id( $id ); + return isset( $this->instances[$id] ) || $this->registry->has( $id ) || class_exists( $id ); + } - // Avoid infinite recursion / simple circular dependency check could be added here - // For now, we trust get() to handle it (standard recursion) - $args[] = $this->get( $id ); - continue; - } + /** + * Get the terminal resolved ID for a given identifier. + * + * @param string $id + * @return string + */ + public function resolved_id( string $id ): string { + return $this->registry->resolve_id( $id ); + } - if ( $param->isDefaultValueAvailable() ) { - $args[] = $param->getDefaultValue(); - continue; - } + /** + * Clear all cached singleton instances. + * + * @return void + */ + public function forget_instances(): void { + $this->instances = []; + } - // If we cannot resolve it, pass null or throw? - // PHP will throw ArgumentCountError if we don't pass anything for a required param. - // We'll let that happen as it's the most correct behavior for missing dependencies. - } - return $args; + /** + * Reset the entire container to its initial state. + * + * @return void + */ + public function flush(): void { + $this->instances = []; + $this->registry->flush(); } } diff --git a/src/ContextualBindingBuilder.php b/src/ContextualBindingBuilder.php new file mode 100644 index 0000000..030ffac --- /dev/null +++ b/src/ContextualBindingBuilder.php @@ -0,0 +1,81 @@ +container = $container; + $this->concrete = $concrete; + } + + /** + * Define the abstract dependency the class needs. + * + * This is usually an interface or a class name that is injected + * into the concrete class's constructor. + * + * @param string $abstract The abstract identifier. + * @return $this + */ + public function needs( string $abstract ): self { + $this->needs = $abstract; + return $this; + } + + /** + * Define the implementation to be given when the dependency is requested. + * + * Binds the specific implementation (class name or closure) to the + * abstract dependency for the current concrete context. + * + * @param mixed $implementation The implementation to provide (Class name or Closure). + * @return void + */ + public function give( $implementation ): void { + $this->container->add_contextual_binding( $this->concrete, $this->needs, $implementation ); + } +} diff --git a/src/Exception/CircularDependencyException.php b/src/Exception/CircularDependencyException.php new file mode 100644 index 0000000..0a5e9d6 --- /dev/null +++ b/src/Exception/CircularDependencyException.php @@ -0,0 +1,23 @@ +bindings[$abstract] = [ + 'concrete' => $concrete ?: $abstract, + 'shared' => false, + ]; + } + + /** + * Register a shared (singleton) binding. + * + * @param string $abstract + * @param mixed|null $concrete + * @return void + */ + public function singleton( string $abstract, $concrete = null ): void { + $this->bindings[$abstract] = [ + 'concrete' => $concrete ?: $abstract, + 'shared' => true, + ]; + } + + /** + * Alias a service to a different name. + * + * @param string $abstract + * @param string $alias + * @return void + */ + public function alias( string $abstract, string $alias ): void { + $this->aliases[$alias] = $abstract; + } + + /** + * Assign a set of tags to a given binding. + * + * @param array|string $abstracts + * @param array|mixed $tags + * @return void + */ + public function tag( $abstracts, $tags ): void { + $tags = (array) $tags; + $abstracts = (array) $abstracts; + + foreach ( $tags as $tag ) { + if ( ! isset( $this->tags[$tag] ) ) { + $this->tags[$tag] = []; + } + + foreach ( $abstracts as $abstract ) { + if ( ! in_array( $abstract, $this->tags[$tag], true ) ) { + $this->tags[$tag][] = $abstract; + } + } + } + } + + /** + * Get all bindings associated with a given tag. + * + * @param string $tag + * @return array + */ + public function get_tag( string $tag ): array { + return $this->tags[$tag] ?? []; + } + + /** + * Resolve the terminal identifier by following all aliases. + * + * This method recursively follows aliases until it finds the root identifier. + * It includes basic circular alias detection. + * + * @param string $id The identifier or alias to resolve. + * @return string The terminal (root) identifier. + * @throws ContainerException If a circular alias resolution is detected. + */ + public function resolve_id( string $id ): string { + $history = []; + + while ( isset( $this->aliases[$id] ) ) { + if ( isset( $history[$id] ) ) { + throw new ContainerException( "Circular alias resolution detected for [{$id}]." ); + } + + $history[$id] = true; + $id = $this->aliases[$id]; + } + + return $id; + } + + /** + * Get the concrete implementation mapped to an abstract identifier. + * + * Performs a full resolution including alias traversal and recursion + * for chained bindings. + * + * @param string $abstract The abstract identifier to resolve. + * @param array $history Resolution history (used for circular detection). + * @return mixed The concrete implementation (target class or closure). + * @throws ContainerException If a circular resolution path is detected. + */ + public function get_concrete( string $abstract, array $history = [] ) { + if ( isset( $history[$abstract] ) ) { + throw new ContainerException( "Circular resolution detected for [{$abstract}]." ); + } + + $history[$abstract] = true; + + $id = $this->resolve_id( $abstract ); + + return $this->get_concrete_internal( $id, $history ); + } + + /** + * Internal method to get concrete implementation without re-resolving ID. + * + * @internal This method assumes the ID has already been resolved via resolve_id(). + * + * @param string $id The already resolved terminal ID. + * @param array $history Resolution history. + * @return mixed + */ + public function get_concrete_internal( string $id, array $history = [] ) { + if ( isset( $this->bindings[$id] ) ) { + $concrete = $this->bindings[$id]['concrete']; + + // Recurse if the concrete is another binding or alias + if ( is_string( $concrete ) && $concrete !== $id && $this->has( $concrete ) ) { + return $this->get_concrete( $concrete, $history ); + } + + return $concrete; + } + + return $id; + } + + /** + * Determine if a given service is shared (singleton). + * + * @param string $abstract + * @return bool + */ + public function is_shared( string $abstract ): bool { + $id = $this->resolve_id( $abstract ); + return $this->is_shared_internal( $id ); + } + + /** + * Internal method to check shared status without re-resolving ID. + * + * @internal + */ + public function is_shared_internal( string $id ): bool { + return $this->bindings[$id]['shared'] ?? false; + } + + /** + * Add a contextual binding to the registry. + * + * @param string $concrete + * @param string $abstract + * @param mixed $implementation + * @return void + */ + public function add_contextual_binding( string $concrete, string $abstract, $implementation ): void { + $this->contextual[$concrete][$abstract] = $implementation; + } + + /** + * Get the contextual binding for a given concrete and abstract. + * + * @param string $concrete + * @param string $abstract + * @return mixed|null + */ + public function get_contextual_binding( string $concrete, string $abstract ) { + return $this->contextual[$concrete][$abstract] ?? null; + } + + /** + * Check if a binding or alias exists for the given ID. + * + * @param string $id + * @return bool + */ + public function has( string $id ): bool { + return isset( $this->bindings[$id] ) || isset( $this->aliases[$id] ); + } + + /** + * Clear all bindings, aliases, and tags from the registry. + * + * @return void + */ + public function flush(): void { + $this->bindings = []; + $this->aliases = []; + $this->tags = []; + $this->contextual = []; + } +} diff --git a/src/ResolutionEngine.php b/src/ResolutionEngine.php new file mode 100644 index 0000000..4d5cd66 --- /dev/null +++ b/src/ResolutionEngine.php @@ -0,0 +1,269 @@ + [], + 'methods' => [], + ]; + + /** + * Stack of classes currently being built. + * + * @var array + */ + protected $build_stack = []; + + /** + * ResolutionEngine constructor. + * + * @param ContainerInterface $container The container instance used for recursive resolution. + */ + public function __construct( ContainerInterface $container ) { + $this->container = $container; + } + + /** + * Resolve a service instance using Reflection. + * + * @param string $id + * @param array $params + * @return mixed + * @throws NotFoundException If the class does not exist. + * @throws ContainerException If the class is not instantiable. + */ + public function resolve( string $id, array $params = [] ) { + // 1. Fetch from reflection cache if available, otherwise introspect the class. + if ( ! isset( $this->reflection_cache['classes'][$id] ) ) { + $ref = new ReflectionClass( $id ); + + if ( ! $ref->isInstantiable() ) { + throw new ContainerException( "Class is not instantiable: {$id}" ); + } + + $this->reflection_cache['classes'][$id] = $ref; + } + + $ref = $this->reflection_cache['classes'][$id]; + + // 2. Track the class being built to allow for contextual dependency resolution. + $this->build_stack[] = $id; + + try { + $constructor = $ref->getConstructor(); + $args = []; + + if ( $constructor ) { + // 3. Resolve all dependencies required by the constructor. + $args = $this->resolve_dependencies( $constructor->getParameters(), $params ); + } + + return $ref->newInstanceArgs( $args ); + } finally { + // 4. Pop the build stack to maintain accurate context for subsequent resolutions. + array_pop( $this->build_stack ); + } + } + + /** + * Resolve dependencies for a set of ReflectionParameters. + * + * @param \ReflectionParameter[] $parameters_metadata + * @param array $parameters + * @return array + * @throws ContainerException If a dependency cannot be resolved. + */ + public function resolve_dependencies( array $parameters_metadata, array $parameters = [] ): array { + $args = []; + + foreach ( $parameters_metadata as $param ) { + $name = $param->getName(); + + // 1. Try named parameter from the provided array + if ( array_key_exists( $name, $parameters ) ) { + $args[] = $parameters[$name]; + unset( $parameters[$name] ); + continue; + } + + // 2. Try to resolve by type hint (Interface or Class). + $type = $param->getType(); + if ( $type instanceof ReflectionNamedType && ! $type->isBuiltin() ) { + $id = $type->getName(); + + // 2a. Check for contextual binding first. + // If the class currently being built has a specific rule for this dependency, use it. + $concrete = end( $this->build_stack ); + if ( $concrete && ( $contextual = $this->container->get_contextual_binding( $concrete, $id ) ) ) { + $args[] = $contextual instanceof \Closure + ? $contextual( $this->container, $parameters ) + : $this->container->get( $contextual, $parameters ); + continue; + } + + // 2b. Optimization: Check if any provided parameter object matches this type hint. + foreach ( $parameters as $key => $provided_param ) { + if ( is_object( $provided_param ) && $provided_param instanceof $id ) { + $args[] = $provided_param; + unset( $parameters[$key] ); + continue 2; + } + } + + try { + // Recursively resolve the dependency from the container + $args[] = $this->container->get( $id, $parameters ); + continue; + } catch ( NotFoundException $e ) { + // Fall through to other resolution strategies (default values, nullables) + } catch ( CircularDependencyException $e ) { + throw $e; + } + } + + // 3. Try variadic parameter + if ( $param->isVariadic() ) { + // Collect all remaining parameters + foreach ( $parameters as $key => $value ) { + $args[] = $value; + unset( $parameters[$key] ); + } + break; + } + + // 4. Try positional parameter from the remaining array + if ( ( $found_key = $this->resolve_positional_parameter( $parameters, $type ) ) !== null ) { + $args[] = $parameters[$found_key]; + unset( $parameters[$found_key] ); + continue; + } + + // 5. Try default value + if ( $param->isDefaultValueAvailable() ) { + $args[] = $param->getDefaultValue(); + continue; + } + + // 6. Handle Nullable types + if ( ( method_exists( $param, 'allowsNull' ) && $param->allowsNull() ) || ( $type && $type->allowsNull() ) ) { + $args[] = null; + continue; + } + + throw new ContainerException( "Unresolvable dependency [{$param}] in [{$param->getDeclaringClass()->getName()}]." ); + } + + return $args; + } + + /** + * Resolve a positional parameter from the given candidates. + * + * @param array $parameters + * @param \ReflectionType|null $type + * @return int|string|null + */ + protected function resolve_positional_parameter( array $parameters, $type ) { + $candidates = array_filter( array_keys( $parameters ), 'is_int' ); + sort( $candidates ); + + foreach ( $candidates as $key ) { + if ( $this->is_valid_type( $parameters[$key], $type ) ) { + return $key; + } + } + + return null; + } + + /** + * Check if a given value matches the expected reflection type. + * + * @param mixed $value + * @param \ReflectionType|null $type + * @return bool + */ + protected function is_valid_type( $value, $type ): bool { + if ( ! $type instanceof ReflectionNamedType ) { + return true; + } + + if ( ! $type->isBuiltin() ) { + $target = $type->getName(); + return $value instanceof $target; + } + + // Tighten scalar type checks + switch ( $type->getName() ) { + case 'int': return is_int( $value ); + case 'string': return is_string( $value ); + case 'bool': return is_bool( $value ); + case 'float': return is_float( $value ); + case 'array': return is_array( $value ); + case 'object': return is_object( $value ); + case 'callable': return is_callable( $value ); + case 'iterable': return is_iterable( $value ); + case 'mixed': return true; + } + + return false; + } + + /** + * Retrieve a cached reflection method. + * + * @param string $key + * @return \ReflectionMethod|null + */ + public function get_cached_method( string $key ): ?\ReflectionMethod { + return $this->reflection_cache['methods'][$key] ?? null; + } + + /** + * Cache a reflection method for future reuse. + * + * @param string $key + * @param \ReflectionMethod $method + * @return void + */ + public function cache_method( string $key, \ReflectionMethod $method ): void { + $this->reflection_cache['methods'][$key] = $method; + } +} diff --git a/tests/Fixtures/Contracts/InterfaceA.php b/tests/Fixtures/Contracts/InterfaceA.php new file mode 100644 index 0000000..343e0b6 --- /dev/null +++ b/tests/Fixtures/Contracts/InterfaceA.php @@ -0,0 +1,3 @@ +value = $value; + } +} diff --git a/tests/Fixtures/Models/ClassWithParams.php b/tests/Fixtures/Models/ClassWithParams.php new file mode 100644 index 0000000..810c738 --- /dev/null +++ b/tests/Fixtures/Models/ClassWithParams.php @@ -0,0 +1,14 @@ +value = $value; + $this->number = $number; + } +} diff --git a/tests/Fixtures/Models/ConcreteC.php b/tests/Fixtures/Models/ConcreteC.php new file mode 100644 index 0000000..ecd11df --- /dev/null +++ b/tests/Fixtures/Models/ConcreteC.php @@ -0,0 +1,4 @@ + $concrete, 'param' => $param]; + } +} diff --git a/tests/Fixtures/Models/Level1.php b/tests/Fixtures/Models/Level1.php new file mode 100644 index 0000000..0393ed0 --- /dev/null +++ b/tests/Fixtures/Models/Level1.php @@ -0,0 +1,8 @@ +level2 = $level2; } +} diff --git a/tests/Fixtures/Models/Level2.php b/tests/Fixtures/Models/Level2.php new file mode 100644 index 0000000..8a9136d --- /dev/null +++ b/tests/Fixtures/Models/Level2.php @@ -0,0 +1,8 @@ +level3 = $level3; } +} diff --git a/tests/Fixtures/Models/Level3.php b/tests/Fixtures/Models/Level3.php new file mode 100644 index 0000000..25d7fe0 --- /dev/null +++ b/tests/Fixtures/Models/Level3.php @@ -0,0 +1,8 @@ +level4 = $level4; } +} diff --git a/tests/Fixtures/Models/Level4.php b/tests/Fixtures/Models/Level4.php new file mode 100644 index 0000000..c5065d6 --- /dev/null +++ b/tests/Fixtures/Models/Level4.php @@ -0,0 +1,8 @@ +level5 = $level5; } +} diff --git a/tests/Fixtures/Models/Level5.php b/tests/Fixtures/Models/Level5.php new file mode 100644 index 0000000..1ac41ad --- /dev/null +++ b/tests/Fixtures/Models/Level5.php @@ -0,0 +1,3 @@ + $dependency, 'param' => $param]; + } + + public static function static_method( ConcreteClass $dependency, $param ) { + return ['dependency' => $dependency, 'param' => $param]; + } +} diff --git a/tests/Fixtures/Models/MixedDependencyClass.php b/tests/Fixtures/Models/MixedDependencyClass.php new file mode 100644 index 0000000..2c530ea --- /dev/null +++ b/tests/Fixtures/Models/MixedDependencyClass.php @@ -0,0 +1,12 @@ +concrete = $concrete; + $this->value = $value; + } +} diff --git a/tests/Fixtures/Models/NullablePrecClass.php b/tests/Fixtures/Models/NullablePrecClass.php new file mode 100644 index 0000000..d8befed --- /dev/null +++ b/tests/Fixtures/Models/NullablePrecClass.php @@ -0,0 +1,10 @@ +dep = $dep; + } +} diff --git a/tests/Fixtures/Models/ServiceWithDependency.php b/tests/Fixtures/Models/ServiceWithDependency.php new file mode 100644 index 0000000..4d9d45b --- /dev/null +++ b/tests/Fixtures/Models/ServiceWithDependency.php @@ -0,0 +1,11 @@ +dependency = $dependency; + } +} diff --git a/tests/Fixtures/Models/ServiceWithNestedDependency.php b/tests/Fixtures/Models/ServiceWithNestedDependency.php new file mode 100644 index 0000000..5c32e79 --- /dev/null +++ b/tests/Fixtures/Models/ServiceWithNestedDependency.php @@ -0,0 +1,11 @@ +service = $service; + } +} diff --git a/tests/Fixtures/Models/VariadicClass.php b/tests/Fixtures/Models/VariadicClass.php new file mode 100644 index 0000000..96813f2 --- /dev/null +++ b/tests/Fixtures/Models/VariadicClass.php @@ -0,0 +1,10 @@ +args = $args; + } +} diff --git a/tests/Fixtures/Models/VisibilityClass.php b/tests/Fixtures/Models/VisibilityClass.php new file mode 100644 index 0000000..49c12ed --- /dev/null +++ b/tests/Fixtures/Models/VisibilityClass.php @@ -0,0 +1,13 @@ +container = new Container(); + } + + /** + * Verifies resolution of deep dependency trees (5 levels). + */ + public function test_it_resolves_deep_dependency_trees() { + $start_time = microtime( true ); + $level1 = $this->container->get( Level1::class ); + $end_time = microtime( true ); + + $this->assertInstanceOf( Level1::class, $level1 ); + $this->assertInstanceOf( Level2::class, $level1->level2 ); + $this->assertInstanceOf( Level3::class, $level1->level2->level3 ); + $this->assertInstanceOf( Level4::class, $level1->level2->level3->level4 ); + $this->assertInstanceOf( Level5::class, $level1->level2->level3->level4->level5 ); + + // Performance check for reflection cache: second resolution should be significantly faster + $start_time2 = microtime( true ); + $this->container->make( Level1::class ); + $end_time2 = microtime( true ); + + $this->assertLessThanOrEqual( $end_time - $start_time, $end_time2 - $start_time2, "Cached resolution should be faster." ); + } + + /** + * Verifies resolution of interface chains (A -> B -> Concrete). + */ + public function test_it_resolves_interface_chains() { + $this->container->bind( InterfaceA::class, InterfaceB::class ); + $this->container->bind( InterfaceB::class, ConcreteC::class ); + + $instance = $this->container->get( InterfaceA::class ); + $this->assertInstanceOf( ConcreteC::class, $instance ); + } + + /** + * Verifies resolution of recursive alias chains. + */ + public function test_it_resolves_recursive_aliases() { + $this->container->alias( ConcreteClass::class, 'alias3' ); + $this->container->alias( 'alias3', 'alias2' ); + $this->container->alias( 'alias2', 'alias1' ); + + $instance = $this->container->get( 'alias1' ); + $this->assertInstanceOf( ConcreteClass::class, $instance ); + } + + /** + * Verifies handling of mixed autowiring (classes + provided parameters). + */ + public function test_it_handles_mixed_autowiring_with_overrides() { + $instance = $this->container->make( + MixedDependencyClass::class, [ + 'value' => 'injected-value' + ] + ); + + $this->assertInstanceOf( MixedDependencyClass::class, $instance ); + $this->assertInstanceOf( ConcreteClass::class, $instance->concrete ); + $this->assertEquals( 'injected-value', $instance->value ); + } +} diff --git a/tests/Integration/CircularDependencyTest.php b/tests/Integration/CircularDependencyTest.php new file mode 100644 index 0000000..f2dcf63 --- /dev/null +++ b/tests/Integration/CircularDependencyTest.php @@ -0,0 +1,140 @@ +singleton( StubConcrete::class ); + $container->singleton( StubInterface::class, StubConcrete::class ); + + // Resolve concrete FIRST + $instance1 = $container->get( StubConcrete::class ); + // Resolve abstract SECOND + $instance2 = $container->get( StubInterface::class ); + + $this->assertSame( $instance1, $instance2, "Concrete and Abstract must resolve to the SAME singleton instance." ); + } + + /** + * Verifies that the CallbackInvoker can resolve aliased services. + */ + public function test_callback_invoker_resolves_aliases() { + $container = new Container(); + $container->singleton( StubConcrete::class ); + $container->alias( StubConcrete::class, 'my_api' ); + + $result = $container->call( 'my_api::testMethod' ); + $this->assertEquals( 'ok', $result ); + } + + /** + * Verifies that circular dependencies throw the specific CircularDependencyException. + */ + public function test_circular_dependency_uses_specific_exception() { + $container = new Container(); + + $this->expectException( CircularDependencyException::class ); + $container->get( CircA::class ); + } + + /** + * Verifies that setting a service with an alias synchronizes correctly with the real class. + */ + public function test_set_with_alias_is_synchronized_with_real_class() { + $container = new Container(); + $container->alias( StubConcrete::class, 'my_service' ); + + $manual = new StubConcrete(); + $manual->val = 'manual'; + + $container->set( 'my_service', $manual ); + + $resolved = $container->get( StubConcrete::class ); + $this->assertSame( $manual, $resolved ); + $this->assertEquals( 'manual', $resolved->val ); + } + + /** + * Verifies that positional parameters do not collide with type hints during resolution. + */ + public function test_positional_parameters_do_not_collide_with_type_hints() { + $container = new Container(); + + $instance = $container->make( TargetWithMixedParams::class, ['SomeName'] ); + + $this->assertInstanceOf( StubConcrete::class, $instance->dep ); + $this->assertEquals( 'SomeName', $instance->name ); + } + + /** + * Verifies that variadic parameters correctly collect remaining named arguments. + */ + public function test_variadic_parameters_collect_remaining_named_args() { + $container = new Container(); + $instance = $container->make( VariadicTarget::class, ['first' => 1, 'second' => 2] ); + + $this->assertEquals( [1, 2], array_values( $instance->args ) ); + } + + /** + * Verifies that the reflection cache is isolated per container instance. + */ + public function test_reflection_cache_is_isolated_per_container() { + $c1 = new Container(); + $c2 = new Container(); + + $this->assertInstanceOf( StubConcrete::class, $c1->get( StubConcrete::class ) ); + $this->assertInstanceOf( StubConcrete::class, $c2->get( StubConcrete::class ) ); + } +} + +// Internal Stubs for Testing CircularDependencyTest +interface StubInterface {} +class StubConcrete implements StubInterface { + public $val = 'real'; + + public function testMethod() { + return 'ok'; } +} +class CircA { public function __construct( CircB $b ) {} } +class CircB { public function __construct( CircA $a ) {} } +class TargetWithMixedParams { + public $dep; + + public $name; + + public function __construct( StubConcrete $dep, $name ) { + $this->dep = $dep; + $this->name = $name; + } +} +class VariadicTarget { + public $args; + + public function __construct( ...$args ) { + $this->args = $args; } +} diff --git a/tests/Integration/ContainerLifecycleTest.php b/tests/Integration/ContainerLifecycleTest.php new file mode 100644 index 0000000..dea9f42 --- /dev/null +++ b/tests/Integration/ContainerLifecycleTest.php @@ -0,0 +1,111 @@ +container = new Container(); + } + + /** + * Verifies the distinction between singleton and transient scopes. + */ + public function test_it_differentiates_between_singleton_and_transient_scopes() { + // Singleton scope + $this->container->singleton( 'shared', ConcreteClass::class ); + $obj1 = $this->container->get( 'shared' ); + $obj2 = $this->container->get( 'shared' ); + $this->assertSame( $obj1, $obj2, "Singleton must return the same instance." ); + + // Transient scope + $this->container->bind( 'transient', ConcreteClass::class ); + $obj3 = $this->container->get( 'transient' ); + $obj4 = $this->container->get( 'transient' ); + $this->assertNotSame( $obj3, $obj4, "Bind must return a fresh instance every time." ); + } + + /** + * Verifies that parameters can be overridden during make() calls. + */ + public function test_it_overrides_parameters_in_make() { + $instance = $this->container->make( + ClassWithParams::class, [ + 'value' => 'runtime-value', + 'number' => 100 + ] + ); + + $this->assertEquals( 'runtime-value', $instance->value ); + $this->assertEquals( 100, $instance->number ); + + // Different call with partial overrides + $instance2 = $this->container->make( + ClassWithParams::class, [ + 'value' => 'another-value' + ] + ); + $this->assertEquals( 'another-value', $instance2->value ); + $this->assertEquals( 0, $instance2->number ); // Should use default 0 + } + + /** + * Verifies that shared instances can be parameterized (parameters are ignored if already instantiated). + */ + public function test_it_allows_shared_instance_parameterization() { + $this->container->singleton( ClassWithParams::class ); + + $instance = $this->container->get( ClassWithParams::class, ['value' => 'first-call'] ); + $this->assertEquals( 'first-call', $instance->value ); + + // This should NO LONGER throw an exception + $instance2 = $this->container->get( ClassWithParams::class, ['value' => 'second-call'] ); + + $this->assertSame( $instance, $instance2 ); + $this->assertEquals( 'first-call', $instance2->value ); // Parameters were ignored + } + + /** + * Verifies that closure factories receive the container and specific parameters. + */ + public function test_it_passes_parameters_to_closure_factories() { + $this->container->bind( + 'factory', function( $app, $params ) { + $obj = new \stdClass(); + $obj->injected = $params['val'] ?? 'default'; + $obj->app = $app; + return $obj; + } + ); + + $instance = $this->container->make( 'factory', ['val' => 'custom'] ); + $this->assertEquals( 'custom', $instance->injected ); + $this->assertSame( $this->container, $instance->app ); + + $instance2 = $this->container->make( 'factory' ); + $this->assertEquals( 'default', $instance2->injected ); + } +} diff --git a/tests/Integration/ContainerRobustnessTest.php b/tests/Integration/ContainerRobustnessTest.php new file mode 100644 index 0000000..80197eb --- /dev/null +++ b/tests/Integration/ContainerRobustnessTest.php @@ -0,0 +1,165 @@ +container = new Container(); + } + + /** + * Verifies support for object bindings (injecting an already instantiated object). + */ + public function test_it_supports_object_bindings() { + $object = new \stdClass(); + $object->val = 'pre-built'; + + $this->container->singleton( 'service', $object ); + + $this->assertSame( $object, $this->container->get( 'service' ) ); + $this->assertEquals( 'pre-built', $this->container->get( 'service' )->val ); + } + + /** + * Verifies handling of variadic parameters in class constructors. + */ + public function test_it_handles_variadic_constructor_params() { + // Empty variadics + $instance = $this->container->make( VariadicClass::class ); + $this->assertEmpty( $instance->args ); + + // Passed positional params + $instance2 = $this->container->make( VariadicClass::class, ['a', 'b', 'c'] ); + $this->assertCount( 3, $instance2->args ); + $this->assertEquals( ['a', 'b', 'c'], $instance2->args ); + } + + /** + * Verifies that the container can call invokable objects. + */ + public function test_it_resolves_invokable_objects() { + $invokable = new InvokableClass(); + $result = $this->container->call( $invokable, ['param' => 'hello'] ); + + $this->assertInstanceOf( ConcreteClass::class, $result['concrete'] ); + $this->assertEquals( 'hello', $result['param'] ); + } + + /** + * Verifies detection of alias resolution loops (A -> B -> A). + */ + public function test_it_detects_alias_loops() { + $this->container->alias( 'serviceB', 'serviceA' ); + $this->container->alias( 'serviceA', 'serviceB' ); + + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( 'Circular alias resolution' ); + + $this->container->get( 'serviceA' ); + } + + /** + * Verifies that instances can be cleared from the container. + */ + public function test_it_can_forget_instances() { + $this->container->singleton( 's1', ConcreteClass::class ); + $obj1 = $this->container->get( 's1' ); + + $this->container->forget_instances(); + + $obj2 = $this->container->get( 's1' ); + $this->assertNotSame( $obj1, $obj2, "After forgetting instances, singleton should be re-instantiated." ); + } + + /** + * Verifies the ability to flush the entire container registry and instance cache. + */ + public function test_it_can_flush_everything() { + $this->container->singleton( 's1', ConcreteClass::class ); + $this->container->alias( 's1', 'alias1' ); + + $this->container->flush(); + + $this->assertFalse( $this->container->has( 's1' ) ); + $this->assertFalse( $this->container->has( 'alias1' ) ); + } + + /** + * Verifies that optional/nullable parameters resolve to null when dependencies are missing. + */ + public function test_it_prefers_default_values_over_null_for_nullable_params() { + $result = $this->container->call( + function( ?NonExistentInterfaceForTest $dep = null ) { + return $dep === null ? 'is-null' : 'is-not-null'; + } + ); + + $this->assertEquals( 'is-null', $result ); + } + + /** + * Verifies that the container strictly respects method visibility (public vs private). + */ + public function test_it_strictly_respects_method_visibility() { + $instance = new VisibilityClass(); + + // Public should work + $this->assertEquals( 'public', $this->container->call( [$instance, 'public_method'] ) ); + + // Private should fail + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( 'is not public' ); + $this->container->call( [$instance, 'private_method'] ); + } + + /** + * Verifies support for static method resolution. + */ + public function test_it_resolves_static_methods() { + $result = $this->container->call( 'WpMVC\Container\Tests\Fixtures\Models\MethodInjectionClass::static_method', ['param' => 'static'] ); + $this->assertEquals( 'static', $result['param'] ); + $this->assertInstanceOf( ConcreteClass::class, $result['dependency'] ); + } + + /** + * Verifies support for primitive value bindings (strings, arrays, bools). + */ + public function test_it_supports_primitive_value_bindings() { + $this->container->singleton( 'api_key', 'secret-123' ); + $this->container->bind( 'config_array', ['db' => 'localhost'] ); + $this->container->singleton( 'is_enabled', true ); + + $this->assertEquals( 'secret-123', $this->container->get( 'api_key' ) ); + $this->assertEquals( ['db' => 'localhost'], $this->container->get( 'config_array' ) ); + $this->assertTrue( $this->container->get( 'is_enabled' ) ); + } +} + +// Internal Stub for Nullable Check +interface NonExistentInterfaceForTest {} diff --git a/tests/Integration/ExtensionTaggingTest.php b/tests/Integration/ExtensionTaggingTest.php new file mode 100644 index 0000000..aa7e631 --- /dev/null +++ b/tests/Integration/ExtensionTaggingTest.php @@ -0,0 +1,89 @@ +container = new Container(); + } + + /** + * Verifies basic tagging and retrieval of multiple services. + */ + public function test_it_can_tag_and_retrieve_services() { + $this->container->singleton( 'service1', ConcreteClass::class ); + $this->container->singleton( 'service2', ConcreteClass::class ); + + $this->container->tag( ['service1', 'service2'], 'test_tag' ); + + $tagged = $this->container->tagged( 'test_tag' ); + + $this->assertCount( 2, $tagged ); + $this->assertInstanceOf( ConcreteClass::class, $tagged[0] ); + $this->assertInstanceOf( ConcreteClass::class, $tagged[1] ); + } + + /** + * Verifies that a single service can be assigned multiple tags. + */ + public function test_it_supports_multi_tagging() { + $this->container->singleton( 'service', ConcreteClass::class ); + $this->container->tag( 'service', ['tag1', 'tag2'] ); + + $this->assertCount( 1, $this->container->tagged( 'tag1' ) ); + $this->assertCount( 1, $this->container->tagged( 'tag2' ) ); + + $this->assertInstanceOf( ConcreteClass::class, $this->container->tagged( 'tag1' )[0] ); + } + + /** + * Verifies that tagged services have their dependencies correctly autowired upon retrieval. + */ + public function test_it_resolves_complex_services_via_tags() { + $this->container->singleton( 'complex', ServiceWithDependency::class ); + $this->container->tag( 'complex', 'app.services' ); + + $services = $this->container->tagged( 'app.services' ); + + $this->assertInstanceOf( ServiceWithDependency::class, $services[0] ); + $this->assertInstanceOf( ConcreteClass::class, $services[0]->dependency ); + } + + /** + * Verifies that aliases can be used when tagging services. + */ + public function test_it_resolves_aliases_in_tags() { + $this->container->singleton( ConcreteClass::class ); + $this->container->alias( ConcreteClass::class, 'my_alias' ); + + $this->container->tag( 'my_alias', 'aliased_tag' ); + + $tagged = $this->container->tagged( 'aliased_tag' ); + + $this->assertCount( 1, $tagged ); + $this->assertInstanceOf( ConcreteClass::class, $tagged[0] ); + } +} diff --git a/tests/Integration/RecursiveResolutionTest.php b/tests/Integration/RecursiveResolutionTest.php new file mode 100644 index 0000000..6a59b93 --- /dev/null +++ b/tests/Integration/RecursiveResolutionTest.php @@ -0,0 +1,90 @@ +alias( $abstract, $interface ); + $container->bind( $abstract, $alias ); + $container->alias( $concrete, $alias ); + + $instance = $container->get( $interface ); + $this->assertInstanceOf( $concrete, $instance ); + } + + /** + * Verifies that the CallbackInvoker can resolve service IDs from the container. + */ + public function test_callback_invoker_resolves_generic_service_id() { + $container = new Container(); + $container->set( 'my_service', new CallbackService() ); + + $result = $container->call( ['my_service', 'handle'] ); + $this->assertEquals( 'handled', $result ); + } + + /** + * Verifies that the ResolutionEngine correctly filters positional candidates based on type hints. + */ + public function test_positional_parameters_respect_builtin_type_hints() { + $container = new Container(); + $container->singleton( ConcreteClass::class ); + + // Provides stdClass (invalid for string $name) and "John" (valid for string $name) + $instance = $container->make( PositionalGuardTarget::class, [new \stdClass(), "John"] ); + + $this->assertEquals( "John", $instance->name ); + $this->assertInstanceOf( ConcreteClass::class, $instance->dep ); + } +} + +// Internal Stubs for RecursiveResolutionTest +interface RecursiveInterface {} +class RecursiveImpl implements RecursiveInterface {} + +class CallbackService { + public function handle() { + return 'handled'; + } +} + +class PositionalGuardTarget { + public $name; + + public $dep; + + public function __construct( string $name, ConcreteClass $dep ) { + $this->name = $name; + $this->dep = $dep; + } +} diff --git a/tests/Integration/TypeSafetyTest.php b/tests/Integration/TypeSafetyTest.php new file mode 100644 index 0000000..ed8cf1c --- /dev/null +++ b/tests/Integration/TypeSafetyTest.php @@ -0,0 +1,80 @@ +bind( + 'A', function( $c ) { + return $c->get( 'B' ); + } + ); + + $container->bind( + 'B', function( $c ) { + return $c->get( 'A' ); + } + ); + + try { + $container->get( 'A' ); + } catch ( \Exception $e ) { + $this->assertInstanceOf( CircularDependencyException::class, $e ); + return; + } + + $this->fail( 'CircularDependencyException was not thrown for closure cycle.' ); + } + + /** + * Verifies that scalar type guards prevent type mismatches during resolution. + */ + public function test_scalar_type_guards_prevent_mismatch() { + $container = new Container(); + + // Target needs an int + $target = new class(0) { + public $val; + + public function __construct( int $val ) { + $this->val = $val; } + }; + $class = get_class( $target ); + + // Providing a string "123" instead of an int should fail + try { + $container->make( $class, ["123"] ); + } catch ( \Exception $e ) { + $this->assertInstanceOf( ContainerException::class, $e ); + return; + } + + $this->fail( 'ContainerException was not thrown for scalar type mismatch.' ); + } +} diff --git a/tests/Unit/ContainerTest.php b/tests/Unit/ContainerTest.php index 32aae0c..270a099 100644 --- a/tests/Unit/ContainerTest.php +++ b/tests/Unit/ContainerTest.php @@ -1,4 +1,11 @@ container = new Container(); } + /** + * Verifies that the container can be instantiated. + */ public function test_it_can_be_instantiated() { $this->assertInstanceOf( Container::class, $this->container ); } + /** + * Verifies resolution of a class that has no dependencies. + */ public function test_it_resolves_a_class_without_dependencies() { $instance = $this->container->get( ConcreteClass::class ); $this->assertInstanceOf( ConcreteClass::class, $instance ); } + /** + * Verifies that the container returns a fresh instance by default for unregistered classes. + */ + public function test_it_returns_fresh_instance_by_default() { + $instance1 = $this->container->get( ConcreteClass::class ); + $instance2 = $this->container->get( ConcreteClass::class ); + + $this->assertNotSame( $instance1, $instance2 ); + } + + /** + * Verifies that singletons return the same instance across multiple resolutions. + */ public function test_it_returns_same_instance_for_singleton() { + $this->container->singleton( ConcreteClass::class ); + $instance1 = $this->container->get( ConcreteClass::class ); $instance2 = $this->container->get( ConcreteClass::class ); $this->assertSame( $instance1, $instance2 ); } + /** + * Verifies that make() always returns a new instance even for shared services. + */ public function test_make_returns_new_instance() { $instance1 = $this->container->make( ConcreteClass::class ); $instance2 = $this->container->make( ConcreteClass::class ); @@ -40,16 +88,16 @@ public function test_make_returns_new_instance() { $this->assertNotSame( $instance1, $instance2 ); } + /** + * Verifies that has() returns true for classes that exist. + */ public function test_has_returns_true_for_existing_classes() { $this->assertTrue( $this->container->has( ConcreteClass::class ) ); } - public function test_has_returns_false_for_non_existent_classes() { - // has() returns true if class_exists() is true. - // It should return false for a nonsense string. - $this->assertFalse( $this->container->has( 'NonExistentClassXYZ' ) ); - } - + /** + * Verifies automatic resolution of constructor dependencies. + */ public function test_it_resolves_dependencies_automatically() { $service = $this->container->get( ServiceWithDependency::class ); @@ -57,6 +105,9 @@ public function test_it_resolves_dependencies_automatically() { $this->assertInstanceOf( ConcreteClass::class, $service->dependency ); } + /** + * Verifies automatic resolution of multi-level nested dependencies. + */ public function test_it_resolves_nested_dependencies() { $nested = $this->container->get( ServiceWithNestedDependency::class ); @@ -65,11 +116,17 @@ public function test_it_resolves_nested_dependencies() { $this->assertInstanceOf( ConcreteClass::class, $nested->service->dependency ); } + /** + * Verifies that requesting a non-existent class throws a NotFoundException. + */ public function test_it_throws_not_found_exception_for_non_existent_class() { $this->expectException( NotFoundException::class ); $this->container->get( 'Some\Random\NonExistent\Class' ); } + /** + * Verifies that circular dependencies are caught (basic autowiring case). + */ public function test_it_throws_container_exception_for_circular_dependency() { $this->expectException( ContainerException::class ); $this->expectExceptionMessage( 'Circular dependency detected' ); @@ -77,6 +134,9 @@ public function test_it_throws_container_exception_for_circular_dependency() { $this->container->get( CircularA::class ); } + /** + * Verifies that make() can override default parameters. + */ public function test_it_can_make_with_parameters_override() { $instance = $this->container->make( ClassWithParams::class, @@ -90,6 +150,22 @@ public function test_it_can_make_with_parameters_override() { $this->assertEquals( 42, $instance->number ); } + /** + * Verifies that passing parameters to an already instantiated shared service does not throw anymore. + */ + public function test_it_ignores_parameters_for_existing_shared_service() { + $this->container->singleton( ClassWithParams::class ); + + $instance1 = $this->container->get( ClassWithParams::class, ['value' => 'first', 'number' => 1] ); + $instance2 = $this->container->get( ClassWithParams::class, ['value' => 'ignored', 'number' => 2] ); + + $this->assertSame( $instance1, $instance2 ); + $this->assertEquals( 'first', $instance2->value ); + } + + /** + * Verifies method injection via the call() method. + */ public function test_call_method_injection() { $instance = new MethodInjectionClass(); @@ -104,6 +180,9 @@ public function test_call_method_injection() { $this->assertInstanceOf( ConcreteClass::class, $result['dependency'] ); } + /** + * Verifies closure injection via the call() method. + */ public function test_call_closure_injection() { $result = $this->container->call( function ( ConcreteClass $dep, $test ) { @@ -116,74 +195,110 @@ function ( ConcreteClass $dep, $test ) { $this->assertEquals( 'worked', $result['test'] ); } - public function test_set_manually_binds_instance() { - $mock = new \stdClass(); - $this->container->set( 'custom_key', $mock ); + /** + * Verifies interface to concrete mapping. + */ + public function test_it_binds_interface_to_concrete() { + $this->container->bind( TestInterface::class, ConcreteImplementation::class ); + $instance = $this->container->get( TestInterface::class ); - $this->assertTrue( $this->container->has( 'custom_key' ) ); - $this->assertSame( $mock, $this->container->get( 'custom_key' ) ); + $this->assertInstanceOf( ConcreteImplementation::class, $instance ); } - public function test_call_static_method_injection() { - $result = $this->container->call( - [MethodInjectionClass::class, 'static_method'], - [ - 'param' => 'static_test' - ] + /** + * Verifies using a closure as a factory binding. + */ + public function test_it_binds_closure_as_factory() { + $this->container->bind( + 'config', function() { + return ['db' => 'localhost']; + } ); - $this->assertEquals( 'static_test', $result['param'] ); - $this->assertInstanceOf( ConcreteClass::class, $result['dependency'] ); + $config = $this->container->get( 'config' ); + $this->assertEquals( 'localhost', $config['db'] ); } -} - -// Fixtures - -class ConcreteClass {} -class ServiceWithDependency { - public $dependency; + /** + * Verifies the use of positional parameters in call(). + */ + public function test_call_with_positional_parameters() { + $result = $this->container->call( + function ( ConcreteClass $dep, $test ) { + return ['dep' => $dep, 'test' => $test]; + }, + [1 => 'positional'] // Index 1 is $test + ); - public function __construct( ConcreteClass $dependency ) { - $this->dependency = $dependency; + $this->assertInstanceOf( ConcreteClass::class, $result['dep'] ); + $this->assertEquals( 'positional', $result['test'] ); } -} -class ServiceWithNestedDependency { - public $service; + /** + * Verifies handling of nullable parameters in call(). + */ + public function test_it_handles_nullable_parameters() { + $result = $this->container->call( + function ( ?TestInterface $dep = null ) { + return $dep; + } + ); - public function __construct( ServiceWithDependency $service ) { - $this->service = $service; + $this->assertNull( $result ); } -} -class CircularA { - public function __construct( CircularB $b ) { + /** + * Verifies resolution of optional constructor parameters. + */ + public function test_it_resolves_optional_parameters() { + $instance = $this->container->make( ClassWithOptionalParam::class ); + $this->assertEquals( 'default', $instance->value ); } -} -class CircularB { - public function __construct( CircularA $a ) { - } -} + /** + * Verifies that middleware-style calls work correctly (injecting stdClass and closure). + */ -class ClassWithParams { - public $value; + /** + * Verifies that the container correctly resolves middleware-like calls (injecting stdClass and closure). + */ + public function test_it_correctly_resolves_middleware_like_calls() { + $req = new \stdClass(); + $next = function() { return 'next'; }; - public $number; + $result = $this->container->call( + function( \stdClass $r, $n ) { + return [$r, $n()]; + }, [$req, $next] + ); - public function __construct( $value, $number = 0 ) { - $this->value = $value; - $this->number = $number; + $this->assertSame( $req, $result[0] ); + $this->assertEquals( 'next', $result[1] ); } -} -class MethodInjectionClass { - public function method( ConcreteClass $dependency, $param ) { - return ['dependency' => $dependency, 'param' => $param]; + /** + * Verifies that duplicate tags are ignored. + */ + public function test_it_prevents_duplicate_tags() { + $this->container->tag( ConcreteClass::class, ['plugin', 'service'] ); + $this->container->tag( ConcreteClass::class, ['plugin'] ); // Duplicate + + $tagged = $this->container->tagged( 'plugin' ); + $count = 0; + foreach ( $tagged as $item ) { + $count++; + } + + $this->assertEquals( 1, $count ); } - public static function static_method( ConcreteClass $dependency, $param ) { - return ['dependency' => $dependency, 'param' => $param]; + /** + * Verifies that calling an abstract method throws a ContainerException. + */ + public function test_it_throws_exception_on_abstract_method_call() { + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( 'Cannot call abstract method' ); + + $this->container->call( [\Psr\Container\ContainerInterface::class, 'get'] ); } } diff --git a/tests/Unit/ContextualBindingTest.php b/tests/Unit/ContextualBindingTest.php new file mode 100644 index 0000000..00ea6b8 --- /dev/null +++ b/tests/Unit/ContextualBindingTest.php @@ -0,0 +1,65 @@ +dep = $dep; + } +} + +class ClientB { + public $dep; + + public function __construct( TestInterface $dep ) { + $this->dep = $dep; + } +} + +class ContextualBindingTest extends TestCase +{ + protected $container; + + protected function setUp(): void { + parent::setUp(); + $this->container = new Container(); + } + + public function test_contextual_binding_resolves_correctly() { + $this->container->when( ClientA::class ) + ->needs( TestInterface::class ) + ->give( ImplementationA::class ); + + $this->container->when( ClientB::class ) + ->needs( TestInterface::class ) + ->give( ImplementationB::class ); + + $client_a = $this->container->get( ClientA::class ); + $client_b = $this->container->get( ClientB::class ); + + $this->assertInstanceOf( ImplementationA::class, $client_a->dep ); + $this->assertInstanceOf( ImplementationB::class, $client_b->dep ); + } + + public function test_contextual_binding_with_closure() { + $this->container->when( ClientA::class ) + ->needs( TestInterface::class ) + ->give( + function() { + return new ImplementationA(); + } + ); + + $client_a = $this->container->get( ClientA::class ); + $this->assertInstanceOf( ImplementationA::class, $client_a->dep ); + } +}