diff --git a/README.md b/README.md index 794a3402..862c83f0 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,19 @@ [![Psalm coverage](https://shepherd.dev/github/laminas/laminas-servicemanager/coverage.svg?)](https://shepherd.dev/github/laminas/laminas-servicemanager) > ## 🇷🇺 Русским гражданам -> +> > Мы, участники Laminas, родились и живем в разных странах. У многих из нас есть друзья, родственники и коллеги как в России, так и в Украине. Некоторые из нас родились в России. Некоторые из нас живут в России. У некоторых бабушки и дедушки сражались с фашистами во Второй мировой войне. Здесь никто не поддерживает фашизм. -> +> > У одного из нас есть украинская родственница, которая спаслась из дома вместе с сыном. Поезд задержался из-за бомбежки на дороге впереди. У нас есть друзья, которые прячутся в бомбоубежищах. Мы с тревогой ждем весточки от них после воздушных налетов, которые беспорядочно наносят удары и попадают по больницам, школам, детским садам и домам. Мы не берем это из каких-либо СМИ. Мы наблюдаем это напрямую. -> +> > Вы доверяете нам достаточно, чтоб использовать наши программы, и мы просим вас довериться нам вновь. Мы нуждаемся в помощи. Выходите и протестуйте против этой бесполезной войны. Остановите кровопролитие. Скажите "Нет войне!" -> +> > ## 🇺🇸 To Citizens of Russia -> +> > We at Laminas come from all over the world. Many of us have friends, family and colleagues in both Russia and Ukraine. Some of us were born in Russia. Some of us currently live in Russia. Some have grandparents who fought Nazis in World War II. Nobody here supports fascism. -> +> > One team member has a Ukrainian relative who fled her home with her son. The train was delayed due to bombing on the road ahead. We have friends who are hiding in bomb shelters. We anxiously follow up on them after the air raids, which indiscriminately fire at hospitals, schools, kindergartens and houses. We're not taking this from any media. These are our actual experiences. -> +> > You trust us enough to use our software. We ask that you trust us to say the truth on this. We need your help. Go out and protest this unnecessary war. Stop the bloodshed. Say "stop the war!" The Service Locator design pattern is implemented by the `Laminas\ServiceManager` diff --git a/composer.json b/composer.json index bd0236f6..d6a5cacc 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ }, "require": { "php": "~8.0.0 || ~8.1.0 || ~8.2.0", + "brick/varexporter": "^0.3.8", "laminas/laminas-stdlib": "^3.2.1", "psr/container": "^1.0" }, @@ -49,6 +50,7 @@ "laminas/laminas-coding-standard": "~2.5.0", "laminas/laminas-container-config-test": "^0.8", "laminas/laminas-dependency-plugin": "^2.2", + "lctrs/psalm-psr-container-plugin": "^1.9", "mikey179/vfsstream": "^1.6.11@alpha", "ocramius/proxy-manager": "^2.14.1", "phpbench/phpbench": "^1.2.7", diff --git a/composer.lock b/composer.lock index 3e4fa2df..38db349e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,57 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7bba09ab6e84159c92c4425f2bfe37a3", + "content-hash": "b6ac4e51e97fa9ed48da4804d35b859c", "packages": [ + { + "name": "brick/varexporter", + "version": "0.3.8", + "source": { + "type": "git", + "url": "https://github.com/brick/varexporter.git", + "reference": "b5853edea6204ff8fa10633c3a4cccc4058410ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/varexporter/zipball/b5853edea6204ff8fa10633c3a4cccc4058410ed", + "reference": "b5853edea6204ff8fa10633c3a4cccc4058410ed", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.0", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^8.5 || ^9.0", + "vimeo/psalm": "4.23.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\VarExporter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A powerful alternative to var_export(), which can export closures and objects without __set_state()", + "keywords": [ + "var_export" + ], + "support": { + "issues": "https://github.com/brick/varexporter/issues", + "source": "https://github.com/brick/varexporter/tree/0.3.8" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2023-01-21T23:05:38+00:00" + }, { "name": "laminas/laminas-stdlib", "version": "3.16.1", @@ -65,6 +114,62 @@ ], "time": "2022-12-03T18:48:01+00:00" }, + { + "name": "nikic/php-parser", + "version": "v4.15.3", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "570e980a201d8ed0236b0a62ddf2c9cbb2034039" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/570e980a201d8ed0236b0a62ddf2c9cbb2034039", + "reference": "570e980a201d8ed0236b0a62ddf2c9cbb2034039", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": ">=7.0" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.3" + }, + "time": "2023-01-16T22:05:37+00:00" + }, { "name": "psr/container", "version": "1.1.2", @@ -1481,6 +1586,84 @@ ], "time": "2021-09-08T17:51:35+00:00" }, + { + "name": "lctrs/psalm-psr-container-plugin", + "version": "1.9.0", + "source": { + "type": "git", + "url": "https://github.com/Lctrs/psalm-psr-container-plugin.git", + "reference": "2a3608a19a555a1589c12a97ff6f814a780def48" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Lctrs/psalm-psr-container-plugin/zipball/2a3608a19a555a1589c12a97ff6f814a780def48", + "reference": "2a3608a19a555a1589c12a97ff6f814a780def48", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "nikic/php-parser": "^4.15.2", + "php": ">=8.0.2 <8.3.0", + "psr/container": "^1.1.2 || ^2.0.2", + "vimeo/psalm": "^5.0.0" + }, + "require-dev": { + "codeception/codeception": "4.2.2", + "codeception/module-asserts": "2.0.1", + "codeception/module-cli": "1.1.1", + "codeception/module-filesystem": "1.0.3", + "doctrine/coding-standard": "9.0.0", + "ergebnis/composer-normalize": "2.28.3", + "ergebnis/license": "2.1.0", + "phpstan/extension-installer": "1.2.0", + "phpstan/phpstan": "1.9.2", + "phpstan/phpstan-deprecation-rules": "1.0.0", + "phpstan/phpstan-phpunit": "1.2.2", + "phpstan/phpstan-strict-rules": "1.4.4", + "phpunit/phpunit": "9.5.26", + "psalm/plugin-phpunit": "0.18.3", + "symfony/yaml": "5.4.16", + "weirdan/codeception-psalm-module": "0.13.1" + }, + "type": "psalm-plugin", + "extra": { + "psalm": { + "pluginClass": "Lctrs\\PsalmPsrContainerPlugin\\Plugin" + } + }, + "autoload": { + "psr-4": { + "Lctrs\\PsalmPsrContainerPlugin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jérôme Parmentier", + "email": "jerome@prmntr.me" + } + ], + "description": "Let Psalm understand better psr11 containers", + "homepage": "https://github.com/Lctrs/psalm-psr-container-plugin", + "keywords": [ + "code", + "container", + "inspection", + "php", + "psalm", + "psalm-plugin", + "psr", + "psr11" + ], + "support": { + "issues": "https://github.com/Lctrs/psalm-psr-container-plugin/issues", + "source": "https://github.com/Lctrs/psalm-psr-container-plugin" + }, + "time": "2022-12-01T07:02:46+00:00" + }, { "name": "mikey179/vfsstream", "version": "v1.6.11", @@ -1642,62 +1825,6 @@ }, "time": "2022-12-08T20:46:14+00:00" }, - { - "name": "nikic/php-parser", - "version": "v4.15.2", - "source": { - "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "f59bbe44bf7d96f24f3e2b4ddc21cd52c1d2adbc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f59bbe44bf7d96f24f3e2b4ddc21cd52c1d2adbc", - "reference": "f59bbe44bf7d96f24f3e2b4ddc21cd52c1d2adbc", - "shasum": "" - }, - "require": { - "ext-tokenizer": "*", - "php": ">=7.0" - }, - "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" - }, - "bin": [ - "bin/php-parse" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.9-dev" - } - }, - "autoload": { - "psr-4": { - "PhpParser\\": "lib/PhpParser" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Nikita Popov" - } - ], - "description": "A PHP parser written in PHP", - "keywords": [ - "parser", - "php" - ], - "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.2" - }, - "time": "2022-11-12T15:38:23+00:00" - }, { "name": "ocramius/proxy-manager", "version": "2.14.1", diff --git a/docs/book/index.md b/docs/book/index.md deleted file mode 120000 index fe840054..00000000 --- a/docs/book/index.md +++ /dev/null @@ -1 +0,0 @@ -../../README.md \ No newline at end of file diff --git a/docs/book/reflection-abstract-factory.md b/docs/book/reflection-abstract-factory.md deleted file mode 100644 index 9e771b6d..00000000 --- a/docs/book/reflection-abstract-factory.md +++ /dev/null @@ -1,166 +0,0 @@ -# Reflection Factory - -- Since 3.2.0. - -Writing a factory class for each and every service that has dependencies -can be tedious, particularly in early development as you are still sorting -out dependencies. - -laminas-servicemanager ships with `Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory`, -which provides a reflection-based approach to instantiation, resolving -constructor dependencies to the relevant services. The factory may be used as -either an abstract factory, or mapped to specific service names as a factory: - -```php -use Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory; - -return [ - /* ... */ - 'service_manager' => [ - 'abstract_factories' => [ - ReflectionBasedAbstractFactory::class, - ], - 'factories' => [ - 'MyModule\Model\FooModel' => ReflectionBasedAbstractFactory::class, - ], - ], - /* ... */ -]; -``` - -Mapping services to the factory is more explicit and performant. - -The factory operates with the following constraints/features: - -- A parameter named `$config` typehinted as an array will receive the - application "config" service (i.e., the merged configuration). -- Parameters typehinted against array, but not named `$config`, will - be injected with an empty array. -- Scalar parameters will result in the factory raising an exception, - unless a default value is present; if it is, that value will be used. -- If a service cannot be found for a given typehint, the factory will - raise an exception detailing this. - -`$options` passed to the factory are ignored in all cases, as we cannot -make assumptions about which argument(s) they might replace. - -Once your dependencies have stabilized, we recommend writing a dedicated -factory, as reflection can introduce performance overhead; you may use the -[generate-factory-for-class console tool](console-tools.md#generate-factory-for-class) -to do so. - -## Handling well-known services - -Some services provided by Laminas components do not have -entries based on their class name (for historical reasons). As examples: - -- `Laminas\Console\Adapter\AdapterInterface` maps to the service name `ConsoleAdapter`, -- `Laminas\Filter\FilterPluginManager` maps to the service name `FilterManager`, -- `Laminas\Hydrator\HydratorPluginManager` maps to the service name `HydratorManager`, -- `Laminas\InputFilter\InputFilterPluginManager` maps to the service name `InputFilterManager`, -- `Laminas\Log\FilterPluginManager` maps to the service name `LogFilterManager`, -- `Laminas\Log\FormatterPluginManager` maps to the service name `LogFormatterManager`, -- `Laminas\Log\ProcessorPluginManager` maps to the service name `LogProcessorManager`, -- `Laminas\Log\WriterPluginManager` maps to the service name `LogWriterManager`, -- `Laminas\Serializer\AdapterPluginManager` maps to the service name `SerializerAdapterManager`, -- `Laminas\Validator\ValidatorPluginManager` maps to the service name `ValidatorManager`, - -To allow the `ReflectionBasedAbstractFactory` to find these, you have two -options. - -The first is to pass an array of mappings via the constructor: - -```php -$reflectionFactory = new ReflectionBasedAbstractFactory([ - \Laminas\Console\Adapter\AdapterInterface::class => 'ConsoleAdapter', - \Laminas\Filter\FilterPluginManager::class => 'FilterManager', - \Laminas\Hydrator\HydratorPluginManager::class => 'HydratorManager', - \Laminas\InputFilter\InputFilterPluginManager::class => 'InputFilterManager', - \Laminas\Log\FilterPluginManager::class => 'LogFilterManager', - \Laminas\Log\FormatterPluginManager::class => 'LogFormatterManager', - \Laminas\Log\ProcessorPluginManager::class => 'LogProcessorManager', - \Laminas\Log\WriterPluginManager::class => 'LogWriterManager', - \Laminas\Serializer\AdapterPluginManager::class => 'SerializerAdapterManager', - \Laminas\Validator\ValidatorPluginManager::class => 'ValidatorManager', -]); -``` - -This can be done either in your configuration file (which could be problematic -when considering serialization for caching), or during an early phase of -application bootstrapping. - -For instance, with laminas-mvc, this might be in your `Application` module's -bootstrap listener: - -```php -namespace Application - -use Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory; - -class Module -{ - public function onBootstrap($e) - { - $application = $e->getApplication(); - $container = $application->getServiceManager(); - - $container->addAbstractFactory(new ReflectionBasedAbstractFactory([ - /* ... */ - ])); - } -} -``` - -For Mezzio, it could be part of your `config/container.php` definition: - -```php -$container = new ServiceManager(); -(new Config($config['dependencies']))->configureServiceManager($container); -// Add the following: -$container->addAbstractFactory(new ReflectionBasedAbstractFactory([ - /* ... */ -])); -``` - -The second approach is to extend the class, and define the map in the -`$aliases` property: - -```php -namespace Application; - -use Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory; - -class ReflectionAbstractFactory extends ReflectionBasedAbstractFactory -{ - protected $aliases = [ - \Laminas\Console\Adapter\AdapterInterface::class => 'ConsoleAdapter', - \Laminas\Filter\FilterPluginManager::class => 'FilterManager', - \Laminas\Hydrator\HydratorPluginManager::class => 'HydratorManager', - \Laminas\InputFilter\InputFilterPluginManager::class => 'InputFilterManager', - \Laminas\Log\FilterPluginManager::class => 'LogFilterManager', - \Laminas\Log\FormatterPluginManager::class => 'LogFormatterManager', - \Laminas\Log\ProcessorPluginManager::class => 'LogProcessorManager', - \Laminas\Log\WriterPluginManager::class => 'LogWriterManager', - \Laminas\Serializer\AdapterPluginManager::class => 'SerializerAdapterManager', - \Laminas\Validator\ValidatorPluginManager::class => 'ValidatorManager', - ]; -} -``` - -You could then register it via class name in your service configuration. - -## Alternatives - -You may also use the [Config Abstract Factory](config-abstract-factory.md), -which gives slightly more flexibility in terms of mapping dependencies: - -- If you wanted to map to a specific implementation, choose the - `ConfigAbstractFactory`. -- If you need to map to a service that will return a scalar or array (e.g., a - subset of the `'config'` service), choose the `ConfigAbstractFactory`. -- If you need a faster factory for production, choose the - `ConfigAbstractFactory` or create a custom factory. - -## References - -This feature was inspired by [a blog post by Alexandre Lemaire](http://circlical.com/blog/2016/3/9/preparing-for-zend-f). diff --git a/docs/book/config-abstract-factory.md b/docs/book/v3/config-abstract-factory.md similarity index 100% rename from docs/book/config-abstract-factory.md rename to docs/book/v3/config-abstract-factory.md diff --git a/docs/book/configuring-the-service-manager.md b/docs/book/v3/configuring-the-service-manager.md similarity index 100% rename from docs/book/configuring-the-service-manager.md rename to docs/book/v3/configuring-the-service-manager.md diff --git a/docs/book/console-tools.md b/docs/book/v3/console-tools.md similarity index 100% rename from docs/book/console-tools.md rename to docs/book/v3/console-tools.md diff --git a/docs/book/cookbook/factories-vs-abstract-factories.md b/docs/book/v3/cookbook/factories-vs-abstract-factories.md similarity index 100% rename from docs/book/cookbook/factories-vs-abstract-factories.md rename to docs/book/v3/cookbook/factories-vs-abstract-factories.md diff --git a/docs/book/delegators.md b/docs/book/v3/delegators.md similarity index 100% rename from docs/book/delegators.md rename to docs/book/v3/delegators.md diff --git a/docs/book/index.html b/docs/book/v3/index.html similarity index 100% rename from docs/book/index.html rename to docs/book/v3/index.html diff --git a/docs/book/v3/index.md b/docs/book/v3/index.md new file mode 120000 index 00000000..8a33348c --- /dev/null +++ b/docs/book/v3/index.md @@ -0,0 +1 @@ +../../../README.md \ No newline at end of file diff --git a/docs/book/migration.md b/docs/book/v3/migration.md similarity index 99% rename from docs/book/migration.md rename to docs/book/v3/migration.md index 37950abf..8fa1d899 100644 --- a/docs/book/migration.md +++ b/docs/book/v3/migration.md @@ -1,6 +1,6 @@ # Migration Guide -The Service Manager was first introduced for Laminas.0.0. Its API +The Service Manager was first introduced for Laminas 2.0.0. Its API remained the same throughout that version. Version 3 is the first new major release of the Service Manager, and contains a @@ -116,7 +116,7 @@ version 2 in version 3. However, we recommend starting to update your configuration to remove `invokables` entries in favor of factories (and aliases, if needed). -> #### Invokables and plugin managers +> ### Invokables and plugin managers > > If you are creating a plugin manager and in-lining invokables into the class > definition, you will need to make some changes. @@ -718,7 +718,7 @@ class FooFactory implements FactoryInterface } ``` -> #### Many factories already work with v3! +> #### Many factories already work with v3 > > Within the skeleton application, tutorial, and even in commonly shipped > modules such as those in Laminas API Tools, we have typically suggested building your @@ -855,7 +855,7 @@ class FooInitializer implements InitializerInterface } ``` -> ### Update your callables! +> ### Update your callables > > Version 2 allows you to provide initializers as PHP callables. However, this > means that the signature of those callables is incorrect for version 3! @@ -1276,7 +1276,6 @@ This will check that: - That requesting an invalid plugin throws the right exception. - That all your aliases resolve. - ### Post migration After you migrate to version 3, you can clean up your plugin manager: diff --git a/docs/book/psr-11.md b/docs/book/v3/psr-11.md similarity index 100% rename from docs/book/psr-11.md rename to docs/book/v3/psr-11.md diff --git a/docs/book/quick-start.md b/docs/book/v3/quick-start.md similarity index 100% rename from docs/book/quick-start.md rename to docs/book/v3/quick-start.md diff --git a/docs/book/v4/ahead-of-time-factories.md b/docs/book/v4/ahead-of-time-factories.md new file mode 100644 index 00000000..24fc8ffb --- /dev/null +++ b/docs/book/v4/ahead-of-time-factories.md @@ -0,0 +1,105 @@ +# Ahead of Time Factories + +- Since 4.0.0 + +In addition to the already existing [Reflection Factory](reflection-abstract-factory.md), one can create factories for those services using `ReflectionBasedAbstractFactory` before deploying the project to production. For the initial project setup regarding CLI tooling, please refer to [this documentation](console-tools.md#requirements). + +## Usage + +It is recommended to create factories within CI pipeline. While developing a service, the `ReflectionBasedAbstractFactory` can help to dynamically extend the constructor without the need of regenerating already created/generated factories. + +To generate the factories, run the following CLI command after [setting up the project](#project-setup): + +```shell +$ php vendor/bin/laminas servicemanager:generate-aot-factories [] +``` + +The CLI command will then scan your whole configuration for **every** container/plugin-manager look-a-like service configuration where services are using `ReflectionBasedAbstractFactory` as their factory. +Wherever `ReflectionBasedAbstractFactory` is used within a `factories` config entry, the CLI command will generate a factory while adding the replacement to the generated factory config. + +When the CLI command has finished, there are all factories generated within the path (`ConfigProvider::CONFIGURATION_KEY_FACTORY_TARGET_PATH`) registered in the projects configuration along with the `` file (defaults to `config/autoload/generated-factories.local.php`). It is required to run `composer dump-autoload` (in case you've used optimized/classmap-authoritative flag, you should pass these here again) after executing the CLI command as the autoloader has to pick up the generated factory classes. In case of an existing config cache, it is also mandatory to remove that cached configuration file. + +When the project is executed having all the files in-place, the generated factory classes are picked up instead of the `ReflectionBasedAbstractFactory` and thus, no additional runtime side-effects based on `Reflection` will occur. + +Ensure that both `` file and the directory (including sub-directories and files) configured within `ConfigProvider::CONFIGURATION_KEY_FACTORY_TARGET_PATH` is being picked up when generating the artifact which is deployed to production. + +## Project Setup + +The project needs some additional configuration so that the generated factories are properly detected and registered. + +### Additional Composer Dependencies + +To execute the CLI command which auto-detects all services using the `ReflectionBasedAbstractFactory`, `laminas/laminas-cli` needs to be added as at least a dev requirement. +There is no TODO in case that `laminas/laminas-cli` is already available in the project. + +```shell +$ composer require --dev laminas/laminas-cli +``` + +### Configuration + +The configuration needs an additional configuration key which provides the target on where the generated factory classes should be stored. +One should use the `CONFIGURATION_KEY_FACTORY_TARGET_PATH` constant from `\Laminas\ServiceManager\ConfigProvider` for this. +Use either `config/autoload/global.php` (which might already exist) or the `Application`-Module configuration (`Application\Module#getConfig` or `Application\ConfigProvider#__invoke`) to do so. + +Both Laminas-MVC and Mezzio do share the configuration directory structure as follows: + +```text +. +├── config +│   ├── autoload +│   │   ├── global.php +│   │   └── local.php.dist +└── data +``` + +#### Generated Factories Location + +To avoid namespace conflicts with existing modules, it is recommended to create a dedicated directory under `data` which can be used as the target directory for the generated factories. +For example: `data/GeneratedServiceManagerFactories`. This directory should contain either `.gitkeep` (in case you prefer to commit your generated factories) and/or a `.gitignore` which excludes all PHP files from being committed to your project. After adding either `.gittkeep` or `.gitignore`, head to the projects `composer.json` and add (if not yet exists) `classmap` to the `autoload` section. Within that `classmap` property, target the recently created directory where the factories are meant to be stored: + +```json +{ + "name": "vendor/project", + "type": "project", + "[...]": {}, + "autoload": { + "classmap": ["data/GeneratedServiceManagerFactories"] + } +} +``` + +This will provide composer with the information, that PHP classes can be found within that directory and thus, all classes are automatically dumped on `composer dump-autoload` for example. + +#### Configuration overrides + +> ### Configuration merge strategy +> +> The `autoload` config folder is scanned for files named `[].php`. +> Those files containing `[*.]local.php` are ignored via `.gitignore` so that these are not accidentally committed. +> The configuration merge will happen in the following order: +> +> 1. global configurations are used first +> 2. global configurations are overridden by environment specific configurations +> 3. global and environment specific configurations are overridden by local configurations + +The CLI command to generate the factories expects a path to a file, which will be created (or overridden) and which will contain **all** service <=> factory entries for the projects container and plugin-managers. + +For example, if the CLI command detects `Laminas-MVC` `service_manager` service and `laminas/laminas-validator` validators using `ReflectionBasedAbstractFactory`, it will create a file like this: + +```php +return [ + 'service_manager' => [ + 'factories' => [ + MyService::class => GeneratedMyServiceFactory::class, + ], + ], + 'validators' => [ + 'factories' => [ + MyValidator::class => GeneratedMyValidatorFactory::class, + ], + ], +]; +``` + +So the default location of the generated configuration which should automatically replace existing configuration (containing `ReflectionBasedAbstractFactory`) is targeted to `config/autoload/generated-factories.local.php`. Local configuration files will always replace global/environment/module configurations and therefore, it perfectly fit our needs. diff --git a/docs/book/v4/config-abstract-factory.md b/docs/book/v4/config-abstract-factory.md new file mode 100644 index 00000000..fd9c2dc4 --- /dev/null +++ b/docs/book/v4/config-abstract-factory.md @@ -0,0 +1,144 @@ +# Config Abstract Factory + +- Since 3.2.0 + +You can simplify the process of creating factories by registering +`Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory` with your service +manager instance. This allows you to define services using a configuration map, +rather than having to create separate factories for each of your services. + +## Enabling the ConfigAbstractFactory + +Enable the `ConfigAbstractFactory` in the same way that you would enable +any other abstract factory. + +Programmatically: + +```php +$serviceManager = new ServiceManager(); +$serviceManager->addAbstractFactory(new ConfigAbstractFactory()); +``` + +Or within configuration: + +```php +return [ + // laminas-mvc: + 'service_manager' => [ + 'abstract_factories' => [ + ConfigAbstractFactory::class, + ], + ], + + // mezzio or ConfigProvider consumers: + 'dependencies' => [ + 'abstract_factories' => [ + ConfigAbstractFactory::class, + ], + ], +]; +``` + +Like all abstract factories starting in version 3, you may also use the config +abstract factory as a mapped factory, registering it as a factory for a specific +class: + +```php +return [ + 'service_manager' => [ + 'factories' => [ + SomeCustomClass::class => ConfigAbstractFactory::class, + ], + ], +]; +``` + +## Configuration + +Configuration should be provided via the `config` service, which should return +an array or `ArrayObject`. `ConfigAbstractFactory` looks for a top-level key in +this service named after itself (i.e., `Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory`) +that is an array value. Each item in the array: + +- Should have a key representing the service name (typically the fully + qualified class name) +- Should have a value that is an array of each dependency, ordered using the + constructor argument order, and using service names registered with the + container. + +As an example: + +```php +use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; + +return [ + ConfigAbstractFactory::class => [ + MyInvokableClass::class => [], + MySimpleClass::class => [ + Logger::class, + ], + Logger::class => [ + Handler::class, + ], + ], +]; +``` + +The definition tells the service manager how this abstract factory should manage +dependencies in the classes defined. In the above example, `MySimpleClass` has a +single dependency on a `Logger` instance. The abstract factory will simply look +to fulfil that dependency by calling `get()` with that key on the container +passed to it. In this way, you can create the correct tree of +dependencies to successfully return any given service. + +In the above example, note that the abstract factory configuration does not +contain configuration for the `Handler` class. At first glance, this appears as +if it will fail; however, if `Handler` is configured directly with the container +already — for example, mapped to a custom factory — the service will +be created and used as a dependency. + +As another, more complete example, consider the following classes: + +```php +class UserMapper +{ + public function __construct(Adapter $db, Cache $cache) {} +} + +class Adapter +{ + public function __construct(array $config) {} +} + +class Cache +{ + public function __construct(CacheAdapter $cacheAdapter) {} +} + +class CacheAdapter +{ +} +``` + +In this case, we can define the configuration for these classes as follows: + +```php +// config/autoload/dependencies.php or anywhere that gets merged into global config +return [ + ConfigAbstractFactory::class => [ + CacheAdapter::class => [], // no dependencies + Cache::class => [ + CacheAdapter::class, // dependency on the CacheAdapter key defined above + ], + UserMapper::class => [ + Adapter::class, // will be called using normal factory defined below + Cache::class, // defined above and will be created using this abstract factory + ], + ], + 'service_manager' => [ + 'factories' => [ + Adapter::class => AdapterFactory::class, // normal factory not using above config + ], + ], +], +``` diff --git a/docs/book/v4/configuring-the-service-manager.md b/docs/book/v4/configuring-the-service-manager.md new file mode 100644 index 00000000..6e09e176 --- /dev/null +++ b/docs/book/v4/configuring-the-service-manager.md @@ -0,0 +1,594 @@ +# Configuring the service manager + +The Service Manager component can be configured by passing an associative array to the component's +constructor. The following keys are: + +- `services`: associative array that maps a key to a service instance. +- `invokables`: an associative array that maps a key to a constructor-less service; + i.e., for services that do not require arguments to the constructor. The key and + service name usually are the same; if they are not, the key is treated as an alias. +- `factories`: associative array that map a key to a factory name, or any callable. +- `abstract_factories`: a list of abstract factories classes. An abstract + factory is a factory that can potentially create any object, based on some + criterias. +- `delegators`: an associative array that maps service keys to lists of delegator factory keys, see the [delegators documentation](delegators.md) for more details. +- `aliases`: associative array that map a key to a service key (or another alias). +- `initializers`: a list of callable or initializers that are run whenever a service has been created. +- `lazy_services`: configuration for the lazy service proxy manager, and a class + map of service:class pairs that will act as lazy services; see the + [lazy services documentation](lazy-services.md) for more details. +- `shared`: associative array that maps a service name to a boolean, in order to + indicate to the service manager whether or not it should cache services it + creates via `get` method, independent of the `shared_by_default` setting. +- `shared_by_default`: boolean that indicates whether services created through + the `get` method should be cached. This is `true` by default. + +Here is an example of how you could configure a service manager: + +```php +use Laminas\ServiceManager\ServiceManager; + +$serviceManager = new ServiceManager([ + 'services' => [], + 'invokables' => [], + 'factories' => [], + 'abstract_factories' => [], + 'delegators' => [], + 'aliases' => [], + 'initializers' => [], + 'lazy_services' => [], + 'shared' => [], + 'shared_by_default' => true, +]); +``` + +## Factories + +A factory is any callable or any class that implements the interface +`Laminas\ServiceManager\Factory\FactoryInterface`. + +Service manager components provide a default factory that can be used to create +objects that do not have any dependencies: + +```php +use Laminas\ServiceManager\Factory\InvokableFactory; +use Laminas\ServiceManager\ServiceManager; +use stdClass; + +$serviceManager = new ServiceManager([ + 'factories' => [ + stdClass::class => InvokableFactory::class, + MyObject::class => MyObjectFactory::class, + ], +]); +``` + +> For invokable classes we recommend using `Laminas\ServiceManager\Factory\InvokableFactory`, +> because ServiceManager will convert all `invokables` into `factories` using `InvokableFactory` internally. + +As said before, a factory can also be a callable, to create more complex objects: + +```php +use Interop\Container\ContainerInterface; +use Laminas\ServiceManager\Factory\InvokableFactory; +use Laminas\ServiceManager\ServiceManager; +use stdClass; + +$serviceManager = new ServiceManager([ + 'factories' => [ + stdClass::class => InvokableFactory::class, + MyObject::class => function(ContainerInterface $container, $requestedName) { + $dependency = $container->get(stdClass::class); + return new MyObject($dependency); + }, + ], +]); +``` + +Each factory always receive a `ContainerInterface` argument (this is the base +interface that the `ServiceManager` implements), as well as the requested name +as the second argument. In this case, the `$requestedName` is `MyObject`. + +Alternatively, the above code can be replaced by a factory class instead of a +closure. This leads to more readable code. For instance: + +```php +// In MyObjectFactory.php file + +class MyObjectFactory implements FactoryInterface +{ + public function __invoke(ContainerInterface $container, $requestedName, array $options = null) + { + $dependency = $container->get(stdClass::class); + return new MyObject($dependency); + } +} + +// or without implementing the interface: +class MyObjectFactory +{ + public function __invoke(ContainerInterface $container, $requestedName) + { + $dependency = $container->get(Dependency::class); + return new MyObject($dependency); + } +} + +// When creating the service manager: +$serviceManager = new ServiceManager([ + 'factories' => [ + stdClass::class => InvokableFactory::class, + MyObject::class => MyObjectFactory::class + ] +]); +``` + +> For performance reasons, factories objects are not created until requested. +> In the above example, this means that the `MyObjectFactory` object won't be +> created until `MyObject` is requested. + +### Mapping multiple service to the same factory + +Unlike version 2 implementations of the component, in the version 3 +implementation, the `$requestedName` is guaranteed to be passed as the second +parameter of a factory. This is useful when you need to create multiple +services that are created exactly the same way, hence reducing the number of +needed factories. + +For instance, if two services share the same creation pattern, you could attach the same factory: + +```php +// In MyObjectFactory.php file + +class MyObjectFactory implements FactoryInterface +{ + public function __invoke(ContainerInterface $container, $requestedName, array $options = null) + { + $dependency = $container->get(stdClass::class); + return new $requestedName($dependency); + } +} + +// or without implementing the interface: +class MyObjectFactory +{ + public function __invoke(ContainerInterface $container, $requestedName) + { + $dependency = $container->get(Dependency::class); + return new $requestedName($dependency); + } +} + +// When creating the service manager: +$serviceManager = new ServiceManager([ + 'factories' => [ + MyObjectA::class => MyObjectFactory::class, + MyObjectB::class => MyObjectFactory::class + ] +]); +``` + +This pattern can often replace abstract factories, and is more performant: + +- Lookups for services do not need to query abstract factories; the service is + mapped explicitly. +- Once the factory is loaded for any object, it stays in memory for any other + service using the same factory. + +Using factories is recommended in most cases where abstract factories were used +in version 2. + +This feature *can* be abused, however: for instance, if you have dozens of +services that share the same creation, but which do not share any common +functionality, we recommend to create separate factories. + +## Abstract factories + +An abstract factory is a specialized factory that can be used to create any +service, if it has the capability to do so. An abstract factory is often useful +when you do not know in advance the name of the service (e.g. if the service +name is generated dynamically at runtime), but know that the services share a +common creation pattern. + +An abstract factory must be registered inside the service manager, and is +checked if no factory can create an object. Each abstract factory must +implement `Laminas\ServiceManager\Factory\AbstractFactoryInterface`: + +```php +// In MyAbstractFactory.php: + +class MyAbstractFactory implements AbstractFactoryInterface +{ + public function canCreate(ContainerInterface $container, $requestedName) + { + return in_array('Traversable', class_implements($requestedName), true); + } + + public function __invoke(ContainerInterface $container, $requestedName, array $options = null) + { + return $requestedName(); + } +} + +// When creating the service manager: +$serviceManager = new ServiceManager([ + 'abstract_factories' => [ + new MyAbstractFactory() // You could also pass a class name: MyAbstractFactory::class + ] +]); + +// When fetching an object: +$object = $serviceManager->get(A::class); +``` + +Here is what will happen: + +1. The service manager will check if it contains a factory mapped to the + `A::class` service. +2. Because none is found, it will process each abstract factory, in the order + in which they were registered. +3. It will call the `canCreate()` method, passing the service manager instance and + the name of the requested object. The method can use any logic whatsoever to + determine if it can create the service (such as checking its name, checking + for a required dependency in the passed container, checking if a class + implements a given interface, etc.). +4. If `canCreate()` returns `true`, it will call the `__invoke` method to + create the object. Otherwise, it will continue iterating the abstract + factories, until one matches, or the queue is exhausted. + +### Best practices + +While convenient, we recommend you to limit the number of abstract factories. +Because the service manager needs to iterate through all registered abstract +factories to resolve services, it can be costly when multiple abstract +factories are present. + +Often, mapping the same factory to multiple services can solve the issue more +efficiently (as described in the `Factories` section). + +## Aliases + +An *alias* provides an alternative name for a registered service. + +An alias can also be mapped to another alias (it will be resolved recursively). +For instance: + +```php +use Laminas\ServiceManager\Factory\InvokableFactory; +use Laminas\ServiceManager\ServiceManager; +use stdClass; + +$serviceManager = new ServiceManager([ + 'factories' => [ + stdClass::class => InvokableFactory::class + ], + + 'aliases' => [ + 'A' => stdClass::class, + 'B' => 'A' + ] +]); + +$object = $serviceManager->get('B'); +``` + +In this example, asking `B` will be resolved to `A`, which will be itself +resolved to `stdClass::class`, which will finally be constructed using the +provided factory. + +### Best practices + +We recommend you minimal use of aliases, and instead using the `::class` +language construct to map using a FQCN (Fully-Qualified-Class-Name). This +provides both better discoverability within your code, and allows simpler +refactoring, as most modern IDEs can refactor class names specified using the +`::class` keyword. + +## Initializers + +An initializer is any callable or any class that implements the interface +`Laminas\ServiceManager\Initializer\InitializerInterface`. Initializers are +executed for each service the first time they are created, and can be used to +inject additional dependencies. + +For instance, if we'd want to automatically inject the dependency +`EventManager::class` in all objects that implement the interface +`EventManagerAwareInterface`, we could create the following initializer: + +```php +use Interop\Container\ContainerInterface; +use stdClass; +use Laminas\ServiceManager\ServiceManager; + +$serviceManager = new ServiceManager([ + 'initializers' => [ + function(ContainerInterface $container, $instance) { + if (! $instance instanceof EventManagerAwareInterface) { + return; + } + $instance->setEventManager($container->get(EventManager::class)); + } + ] +]); +``` + +Alternately, you can create a class that implements +`Laminas\ServiceManager\Initializer\InitializerInterface`, and pass it to the +`initializers` array: + +```php +// In MyInitializer.php + +class MyInitializer implements InitializerInterface +{ + public function __invoke(ContainerInterface $container, $instance) + { + if (! $instance instanceof EventManagerAwareInterface) { + return; + } + $instance->setEventManager($container->get(EventManager::class)); + } +} + +// When creating the service manager: + +use Interop\Container\ContainerInterface; +use stdClass; +use Laminas\ServiceManager\ServiceManager; + +$serviceManager = new ServiceManager([ + 'initializers' => [ + new MyInitializer() // You could also use MyInitializer::class + ] +]); +``` + +> Note that initializers are automatically created when the service manager is +> initialized, even if you pass a class name. + +### Best practices + +While convenient, initializer usage is also problematic. They are provided +primarily for backwards compatibility, but we highly discourage their usage. + +The primary issues with initializers are: + +- They lead to fragile code. Because the dependency is not injected directly in + the constructor, it means that the object may be in an "incomplete state". If + for any reason the initializer is not run (if it was not correctly registered + for instance), bugs ranging from the subtle to fatal can be introduced. + + Instead, we encourage you to inject all necessary dependencies via + the constructor, using factories. If some dependencies use setter or interface + injection, use delegator factories. + + If a given service has too many dependencies, then it may be a sign that you + need to split this service into smaller, more focused services. + +- They are slow: an initializer is run for EVERY instance you create through + the service manager. If you have ten initializers or more, this can quickly + add up! + +## Shared + +By default, a service created is shared. This means that calling the `get()` +method twice for a given service will return exactly the same service. This is +typically what you want, as it can save a lot of memory and increase +performance: + +```php +$serviceManager = new ServiceManager([ + 'factories' => [ + stdClass::class => InvokableFactory::class + ] +]); + +$object1 = $serviceManager->get(stdClass::class); +$object2 = $serviceManager->get(stdClass::class); + +var_dump($object1 === $object2); // prints "true" +``` + +However, occasionally you may require discrete instances of a service. To +enable this, you can use the `shared` key, providing a boolean false value for +your service, as shown below: + +```php +$serviceManager = new ServiceManager([ + 'factories' => [ + stdClass::class => InvokableFactory::class + ], + 'shared' => [ + stdClass::class => false + ] +]); + +$object1 = $serviceManager->get(stdClass::class); +$object2 = $serviceManager->get(stdClass::class); + +var_dump($object1 === $object2); // prints "false" +``` + +Alternately, you can use the `build()` method instead of the `get()` method. +The `build()` method works exactly the same as the `get` method, but never +caches the service created, nor uses a previously cached instance for the +service. + +```php +$serviceManager = new ServiceManager([ + 'factories' => [ + stdClass::class => InvokableFactory::class + ] +]); + +$object1 = $serviceManager->build(stdClass::class); +$object2 = $serviceManager->build(stdClass::class); + +var_dump($object1 === $object2); // prints "false" +``` + +Finally, you could also decide to disable caching by default (even when calling +the `get()` method), by setting the `shared_by_default` option to false: + +```php +$serviceManager = new ServiceManager([ + 'factories' => [ + stdClass::class => InvokableFactory::class + ], + 'shared_by_default' => false, +]); + +$object1 = $serviceManager->get(stdClass::class); +$object2 = $serviceManager->get(stdClass::class); + +var_dump($object1 === $object2); // prints "false" +``` + +## Passing config to a factory/delegator + +So far, we have covered examples where services are created through factories +(or abstract factories). The factory is able to create the object itself. + +Occasionally you may need to pass additional options that act as a "context". +For instance, we could have a `StringLengthValidator` service registered. +However, this validator can have multiple options, such as `min` and `max`. +Because this is dependent on the caller context (or might even be retrieved +from a database, for instance), the factory cannot know what options to give +when constructing the validator. + +To solve this issue, the service manager offers a `build()` method. It works +similarly to the `get()` method, with two main differences: + +- Services created with the `build()` method are **never cached**, nor pulled + from previously cached instances for that service. +- `build()` accepts an optional secondary parameter, an array of options. + +Those options are transferred to all factories, abstract factories, and delegators. +For instance: + +```php +// In StringLengthValidatorFactory.php + +class StringLengthValidatorFactory implements FactoryInterface +{ + public function __invoke(ContainerInterface $container, $requestedName, array $options = []) + { + return new StringLengthValidator($options); + } +} + +// When creating the service manager: +$serviceManager = new ServiceManager([ + 'factories' => [ + StringLengthValidator::class => StringLengthValidatorFactory::class + ] +]); + +// When creating the objects: + +$validator1 = $serviceManager->build(StringLengthValidator::class, ['min' => 5]); +$validator2 = $serviceManager->build(StringLengthValidator::class, ['min' => 15]); +``` + +In our previous example, because the `StringLengthValidator` does not have any +other dependencies other than the `$options`, we could remove the factory, and +simply map it to the built-in `InvokableFactory` factory: + +```php +// When creating the service manager: +$serviceManager = new ServiceManager([ + 'factories' => [ + StringLengthValidator::class => InvokableFactory::class + ] +]); + +// When creating the objects: + +$validator1 = $serviceManager->build(StringLengthValidator::class, ['min' => 5]); +$validator2 = $serviceManager->build(StringLengthValidator::class, ['min' => 15]); +``` + +This works because the `InvokableFactory` will automatically pass the options +(if any) to the constructor of the created object. + +## Altering a service manager's config + +Assuming that you have not called `$container->setAllowOverride(false)`, you can, +at any time, configure the service manager with new services using any of the +following methods: + +- `configure()`, which accepts the same configuration array as the constructor. +- `setAlias($alias, $target)` +- `setInvokableClass($name, $class = null)`; if no `$class` is passed, the + assumption is that `$name` is the class name. +- `setFactory($name, $factory)`, where `$factory` can be either a callable + factory or the name of a factory class to use. +- `mapLazyService($name, $class = null)`, to map the service name `$name` to + `$class`; if the latter is not provided, `$name` is used for both sides of + the map. +- `addAbstractFactory($factory)`, where `$factory` can be either a + `Laminas\ServiceManager\Factory\AbstractFactoryInterface` instance or the name + of a class implementing the interface. +- `addDelegator($name, $factory)`, where `$factory` can be either a callable + delegator factory, or the name of a delegator factory class to use. +- `addInitializer($initializer)`, where `$initializer` can be either a callable + initializer, or the name of an initializer class to use. +- `setService($name, $instance)` +- `setShared($name, $shared)`, where `$shared` is a boolean flag indicating + whether or not the named service should be shared. + +As examples: + +```php +use Laminas\ServiceManager\ServiceManager; + +$serviceManager = new ServiceManager([ + 'factories' => [ + stdClass::class => InvokableFactory::class; + ] +]); + +$serviceManager->configure([ + 'factories' => [ + DateTime::class => InvokableFactory::class + ] +]); + +var_dump($newServiceManager->has(DateTime::class)); // prints true + +// Create an alias from 'Date' to 'DateTime' +$serviceManager->setAlias('Date', DateTime::class); + +// Set a factory for the 'Time' service +$serviceManager->setFactory('Time', function ($container) { + return $container->get(DateTime::class); +}); + +// Map a lazy service named 'localtime' to the class DateTime. +$serviceManager->mapLazyService('localtime', DateTime::class); + +// Add an abstract factory +$serviceManager->addAbstractFactory(new CustomAbstractFactory()); + +// Add a delegator factory for the DateTime service +$serviceManager->addDelegator(DateTime::class, function ($container, $name, $callback) { + $dateTime = $callback(); + $dateTime->setTimezone(new DateTimezone('UTC')); + return $dateTime; +}); + +// Add an initializer +// Note: don't do this. Use delegator factories instead. +$serviceManager->addInitializer(function ($service, $instance) { + if (! $instance instanceof DateTime) { + return; + } + $instance->setTimezone(new DateTimezone('America/Chicago')); +}) + +// Explicitly map a service name to an instance. +$serviceManager->setService('foo', new stdClass); + +// Mark the DateTime service as NOT being shared. +$serviceManager->setShared(DateTime::class, false); +``` diff --git a/docs/book/v4/console-tools.md b/docs/book/v4/console-tools.md new file mode 100644 index 00000000..73a89f51 --- /dev/null +++ b/docs/book/v4/console-tools.md @@ -0,0 +1,103 @@ +# Console Tools + +Starting in 4.0.0, `laminas-servicemanager` moved the CLI tooling to `laminas-cli` and provides several commands to be executed. + +## Requirements + +To run the console tools with `laminas-servicemanager` v4, the [`laminas/laminas-cli`](https://docs.laminas.dev/laminas-cli/) component needs to be added to the project dependencies. + +> ### Installation +> +> ```shell +> $ composer require laminas/laminas-cli +> ``` +> +> _In case laminas-cli is only required to consume these console tools, you might consider using the `--dev` flag._ + +## Available Commands + +- [Generate Dependencies for Config Factory](#generate-dependencies-for-config-factory) +- [Generate Factory for Class](#generate-factory-for-class) +- [Generate Ahead of Time Factories](#ahead-of-time-factories) + +## Generate Dependencies for Config Factory + +```bash +$ ./vendor/bin/laminas servicemanager:generate-deps-for-config-factory -h +Description: + Reads the provided configuration file (creating it if it does not exist), and injects it with ConfigAbstractFactory dependency configuration for the provided class name, writing the changes back to the file. + +Usage: + servicemanager:generate-deps-for-config-factory [options] [--] + +Arguments: + configFile Path to a config file for which to generate configuration. If the file does not exist, it will be created. If it does exist, it must return an array, and the file will be updated with new configuration. + class Name of the class to reflect and for which to generate dependency configuration. + +Options: + -i, --ignore-unresolved Ignore classes with unresolved direct dependencies. + -q, --quiet Do not output any message +``` + +This utility will generate dependency configuration for the named class for use +with the [ConfigAbstractFactory](config-abstract-factory.md). When doing so, it +will read the named configuration file (creating it if it does not exist), and +merge any configuration it generates with the return values of that file, +writing the changes back to the original file. + +Since 3.2.1, the tool also supports the `-i` or `--ignore-unresolved` flag. +Use these flags when you have typehints to classes that cannot be resolved. +When you omit the flag, such classes will cause the tool to fail with an +exception message. By adding the flag, you can have it continue and produce +configuration. This option is particularly useful when typehints are on +interfaces or resolve to services served by other abstract factories. + +## Generate Factory for Class + +```bash +$ ./vendor/bin/laminas servicemanager:generate-factory-for-class -h +Description: + Generates to STDOUT a factory for creating the specified class; this may then be added to your application, and configured as a factory for the class. + +Usage: + servicemanager:generate-factory-for-class + +Arguments: + className Name of the class to reflect and for which to generate a factory. + +Options: + -q, --quiet Do not output any message +``` + +This utility generates a factory class for the given class, based on the +typehints in its constructor. The factory is emitted to STDOUT, and may be piped +to a file if desired: + +```bash +$ ./vendor/bin/laminas servicemanager:generate-factory-for-class \ +> "Application\\Model\\AlbumModel" > ./module/Application/src/Model/AlbumModelFactory.php +``` + +The class generated implements `Laminas\ServiceManager\Factory\FactoryInterface`, +and is generated within the same namespace as the originating class. + +## Generate Ahead of Time Factories + +```bash +$ vendor/bin/laminas servicemanager:generate-aot-factories -h +Description: + Creates factories which replace the runtime overhead for `ReflectionBasedAbstractFactory`. + +Usage: + servicemanager:generate-aot-factories [] + +Arguments: + localConfigFilename Should be a path targeting a filename which will be created so that the config autoloading will pick it up. Using a `.local.php` suffix should verify that the file is overriding existing configuration. [default: "config/autoload/generated-factories.local.php"] + +Options: + -q, --quiet Do not output any message +``` + +This utility will generate factories in the same way as [servicemanager:generate-factory-for-class](#generate-factory-for-class). The main difference is, that it will scan the whole project configuration for the usage of `ReflectionBasedAbstractFactory` within **any** ServiceManager look-a-like configuration (i.e. explicit usage within `factories`) and auto-generates factories for all of these services **plus** creates a configuration file which overrides **all** ServiceManager look-a-like configurations so that these consume the generated factories. + +For more details and how to set up a project so that all factories are properly replaced, refer to the [dedicated command documentation](ahead-of-time-factories.md). diff --git a/docs/book/v4/cookbook/factories-vs-abstract-factories.md b/docs/book/v4/cookbook/factories-vs-abstract-factories.md new file mode 100644 index 00000000..986747d5 --- /dev/null +++ b/docs/book/v4/cookbook/factories-vs-abstract-factories.md @@ -0,0 +1,98 @@ +# When To Use Factories vs Abstract Factories + +Starting with version 3, `Laminas\ServiceManager\Factory\AbstractFactoryInterface` +extends `Laminas\ServiceManager\Factory\FactoryInterface`, meaning they may be used +as either an abstract factory, or mapped to a specific service name as its +factory. + +As an example: + +```php +return [ + 'factories' => [ + SomeService::class => AnAbstractFactory::class, + ], +]; +``` + +Why would you choose one approach over the other? + +## Comparisons + +Approach | Pros | Cons +---------------- | -------------- | ---- +Abstract factory | One-time setup | Performance; discovery of code responsible for creating instance +Factory | Performance; explicit mapping to factory responsible | Additional (duplicate) setup + +Essentially, it comes down to *convenience* versus *explicitness* and/or +*performance*. + +## Convenience + +Writing a factory per service is time consuming, and, particularly in early +stages of an application, can distract from the actual business of writing the +classes and implementations; in addition, since requirements are often changing +regularly, this boiler-plate code can be a nuisance. + +In such situations, one or more abstract factories — such as the +[ConfigAbstractFactory](../config-abstract-factory.md), the +[ReflectionBasedAbstractFactory](../reflection-abstract-factory.md), or the +[laminas-mvc LazyControllerAbstractFactory](https://docs.laminas.dev/laminas-mvc/cookbook/automating-controller-factories/) +— that can handle the bulk of your needs are often worthwhile, saving you +time and effort as you code. + +## Explicitness + +The drawback of abstract factories is that lookups by the service manager take +longer, and increase based on the number of abstract factories in the system. +The service manager is optimized to locate *factories*, as it can do an +immediate hash table lookup; abstract factories involve: + +- Looping through each abstract factory + - invoking its method for service location + - if the service is located, using the factory + +This means, internally: + +- a hash table lookup (for the abstract factory) +- invocation of 1:N methods for discovery + - which may contain additional lookups and/or retrievals in the container +- invocation of a factory method (assuming successful lookup) + +As such, having an explicit map can aid performance dramatically. + +Additionally, having an explicit map can aid in understanding what class is +responsible for initializing a given service. Without an explicit map, you need +to identify all possible abstract factories, and determine which one is capable +of handling the specific service; in some cases, multiple factories might be +able to, which means you additionally need to know the *order* in which they +will be queried. + +The primary drawback is that you also end up with potentially duplicate +information in your configuration: + +- Multiple services mapped to the same factory. +- In cases such as the `ConfigAbstractFactory`, additional configuration + detailing how to create the service. + +## Tradeoffs + +What it comes down to is which development aspects your organization or project +favor. Hopefully the above arguments detail what tradeoffs occur, so you may +make an appropriate choice. + +## Tooling + +Starting with 3.2.0, we began offering a variety of [console tools](../console-tools.md) +to assist you in generating both dependency configuration and factories. Use +these to help your code evolve. An expected workflow in your application +development evolution is: + +- Usage of the `ReflectionBasedAbstractFactory` as a "catch-all", so that you + do not need to do any factory/dependency configuration immediately. +- Usage of the `ConfigAbstractFactory`, mapped to services, once dependencies + have settled, to disambiguate dependencies, or to list custom services + returning scalar or array values. +- Finally, usage of the `generate-factory-for-class` vendor binary to generate + actual factory classes for your production-ready code, providing the best + performance. diff --git a/docs/book/v4/delegators.md b/docs/book/v4/delegators.md new file mode 100644 index 00000000..a487bef7 --- /dev/null +++ b/docs/book/v4/delegators.md @@ -0,0 +1,209 @@ +# Delegators + +`Laminas\ServiceManager` can instantiate [delegators](http://en.wikipedia.org/wiki/Delegation_pattern) +of requested services, decorating them as specified in a delegate factory +implementing the [delegator factory interface](https://github.com/laminas/laminas-servicemanager/tree/master/src/Factory/DelegatorFactoryInterface.php). + +The delegate pattern is useful in cases when you want to wrap a real service in +a [decorator](http://en.wikipedia.org/wiki/Decorator_pattern), or generally +intercept actions being performed on the delegate in an +[AOP](http://en.wikipedia.org/wiki/Aspect-oriented_programming) fashioned way. + +## Delegator factory signature + +A delegator factory has the following signature: + +```php +use Interop\Container\ContainerInterface; + +public function __invoke( + ContainerInterface $container, + $name, + callable $callback, + array $options = null +); +``` + +The parameters passed to the delegator factory are the following: + +- `$container` is the service locator that is used while creating the delegator + for the requested service. +- `$name` is the name of the service being requested. +- `$callback` is a [callable](http://www.php.net/manual/en/language.types.callable.php) that is + responsible for instantiating the delegated service (the real service instance). +- `$options` is an array of options to use when creating the instance; these are + typically used only during `build()` operations. + +## A Delegator factory use case + +A typical use case for delegators is to handle logic before or after a method is +called. + +In the following example, an event is being triggered before `Buzzer::buzz()` is +called and some output text is prepended. + +The delegated object `Buzzer` (original object) is defined as following: + +```php +class Buzzer +{ + public function buzz() + { + return 'Buzz!'; + } +} +``` + +The delegator class `BuzzerDelegator` has the following structure: + +```php +use Laminas\EventManager\EventManagerInterface; + +class BuzzerDelegator extends Buzzer +{ + protected $realBuzzer; + protected $eventManager; + + public function __construct(Buzzer $realBuzzer, EventManagerInterface $eventManager) + { + $this->realBuzzer = $realBuzzer; + $this->eventManager = $eventManager; + } + + public function buzz() + { + $this->eventManager->trigger('buzz', $this); + + return $this->realBuzzer->buzz(); + } +} +``` + +To use the `BuzzerDelegator`, you can run the following code: + +```php +$wrappedBuzzer = new Buzzer(); +$eventManager = new Laminas\EventManager\EventManager(); + +$eventManager->attach('buzz', function () { echo "Stare at the art!\n"; }); + +$buzzer = new BuzzerDelegator($wrappedBuzzer, $eventManager); + +echo $buzzer->buzz(); // "Stare at the art!\nBuzz!" +``` + +This logic is fairly simple as long as you have access to the instantiation +logic of the `$wrappedBuzzer` object. + +You may not always be able to define how `$wrappedBuzzer` is created, since a +factory for it may be defined by some code to which you don't have access, or +which you cannot modify without introducing further complexity. + +Delegator factories solve this specific problem by allowing you to wrap, +decorate or modify any existing service. + +A simple delegator factory for the `buzzer` service can be implemented as +following: + +```php +use Interop\Container\ContainerInterface; +use Laminas\ServiceManager\Factory\DelegatorFactoryInterface; + +class BuzzerDelegatorFactory implements DelegatorFactoryInterface +{ + public function __invoke(ContainerInterface $container, $name, callable $callback, array $options = null) + { + $realBuzzer = call_user_func($callback); + $eventManager = $container->get('EventManager'); + + $eventManager->attach('buzz', function () { echo "Stare at the art!\n"; }); + + return new BuzzerDelegator($realBuzzer, $eventManager); + } +} +``` + +You can then instruct the service manager to handle the service `buzzer` as a +delegate: + +```php +use Laminas\ServiceManager\Factory\InvokableFactory; +use Laminas\ServiceManager\ServiceManager; + +$serviceManager = new Laminas\ServiceManager\ServiceManager([ + 'factories' => [ + Buzzer::class => InvokableFactory::class, + ], + 'delegators' => [ + Buzzer::class => [ + BuzzerDelegatorFactory::class, + ], + ], +]); + +// now, when fetching Buzzer, we get a BuzzerDelegator instead +$buzzer = $serviceManager->get(Buzzer::class); + +$buzzer->buzz(); // "Stare at the art!\nBuzz!" +``` + +You can specify multiple delegators for a service. Each will add one decorator +around the instantiation logic of that particular service. + +This latter point is the primary use case for delegators: *decorating the +instantiation logic for a service*. + +## Delegator Factories and Service Aliases + +In typical [service manager configurations](./configuring-the-service-manager.md) you have the opportunity to alias services. The following configuration would enable you to retrieve a `Buzzer` instance by its concrete implementation name and by the name of an interface that it implements, in this case, `BuzzerInterface`. + +```php +$serviceManager = new Laminas\ServiceManager\ServiceManager([ + 'factories' => [ + Buzzer::class => Laminas\ServiceManager\Factory\InvokableFactory::class, + ], + 'aliases' => [ + BuzzerInterface::class => Buzzer::class, + ], +]); +``` + +Currently, a delegator factory that targets an alias will not execute. Delegators must be configured using the resolved name of the service. + +For example, given the following configuration, **no delegation would occur**: + +```php +$serviceManager = new Laminas\ServiceManager\ServiceManager([ + 'factories' => [ + Buzzer::class => Laminas\ServiceManager\Factory\InvokableFactory::class, + ], + 'aliases' => [ + BuzzerInterface::class => Buzzer::class, + ], + 'delegators' => [ + BuzzerInterface::class => [ + BuzzerDelegatorFactory::class, // will not be executed + ], + ], +]); +``` + +In order for delegation to occur, the above configuration would need to be modified to target the resolved service name: + +```php +$serviceManager = new Laminas\ServiceManager\ServiceManager([ + 'factories' => [ + Buzzer::class => Laminas\ServiceManager\Factory\InvokableFactory::class, + ], + 'aliases' => [ + BuzzerInterface::class => Buzzer::class, + ], + 'delegators' => [ + Buzzer::class => [ + BuzzerDelegatorFactory::class, // will now execute as expected + ], + ], +]); +``` + +Retrieving the `Buzzer` using its resolved name "`Buzzer::class`" or its alias "`BuzzerInterface::class`" will now both yield delegated instances. diff --git a/docs/book/v4/index.html b/docs/book/v4/index.html new file mode 100644 index 00000000..5c00f74d --- /dev/null +++ b/docs/book/v4/index.html @@ -0,0 +1,10 @@ +
+
+

laminas-servicemanager

+ +

Factory-Driven Dependency Injection Container

+ +
$ composer require laminas/laminas-servicemanager
+
+
+ diff --git a/docs/book/v4/index.md b/docs/book/v4/index.md new file mode 120000 index 00000000..8a33348c --- /dev/null +++ b/docs/book/v4/index.md @@ -0,0 +1 @@ +../../../README.md \ No newline at end of file diff --git a/docs/book/lazy-services.md b/docs/book/v4/lazy-services.md similarity index 100% rename from docs/book/lazy-services.md rename to docs/book/v4/lazy-services.md diff --git a/docs/book/v4/migration.md b/docs/book/v4/migration.md new file mode 100644 index 00000000..c3b0fb29 --- /dev/null +++ b/docs/book/v4/migration.md @@ -0,0 +1,3 @@ +# Migration Guide + +TBD diff --git a/docs/book/plugin-managers.md b/docs/book/v4/plugin-managers.md similarity index 100% rename from docs/book/plugin-managers.md rename to docs/book/v4/plugin-managers.md diff --git a/docs/book/v4/psr-11.md b/docs/book/v4/psr-11.md new file mode 100644 index 00000000..c0bfe98f --- /dev/null +++ b/docs/book/v4/psr-11.md @@ -0,0 +1,55 @@ +# PSR-11 Support + +[container-interop/container-interop 1.2.0](https://github.com/container-interop/container-interop/releases/tag/1.2.0) +modifies its codebase to extend interfaces from [psr/container](https://github.com/php-fig/container) +(the official interfaces for [PSR-11](http://www.php-fig.org/psr/psr-11/)). If +you are on a pre-3.3.0 version of laminas-servicemanager, update your project, and +receive container-interop 1.2, then laminas-servicemanager can already act as a +PSR-11 provider! + +laminas-servicemanager 3.3.0 requires at least version 1.2 of container-interop, +and _also_ requires psr/container 1.0 to explicitly signal that it is a PSR-11 +provider, and to allow removal of the container-interop dependency later. + +Version 4.0 will require only psr/container, and will update the various factory +interfaces and exception implementations to typehint against the PSR-11 +interfaces, which will require changes to any implementations you have. In the +meantime, you can [duck-type](https://en.wikipedia.org/wiki/Duck_typing) the +following factory types: + +- `Laminas\ServiceManager\Factory\FactoryInterface`: use a callable with the + following signature: + ```php + function ( + \Psr\Container\ContainerInterface $container, + string $requestedName, + array $options = null + ) + ``` + +- `Laminas\ServiceManager\Factory\DelegatorFactoryInterface`: use a callable with + the following signature: + ```php + function ( + \Psr\Container\ContainerInterface $container, + string $name, + callable $callback, + array $options = null + ) + ``` + +- `Laminas\ServiceManager\Initializer\InitializerInterface`: use a callable with + the following signature: + ```php + function ( + \Psr\Container\ContainerInterface $container, + $instance + ) + ``` + +Abstract factories _can not_ be duck typed, due to the additional `canCreate()` +method. + +You can also leave your factories as-is for now, and update them once +laminas-servicemanager v4.0 is released, at which time we will be providing tooling +to help migrate your factories to PSR-11. diff --git a/docs/book/v4/quick-start.md b/docs/book/v4/quick-start.md new file mode 100644 index 00000000..c9246dad --- /dev/null +++ b/docs/book/v4/quick-start.md @@ -0,0 +1,67 @@ +# Quick Start + +The Service Manager is a modern, fast, and easy-to-use implementation of the +[Service Locator design pattern](https://en.wikipedia.org/wiki/Service_locator_pattern). +The implementation implements the +[Container Interop](https://github.com/container-interop/container-interop) +interfaces, providing interoperability with other implementations. + +The following is a "quick start" tutorial intended to get you up and running +with the most common features of the Service manager. + +## 1. Install Laminas Service Manager + +If you haven't already, [install Composer](https://getcomposer.org). Once you +have, you can install the service manager: + +```bash +$ composer require laminas/laminas-servicemanager +``` + +## 2. Configuring a service manager + +You can now create and configure a service manager. The service manager +constructor accepts a simple array: + +```php +use Laminas\ServiceManager\ServiceManager; +use Laminas\ServiceManager\Factory\InvokableFactory; +use stdClass; + +$serviceManager = new ServiceManager([ + 'factories' => [ + stdClass::class => InvokableFactory::class, + ], +]); +``` + +The service manager accepts a variety of keys; refer to the +[Configuring service manager](configuring-the-service-manager.md) section for +full details. + +## 3. Retrieving objects + +Finally, you can retrieve instances using the `get()` method: + +```php +$object = $serviceManager->get(stdClass::class); +``` + +By default, all objects created through the service manager are shared. This +means that calling the `get()` method twice will return the exact same object: + +```php +$object1 = $serviceManager->get(stdClass::class); +$object2 = $serviceManager->get(stdClass::class); + +var_dump($object1 === $object2); // prints "true" +``` + +You can use the `build()` method to retrieve discrete instances for a service: + +```php +$object1 = $serviceManager->build(stdClass::class); +$object2 = $serviceManager->build(stdClass::class); + +var_dump($object1 === $object2); // prints "false" +``` diff --git a/docs/book/v4/reflection-abstract-factory.md b/docs/book/v4/reflection-abstract-factory.md new file mode 100644 index 00000000..cbd9beeb --- /dev/null +++ b/docs/book/v4/reflection-abstract-factory.md @@ -0,0 +1,69 @@ +# Reflection Factory + +- Since 3.2.0. + +Writing a factory class for each and every service that has dependencies +can be tedious, particularly in early development as you are still sorting +out dependencies. + +laminas-servicemanager ships with `Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory`, +which provides a reflection-based approach to instantiation, resolving +constructor dependencies to the relevant services. The factory may be used as +either an abstract factory, or mapped to specific service names as a factory: + +```php +use Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory; + +return [ + /* ... */ + 'service_manager' => [ + 'abstract_factories' => [ + ReflectionBasedAbstractFactory::class, + ], + 'factories' => [ + 'MyModule\Model\FooModel' => ReflectionBasedAbstractFactory::class, + ], + ], + /* ... */ +]; +``` + +Mapping services to the factory is more explicit and even more performant than in v3.0 due to the [ahead of time factory generation](ahead-of-time-factories.md). + +The factory operates with the following constraints/features: + +- A parameter named `$config` typehinted as an array will receive the + application "config" service (i.e., the merged configuration). +- Parameters typehinted against array, but not named `$config`, will + be injected with an empty array. +- Scalar parameters will result in the factory raising an exception, + unless a default value is present; if it is, that value will be used. +- If a service cannot be found for a given typehint, the factory will + raise an exception detailing this. + +`$options` passed to the factory are ignored in all cases, as we cannot +make assumptions about which argument(s) they might replace. + +Once your dependencies have stabilized, we recommend providing a dedicated +factory, as reflection introduces a performance overhead. + +There are two ways to provide dedicated factories for services consuming `ReflectionBasedAbstractFactory`: + +1. Usage of the [generate-factory-for-class console tool](console-tools.md#generate-factory-for-class) (this will also require to manually modify the configuration) +2. Usage of the [generate-aot-factories console tool](console-tools.md#generate-ahead-of-time-factories) which needs an initial project + deployment setup + +## Alternatives + +You may also use the [Config Abstract Factory](config-abstract-factory.md), +which gives slightly more flexibility in terms of mapping dependencies: + +- If you wanted to map to a specific implementation, choose the + `ConfigAbstractFactory`. +- If you need to map to a service that will return a scalar or array (e.g., a + subset of the `'config'` service), choose the `ConfigAbstractFactory`. +- If you need a faster factory for production, choose the + `ConfigAbstractFactory` or create a custom factory. + +## References + +This feature was inspired by [a blog post by Alexandre Lemaire](http://circlical.com/blog/2016/3/9/preparing-for-zend-f). diff --git a/mkdocs.yml b/mkdocs.yml index 35bad2f6..7e405119 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,7 +10,9 @@ nav: - 'Lazy services': lazy-services.md - 'Plugin managers': plugin-managers.md - 'Configuration-based Abstract Factory': config-abstract-factory.md - - 'Reflection-based Abstract Factory': reflection-abstract-factory.md + - 'Reflection-based Abstract Factory': + - 'Usage': reflection-abstract-factory.md + - 'Ahead of Time Factories': ahead-of-time-factories.md - 'Console Tools': console-tools.md - Cookbook: - 'Factories vs Abstract Factories': cookbook/factories-vs-abstract-factories.md diff --git a/psalm-baseline.xml b/psalm-baseline.xml index bcc795db..f80e1fcb 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -20,36 +20,9 @@ $requestedName - - new $requestedName() - new $requestedName() - new $requestedName(...$parameters) - - - function (ReflectionParameter $parameter) use ($container, $requestedName) { - - - $requestedName - - - $requestedName - $requestedName - $requestedName - - - new $requestedName() - new $requestedName() + new $requestedName(...$parameters) - - DispatchableInterface - - - is_string($type) - - - DispatchableInterface - @@ -247,6 +220,11 @@ array + + + enum_exists($service) + + $config['service_manager'] @@ -279,21 +257,6 @@ 'Holistic' - - - assertInstanceOf - assertInstanceOf - assertInstanceOf - assertInstanceOf - assertInstanceOf - assertInstanceOf - assertInstanceOf - assertInstanceOf - - - array - - setServiceLocator @@ -334,9 +297,6 @@ - - getServiceLocator - $object['get'][0] @@ -368,7 +328,7 @@ $names[$name] $object[$shared ? $method : 'build'] - + $first $idx1 $idx2 @@ -379,14 +339,6 @@ $nonSharedObj1 $nonSharedObj2 $obj - $object1 - $object1 - $object1 - $object1 - $object2 - $object2 - $object2 - $object2 $object[$shared ? $method : 'build'][] $second $shared @@ -467,22 +419,14 @@ $context $name - - $a - $alias - $alias - $b - $headAlias + $inc $inc - $instance $instance1 $instance1 $instance2 $instance2 $service - $service - $service $serviceFromAlias $serviceFromServiceNameAfterUsingAlias @@ -492,10 +436,6 @@ $inc - - $instance->foo - $instance->option - $container $container @@ -505,6 +445,21 @@ $name + + + + array<non-empty-string,array{string}> + + + WhateverEnum + + + + + WhateverEnum + case + + $test diff --git a/psalm.xml.dist b/psalm.xml.dist index 220b419a..78f1b869 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -63,5 +63,6 @@ + diff --git a/src/AbstractFactory/ReflectionBasedAbstractFactory.php b/src/AbstractFactory/ReflectionBasedAbstractFactory.php index 4f4654bd..dbda3046 100644 --- a/src/AbstractFactory/ReflectionBasedAbstractFactory.php +++ b/src/AbstractFactory/ReflectionBasedAbstractFactory.php @@ -4,17 +4,14 @@ namespace Laminas\ServiceManager\AbstractFactory; -use Laminas\ServiceManager\Exception\ServiceNotFoundException; +use Laminas\ServiceManager\Exception\InvalidArgumentException; use Laminas\ServiceManager\Factory\AbstractFactoryInterface; +use Laminas\ServiceManager\Tool\ConstructorParameterResolver\ConstructorParameterResolver; +use Laminas\ServiceManager\Tool\ConstructorParameterResolver\ConstructorParameterResolverInterface; use Psr\Container\ContainerInterface; use ReflectionClass; -use ReflectionNamedType; -use ReflectionParameter; -use function array_map; use function class_exists; -use function interface_exists; -use function is_string; use function sprintf; /** @@ -67,72 +64,44 @@ * * Based on the LazyControllerAbstractFactory from laminas-mvc. */ -class ReflectionBasedAbstractFactory implements AbstractFactoryInterface +final class ReflectionBasedAbstractFactory implements AbstractFactoryInterface { - /** - * Maps known classes/interfaces to the service that provides them; only - * required for those services with no entry based on the class/interface - * name. - * - * Extend the class if you wish to add to the list. - * - * Example: - * - * - * [ - * \Laminas\Filter\FilterPluginManager::class => 'FilterManager', - * \Laminas\Validator\ValidatorPluginManager::class => 'ValidatorManager', - * ] - * - * - * @var string[] - */ - protected $aliases = []; + private ConstructorParameterResolverInterface $constructorParameterResolver; /** * Allows overriding the internal list of aliases. These should be of the * form `class name => well-known service name`; see the documentation for * the `$aliases` property for details on what is accepted. * - * @param string[] $aliases + * @param array $aliases */ - public function __construct(array $aliases = []) - { - if (! empty($aliases)) { - $this->aliases = $aliases; - } + public function __construct( + public array $aliases = [], + ?ConstructorParameterResolverInterface $constructorParameterResolver = null, + ) { + $this->constructorParameterResolver = $constructorParameterResolver ?? new ConstructorParameterResolver(); } /** * {@inheritDoc} - * - * @return DispatchableInterface */ - public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null) + public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null): object { - $reflectionClass = new ReflectionClass($requestedName); - - if (null === ($constructor = $reflectionClass->getConstructor())) { - return new $requestedName(); + if (! class_exists($requestedName)) { + throw new InvalidArgumentException(sprintf('%s can only be used with class names.', self::class)); } - $reflectionParameters = $constructor->getParameters(); - - if (empty($reflectionParameters)) { - return new $requestedName(); - } - - $resolver = $container->has('config') - ? $this->resolveParameterWithConfigService($container, $requestedName) - : $this->resolveParameterWithoutConfigService($container, $requestedName); - - $parameters = array_map($resolver, $reflectionParameters); + $parameters = $this->constructorParameterResolver->resolveConstructorParameters( + $requestedName, + $container, + $this->aliases + ); return new $requestedName(...$parameters); } /** {@inheritDoc} */ - public function canCreate(ContainerInterface $container, $requestedName) + public function canCreate(ContainerInterface $container, $requestedName): bool { return class_exists($requestedName) && $this->canCallConstructor($requestedName); } @@ -143,103 +112,4 @@ private function canCallConstructor(string $requestedName): bool return $constructor === null || $constructor->isPublic(); } - - /** - * Resolve a parameter to a value. - * - * Returns a callback for resolving a parameter to a value, but without - * allowing mapping array `$config` arguments to the `config` service. - * - * @param string $requestedName - * @return callable - */ - private function resolveParameterWithoutConfigService(ContainerInterface $container, $requestedName) - { - /** - * @param ReflectionParameter $parameter - * @return mixed - * @throws ServiceNotFoundException If type-hinted parameter cannot be - * resolved to a service in the container. - * @psalm-suppress MissingClosureReturnType - */ - return fn(ReflectionParameter $parameter) => $this->resolveParameter($parameter, $container, $requestedName); - } - - /** - * Returns a callback for resolving a parameter to a value, including mapping 'config' arguments. - * - * Unlike resolveParameter(), this version will detect `$config` array - * arguments and have them return the 'config' service. - * - * @param string $requestedName - * @return callable - */ - private function resolveParameterWithConfigService(ContainerInterface $container, $requestedName) - { - /** - * @param ReflectionParameter $parameter - * @return mixed - * @throws ServiceNotFoundException If type-hinted parameter cannot be - * resolved to a service in the container. - */ - return function (ReflectionParameter $parameter) use ($container, $requestedName) { - if ($parameter->getName() === 'config') { - $type = $parameter->getType(); - if ($type instanceof ReflectionNamedType && $type->getName() === 'array') { - return $container->get('config'); - } - } - return $this->resolveParameter($parameter, $container, $requestedName); - }; - } - - /** - * Logic common to all parameter resolution. - * - * @param string $requestedName - * @return mixed - * @throws ServiceNotFoundException If type-hinted parameter cannot be - * resolved to a service in the container. - */ - private function resolveParameter(ReflectionParameter $parameter, ContainerInterface $container, $requestedName) - { - $type = $parameter->getType(); - $type = $type instanceof ReflectionNamedType ? $type->getName() : null; - - if ($type === 'array') { - return []; - } - - if ($type === null || (is_string($type) && ! class_exists($type) && ! interface_exists($type))) { - if (! $parameter->isDefaultValueAvailable()) { - throw new ServiceNotFoundException(sprintf( - 'Unable to create service "%s"; unable to resolve parameter "%s" ' - . 'to a class, interface, or array type', - $requestedName, - $parameter->getName() - )); - } - - return $parameter->getDefaultValue(); - } - - $type = $this->aliases[$type] ?? $type; - - if ($container->has($type)) { - return $container->get($type); - } - - if (! $parameter->isOptional()) { - throw new ServiceNotFoundException(sprintf( - 'Unable to create service "%s"; unable to resolve parameter "%s" using type hint "%s"', - $requestedName, - $parameter->getName(), - $type - )); - } - - // Type not available in container, but the value is optional and has a - // default defined. - return $parameter->getDefaultValue(); - } } diff --git a/src/Command/AheadOfTimeFactoryCreatorCommand.php b/src/Command/AheadOfTimeFactoryCreatorCommand.php new file mode 100644 index 00000000..c054c309 --- /dev/null +++ b/src/Command/AheadOfTimeFactoryCreatorCommand.php @@ -0,0 +1,159 @@ +setDescription( + 'Creates factories which replace the runtime overhead for `ReflectionBasedAbstractFactory`.' + ); + $this->addArgument( + 'localConfigFilename', + InputArgument::OPTIONAL, + 'Should be a path targeting a filename which will be created so that the config autoloading' + . ' will pick it up. Using a `.local.php` suffix should verify that the file is overriding existing' + . ' configuration.', + 'config/autoload/generated-factories.local.php', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + if ($this->factoryTargetPath === '' || ! is_writable($this->factoryTargetPath)) { + $output->writeln(sprintf( + 'Please configure the `%s` configuration key in your projects config and ensure that the' + . ' directory is registered to the composer autoloader using `classmap` and writable by the executing' + . ' user. In case you are targeting a nonexistent directory, please create the appropriate directory' + . ' structure before executing this command.', + ConfigProvider::CONFIGURATION_KEY_FACTORY_TARGET_PATH, + )); + + return self::FAILURE; + } + + $localConfigFilename = $input->getArgument('localConfigFilename'); + assert(is_string($localConfigFilename)); + + if (! is_writable(dirname($localConfigFilename))) { + $output->writeln(sprintf( + 'Provided `localConfigFilename` argument "%s" is not writable. In case you are targeting a' + . ' nonexistent directory, please create the appropriate directory structure before executing this' + . ' command.', + $localConfigFilename, + )); + + return self::FAILURE; + } + + $compiledFactories = $this->factoryCompiler->compile($this->config); + if ($compiledFactories === []) { + $output->writeln( + 'There is no (more) service registered to use the `ReflectionBasedAbstractFactory`.' + ); + + return self::SUCCESS; + } + + $containerConfigurations = []; + + foreach ($compiledFactories as $factory) { + $targetDirectory = sprintf( + '%s/%s', + $this->factoryTargetPath, + preg_replace('/\W/', '', $factory->containerConfigurationKey) + ); + + /** @var class-string $factoryClassName */ + $factoryClassName = sprintf('%sFactory', $factory->fullyQualifiedClassName); + if (class_exists($factoryClassName)) { + $output->writeln(sprintf( + 'There is already an existing factory class registered for "%s": %s', + $factory->fullyQualifiedClassName, + $factoryClassName, + )); + + return self::FAILURE; + } + + if (! is_dir($targetDirectory)) { + if (! mkdir($targetDirectory, recursive: true) && ! is_dir($targetDirectory)) { + throw new RuntimeException(sprintf('Unable to create directory "%s".', $targetDirectory)); + } + } + + $factoryFileName = sprintf( + '%s/%s.php', + $targetDirectory, + str_replace('\\', '_', $factoryClassName) + ); + file_put_contents($factoryFileName, $factory->generatedFactory); + if (! isset($containerConfigurations[$factory->containerConfigurationKey])) { + $containerConfigurations[$factory->containerConfigurationKey] = ['factories' => []]; + } + + $containerConfigurations[$factory->containerConfigurationKey]['factories'] += [ + $factory->fullyQualifiedClassName => $factoryClassName, + ]; + } + + file_put_contents($localConfigFilename, $this->createLocalAotContainerConfigContent($containerConfigurations)); + + $output->writeln(sprintf('Successfully created %d factories.', count($compiledFactories))); + return self::SUCCESS; + } + + /** + * @param non-empty-array $containerConfigurations + * @return non-empty-string + */ + private function createLocalAotContainerConfigContent(array $containerConfigurations): string + { + return sprintf('get(AheadOfTimeFactoryCompilerInterface::class); + $config = $container->has('config') ? $container->get('config') : []; + if (! is_iterable($config)) { + return new AheadOfTimeFactoryCreatorCommand([], '', $aheadOfTimeFactoryCompiler); + } + + if (! is_array($config)) { + $config = iterator_to_array($config); + } + + /** @psalm-suppress MixedAssignment Even tho we do verify the type right after assigning it, psalm has problems with that*/ + $factoryTargetPath = $config[ConfigProvider::CONFIGURATION_KEY_FACTORY_TARGET_PATH] ?? ''; + if (! is_string($factoryTargetPath)) { + $factoryTargetPath = ''; + } + + return new AheadOfTimeFactoryCreatorCommand($config, $factoryTargetPath, $aheadOfTimeFactoryCompiler); + } +} diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index 5bc5cba8..abc79e56 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -4,12 +4,18 @@ namespace Laminas\ServiceManager; +use Laminas\ServiceManager\Command\AheadOfTimeFactoryCreatorCommand; +use Laminas\ServiceManager\Command\AheadOfTimeFactoryCreatorCommandFactory; use Laminas\ServiceManager\Command\ConfigDumperCommand; use Laminas\ServiceManager\Command\FactoryCreatorCommand; use Laminas\ServiceManager\Factory\InvokableFactory; +use Laminas\ServiceManager\Tool\AheadOfTimeFactoryCompiler\AheadOfTimeFactoryCompilerFactory; +use Laminas\ServiceManager\Tool\AheadOfTimeFactoryCompiler\AheadOfTimeFactoryCompilerInterface; use Laminas\ServiceManager\Tool\ConfigDumperFactory; use Laminas\ServiceManager\Tool\ConfigDumperInterface; -use Laminas\ServiceManager\Tool\FactoryCreator; +use Laminas\ServiceManager\Tool\ConstructorParameterResolver\ConstructorParameterResolver; +use Laminas\ServiceManager\Tool\ConstructorParameterResolver\ConstructorParameterResolverInterface; +use Laminas\ServiceManager\Tool\FactoryCreatorFactory; use Laminas\ServiceManager\Tool\FactoryCreatorInterface; use Symfony\Component\Console\Command\Command; @@ -20,6 +26,8 @@ */ final class ConfigProvider { + public const CONFIGURATION_KEY_FACTORY_TARGET_PATH = 'aot-factory-target-path'; + /** * @return array{ * dependencies: ServiceManagerConfigurationType, @@ -40,14 +48,18 @@ public function __invoke(): array public function getServiceDependencies(): array { $factories = [ - ConfigDumperInterface::class => ConfigDumperFactory::class, - FactoryCreatorInterface::class => static fn (): FactoryCreatorInterface => new FactoryCreator(), + ConfigDumperInterface::class => ConfigDumperFactory::class, + FactoryCreatorInterface::class => FactoryCreatorFactory::class, + AheadOfTimeFactoryCompilerInterface::class => AheadOfTimeFactoryCompilerFactory::class, + ConstructorParameterResolverInterface::class => static fn (): ConstructorParameterResolverInterface + => new ConstructorParameterResolver(), ]; if (class_exists(Command::class)) { $factories += [ - ConfigDumperCommand::class => InvokableFactory::class, - FactoryCreatorCommand::class => InvokableFactory::class, + AheadOfTimeFactoryCreatorCommand::class => AheadOfTimeFactoryCreatorCommandFactory::class, + ConfigDumperCommand::class => InvokableFactory::class, + FactoryCreatorCommand::class => InvokableFactory::class, ]; } @@ -67,8 +79,9 @@ private function getLaminasCliDependencies(): array return [ 'commands' => [ - ConfigDumperCommand::NAME => ConfigDumperCommand::class, - FactoryCreatorCommand::NAME => FactoryCreatorCommand::class, + ConfigDumperCommand::NAME => ConfigDumperCommand::class, + FactoryCreatorCommand::NAME => FactoryCreatorCommand::class, + AheadOfTimeFactoryCreatorCommand::NAME => AheadOfTimeFactoryCreatorCommand::class, ], ]; } diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php new file mode 100644 index 00000000..d94d037f --- /dev/null +++ b/src/Exception/RuntimeException.php @@ -0,0 +1,11 @@ +extractServicesRegisteredByReflectionBasedFactory( + $config + ); + + $compiledFactories = []; + + foreach ($servicesRegisteredByReflectionBasedFactory as $service => [$containerConfigurationKey, $aliases]) { + $compiledFactories[] = new AheadOfTimeCompiledFactory( + $service, + $containerConfigurationKey, + $this->factoryCreator->createFactory($service, $aliases), + ); + } + + return $compiledFactories; + } + + /** + * @return array}> + */ + private function extractServicesRegisteredByReflectionBasedFactory(array $config): array + { + $services = []; + + foreach ($config as $key => $entry) { + if (! is_string($key) || $key === '' || ! is_array($entry)) { + continue; + } + + if (! array_key_exists('factories', $entry) || ! is_array($entry['factories'])) { + continue; + } + + /** @var array> $servicesUsingReflectionBasedFactory */ + $servicesUsingReflectionBasedFactory = array_filter( + $entry['factories'], + static fn(mixed $value): bool => + $value === ReflectionBasedAbstractFactory::class + || $value instanceof ReflectionBasedAbstractFactory, + ARRAY_FILTER_USE_BOTH, + ); + + if ($servicesUsingReflectionBasedFactory === []) { + continue; + } + + foreach ($servicesUsingReflectionBasedFactory as $service => $factory) { + if (! $this->canServiceBeUsedWithReflectionBasedFactory($service)) { + throw new InvalidArgumentException(sprintf( + 'Configured service "%s" using the `ReflectionBasedAbstractFactory` does not exist or does' + . ' not refer to an actual class.', + $service + )); + } + + if (isset($services[$service])) { + throw new InvalidArgumentException(sprintf( + 'The exact same service "%s" is registered in (at least) two service-/plugin-managers: %s, %s', + $service, + $services[$service][0], + $key + )); + } + + $aliases = []; + if ($factory instanceof ReflectionBasedAbstractFactory && $factory->aliases !== []) { + $aliases = $factory->aliases; + } + + $services[$service] = [$key, $aliases]; + } + } + + return $services; + } + + /** + * Starting with PHP 8.1, `class_exists` resolves to `true` for enums. + * + * @link https://3v4l.org/FY7eg + * + * @psalm-assert-if-true class-string $service + */ + private function canServiceBeUsedWithReflectionBasedFactory(string $service): bool + { + if (! class_exists($service)) { + return false; + } + + if (PHP_VERSION_ID < 80100) { + return true; + } + + return ! enum_exists($service); + } +} diff --git a/src/Tool/AheadOfTimeFactoryCompiler/AheadOfTimeFactoryCompilerFactory.php b/src/Tool/AheadOfTimeFactoryCompiler/AheadOfTimeFactoryCompilerFactory.php new file mode 100644 index 00000000..2e997707 --- /dev/null +++ b/src/Tool/AheadOfTimeFactoryCompiler/AheadOfTimeFactoryCompilerFactory.php @@ -0,0 +1,16 @@ +get(FactoryCreatorInterface::class)); + } +} diff --git a/src/Tool/AheadOfTimeFactoryCompiler/AheadOfTimeFactoryCompilerInterface.php b/src/Tool/AheadOfTimeFactoryCompiler/AheadOfTimeFactoryCompilerInterface.php new file mode 100644 index 00000000..c954a22f --- /dev/null +++ b/src/Tool/AheadOfTimeFactoryCompiler/AheadOfTimeFactoryCompilerInterface.php @@ -0,0 +1,13 @@ + + */ + public function compile(array $config): array; +} diff --git a/src/Tool/ConfigDumper.php b/src/Tool/ConfigDumper.php index 20f5b32d..4e131c2e 100644 --- a/src/Tool/ConfigDumper.php +++ b/src/Tool/ConfigDumper.php @@ -59,17 +59,16 @@ public function createDependencyConfig(array $config, string $className, bool $i return $this->createInvokable($config, $className); } - $constructorArguments = $constructor->getParameters(); - $constructorArguments = array_filter( - $constructorArguments, - static fn(ReflectionParameter $argument): bool => ! $argument->isOptional() - ); - // has no required parameters, treat it as an invokable - if ($constructorArguments === []) { + if ($constructor->getNumberOfRequiredParameters() === 0) { return $this->createInvokable($config, $className); } + $constructorArguments = array_filter( + $constructor->getParameters(), + static fn(ReflectionParameter $argument): bool => ! $argument->isOptional() + ); + $classConfig = []; foreach ($constructorArguments as $constructorArgument) { @@ -81,6 +80,7 @@ public function createDependencyConfig(array $config, string $className, bool $i // don't throw an exception, just return the previous config return $config; } + // don't throw an exception if the class is an already defined service if ($this->container && $this->container->has($className)) { return $config; diff --git a/src/Tool/ConstructorParameterResolver/ConstructorParameterResolver.php b/src/Tool/ConstructorParameterResolver/ConstructorParameterResolver.php new file mode 100644 index 00000000..6e1830e0 --- /dev/null +++ b/src/Tool/ConstructorParameterResolver/ConstructorParameterResolver.php @@ -0,0 +1,175 @@ +resolveConstructorParameterServiceNamesOrFallbackTypes($className, $container, $aliases); + + return array_map(static function ( + FallbackConstructorParameter|ServiceFromContainerConstructorParameter $parameter + ) use ($container): mixed { + if ($parameter instanceof FallbackConstructorParameter) { + return $parameter->argumentValue; + } + + return $container->get($parameter->serviceName); + }, $parameters); + } + + /** + * Resolve a parameter to a value. + * + * Returns a callback for resolving a parameter to a value, but without + * allowing mapping array `$config` arguments to the `config` service. + * + * @param class-string $className + * @param array $aliases + * @return callable(ReflectionParameter):(FallbackConstructorParameter|ServiceFromContainerConstructorParameter) + */ + private function resolveParameterWithoutConfigService( + ContainerInterface $container, + string $className, + array $aliases + ): callable { + return fn(ReflectionParameter $parameter): FallbackConstructorParameter|ServiceFromContainerConstructorParameter + => $this->resolveParameter($parameter, $container, $className, $aliases); + } + + /** + * Returns a callback for resolving a parameter to a value, including mapping 'config' arguments. + * + * Unlike resolveParameter(), this version will detect `$config` array + * arguments and have them return the 'config' service. + * + * @param class-string $className + * @param array $aliases + * @return callable(ReflectionParameter):(FallbackConstructorParameter|ServiceFromContainerConstructorParameter) + */ + private function resolveParameterWithConfigService( + ContainerInterface $container, + string $className, + array $aliases + ): callable { + return function ( + ReflectionParameter $parameter + ) use ( + $container, + $className, + $aliases + ): FallbackConstructorParameter|ServiceFromContainerConstructorParameter { + if ($parameter->getName() === 'config') { + $type = $parameter->getType(); + if ( + $type instanceof ReflectionNamedType + && in_array($type->getName(), ['array', ArrayAccess::class], true) + ) { + return new ServiceFromContainerConstructorParameter('config'); + } + } + return $this->resolveParameter($parameter, $container, $className, $aliases); + }; + } + + /** + * Logic common to all parameter resolution. + * + * @param class-string $className + * @param array $aliases + * @throws ServiceNotFoundException If type-hinted parameter cannot be + * resolved to a service in the container. + */ + private function resolveParameter( + ReflectionParameter $parameter, + ContainerInterface $container, + string $className, + array $aliases + ): FallbackConstructorParameter|ServiceFromContainerConstructorParameter { + $type = $parameter->getType(); + $type = $type instanceof ReflectionNamedType ? $type->getName() : null; + + if ($type === null || (! class_exists($type) && ! interface_exists($type))) { + if (! $parameter->isDefaultValueAvailable()) { + throw new ServiceNotFoundException(sprintf( + 'Unable to create service "%s"; unable to resolve parameter "%s" ' + . 'to a class, interface, or array type', + $className, + $parameter->getName() + )); + } + + return new FallbackConstructorParameter($parameter->getDefaultValue()); + } + + $type = $aliases[$type] ?? $type; + + if ($container->has($type)) { + assert($type !== ''); + return new ServiceFromContainerConstructorParameter($type); + } + + if (! $parameter->isOptional()) { + throw new ServiceNotFoundException(sprintf( + 'Unable to create service "%s"; unable to resolve parameter "%s" using type hint "%s"', + $className, + $parameter->getName(), + $type + )); + } + + // Type not available in container, but the value is optional and has a + // default defined. + return new FallbackConstructorParameter($parameter->getDefaultValue()); + } + + /** {@inheritDoc} */ + public function resolveConstructorParameterServiceNamesOrFallbackTypes( + string $className, + ContainerInterface $container, + array $aliases = [], + ): array { + $reflectionClass = new ReflectionClass($className); + + $constructor = $reflectionClass->getConstructor(); + if (null === $constructor) { + return []; + } + + $reflectionParameters = $constructor->getParameters(); + + if ($reflectionParameters === []) { + return []; + } + + $resolver = $container->has('config') + ? $this->resolveParameterWithConfigService($container, $className, $aliases) + : $this->resolveParameterWithoutConfigService($container, $className, $aliases); + + return array_map($resolver, $reflectionParameters); + } +} diff --git a/src/Tool/ConstructorParameterResolver/ConstructorParameterResolverInterface.php b/src/Tool/ConstructorParameterResolver/ConstructorParameterResolverInterface.php new file mode 100644 index 00000000..45be5474 --- /dev/null +++ b/src/Tool/ConstructorParameterResolver/ConstructorParameterResolverInterface.php @@ -0,0 +1,37 @@ + $aliases + * @return list + */ + public function resolveConstructorParameters( + string $className, + ContainerInterface $container, + array $aliases = [], + ): array; + + /** + * Returns service names and/or native fallback types which can be either used to retrieve services from container + * or to be passed to the constructor directly. + * + * @param class-string $className + * @param array $aliases + * @return list + */ + public function resolveConstructorParameterServiceNamesOrFallbackTypes( + string $className, + ContainerInterface $container, + array $aliases = [], + ): array; +} diff --git a/src/Tool/ConstructorParameterResolver/FallbackConstructorParameter.php b/src/Tool/ConstructorParameterResolver/FallbackConstructorParameter.php new file mode 100644 index 00000000..c36cc438 --- /dev/null +++ b/src/Tool/ConstructorParameterResolver/FallbackConstructorParameter.php @@ -0,0 +1,13 @@ +container = $container ?? new ServiceManager(); + $this->constructorParameterResolver = $constructorParameterResolver ?? new ConstructorParameterResolver(); + } + + public function createFactory(string $className, array $aliases = []): string { - $class = $this->getClassName($className); + $class = $this->getClassName($className); + $namespace = $this->getNamespace($className, $class); return sprintf( self::FACTORY_TEMPLATE, - preg_replace('/\\\\' . $class . '$/', '', $className), - $this->createImportStatements($className), + $namespace, + $this->createImportStatements(), $class, $class, $class, - $this->createArgumentString($className) + $this->createArgumentString($className, $aliases) ); } @@ -81,7 +97,7 @@ public function createFactory(string $className): string */ private function getClassName(string $className): string { - $lastNamespaceSeparator = strrpos($className, '\\'); + $lastNamespaceSeparator = strrpos($className, self::NAMESPACE_SEPARATOR); if ($lastNamespaceSeparator === false) { return $className; } @@ -94,67 +110,44 @@ private function getClassName(string $className): string /** * @param class-string $className + * @param array $aliases * @return array */ - private function getConstructorParameters(string $className): array + private function getConstructorParameters(string $className, array $aliases): array { - $reflectionClass = new ReflectionClass($className); - $constructor = $reflectionClass->getConstructor(); - - if ($constructor === null) { - return []; - } - - $constructorParameters = $constructor->getParameters(); + $dependencies = $this->constructorParameterResolver->resolveConstructorParameterServiceNamesOrFallbackTypes( + $className, + $this->container, + $aliases, + ); - if ($constructorParameters === []) { - return []; - } + $stringifiedConstructorArguments = []; - $constructorParameters = array_filter( - $constructorParameters, - static function (ReflectionParameter $argument): bool { - if ($argument->isOptional()) { - return false; - } - - $type = $argument->getType(); - $class = $type instanceof ReflectionNamedType && ! $type->isBuiltin() ? $type->getName() : null; - - if (null === $class) { - throw new InvalidArgumentException(sprintf( - 'Cannot identify type for constructor argument "%s"; ' - . 'no type hint, or non-class/interface type hint', - $argument->getName() - )); - } - - return true; + foreach ($dependencies as $dependency) { + if ($dependency instanceof ServiceFromContainerConstructorParameter) { + $stringifiedConstructorArguments[] = sprintf( + '$container->get(%s)', + $this->export($dependency->serviceName) + ); + continue; } - ); - if ($constructorParameters === []) { - return []; + $stringifiedConstructorArguments[] = $this->export($dependency->argumentValue); } - return array_map(static function (ReflectionParameter $parameter): string { - $type = $parameter->getType(); - // We can safely assert here as the filter above already triggers InvalidArgumentException - assert($type instanceof ReflectionNamedType && ! $type->isBuiltin()); - - return $type->getName(); - }, $constructorParameters); + return $stringifiedConstructorArguments; } /** * @param class-string $className + * @param array $aliases */ - private function createArgumentString(string $className): string + private function createArgumentString(string $className, array $aliases): string { $arguments = array_map( static fn(string $dependency): string - => sprintf('$container->get(\\%s::class)', $dependency), - $this->getConstructorParameters($className) + => sprintf('%s', $dependency), + $this->getConstructorParameters($className, $aliases) ); switch (count($arguments)) { @@ -174,10 +167,40 @@ private function createArgumentString(string $className): string } } - private function createImportStatements(string $className): string + private function createImportStatements(): string { - $imports = array_merge(self::IMPORT_ALWAYS, [$className]); + $imports = self::IMPORT_ALWAYS; sort($imports); return implode("\n", array_map(static fn(string $import): string => sprintf('use %s;', $import), $imports)); } + + private function export(mixed $value): string + { + if (is_string($value) && class_exists($value)) { + return sprintf('\\%s::class', $value); + } + + return VarExporter::export( + $value, + VarExporter::NO_CLOSURES | VarExporter::NO_SERIALIZE | VarExporter::NO_SERIALIZE | VarExporter::NO_SET_STATE + ); + } + + /** + * @param class-string $className + * @param non-empty-string $class + */ + private function getNamespace(string $className, string $class): string + { + if (! str_contains($className, self::NAMESPACE_SEPARATOR)) { + return ''; + } + + return sprintf( + '%snamespace %s;%s', + PHP_EOL, + preg_replace('/\\\\' . $class . '$/', '', $className), + PHP_EOL + ); + } } diff --git a/src/Tool/FactoryCreatorFactory.php b/src/Tool/FactoryCreatorFactory.php new file mode 100644 index 00000000..cb487ca3 --- /dev/null +++ b/src/Tool/FactoryCreatorFactory.php @@ -0,0 +1,19 @@ +get(ConstructorParameterResolverInterface::class)); + } +} diff --git a/src/Tool/FactoryCreatorInterface.php b/src/Tool/FactoryCreatorInterface.php index a1b2424d..433b9755 100644 --- a/src/Tool/FactoryCreatorInterface.php +++ b/src/Tool/FactoryCreatorInterface.php @@ -8,7 +8,8 @@ interface FactoryCreatorInterface { /** * @param class-string $className + * @param array $aliases * @return non-empty-string */ - public function createFactory(string $className): string; + public function createFactory(string $className, array $aliases = []): string; } diff --git a/test/AbstractFactory/ReflectionBasedAbstractFactoryTest.php b/test/AbstractFactory/ReflectionBasedAbstractFactoryTest.php index a775e8a2..99ff2e09 100644 --- a/test/AbstractFactory/ReflectionBasedAbstractFactoryTest.php +++ b/test/AbstractFactory/ReflectionBasedAbstractFactoryTest.php @@ -4,14 +4,15 @@ namespace LaminasTest\ServiceManager\AbstractFactory; -use ArrayAccess; use Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory; -use Laminas\ServiceManager\Exception\ServiceNotFoundException; +use Laminas\ServiceManager\Exception\ExceptionInterface; +use Laminas\ServiceManager\Exception\InvalidArgumentException; +use Laminas\ServiceManager\Tool\ConstructorParameterResolver\ConstructorParameterResolverInterface; +use LaminasTest\ServiceManager\AbstractFactory\TestAsset\ClassWithConstructorAcceptingAnyArgument; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; - -use function sprintf; +use stdClass; /** * @covers \Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory @@ -23,40 +24,41 @@ final class ReflectionBasedAbstractFactoryTest extends TestCase private ReflectionBasedAbstractFactory $factory; + /** @var ConstructorParameterResolverInterface&MockObject */ + private ConstructorParameterResolverInterface $constructorParameterResolver; + protected function setUp(): void { parent::setUp(); - $this->container = $this->createMock(ContainerInterface::class); - $this->factory = new ReflectionBasedAbstractFactory(); + $this->container = $this->createMock(ContainerInterface::class); + $this->constructorParameterResolver = $this->createMock(ConstructorParameterResolverInterface::class); + $this->factory = new ReflectionBasedAbstractFactory( + [], + $this->constructorParameterResolver + ); } - public function nonClassRequestedNames(): array + /** + * @return array + */ + public function invalidRequestNames(): array { return [ - 'non-class-string' => ['non-class-string'], + 'empty-string' => [''], + 'non-existing-class' => ['non-class-string'], + 'class-with-private-constructor' => [TestAsset\ClassWithPrivateConstructor::class], ]; } /** - * @dataProvider nonClassRequestedNames + * @dataProvider invalidRequestNames */ - public function testCanCreateReturnsFalseForNonClassRequestedNames(string $requestedName): void + public function testCanCreateReturnsFalseForUnsupportedRequestNames(string $requestedName): void { self::assertFalse($this->factory->canCreate($this->container, $requestedName)); } - public function testCanCreateReturnsFalseWhenConstructorIsPrivate(): void - { - self::assertFalse( - $this->factory->canCreate( - $this->container, - TestAsset\ClassWithPrivateConstructor::class - ), - 'ReflectionBasedAbstractFactory should not be able to instantiate a class with a private constructor' - ); - } - public function testCanCreateReturnsTrueWhenClassHasNoConstructor(): void { self::assertTrue( @@ -68,207 +70,79 @@ public function testCanCreateReturnsTrueWhenClassHasNoConstructor(): void ); } - public function testFactoryInstantiatesClassDirectlyIfItHasNoConstructor(): void - { - $instance = $this->factory->__invoke($this->container, TestAsset\ClassWithNoConstructor::class); - - self::assertInstanceOf(TestAsset\ClassWithNoConstructor::class, $instance); - } - - public function testFactoryInstantiatesClassDirectlyIfConstructorHasNoArguments(): void - { - $instance = $this->factory->__invoke($this->container, TestAsset\ClassWithEmptyConstructor::class); - - self::assertInstanceOf(TestAsset\ClassWithEmptyConstructor::class, $instance); - } - - public function testFactoryRaisesExceptionWhenUnableToResolveATypeHintedService(): void + /** + * @return array + */ + public function classNamesWithoutConstructorArguments(): array { - $this->container - ->expects(self::exactly(2)) - ->method('has') - ->withConsecutive( - ['config'], - [TestAsset\SampleInterface::class], - ) - ->willReturn(false, false); - - $this->expectException(ServiceNotFoundException::class); - $this->expectExceptionMessage(sprintf( - 'Unable to create service "%s"; unable to resolve parameter "sample" using type hint "%s"', - TestAsset\ClassWithTypeHintedConstructorParameter::class, - TestAsset\SampleInterface::class - )); - - $this->factory->__invoke($this->container, TestAsset\ClassWithTypeHintedConstructorParameter::class); + return [ + 'no-constructor' => [ + TestAsset\ClassWithNoConstructor::class, + ], + 'no-constructor-arguments' => [ + TestAsset\ClassWithEmptyConstructor::class, + ], + ]; } - public function testFactoryRaisesExceptionForScalarParameters(): void + /** + * @param class-string $className + * @dataProvider classNamesWithoutConstructorArguments + */ + public function testFactoryInstantiatesClassWithoutConstructorArguments(string $className): void { - $this->expectException(ServiceNotFoundException::class); - $this->expectExceptionMessage(sprintf( - 'Unable to create service "%s"; unable to resolve parameter "foo" to a class, interface, or array type', - TestAsset\ClassWithScalarParameters::class - )); + $instance = $this->factory->__invoke($this->container, $className); - $this->factory->__invoke($this->container, TestAsset\ClassWithScalarParameters::class); + self::assertInstanceOf($className, $instance); } - public function testFactoryInjectsConfigServiceForConfigArgumentsTypeHintedAsArray(): void + public function testWillThrowInvalidArgumentExceptionForInExistentClassName(): void { - $config = ['foo' => 'bar']; - - $this->container - ->expects(self::once()) - ->method('has') - ->with('config') - ->willReturn(true); - - $this->container - ->expects(self::once()) - ->method('get') - ->with('config') - ->willReturn($config); - - $instance = $this->factory->__invoke($this->container, TestAsset\ClassAcceptingConfigToConstructor::class); - - self::assertInstanceOf(TestAsset\ClassAcceptingConfigToConstructor::class, $instance); - self::assertSame($config, $instance->config); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('can only be used with class names.'); + $this->factory->__invoke($this->container, 'serviceName'); } - public function testFactoryCanInjectKnownTypeHintedServices(): void + public function testFactoryPassesContainerExceptions(): void { - $this->container - ->expects(self::exactly(2)) - ->method('has') - ->withConsecutive( - ['config'], - [TestAsset\SampleInterface::class], - ) - ->willReturn(false, true); + $this->expectException(ExceptionInterface::class); + $this->constructorParameterResolver + ->method('resolveConstructorParameters') + ->with(stdClass::class) + ->willThrowException($this->createMock(ExceptionInterface::class)); - $sample = $this->createMock(TestAsset\SampleInterface::class); - - $this->container - ->expects(self::once()) - ->method('get') - ->with(TestAsset\SampleInterface::class) - ->willReturn($sample); - - $instance = $this->factory->__invoke( - $this->container, - TestAsset\ClassWithTypeHintedConstructorParameter::class, - ); - - self::assertInstanceOf(TestAsset\ClassWithTypeHintedConstructorParameter::class, $instance); - self::assertSame($sample, $instance->sample); + $this->factory->__invoke($this->container, stdClass::class); } - public function testFactoryResolvesTypeHintsForServicesToWellKnownServiceNames(): void + public function testFactoryPassesAliasesToArgumentResolver(): void { - $this->container - ->expects(self::exactly(2)) - ->method('has') - ->withConsecutive( - ['config'], - ['ValidatorManager'], - ) - ->willReturn(false, true); - - $validators = $this->createMock(TestAsset\ValidatorPluginManager::class); + $factory = new ReflectionBasedAbstractFactory([ + 'Foo' => 'Bar', + ], $this->constructorParameterResolver); - $this->container + $this->constructorParameterResolver ->expects(self::once()) - ->method('get') - ->with('ValidatorManager') - ->willReturn($validators); - - $factory = new ReflectionBasedAbstractFactory([TestAsset\ValidatorPluginManager::class => 'ValidatorManager']); - $instance = $factory( - $this->container, - TestAsset\ClassAcceptingWellKnownServicesAsConstructorParameters::class - ); + ->method('resolveConstructorParameters') + ->with(stdClass::class, $this->container, ['Foo' => 'Bar']); - self::assertInstanceOf( - TestAsset\ClassAcceptingWellKnownServicesAsConstructorParameters::class, - $instance - ); - self::assertSame($validators, $instance->validators); + $factory->__invoke($this->container, stdClass::class); } - public function testFactoryCanSupplyAMixOfParameterTypes(): void + public function testPassesConstructorArgumentsInTheSameOrderAsReturnedFromResolver(): void { - $this->container - ->expects(self::exactly(3)) - ->method('has') - ->withConsecutive( - ['config'], - [TestAsset\SampleInterface::class], - ['ValidatorManager'], - ) - ->willReturn(true, true, true); - - $config = ['foo' => 'bar']; - $sample = $this->createMock(TestAsset\SampleInterface::class); - $validators = $this->createMock(TestAsset\ValidatorPluginManager::class); - - $this->container - ->expects(self::exactly(3)) - ->method('get') - ->withConsecutive( - ['config'], - [TestAsset\SampleInterface::class], - ['ValidatorManager'], - ) - ->willReturn($config, $sample, $validators); + $resolvedParameters = ['foo', true, 1, 0.0, static fn (): bool => true]; - $factory = new ReflectionBasedAbstractFactory([TestAsset\ValidatorPluginManager::class => 'ValidatorManager']); - $instance = $factory->__invoke($this->container, TestAsset\ClassWithMixedConstructorParameters::class); - - self::assertInstanceOf(TestAsset\ClassWithMixedConstructorParameters::class, $instance); - self::assertSame($config, $instance->config); - self::assertSame([], $instance->options); - self::assertSame($sample, $instance->sample); - self::assertSame($validators, $instance->validators); - } - - public function testFactoryWillUseDefaultValueWhenPresentForScalarArgument(): void - { - $this->container + $this->constructorParameterResolver ->expects(self::once()) - ->method('has') - ->with('config') - ->willReturn(false); - - $instance = $this->factory->__invoke( - $this->container, - TestAsset\ClassWithScalarDependencyDefiningDefaultValue::class - ); - - self::assertInstanceOf(TestAsset\ClassWithScalarDependencyDefiningDefaultValue::class, $instance); - self::assertSame('bar', $instance->foo); - } - - /** - * @see https://github.com/zendframework/zend-servicemanager/issues/239 - */ - public function testFactoryWillUseDefaultValueForTypeHintedArgument(): void - { - $this->container - ->expects(self::exactly(2)) - ->method('has') - ->withConsecutive( - ['config'], - [ArrayAccess::class], - ) - ->willReturn(false, false); - - $instance = $this->factory->__invoke( - $this->container, - TestAsset\ClassWithTypehintedDefaultValue::class - ); - - self::assertInstanceOf(TestAsset\ClassWithTypehintedDefaultValue::class, $instance); - self::assertNull($instance->value); + ->method('resolveConstructorParameters') + ->willReturn($resolvedParameters); + + $factory = new ReflectionBasedAbstractFactory([], $this->constructorParameterResolver); + $instance = $factory->__invoke($this->container, ClassWithConstructorAcceptingAnyArgument::class); + self::assertInstanceOf(ClassWithConstructorAcceptingAnyArgument::class, $instance); + foreach ($resolvedParameters as $index => $parameter) { + self::assertArrayHasKey($index, $instance->arguments); + self::assertSame($parameter, $instance->arguments[$index]); + } } } diff --git a/test/AbstractFactory/TestAsset/ClassWithConstructorAcceptingAnyArgument.php b/test/AbstractFactory/TestAsset/ClassWithConstructorAcceptingAnyArgument.php new file mode 100644 index 00000000..a21e6dce --- /dev/null +++ b/test/AbstractFactory/TestAsset/ClassWithConstructorAcceptingAnyArgument.php @@ -0,0 +1,16 @@ +arguments = $arguments; + } +} diff --git a/test/AbstractFactory/TestAsset/ClassWithTypehintedDefaultValue.php b/test/AbstractFactory/TestAsset/ClassWithTypehintedDefaultNullValue.php similarity index 84% rename from test/AbstractFactory/TestAsset/ClassWithTypehintedDefaultValue.php rename to test/AbstractFactory/TestAsset/ClassWithTypehintedDefaultNullValue.php index 8ce71187..e6bf55fd 100644 --- a/test/AbstractFactory/TestAsset/ClassWithTypehintedDefaultValue.php +++ b/test/AbstractFactory/TestAsset/ClassWithTypehintedDefaultNullValue.php @@ -6,7 +6,7 @@ use ArrayAccess; -final class ClassWithTypehintedDefaultValue +final class ClassWithTypehintedDefaultNullValue { public ?ArrayAccess $value; diff --git a/test/Command/AheadOfTimeFactoryCreatorCommandTest.php b/test/Command/AheadOfTimeFactoryCreatorCommandTest.php new file mode 100644 index 00000000..b5ce594c --- /dev/null +++ b/test/Command/AheadOfTimeFactoryCreatorCommandTest.php @@ -0,0 +1,296 @@ +input = $this->createMock(InputInterface::class); + $this->output = $this->createMock(OutputInterface::class); + $this->factoryTargetPath = vfsStream::setup('root', 0644); + $this->factoryCompiler = $this->createMock(AheadOfTimeFactoryCompilerInterface::class); + } + + /** + * @return array + */ + public function invalidFactoryTargetPaths(): array + { + $readOnlyDirectory = vfsStream::setup('read-only', 0544, ['bar' => []]); + return [ + 'no target path' => [''], + 'read-only directory' => [$readOnlyDirectory->getChild('bar')->url()], + 'nonexistent-directory' => ['/foo/bar/baz'], + ]; + } + + /** + * @dataProvider invalidFactoryTargetPaths + */ + public function testEmitsErrorMessageIfFactoryTargetPathDoesNotMatchRequirements(string $factoryTargetPath): void + { + $command = new AheadOfTimeFactoryCreatorCommand([], $factoryTargetPath, $this->factoryCompiler); + + $this->factoryCompiler + ->expects(self::never()) + ->method(self::anything()); + + $this->assertErrorRaised(sprintf( + 'Please configure the `%s` configuration key in your projects config and ensure that the' + . ' directory is registered to the composer autoloader using `classmap` and writable by the executing' + . ' user. In case you are targeting a nonexistent directory, please create the appropriate directory' + . ' structure before executing this command.', + ConfigProvider::CONFIGURATION_KEY_FACTORY_TARGET_PATH + )); + self::assertSame(1, $command->run($this->input, $this->output)); + } + + public function assertErrorRaised(string $message): void + { + $this->output + ->expects(self::once()) + ->method('writeln') + ->with(self::stringContains(sprintf('%s', $message))); + } + + public function testWillNotCreateConfigurationFileWhenNoFactoriesDetected(): void + { + $directory = $this->factoryTargetPath->url(); + + $command = new AheadOfTimeFactoryCreatorCommand( + [], + $directory, + $this->factoryCompiler, + ); + + $this->input + ->method('getArgument') + ->with('localConfigFilename') + ->willReturn(sprintf('%s/generated-factories.local.php', $directory)); + + $this->output + ->expects(self::once()) + ->method('writeln') + ->with( + 'There is no (more) service registered to use the `ReflectionBasedAbstractFactory`.' + ); + + $this->factoryCompiler + ->expects(self::once()) + ->method('compile') + ->willReturn([]); + + self::assertSame(0, $command->run($this->input, $this->output)); + + self::assertCount(0, $this->factoryTargetPath->getChildren()); + } + + /** + * @requires testWillVerifyLocalConfigFilenameIsWritable + */ + public function testWillCreateExpectedGeneratedFactoriesConfig(): void + { + $directory = $this->factoryTargetPath->url(); + + $command = new AheadOfTimeFactoryCreatorCommand( + [], + $directory, + $this->factoryCompiler, + ); + + $localConfigFilename = 'yada-yada.local.php'; + + $this->input + ->method('getArgument') + ->with('localConfigFilename') + ->willReturn(sprintf('%s/%s', $directory, $localConfigFilename)); + + $generatedFactory = file_get_contents(__DIR__ . '/../TestAsset/factories/SimpleDependencyObject.php'); + assert($generatedFactory !== ''); + + $this->factoryCompiler + ->expects(self::once()) + ->method('compile') + ->willReturn([ + new AheadOfTimeCompiledFactory( + SimpleDependencyObject::class, + 'foobar', + $generatedFactory, + ), + ]); + + $this->output + ->expects(self::once()) + ->method('writeln') + ->with('Successfully created 1 factories.'); + + self::assertSame(0, $command->run($this->input, $this->output)); + + self::assertCount(2, $this->factoryTargetPath->getChildren()); + self::assertTrue($this->factoryTargetPath->hasChild('foobar')); + $foobarDirectory = $this->factoryTargetPath->getChild('foobar'); + self::assertInstanceOf(vfsStreamDirectory::class, $foobarDirectory); + self::assertTrue($foobarDirectory->hasChild( + 'LaminasTest_ServiceManager_TestAsset_SimpleDependencyObjectFactory.php' + )); + $generatedFactoryFile = $foobarDirectory->getChild( + 'LaminasTest_ServiceManager_TestAsset_SimpleDependencyObjectFactory.php' + ); + self::assertInstanceOf(vfsStreamFile::class, $generatedFactoryFile); + self::assertSame($generatedFactory, $generatedFactoryFile->getContent()); + self::assertTrue($this->factoryTargetPath->hasChild('yada-yada.local.php')); + $localConfigFile = $this->factoryTargetPath->getChild('yada-yada.local.php'); + self::assertInstanceOf(vfsStreamFile::class, $localConfigFile); + /** @psalm-suppress UnresolvableInclude Psalm is unable to determine i/o when using vfs stream wrapper */ + $localConfiguration = require $localConfigFile->url(); + self::assertIsArray($localConfiguration, 'Expected generated local config file to return an array.'); + self::assertArrayHasKey( + 'foobar', + $localConfiguration, + 'Expected local configuration containing an array key `foobar`' + ); + $localFoobarServiceManagerConfiguration = $localConfiguration['foobar']; + self::assertIsArray( + $localFoobarServiceManagerConfiguration, + 'Expected local configuration `foobar` key provides an array structure' + ); + self::assertArrayHasKey( + 'factories', + $localFoobarServiceManagerConfiguration, + 'Expected local configuration `foobar` key provides an array structure with a `factories` key.' + ); + $localFoobarServiceManagerFactories = $localFoobarServiceManagerConfiguration['factories']; + self::assertIsArray( + $localFoobarServiceManagerFactories, + 'Expected local configuration `foobar` key provides a factory map.' + ); + self::assertArrayHasKey( + SimpleDependencyObject::class, + $localFoobarServiceManagerFactories, + sprintf( + 'Expected local configuration `foobar` factory map provides a factory for "%s".', + SimpleDependencyObject::class, + ), + ); + + self::assertSame( + sprintf('%sFactory', SimpleDependencyObject::class), + $localFoobarServiceManagerFactories[SimpleDependencyObject::class], + ); + } + + public function testWillVerifyLocalConfigFilenameIsWritable(): void + { + $localConfigFilename = sprintf('foo/bar/baz/qoo/ooq/%s', 'yada-yada.local.php'); + + $directory = $this->factoryTargetPath->url(); + + $command = new AheadOfTimeFactoryCreatorCommand( + [], + $directory, + $this->factoryCompiler, + ); + + $localConfigPath = sprintf('%s/%s', $directory, $localConfigFilename); + + $this->input + ->method('getArgument') + ->with('localConfigFilename') + ->willReturn($localConfigPath); + + $this->factoryCompiler + ->expects(self::never()) + ->method(self::anything()); + + $this->assertErrorRaised(sprintf( + 'Provided `localConfigFilename` argument "%s" is not writable. In case you are targeting a' + . ' nonexistent directory, please create the appropriate directory structure before executing this' + . ' command.', + $localConfigPath, + )); + + self::assertSame(1, $command->run($this->input, $this->output)); + } + + /** + * @requires testWillVerifyLocalConfigFilenameIsWritable + */ + public function testWillDetectAlreadyExistingFactories(): void + { + $directory = $this->factoryTargetPath->url(); + + $command = new AheadOfTimeFactoryCreatorCommand( + [], + $directory, + $this->factoryCompiler, + ); + + $localConfigFilename = 'yada-yada.local.php'; + + $this->input + ->method('getArgument') + ->with('localConfigFilename') + ->willReturn(sprintf('%s/%s', $directory, $localConfigFilename)); + + $generatedFactoryAssetPath = __DIR__ . '/../TestAsset/factories/SimpleDependencyObject.php'; + $generatedFactory = file_get_contents($generatedFactoryAssetPath); + assert($generatedFactory !== ''); + + $this->factoryCompiler + ->expects(self::once()) + ->method('compile') + ->willReturn([ + new AheadOfTimeCompiledFactory( + SimpleDependencyObject::class, + 'foobar', + $generatedFactory, + ), + ]); + + $this->assertErrorRaised( + 'There is already an existing factory class registered for' + . ' "LaminasTest\\ServiceManager\\TestAsset\\SimpleDependencyObject":' + . ' LaminasTest\\ServiceManager\\TestAsset\\SimpleDependencyObjectFactory' + ); + + require $generatedFactoryAssetPath; + + self::assertSame(1, $command->run($this->input, $this->output)); + } +} diff --git a/test/TestAsset/ClassWithConstructorWithOnlyOptionalArguments.php b/test/TestAsset/ClassWithConstructorWithOnlyOptionalArguments.php new file mode 100644 index 00000000..ae056d17 --- /dev/null +++ b/test/TestAsset/ClassWithConstructorWithOnlyOptionalArguments.php @@ -0,0 +1,18 @@ +factoryCreator = $this->createMock(FactoryCreatorInterface::class); + $this->compiler = new AheadOfTimeFactoryCompiler( + $this->factoryCreator, + ); + } + + /** + * @return array + */ + public function configurationsWithoutRegisteredServices(): array + { + return [ + 'empty config' => [ + [], + ], + 'config with integer keys' => [ + [1, 2, 3], + ], + 'config with container config without having registered services' => [ + ['service_manager' => ['factories' => []]], + ], + 'config with non-array config parameters' => [ + ['foo' => 'bar'], + ], + ]; + } + + /** + * @dataProvider configurationsWithoutRegisteredServices + */ + public function testCanHandleConfigWithoutServicesRegisteredWithReflectionBasedAbstractFactory(array $config): void + { + $this->factoryCreator + ->expects(self::never()) + ->method(self::anything()); + + self::assertSame([], $this->compiler->compile($config)); + } + + public function testCanHandleLaminasMvcServiceManagerConfiguration(): void + { + $config = [ + 'service_manager' => [ + 'factories' => [ + stdClass::class => ReflectionBasedAbstractFactory::class, + ], + ], + ]; + + $this->factoryCreator + ->expects(self::once()) + ->method('createFactory') + ->with(stdClass::class) + ->willReturn('created factory'); + + $factories = $this->compiler->compile($config); + self::assertCount(1, $factories); + $factory = $factories[0]; + self::assertSame('service_manager', $factory->containerConfigurationKey); + self::assertSame(stdClass::class, $factory->fullyQualifiedClassName); + self::assertSame('created factory', $factory->generatedFactory); + } + + /** + * @return array + */ + public function nonClassReferencingServiceNames(): array + { + return [ + 'nonexistent-service-name' => [ + 'foobar', + ], + 'interface' => [ + FactoryInterface::class, + ], + 'trait' => [ + WhateverTrait::class, + ], + ]; + } + + /** + * @return array + */ + public function nonClassReferencingServiceNamesPhp81Upwards(): array + { + if (PHP_VERSION_ID < 80100) { + return []; + } + + return [ + 'enum' => [ + WhateverEnum::class, + ], + ]; + } + + /** + * @dataProvider nonClassReferencingServiceNames + * @dataProvider nonClassReferencingServiceNamesPhp81Upwards + */ + public function testWillRaiseExceptionWhenFactoryIsUsedWithNonClassReferencingService(string $serviceName): void + { + $config = [ + 'dependencies' => [ + 'factories' => [ + $serviceName => ReflectionBasedAbstractFactory::class, + ], + ], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('does not exist or does not refer to an actual class'); + + $this->factoryCreator + ->expects(self::never()) + ->method(self::anything()); + + $this->compiler->compile($config); + } + + public function testWillDetectSameServiceProvidedByMultipleServiceOrPluginManagers(): void + { + $config = [ + 'foo' => [ + 'factories' => [ + stdClass::class => ReflectionBasedAbstractFactory::class, + ], + ], + 'bar' => [ + 'factories' => [ + stdClass::class => ReflectionBasedAbstractFactory::class, + ], + ], + ]; + + $this->factoryCreator + ->expects(self::never()) + ->method(self::anything()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is registered in (at least) two service-/plugin-managers: foo, bar'); + + $this->compiler->compile($config); + } + + public function testWillProvideFactoriesForDifferentContainerConfigurations(): void + { + $config = [ + 'foo' => [ + 'factories' => [ + ComplexDependencyObject::class => ReflectionBasedAbstractFactory::class, + ], + ], + 'bar' => [ + 'factories' => [ + SimpleDependencyObject::class => ReflectionBasedAbstractFactory::class, + ], + ], + ]; + + $this->factoryCreator + ->expects(self::exactly(2)) + ->method('createFactory') + ->willReturnMap([ + [ComplexDependencyObject::class, [], 'factory for complex dependency object'], + [SimpleDependencyObject::class, [], 'factory for simple dependency object'], + ]); + + $factories = $this->compiler->compile($config); + self::assertCount(2, $factories); + } + + public function testWillDetectReflectionBasedFactoryInstancesWithClassString(): void + { + $config = [ + 'foo' => [ + 'factories' => [ + ComplexDependencyObject::class => ReflectionBasedAbstractFactory::class, + ], + ], + 'bar' => [ + 'factories' => [ + SimpleDependencyObject::class => new ReflectionBasedAbstractFactory(), + ], + ], + ]; + + $this->factoryCreator + ->expects(self::exactly(2)) + ->method('createFactory') + ->willReturnMap([ + [ComplexDependencyObject::class, [], 'factory for complex dependency object'], + [SimpleDependencyObject::class, [], 'factory for simple dependency object'], + ]); + + $factories = $this->compiler->compile($config); + self::assertCount(2, $factories); + } + + public function testPassesAliasesToFactoryCreator(): void + { + $config = [ + 'dependencies' => [ + 'factories' => [ + stdClass::class => new ReflectionBasedAbstractFactory([ + 'foo' => 'bar', + ]), + ], + ], + ]; + + $this->factoryCreator + ->expects(self::once()) + ->method('createFactory') + ->with(stdClass::class, ['foo' => 'bar']) + ->willReturn('generated factory'); + + $factories = $this->compiler->compile($config); + self::assertCount(1, $factories); + self::assertSame('generated factory', $factories[0]->generatedFactory); + } +} diff --git a/test/Tool/AheadOfTimeFactoryCompiler/TestAsset/WhateverEnum.php b/test/Tool/AheadOfTimeFactoryCompiler/TestAsset/WhateverEnum.php new file mode 100644 index 00000000..6f0bffa3 --- /dev/null +++ b/test/Tool/AheadOfTimeFactoryCompiler/TestAsset/WhateverEnum.php @@ -0,0 +1,10 @@ +resolver = new ConstructorParameterResolver(); + $this->container = $this->createMock(ContainerInterface::class); + } + + public function testCanHandleClassNameWithoutConstructor(): void + { + $container = $this->createMock(ContainerInterface::class); + $parameters = $this->resolver->resolveConstructorParameterServiceNamesOrFallbackTypes( + ClassWithNoConstructor::class, + $container + ); + self::assertSame([], $parameters); + } + + public function testCanHandleClassNameWithOptionalConstructorDependencies(): void + { + $container = $this->createMock(ContainerInterface::class); + $parameters = $this->resolver->resolveConstructorParameterServiceNamesOrFallbackTypes( + ClassWithConstructorWithOnlyOptionalArguments::class, + $container + ); + $expectedResolvedParameters = [ + [], + '', + true, + 1, + 0.0, + null, + ]; + + self::assertSameSize($expectedResolvedParameters, $parameters); + foreach ($parameters as $index => $parameter) { + self::assertInstanceOf(FallbackConstructorParameter::class, $parameter); + $expectedParameter = $expectedResolvedParameters[$index] ?? null; + self::assertSame($expectedParameter, $parameter->argumentValue); + } + } + + public function testWillDetectRequiredConstructorArguments(): void + { + $container = $this->createMock(ContainerInterface::class); + $container + ->expects(self::exactly(2)) + ->method('has') + ->willReturnMap([ + ['config', false], + [FactoryInterface::class, true], + ]); + + $parameters = $this->resolver->resolveConstructorParameterServiceNamesOrFallbackTypes( + ClassDependingOnAnInterface::class, + $container + ); + self::assertCount(1, $parameters); + self::assertInstanceOf(ServiceFromContainerConstructorParameter::class, $parameters[0]); + $parameter = $parameters[0]; + self::assertSame(FactoryInterface::class, $parameter->serviceName); + } + + public function testRaisesExceptionWhenUnableToResolveATypeHintedService(): void + { + $this->container + ->expects(self::exactly(2)) + ->method('has') + ->withConsecutive( + ['config'], + [SampleInterface::class], + ) + ->willReturn(false, false); + + $this->expectException(ServiceNotFoundException::class); + $this->expectExceptionMessage(sprintf( + 'Unable to create service "%s"; unable to resolve parameter "sample" using type hint "%s"', + ClassWithTypeHintedConstructorParameter::class, + SampleInterface::class + )); + + $this->resolver->resolveConstructorParameters(ClassWithTypeHintedConstructorParameter::class, $this->container); + } + + public function testRaisesExceptionForScalarParameters(): void + { + $this->expectException(ServiceNotFoundException::class); + $this->expectExceptionMessage(sprintf( + 'Unable to create service "%s"; unable to resolve parameter "foo" to a class, interface, or array type', + ClassWithScalarParameters::class + )); + + $this->resolver->resolveConstructorParameters(ClassWithScalarParameters::class, $this->container); + } + + public function testResolvesConfigServiceForConfigArgumentsTypeHintedAsArray(): void + { + $config = ['foo' => 'bar']; + + $this->container + ->expects(self::once()) + ->method('has') + ->with('config') + ->willReturn(true); + + $this->container + ->expects(self::once()) + ->method('get') + ->with('config') + ->willReturn($config); + + $parameters = $this->resolver->resolveConstructorParameters( + ClassAcceptingConfigToConstructor::class, + $this->container + ); + self::assertCount(1, $parameters); + self::assertSame($config, $parameters[0]); + } + + public function testFactoryCanInjectKnownTypeHintedServices(): void + { + $this->container + ->expects(self::exactly(2)) + ->method('has') + ->willReturnMap([ + ['config', false], + [SampleInterface::class, true], + ]); + + $sample = $this->createMock(SampleInterface::class); + + $this->container + ->expects(self::once()) + ->method('get') + ->with(SampleInterface::class) + ->willReturn($sample); + + $parameters = $this->resolver->resolveConstructorParameters( + ClassWithTypeHintedConstructorParameter::class, + $this->container, + ); + + self::assertCount(1, $parameters); + self::assertSame($sample, $parameters[0]); + } + + public function testResolvesTypeHintsForServicesToWellKnownServiceNames(): void + { + $this->container + ->expects(self::exactly(2)) + ->method('has') + ->willReturnMap([ + ['config', false], + ['ValidatorManager', true], + ]); + + $validators = $this->createMock(ValidatorPluginManager::class); + + $this->container + ->expects(self::once()) + ->method('get') + ->with('ValidatorManager') + ->willReturn($validators); + + $parameters = $this->resolver->resolveConstructorParameters( + ClassAcceptingWellKnownServicesAsConstructorParameters::class, + $this->container, + [ValidatorPluginManager::class => 'ValidatorManager'], + ); + + self::assertCount(1, $parameters); + self::assertSame($validators, $parameters[0]); + } + + /** + * @depends testWillResolveConstructorArgumentsAccordingToTheirPosition + */ + public function testResolvesAMixOfParameterTypes(): void + { + $this->container + ->expects(self::exactly(3)) + ->method('has') + ->willReturnMap([ + ['config', true], + [SampleInterface::class, true], + ['ValidatorManager', true], + ]); + + $config = ['foo' => 'bar']; + $sample = $this->createMock(SampleInterface::class); + $validators = $this->createMock(ValidatorPluginManager::class); + + $this->container + ->expects(self::exactly(3)) + ->method('get') + ->willReturnMap([ + ['config', $config], + [SampleInterface::class, $sample], + ['ValidatorManager', $validators], + ]); + + $parameters = $this->resolver->resolveConstructorParameters( + ClassWithMixedConstructorParameters::class, + $this->container, + [ValidatorPluginManager::class => 'ValidatorManager'] + ); + + self::assertCount(4, $parameters); + self::assertSame($config, $parameters[0]); + self::assertSame($sample, $parameters[1]); + self::assertSame($validators, $parameters[2]); + self::assertNull($parameters[3], 'Optional parameters should resolve to their default value.'); + } + + public function testResolvesDefaultValuesWhenPresentForScalarArgument(): void + { + $parameters = $this->resolver->resolveConstructorParameters( + ClassWithScalarDependencyDefiningDefaultValue::class, + $this->container, + ); + + self::assertCount(1, $parameters); + self::assertSame('bar', $parameters[0]); + } + + /** + * @see https://github.com/zendframework/zend-servicemanager/issues/239 + */ + public function testWillResolveToDefaultValueForTypeHintedArgumentWhichDoesNotExistInContainer(): void + { + $parameters = $this->resolver->resolveConstructorParameters( + ClassWithTypehintedDefaultNullValue::class, + $this->container, + ); + + self::assertCount(1, $parameters); + self::assertNull($parameters[0]); + } + + public function testWillResolveConstructorArgumentsAccordingToTheirPosition(): void + { + $this->container + ->method('has') + ->willReturnMap([ + ['config', true], + [SampleInterface::class, true], + [ValidatorPluginManager::class, true], + ]); + + $sample = $this->createMock(SampleInterface::class); + $validators = $this->createMock(ValidatorPluginManager::class); + + $this->container + ->method('get') + ->willReturnMap([ + ['config', ['foo' => 'bar']], + [SampleInterface::class, $sample], + [ValidatorPluginManager::class, $validators], + ]); + + $parameters = $this->resolver->resolveConstructorParameters( + ClassWithMixedConstructorParameters::class, + $this->container + ); + + self::assertCount(4, $parameters); + self::assertSame(['foo' => 'bar'], $parameters[0]); + self::assertSame($sample, $parameters[1]); + self::assertSame($validators, $parameters[2]); + self::assertNull($parameters[3], 'Optional parameters should resolve to their default value.'); + } +} diff --git a/test/Tool/FactoryCreatorTest.php b/test/Tool/FactoryCreatorTest.php index 4ce46b3d..36293022 100644 --- a/test/Tool/FactoryCreatorTest.php +++ b/test/Tool/FactoryCreatorTest.php @@ -8,12 +8,18 @@ use LaminasTest\ServiceManager\TestAsset\ComplexDependencyObject; use LaminasTest\ServiceManager\TestAsset\DelegatorAndAliasBehaviorTest\TargetObjectDelegator; use LaminasTest\ServiceManager\TestAsset\InvokableObject; +use LaminasTest\ServiceManager\TestAsset\SecondComplexDependencyObject; use LaminasTest\ServiceManager\TestAsset\SimpleDependencyObject; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use stdClass; use function file_get_contents; use function preg_match; +use const PHP_EOL; + /** * @covers \Laminas\ServiceManager\Tool\FactoryCreator */ @@ -21,14 +27,17 @@ final class FactoryCreatorTest extends TestCase { private FactoryCreator $factoryCreator; - /** - * @internal param FactoryCreator $factoryCreator - */ + /** @var MockObject&ContainerInterface */ + private ContainerInterface $container; + protected function setUp(): void { parent::setUp(); - $this->factoryCreator = new FactoryCreator(); + $this->container = $this->createMock(ContainerInterface::class); + $this->factoryCreator = new FactoryCreator( + $this->container, + ); } public function testCreateFactoryCreatesForInvokable(): void @@ -43,6 +52,12 @@ public function testCreateFactoryCreatesForSimpleDependencies(): void { $className = SimpleDependencyObject::class; $factory = file_get_contents(__DIR__ . '/../TestAsset/factories/SimpleDependencyObject.php'); + $this->container + ->expects(self::atLeastOnce()) + ->method('has') + ->willReturnMap([ + [InvokableObject::class, true], + ]); self::assertSame($factory, $this->factoryCreator->createFactory($className)); } @@ -52,6 +67,14 @@ public function testCreateFactoryCreatesForComplexDependencies(): void $className = ComplexDependencyObject::class; $factory = file_get_contents(__DIR__ . '/../TestAsset/factories/ComplexDependencyObject.php'); + $this->container + ->expects(self::atLeastOnce()) + ->method('has') + ->willReturnMap([ + [SimpleDependencyObject::class, true], + [SecondComplexDependencyObject::class, true], + ]); + self::assertSame($factory, $this->factoryCreator->createFactory($className)); } @@ -60,9 +83,25 @@ public function testNamespaceGeneration(): void $testClassNames = [ ComplexDependencyObject::class => 'LaminasTest\\ServiceManager\\TestAsset', TargetObjectDelegator::class => 'LaminasTest\\ServiceManager\\TestAsset\\DelegatorAndAliasBehaviorTest', + stdClass::class => '', ]; + + $this->container + ->expects(self::atLeastOnce()) + ->method('has') + ->willReturnMap([ + [SimpleDependencyObject::class, true], + [SecondComplexDependencyObject::class, true], + ]); + foreach ($testClassNames as $testFqcn => $expectedNamespace) { $generatedFactory = $this->factoryCreator->createFactory($testFqcn); + + if ($expectedNamespace === '') { + self::assertStringNotContainsString(PHP_EOL . 'namespace ', $generatedFactory); + continue; + } + preg_match('/^namespace\s([^;]+)/m', $generatedFactory, $namespaceMatch); self::assertNotEmpty($namespaceMatch);