diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index c0fbc53..9652006 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -15,14 +15,14 @@ jobs: - ubuntu-latest php: - - "8.2" - "8.3" - "8.4" - "8.5" + - "8.6" steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v7 - name: Install PHP uses: shivammathur/setup-php@v2 @@ -36,7 +36,7 @@ jobs: run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV - name: Cache dependencies installed with composer - uses: actions/cache@v3 + uses: actions/cache@v6 with: path: ${{ env.COMPOSER_CACHE_DIR }} key: php${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} @@ -50,6 +50,6 @@ jobs: run: vendor/bin/phpunit --colors=always --coverage-clover clover.xml - name: Send code coverage report to Codecov.io - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 9976515..2763b9c 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -15,14 +15,14 @@ jobs: - ubuntu-latest php: - - "8.2" - "8.3" - "8.4" - "8.5" + - "8.6" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install PHP uses: shivammathur/setup-php@v2 @@ -36,7 +36,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/.laminas-ci.json b/.laminas-ci.json index 29216fa..e827682 100644 --- a/.laminas-ci.json +++ b/.laminas-ci.json @@ -1,3 +1,6 @@ { + "ignore_php_platform_requirements": { + "8.6": true + }, "backwardCompatibilityCheck": true } diff --git a/CHANGELOG.md b/CHANGELOG.md index 44badec..f93636f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,221 +1,662 @@ -## 4.0.0 - 2022-09-19 +# Changelog + +All notable changes to this project are documented in this file. + +Version `5.x` is a separate, attribute-based line of this package (see `5.0.0`). +Version `6.0.0` continues the annotation-based API of `4.x`. + +## 6.0.0 - Unreleased ### Changed -* Added new feature to inject doctrine repositories + +- Minimum PHP version is now 8.3. Supported versions: 8.3, 8.4, 8.5 and 8.6. +- `doctrine/orm` is now required as `^3.0` (was `^2.20`) and `doctrine/annotations` as `^2.0` (was `^1.14.3`). +- `laminas/laminas-servicemanager` is no longer a requirement. The factories only depend on `psr/container`, which is now allowed as `^1.1 || ^2.0` (PSR-11 versions 1 and 2). +- `Dot\AnnotatedServices\ConfigProvider` returns an empty configuration. It no longer registers an abstract factory. +- `AnnotatedRepositoryFactory` now throws a `RuntimeException` when the entity manager returns a repository that is not an instance of the requested class. Set `repositoryClass` on the entity to the registered repository. +- `AnnotatedServiceFactory::__invoke()` and `AnnotatedServiceFactory::createObject()` now return `object` instead of `mixed`. +- `AbstractAnnotatedFactory::CACHE_SERVICE` is now a typed constant (`public const string`). +- `Inject` accepts a single unbraced service, e.g. `@Inject("config")`. +- `Entity::getName()` is typed as `class-string`. +- Array and generic types are specified in docblocks, so the code passes PHPStan level 8. +- Development tooling: PHPUnit 12, PHPStan level 8, GitHub Actions run PHP 8.3 to 8.6 with updated `actions/checkout`, `actions/cache` and `codecov/codecov-action`, and the Laminas backward-compatibility check is disabled for this major version. +- Documentation for 6.0 is available in `docs/book/v6`, including an upgrade guide for 4.x and 5.x. +- The cache documentation describes a PSR-6 cache pool (`Psr\Cache\CacheItemPoolInterface`) instead of `doctrine/cache` drivers. ### Added -* AnnotatedRepositoryFactory.php + +- `RuntimeException::repositoryMismatch()`. +- `psr/cache` and `psr/container` as direct requirements, and a `suggest` entry for a PSR-6 cache implementation. +- A test suite with fixtures, a real annotation reader and a minimal PSR-11 container, covering the factories, the annotation reader selection, the annotations and the exceptions. ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- PHP 8.2 support. +- `doctrine/cache` as a requirement. `doctrine/orm` 3.x does not install it either, so your own code should require it if it uses its classes. +- Support for Doctrine ORM 2.x and `doctrine/annotations` 1.x. +- `AnnotatedServiceAbstractFactory` and the `@Service` annotation, together with the `laminas/laminas-servicemanager` dependency they needed. Register classes with `AnnotatedServiceFactory` instead (see the upgrade guide). ### Fixed -* Nothing -## 3.2.0 - 2022-04-29 +- The constructor of a service was called a second time while injecting setters. Services with `readonly` promoted properties could not be created. +- A dotted dependency such as `config.key` failed when the value was `null`. It is now injected as `null`. +- A dotted dependency longer than the data (e.g. `config.a.b` where `config['a']` is a string) returned an intermediate value. It now throws an `InvalidArgumentException`. +- `@Inject("config")` without curly braces failed with a creation error. +- Static methods annotated with `@Inject` are no longer called during setter injection. + +## 5.2.0 - 2024-10-23 + +### Changed + +- Version 5 entered maintenance mode (Issue #52). +- `laminas/laminas-coding-standard` updated to `^3.0`. +- Code linting. +- `SECURITY.md` targets the latest releases, and the site description in `mkdocs.yml` was updated. + +### Added + +- PHP 8.4 support. +- `.laminas-ci.json`. + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 5.1.2 - 2024-05-03 + +### Changed + +- Documentation build workflow and `mkdocs.yml`. + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 5.1.1 - 2024-04-24 + +### Changed + +- Documentation build workflow, `mkdocs.yml` and `README.md`. + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 5.1.0 - 2024-04-24 + +### Changed + +- Continuous integration uses the GitHub workflows provided by Laminas. +- `doctrine/orm` constraint changed from `^2.0 || ^3.0` to `^2.9 || ^3.0`. +- Code linting. + +### Added + +- Documentation for versions 4 and 5 (Issue #37), and the documentation build GitHub Action. + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- The `Build static` badge in `README.md`. + +## 5.0.0 - 2024-03-08 + +### Changed + +- Annotations were replaced with PHP attributes (Issue #32): `Dot\AnnotatedServices\Attribute\Inject`, `Dot\AnnotatedServices\Attribute\Entity`, `AttributedServiceFactory` and `AttributedRepositoryFactory`. +- The package no longer depends on `doctrine/annotations`, `doctrine/cache` and `laminas/laminas-servicemanager`. +- `Dot\AnnotatedServices\ConfigProvider` no longer registers an abstract factory. + +### Added + +- `psr/container` `^1.0 || ^2.0` as a requirement. +- `SECURITY.md`. + +### Deprecated + +- Nothing + +### Removed + +- PHP 8.1 support. +- `AnnotatedServiceAbstractFactory` and the `@Service` annotation. +- Annotation caching, and `@Inject` on setters. + +### Fixed + +- Nothing + +## 4.4.0 - 2025-11-03 + +### Changed + +- Updated the PHP versions in the continuous integration workflows (Issue #60). +- Updated README, `SECURITY.md` and documentation. + +### Added + +- PHP 8.5 support. + +### Deprecated + +- Nothing + +### Removed + +- The version 5 documentation, which belongs to the `5.0` branch. + +### Fixed + +- Nothing + +## 4.3.0 - 2025-03-04 + +### Changed + +- Replaced Psalm with PHPStan for static analysis (Issue #58). + +### Added + +- `phpstan/phpstan` and `phpstan/phpstan-phpunit`, `phpstan.neon` and a static analysis GitHub workflow. +- Unit tests for `AnnotatedRepositoryFactory` and `AnnotatedServiceFactory` were extended. + +### Deprecated + +- Nothing + +### Removed + +- `vimeo/psalm` and `psalm.xml`. + +### Fixed + +- Nothing + +## 4.2.1 - 2025-01-27 + +### Changed + +- Psalm updated to 6.x (Issue #56). +- Codecov runs on PHP 8.4 as well. +- Updated README and documentation, and reordered the packages in `composer.json`. + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.2.0 - 2024-10-23 + +### Changed + +- Version 4 entered active mode (Issue #52). +- `doctrine/orm` constraint changed from `^2.17.3` to `^2.20`. +- `laminas/laminas-coding-standard` updated to `^3.0`. +- Code linting. + +### Added + +- PHP 8.4 support. +- Documentation for version 4 (Issue #37), and the documentation build GitHub Action. + +### Deprecated + +- Nothing + +### Removed + +- PHP 8.1 support. + +### Fixed + +- Nothing + +## 4.1.7 - 2024-02-23 + +### Changed + +- Updated dependencies: `laminas/laminas-servicemanager` `^3.22.1`, `doctrine/annotations` `^1.14.3`, `doctrine/cache` `^1.12.1 || ^2.1.1`, `doctrine/orm` `^2.17.3`, `phpunit/phpunit` `^10.5.9` and `vimeo/psalm` `^5.20`. +- `doctrine/cache` versions in `composer.json` widened to stay backward compatible. +- Annotations are read with `Doctrine\Common\Annotations\PsrCachedReader`. `AbstractAnnotatedFactory::CACHE_SERVICE` is now `Psr\Cache\CacheItemPoolInterface::class`, and the service registered at that key must be a PSR-6 cache pool. + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- The call to `AnnotationRegistry::registerLoader()`. + +### Fixed + +- Deprecations of `doctrine/annotations` v2. + +## 4.1.4 - 2023-11-29 + +### Changed + +- README update. + +### Added + +- PHP 8.3 support. + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.1.3 - 2023-08-29 + +### Changed + +- README update. + +### Added + +- Codecov integration (Issue #21), and the Codecov and static analysis badges. + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.1.2 - 2023-07-25 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- `AbstractAnnotatedFactory::CACHE_SERVICE` was restored to public. + +## 4.1.1 - 2023-07-20 ### Changed -* Added PHP 8.0 and 8.1 compatibility + +- Nothing ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing +- The `annotationReader` property of `AbstractAnnotatedFactory` is initialized to `null`. + +## 4.1.0 - 2023-07-18 + +### Changed + +- Minimum PHP version is now 8.1. +- `phpunit/phpunit` updated to `^10.2` and `laminas/laminas-coding-standard` to `^2.5`. +- Added missing return types, and solved code consistency issues. +- Removed the `suggest` section from `composer.json`. +- README update. + +### Added + +- PHP 8.2 support. +- Psalm static analysis, and code style check. + +### Deprecated + +- Nothing + +### Removed + +- PHP 7.4 and 8.0 support. + +### Fixed + +- Nothing + +## 4.0.0 - 2022-09-19 + +### Changed + +- Added new feature to inject doctrine repositories + +### Added + +- AnnotatedRepositoryFactory.php + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing ## 3.2.1 - 2022-07-01 ### Changed -* Downgraded package doctrine/cache to v1 to fix conflict + +- Downgraded package doctrine/cache to v1 to fix conflict ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Conflict for doctrine/common v2 (requires doctrine/cache v1) +- Conflict for doctrine/common v2 (requires doctrine/cache v1) ## 3.2.0 - 2022-04-29 ### Changed -* Added PHP 8.0 and 8.1 compatibility + +- Added PHP 8.0 and 8.1 compatibility ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing +- Nothing ## 3.1.0 - 2020-11-02 ### Changed -* Composer 2 compatibility. -* Update composer.json to require version 2 of laminas/laminas-dependency-plugin to be Composer 2 compatible. + +- Composer 2 compatibility. +- Update composer.json to require version 2 of laminas/laminas-dependency-plugin to be Composer 2 compatible. ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 3.0.2 - 2020-04-23 Updated keywords ### Added -* Everything + +- Everything ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 3.0.1 - 2020-04-22 Updated phpunit and php_codesniffer package versions ### Added -* Everything + +- Everything ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 3.0.0 - 2020-04-12 Updated packages and php versions ### Added -* Everything + +- Everything ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 2.0.0 - 2020-01-30 ### Changed -* Laminas and Mezzio migration. + +- Laminas and Mezzio migration. ### Added -* Nothing + +- Support ### Deprecated -* Nothing + +- Nothing ### Removed -* Support + +- Nothing ### Fixed -* Nothing +- Nothing ## 1.1.1 - 2018-05-03 ### Changed -* updated required dependencies + +- updated required dependencies ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing +- Nothing ## 1.1.0 - 2017-03-15 ### Changed -* updated factories to use PSR11 container + +- updated factories to use PSR11 container ### Added -* documentation in readme file + +- documentation in readme file ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing +- Nothing ## 1.0.1 - 2017-03-10 +### Changed + +- Nothing + ### Added -* Updated php file headers doc blocks, updated package description + +- Updated php file headers doc blocks, updated package description ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing +- Nothing ## 1.0.0 - 2017-03-07 Initial tagged release ### Added -* Everything + +- Everything ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing diff --git a/README.md b/README.md index 04b81e2..821a0c0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # dot-annotated-services -Dotkernel component used to create services through [Laminas Service Manager](https://github.com/laminas/laminas-servicemanager) and inject them with dependencies just using method annotations. -It can also create services without the need to write factories. +Dotkernel component used to create services through any PSR-11 container, such as [Laminas Service Manager](https://github.com/laminas/laminas-servicemanager), and inject them with dependencies just using method annotations. Annotation parsing can be cached to improve performance. This package can clean up your code by getting rid of all the factories you write, sometimes just to inject a dependency or two. @@ -14,23 +13,29 @@ Documentation is available at: https://docs.dotkernel.org/dot-annotated-services | Branch | PSR-11 | Docblock Comments | OSS Lifecycle | PHP Version | |--------|----------|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------| +| 6.0 | 1 \|\| 2 | Annotations | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-annotated-services%2Fblob%2F6.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/6.0.0) | | 5.0 | 1 \|\| 2 | Attributes | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-annotated-services%2Fblob%2F5.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/5.2.0) | | 4.0 | 1 | Annotations | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-annotated-services%2Fblob%2F4.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/4.4.0) | | 3.0 | 1 | Annotations | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-annotated-services%2Fblob%2F3.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/3.2.1) | +Versions `4.x` and `6.0` are the proper releases of this package. They are annotation-based. + +Version `5.x` is discontinued. It used PHP attributes, which are now provided by a separate package: [dot-dependency-injection](https://github.com/dotkernel/dot-dependency-injection) ([documentation](https://docs.dotkernel.org/dot-dependency-injection/)). +Use it if you want attributes instead of annotations. See [Version 5 is discontinued](https://github.com/dotkernel/dot-annotated-services/blob/6.0/docs/book/v5/overview.md) for migration notes. + ## Badges -![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-annotated-services%2Fblob%2F4.0%2FOSSMETADATA) -![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/4.4.0) +![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-annotated-services%2Fblob%2F6.0%2FOSSMETADATA) +![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/6.0.0) [![GitHub issues](https://img.shields.io/github/issues/dotkernel/dot-annotated-services)](https://github.com/dotkernel/dot-annotated-services/issues) [![GitHub forks](https://img.shields.io/github/forks/dotkernel/dot-annotated-services)](https://github.com/dotkernel/dot-annotated-services/network) [![GitHub stars](https://img.shields.io/github/stars/dotkernel/dot-annotated-services)](https://github.com/dotkernel/dot-annotated-services/stargazers) -[![GitHub license](https://img.shields.io/github/license/dotkernel/dot-annotated-services)](https://github.com/dotkernel/dot-annotated-services/blob/4.0/LICENSE.md) +[![GitHub license](https://img.shields.io/github/license/dotkernel/dot-annotated-services)](https://github.com/dotkernel/dot-annotated-services/blob/6.0/LICENSE.md) -[![Build Static](https://github.com/dotkernel/dot-annotated-services/actions/workflows/continuous-integration.yml/badge.svg?branch=4.0)](https://github.com/dotkernel/dot-annotated-services/actions/workflows/continuous-integration.yml) +[![Build Static](https://github.com/dotkernel/dot-annotated-services/actions/workflows/continuous-integration.yml/badge.svg?branch=6.0)](https://github.com/dotkernel/dot-annotated-services/actions/workflows/continuous-integration.yml) [![codecov](https://codecov.io/gh/dotkernel/dot-annotated-services/graph/badge.svg?token=ZBZDEA3LY8)](https://codecov.io/gh/dotkernel/dot-annotated-services) -[![PHPStan](https://github.com/dotkernel/dot-annotated-services/actions/workflows/static-analysis.yml/badge.svg?branch=4.0)](https://github.com/dotkernel/dot-annotated-services/actions/workflows/static-analysis.yml) +[![PHPStan](https://github.com/dotkernel/dot-annotated-services/actions/workflows/static-analysis.yml/badge.svg?branch=6.0)](https://github.com/dotkernel/dot-annotated-services/actions/workflows/static-analysis.yml) ## Installation @@ -40,13 +45,11 @@ Run the following command in your project directory composer require dotkernel/dot-annotated-services ``` -After installing, add the `ConfigProvider` class to your configuration aggregate. - ## Usage ### Using the AnnotatedServiceFactory -You can register services in the service manager using the `AnnotatedServiceFactory` as below. +You can register services in the container using the `AnnotatedServiceFactory` as below. ```php return [ @@ -79,9 +82,9 @@ public function __construct( ``` The annotation `@Inject` is telling the factory to inject the services between curly braces. -Valid service names should be provided, as registered in the service manager. +Valid service names should be provided, as registered in the container. -To inject an array value from the service manager, you can use dot notation as below. +To inject an array value from the container, you can use dot notation as below. ```php use Dot\AnnotatedServices\Annotation\Inject; @@ -113,7 +116,7 @@ The next step is to add the `@Entity` annotation in the repository class. The `name` field has to be the fully qualified class name. -Every repository should extend `Doctrine\ORM\EntityRepository`. +Every repository should extend `Doctrine\ORM\EntityRepository`, and must be the repository class of the entity. ```php use Doctrine\ORM\EntityRepository; @@ -127,33 +130,12 @@ class ExampleRepository extends EntityRepository } ``` -### Using the abstract factory - -Using this approach, no service manager configuration is required. It uses the registered abstract factory to create annotated services. - -To tell the abstract factory which services are to be created, you need to annotate the service class with the `@Service` annotation. - -```php -use Dot\AnnotatedServices\Annotation\Service; - -/* - * @Service - */ -class ServiceClass -{ - // configure injections as described in the previous section -} -``` - -And that's it, you don't need to configure the service manager with this class, creation will happen automatically. - ## Cache annotations -This package is built on top of `doctrine/annotation` and `doctrine/cache`. -To cache annotations, you should register a service factory at key `AbstractAnnotatedFactory::CACHE_SERVICE` that should return a valid `Doctrine\Common\Cache\Cache` cache driver. -See [Cache Drivers](https://github.com/doctrine/cache/tree/master/lib/Doctrine/Common/Cache) for available implementations offered by doctrine. +This package is built on top of `doctrine/annotations`. +To cache annotations, you should register a service factory at key `AbstractAnnotatedFactory::CACHE_SERVICE` that should return a valid PSR-6 cache pool, an implementation of `Psr\Cache\CacheItemPoolInterface`. -Below, we give an example, as defined in our frontend and admin starter applications: +Below, we give an example, using [symfony/cache](https://github.com/symfony/cache): ```php return [ @@ -161,7 +143,7 @@ return [ 'dependencies' => [ 'factories' => [ // used by dot-annotated-services to cache annotations - // needs to return a cache instance from Doctrine\Common\Cache + // needs to return a PSR-6 cache pool AbstractAnnotatedFactory::CACHE_SERVICE => AnnotationsCacheFactory::class, ] ], @@ -171,15 +153,19 @@ return [ ```php namespace Frontend\App\Factory; -use Doctrine\Common\Cache\FilesystemCache; +use Psr\Cache\CacheItemPoolInterface; use Psr\Container\ContainerInterface; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; class AnnotationsCacheFactory { - public function __invoke(ContainerInterface $container) + public function __invoke(ContainerInterface $container): CacheItemPoolInterface { //change this to suite your caching needs - return new FilesystemCache($container->get('config')['annotations_cache_dir']); + return new FilesystemAdapter( + namespace: 'annotations', + directory: $container->get('config')['annotations_cache_dir'], + ); } } ``` diff --git a/SECURITY.md b/SECURITY.md index 5889225..b54a862 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,7 @@ | Version | Supported | PHP Version | |---------|---------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------| +| 6.0 | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-annotated-services%2Fblob%2F6.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/6.0.0) | | 5.0 | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-annotated-services%2Fblob%2F5.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/5.2.0) | | 4.0 | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-annotated-services%2Fblob%2F4.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/4.4.0) | | 3.0 | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-annotated-services%2Fblob%2F3.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/3.2.1) | diff --git a/composer.json b/composer.json index 1f70e67..e1fa4e3 100644 --- a/composer.json +++ b/composer.json @@ -20,17 +20,20 @@ "service-manager" ], "require": { - "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", - "doctrine/annotations": "^1.14.3", - "doctrine/cache": "^1.12.1 || ^2.1.1", - "doctrine/orm" : "^2.20", - "laminas/laminas-servicemanager": "^3.22.1" + "php": "~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0", + "doctrine/annotations": "^2.0", + "doctrine/orm" : "^3.0", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "psr/container": "^1.1 || ^2.0" + }, + "suggest": { + "symfony/cache": "A PSR-6 pool to cache parsed annotations" }, "require-dev": { "laminas/laminas-coding-standard": "^3.0", "phpstan/phpstan": "^2.1", "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.5.9" + "phpunit/phpunit": "^12.0" }, "autoload": { "psr-4": { diff --git a/docs/book/v5/overview.md b/docs/book/v5/overview.md new file mode 100644 index 0000000..c858ab5 --- /dev/null +++ b/docs/book/v5/overview.md @@ -0,0 +1,45 @@ +# Version 5 is discontinued + +Version `5.x` of `dot-annotated-services` is discontinued and receives no further releases. +The last release was `5.2.0`. + +Version `5.x` was a separate line of this package that replaced annotations with PHP attributes. +That functionality now lives in its own package, [dot-dependency-injection](https://github.com/dotkernel/dot-dependency-injection), which is the supported alternative for attribute-based dependency injection. + +## Which package to use + +| You want | Use | +| --- | --- | +| PHP attributes | [dot-dependency-injection](https://docs.dotkernel.org/dot-dependency-injection/) | +| Annotations (`doctrine/annotations`) | `dot-annotated-services` `6.0`, or `4.x` if you have to stay on Doctrine ORM `2.x` | + +Versions `4.x` and `6.0` are the proper releases of `dot-annotated-services`. +They are annotation-based and are documented in [version 6](../v6/overview.md) and [version 4](../v4/overview.md). + +## Migrating from 5.x to dot-dependency-injection + +The attributes and factories keep their names and syntax. +Only the package and the namespaces change. + +Replace the package: + +```shell +composer remove dotkernel/dot-annotated-services +composer require dotkernel/dot-dependency-injection +``` + +Replace the namespaces in your code and configuration: + +| 5.x (`dot-annotated-services`) | `dot-dependency-injection` | +| --- | --- | +| `Dot\AnnotatedServices\Attribute\Inject` | `Dot\DependencyInjection\Attribute\Inject` | +| `Dot\AnnotatedServices\Attribute\Entity` | `Dot\DependencyInjection\Attribute\Entity` | +| `Dot\AnnotatedServices\Factory\AttributedServiceFactory` | `Dot\DependencyInjection\Factory\AttributedServiceFactory` | +| `Dot\AnnotatedServices\Factory\AttributedRepositoryFactory` | `Dot\DependencyInjection\Factory\AttributedRepositoryFactory` | +| `Dot\AnnotatedServices\ConfigProvider` | `Dot\DependencyInjection\ConfigProvider` | + +Check the requirements, including the supported PHP versions, in the [dot-dependency-injection documentation](https://docs.dotkernel.org/dot-dependency-injection/). + +## Migrating from 5.x to annotations + +If you prefer annotations, follow the [upgrade guide](../v6/upgrading.md#from-5x) for `6.0`. diff --git a/docs/book/v6/cache.md b/docs/book/v6/cache.md new file mode 100644 index 0000000..a889565 --- /dev/null +++ b/docs/book/v6/cache.md @@ -0,0 +1,53 @@ +# Caching the annotations + +`dot-annotated-services` reads class annotations using [doctrine/annotations](https://github.com/doctrine/annotations) and can cache them in any [PSR-6](https://www.php-fig.org/psr/psr-6/) cache pool. + +## Configuration + +To cache annotations, register a service factory at key `Dot\AnnotatedServices\Factory\AbstractAnnotatedFactory::CACHE_SERVICE` (which is `Psr\Cache\CacheItemPoolInterface::class`). +It must return an implementation of `Psr\Cache\CacheItemPoolInterface`. + +Without this service, annotations are parsed on every request. + +If the `config` service has the key `debug` set to `true`, the cache checks whether the annotated files changed and refreshes stale entries. +Keep it `false` in production. + +See below an example on how you can configure `dot-annotated-services` to cache annotations. +You can add this configuration value to your application's config file: + +```php +'annotations_cache_dir' => __DIR__ . '/../../data/cache/annotations', +'dependencies' => [ + 'factories' => [ + Dot\AnnotatedServices\Factory\AbstractAnnotatedFactory::CACHE_SERVICE => YourApp\Factory\AnnotationsCacheFactory::class, + ], +], +``` + +where `AnnotationsCacheFactory` is a custom factory that needs to return a PSR-6 cache pool. +The example below uses [symfony/cache](https://github.com/symfony/cache), but any PSR-6 implementation works: + +```php +get('config')['annotations_cache_dir'], + ); + } +} +``` + +> Cache drivers from `doctrine/cache`, such as `FilesystemCache`, are not PSR-6 pools and cannot be used directly. diff --git a/docs/book/v6/configuration.md b/docs/book/v6/configuration.md new file mode 100644 index 0000000..8de419a --- /dev/null +++ b/docs/book/v6/configuration.md @@ -0,0 +1,12 @@ +# Configuration + +`dot-annotated-services` needs no global registration. +The factories are registered per class, under `factories` in your own `ConfigProvider`. +See [Inject class dependencies](factories/service.md) and [Inject entity repositories](factories/repository.md). + +`Dot\AnnotatedServices\ConfigProvider` is still provided, but it returns an empty configuration. +You can keep it in your configuration aggregator (usually: `config/config.php`) if it is already there, or leave it out. + +The factories only depend on `psr/container`, so they work with any PSR-11 container that accepts a factory callable per service key, such as Laminas Service Manager. + +See [Factories](factories.md) for details and [Cache](cache.md) to cache the parsed annotations. diff --git a/docs/book/v6/factories.md b/docs/book/v6/factories.md new file mode 100644 index 0000000..4d9d0c6 --- /dev/null +++ b/docs/book/v6/factories.md @@ -0,0 +1,45 @@ +# Factories + +`dot-annotated-services` is based on two reusable factories - `AnnotatedRepositoryFactory` and `AnnotatedServiceFactory` - able to inject any dependency into a class. + +## AnnotatedRepositoryFactory + +Injects entity repositories into a class. + +The repository must extend `Doctrine\ORM\EntityRepository` and carry the `@Entity` annotation. +The repository class returned by the entity manager must be the registered class: set the repository class on the entity (`#[ORM\Entity(repositoryClass: ...)]`) so that `EntityManager::getRepository()` returns an instance of it. + +### Exceptions thrown + +- `Dot\AnnotatedServices\Exception\RuntimeException` if repository does not exist +- `Dot\AnnotatedServices\Exception\RuntimeException` if repository does not extend `Doctrine\ORM\EntityRepository` +- `Dot\AnnotatedServices\Exception\RuntimeException` if repository does not have `@Entity` annotation +- `Dot\AnnotatedServices\Exception\RuntimeException` if the entity manager 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 container is unable to provide an instance of `Doctrine\ORM\EntityManagerInterface` + +## AnnotatedServiceFactory + +Injects class dependencies into classes. + +A constructor with required parameters must carry the `@Inject` annotation. +A constructor without required parameters can omit it. + +If a dependency is specified using the dot notation, `AnnotatedServiceFactory` 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. + +If a dependency is not registered in the service container but is the name of an existing class, `AnnotatedServiceFactory` creates it with `new`, without constructor arguments. + +You can use the `@Inject` annotation on public setters too. +They are called, in declaration order, after the object is constructed and injected with the configured dependencies. +The constructor is only called once, and static methods are ignored. + +### Exceptions thrown + +- `Dot\AnnotatedServices\Exception\RuntimeException` if service does not exist +- `Dot\AnnotatedServices\Exception\RuntimeException` if service does not have `@Inject` annotation on its constructor, and the constructor has required parameters +- `Dot\AnnotatedServices\Exception\RuntimeException` if a dependency is neither a service nor a class +- `Dot\AnnotatedServices\Exception\InvalidArgumentException` if a key of a dotted dependency does not exist, including when the path is longer than the data +- `ReflectionException` on failure of creating a ReflectionClass of the dependency +- `Psr\Container\NotFoundExceptionInterface` if a dependency does not exist in the service container +- `Psr\Container\ContainerExceptionInterface` if the container is unable to provide an instance of a dependency diff --git a/docs/book/v6/factories/repository.md b/docs/book/v6/factories/repository.md new file mode 100644 index 0000000..1d68a41 --- /dev/null +++ b/docs/book/v6/factories/repository.md @@ -0,0 +1,68 @@ +# Inject entity repositories + +## Prepare repository + +`dot-annotated-services` determines the entity a repository is related to by looking at the `@Entity` annotation, added to the repository class. + +```php + $this->getDependencies(), + ]; + } + + public function getDependencies(): array + { + return [ + 'factories' => [ + YourApp\Repository\ExampleRepository::class => Dot\AnnotatedServices\Factory\AnnotatedRepositoryFactory::class, + ], + ]; + } +} +``` diff --git a/docs/book/v6/factories/service.md b/docs/book/v6/factories/service.md new file mode 100644 index 0000000..b484d82 --- /dev/null +++ b/docs/book/v6/factories/service.md @@ -0,0 +1,122 @@ +# Inject class dependencies + +## Prepare class + +`dot-annotated-services` determines the dependencies by looking at the `@Inject` annotation, added to the constructor of a class. +Dependencies are specified as a list of service names or FQCNs. + +```php +helper = $helper; + } +``` + +The constructor is never called a second time, so classes with `readonly` promoted properties work. +Static methods are ignored. + +## Register class + +Open the ConfigProvider of the module where your class resides. + +Add a new entry under `factories`, where the key is your class FQCN and the value is `Dot\AnnotatedServices\Factory\AnnotatedServiceFactory::class`. + +See the below example for a better understanding of the file structure. + +```php + $this->getDependencies(), + ]; + } + + public function getDependencies(): array + { + return [ + 'factories' => [ + YourApp\Service\Example::class => Dot\AnnotatedServices\Factory\AnnotatedServiceFactory::class, + ], + ]; + } +} +``` + +> You can use only the fully qualified class name as the service key. diff --git a/docs/book/v6/installation.md b/docs/book/v6/installation.md new file mode 100644 index 0000000..abc9cac --- /dev/null +++ b/docs/book/v6/installation.md @@ -0,0 +1,15 @@ +# Installation + +`dot-annotated-services` requires PHP `8.3` or newer. + +Install `dotkernel/dot-annotated-services` by executing the following Composer command: + +```shell +composer require dotkernel/dot-annotated-services +``` + +To [cache the parsed annotations](cache.md), also install any PSR-6 cache implementation, for example: + +```shell +composer require symfony/cache +``` diff --git a/docs/book/v6/overview.md b/docs/book/v6/overview.md new file mode 100644 index 0000000..c2f055a --- /dev/null +++ b/docs/book/v6/overview.md @@ -0,0 +1,44 @@ +# Overview + +`dot-annotated-services` is Dotkernel's dependency injection service using annotations. + +By providing reusable factories for service and repository injection, it reduces code complexity in projects. + +Version `6.0` continues the annotation-based API of version `4.x`. +Version `5.x` is a separate package line that uses PHP attributes instead of annotations. +See the [upgrade guide](upgrading.md) if you are coming from `4.x` or `5.x`. + +## Requirements + +- PHP `8.3`, `8.4`, `8.5` or `8.6` +- PSR-11 version `1` or `2` +- `doctrine/annotations` `^2.0` +- `doctrine/orm` `^3.0` + +## Version History + +| Branch | PSR-11 | Docblock Comments | OSS Lifecycle | PHP Version | +|--------|----------|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------| +| 6.0 | 1 \|\| 2 | Annotations | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-annotated-services%2Fblob%2F6.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/6.0.0) | +| 5.0 | 1 \|\| 2 | Attributes | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-annotated-services%2Fblob%2F5.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/5.2.0) | +| 4.0 | 1 | Annotations | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-annotated-services%2Fblob%2F4.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/4.4.0) | +| 3.0 | 1 | Annotations | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-annotated-services%2Fblob%2F3.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/3.2.1) | + +Versions `4.x` and `6.0` are the proper releases of this package. They are annotation-based. + +Version `5.x` is discontinued. It used PHP attributes, which are now provided by a separate package: [dot-dependency-injection](https://github.com/dotkernel/dot-dependency-injection) ([documentation](https://docs.dotkernel.org/dot-dependency-injection/)). +Use it if you want attributes instead of annotations. See [Version 5 is discontinued](../v5/overview.md) for migration notes. + +## Badges + +![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-annotated-services%2Fblob%2F6.0%2FOSSMETADATA) +![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/6.0.0) + +[![GitHub issues](https://img.shields.io/github/issues/dotkernel/dot-annotated-services)](https://github.com/dotkernel/dot-annotated-services/issues) +[![GitHub forks](https://img.shields.io/github/forks/dotkernel/dot-annotated-services)](https://github.com/dotkernel/dot-annotated-services/network) +[![GitHub stars](https://img.shields.io/github/stars/dotkernel/dot-annotated-services)](https://github.com/dotkernel/dot-annotated-services/stargazers) +[![GitHub license](https://img.shields.io/github/license/dotkernel/dot-annotated-services)](https://github.com/dotkernel/dot-annotated-services/blob/6.0/LICENSE.md) + +[![Build Static](https://github.com/dotkernel/dot-annotated-services/actions/workflows/continuous-integration.yml/badge.svg?branch=6.0)](https://github.com/dotkernel/dot-annotated-services/actions/workflows/continuous-integration.yml) +[![codecov](https://codecov.io/gh/dotkernel/dot-annotated-services/graph/badge.svg?token=ZBZDEA3LY8)](https://codecov.io/gh/dotkernel/dot-annotated-services) +[![PHPStan](https://github.com/dotkernel/dot-annotated-services/actions/workflows/static-analysis.yml/badge.svg?branch=6.0)](https://github.com/dotkernel/dot-annotated-services/actions/workflows/static-analysis.yml) diff --git a/docs/book/v6/upgrading.md b/docs/book/v6/upgrading.md new file mode 100644 index 0000000..efadec3 --- /dev/null +++ b/docs/book/v6/upgrading.md @@ -0,0 +1,139 @@ +# Upgrading to 6.0 + +Version `6.0` continues the annotation-based API of `4.x`. +The `5.x` releases are a separate, attribute-based package line. +Follow the section that matches the version you are coming from. + +## From 4.x + +### Requirements + +- PHP `8.3` or newer is required. PHP `8.2` is no longer supported. +- `doctrine/orm` `^3.0` and `doctrine/annotations` `^2.0` are required. Applications still on Doctrine ORM `2.x` or `doctrine/annotations` `1.x` must upgrade those first, or stay on `4.x`. +- `doctrine/cache` is no longer a requirement of this package, and `doctrine/orm` `3.x` does not install it. If your application uses its classes, require it yourself. +- `laminas/laminas-servicemanager` is no longer a requirement. The factories only depend on `psr/container`, version `1.1` or `2`. If your application uses Laminas Service Manager, require it yourself. +- `psr/cache` and `psr/container` are now declared as direct requirements. + +### Changes that can break your code + +| Change | Symptom | Fix | +| --- | --- | --- | +| `AnnotatedServiceAbstractFactory` and the `@Service` annotation are removed | Class not found, or the service is no longer created automatically | Register the class under `factories` with `AnnotatedServiceFactory`, and remove `@Service` and the abstract factory registration. See below | +| `Dot\AnnotatedServices\ConfigProvider` no longer registers the abstract factory | `abstract_factories` is no longer filled by this package | None, unless you read that configuration key | +| The constructor is no longer called a second time when it carries `@Inject` | Code that relied on the constructor running twice no longer does | Remove that reliance. Classes with `readonly` promoted properties now work | +| `AnnotatedRepositoryFactory` throws if the entity manager returns a repository that is not an instance of the registered class | `Dot\AnnotatedServices\Exception\RuntimeException: Entity manager returned a repository of type ...` | Set `repositoryClass` on the entity to the registered repository, or register the class the entity manager returns | +| A dotted dependency whose path is longer than the data now throws | `InvalidArgumentException` where `config.a.b` used to inject the value of `config.a` | Fix the key path | +| `AnnotatedServiceFactory::__invoke()` and `createObject()` return `object` instead of `mixed` | Fatal error in subclasses that override them with an incompatible return type | Return an `object` from the override | +| `AbstractAnnotatedFactory::CACHE_SERVICE` is a typed constant (`string`) | Fatal error in subclasses that redeclare it untyped | Declare it as `public const string` | + +### Behavior that now works + +- `@Inject("config")` without curly braces is accepted. +- A dotted key whose value is `null` is injected as `null`. It used to throw. + +### Replacing `@Service` + +Before, on `4.x`: + +```php +/** + * @Service + */ +class ExampleService +{ +} +``` + +After, on `6.0`: remove the annotation and register the class with the factory in your `ConfigProvider`: + +```php +'dependencies' => [ + 'factories' => [ + ExampleService::class => Dot\AnnotatedServices\Factory\AnnotatedServiceFactory::class, + ], +], +``` + +Also remove `Dot\AnnotatedServices\Factory\AnnotatedServiceAbstractFactory` from `abstract_factories` if you registered it yourself. + +### Documentation changes + +- The cache documentation of `4.x` described `doctrine/cache` drivers. The package has always required a PSR-6 cache pool. See [Cache](cache.md). + +## From 5.x + +`5.x` uses PHP attributes. `6.0` uses annotations, and adds setter injection and an annotation cache. +Migrating from `5.x` to `6.0` means moving back from attributes to annotations. + +### Classes + +| 5.x | 6.0 | +| --- | --- | +| `Dot\AnnotatedServices\Attribute\Inject` | `Dot\AnnotatedServices\Annotation\Inject` | +| `Dot\AnnotatedServices\Attribute\Entity` | `Dot\AnnotatedServices\Annotation\Entity` | +| `Dot\AnnotatedServices\Factory\AttributedServiceFactory` | `Dot\AnnotatedServices\Factory\AnnotatedServiceFactory` | +| `Dot\AnnotatedServices\Factory\AttributedRepositoryFactory` | `Dot\AnnotatedServices\Factory\AnnotatedRepositoryFactory` | + +### Syntax + +Before, on `5.x`: + +```php +use Dot\AnnotatedServices\Attribute\Entity; +use Dot\AnnotatedServices\Attribute\Inject; + +#[Entity(name: Example::class)] +class ExampleRepository extends EntityRepository +{ +} + +class ExampleService +{ + #[Inject(ExampleRepository::class, 'config')] + public function __construct( + protected ExampleRepository $repository, + protected array $config, + ) { + } +} +``` + +After, on `6.0`: + +```php +use Dot\AnnotatedServices\Annotation\Entity; +use Dot\AnnotatedServices\Annotation\Inject; + +/** + * @Entity(name="App\Entity\Example") + */ +class ExampleRepository extends EntityRepository +{ +} + +class ExampleService +{ + /** + * @Inject({ + * ExampleRepository::class, + * "config" + * }) + */ + public function __construct( + protected ExampleRepository $repository, + protected array $config, + ) { + } +} +``` + +### Registration + +Replace the factory class names in your `ConfigProvider`. Nothing needs to be added to your configuration aggregator. + +### Differences to be aware of + +- `6.0` requires Doctrine ORM `3.x`. `5.x` also supported `2.x`. +- `6.0` has no check for services that inject themselves. A service listed in its own `@Inject` is not reported as a recursion. +- `6.0` additionally supports `@Inject` on setters, and caching the parsed annotations (see [Cache](cache.md)). +- `6.0` requires `doctrine/annotations` and a PSR-6 pool if you want to cache annotations. diff --git a/docs/book/v6/usage.md b/docs/book/v6/usage.md new file mode 100644 index 0000000..ff8b253 --- /dev/null +++ b/docs/book/v6/usage.md @@ -0,0 +1,15 @@ +# Usage + +Version `6.x` of `dot-annotated-services` uses [doctrine/annotations](https://github.com/doctrine/annotations) to parse and inject dependencies. +It continues the annotation-based API of version `4.x`. +Version `5.x` is a separate package line that uses PHP attributes instead. + +> Annotations must be written in a docblock, which starts with `/**`. +> A comment starting with `/*` is ignored. + +You can use it to: + +- [Inject class dependencies](factories/service.md) +- [Inject entity repositories](factories/repository.md) + +Coming from `4.x` or `5.x`? Read the [upgrade guide](upgrading.md). diff --git a/mkdocs.yml b/mkdocs.yml index ae5f3d8..d9bbc7c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,11 +2,26 @@ docs_dir: docs/book site_dir: docs/html extra: project: Packages - current_version: v4 + current_version: v6 versions: + - v6 + - v5 - v4 nav: - Home: index.md + - v6: + - Overview: v6/overview.md + - Installation: v6/installation.md + - Configuration: v6/configuration.md + - Usage: v6/usage.md + - Factories: v6/factories.md + - Cache: v6/cache.md + - Upgrading: v6/upgrading.md + - Reference: + - "Inject class dependencies": v6/factories/service.md + - "Inject entity repositories": v6/factories/repository.md + - v5: + - Discontinued: v5/overview.md - v4: - Overview: v4/overview.md - Installation: v4/installation.md diff --git a/phpstan.neon b/phpstan.neon index 349be25..08d6acd 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,7 +1,7 @@ includes: - vendor/phpstan/phpstan-phpunit/extension.neon parameters: - level: 5 + level: 8 paths: - src - test diff --git a/phpunit.xml b/phpunit.xml index 65d03b8..b2a6f47 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,14 +1,13 @@ - - - - ./test - - - - - - ./src - - + + + + ./test + + + + + ./src + + diff --git a/src/Annotation/Entity.php b/src/Annotation/Entity.php index c8c84f9..9a93856 100644 --- a/src/Annotation/Entity.php +++ b/src/Annotation/Entity.php @@ -16,14 +16,23 @@ */ class Entity { - /** @Required */ + /** + * @Required + * @var class-string + */ private string $name; + /** + * @param class-string $name + */ public function __construct(string $name) { $this->name = $name; } + /** + * @return class-string + */ public function getName(): string { return $this->name; diff --git a/src/Annotation/Inject.php b/src/Annotation/Inject.php index cb13367..cee96fd 100644 --- a/src/Annotation/Inject.php +++ b/src/Annotation/Inject.php @@ -7,19 +7,28 @@ use Doctrine\Common\Annotations\Annotation; use Doctrine\Common\Annotations\Annotation\Target; +use function array_values; + /** * @Annotation * @Target({"METHOD"}) */ class Inject { + /** @var list */ private array $services = []; + /** + * @param array{value?: string|list} $values + */ public function __construct(array $values) { - $this->services = $values['value'] ?? []; + $this->services = array_values((array) ($values['value'] ?? [])); } + /** + * @return list + */ public function getServices(): array { return $this->services; diff --git a/src/Annotation/Service.php b/src/Annotation/Service.php deleted file mode 100644 index b378408..0000000 --- a/src/Annotation/Service.php +++ /dev/null @@ -1,17 +0,0 @@ - + */ public function __invoke(): array { - return [ - 'dependencies' => $this->getDependenciesConfig(), - ]; - } - - public function getDependenciesConfig(): array - { - return [ - 'abstract_factories' => [ - AnnotatedServiceAbstractFactory::class, - ], - ]; + return []; } } diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php index 0571bb7..700c73b 100644 --- a/src/Exception/ExceptionInterface.php +++ b/src/Exception/ExceptionInterface.php @@ -4,9 +4,6 @@ namespace Dot\AnnotatedServices\Exception; -/** - * Interface ExceptionInterface - */ interface ExceptionInterface { } diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php index 72ea6c2..fccf439 100644 --- a/src/Exception/RuntimeException.php +++ b/src/Exception/RuntimeException.php @@ -31,6 +31,17 @@ public static function annotationNotFound(string $annotation, string $class, str )); } + public static function repositoryMismatch(string $requestedName, string $actualClass): self + { + return new self(sprintf( + 'Entity manager returned a repository of type "%s", but "%s" was requested. ' + . 'Set "%s" as the repositoryClass of the entity.', + $actualClass, + $requestedName, + $requestedName + )); + } + public static function invalidAnnotation(string $requestedName): self { return new self(sprintf( diff --git a/src/Factory/AbstractAnnotatedFactory.php b/src/Factory/AbstractAnnotatedFactory.php index bc4d523..b00ae5b 100644 --- a/src/Factory/AbstractAnnotatedFactory.php +++ b/src/Factory/AbstractAnnotatedFactory.php @@ -14,8 +14,10 @@ abstract class AbstractAnnotatedFactory { - public const CACHE_SERVICE = CacheItemPoolInterface::class; + public const string CACHE_SERVICE = CacheItemPoolInterface::class; + protected ?Reader $annotationReader = null; + public function setAnnotationReader(Reader $annotationReader): void { $this->annotationReader = $annotationReader; @@ -33,17 +35,16 @@ protected function createAnnotationReader(ContainerInterface $container): Reader if (! $container->has(self::CACHE_SERVICE)) { return $this->annotationReader = new AnnotationReader(); - } else { - /** @var CacheItemPoolInterface $cache */ - $cache = $container->get(self::CACHE_SERVICE); - $debug = false; - if ($container->has('config')) { - $config = $container->get('config'); - if (isset($config['debug'])) { - $debug = (bool) $config['debug']; - } - } - return $this->annotationReader = new PsrCachedReader(new AnnotationReader(), $cache, $debug); } + + /** @var CacheItemPoolInterface $cache */ + $cache = $container->get(self::CACHE_SERVICE); + $debug = false; + if ($container->has('config')) { + $config = $container->get('config'); + $debug = isset($config['debug']) && (bool) $config['debug']; + } + + return $this->annotationReader = new PsrCachedReader(new AnnotationReader(), $cache, $debug); } } diff --git a/src/Factory/AnnotatedRepositoryFactory.php b/src/Factory/AnnotatedRepositoryFactory.php index 45fec3e..90b801b 100644 --- a/src/Factory/AnnotatedRepositoryFactory.php +++ b/src/Factory/AnnotatedRepositoryFactory.php @@ -19,6 +19,7 @@ class AnnotatedRepositoryFactory extends AbstractAnnotatedFactory { /** + * @return EntityRepository * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface * @throws ReflectionException @@ -30,9 +31,11 @@ public function __invoke(ContainerInterface $container, string $requestedName): } /** + * @return EntityRepository * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface * @throws ReflectionException + * @throws RuntimeException */ public function createObject(ContainerInterface $container, string $requestedName): EntityRepository { @@ -45,13 +48,19 @@ public function createObject(ContainerInterface $container, string $requestedNam throw RuntimeException::doesNotExtend(EntityRepository::class); } - $annotationReader = $this->createAnnotationReader($container); - $entity = $annotationReader->getClassAnnotation($reflectionClass, Entity::class); - if (! $entity) { + $entity = $this->createAnnotationReader($container)->getClassAnnotation($reflectionClass, Entity::class); + if (! $entity instanceof Entity) { throw RuntimeException::annotationNotFound(Entity::class, $requestedName, static::class); } + /** @var EntityManagerInterface $entityManager */ $entityManager = $container->get(EntityManagerInterface::class); - return $entityManager->getRepository($entity->getName()); + $repository = $entityManager->getRepository($entity->getName()); + + if (! $repository instanceof $requestedName) { + throw RuntimeException::repositoryMismatch($requestedName, $repository::class); + } + + return $repository; } } diff --git a/src/Factory/AnnotatedServiceAbstractFactory.php b/src/Factory/AnnotatedServiceAbstractFactory.php deleted file mode 100644 index c53b97b..0000000 --- a/src/Factory/AnnotatedServiceAbstractFactory.php +++ /dev/null @@ -1,50 +0,0 @@ -createAnnotationReader($container); - $refClass = new ReflectionClass($requestedName); - - $service = $annotationReader->getClassAnnotation($refClass, Service::class); - if ($service === null) { - return false; - } - - return true; - } - - /** - * @param string $requestedName - */ - public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null): object - { - $factory = new AnnotatedServiceFactory(); - $factory->setAnnotationReader($this->createAnnotationReader($container)); - - return $factory->createObject($container, $requestedName); - } -} diff --git a/src/Factory/AnnotatedServiceFactory.php b/src/Factory/AnnotatedServiceFactory.php index a75c2ea..bf1ba08 100644 --- a/src/Factory/AnnotatedServiceFactory.php +++ b/src/Factory/AnnotatedServiceFactory.php @@ -15,6 +15,7 @@ use ReflectionException; use ReflectionMethod; +use function array_key_exists; use function array_shift; use function class_exists; use function count; @@ -28,8 +29,9 @@ class AnnotatedServiceFactory extends AbstractAnnotatedFactory * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface * @throws ReflectionException + * @throws RuntimeException */ - public function __invoke(ContainerInterface $container, string $requestedName): mixed + public function __invoke(ContainerInterface $container, string $requestedName): object { return $this->createObject($container, $requestedName); } @@ -38,15 +40,14 @@ public function __invoke(ContainerInterface $container, string $requestedName): * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface * @throws ReflectionException + * @throws RuntimeException */ - public function createObject(ContainerInterface $container, string $requestedName): mixed + public function createObject(ContainerInterface $container, string $requestedName): object { if (! class_exists($requestedName)) { throw RuntimeException::classNotFound($requestedName); } - $service = null; - $annotationReader = $this->createAnnotationReader($container); $refClass = $this->getReflectionClass($requestedName); $constructor = $refClass->getConstructor(); @@ -55,7 +56,7 @@ public function createObject(ContainerInterface $container, string $requestedNam $service = new $requestedName(); } else { $inject = $annotationReader->getMethodAnnotation($constructor, Inject::class); - if ($inject === null && $constructor->getNumberOfRequiredParameters() > 0) { + if (! $inject instanceof Inject && $constructor->getNumberOfRequiredParameters() > 0) { throw RuntimeException::annotationNotFound( Inject::class, $requestedName, @@ -63,27 +64,30 @@ public function createObject(ContainerInterface $container, string $requestedNam ); } - $services = []; - if ($inject) { - $services = $this->getServicesToInject($container, $inject); - } + $services = $inject instanceof Inject ? $this->getServicesToInject($container, $inject) : []; $service = new $requestedName(...$services); } - $methods = $refClass->getMethods(ReflectionMethod::IS_PUBLIC); - foreach ($methods as $method) { + foreach ($refClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + // The constructor was already called above with its injected services + if ($method->isConstructor() || $method->isStatic()) { + continue; + } + $inject = $annotationReader->getMethodAnnotation($method, Inject::class); - if ($inject) { - $services = $this->getServicesToInject($container, $inject); - $method->invoke($service, ...$services); + if (! $inject instanceof Inject) { + continue; } + + $method->invoke($service, ...$this->getServicesToInject($container, $inject)); } return $service; } /** + * @return list * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface */ @@ -108,30 +112,47 @@ protected function getServicesToInject(ContainerInterface $container, Inject $in throw RuntimeException::classNotFound($serviceKey); } - $services[] = empty($parts) ? $service : $this->readKeysFromArray($parts, $service); + $services[] = $parts === [] ? $service : $this->readKeysFromArray($parts, $service); } return $services; } + /** + * @param non-empty-list $keys + * @throws InvalidArgumentException + */ protected function readKeysFromArray(array $keys, mixed $array): mixed { $key = array_shift($keys); - // When one of the provided keys is not found, throw an exception - if (! isset($array[$key])) { + + if (is_array($array)) { + $exists = array_key_exists($key, $array); + } elseif ($array instanceof ArrayAccess) { + $exists = $array->offsetExists($key); + } else { + $exists = false; + } + + // Also thrown when the path is longer than the data, e.g. "config.a.b" where "a" is a scalar + if (! $exists) { throw new InvalidArgumentException(sprintf( 'The key "%s" provided in the dotted notation could not be found in the array service', $key )); } + $value = $array[$key]; - if (! empty($keys) && (is_array($value) || $value instanceof ArrayAccess)) { - $value = $this->readKeysFromArray($keys, $value); + if ($keys === []) { + return $value; } - return $value; + + return $this->readKeysFromArray($keys, $value); } /** + * @param class-string $requestedName + * @return ReflectionClass * @throws ReflectionException */ protected function getReflectionClass(string $requestedName): ReflectionClass diff --git a/test/AbstractAnnotatedFactoryTest.php b/test/AbstractAnnotatedFactoryTest.php new file mode 100644 index 0000000..6eeab1f --- /dev/null +++ b/test/AbstractAnnotatedFactoryTest.php @@ -0,0 +1,73 @@ +subject = new ExposedAnnotatedFactory(); + } + + /** + * @param array $services + */ + private function container(array $services = []): ArrayContainer + { + return new ArrayContainer($services); + } + + public function testUsesPlainReaderWhenNoCacheIsRegistered(): void + { + $this->assertInstanceOf(AnnotationReader::class, $this->subject->reader($this->container())); + } + + public function testUsesCachedReaderWhenACachePoolIsRegistered(): void + { + $container = $this->container([ + AbstractAnnotatedFactory::CACHE_SERVICE => $this->createStub(CacheItemPoolInterface::class), + ]); + + $this->assertInstanceOf(PsrCachedReader::class, $this->subject->reader($container)); + } + + public function testUsesCachedReaderWhenCacheAndConfigAreRegistered(): void + { + $container = $this->container([ + AbstractAnnotatedFactory::CACHE_SERVICE => $this->createStub(CacheItemPoolInterface::class), + 'config' => ['debug' => true], + ]); + + $this->assertInstanceOf(PsrCachedReader::class, $this->subject->reader($container)); + } + + public function testReusesTheReaderOnceCreated(): void + { + $container = $this->container(); + + $this->assertSame($this->subject->reader($container), $this->subject->reader($container)); + } + + public function testExplicitlySetReaderTakesPrecedence(): void + { + $reader = $this->createStub(Reader::class); + $this->subject->setAnnotationReader($reader); + + $this->assertSame($reader, $this->subject->reader($this->container())); + } +} diff --git a/test/AnnotatedRepositoryFactoryTest.php b/test/AnnotatedRepositoryFactoryTest.php index 0b498c1..fe95b23 100644 --- a/test/AnnotatedRepositoryFactoryTest.php +++ b/test/AnnotatedRepositoryFactoryTest.php @@ -4,118 +4,90 @@ namespace DotTest\AnnotatedServices; -use Doctrine\Common\Annotations\Reader; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Dot\AnnotatedServices\Annotation\Entity; use Dot\AnnotatedServices\Exception\RuntimeException; -use Dot\AnnotatedServices\Factory\AnnotatedRepositoryFactory as Subject; -//use DotTest\AnnotatedServices\TestClass; -use PHPUnit\Framework\MockObject\Exception; -use PHPUnit\Framework\MockObject\MockObject; +use Dot\AnnotatedServices\Factory\AnnotatedRepositoryFactory; +use DotTest\AnnotatedServices\Fixture\ArrayContainer; +use DotTest\AnnotatedServices\Fixture\ExampleEntity; +use DotTest\AnnotatedServices\Fixture\ExampleRepository; +use DotTest\AnnotatedServices\Fixture\PlainClass; +use DotTest\AnnotatedServices\Fixture\RepositoryWithoutAnnotation; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerExceptionInterface; -use Psr\Container\ContainerInterface; -use Psr\Container\NotFoundExceptionInterface; -use ReflectionException; - -use function get_class; +#[CoversClass(AnnotatedRepositoryFactory::class)] class AnnotatedRepositoryFactoryTest extends TestCase { - private MockObject&ContainerInterface $container; - private MockObject&Subject $subject; - private MockObject&Reader $annotationReader; - - /** - * @throws Exception - */ - public function setUp(): void + private AnnotatedRepositoryFactory $subject; + + protected function setUp(): void { - $this->container = $this->createMock(ContainerInterface::class); - $this->annotationReader = $this->createMock(Reader::class); - $this->subject = $this->createPartialMock(Subject::class, ['createAnnotationReader']); + $this->subject = new AnnotatedRepositoryFactory(); } - /** - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - * @throws ReflectionException - */ - public function testThrowsExceptionClassNotFound() + private function container(EntityManagerInterface $entityManager): ArrayContainer { - $requestedName = 'TestRepository'; - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage(RuntimeException::classNotFound($requestedName)->getMessage()); - - $this->subject->__invoke($this->container, $requestedName); + return new ArrayContainer([EntityManagerInterface::class => $entityManager]); } - /** - * @throws ContainerExceptionInterface - * @throws ReflectionException - * @throws NotFoundExceptionInterface - */ - public function testThrowsExceptionClassNotExtendsEntityRepository() + public function testThrowsExceptionClassNotFound(): void { - $requestedName = TestClass::class; + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(RuntimeException::classNotFound('TestRepository')->getMessage()); - $this->getMockBuilder($requestedName)->getMock(); + ($this->subject)($this->container($this->createStub(EntityManagerInterface::class)), 'TestRepository'); + } + public function testThrowsExceptionClassDoesNotExtendEntityRepository(): void + { $this->expectException(RuntimeException::class); $this->expectExceptionMessage(RuntimeException::doesNotExtend(EntityRepository::class)->getMessage()); - $this->subject->__invoke($this->container, $requestedName); + + ($this->subject)($this->container($this->createStub(EntityManagerInterface::class)), PlainClass::class); } - /** - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - * @throws Exception - * @throws ReflectionException - */ - public function testCreateObjectThrowsExceptionAnnotationNotFound() + public function testThrowsExceptionWhenEntityAnnotationIsMissing(): void { - $repository = $this->createMock(EntityRepository::class); - $this->annotationReader->method('getClassAnnotation')->willReturn(null); - - $this->subject->method('createAnnotationReader')->willReturn($this->annotationReader); - $this->expectException(RuntimeException::class); $this->expectExceptionMessage(RuntimeException::annotationNotFound( Entity::class, - $repository::class, - get_class($this->subject) + RepositoryWithoutAnnotation::class, + AnnotatedRepositoryFactory::class )->getMessage()); - $this->subject->__invoke($this->container, $repository::class); + ($this->subject)( + $this->container($this->createStub(EntityManagerInterface::class)), + RepositoryWithoutAnnotation::class + ); } - /** - * @throws ContainerExceptionInterface - * @throws Exception - * @throws NotFoundExceptionInterface - * @throws ReflectionException - */ - public function testCreateObjectReturnsEntityRepository() + public function testReturnsRepositoryFromEntityManager(): void { - $repository = $this->createMock(EntityRepository::class); - $annotation = new Entity('test'); + $repository = $this->createStub(ExampleRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects($this->once()) + ->method('getRepository') + ->with(ExampleEntity::class) + ->willReturn($repository); - $entityManager->method('getRepository')->willReturn($repository); - - $this->annotationReader->method('getClassAnnotation')->willReturn($annotation); + $result = ($this->subject)($this->container($entityManager), ExampleRepository::class); - $this->container->method('get') - ->with(EntityManagerInterface::class) - ->willReturn($entityManager); + $this->assertSame($repository, $result); + } - $this->subject - ->method('createAnnotationReader') - ->willReturn($this->annotationReader); + public function testThrowsExceptionWhenEntityManagerReturnsADifferentRepositoryClass(): void + { + $repository = $this->createStub(EntityRepository::class); + $entityManager = $this->createStub(EntityManagerInterface::class); + $entityManager->method('getRepository')->willReturn($repository); - $object = $this->subject->__invoke($this->container, $repository::class); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + RuntimeException::repositoryMismatch(ExampleRepository::class, $repository::class)->getMessage() + ); - $this->assertContainsOnlyInstancesOf(EntityRepository::class, [$object]); + ($this->subject)($this->container($entityManager), ExampleRepository::class); } } diff --git a/test/AnnotatedServiceFactoryTest.php b/test/AnnotatedServiceFactoryTest.php index 8f56ec4..e2768a1 100644 --- a/test/AnnotatedServiceFactoryTest.php +++ b/test/AnnotatedServiceFactoryTest.php @@ -4,139 +4,337 @@ namespace DotTest\AnnotatedServices; -use Doctrine\Common\Annotations\Reader; +use ArrayObject; +use DomainException; use Dot\AnnotatedServices\Annotation\Inject; +use Dot\AnnotatedServices\Exception\ExceptionInterface; +use Dot\AnnotatedServices\Exception\InvalidArgumentException; use Dot\AnnotatedServices\Exception\RuntimeException; -use Dot\AnnotatedServices\Factory\AnnotatedServiceFactory as Subject; -use PHPUnit\Framework\MockObject\Exception; -use PHPUnit\Framework\MockObject\MockObject; +use Dot\AnnotatedServices\Factory\AnnotatedServiceFactory; +use DotTest\AnnotatedServices\Fixture\ArrayContainer; +use DotTest\AnnotatedServices\Fixture\ClassStringService; +use DotTest\AnnotatedServices\Fixture\ConfigService; +use DotTest\AnnotatedServices\Fixture\ContainerFailureException; +use DotTest\AnnotatedServices\Fixture\Dependency; +use DotTest\AnnotatedServices\Fixture\DottedConfigService; +use DotTest\AnnotatedServices\Fixture\InheritedSetterService; +use DotTest\AnnotatedServices\Fixture\MissingServiceService; +use DotTest\AnnotatedServices\Fixture\MissingSetterDependencyService; +use DotTest\AnnotatedServices\Fixture\MultiInjectService; +use DotTest\AnnotatedServices\Fixture\NoConstructorService; +use DotTest\AnnotatedServices\Fixture\NullKeyService; +use DotTest\AnnotatedServices\Fixture\OptionalParamsService; +use DotTest\AnnotatedServices\Fixture\RequiredParamsWithoutInject; +use DotTest\AnnotatedServices\Fixture\SetterEdgeCasesService; +use DotTest\AnnotatedServices\Fixture\SetterInjectedService; +use DotTest\AnnotatedServices\Fixture\ThrowingConstructorService; +use DotTest\AnnotatedServices\Fixture\UnbracedInjectService; +use DotTest\AnnotatedServices\Fixture\UnknownRootKeyService; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerExceptionInterface; -use Psr\Container\ContainerInterface; -use Psr\Container\NotFoundExceptionInterface; -use ReflectionClass; -use ReflectionException; -use ReflectionMethod; - -use function get_class; +use stdClass; +#[CoversClass(AnnotatedServiceFactory::class)] class AnnotatedServiceFactoryTest extends TestCase { - private MockObject&ContainerInterface $container; - private MockObject&Subject $subject; - private MockObject&Reader $annotationReader; + private AnnotatedServiceFactory $subject; - /** - * @throws Exception - */ - public function setUp(): void + protected function setUp(): void { - $this->container = $this->createMock(ContainerInterface::class); - $this->annotationReader = $this->createMock(Reader::class); - $this->subject = $this->createPartialMock(Subject::class, [ - 'createAnnotationReader', - 'getReflectionClass', - ]); + $this->subject = new AnnotatedServiceFactory(); + SetterInjectedService::$constructed = 0; + SetterInjectedService::$staticCalls = 0; + SetterInjectedService::$plainCalls = 0; } /** - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - * @throws ReflectionException + * @param array $services */ - public function testThrowsExceptionClassNotFound() + private function container(array $services = []): ArrayContainer { - $requestedName = 'TestService'; + return new ArrayContainer($services); + } + public function testThrowsExceptionClassNotFound(): void + { $this->expectException(RuntimeException::class); - $this->expectExceptionMessage(RuntimeException::classNotFound($requestedName)->getMessage()); + $this->expectExceptionMessage(RuntimeException::classNotFound('TestService')->getMessage()); - $this->subject->__invoke($this->container, $requestedName); + ($this->subject)($this->container(), 'TestService'); } - /** - * @throws ContainerExceptionInterface - * @throws Exception - * @throws NotFoundExceptionInterface - * @throws ReflectionException - */ - public function testReturnServiceWithNoDependencies() + public function testCreatesServiceWithoutConstructor(): void + { + $service = ($this->subject)($this->container(), NoConstructorService::class); + + $this->assertInstanceOf(NoConstructorService::class, $service); + } + + public function testThrowsExceptionWhenInjectIsMissingAndConstructorRequiresParameters(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(RuntimeException::annotationNotFound( + Inject::class, + RequiredParamsWithoutInject::class, + AnnotatedServiceFactory::class + )->getMessage()); + + ($this->subject)($this->container(), RequiredParamsWithoutInject::class); + } + + public function testCreatesServiceWithoutInjectWhenAllParametersAreOptional(): void { - $requestedName = TestClass::class; - $this->getMockBuilder($requestedName)->allowMockingUnknownTypes()->getMock(); - $refClass = $this->createMock(ReflectionClass::class); + $service = ($this->subject)($this->container(), OptionalParamsService::class); + + $this->assertInstanceOf(OptionalParamsService::class, $service); + $this->assertSame('default', $service->name); + } - $refClass->method('getConstructor')->willReturn(null); - $refClass->method('getMethods')->willReturn([]); + public function testInjectsServiceFromContainer(): void + { + $config = ['debug' => true]; + $service = ($this->subject)($this->container(['config' => $config]), ConfigService::class); - $this->annotationReader->method('getMethodAnnotation')->willReturn(null); - $this->subject - ->method('createAnnotationReader') - ->willReturn($this->annotationReader); - $this->subject->method('getReflectionClass')->willReturn($refClass); + $this->assertInstanceOf(ConfigService::class, $service); + $this->assertSame($config, $service->config); + } - $object = $this->subject->__invoke($this->container, $requestedName); + public function testInjectsServiceFromUnbracedAnnotation(): void + { + $config = ['debug' => true]; + $service = ($this->subject)($this->container(['config' => $config]), UnbracedInjectService::class); - $this->assertSame($requestedName, $object::class); + $this->assertInstanceOf(UnbracedInjectService::class, $service); + $this->assertSame($config, $service->config); } - /** - * @throws ContainerExceptionInterface - * @throws Exception - * @throws NotFoundExceptionInterface - * @throws ReflectionException - */ - public function testThrowsExceptionAnnotationNotFound() + public function testInstantiatesClassStringWhenNotInContainer(): void + { + $service = ($this->subject)($this->container(), ClassStringService::class); + + $this->assertInstanceOf(ClassStringService::class, $service); + } + + public function testPrefersContainerServiceOverClassInstantiation(): void + { + $dependency = new Dependency(); + $service = ($this->subject)( + $this->container([Dependency::class => $dependency]), + ClassStringService::class + ); + + $this->assertInstanceOf(ClassStringService::class, $service); + $this->assertSame($dependency, $service->dependency); + } + + public function testInjectsNestedConfigValueUsingDotNotation(): void + { + $service = ($this->subject)( + $this->container(['config' => ['a' => ['b' => 'value']]]), + DottedConfigService::class + ); + + $this->assertInstanceOf(DottedConfigService::class, $service); + $this->assertSame('value', $service->value); + } + + public function testInjectsNullValueUsingDotNotation(): void + { + $service = ($this->subject)($this->container(['config' => ['k' => null]]), NullKeyService::class); + + $this->assertInstanceOf(NullKeyService::class, $service); + $this->assertNull($service->value); + } + + public function testServiceRegisteredWithDottedNameWinsOverConfigPath(): void { - $requestedName = TestClass::class; - $this->getMockBuilder($requestedName)->allowMockingUnknownTypes()->getMock(); - $refClass = $this->createMock(ReflectionClass::class); - $refConstructor = $this->createMock(ReflectionMethod::class); + $service = ($this->subject)( + $this->container([ + 'config.a.b' => 'from-service', + 'config' => ['a' => ['b' => 'from-config']], + ]), + DottedConfigService::class + ); + + $this->assertInstanceOf(DottedConfigService::class, $service); + $this->assertSame('from-service', $service->value); + } + + public function testThrowsExceptionWhenDottedKeyIsMissing(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The key "b" provided in the dotted notation could not be found'); + + ($this->subject)($this->container(['config' => ['a' => []]]), DottedConfigService::class); + } - $refClass->method('getConstructor')->willReturn($refConstructor); - $refConstructor->method('getNumberOfRequiredParameters')->willReturn(100); + public function testThrowsExceptionWhenDottedPathIsLongerThanTheData(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The key "b" provided in the dotted notation could not be found'); - $this->annotationReader->method('getMethodAnnotation')->willReturn(null); - $this->subject - ->method('createAnnotationReader') - ->willReturn($this->annotationReader); - $this->subject->method('getReflectionClass')->willReturn($refClass); + ($this->subject)($this->container(['config' => ['a' => 'scalar']]), DottedConfigService::class); + } + public function testThrowsExceptionWhenInjectedServiceDoesNotExist(): void + { $this->expectException(RuntimeException::class); - $this->expectExceptionMessage(RuntimeException::annotationNotFound( - Inject::class, - $requestedName, - get_class($this->subject), - )->getMessage()); + $this->expectExceptionMessage(RuntimeException::classNotFound('missing_service')->getMessage()); - $this->subject->__invoke($this->container, $requestedName); + ($this->subject)($this->container(), MissingServiceService::class); } - /** - * @throws ContainerExceptionInterface - * @throws Exception - * @throws NotFoundExceptionInterface - * @throws ReflectionException - */ - public function testReturnService() + public function testCallsConstructorOnlyOnceAndInjectsSetters(): void + { + $config = ['debug' => true]; + $service = ($this->subject)($this->container(['config' => $config]), SetterInjectedService::class); + + $this->assertInstanceOf(SetterInjectedService::class, $service); + $this->assertSame(1, SetterInjectedService::$constructed); + $this->assertSame($config, $service->config); + $this->assertInstanceOf(Dependency::class, $service->dependency); + } + + public function testDoesNotCallAnnotatedStaticMethods(): void + { + ($this->subject)($this->container(['config' => []]), SetterInjectedService::class); + + $this->assertSame(0, SetterInjectedService::$staticCalls); + } + + public function testDoesNotCallPublicMethodsWithoutInject(): void + { + $service = ($this->subject)($this->container(['config' => []]), SetterInjectedService::class); + + $this->assertInstanceOf(SetterInjectedService::class, $service); + $this->assertSame(0, SetterInjectedService::$plainCalls); + $this->assertSame('value', $service->process('value')); + } + + public function testInjectsValueFromArrayAccessObjectUsingDotNotation(): void + { + $config = new ArrayObject(['a' => new ArrayObject(['b' => 'value'])]); + $service = ($this->subject)($this->container(['config' => $config]), DottedConfigService::class); + + $this->assertInstanceOf(DottedConfigService::class, $service); + $this->assertSame('value', $service->value); + } + + public function testThrowsExceptionWhenKeyIsMissingFromArrayAccessObject(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The key "b" provided in the dotted notation could not be found'); + + ($this->subject)( + $this->container(['config' => ['a' => new ArrayObject([])]]), + DottedConfigService::class + ); + } + + public function testThrowsExceptionWhenDottedPathReachesAnObjectWithoutArrayAccess(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The key "b" provided in the dotted notation could not be found'); + + ($this->subject)( + $this->container(['config' => ['a' => new stdClass()]]), + DottedConfigService::class + ); + } + + public function testInjectsMixedServicesInTheDeclaredOrder(): void + { + $config = ['debug' => true]; + $service = ($this->subject)( + $this->container([ + 'config' => $config, + 'settings' => ['mode' => 'fast'], + ]), + MultiInjectService::class + ); + + $this->assertInstanceOf(MultiInjectService::class, $service); + // Typed constructor parameters make a wrong injection order a TypeError, so the types prove the order + $this->assertSame($config, $service->config); + $this->assertSame('fast', $service->mode); + } + + public function testThrowsExceptionWhenFirstSegmentOfDottedKeyIsNotAService(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(RuntimeException::classNotFound('unknown_root')->getMessage()); + + ($this->subject)($this->container(), UnknownRootKeyService::class); + } + + public function testInjectsSetterWithMultipleArguments(): void + { + $service = ($this->subject)( + $this->container(['settings' => ['mode' => 'fast']]), + SetterEdgeCasesService::class + ); + + $this->assertInstanceOf(SetterEdgeCasesService::class, $service); + $this->assertInstanceOf(Dependency::class, $service->dependency); + $this->assertSame('fast', $service->mode); + } + + public function testDoesNotCallNonPublicMethodsWithInject(): void + { + $service = ($this->subject)( + $this->container(['settings' => ['mode' => 'fast']]), + SetterEdgeCasesService::class + ); + + $this->assertInstanceOf(SetterEdgeCasesService::class, $service); + $this->assertFalse($service->protectedCalled); + } + + public function testThrowsExceptionWhenSetterDependencyDoesNotExist(): void { - $requestedName = TestClass::class; - $this->getMockBuilder($requestedName)->allowMockingUnknownTypes()->getMock(); - $refClass = $this->createMock(ReflectionClass::class); - $refConstructor = $this->createMock(ReflectionMethod::class); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(RuntimeException::classNotFound('missing_setter_service')->getMessage()); + + ($this->subject)($this->container(), MissingSetterDependencyService::class); + } + + public function testInjectsSetterInheritedFromParentClass(): void + { + $service = ($this->subject)($this->container(), InheritedSetterService::class); + + $this->assertInstanceOf(InheritedSetterService::class, $service); + $this->assertInstanceOf(Dependency::class, $service->dependency); + } - $refClass->method('getConstructor')->willReturn($refConstructor); - $refClass->method('getMethods')->willReturn([]); - $refConstructor->method('getNumberOfRequiredParameters')->willReturn(1); + public function testContainerExceptionsAreNotWrapped(): void + { + $container = new ArrayContainer([], [ + 'config' => static function (): never { + throw new ContainerFailureException('boom'); + }, + ]); - $inject = new Inject(['test']); - $this->annotationReader->method('getMethodAnnotation')->willReturn($inject); + $this->expectException(ContainerFailureException::class); + $this->expectExceptionMessage('boom'); - $this->subject->method('createAnnotationReader')->willReturn($this->annotationReader); - $this->subject->method('getReflectionClass')->willReturn($refClass); + ($this->subject)($container, ConfigService::class); + } - $service = $this->subject->__invoke($this->container, $requestedName); + public function testConstructorExceptionsAreNotWrapped(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('constructor failed'); - $this->assertSame($requestedName, $service::class); + ($this->subject)($this->container(), ThrowingConstructorService::class); + } + + public function testThrownExceptionsImplementTheExceptionInterface(): void + { + try { + ($this->subject)($this->container(), 'TestService'); + $this->fail('Exception expected'); + } catch (ExceptionInterface $exception) { + $this->assertInstanceOf(RuntimeException::class, $exception); + } } } diff --git a/test/AnnotationTest.php b/test/AnnotationTest.php new file mode 100644 index 0000000..a3b5e9d --- /dev/null +++ b/test/AnnotationTest.php @@ -0,0 +1,66 @@ + ['a', 'b']]); + + $this->assertSame(['a', 'b'], $inject->getServices()); + } + + public function testInjectAcceptsASingleUnbracedService(): void + { + $inject = new Inject(['value' => 'config']); + + $this->assertSame(['config'], $inject->getServices()); + } + + public function testInjectWithoutValueHasNoServices(): void + { + $this->assertSame([], (new Inject([]))->getServices()); + } + + public function testEntityExposesItsName(): void + { + $this->assertSame(self::class, (new Entity(self::class))->getName()); + } + + public function testExceptionsImplementTheExceptionInterface(): void + { + $this->assertContains(ExceptionInterface::class, class_implements(RuntimeException::class)); + $this->assertContains(ExceptionInterface::class, class_implements(InvalidArgumentException::class)); + } + + public function testRepositoryMismatchMessageNamesBothClasses(): void + { + $message = RuntimeException::repositoryMismatch('App\Repo', EntityRepository::class)->getMessage(); + + $this->assertStringContainsString('App\Repo', $message); + $this->assertStringContainsString(EntityRepository::class, $message); + } + + public function testInvalidAnnotationMessageNamesTheService(): void + { + $this->assertStringContainsString('"Foo"', RuntimeException::invalidAnnotation('Foo')->getMessage()); + } +} diff --git a/test/ConfigProviderTest.php b/test/ConfigProviderTest.php index 536a1c8..5293637 100644 --- a/test/ConfigProviderTest.php +++ b/test/ConfigProviderTest.php @@ -5,29 +5,14 @@ namespace DotTest\AnnotatedServices; use Dot\AnnotatedServices\ConfigProvider; -use Dot\AnnotatedServices\Factory\AnnotatedServiceAbstractFactory; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +#[CoversClass(ConfigProvider::class)] class ConfigProviderTest extends TestCase { - protected array $config; - - protected function setup(): void - { - $this->config = (new ConfigProvider())(); - } - - public function testHasDependencies(): void - { - $this->assertArrayHasKey('dependencies', $this->config); - } - - public function testDependenciesHasFactories(): void + public function testRegistersNothingGlobally(): void { - $this->assertArrayHasKey('abstract_factories', $this->config['dependencies']); - $this->assertContainsEquals( - AnnotatedServiceAbstractFactory::class, - $this->config['dependencies']['abstract_factories'] - ); + $this->assertSame([], (new ConfigProvider())()); } } diff --git a/test/Fixture/ArrayContainer.php b/test/Fixture/ArrayContainer.php new file mode 100644 index 0000000..a25e0c3 --- /dev/null +++ b/test/Fixture/ArrayContainer.php @@ -0,0 +1,45 @@ + $services Ready-made services + * @param array $factories Called on every get() + */ + public function __construct( + private readonly array $services = [], + private readonly array $factories = [], + ) { + } + + public function get(string $id): mixed + { + if (array_key_exists($id, $this->services)) { + return $this->services[$id]; + } + + if (isset($this->factories[$id])) { + return ($this->factories[$id])(); + } + + throw new ServiceNotFoundException(sprintf('Service "%s" was not found', $id)); + } + + public function has(string $id): bool + { + return array_key_exists($id, $this->services) || isset($this->factories[$id]); + } +} diff --git a/test/Fixture/ClassStringService.php b/test/Fixture/ClassStringService.php new file mode 100644 index 0000000..88e8ffe --- /dev/null +++ b/test/Fixture/ClassStringService.php @@ -0,0 +1,17 @@ + $config + */ + public function __construct(public readonly array $config) + { + } +} diff --git a/test/Fixture/ContainerFailureException.php b/test/Fixture/ContainerFailureException.php new file mode 100644 index 0000000..7f8720c --- /dev/null +++ b/test/Fixture/ContainerFailureException.php @@ -0,0 +1,12 @@ + + */ +class ExampleRepository extends EntityRepository +{ +} diff --git a/test/Fixture/ExposedAnnotatedFactory.php b/test/Fixture/ExposedAnnotatedFactory.php new file mode 100644 index 0000000..8929e02 --- /dev/null +++ b/test/Fixture/ExposedAnnotatedFactory.php @@ -0,0 +1,20 @@ +createAnnotationReader($container); + } +} diff --git a/test/Fixture/InheritedSetterParent.php b/test/Fixture/InheritedSetterParent.php new file mode 100644 index 0000000..3e2d74f --- /dev/null +++ b/test/Fixture/InheritedSetterParent.php @@ -0,0 +1,20 @@ +dependency = $dependency; + } +} diff --git a/test/Fixture/InheritedSetterService.php b/test/Fixture/InheritedSetterService.php new file mode 100644 index 0000000..f391101 --- /dev/null +++ b/test/Fixture/InheritedSetterService.php @@ -0,0 +1,9 @@ + $config + */ + public function __construct( + public readonly array $config, + public readonly Dependency $dependency, + public readonly string $mode, + ) { + } +} diff --git a/test/Fixture/NoConstructorService.php b/test/Fixture/NoConstructorService.php new file mode 100644 index 0000000..1e9a680 --- /dev/null +++ b/test/Fixture/NoConstructorService.php @@ -0,0 +1,9 @@ + + */ +class RepositoryWithoutAnnotation extends EntityRepository +{ +} diff --git a/test/Fixture/RequiredParamsWithoutInject.php b/test/Fixture/RequiredParamsWithoutInject.php new file mode 100644 index 0000000..67ad1ca --- /dev/null +++ b/test/Fixture/RequiredParamsWithoutInject.php @@ -0,0 +1,12 @@ +dependency = $dependency; + $this->mode = $mode; + } + + /** + * @Inject({Dependency::class}) + */ + protected function protectedSetter(Dependency $dependency): void + { + $this->protectedCalled = true; + } +} diff --git a/test/Fixture/SetterInjectedService.php b/test/Fixture/SetterInjectedService.php new file mode 100644 index 0000000..37ffa78 --- /dev/null +++ b/test/Fixture/SetterInjectedService.php @@ -0,0 +1,53 @@ + $config + */ + public function __construct(public readonly array $config) + { + self::$constructed++; + } + + /** + * @Inject({Dependency::class}) + */ + public function setDependency(Dependency $dependency): void + { + $this->dependency = $dependency; + } + + /** + * Public method that carries no injection annotation: the factory must never call it. + */ + public function process(string $input): string + { + self::$plainCalls++; + + return $input; + } + + /** + * @Inject({Dependency::class}) + */ + public static function staticSetter(Dependency $dependency): void + { + self::$staticCalls++; + } +} diff --git a/test/Fixture/ThrowingConstructorService.php b/test/Fixture/ThrowingConstructorService.php new file mode 100644 index 0000000..1a44cfb --- /dev/null +++ b/test/Fixture/ThrowingConstructorService.php @@ -0,0 +1,15 @@ + $config + */ + public function __construct(public readonly array $config) + { + } +} diff --git a/test/Fixture/UnknownRootKeyService.php b/test/Fixture/UnknownRootKeyService.php new file mode 100644 index 0000000..4680bae --- /dev/null +++ b/test/Fixture/UnknownRootKeyService.php @@ -0,0 +1,17 @@ +