diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 97589f2..c8de574 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -15,14 +15,13 @@ jobs: - ubuntu-latest php: - - "8.2" - "8.3" - "8.4" - "8.5" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install PHP uses: shivammathur/setup-php@v2 @@ -36,7 +35,7 @@ jobs: run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV - name: Cache dependencies installed with composer - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ env.COMPOSER_CACHE_DIR }} key: php${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 9976515..9f01888 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -15,14 +15,13 @@ jobs: - ubuntu-latest php: - - "8.2" - "8.3" - "8.4" - "8.5" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install PHP uses: shivammathur/setup-php@v2 @@ -36,7 +35,7 @@ jobs: run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV - name: Cache dependencies installed with composer - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ env.COMPOSER_CACHE_DIR }} key: php${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} diff --git a/README.md b/README.md index 2a7cd92..e265d36 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,27 @@ `dot-dependency-injection` is Dotkernel's dependency injection service. -By providing reusable factories for service and repository injection, it reduces code complexity in projects. +Instead of a hand-written factory class per service, you declare a class's dependencies with the `#[Inject]` attribute on its constructor - or a repository's entity with `#[Entity]` - and register one of the two reusable factories this package ships. +That removes an entire category of boilerplate files from a project and keeps the dependency list next to the constructor it feeds. + +See [Attributes vs. factories](https://docs.dotkernel.org/dot-dependency-injection/v1/attributes-vs-factories/) for the full comparison and the trade-offs. ## Documentation -Documentation is available at: https://docs.dotkernel.org/dot-dependency-injection/. +Documentation is available at: . + +- [Installation](https://docs.dotkernel.org/dot-dependency-injection/v1/installation/) +- [Configuration](https://docs.dotkernel.org/dot-dependency-injection/v1/configuration/) +- [Attributes vs. factories](https://docs.dotkernel.org/dot-dependency-injection/v1/attributes-vs-factories/) +- [Factories](https://docs.dotkernel.org/dot-dependency-injection/v1/factories/) +- [Inject class dependencies](https://docs.dotkernel.org/dot-dependency-injection/v1/factories/service/) +- [Inject entity repositories](https://docs.dotkernel.org/dot-dependency-injection/v1/factories/repository/) +- [FAQ](https://docs.dotkernel.org/dot-dependency-injection/v1/faq/) ## Badges ![OSS Lifecycle](https://img.shields.io/osslifecycle/dotkernel/dot-dependency-injection) -![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-dependency-injection/1.3.0) +![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-dependency-injection/1.4.0) [![GitHub issues](https://img.shields.io/github/issues/dotkernel/dot-dependency-injection)](https://github.com/dotkernel/dot-dependency-injection/issues) [![GitHub forks](https://img.shields.io/github/forks/dotkernel/dot-dependency-injection)](https://github.com/dotkernel/dot-dependency-injection/network) @@ -23,6 +34,12 @@ Documentation is available at: https://docs.dotkernel.org/dot-dependency-injecti [![docs-build](https://github.com/dotkernel/dot-dependency-injection/actions/workflows/docs-build.yml/badge.svg)](https://github.com/dotkernel/dot-dependency-injection/actions/workflows/docs-build.yml) [![PHPStan](https://github.com/dotkernel/dot-dependency-injection/actions/workflows/static-analysis.yml/badge.svg?branch=1.0)](https://github.com/dotkernel/dot-dependency-injection/actions/workflows/static-analysis.yml) +## Requirements + +- PHP 8.3, 8.4 or 8.5 +- a PSR-11 container, usually `laminas/laminas-servicemanager` +- `doctrine/orm` ^2.9 || ^3.0, if you use `AttributedRepositoryFactory` + ## Installation Install `dot-dependency-injection` by running the following command in your project directory: @@ -44,6 +61,8 @@ Dot\DependencyInjection\ConfigProvider::class, You can register services in the service manager using `AttributedServiceFactory` as seen in the below example: ```php +use Dot\DependencyInjection\Factory\AttributedServiceFactory; + return [ 'factories' => [ ServiceClass::class => AttributedServiceFactory::class, @@ -56,39 +75,49 @@ return [ The next step is to add the `#[Inject]` attribute to the service constructor with the service FQCNs to inject: ```php -#[\Dot\DependencyInjection\Attribute\Inject( - App\Srevice\Dependency1::class, - App\Srevice\Dependency2::class, - "config", +use App\Service\Dependency1; +use App\Service\Dependency2; +use Dot\DependencyInjection\Attribute\Inject; + +#[Inject( + Dependency1::class, + Dependency2::class, + 'config', )] public function __construct( - protected App\Srevice\Dependency1 $dep1, - protected App\Srevice\Dependency2 $dep2, - protected array $config + protected Dependency1 $dep1, + protected Dependency2 $dep2, + protected array $config, ) { } ``` -The `#[Inject]` attribute is telling `AttributedServiceFactory` to inject the services specified as parameters. +The `#[Inject]` attribute is telling `AttributedServiceFactory` to inject the services specified as parameters, in the same order as the constructor parameters. Valid service names should be provided, as registered in the service manager. +A name that is not registered in the container, but is an existing class, is instantiated directly. + +A class without a constructor does not need the attribute. To inject an array value from the service manager, you can use dot notation as below ```php -#[\Dot\DependencyInjection\Attribute\Inject( - "config.debug", +#[Inject( + 'config.debug', )] ``` which will inject `$container->get('config')['debug'];`. > Even if using dot notation, `AttributedServiceFactory` will check first if a service name exists with that name. +> Only the segment before the first dot is resolved from the container; the rest are array keys, and arrays as well as `ArrayAccess` objects can be traversed. ### Using the AttributedRepositoryFactory You can register doctrine repositories and inject them using the `AttributedRepositoryFactory` as below: ```php +use Dot\DependencyInjection\Factory\AttributedRepositoryFactory; + return [ 'factories' => [ ExampleRepository::class => AttributedRepositoryFactory::class, @@ -103,7 +132,7 @@ The `name` field has to be the fully qualified class name. Every repository should extend `Doctrine\ORM\EntityRepository`. ```php -use Api\App\Entity\Example; +use App\Entity\Example; use Doctrine\ORM\EntityRepository; use Dot\DependencyInjection\Attribute\Entity; @@ -113,6 +142,24 @@ class ExampleRepository extends EntityRepository } ``` -> Dependencies injected via the`#[Entity]`/`#[Inject]` attributes are not cached +Because Doctrine builds the repository from the entity's mapping, the entity must point back to the repository: + +```php +#[ORM\Entity(repositoryClass: ExampleRepository::class)] +class Example +{ +} +``` + +> Dependencies injected via the `#[Entity]`/`#[Inject]` attributes are not cached. +> Injecting dependencies into property setters is not supported. + +## Quality assurance + +Run the full suite - coding standard, tests and static analysis: + +```shell +composer check +``` -> Injecting dependencies into property setters is not supported +Individual targets: `composer cs-check`, `composer cs-fix`, `composer test`, `composer static-analysis`. diff --git a/SECURITY.md b/SECURITY.md index 15fa2fa..1ecc7e6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,15 +2,14 @@ ## Supported Versions -| Version | Supported | PHP Version | -|---------|--------------------|--------------------------------------------------------------------------------------------------------------------------| -| 1.x | :white_check_mark: | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-dependency-injection/1.3.0) | +| Version | Supported | PHP Version | +| --- | --- | --- | +| 1.x | :white_check_mark: | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-dependency-injection/1.4.0) | ## Reporting Potential Security Issues -If you have encountered a potential security vulnerability in this project, -please report it to us at . We will work with you to -verify the vulnerability and patch it. +If you have encountered a potential security vulnerability in this project, please report it to us at . +We will work with you to verify the vulnerability and patch it. When reporting issues, please provide the following information: @@ -18,19 +17,11 @@ When reporting issues, please provide the following information: - A description indicating how to reproduce the issue - A summary of the security vulnerability and impact -We request that you contact us via the email address above and give the -project contributors a chance to resolve the vulnerability and issue a new -release prior to any public exposure; this helps protect the project's -users, and provides them with a chance to upgrade and/or update in order to -protect their applications. +We request that you contact us via the email address above and give the project contributors a chance to resolve the vulnerability and issue a new release prior to any public exposure; this helps protect the project's users, and provides them with a chance to upgrade and/or update in order to protect their applications. ## Policy If we verify a reported security vulnerability, our policy is: -- We will patch the current release branch, as well as the immediate prior minor - release branch. - -- After patching the release branches, we will immediately issue new security - fix releases for each patched release branch. - +- We will patch the current release branch, as well as the immediate prior minor release branch. +- After patching the release branches, we will immediately issue new security fix releases for each patched release branch. diff --git a/composer.json b/composer.json index 0aa14e9..85c8e44 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "service" ], "require": { - "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "php": "~8.3.0 || ~8.4.0 || ~8.5.0", "doctrine/orm": "^2.9 || ^3.0", "psr/container": "^1.0 || ^2.0" }, diff --git a/docs/book/v1/attributes-vs-factories.md b/docs/book/v1/attributes-vs-factories.md new file mode 100644 index 0000000..1b4a976 --- /dev/null +++ b/docs/book/v1/attributes-vs-factories.md @@ -0,0 +1,155 @@ +# Attributes vs. factories + +A PSR-11 container needs to know how to build every service. +The usual answer is a factory class per service, which means that a project ends up carrying one extra file for every class it wires up. +`dot-dependency-injection` replaces those files with a single attribute on the constructor and two reusable factories. + +## The same service, both ways + +### With a hand-written factory + +Two files, plus the registration. + +```php +get(UserRepository::class), + $container->get(UrlHelper::class), + $container->get('config')['user'], + ); + } +} +``` + +```php +'factories' => [ + UserService::class => UserServiceFactory::class, +], +``` + +### With `#[Inject]` + +One file, and the registration. + +```php + [ + UserService::class => AttributedServiceFactory::class, +], +``` + +## Why this is an advantage + +**Fewer files, less code.** +A module with 20 services no longer needs 20 factory classes. +The wiring shrinks from a class per service to four lines inside the class that already exists. + +**One source of truth.** +The dependency list sits directly above the constructor it feeds. +Adding a constructor parameter and forgetting to update the factory is the single most common wiring bug in a factory-based project, and it cannot happen when both live on the same lines. + +**No drift, no duplication.** +Hand-written factories repeat the same `$container->get(...)` pattern hundreds of times across a project. +Every repetition is a place where a typo, a stale service name, or a copy-paste mistake can hide. + +**Nothing to test.** +A trivial factory is still code, so it shows up in coverage reports and either gets a test that asserts nothing meaningful or drags coverage down. +The two factories in this package are tested once, here. + +**Configuration without boilerplate.** +`'config.user'` replaces `$container->get('config')['user']` and fails with a clear, package-level exception naming the full path when that key is missing, instead of an `Undefined array key` notice or a silent `null`. + +**Repositories for free.** +`AttributedRepositoryFactory` removes an entire category of factories: every Doctrine repository in a project is otherwise a near-identical factory calling `$container->get(EntityManagerInterface::class)->getRepository(...)`. + +**Consistent failures.** +All wiring errors surface as `Dot\DependencyInjection\Exception\ExceptionInterface`, with messages that name the class, the attribute and the factory involved, rather than whatever each hand-written factory happened to do. + +**Readable classes.** +Anyone opening the class sees what it needs and where each dependency comes from without opening a second file. + +## What you give up + +Being explicit about the trade-offs matters more than the line count. + +| | Hand-written factory | `#[Inject]` | +| --- | --- | --- | +| Wiring is checked by the type system | yes, `new UserService(...)` is analysed | no, names are strings resolved at runtime | +| Order of arguments verified statically | yes | no, the attribute order must match the constructor | +| Reflection at build time | none | one `ReflectionClass` per service creation, not cached | +| Conditional or computed wiring | anything PHP allows | not supported | +| Works on third-party classes | yes | only on classes you can annotate | + +## When to keep writing a factory + +`#[Inject]` covers the common case: a constructor that receives services and configuration. +Write a factory by hand when you need something else. + +- the arguments are computed, conditional, or come from something other than the container +- the service is decorated, or built through a delegator or an abstract factory +- you are wiring a class you do not own and cannot annotate +- the class is intentionally constructed with runtime arguments rather than resolved from the container +- injection has to happen somewhere other than the constructor + +Both styles coexist without any friction: the factory is chosen per service in your `ConfigProvider`, so you can use `AttributedServiceFactory` for the bulk of a module and a hand-written factory for the few services that need one. diff --git a/docs/book/v1/configuration.md b/docs/book/v1/configuration.md index d4915b1..a9e596b 100644 --- a/docs/book/v1/configuration.md +++ b/docs/book/v1/configuration.md @@ -5,3 +5,15 @@ After installation, register `dot-dependency-injection` in your project by addin ```php Dot\DependencyInjection\ConfigProvider::class, ``` + +`Dot\DependencyInjection\ConfigProvider` currently returns an empty array: the package exposes factories that you reference from your own modules, so it does not need to register any service of its own. +Registering it keeps your configuration aggregate consistent with the other Dotkernel packages and makes sure that future configuration shipped by this package is picked up automatically. + +There is no package-specific configuration to set. +Everything else is declared where it belongs: + +- the dependencies of a class, through the `#[Inject]` attribute on its constructor +- the entity of a repository, through the `#[Entity]` attribute on the repository class +- the mapping between a class and the factory that builds it, under the `dependencies.factories` key of the `ConfigProvider` of your own module + +See [Inject class dependencies](factories/service.md) and [Inject entity repositories](factories/repository.md). diff --git a/docs/book/v1/factories.md b/docs/book/v1/factories.md index a4bac6f..66cb4b5 100644 --- a/docs/book/v1/factories.md +++ b/docs/book/v1/factories.md @@ -1,30 +1,82 @@ # Factories -`dot-dependency-injection` is based on two reusable factories - `AttributedRepositoryFactory` and `AttributedServiceFactory` - able to inject any dependency into a class. +`dot-dependency-injection` is based on two reusable factories - `AttributedServiceFactory` and `AttributedRepositoryFactory` - able to inject any dependency into a class. -## AttributedRepositoryFactory +Both are plain invokable factories, so they work with any PSR-11 container that passes the requested service name to the factory (for example `laminas/laminas-servicemanager`): -Injects entity repositories into a class. +```php +$factory = new Dot\DependencyInjection\Factory\AttributedServiceFactory(); +$service = $factory($container, YourApp\Service\Example::class); +``` -### Exceptions thrown +Both expose the same logic through `createObject(ContainerInterface $container, string $requestedName)`, which `__invoke()` simply delegates to. +Use `createObject()` when you extend one of the factories and need to call the parent implementation explicitly. -- `Dot\DependencyInjection\Exception\RuntimeException` if repository does not exist -- `Dot\DependencyInjection\Exception\RuntimeException` if repository does not extend `Doctrine\ORM\EntityRepository` -- `Dot\DependencyInjection\Exception\RuntimeException` if repository does not have `#[Entity]` attribute -- `Psr\Container\NotFoundExceptionInterface` if `Doctrine\ORM\EntityManagerInterface` does not exist in the service container -- `Psr\Container\ContainerExceptionInterface` if service manager is unable to provide an instance of `Doctrine\ORM\EntityManagerInterface` +> Because the service name is also the class to instantiate, you can only register these factories under the fully qualified class name of the service. +> Aliases still work, as long as the alias points to a service registered under its FQCN. ## AttributedServiceFactory Injects class dependencies into classes. -If a dependency is specified using the dot notation, `AttributedServiceFactory` will try to load a service having that specific alias. -If it does not find one, it will try to load the dependency as a config tree, checking each segment if it's available in the service container. +The factory looks for the `#[Inject]` attribute on the constructor of the requested class and resolves each of its parameters, in the declared order, into a constructor argument. + +Resolution of a single dependency happens as follows: + +1. if the container has a service registered under the exact name, that service is used - this is checked first, even when the name contains dots +2. otherwise, if the name contains dots, the part before the first dot is resolved from the container and the remaining parts are read as keys from the returned array (or `ArrayAccess` object) +3. otherwise, if the name is an existing class, the factory instantiates it directly with `new $name()` +4. otherwise, a `RuntimeException` is thrown + +A class whose constructor has no parameters at all does not need the `#[Inject]` attribute: if the requested class has no constructor, it is instantiated directly. -### Exceptions thrown +### Exceptions thrown by AttributedServiceFactory -- `Dot\DependencyInjection\Exception\RuntimeException` if service does not exist -- `Dot\DependencyInjection\Exception\RuntimeException` if service does not have `#[Inject]` attribute on it's constructor -- `Dot\DependencyInjection\Exception\RuntimeException` if service tries to inject itself recursively +- `Dot\DependencyInjection\Exception\RuntimeException` if the requested class does not exist +- `Dot\DependencyInjection\Exception\RuntimeException` if the requested class has a constructor without the `#[Inject]` attribute +- `Dot\DependencyInjection\Exception\RuntimeException` if the class tries to inject itself +- `Dot\DependencyInjection\Exception\RuntimeException` if a dependency is neither a registered service nor an existing class +- `Dot\DependencyInjection\Exception\InvalidArgumentException` if a key of a dot-separated dependency cannot be found in the array service - `Psr\Container\NotFoundExceptionInterface` if a dependency does not exist in the service container -- `Psr\Container\ContainerExceptionInterface` if service manager is unable to provide an instance of a dependency +- `Psr\Container\ContainerExceptionInterface` if the service manager is unable to provide an instance of a dependency + +## AttributedRepositoryFactory + +Injects entity repositories into a class. + +The factory looks for the `#[Entity]` attribute on the requested repository class and returns `$container->get(EntityManagerInterface::class)->getRepository($entityName)`. + +Since Doctrine decides which repository class to build from the mapping of the entity, the entity referenced by the `#[Entity]` attribute must declare the repository back: + +```php +#[ORM\Entity(repositoryClass: ExampleRepository::class)] +``` + +If it does not, Doctrine returns its default repository and the factory throws a `RuntimeException` instead of handing back an object of an unexpected type. + +### Exceptions thrown by AttributedRepositoryFactory + +- `Dot\DependencyInjection\Exception\RuntimeException` if the repository class does not exist +- `Dot\DependencyInjection\Exception\RuntimeException` if the repository class does not extend `Doctrine\ORM\EntityRepository` +- `Dot\DependencyInjection\Exception\RuntimeException` if the repository class does not have the `#[Entity]` attribute +- `Dot\DependencyInjection\Exception\RuntimeException` if the class referenced by the `#[Entity]` attribute does not exist +- `Dot\DependencyInjection\Exception\RuntimeException` if Doctrine returns a repository that is not an instance of the requested class +- `Psr\Container\NotFoundExceptionInterface` if `Doctrine\ORM\EntityManagerInterface` does not exist in the service container +- `Psr\Container\ContainerExceptionInterface` if the service manager is unable to provide an instance of `Doctrine\ORM\EntityManagerInterface` + +## Exception hierarchy + +Both exceptions implement `Dot\DependencyInjection\Exception\ExceptionInterface`, so you can catch anything thrown by this package with a single `catch` block: + +```php +try { + $service = $container->get(YourApp\Service\Example::class); +} catch (Dot\DependencyInjection\Exception\ExceptionInterface $exception) { + // ... +} +``` + +| Exception | Extends | Meaning | +| --- | --- | --- | +| `RuntimeException` | `\RuntimeException` | the class or service graph cannot be built as declared | +| `InvalidArgumentException` | `\InvalidArgumentException` | a dot-separated dependency points to a missing array key | diff --git a/docs/book/v1/factories/repository.md b/docs/book/v1/factories/repository.md index e8687a1..52370d5 100644 --- a/docs/book/v1/factories/repository.md +++ b/docs/book/v1/factories/repository.md @@ -1,5 +1,25 @@ # Inject entity repositories +## Prepare entity + +Doctrine decides which repository class to instantiate based on the mapping of the entity, so the entity must point to its repository: + +```php + + */ +#[Entity(name: Example::class)] +class ExampleRepository extends EntityRepository { } ``` -Each entity repository must extend `Doctrine\ORM\EntityRepository`. +- the `name` field has to be the fully qualified class name of the entity +- each entity repository must extend `Doctrine\ORM\EntityRepository` +- `#[Entity]` targets classes only + +The factory returns `$container->get(EntityManagerInterface::class)->getRepository(Example::class)`, which means the repository is built and managed by Doctrine, with the entity manager already injected. +Do not declare a constructor with a different signature on your repository. + +> If the entity does not declare `repositoryClass`, Doctrine returns its default `EntityRepository` and the factory throws a `Dot\DependencyInjection\Exception\RuntimeException` naming both classes, instead of returning an object of an unexpected type under your repository's service name. ## Register repository @@ -34,6 +68,9 @@ declare(strict_types=1); namespace YourApp; +use Dot\DependencyInjection\Factory\AttributedRepositoryFactory; +use YourApp\Repository\ExampleRepository; + class ConfigProvider { public function __invoke(): array @@ -47,9 +84,30 @@ class ConfigProvider { return [ 'factories' => [ - YourApp\Repository\ExampleRepository::class => Dot\DependencyInjection\Factory\AttributedRepositoryFactory::class, + ExampleRepository::class => AttributedRepositoryFactory::class, ], ]; } } ``` + +`Doctrine\ORM\EntityManagerInterface` must be registered in your container. +In Dotkernel projects this is provided by `doctrine/doctrine-orm-module` / `dot-cache` configuration and is already in place. + +## Inject the repository into a service + +Once registered, the repository is an ordinary service, so it can be injected with `#[Inject]`: + +```php +#[Inject( + ExampleRepository::class, +)] +public function __construct( + protected ExampleRepository $exampleRepository, +) { +} +``` + +## Caching + +The `#[Entity]` attribute is read with reflection on every repository creation and is not cached by this package. diff --git a/docs/book/v1/factories/service.md b/docs/book/v1/factories/service.md index dc16e0d..e08d9ee 100644 --- a/docs/book/v1/factories/service.md +++ b/docs/book/v1/factories/service.md @@ -3,7 +3,7 @@ ## Prepare class `dot-dependency-injection` determines the dependencies by looking at the `#[Inject]` attribute, added to the constructor of a class. -Dependencies are specified as separate parameters of the `#[Inject]` attribute. +Dependencies are specified as separate parameters of the `#[Inject]` attribute, in the same order as the constructor parameters. ```php `#[Inject]` targets methods only, and only the constructor is inspected. +> Injecting dependencies into property setters is not supported. + +## Inject a configuration value + If your class needs the value of a specific configuration key, you can specify the path using dot notation: ```php - #[Dot\DependencyInjection\Attribute\Inject( - YourApp\Repository\Dependency1::class, - YourApp\Helper\Dependency2::class, - "config.example", + #[Inject( + Dependency1::class, + 'config.example', )] public function __construct( - protected YourApp\Repository\Dependency1 $dependency1, - protected YourApp\Helper\Dependency2 $dependency2, + protected Dependency1 $dependency1, protected array $exampleConfig, ) { } ``` +`'config.example'` injects `$container->get('config')['example']`, and paths of any depth work: `'config.example.nested.value'`. + +Things worth knowing about dot notation: + +- the full name is always looked up in the container first, so a service literally registered as `config.example` takes precedence over the `example` key of the `config` service +- only the part before the first dot is resolved from the container; every following segment is an array key +- both arrays and objects implementing `ArrayAccess` can be traversed +- a key holding `null` is injected as `null`, it is not treated as missing +- if a segment does not exist, or the path continues past a scalar value, `Dot\DependencyInjection\Exception\InvalidArgumentException` is thrown, naming the full path you declared + ## Register class Open the ConfigProvider of the module where your class resides. @@ -59,6 +85,9 @@ declare(strict_types=1); namespace YourApp; +use Dot\DependencyInjection\Factory\AttributedServiceFactory; +use YourApp\Service\Example; + class ConfigProvider { public function __invoke(): array @@ -67,14 +96,38 @@ class ConfigProvider 'dependencies' => $this->getDependencies(), ]; } - + public function getDependencies(): array { return [ 'factories' => [ - YourApp\Service\Example::class => Dot\DependencyInjection\Factory\AttributedServiceFactory::class, + Example::class => AttributedServiceFactory::class, ], ]; } } ``` + +> The service key must be the fully qualified class name of the class to build, because the factory instantiates the requested service name. +> Register an alias if you need a shorter name. + +## Recursion + +A class cannot inject itself: + +```php +#[Inject(self::class)] +public function __construct(protected ?Example $example = null) +{ +} +``` + +This throws a `Dot\DependencyInjection\Exception\RuntimeException`. + +> Only direct self-injection is detected. +> A cycle spanning several classes (`A` needs `B`, `B` needs `A`) is not detected by this package and will end in infinite recursion, exactly as it would with hand-written factories. + +## Caching + +Dependencies declared through `#[Inject]` are resolved with reflection on every service creation, and the result is not cached by this package. +The container's own instance cache still applies: a shared service is built once per request, no matter how many classes inject it. diff --git a/docs/book/v1/faq.md b/docs/book/v1/faq.md new file mode 100644 index 0000000..80b2e02 --- /dev/null +++ b/docs/book/v1/faq.md @@ -0,0 +1,155 @@ +# FAQ + +## Setup and registration + +### Do I still have to register every service in a ConfigProvider? + +Yes. +This package replaces the factory class you would write for a service, not the `dependencies.factories` entry that tells the container which factory to use. +Each service still needs one line mapping its FQCN to `AttributedServiceFactory` or `AttributedRepositoryFactory`. + +### Why does it not read the dependencies from my constructor type hints? + +Container identifiers are not types. +The same type can be registered several times under different names, plenty of services are registered under strings such as `config`, and a parameter typed against an interface gives no indication of which implementation you want. +`#[Inject]` asks you for the identifier because that is the only thing the container can actually resolve. + +### Can I register a service under a name other than its fully qualified class name? + +Not directly, because the factory instantiates the requested service name. +Register the service under its FQCN and add an alias pointing to it if you need a shorter name. + +### Can I keep some hand-written factories? + +Yes, the factory is chosen per service, so both styles can live side by side in the same module. +See [Attributes vs. factories](attributes-vs-factories.md) for the cases where a hand-written factory is still the better choice. + +## The `#[Inject]` attribute + +### My constructor takes no arguments, do I need the attribute? + +It depends on whether the constructor exists at all. +A class with no constructor is instantiated directly and needs no attribute. +A class that explicitly declares `public function __construct()` does need the attribute, otherwise you get `You need to use the "...\Inject" attribute on the "..." class`. +Use `#[Inject()]` with no parameters, or delete the empty constructor. + +### What happens if the order in the attribute does not match the constructor? + +Nothing checks it, and the arguments are passed positionally in the order you listed them. +If the types differ you get a `TypeError` when the service is created; if they happen to be compatible, the wrong values are injected silently. +Keep the attribute parameters in the same order as the constructor parameters. + +### Can I leave out some constructor parameters? + +Only trailing ones that have a default value. +Arguments are passed positionally, so you cannot skip a parameter in the middle of the list and you cannot inject by parameter name. + +### Can I inject a plain value, such as a string or a number? + +No. +Every parameter of the attribute is resolved as a container identifier, a class name, or a dot-separated path into an array service. +Put the literal in your configuration and inject it with dot notation instead. + +### Is `#[Inject]` inherited by subclasses? + +Yes. +If a subclass does not declare its own constructor, the factory reads the attribute from the inherited constructor and builds the subclass with the dependencies the parent declared. +Declare a constructor with its own `#[Inject]` in the subclass to change them. + +### Can I put `#[Inject]` on a property or a setter? + +No. +The attribute targets methods, and only the constructor is inspected. + +### Can I inject the same service twice? + +Yes, list it as many times as your constructor needs it. +A shared service is resolved from the container each time, so both parameters receive the same instance. + +## Configuration values + +### How do I inject a single configuration key? + +Use dot notation: `#[Inject('config.example')]` injects `$container->get('config')['example']`. +Paths of any depth work, and both arrays and `ArrayAccess` objects can be traversed. + +### What if a service is registered under a name that contains dots? + +The full name is always looked up in the container first, so a service literally registered as `config.example` wins over the `example` key of the `config` service. + +### Can I address a configuration key that itself contains a dot? + +No. +Only the segment before the first dot is treated as a service identifier; every following dot starts a new array key, so a key named `my.key` cannot be reached. +Nest the value one level deeper, or expose it as its own service. + +### What if the configuration value is `null`? + +It is injected as `null`. +A key that exists but holds `null` is not treated as missing. + +### What if the key does not exist? + +`Dot\DependencyInjection\Exception\InvalidArgumentException` is thrown, naming the full path you declared. +You get the same exception when the path continues past a scalar value, for example `config.debug.verbose` where `config.debug` is a boolean. + +## Doctrine repositories + +### Why do I get a repository of the wrong class, or an "unexpected repository" error? + +Doctrine, not this package, decides which class to instantiate, and it reads that from the mapping of the entity. +Add `#[ORM\Entity(repositoryClass: ExampleRepository::class)]` to the entity, so that it points back to the repository that declares it through `#[Entity]`. + +### Do I still need `#[Entity]` if the entity already declares `repositoryClass`? + +Yes. +The two point at each other: `repositoryClass` tells Doctrine which class to build, and `#[Entity]` tells the factory which entity to ask for. + +### Can I inject extra dependencies into a repository? + +No. +The repository is constructed by Doctrine with the entity manager and the class metadata, so its constructor signature is not yours to change. +Put the extra dependencies in a service that receives the repository through `#[Inject]`. + +### How do I inject a repository into a service? + +Register the repository with `AttributedRepositoryFactory`, then list it like any other service: `#[Inject(ExampleRepository::class)]`. + +### Do I need `doctrine/orm` if I only use `AttributedServiceFactory`? + +It is a hard requirement of the package, so it will be installed, but nothing loads it unless you use `AttributedRepositoryFactory`. + +## Performance + +### Is the attribute lookup cached? + +No. +One `ReflectionClass` is created per service creation and the attribute is read every time. +The container's own instance cache still applies, so a shared service is built once per request no matter how many classes inject it. + +### Does that slow down my application? + +The reflection happens once per service that a request actually builds, and it is a lookup on an already-loaded class rather than file parsing. +If a profile ever shows it mattering for a specific hot service, write a factory by hand for that one service. + +## Testing and debugging + +### How do I unit test a class that uses `#[Inject]`? + +Exactly as you would test any other class: instantiate it with `new` and pass test doubles. +The attribute is inert outside the factory, so it has no effect on your tests. + +### I get "You need to use the ... attribute on the ... class", what now? + +The requested class has a constructor without the `#[Inject]` attribute, or the attribute sits somewhere other than the constructor. +Check that the attribute is imported from `Dot\DependencyInjection\Attribute\Inject`, because an attribute of another `Inject` class is ignored. + +### Are circular dependencies detected? + +Only direct self-injection, which throws a `RuntimeException`. +A cycle spanning several classes, where `A` needs `B` and `B` needs `A`, is not detected and ends in infinite recursion, exactly as it would with hand-written factories. + +### Which exception should I catch? + +Both exceptions of this package implement `Dot\DependencyInjection\Exception\ExceptionInterface`, so a single `catch` covers every wiring error. +Note that your container usually wraps them, for example in a `Laminas\ServiceManager\Exception\ServiceNotCreatedException`, so inspect the previous exception. diff --git a/docs/book/v1/installation.md b/docs/book/v1/installation.md index dacf977..ad3402d 100644 --- a/docs/book/v1/installation.md +++ b/docs/book/v1/installation.md @@ -1,5 +1,13 @@ # Installation +## Requirements + +- PHP 8.3, 8.4 or 8.5 +- A PSR-11 container (`psr/container` ^1.0 || ^2.0), usually `laminas/laminas-servicemanager` +- `doctrine/orm` ^2.9 || ^3.0 - required only if you use `AttributedRepositoryFactory` + +## Install + Install `dotkernel/dot-dependency-injection` by executing the following Composer command: ```shell diff --git a/docs/book/v1/overview.md b/docs/book/v1/overview.md index 87f9685..b70544f 100644 --- a/docs/book/v1/overview.md +++ b/docs/book/v1/overview.md @@ -2,12 +2,22 @@ `dot-dependency-injection` is Dotkernel's dependency injection service. -By providing reusable factories for service and repository injection, it reduces code complexity in projects. +Instead of a handwritten factory class per service, you declare a class's dependencies with the `#[Inject]` attribute on its constructor - or a repository's entity with `#[Entity]` - and register one of the two reusable factories this package ships. +That removes an entire category of boilerplate files from a project and keeps the dependency list next to the constructor it feeds. + +It provides: + +- `Dot\DependencyInjection\Attribute\Inject` - declares the dependencies of a constructor +- `Dot\DependencyInjection\Attribute\Entity` - declares the entity of a Doctrine repository +- `Dot\DependencyInjection\Factory\AttributedServiceFactory` - builds any class from its `#[Inject]` attribute +- `Dot\DependencyInjection\Factory\AttributedRepositoryFactory` - builds any Doctrine repository from its `#[Entity]` attribute + +Continue with [Attributes vs. factories](attributes-vs-factories.md) for the comparison and the trade-offs, or with [Installation](installation.md). ## Badges ![OSS Lifecycle](https://img.shields.io/osslifecycle/dotkernel/dot-dependency-injection) -![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-dependency-injection/1.3.0) +![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-dependency-injection/1.4.0) [![GitHub issues](https://img.shields.io/github/issues/dotkernel/dot-dependency-injection)](https://github.com/dotkernel/dot-dependency-injection/issues) [![GitHub forks](https://img.shields.io/github/forks/dotkernel/dot-dependency-injection)](https://github.com/dotkernel/dot-dependency-injection/network) diff --git a/docs/book/v1/usage.md b/docs/book/v1/usage.md index 21ae6d0..bb49199 100644 --- a/docs/book/v1/usage.md +++ b/docs/book/v1/usage.md @@ -1,6 +1,12 @@ # Usage +Start with [Attributes vs. factories](attributes-vs-factories.md) for what this package replaces and why. + You can use it to: - [Inject class dependencies](factories/service.md) - [Inject entity repositories](factories/repository.md) + +Both are described in detail in [Factories](factories.md). + +For the questions that come up most often, see the [FAQ](faq.md). diff --git a/mkdocs.yml b/mkdocs.yml index 197b451..5380864 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,10 +12,12 @@ nav: - Installation: v1/installation.md - Configuration: v1/configuration.md - Usage: v1/usage.md + - "Attributes vs. factories": v1/attributes-vs-factories.md - Factories: v1/factories.md - Reference: - "Inject class dependencies": v1/factories/service.md - "Inject entity repositories": v1/factories/repository.md + - FAQ: v1/faq.md site_name: dot-dependency-injection site_description: "Dotkernel's dependency injection service" repo_url: "https://github.com/dotkernel/dot-dependency-injection" diff --git a/phpstan.neon b/phpstan.neon index 45a12e4..349be25 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,8 +6,3 @@ parameters: - src - test treatPhpDocTypesAsCertain: false - ignoreErrors: - - message: '#Constructor of an anonymous class has an unused parameter \$config.#' - path: test/Factory/AttributedServiceFactoryTest.php - - message: '#Constructor of an anonymous class has an unused parameter \$test.#' - path: test/Factory/AttributedServiceFactoryTest.php diff --git a/src/Attribute/Entity.php b/src/Attribute/Entity.php index 78acdec..ff0a6b8 100644 --- a/src/Attribute/Entity.php +++ b/src/Attribute/Entity.php @@ -6,14 +6,20 @@ use Attribute; -#[Attribute] +/** + * Marks a Doctrine entity repository as belonging to the given entity. + * + * Read by {@see \Dot\DependencyInjection\Factory\AttributedRepositoryFactory}. + */ +#[Attribute(Attribute::TARGET_CLASS)] class Entity { - private string $name; - - public function __construct(string $name) + /** + * @param string $name FQCN of the entity the annotated repository manages. + * Validated at runtime by AttributedRepositoryFactory. + */ + public function __construct(private readonly string $name) { - $this->name = $name; } public function getName(): string diff --git a/src/Attribute/Inject.php b/src/Attribute/Inject.php index 3a43b0e..bec9cfa 100644 --- a/src/Attribute/Inject.php +++ b/src/Attribute/Inject.php @@ -6,16 +6,31 @@ use Attribute; -#[Attribute] +use function array_values; + +/** + * Declares, in order, the dependencies to pass to the annotated constructor. + * + * Read by {@see \Dot\DependencyInjection\Factory\AttributedServiceFactory}. + */ +#[Attribute(Attribute::TARGET_METHOD)] class Inject { + /** @var list */ protected array $services = []; + /** + * @param string ...$services Service container identifiers, class names, + * or dot-separated paths into an array service. + */ public function __construct(string ...$services) { - $this->services = $services; + $this->services = array_values($services); } + /** + * @return list + */ public function getServices(): array { return $this->services; diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php index e5e8f32..026ee4b 100644 --- a/src/Exception/InvalidArgumentException.php +++ b/src/Exception/InvalidArgumentException.php @@ -4,8 +4,15 @@ namespace Dot\DependencyInjection\Exception; +use function sprintf; + class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface { public const MESSAGE_MISSING_KEY = 'The key "%s" provided in the dotted notation could not be found in the array service.'; + + public static function missingKey(string $serviceKey): self + { + return new self(sprintf(self::MESSAGE_MISSING_KEY, $serviceKey)); + } } diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php index 99e5bb6..14c73b9 100644 --- a/src/Exception/RuntimeException.php +++ b/src/Exception/RuntimeException.php @@ -8,14 +8,17 @@ class RuntimeException extends \RuntimeException implements ExceptionInterface { - public const MESSAGE_ATTRIBUTE_NOT_FOUND = + public const MESSAGE_ATTRIBUTE_NOT_FOUND = 'You need to use the "%s" attribute on the "%s" class so that "%s" can create it.'; - public const MESSAGE_CLASS_NOT_FOUND = + public const MESSAGE_CLASS_NOT_FOUND = 'Defined injectable "%s" could not be found in container or as a class.'; - public const MESSAGE_DOES_NOT_EXTEND = + public const MESSAGE_DOES_NOT_EXTEND = 'Class "%s" must extend class "%s".'; - public const MESSAGE_RECURSIVE_INJECT = + public const MESSAGE_RECURSIVE_INJECT = 'Class "%s" can not be injected into itself.'; + public const MESSAGE_UNEXPECTED_REPOSITORY = + 'Doctrine returned an instance of "%s" instead of "%s". ' + . 'Make sure that entity "%s" declares #[ORM\Entity(repositoryClass: ...)] pointing to it.'; public static function classNotFound(string $requestedName): self { @@ -36,4 +39,9 @@ public static function recursiveInject(string $requestedName): self { return new self(sprintf(self::MESSAGE_RECURSIVE_INJECT, $requestedName)); } + + public static function unexpectedRepository(string $actual, string $requestedName, string $entity): self + { + return new self(sprintf(self::MESSAGE_UNEXPECTED_REPOSITORY, $actual, $requestedName, $entity)); + } } diff --git a/src/Factory/AttributedRepositoryFactory.php b/src/Factory/AttributedRepositoryFactory.php index d5c64ca..dc38488 100644 --- a/src/Factory/AttributedRepositoryFactory.php +++ b/src/Factory/AttributedRepositoryFactory.php @@ -14,12 +14,17 @@ use ReflectionClass; use function class_exists; +use function is_a; +/** + * Creates a Doctrine entity repository based on the #[Entity] attribute of the requested class. + */ class AttributedRepositoryFactory { /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface + * @throws RuntimeException */ public function __invoke(ContainerInterface $container, string $requestedName): EntityRepository { @@ -29,6 +34,7 @@ public function __invoke(ContainerInterface $container, string $requestedName): /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface + * @throws RuntimeException */ public function createObject(ContainerInterface $container, string $requestedName): EntityRepository { @@ -46,18 +52,28 @@ public function createObject(ContainerInterface $container, string $requestedNam throw RuntimeException::attributeNotFound(Entity::class, $requestedName, static::class); } - return $container->get(EntityManagerInterface::class)->getRepository($entityAttribute->getName()); + $entityName = $entityAttribute->getName(); + if (! class_exists($entityName)) { + throw RuntimeException::classNotFound($entityName); + } + + $repository = $container->get(EntityManagerInterface::class)->getRepository($entityName); + if (! is_a($repository, $requestedName)) { + throw RuntimeException::unexpectedRepository($repository::class, $requestedName, $entityName); + } + + return $repository; } + /** + * @template T of object + * @param ReflectionClass $reflectionClass + */ protected function findEntityAttribute(ReflectionClass $reflectionClass): ?Entity { - $attributes = $reflectionClass->getAttributes(); - foreach ($attributes as $attribute) { - if ($attribute->getName() === Entity::class) { - return $attribute->newInstance(); - } - } + $attribute = $reflectionClass->getAttributes(Entity::class)[0] ?? null; + $instance = $attribute?->newInstance(); - return null; + return $instance instanceof Entity ? $instance : null; } } diff --git a/src/Factory/AttributedServiceFactory.php b/src/Factory/AttributedServiceFactory.php index e0ce6ac..4bea971 100644 --- a/src/Factory/AttributedServiceFactory.php +++ b/src/Factory/AttributedServiceFactory.php @@ -14,21 +14,30 @@ use ReflectionClass; use ReflectionMethod; +use function array_key_exists; use function array_shift; use function class_exists; use function count; use function explode; use function in_array; use function is_array; -use function sprintf; +/** + * Creates any class based on the #[Inject] attribute of its constructor. + */ class AttributedServiceFactory { - protected string $originalKey; + /** + * Retained for backwards compatibility with subclasses overriding readKeysFromArray(). + * Assigned immediately before the call, so a nested service creation cannot overwrite it. + */ + protected string $originalKey = ''; /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface + * @throws RuntimeException + * @throws InvalidArgumentException */ public function __invoke(ContainerInterface $container, string $requestedName): mixed { @@ -38,6 +47,8 @@ public function __invoke(ContainerInterface $container, string $requestedName): /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface + * @throws RuntimeException + * @throws InvalidArgumentException */ public function createObject(ContainerInterface $container, string $requestedName): mixed { @@ -66,19 +77,19 @@ public function createObject(ContainerInterface $container, string $requestedNam protected function findInjectAttribute(ReflectionMethod $constructor): ?Inject { - $attributes = $constructor->getAttributes(); - foreach ($attributes as $attribute) { - if ($attribute->getName() === Inject::class) { - return $attribute->newInstance(); - } - } + $attribute = $constructor->getAttributes(Inject::class)[0] ?? null; + $instance = $attribute?->newInstance(); - return null; + return $instance instanceof Inject ? $instance : null; } /** + * @param list $parameters + * @return list * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface + * @throws RuntimeException + * @throws InvalidArgumentException */ protected function getServicesToInject(ContainerInterface $container, array $parameters): array { @@ -94,47 +105,78 @@ protected function getServicesToInject(ContainerInterface $container, array $par /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface + * @throws RuntimeException + * @throws InvalidArgumentException */ protected function getServiceToInject(ContainerInterface $container, string $serviceKey): mixed { - $this->originalKey = $serviceKey; - /** * Even when dots are found, try to find a service with the full name. * If it is not found, then assume dots are used to get part of an array service */ - $parts = explode('.', $serviceKey); + $parts = explode('.', $serviceKey); + $identifier = $serviceKey; if (count($parts) > 1 && ! $container->has($serviceKey)) { - $serviceKey = array_shift($parts); + $identifier = array_shift($parts); } else { $parts = []; } - if ($container->has($serviceKey)) { - $service = $container->get($serviceKey); - } elseif (class_exists($serviceKey)) { - $service = new $serviceKey(); + if ($container->has($identifier)) { + $service = $container->get($identifier); + } elseif (class_exists($identifier)) { + $service = new $identifier(); } else { - throw RuntimeException::classNotFound($serviceKey); + throw RuntimeException::classNotFound($identifier); } - return empty($parts) ? $service : $this->readKeysFromArray($parts, $service); + if ($parts === []) { + return $service; + } + + $this->originalKey = $serviceKey; + + return $this->readKeysFromArray($parts, $service); } + /** + * @param non-empty-list $keys + * @throws InvalidArgumentException + */ protected function readKeysFromArray(array $keys, mixed $array): mixed { $key = array_shift($keys); - if (! isset($array[$key])) { - throw new InvalidArgumentException( - sprintf(InvalidArgumentException::MESSAGE_MISSING_KEY, $this->originalKey) - ); + if (! $this->hasKey($array, $key)) { + throw InvalidArgumentException::missingKey($this->originalKey); } $value = $array[$key]; - if (! empty($keys) && (is_array($value) || $value instanceof ArrayAccess)) { - $value = $this->readKeysFromArray($keys, $value); + if ($keys === []) { + return $value; + } + + if (! is_array($value) && ! $value instanceof ArrayAccess) { + throw InvalidArgumentException::missingKey($this->originalKey); + } + + return $this->readKeysFromArray($keys, $value); + } + + /** + * Unlike isset(), this does not treat a null value as a missing key. + * + * Private so that it cannot collide with a method of the same name in a subclass. + */ + private function hasKey(mixed $array, string $key): bool + { + if (is_array($array)) { + return array_key_exists($key, $array); + } + + if ($array instanceof ArrayAccess) { + return $array->offsetExists($key); } - return $value; + return false; } } diff --git a/test/Factory/AttributedRepositoryFactoryTest.php b/test/Factory/AttributedRepositoryFactoryTest.php index ab241de..9937d7e 100644 --- a/test/Factory/AttributedRepositoryFactoryTest.php +++ b/test/Factory/AttributedRepositoryFactoryTest.php @@ -11,6 +11,7 @@ use Dot\DependencyInjection\Exception\RuntimeException; use Dot\DependencyInjection\Factory\AttributedRepositoryFactory; use DotTest\DependencyInjection\TestData\Entity as TestEntity; +use DotTest\DependencyInjection\TestData\InvalidEntityRepository; use DotTest\DependencyInjection\TestData\Repository as TestRepository; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; @@ -116,4 +117,62 @@ public function testWillCreateRepository(): void $repository = (new AttributedRepositoryFactory())($container, TestRepository::class); $this->assertInstanceOf(TestRepository::class, $repository); } + + /** + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testWillThrowExceptionIfEntityClassNotFound(): void + { + $container = $this->createMock(ContainerInterface::class); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + sprintf( + RuntimeException::MESSAGE_CLASS_NOT_FOUND, + 'DotTest\\DependencyInjection\\TestData\\NotAnEntity' + ) + ); + + (new AttributedRepositoryFactory())($container, InvalidEntityRepository::class); + } + + /** + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testWillThrowExceptionIfDoctrineReturnsAnotherRepository(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $container = $this->createMock(ContainerInterface::class); + + $metadata = new ClassMetadata(TestEntity::class); + $repository = new class ($entityManager, $metadata) extends EntityRepository { + }; + + $container + ->expects($this->once()) + ->method('get') + ->with(EntityManagerInterface::class) + ->willReturn($entityManager); + $entityManager + ->expects($this->once()) + ->method('getRepository') + ->with(TestEntity::class) + ->willReturn($repository); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + sprintf( + RuntimeException::MESSAGE_UNEXPECTED_REPOSITORY, + $repository::class, + TestRepository::class, + TestEntity::class + ) + ); + + (new AttributedRepositoryFactory())($container, TestRepository::class); + } } diff --git a/test/Factory/AttributedServiceFactoryTest.php b/test/Factory/AttributedServiceFactoryTest.php index c5686de..3bfe3e0 100644 --- a/test/Factory/AttributedServiceFactoryTest.php +++ b/test/Factory/AttributedServiceFactoryTest.php @@ -4,6 +4,7 @@ namespace DotTest\DependencyInjection\Factory; +use ArrayObject; use Dot\DependencyInjection\Attribute\Inject; use Dot\DependencyInjection\Exception\InvalidArgumentException; use Dot\DependencyInjection\Exception\RuntimeException; @@ -124,7 +125,7 @@ function (string $key) use ($mapping): array { $subject = new class { #[Inject('config.uration.key')] - public function __construct(array $config = []) + public function __construct(public array $config = []) { } }; @@ -149,7 +150,7 @@ public function testWillThrowExceptionIfDependencyNotFound(): void $subject = new class { #[Inject('test')] - public function __construct(mixed $test = null) + public function __construct(public mixed $test = null) { } }; @@ -209,4 +210,160 @@ function (string $key) use ($mapping): array { $service = (new AttributedServiceFactory())($container, $subject::class); $this->assertInstanceOf(ValidService::class, $service); } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testWillInjectNullValueFromDottedNotation(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->any())->method('has')->willReturnCallback( + fn (string $key): bool => $key === 'config', + ); + $container->expects($this->any())->method('get')->willReturn(['debug' => null]); + + $subject = new class { + #[Inject('config.debug')] + public function __construct(public mixed $debug = 'not injected') + { + } + }; + + $service = (new AttributedServiceFactory())($container, $subject::class); + $this->assertNull($service->debug); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testWillThrowExceptionIfDottedNotationOvershootsAScalar(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->any())->method('has')->willReturnCallback( + fn (string $key): bool => $key === 'config', + ); + $container->expects($this->any())->method('get')->willReturn(['debug' => true]); + + $subject = new class { + #[Inject('config.debug.verbose')] + public function __construct(public mixed $verbose = null) + { + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf(InvalidArgumentException::MESSAGE_MISSING_KEY, 'config.debug.verbose') + ); + + (new AttributedServiceFactory())($container, $subject::class); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testWillReadDottedNotationFromArrayAccessService(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->any())->method('has')->willReturnCallback( + fn (string $key): bool => $key === 'config', + ); + $container->expects($this->any())->method('get')->willReturn( + new ArrayObject(['nested' => new ArrayObject(['value' => 'injected'])]) + ); + + $subject = new class { + #[Inject('config.nested.value')] + public function __construct(public ?string $value = null) + { + } + }; + + $service = (new AttributedServiceFactory())($container, $subject::class); + $this->assertSame('injected', $service->value); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testWillPreferAServiceNamedLikeTheDottedKey(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->any())->method('has')->willReturnCallback( + fn (string $key): bool => $key === 'config.debug', + ); + $container + ->expects($this->once()) + ->method('get') + ->with('config.debug') + ->willReturn(['full-service']); + + $subject = new class { + #[Inject('config.debug')] + public function __construct(public array $debug = []) + { + } + }; + + $service = (new AttributedServiceFactory())($container, $subject::class); + $this->assertSame(['full-service'], $service->debug); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testWillInstantiateAnUnregisteredClassDependency(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->any())->method('has')->willReturn(false); + $container->expects($this->never())->method('get'); + + $subject = new class { + #[Inject(ValidService::class)] + public function __construct(public ?ValidService $service = null) + { + } + }; + + $service = (new AttributedServiceFactory())($container, $subject::class); + $this->assertInstanceOf(ValidService::class, $service->service); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testWillThrowExceptionIfDottedNotationIsUsedOnANonArrayService(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->any())->method('has')->willReturnCallback( + fn (string $key): bool => $key === 'version', + ); + $container->expects($this->any())->method('get')->willReturn('1.0.0'); + + $subject = new class { + #[Inject('version.major')] + public function __construct(public mixed $major = null) + { + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf(InvalidArgumentException::MESSAGE_MISSING_KEY, 'version.major') + ); + + (new AttributedServiceFactory())($container, $subject::class); + } } diff --git a/test/TestData/InvalidEntityRepository.php b/test/TestData/InvalidEntityRepository.php new file mode 100644 index 0000000..bb1827a --- /dev/null +++ b/test/TestData/InvalidEntityRepository.php @@ -0,0 +1,18 @@ + + */ +#[Entity(name: 'DotTest\DependencyInjection\TestData\NotAnEntity')] +class InvalidEntityRepository extends EntityRepository +{ +}