diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..142b914 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + tests: + name: PHP ${{ matrix.php }} - ${{ matrix.dependencies }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3', '8.4'] + dependencies: ['lowest', 'highest'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring + coverage: none + tools: composer:v2 + + - name: Validate composer.json + run: composer validate --strict + + - name: Install dependencies + uses: ramsey/composer-install@v3 + with: + dependency-versions: ${{ matrix.dependencies }} + + - name: Run PHPUnit + run: vendor/bin/phpunit + + static-analysis: + name: Static analysis & code style + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress + + - name: Check code style + run: vendor/bin/php-cs-fixer fix --dry-run --diff diff --git a/.gitignore b/.gitignore index 0abe7ad..23e410c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,8 @@ /.vscode/ /.vs/ /vendor/ -/composer.lock \ No newline at end of file +/composer.lock +/.phpunit.cache/ +/.phpunit.result.cache +/.php-cs-fixer.cache +/.phpstan.cache/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..0d9e235 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,19 @@ +in([__DIR__ . '/src', __DIR__ . '/tests']); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@PSR12' => true, + 'declare_strict_types' => true, + 'array_syntax' => ['syntax' => 'short'], + 'no_unused_imports' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha', 'imports_order' => ['class', 'function', 'const']], + 'single_quote' => true, + 'trailing_comma_in_multiline' => true, + ]) + ->setFinder($finder); diff --git a/README.md b/README.md index cef0cce..b11a612 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,178 @@ -# InitPHP Dependencies Container +# InitPHP Container -Simple Dependencies Container following PSR-11 standards. +Minimal [PSR-11](https://www.php-fig.org/psr/psr-11/) dependency injection container with reflection-based autowiring. -_Note :_ This is a pre-release version of the library currently available. Report potential bugs and feature requests to the issue section of this repo. +[![Latest Stable Version](https://poser.pugx.org/initphp/container/v/stable)](https://packagist.org/packages/initphp/container) +[![Total Downloads](https://poser.pugx.org/initphp/container/downloads)](https://packagist.org/packages/initphp/container) +[![License](https://poser.pugx.org/initphp/container/license)](https://packagist.org/packages/initphp/container) +[![PHP Version Require](https://poser.pugx.org/initphp/container/require/php)](https://packagist.org/packages/initphp/container) + +The container resolves entries lazily. You can register values, factories and class bindings explicitly, or let the container autowire a class straight from its name by reading its constructor signature through reflection. Every resolved entry is cached, so the container behaves as a shared (singleton) registry. ## Requirements -- PHP 7.4 or higher -- [PSR-11 Container Interface Package](https://packagist.org/packages/psr/container) 2.0.2 +- PHP 8.1 or higher +- [`psr/container`](https://packagist.org/packages/psr/container) `^2.0` ## Installation -``` -composer require initphp/container:dev-main +```bash +composer require initphp/container ``` -## Usage - -Check the `Example` directory for an example usage. +## Quick Start ```php -require_once "vendor/autoload.php"; +require_once 'vendor/autoload.php'; + use InitPHP\Container\Container; -class UserModel +class Mailer { - private string $name; +} - public function set(string $name) - { - $this->name = $name; - } - - public function get() +class UserService +{ + public function __construct(public Mailer $mailer) { - return $this->name ?? null; } } -class User +$container = new Container(); + +// No registration needed: the container reads UserService's constructor, +// builds the Mailer dependency automatically and injects it. +$service = $container->get(UserService::class); + +var_dump($service instanceof UserService); // bool(true) +var_dump($service->mailer instanceof Mailer); // bool(true) +``` + +## Usage + +### Autowiring + +When you call `get()` with an existing class name, the container instantiates it and recursively resolves every class-typed constructor argument: + +```php +$service = $container->get(UserService::class); +``` + +The resolved instance is cached. Asking for the same identifier again returns the exact same object: + +```php +$container->get(Mailer::class) === $container->get(Mailer::class); // true +``` + +### Storing values + +`set()` accepts any value. Scalars, arrays and objects are returned as they were stored: + +```php +$container->set('app.name', 'InitPHP'); +$container->set('config', ['debug' => true]); +$container->set('logger', new FileLogger('/var/log/app.log')); + +$container->get('app.name'); // 'InitPHP' +$container->get('config'); // ['debug' => true] +$container->get('logger'); // the same FileLogger instance +``` + +### Factories (closures) + +Register a `Closure` to build an entry lazily. The closure receives the container and runs only once, the first time the entry is requested: + +```php +use Psr\Container\ContainerInterface; + +$container->set('pdo', function (ContainerInterface $c) { + return new PDO('sqlite::memory:'); +}); + +$pdo = $container->get('pdo'); // closure runs here, result is cached +``` + +### Binding interfaces to implementations + +Bind an interface (or any identifier) to a concrete class name so that both direct lookups and autowired dependencies resolve to the implementation: + +```php +interface LoggerInterface { - private $model; +} - public function __construct(UserModel $model) - { - $this->model = $model; - } +class FileLogger implements LoggerInterface +{ +} - public function getModel() +class Report +{ + public function __construct(public LoggerInterface $logger) { - return $this->model; } } -$container = new Container(); -$user = $container->get(\Example\User::class); -$model = $user->getModel(); -$model->set('Muhammet'); -echo $user->getModel()->get(); +$container->set(LoggerInterface::class, FileLogger::class); + +$container->get(LoggerInterface::class); // FileLogger instance +$container->get(Report::class)->logger; // the same FileLogger instance ``` -## Contributing +### Checking for an entry + +`has()` returns `true` when `get()` would not throw a `NotFoundException` — that is, the identifier is a registered entry or an existing class name: + +```php +$container->has('app.name'); // true after set() +$container->has(UserService::class); // true (autowirable class) +$container->has('missing'); // false +``` + +## How resolution works + +`get($id)` resolves in this order: + +1. Return the cached instance if `$id` was resolved before. +2. If `$id` was registered with `set()`, build it (invoke the closure, instantiate the class name, or return the stored value) and cache it. +3. If `$id` is an existing class name, autowire it and cache it. +4. Otherwise throw `NotFoundException`. + +Constructor parameters are resolved as follows: a class-typed parameter is fetched from the container; otherwise its default value is used; otherwise `null` is supplied for nullable parameters. If none of these apply, resolution fails. + +## Exceptions + +All exceptions live in `InitPHP\Container\Exception` and implement the relevant PSR-11 interface. + +| Exception | Implements | Thrown when | +| --- | --- | --- | +| `NotFoundException` | `Psr\Container\NotFoundExceptionInterface` | The identifier is neither registered nor an existing class. | +| `DependencyIsNotInstantiableException` | `Psr\Container\ContainerExceptionInterface` | The target is an interface, abstract class, or has a non-public constructor. | +| `DependencyHasNoDefaultValueException` | `Psr\Container\ContainerExceptionInterface` | A constructor parameter cannot be autowired and has no default or nullable fallback. | +| `CircularDependencyException` | `Psr\Container\ContainerExceptionInterface` | A class depends on itself directly or through a cycle. | -> All contributions to this project will be published under the MIT License. By submitting a pull request or filing a bug, issue, or feature request, you are agreeing to comply with this waiver of copyright interest. +`ContainerException` is the base class for `NotFoundException` and the three resolution exceptions, so you can catch every container error with a single `catch (ContainerException $e)`. + +## Documentation + +In-depth guides with runnable examples live in the [`docs/`](./docs) directory: + +- [Getting Started](./docs/getting-started.md) +- [Autowiring](./docs/autowiring.md) +- [Binding & Factories](./docs/binding-and-factories.md) +- [Exceptions & Error Handling](./docs/exceptions.md) +- [Limitations](./docs/limitations.md) + +## Testing + +```bash +composer test # PHPUnit +composer stan # PHPStan (max level) +composer cs-check # PHP-CS-Fixer (dry run) +``` + +## Contributing -1. Fork it ( https://github.com/initphp/container/fork ) -2. Create your feature branch (git checkout -b my-new-feature) -3. Commit your changes (git commit -am "Add some feature") -4. Push to the branch (git push origin my-new-feature) -5. Create a new Pull Request +Contributions are welcome. Please read the org-wide [CONTRIBUTING guide](https://github.com/InitPHP/.github/blob/main/CONTRIBUTING.md) before opening a pull request. ## Credits @@ -76,4 +180,4 @@ echo $user->getModel()->get(); ## License -Copyright © 2022 [MIT License](./LICENSE) +Released under the [MIT License](./LICENSE). diff --git a/composer.json b/composer.json index 794fca5..04cd9eb 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,16 @@ { "name": "initphp/container", - "description": "Simple Dependencies Container following PSR-11 standards", + "description": "Minimal PSR-11 dependency injection container with reflection-based autowiring.", "type": "library", + "keywords": [ + "container", + "dependency-injection", + "di", + "psr-11", + "autowiring", + "ioc" + ], "license": "MIT", - "autoload": { - "psr-4": { - "InitPHP\\Container\\": "src/" - } - }, "authors": [ { "name": "Muhammet ŞAFAK", @@ -16,9 +19,40 @@ "homepage": "https://www.muhammetsafak.com.tr" } ], - "minimum-stability": "stable", "require": { - "php": ">=7.4", - "psr/container": "2.0.2" + "php": "^8.1", + "psr/container": "^2.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5", + "phpstan/phpstan": "^2.0", + "friendsofphp/php-cs-fixer": "^3.64" + }, + "provide": { + "psr/container-implementation": "2.0" + }, + "autoload": { + "psr-4": { + "InitPHP\\Container\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "InitPHP\\Container\\Tests\\": "tests/" + }, + "files": [ + "tests/Fixtures/fixtures.php" + ] + }, + "minimum-stability": "stable", + "prefer-stable": true, + "config": { + "sort-packages": true + }, + "scripts": { + "test": "phpunit", + "stan": "phpstan analyse", + "cs-check": "php-cs-fixer fix --dry-run --diff", + "cs-fix": "php-cs-fixer fix" } } diff --git a/docs/autowiring.md b/docs/autowiring.md new file mode 100644 index 0000000..cc2c053 --- /dev/null +++ b/docs/autowiring.md @@ -0,0 +1,147 @@ +# Autowiring + +Autowiring is the container's ability to build a class without an explicit registration by inspecting its constructor and resolving each dependency for you. + +## Resolving a class by name + +Pass any existing class name to `get()`: + +```php +use InitPHP\Container\Container; + +class Engine +{ +} + +class Car +{ + public function __construct(public Engine $engine) + { + } +} + +$container = new Container(); +$car = $container->get(Car::class); + +$car instanceof Car; // true +$car->engine instanceof Engine; // true +``` + +The container reads `Car::__construct()`, sees it needs an `Engine`, resolves `Engine` (which has no dependencies of its own) and injects it. + +## Recursive resolution + +Dependencies are resolved recursively to any depth: + +```php +class Db +{ +} + +class Repository +{ + public function __construct(public Db $db) + { + } +} + +class Controller +{ + public function __construct(public Repository $repository) + { + } +} + +$controller = $container->get(Controller::class); +$controller->repository->db instanceof Db; // true +``` + +Because every entry is cached, a dependency shared by several classes is the same instance everywhere: + +```php +$repository = $container->get(Repository::class); +$controller = $container->get(Controller::class); + +$controller->repository === $repository; // true +``` + +## How each constructor parameter is resolved + +For every constructor parameter, the container applies these rules in order: + +1. **Class-typed parameter** (a single, non-builtin type): the container resolves it through `get()`. +2. **Default value available**: the parameter's default value is used. +3. **Nullable parameter**: `null` is injected. +4. **None of the above**: a `DependencyHasNoDefaultValueException` is thrown. + +### Examples + +```php +class Service +{ + public function __construct( + public Engine $engine, // rule 1: autowired + public string $name = 'svc', // rule 2: uses 'svc' + public ?Engine $spare = null, // rule 1 if resolvable, else rule 3 + ) { + } +} + +$service = $container->get(Service::class); +$service->engine instanceof Engine; // true +$service->name; // 'svc' +$service->spare instanceof Engine; // true (Engine is resolvable) +``` + +A scalar parameter without a default cannot be guessed and fails: + +```php +class NeedsString +{ + public function __construct(public string $value) + { + } +} + +$container->get(NeedsString::class); // throws DependencyHasNoDefaultValueException +``` + +Register the value or provide a factory instead — see [Binding & Factories](./binding-and-factories.md). + +## Union and intersection types + +A union or intersection typed parameter is not autowired (the container cannot decide which type to build). It falls back to the default value or `null`: + +```php +class WithUnion +{ + public function __construct(public int|string $value = 1) + { + } +} + +$container->get(WithUnion::class)->value; // 1 +``` + +If such a parameter has no default and is not nullable, a `DependencyHasNoDefaultValueException` is thrown. + +## Classes without a constructor + +A class with no constructor is simply instantiated: + +```php +class Plain +{ +} + +$container->get(Plain::class) instanceof Plain; // true +``` + +## What cannot be autowired + +- Interfaces — not instantiable, and `class_exists()` does not report them, so the container treats an unbound interface as unknown. Requesting one directly throws `NotFoundException`; requiring one as an unbound, non-nullable dependency throws `DependencyHasNoDefaultValueException`. Bind it to a concrete class first (see [Binding & Factories](./binding-and-factories.md)). +- Abstract classes — `class_exists()` reports them, so the container tries to build one and fails with `DependencyIsNotInstantiableException`, both when requested directly and as a dependency. +- Classes with a non-public constructor — they throw `DependencyIsNotInstantiableException`. +- Circular dependencies — they throw `CircularDependencyException`. + +See [Exceptions & Error Handling](./exceptions.md) for details. diff --git a/docs/binding-and-factories.md b/docs/binding-and-factories.md new file mode 100644 index 0000000..c40871b --- /dev/null +++ b/docs/binding-and-factories.md @@ -0,0 +1,122 @@ +# Binding & Factories + +Autowiring covers concrete classes, but real applications also need to register values, bind interfaces to implementations and build entries that need configuration. That is what `set()` is for. + +```php +public function set(string $id, mixed $concrete = null): void; +``` + +The container interprets the `$concrete` definition lazily when the entry is first requested: + +| `$concrete` is… | Behaviour on `get()` | +| --- | --- | +| `null` (omitted) | The identifier is used as the definition. | +| a `Closure` | The closure is invoked with the container; its return value is cached. | +| an existing class name (string) | The class is autowired. | +| any other value (object, scalar, array, non-class string) | The value is returned as is. | + +## Storing values + +```php +use InitPHP\Container\Container; + +$container = new Container(); + +$container->set('app.name', 'InitPHP'); +$container->set('app.debug', true); +$container->set('app.paths', ['cache' => '/tmp/cache']); + +$container->get('app.name'); // 'InitPHP' +$container->get('app.debug'); // true +$container->get('app.paths'); // ['cache' => '/tmp/cache'] +``` + +Objects are stored and returned unchanged: + +```php +$logger = new FileLogger('/var/log/app.log'); +$container->set('logger', $logger); + +$container->get('logger') === $logger; // true +``` + +## Binding a class name + +Register a class so it is built on first use. Passing only the identifier uses it as its own definition: + +```php +$container->set(App\Service::class); +$container->get(App\Service::class); // autowired App\Service instance +``` + +You rarely need to do this for plain classes — autowiring already handles them. It matters when you want to alias one identifier to another class. + +## Binding interfaces to implementations + +Type-hinting an interface is the idiomatic way to depend on abstractions. Bind the interface to a concrete class so both direct lookups and autowired dependencies resolve to it: + +```php +interface CacheInterface +{ +} + +class RedisCache implements CacheInterface +{ +} + +class PageRenderer +{ + public function __construct(public CacheInterface $cache) + { + } +} + +$container->set(CacheInterface::class, RedisCache::class); + +$container->get(CacheInterface::class); // RedisCache instance +$container->get(PageRenderer::class)->cache; // the same RedisCache instance +``` + +Without the binding, `get(PageRenderer::class)` would fail because the container cannot build an interface on its own. + +## Factories (closures) + +When an entry needs constructor arguments the container cannot guess — a DSN, an API key, a file path — register a closure. It receives the container and runs lazily, only on the first `get()`: + +```php +use Psr\Container\ContainerInterface; + +$container->set('pdo', function (ContainerInterface $c) { + return new PDO('mysql:host=localhost;dbname=app', 'user', 'secret'); +}); + +$pdo = $container->get('pdo'); // closure runs here +$container->get('pdo') === $pdo; // true — the result is cached +``` + +The container is passed in so a factory can pull other entries: + +```php +$container->set('config', ['dsn' => 'sqlite::memory:']); + +$container->set('pdo', function (ContainerInterface $c) { + $config = $c->get('config'); + return new PDO($config['dsn']); +}); +``` + +### Factories run once + +The closure's return value is cached just like any other entry. If you need it to run on every call, that is outside this container's model — build the object directly where you need it. + +## Re-registering an entry + +Calling `set()` again replaces the definition and discards the cached instance, so the next `get()` rebuilds it: + +```php +$container->set('mode', 'production'); +$container->get('mode'); // 'production' + +$container->set('mode', 'testing'); +$container->get('mode'); // 'testing' +``` diff --git a/docs/exceptions.md b/docs/exceptions.md new file mode 100644 index 0000000..4993f35 --- /dev/null +++ b/docs/exceptions.md @@ -0,0 +1,144 @@ +# Exceptions & Error Handling + +All container exceptions live in the `InitPHP\Container\Exception` namespace and implement a PSR-11 interface, so you can also catch them by the standard `Psr\Container\ContainerExceptionInterface` / `NotFoundExceptionInterface` contracts. + +## Hierarchy + +``` +Psr\Container\ContainerExceptionInterface +└── InitPHP\Container\Exception\ContainerException + ├── NotFoundException (also implements NotFoundExceptionInterface) + ├── DependencyIsNotInstantiableException + ├── DependencyHasNoDefaultValueException + └── CircularDependencyException +``` + +`ContainerException` is the common base, so a single catch handles every container error: + +```php +use InitPHP\Container\Exception\ContainerException; + +try { + $service = $container->get(App\Service::class); +} catch (ContainerException $e) { + // any container failure +} +``` + +To distinguish a missing entry specifically, catch `NotFoundException` (or the PSR interface) first. + +## `NotFoundException` + +Thrown by `get()` when the identifier is neither a registered entry nor an existing class name. Implements `Psr\Container\NotFoundExceptionInterface`. + +```php +use InitPHP\Container\Exception\NotFoundException; + +try { + $container->get('does-not-exist'); +} catch (NotFoundException $e) { + echo $e->getMessage(); // No entry was found for identifier "does-not-exist". +} +``` + +This is also thrown when you request an unbound interface directly, because `class_exists()` does not report interfaces: + +```php +interface PaymentGatewayInterface +{ +} + +$container->get(PaymentGatewayInterface::class); // NotFoundException +``` + +## `DependencyIsNotInstantiableException` + +Thrown when the container reaches a class it cannot instantiate: an abstract class or a class with a non-public constructor. + +```php +use InitPHP\Container\Exception\DependencyIsNotInstantiableException; + +abstract class AbstractRepository +{ +} + +try { + $container->get(AbstractRepository::class); +} catch (DependencyIsNotInstantiableException $e) { + echo $e->getMessage(); // Class "AbstractRepository" is not instantiable. +} +``` + +## `DependencyHasNoDefaultValueException` + +Thrown when a constructor parameter cannot be autowired and has neither a default value nor a nullable type to fall back on. Typical cases: a scalar without a default, or an unbound interface dependency. + +```php +use InitPHP\Container\Exception\DependencyHasNoDefaultValueException; + +class Connection +{ + public function __construct(public string $dsn) + { + } +} + +try { + $container->get(Connection::class); +} catch (DependencyHasNoDefaultValueException $e) { + echo $e->getMessage(); // Unable to resolve the value of parameter "$dsn". +} +``` + +The fix is to give the container what it needs — register the value, provide a default, or bind the interface: + +```php +$container->set('connection', fn () => new Connection('sqlite::memory:')); +``` + +## `CircularDependencyException` + +Thrown when a class depends on itself, directly or through a chain, which would otherwise cause unbounded recursion. + +```php +use InitPHP\Container\Exception\CircularDependencyException; + +class A +{ + public function __construct(public B $b) + { + } +} + +class B +{ + public function __construct(public A $a) + { + } +} + +try { + $container->get(A::class); +} catch (CircularDependencyException $e) { + echo $e->getMessage(); // Circular dependency detected while resolving "...". +} +``` + +Break the cycle by introducing an interface and a factory, or by injecting one of the classes lazily after construction. + +## Catching by PSR interface + +Because the exceptions implement the PSR-11 interfaces, code that depends only on `psr/container` can catch them without referencing InitPHP types: + +```php +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; + +try { + $container->get($id); +} catch (NotFoundExceptionInterface $e) { + // unknown identifier +} catch (ContainerExceptionInterface $e) { + // any other resolution error +} +``` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..f82632f --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,98 @@ +# Getting Started + +InitPHP Container is a small [PSR-11](https://www.php-fig.org/psr/psr-11/) container. It stores entries you register and can also build classes for you automatically through reflection. This guide covers installation and the two methods that make up the public API: `get()` and `has()`, plus the `set()` registration method. + +## Installation + +```bash +composer require initphp/container +``` + +Requires PHP 8.1+ and `psr/container ^2.0` (pulled in automatically). + +## Creating a container + +```php +require_once 'vendor/autoload.php'; + +use InitPHP\Container\Container; + +$container = new Container(); +``` + +The container takes no constructor arguments and holds no global state. Create as many as you need. + +## The public API + +The container implements `Psr\Container\ContainerInterface`, which defines two methods: + +```php +public function get(string $id): mixed; +public function has(string $id): bool; +``` + +On top of those, the container adds one registration method: + +```php +public function set(string $id, mixed $concrete = null): void; +``` + +### `set()` — register an entry + +```php +// Store a value. +$container->set('app.name', 'InitPHP'); + +// Store an object. +$container->set('clock', new DateTimeImmutable()); + +// Register a class to be built on demand. +$container->set(App\Service::class); + +// Bind an identifier to a class name. +$container->set(App\LoggerInterface::class, App\FileLogger::class); + +// Register a lazy factory. +$container->set('pdo', fn () => new PDO('sqlite::memory:')); +``` + +When the second argument is omitted, the identifier itself becomes the definition. Calling `set()` again with the same identifier replaces the previous definition and clears any cached instance. + +### `get()` — retrieve an entry + +```php +$name = $container->get('app.name'); // 'InitPHP' +$service = $container->get(App\Service::class); +``` + +If the identifier was never registered but is an existing class name, the container autowires it. If it is neither, a `NotFoundException` is thrown. See [Autowiring](./autowiring.md). + +### `has()` — check for an entry + +```php +$container->has('app.name'); // true once registered +$container->has(App\Service::class); // true: it is an autowirable class +$container->has('not-registered'); // false +``` + +`has()` returns `true` whenever `get()` would not throw a `NotFoundException`. + +## Entries are cached + +Every entry the container resolves is cached and reused: + +```php +$a = $container->get(App\Service::class); +$b = $container->get(App\Service::class); + +$a === $b; // true +``` + +This makes the container behave as a registry of shared instances. If you need a fresh object every time, build it yourself inside a closure and create the instance there explicitly, or instantiate the class directly without the container. + +## Next steps + +- [Autowiring](./autowiring.md) — how constructor dependencies are resolved. +- [Binding & Factories](./binding-and-factories.md) — interfaces, closures and values. +- [Exceptions & Error Handling](./exceptions.md) — what can go wrong and why. +- [Limitations](./limitations.md) — what this container intentionally does not do. diff --git a/docs/limitations.md b/docs/limitations.md new file mode 100644 index 0000000..64f688b --- /dev/null +++ b/docs/limitations.md @@ -0,0 +1,63 @@ +# Limitations + +This container is deliberately minimal. Knowing what it does *not* do helps you decide when it is the right tool and when you should reach for a fuller-featured container. + +## Shared instances only + +Every resolved entry is cached and reused. There is no "transient" or "prototype" scope that returns a fresh object on each call. + +```php +$container->get(Service::class) === $container->get(Service::class); // always true +``` + +If you need a new instance every time, build it yourself — either `new Service(...)` directly, or call a factory you keep outside the container. + +## No contextual or tagged bindings + +You cannot say "inject implementation X here but implementation Y there", and there is no concept of tagging multiple services and retrieving them as a group. A given identifier resolves to exactly one definition. + +## Autowiring resolves single class types only + +Constructor autowiring works for parameters typed with a single class or interface name. It does **not** pick a type out of a union or intersection: + +```php +public function __construct(Cache|Store $backend) { /* not autowired */ } +``` + +Such a parameter falls back to its default value or `null`; otherwise resolution fails. Provide a factory for these cases. + +## Scalar dependencies are not guessed + +The container never invents scalar values. A constructor that needs a `string`, `int`, etc. without a default must be satisfied through a factory or by storing the value: + +```php +public function __construct(string $apiKey) { /* needs a factory */ } +``` + +## No attribute-based configuration + +Resolution is driven entirely by constructor type hints and the definitions you register. There is no support for PHP attributes, annotations, or configuration files to influence how a class is built. + +## No method or property injection + +Only constructor injection is performed. Setter methods and properties are never populated by the container. + +## `has()` and autowiring + +`has($id)` returns `true` for any existing class name, because such a class is potentially autowirable. It does **not** prove that resolution will succeed — building the class may still fail (for example a constructor needs a scalar). `has()` only guarantees that `get()` will not throw a `NotFoundException`. + +```php +class NeedsApiKey +{ + public function __construct(string $apiKey) + { + } +} + +$container->has(NeedsApiKey::class); // true +$container->get(NeedsApiKey::class); // throws DependencyHasNoDefaultValueException +``` + +## When to use a larger container + +If you need scopes, tagging, contextual bindings, lazy proxies, compiled containers, or attribute configuration, consider a full-featured container such as PHP-DI or the Symfony DependencyInjection component. InitPHP Container aims to stay small and predictable for projects that only need PSR-11 lookups and straightforward autowiring. diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..b74b10f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: max + paths: + - src + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..d107c6b --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,19 @@ + + + + + tests + + + + + src + + + diff --git a/src/Container.php b/src/Container.php index 9b38c03..da1d837 100644 --- a/src/Container.php +++ b/src/Container.php @@ -1,4 +1,5 @@ * @copyright Copyright © 2022 InitPHP Container - * @license http://initphp.github.io/license.txt MIT - * @version 0.4 - * @link https://www.muhammetsafak.com.tr + * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT */ declare(strict_types=1); namespace InitPHP\Container; -use \InitPHP\Container\Exception\{DependencyHasNoDefaultValueException, DependencyIsNotInstantiable}; -use \Psr\Container\ContainerInterface; +use Closure; +use InitPHP\Container\Exception\CircularDependencyException; +use InitPHP\Container\Exception\DependencyHasNoDefaultValueException; +use InitPHP\Container\Exception\DependencyIsNotInstantiableException; +use InitPHP\Container\Exception\NotFoundException; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use ReflectionClass; +use ReflectionNamedType; +use ReflectionParameter; + +use function array_key_exists; +use function class_exists; +use function is_string; +/** + * A minimal PSR-11 container that resolves entries on demand. + * + * Entries may be registered explicitly with {@see Container::set()} or, when an + * identifier maps to an existing class name, resolved automatically through + * constructor autowiring. Every entry is built lazily on first access and the + * resulting value is cached, so repeated calls to {@see Container::get()} with + * the same identifier return the very same instance. + */ class Container implements ContainerInterface { + /** + * Registered definitions keyed by identifier. + * + * A definition is whatever was passed to {@see Container::set()}: a class + * name, a {@see Closure} factory, an already built object or any scalar. + * + * @var array + */ + protected array $definitions = []; - protected array $instance = []; - - public function set(string $id, $concrete = null) - { - if($concrete === null){ - $concrete = $id; - } + /** + * Fully resolved entries keyed by identifier, used as a build cache. + * + * @var array + */ + protected array $resolved = []; - if (is_string($concrete) && class_exists($concrete)) { - $concrete = $this->resolve($concrete); - } + /** + * Identifiers currently being resolved, used to detect circular graphs. + * + * @var array + */ + private array $building = []; - return $this->instance[$id] = $concrete; + /** + * Registers an entry on the container. + * + * The entry is stored as a definition and resolved lazily the first time it + * is requested. When `$concrete` is `null` the identifier itself is used as + * the definition, which is the common case for autowiring a class by its + * own name. Registering an identifier again replaces any previously cached + * instance. + * + * @param string $id Identifier of the entry. + * @param mixed $concrete A class name, a Closure factory invoked with the + * container, an object, or any value to store as is. + * Defaults to `$id` when omitted. + * @return void + */ + public function set(string $id, mixed $concrete = null): void + { + $this->definitions[$id] = $concrete ?? $id; + unset($this->resolved[$id]); } /** * @inheritDoc + * + * @return mixed + * @throws NotFoundException When no entry or class matches `$id`. + * @throws DependencyIsNotInstantiableException + * @throws DependencyHasNoDefaultValueException + * @throws CircularDependencyException */ - public function get(string $id) + public function get(string $id): mixed { - if(!$this->has($id)){ - $this->set($id); + if (array_key_exists($id, $this->resolved)) { + return $this->resolved[$id]; + } + + if (array_key_exists($id, $this->definitions)) { + return $this->resolved[$id] = $this->build($this->definitions[$id]); } - return $this->instance[$id]; + if (class_exists($id)) { + return $this->resolved[$id] = $this->resolve($id); + } + + throw new NotFoundException('No entry was found for identifier "' . $id . '".'); } /** * @inheritDoc + * + * Returns `true` when {@see Container::get()} would not throw a + * {@see NotFoundException} for the given identifier, i.e. the identifier is + * a registered entry or an existing, loadable class name. A `true` result + * does not guarantee that resolution succeeds; building the entry may still + * fail with another container exception. */ public function has(string $id): bool { - return isset($this->instance[$id]); + return array_key_exists($id, $this->resolved) + || array_key_exists($id, $this->definitions) + || class_exists($id); + } + + /** + * Turns a registered definition into a concrete value. + * + * @param mixed $definition + * @return mixed + * @throws DependencyIsNotInstantiableException + * @throws DependencyHasNoDefaultValueException + * @throws CircularDependencyException + * @throws NotFoundException + */ + private function build(mixed $definition): mixed + { + if ($definition instanceof Closure) { + return $definition($this); + } + + if (is_string($definition) && class_exists($definition)) { + return $this->resolve($definition); + } + + return $definition; } /** - * @param $concrete - * @return object|null + * Instantiates a class, recursively autowiring its constructor arguments. + * + * @param class-string $concrete + * @return object + * @throws DependencyIsNotInstantiableException * @throws DependencyHasNoDefaultValueException - * @throws DependencyIsNotInstantiable - * @throws \ReflectionException + * @throws CircularDependencyException + * @throws NotFoundException */ - private function resolve($concrete): ?object + private function resolve(string $concrete): object { - $reflection = new \ReflectionClass($concrete); - if(!$reflection->isInstantiable()){ - throw new DependencyIsNotInstantiable('Class "' . $concrete . '" is not instantiable.'); + if (isset($this->building[$concrete])) { + throw new CircularDependencyException( + 'Circular dependency detected while resolving "' . $concrete . '".' + ); + } + + $reflection = new ReflectionClass($concrete); + if (!$reflection->isInstantiable()) { + throw new DependencyIsNotInstantiableException('Class "' . $concrete . '" is not instantiable.'); } - if(($constructor = $reflection->getConstructor()) === null){ + $constructor = $reflection->getConstructor(); + if ($constructor === null) { return $reflection->newInstance(); } - $arguments = $constructor->getParameters(); - $dependencies = $this->getDependencies($arguments, $reflection); + $this->building[$concrete] = true; + try { + $dependencies = $this->getDependencies($constructor->getParameters()); + } finally { + unset($this->building[$concrete]); + } + return $reflection->newInstanceArgs($dependencies); } /** - * @param \ReflectionParameter[] $arguments - * @param \ReflectionClass $reflection - * @return array + * Resolves the argument list for a constructor. + * + * Each parameter is resolved in the following order: a class-typed + * parameter is autowired from the container; otherwise a default value is + * used when available; otherwise `null` is supplied for nullable + * parameters. When none of these apply the parameter cannot be resolved. + * + * @param ReflectionParameter[] $parameters + * @return list * @throws DependencyHasNoDefaultValueException + * @throws DependencyIsNotInstantiableException + * @throws CircularDependencyException + * @throws NotFoundException */ - private function getDependencies(array $arguments, \ReflectionClass $reflection): array + private function getDependencies(array $parameters): array { $dependencies = []; - foreach ($arguments as $argument) { - if($argument->isDefaultValueAvailable()){ - $dependencies[] = $argument->getDefaultValue(); - continue; - } - if($argument->hasType()){ - if (($type = $argument->getType()) !== null) { - if(!$type->isBuiltin()){ - $dependencies[] = $this->get($type->getName()); - continue; - } - } - } - if($argument->allowsNull()){ - $dependencies[] = null; - continue; - } - throw new DependencyHasNoDefaultValueException('Sorry cannot resolve class dependency ' . $argument->name); + foreach ($parameters as $parameter) { + $dependencies[] = $this->resolveParameter($parameter); } + return $dependencies; } + /** + * Resolves a single constructor parameter to a concrete value. + * + * A class-typed parameter the container knows about is autowired. If that + * build fails but the parameter is optional (it has a default value or is + * nullable), the optional fallback is used instead of propagating the + * error; otherwise the failure is re-thrown. + * + * @param ReflectionParameter $parameter + * @return mixed + * @throws DependencyHasNoDefaultValueException + * @throws DependencyIsNotInstantiableException + * @throws CircularDependencyException + * @throws NotFoundException + */ + private function resolveParameter(ReflectionParameter $parameter): mixed + { + $type = $parameter->getType(); + + if ($type instanceof ReflectionNamedType && !$type->isBuiltin() && $this->has($type->getName())) { + try { + return $this->get($type->getName()); + } catch (ContainerExceptionInterface $e) { + if (!$parameter->isDefaultValueAvailable() && !$parameter->allowsNull()) { + throw $e; + } + } + } + + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + if ($parameter->allowsNull()) { + return null; + } + + throw new DependencyHasNoDefaultValueException( + 'Unable to resolve the value of parameter "$' . $parameter->getName() . '".' + ); + } } diff --git a/src/Exception/CircularDependencyException.php b/src/Exception/CircularDependencyException.php new file mode 100644 index 0000000..32a8076 --- /dev/null +++ b/src/Exception/CircularDependencyException.php @@ -0,0 +1,23 @@ + + * @copyright Copyright © 2022 InitPHP Container + * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT + */ + +declare(strict_types=1); + +namespace InitPHP\Container\Exception; + +/** + * Thrown when a class depends on itself either directly or through a chain of + * other classes, which would otherwise cause unbounded recursion. + */ +class CircularDependencyException extends ContainerException +{ +} diff --git a/src/Exception/ContainerException.php b/src/Exception/ContainerException.php new file mode 100644 index 0000000..6c70390 --- /dev/null +++ b/src/Exception/ContainerException.php @@ -0,0 +1,26 @@ + + * @copyright Copyright © 2022 InitPHP Container + * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT + */ + +declare(strict_types=1); + +namespace InitPHP\Container\Exception; + +use Exception; +use Psr\Container\ContainerExceptionInterface; + +/** + * Base exception for every error raised while the container is building or + * retrieving an entry. Specific failure modes extend this class. + */ +class ContainerException extends Exception implements ContainerExceptionInterface +{ +} diff --git a/src/Exception/DependencyHasNoDefaultValueException.php b/src/Exception/DependencyHasNoDefaultValueException.php index 4f9f3bb..8fc14c7 100644 --- a/src/Exception/DependencyHasNoDefaultValueException.php +++ b/src/Exception/DependencyHasNoDefaultValueException.php @@ -1,4 +1,5 @@ * @copyright Copyright © 2022 InitPHP Container - * @license http://initphp.github.io/license.txt MIT - * @version pre-0.1 - * @link https://www.muhammetsafak.com.tr + * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT */ declare(strict_types=1); namespace InitPHP\Container\Exception; -use \Psr\Container\NotFoundExceptionInterface; - -class DependencyHasNoDefaultValueException extends \Exception implements NotFoundExceptionInterface +/** + * Thrown when a constructor parameter cannot be autowired and has neither a + * default value nor a nullable type to fall back on. + */ +class DependencyHasNoDefaultValueException extends ContainerException { } diff --git a/src/Exception/DependencyIsNotInstantiable.php b/src/Exception/DependencyIsNotInstantiable.php deleted file mode 100644 index 944bd26..0000000 --- a/src/Exception/DependencyIsNotInstantiable.php +++ /dev/null @@ -1,22 +0,0 @@ - - * @copyright Copyright © 2022 InitPHP Container - * @license http://initphp.github.io/license.txt MIT - * @version pre-0.1 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\Container\Exception; - -use \Psr\Container\NotFoundExceptionInterface; - -class DependencyIsNotInstantiable extends \Exception implements NotFoundExceptionInterface -{ -} diff --git a/src/Exception/DependencyIsNotInstantiableException.php b/src/Exception/DependencyIsNotInstantiableException.php new file mode 100644 index 0000000..e8f7a29 --- /dev/null +++ b/src/Exception/DependencyIsNotInstantiableException.php @@ -0,0 +1,24 @@ + + * @copyright Copyright © 2022 InitPHP Container + * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT + */ + +declare(strict_types=1); + +namespace InitPHP\Container\Exception; + +/** + * Thrown when the container is asked to build a class that cannot be + * instantiated, such as an interface, an abstract class or a class with a + * non-public constructor. + */ +class DependencyIsNotInstantiableException extends ContainerException +{ +} diff --git a/src/Exception/NotFoundException.php b/src/Exception/NotFoundException.php new file mode 100644 index 0000000..0029f01 --- /dev/null +++ b/src/Exception/NotFoundException.php @@ -0,0 +1,25 @@ + + * @copyright Copyright © 2022 InitPHP Container + * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT + */ + +declare(strict_types=1); + +namespace InitPHP\Container\Exception; + +use Psr\Container\NotFoundExceptionInterface; + +/** + * Thrown by {@see \InitPHP\Container\Container::get()} when the requested + * identifier is neither a registered entry nor an autowirable class. + */ +class NotFoundException extends ContainerException implements NotFoundExceptionInterface +{ +} diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php new file mode 100644 index 0000000..fa1af1b --- /dev/null +++ b/tests/ContainerTest.php @@ -0,0 +1,277 @@ + + * @copyright Copyright © 2022 InitPHP Container + * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT + */ + +declare(strict_types=1); + +namespace InitPHP\Container\Tests; + +use InitPHP\Container\Container; +use InitPHP\Container\Exception\CircularDependencyException; +use InitPHP\Container\Exception\ContainerException; +use InitPHP\Container\Exception\DependencyHasNoDefaultValueException; +use InitPHP\Container\Exception\DependencyIsNotInstantiableException; +use InitPHP\Container\Exception\NotFoundException; +use InitPHP\Container\Tests\Fixtures\AbstractClass; +use InitPHP\Container\Tests\Fixtures\CircularA; +use InitPHP\Container\Tests\Fixtures\NeedsInterface; +use InitPHP\Container\Tests\Fixtures\NeedsNoConstructor; +use InitPHP\Container\Tests\Fixtures\NoConstructor; +use InitPHP\Container\Tests\Fixtures\NullableBuiltinWithoutDefault; +use InitPHP\Container\Tests\Fixtures\NullableDependency; +use InitPHP\Container\Tests\Fixtures\OptionalInterface; +use InitPHP\Container\Tests\Fixtures\OptionalUnbuildableDependency; +use InitPHP\Container\Tests\Fixtures\PrivateConstructor; +use InitPHP\Container\Tests\Fixtures\RequiredUnbuildableDependency; +use InitPHP\Container\Tests\Fixtures\ScalarWithDefault; +use InitPHP\Container\Tests\Fixtures\ScalarWithoutDefault; +use InitPHP\Container\Tests\Fixtures\SelfReferencing; +use InitPHP\Container\Tests\Fixtures\ServiceImplementation; +use InitPHP\Container\Tests\Fixtures\ServiceInterface; +use InitPHP\Container\Tests\Fixtures\UnboundInterface; +use InitPHP\Container\Tests\Fixtures\UnionTypeWithDefault; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use stdClass; + +final class ContainerTest extends TestCase +{ + private Container $container; + + protected function setUp(): void + { + $this->container = new Container(); + } + + public function testImplementsPsrContainerInterface(): void + { + $this->assertInstanceOf(ContainerInterface::class, $this->container); + } + + public function testHasReturnsFalseForUnknownNonClassIdentifier(): void + { + $this->assertFalse($this->container->has('unknown-service')); + } + + public function testHasReturnsTrueForExistingClassName(): void + { + $this->assertTrue($this->container->has(NoConstructor::class)); + } + + public function testHasReturnsTrueAfterSet(): void + { + $this->container->set('config', ['debug' => true]); + $this->assertTrue($this->container->has('config')); + } + + public function testGetThrowsNotFoundForUnknownNonClassIdentifier(): void + { + $this->expectException(NotFoundException::class); + $this->container->get('unknown-service'); + } + + public function testGetStoredScalarValue(): void + { + $this->container->set('answer', 42); + $this->assertSame(42, $this->container->get('answer')); + } + + public function testGetStoredArrayValue(): void + { + $config = ['debug' => true]; + $this->container->set('config', $config); + $this->assertSame($config, $this->container->get('config')); + } + + public function testGetStoredNonClassStringIsReturnedVerbatim(): void + { + $this->container->set('greeting', 'hello'); + $this->assertSame('hello', $this->container->get('greeting')); + } + + public function testSetWithExistingObjectReturnsSameInstance(): void + { + $object = new stdClass(); + $this->container->set('obj', $object); + $this->assertSame($object, $this->container->get('obj')); + } + + public function testAutowireClassWithoutRegistration(): void + { + $instance = $this->container->get(NoConstructor::class); + $this->assertInstanceOf(NoConstructor::class, $instance); + } + + public function testResolvedEntriesAreCachedAsSingletons(): void + { + $first = $this->container->get(NoConstructor::class); + $second = $this->container->get(NoConstructor::class); + $this->assertSame($first, $second); + } + + public function testAutowireResolvesClassDependency(): void + { + $instance = $this->container->get(NeedsNoConstructor::class); + $this->assertInstanceOf(NeedsNoConstructor::class, $instance); + $this->assertInstanceOf(NoConstructor::class, $instance->dependency); + } + + public function testNestedDependencyIsTheSharedInstance(): void + { + $dependency = $this->container->get(NoConstructor::class); + $consumer = $this->container->get(NeedsNoConstructor::class); + $this->assertSame($dependency, $consumer->dependency); + } + + public function testScalarParameterUsesDefaultValue(): void + { + $instance = $this->container->get(ScalarWithDefault::class); + $this->assertSame('default', $instance->name); + } + + public function testScalarParameterWithoutDefaultThrows(): void + { + $this->expectException(DependencyHasNoDefaultValueException::class); + $this->container->get(ScalarWithoutDefault::class); + } + + public function testNullableDependencyResolvesToInstanceWhenAvailable(): void + { + $instance = $this->container->get(NullableDependency::class); + $this->assertInstanceOf(NoConstructor::class, $instance->dependency); + } + + public function testNullableBuiltinWithoutDefaultResolvesToNull(): void + { + $instance = $this->container->get(NullableBuiltinWithoutDefault::class); + $this->assertNull($instance->value); + } + + public function testUnionTypedParameterUsesDefaultValue(): void + { + $instance = $this->container->get(UnionTypeWithDefault::class); + $this->assertSame(1, $instance->value); + } + + public function testBoundInterfaceIsResolvedForDependency(): void + { + $this->container->set(ServiceInterface::class, ServiceImplementation::class); + $instance = $this->container->get(NeedsInterface::class); + $this->assertInstanceOf(ServiceImplementation::class, $instance->service); + } + + public function testGetBoundInterfaceReturnsImplementation(): void + { + $this->container->set(ServiceInterface::class, ServiceImplementation::class); + $this->assertInstanceOf(ServiceImplementation::class, $this->container->get(ServiceInterface::class)); + } + + public function testUnboundInterfaceDependencyWithNullDefaultResolvesToNull(): void + { + $instance = $this->container->get(OptionalInterface::class); + $this->assertNull($instance->service); + } + + public function testUnboundInterfaceDependencyWithoutFallbackThrows(): void + { + // An interface dependency with neither a binding, a default value nor a + // nullable type cannot be resolved; the failure points at the parameter. + $this->expectException(DependencyHasNoDefaultValueException::class); + $this->container->get(NeedsInterface::class); + } + + public function testGetUnboundInterfaceThrowsNotFound(): void + { + $this->expectException(NotFoundException::class); + $this->container->get(UnboundInterface::class); + } + + public function testAbstractClassIsNotInstantiable(): void + { + $this->expectException(DependencyIsNotInstantiableException::class); + $this->container->get(AbstractClass::class); + } + + public function testPrivateConstructorIsNotInstantiable(): void + { + $this->expectException(DependencyIsNotInstantiableException::class); + $this->container->get(PrivateConstructor::class); + } + + public function testCircularDependencyIsDetected(): void + { + $this->expectException(CircularDependencyException::class); + $this->container->get(CircularA::class); + } + + public function testSelfReferencingDependencyIsDetected(): void + { + $this->expectException(CircularDependencyException::class); + $this->container->get(SelfReferencing::class); + } + + public function testOptionalDependencyFallsBackToNullWhenClassCannotBeBuilt(): void + { + // ScalarWithoutDefault cannot be autowired (needs a string), but the + // parameter is nullable with a default, so the container falls back. + $instance = $this->container->get(OptionalUnbuildableDependency::class); + $this->assertNull($instance->dependency); + } + + public function testRequiredDependencyRethrowsWhenClassCannotBeBuilt(): void + { + $this->expectException(DependencyHasNoDefaultValueException::class); + $this->container->get(RequiredUnbuildableDependency::class); + } + + public function testClosureFactoryIsInvokedLazily(): void + { + $calls = 0; + $this->container->set('service', function () use (&$calls) { + $calls++; + return new stdClass(); + }); + + $this->assertSame(0, $calls, 'Factory must not run before the entry is requested.'); + $this->container->get('service'); + $this->container->get('service'); + $this->assertSame(1, $calls, 'Factory result must be cached after the first call.'); + } + + public function testClosureFactoryReceivesContainer(): void + { + $this->container->set('inner', new stdClass()); + $this->container->set('outer', fn (ContainerInterface $c) => $c->get('inner')); + + $this->assertSame($this->container->get('inner'), $this->container->get('outer')); + } + + public function testSetWithoutConcreteUsesIdentifierAsDefinition(): void + { + $this->container->set(NoConstructor::class); + $this->assertInstanceOf(NoConstructor::class, $this->container->get(NoConstructor::class)); + } + + public function testReSettingReplacesCachedInstance(): void + { + $this->container->set('value', 'first'); + $this->assertSame('first', $this->container->get('value')); + + $this->container->set('value', 'second'); + $this->assertSame('second', $this->container->get('value')); + } + + public function testNotFoundExceptionIsAContainerException(): void + { + $this->expectException(ContainerException::class); + $this->container->get('unknown-service'); + } +} diff --git a/tests/Fixtures/fixtures.php b/tests/Fixtures/fixtures.php new file mode 100644 index 0000000..ec53169 --- /dev/null +++ b/tests/Fixtures/fixtures.php @@ -0,0 +1,153 @@ + + * @copyright Copyright © 2022 InitPHP Container + * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT + */ + +declare(strict_types=1); + +namespace InitPHP\Container\Tests\Fixtures; + +/** A plain class with no constructor. */ +class NoConstructor +{ +} + +/** A class whose only constructor argument is another resolvable class. */ +class NeedsNoConstructor +{ + public function __construct(public NoConstructor $dependency) + { + } +} + +/** A class with a scalar constructor argument that has a default value. */ +class ScalarWithDefault +{ + public function __construct(public string $name = 'default') + { + } +} + +/** A class with a scalar constructor argument that has no default value. */ +class ScalarWithoutDefault +{ + public function __construct(public string $name) + { + } +} + +/** A class with a nullable, untyped-default constructor argument. */ +class NullableDependency +{ + public function __construct(public ?NoConstructor $dependency) + { + } +} + +/** A class with a nullable, builtin-typed argument that has no default. */ +class NullableBuiltinWithoutDefault +{ + public function __construct(public ?int $value) + { + } +} + +/** An interface that is never bound to a concrete implementation. */ +interface UnboundInterface +{ +} + +/** A concrete implementation of {@see ServiceInterface}. */ +interface ServiceInterface +{ +} + +class ServiceImplementation implements ServiceInterface +{ +} + +/** A class that type-hints an interface argument. */ +class NeedsInterface +{ + public function __construct(public ServiceInterface $service) + { + } +} + +/** A class that type-hints an interface argument with a null default. */ +class OptionalInterface +{ + public function __construct(public ?ServiceInterface $service = null) + { + } +} + +/** An abstract class, which cannot be instantiated. */ +abstract class AbstractClass +{ +} + +/** A class with a private constructor, which cannot be instantiated. */ +class PrivateConstructor +{ + private function __construct() + { + } +} + +/** A class with a PHP 8 union-typed constructor argument and a default. */ +class UnionTypeWithDefault +{ + public function __construct(public int|string $value = 1) + { + } +} + +/** Optional dependency on a class that cannot itself be built. */ +class OptionalUnbuildableDependency +{ + public function __construct(public ?ScalarWithoutDefault $dependency = null) + { + } +} + +/** Required dependency on a class that cannot itself be built. */ +class RequiredUnbuildableDependency +{ + public function __construct(public ScalarWithoutDefault $dependency) + { + } +} + +/** Two classes that depend on each other to form a cycle. */ +class CircularA +{ + public function __construct(public CircularB $b) + { + } +} + +class CircularB +{ + public function __construct(public CircularA $a) + { + } +} + +/** A class that depends on itself directly. */ +class SelfReferencing +{ + public function __construct(public SelfReferencing $self) + { + } +}