diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d2e106..c6bc476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to this project will be documented in this file, in reverse ### Added +- [#87](https://github.com/zendframework/zend-hydrator/pull/87) adds `Zend\Hydrator\HydratorPluginManagerInterface` to allow + type-hinting on plugin manager implementations. The interface simply extends + the [PSR-11 ContainerInterface](https://www.php-fig.org/psr/psr-11/). + +- [#87](https://github.com/zendframework/zend-hydrator/pull/87) adds `Zend\Hydrator\StandaloneHydratorPluginManager` as an implementation + of each of `Psr\Container\ContainerInterface` and `Zend\Hydrator\HydratorPluginManagerInterface`, + along with a factory for creating it, `Zend\Hydrator\StandaloneHydratorPluginManagerFactory`. + It can act as a replacement for `Zend\Hydrator\HydratorPluginManager`, but + only supports the shipped hydrator implementations. See the [plugin manager documentation](https://docs.zendframework.com/zend-hydrator/v3/plugin-managers/) + for more details on usage. + - [#79](https://github.com/zendframework/zend-hydrator/pull/79) adds a third, optional parameter to the `DateTimeFormatterStrategy` constructor. The parameter is a boolean, and, when enabled, a string that can be parsed by the `DateTime` constructor will still result in a `DateTime` instance during @@ -28,6 +39,15 @@ All notable changes to this project will be documented in this file, in reverse Aliases resolving the original class name to the new class were also added to the `HydratorPluginManager` to ensure you can still obtain instances. +- [#87](https://github.com/zendframework/zend-hydrator/pull/87) modifies `Zend\Hydrator\ConfigProvider` to add a factory entry for + `Zend\Hydrator\StandaloneHydratorPluginManager`. + +- [#87](https://github.com/zendframework/zend-hydrator/pull/87) modifies `Zend\Hydrator\ConfigProvider` to change the target of the + `HydratorManager` alias based on the presence of the zend-servicemanager + package; if the package is not available, the target points to + `Zend\Hydrator\StandaloneHydratorPluginManager` instead of + `Zend\Hydrator\HydratorPluginManager`. + - [#83](https://github.com/zendframework/zend-hydrator/pull/83) renames `Zend\Hydrator\FilterEnabledInterface` to `Zend\Hydrator\Filter\FilterEnabledInterface` (new namespace). - [#83](https://github.com/zendframework/zend-hydrator/pull/83) renames `Zend\Hydrator\NamingStrategyEnabledInterface` to `Zend\Hydrator\NamingStrategy\NamingStrategyEnabledInterface` (new namespace). diff --git a/docs/book/v3/migration.md b/docs/book/v3/migration.md index 2f86d45..aba3fd5 100644 --- a/docs/book/v3/migration.md +++ b/docs/book/v3/migration.md @@ -222,4 +222,11 @@ is now marked `private`. This version removes support for zend-servicemanager v2 service names. Under zend-servicemanager v2, most special characters were removed, and the name normalized to all lowercase. Now, only fully qualified class names are mapped to -factories, and short names (names omitting the namespace) are mapped as aliases. +factories, and short names (names omitting the namespace and/or "Hydrator" +suffix) are mapped as aliases. + +Additionally, version 3 ships a standalone, PSR-11 compliant version, +`Zend\Hydrator\StandaloneHydratorPluginManager`. By default, the `HydratorManager` +service alias will point to the `StandaloneHydratorPluginManager` if +zend-servicemanager is not installed, and the `HydratorPluginManager` otherwise. +See the [plugin managers chapter](plugin-managers.md) for more details. diff --git a/docs/book/v3/plugin-managers.md b/docs/book/v3/plugin-managers.md new file mode 100644 index 0000000..6c88236 --- /dev/null +++ b/docs/book/v3/plugin-managers.md @@ -0,0 +1,208 @@ +# Plugin Managers + +It can be useful to compose a plugin manager from which you can retrieve +hydrators; in fact, `Zend\Hydrator\DelegatingHydrator` does exactly that! +With such a manager, you can retrieve instances using short names, or instances +that have dependencies on other services, without needing to know the details of +how that works. + +Examples of Hydrator plugin managers in real-world scenarios include: + +- [hydrating database result sets](https://docs.zendframework.com/zend-db/result-set/#zend92db92resultset92hydratingresultset) +- [preparing API payloads](https://docs.zendframework.com/zend-expressive-hal/resource-generator/#resourcegenerator) + +## HydratorPluginManagerInterface + +We provide two plugin manager implementations. Essentially, they only need to +implement the [PSR-11 ContainerInterface](https://www.php-fig.org/psr/psr-11/), +but plugin managers in current versions of [zend-servicemanager](https://docs.zendframework.com/zend-servicemanager/) +only implement it indirectly via the container-interop project. + +As such, we ship `Zend\Hydrator\HydratorPluginManagerInterface`, which simply +extends the PSR-11 `Psr\Container\ContainerInterface`. Each of our +implementations implement it. + +## HydratorPluginManager + +If you have used zend-hydrator prior to version 3, you are likely already +familiar with this class, as it has been the implementation we have shipped from +initial versions. The `HydratorPluginManager` extends the zend-servicemanager +`AbstractPluginManager`, and has the following behaviors: + +- It will only return `Zend\Hydrator\HydratorInterface` instances. +- It defines short-name aliases for all shipped hydrators (the class name minus + the namespace), in a variety of casing combinations. +- All but the `DelegatingHydrator` are defined as invokable services (meaning + they can be instantiated without any constructor arguments). +- The `DelegatingHydrator` is configured as a factory-based service, mapping to + the `Zend\Hydrator\DelegatingHydratorFactory`. +- No services are shared; a new instance is created each time you call `get()`. + +### HydratorPluginManagerFactory + +`Zend\Hydrator\HydratorPluginManager` is mapped to the factory +`Zend\Hydrator\HydratorPluginManagerFactory` when wired to the dependency +injection container. + +The factory will look for the `config` service, and use the `hydrators` +configuration key to seed it with additional services. This configuration key +should map to an array that follows [standard zend-servicemanager configuration](https://docs.zendframework.com/zend-servicemanager/configuring-the-service-manager/). + +## StandaloneHydratorPluginManager + +`Zend\Hydrator\StandaloneHydratorPluginManager` provides an implementation that +has no dependencies on other libraries. **It can only load the hydrators shipped +with zend-hydrator**. + +### StandardHydratorPluginManagerFactory + +`Zend\Hydrator\StandardHydratorPluginManager` is mapped to the factory +`Zend\Hydrator\StandardHydratorPluginManagerFactory` when wired to the dependency +injection container. + +## HydratorManager alias + +`Zend\Hydrator\ConfigManager` defines an alias service, `HydratorManager`. That +service will point to `Zend\Hydrator\HydratorPluginManager` if +zend-servicemanager is installed, and `Zend\Hydrator\StandaloneHydratorPluginManager` +otherwise. + +## Custom plugin managers + +If you do not want to use zend-servicemanager, but want a plugin manager that is +customizable, or at least capable of loading the hydrators you have defined for +your application, you should write a custom implementation of +`Zend\Hydrator\HydratorPluginManagerInterface`, and wire it to the +`HydratorManager` service, and/or one of the existing service names. + +As an example, if you want a configurable solution that uses factories, and want +those factories capable of pulling application-level dependencies, you might do +something like the following: + +```php +// In src/YourApplication/CustomHydratorPluginManager.php: + +namespace YourApplication; + +use Psr\Container\NotFoundExceptionInterface; +use Psr\Container\ContainerInterface; +use RuntimeException; +use Zend\Hydrator\HydratorInterface; +use Zend\Hydrator\HydratorPluginManagerInterface; +use Zend\Hydrator\StandaloneHydratorPluginManager; + +class CustomHydratorPluginManager implements HydratorPluginManagerInterface +{ + /** @var ContainerInterface */ + private $appContainer; + + /** @var StandaloneHydratorPluginManager */ + private $defaults; + + /** @var array */ + private $factories = []; + + public function __construct(ContainerInterface $appContainer) + { + $this->appContainer = $appContainer; + $this->defaults = new StandaloneHydratorPluginManager(); + } + + /** + * {@inheritDoc} + */ + public function get($id) : HydratorInterface + { + if (! isset($this->factories[$id]) && ! $this->defaults->has($id)) { + $message = sprintf('Hydrator service %s not found', $id); + throw new class($message) extends RuntimeException implements NotFoundExceptionInterface {}; + } + + // Default was requested; fallback to standalone container + if (! isset($this->factories[$id])) { + return $this->defaults->get($id); + } + + $factory = $this->factories[$id]; + if (is_string($factory)) { + $this->factories[$id] = $factory = new $factory(); + } + + return $factory($this->appContainer, $id); + } + + public function has($id) : bool + { + return isset($this->factories[$id]) || $this->defaults->has($id); + } + + public function setFactoryClass(string $name, string $factory) : void + { + $this->factories[$name] = $factory; + } + + public function setFactory(string $name, callable $factory) : void + { + $this->factories[$name] = $factory; + } +} +``` + +```php +// In src/YourApplication/CustomHydratorPluginManagerFactory.php: + +namespace YourApplication; + +use Psr\Container\ContainerInterface; + +class CustomHydratorPluginManagerFactory +{ + public function __invoke(ContainerInterface $container) : CustomHydratorPluginManager + { + $config = $container->has('config') ? $container->get('config') : []; + $config = $config['hydrators']['factories'] ?? []; + + $manager = new CustomHydratorPluginManager($this); + + if ([] !== $config) { + $this->configureManager($manager, $config); + } + + return $manager; + } + + /** + * @param array $config + */ + private function configureManager(CustomHydratorPluginManager $manager, array $config) : void + { + foreach ($config as $name => $factory) { + is_string($factory) + ? $manager->setFactoryClass($name, $factory) + : $manager->setFactory($name, $factory); + } + } +} +``` + +```php +// in config/autoload/hydrators.global.php or similar: + +return [ + 'dependencies' => [ + 'aliases' => [ + 'HydratorManager' => \YourApplication\CustomHydratorPluginManager::class, + ], + 'factories' => [ + \YourApplication\CustomHydratorPluginManager::class => \YourApplication\CustomHydratorPluginManagerFactory::class + ], + ], + 'hydrators' => [ + 'factories' => [ + \Blog\PostHydrator::class => \Blog\PostHydratorFactory::class, + \News\ItemHydrator::class => \News\ItemHydratorFactory::class, + // etc. + ], + ], +]; +``` diff --git a/mkdocs.yml b/mkdocs.yml index b494322..b188190 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,6 +13,7 @@ pages: - "Mapping": v3/naming-strategy/map-naming-strategy.md - "Underscore Mapping": v3/naming-strategy/underscore-naming-strategy.md - "Composite": v3/naming-strategy/composite-naming-strategy.md + - "Plugin Managers": v3/plugin-managers.md - Migration: v3/migration.md - v2: - "Quick Start": v2/quick-start.md diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index c626356..eff8167 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -9,6 +9,8 @@ namespace Zend\Hydrator; +use Zend\ServiceManager\ServiceManager; + class ConfigProvider { /** @@ -26,16 +28,25 @@ public function __invoke() : array /** * Return dependency mappings for this component. * + * If zend-servicemanager is installed, this will alias the HydratorPluginManager + * to the `HydratorManager` service; otherwise, it aliases the + * StandaloneHydratorPluginManager. + * * @return string[][] */ public function getDependencyConfig() : array { + $hydratorManagerTarget = class_exists(ServiceManager::class) + ? HydratorPluginManager::class + : StandaloneHydratorPluginManager::class; + return [ 'aliases' => [ - 'HydratorManager' => HydratorPluginManager::class, + 'HydratorManager' => $hydratorManagerTarget, ], 'factories' => [ - HydratorPluginManager::class => HydratorPluginManagerFactory::class, + HydratorPluginManager::class => HydratorPluginManagerFactory::class, + StandaloneHydratorPluginManager::class => StandaloneHydratorPluginManagerFactory::class, ], ]; } diff --git a/src/DelegatingHydratorFactory.php b/src/DelegatingHydratorFactory.php index d81e416..67e9fc1 100644 --- a/src/DelegatingHydratorFactory.php +++ b/src/DelegatingHydratorFactory.php @@ -25,10 +25,10 @@ public function __invoke(ContainerInterface $container) : DelegatingHydrator /** * Locate and return a HydratorPluginManager instance. */ - private function marshalHydratorPluginManager(ContainerInterface $container) : HydratorPluginManager + private function marshalHydratorPluginManager(ContainerInterface $container) : ContainerInterface { // Already one? Return it. - if ($container instanceof HydratorPluginManager) { + if ($container instanceof HydratorPluginManagerInterface) { return $container; } diff --git a/src/Exception/MissingHydratorServiceException.php b/src/Exception/MissingHydratorServiceException.php new file mode 100644 index 0000000..2ac28cd --- /dev/null +++ b/src/Exception/MissingHydratorServiceException.php @@ -0,0 +1,25 @@ + + */ + private $aliases = [ + 'arrayserializable' => ArraySerializableHydrator::class, + ArraySerializable::class => ArraySerializableHydrator::class, + 'arrayserializablehydrator' => ArraySerializableHydrator::class, + ClassMethods::class => ClassMethodsHydrator::class, + 'classmethods' => ClassMethodsHydrator::class, + 'classmethodshydrator' => ClassMethodsHydrator::class, + 'delegatinghydrator' => DelegatingHydrator::class, + ObjectProperty::class => ObjectPropertyHydrator::class, + 'objectpropertyhydrator' => ObjectPropertyHydrator::class, + 'objectproperty' => ObjectPropertyHydrator::class, + Reflection::class => ReflectionHydrator::class, + 'reflectionhydrator' => ReflectionHydrator::class, + 'reflection' => ReflectionHydrator::class, + ]; + + /** + * @var array + */ + private $factories = []; + + public function __construct() + { + $invokableFactory = function (ContainerInterface $container, string $class) { + return new $class(); + }; + + $this->factories = [ + ArraySerializableHydrator::class => $invokableFactory, + ClassMethodsHydrator::class => $invokableFactory, + DelegatingHydrator::class => new DelegatingHydratorFactory(), + ObjectPropertyHydrator::class => $invokableFactory, + ReflectionHydrator::class => $invokableFactory, + ]; + } + + /** + * {@inheritDoc} + */ + public function get($id) + { + $class = $this->resolveName($id); + if (! $class) { + throw Exception\MissingHydratorServiceException::forService($id); + } + + $instance = ($this->factories[$class])($this, $class); + + return $instance; + } + + /** + * {@inheritDoc} + */ + public function has($id) + { + return null !== $this->resolveName($id); + } + + /** + * Resolve a service name from an identifier. + */ + private function resolveName(string $name) : ?string + { + if (isset($this->factories[$name])) { + return $name; + } + + if (isset($this->aliases[$name])) { + return $this->aliases[$name]; + } + + return $this->aliases[strtolower($name)] ?? null; + } +} diff --git a/src/StandaloneHydratorPluginManagerFactory.php b/src/StandaloneHydratorPluginManagerFactory.php new file mode 100644 index 0000000..a1e7f42 --- /dev/null +++ b/src/StandaloneHydratorPluginManagerFactory.php @@ -0,0 +1,20 @@ +factory = new StandaloneHydratorPluginManagerFactory(); + $this->container = $this->prophesize(ContainerInterface::class); + } + + public function assertDefaultServices( + StandaloneHydratorPluginManager $manager, + string $message = self::MESSAGE_DEFAULT_SERVICES + ) { + $this->assertTrue($manager->has('ArraySerializable'), sprintf($message, 'ArraySerializable')); + $this->assertTrue($manager->has('ArraySerializableHydrator'), sprintf($message, 'ArraySerializableHydrator')); + $this->assertTrue($manager->has(ArraySerializable::class), sprintf($message, ArraySerializable::class)); + $this->assertTrue( + $manager->has(ArraySerializableHydrator::class), + sprintf($message, ArraySerializableHydrator::class) + ); + + $this->assertTrue($manager->has('ClassMethods'), sprintf($message, 'ClassMethods')); + $this->assertTrue($manager->has('ClassMethodsHydrator'), sprintf($message, 'ClassMethodsHydrator')); + $this->assertTrue($manager->has(ClassMethods::class), sprintf($message, ClassMethods::class)); + $this->assertTrue($manager->has(ClassMethodsHydrator::class), sprintf($message, ClassMethodsHydrator::class)); + + $this->assertTrue($manager->has('DelegatingHydrator'), sprintf($message, 'DelegatingHydrator')); + $this->assertTrue($manager->has(DelegatingHydrator::class), sprintf($message, DelegatingHydrator::class)); + + $this->assertTrue($manager->has('ObjectProperty'), sprintf($message, 'ObjectProperty')); + $this->assertTrue($manager->has('ObjectPropertyHydrator'), sprintf($message, 'ObjectPropertyHydrator')); + $this->assertTrue($manager->has(ObjectProperty::class), sprintf($message, ObjectProperty::class)); + $this->assertTrue( + $manager->has(ObjectPropertyHydrator::class), + sprintf($message, ObjectPropertyHydrator::class) + ); + + $this->assertTrue($manager->has('Reflection'), sprintf($message, 'Reflection')); + $this->assertTrue($manager->has('ReflectionHydrator'), sprintf($message, 'ReflectionHydrator')); + $this->assertTrue($manager->has(Reflection::class), sprintf($message, Reflection::class)); + $this->assertTrue($manager->has(ReflectionHydrator::class), sprintf($message, ReflectionHydrator::class)); + } + + public function testCreatesPluginManagerWithDefaultServices() + { + $manager = ($this->factory)($this->container->reveal()); + $this->assertDefaultServices($manager); + } +} diff --git a/test/StandaloneHydratorPluginManagerTest.php b/test/StandaloneHydratorPluginManagerTest.php new file mode 100644 index 0000000..52884a4 --- /dev/null +++ b/test/StandaloneHydratorPluginManagerTest.php @@ -0,0 +1,116 @@ +manager = new StandaloneHydratorPluginManager(); + } + + /** + * @return mixed + */ + public function reflectProperty(object $class, string $property) + { + $r = new ReflectionProperty($class, $property); + $r->setAccessible(true); + return $r->getValue($class); + } + + public function hydratorsWithoutConstructors() : iterable + { + yield 'ArraySerializable' => [Hydrator\ArraySerializableHydrator::class]; + yield 'ArraySerializableHydrator' => [Hydrator\ArraySerializableHydrator::class]; + yield 'ClassMethods' => [Hydrator\ClassMethodsHydrator::class]; + yield 'ClassMethodsHydrator' => [Hydrator\ClassMethodsHydrator::class]; + yield Hydrator\ArraySerializable::class => [Hydrator\ArraySerializableHydrator::class]; + yield Hydrator\ClassMethods::class => [Hydrator\ClassMethodsHydrator::class]; + yield Hydrator\ObjectProperty::class => [Hydrator\ObjectPropertyHydrator::class]; + yield Hydrator\Reflection::class => [Hydrator\ReflectionHydrator::class]; + yield 'ObjectPropertyHydrator' => [Hydrator\ObjectPropertyHydrator::class]; + yield 'ObjectProperty' => [Hydrator\ObjectPropertyHydrator::class]; + yield 'ReflectionHydrator' => [Hydrator\ReflectionHydrator::class]; + yield 'Reflection' => [Hydrator\ReflectionHydrator::class]; + } + + /** + * @dataProvider hydratorsWithoutConstructors + */ + public function testInstantiationInitializesFactoriesForHydratorsWithoutConstructorArguments(string $class) + { + $factories = $this->reflectProperty($this->manager, 'factories'); + + $this->assertArrayHasKey($class, $factories); + $this->assertInstanceOf(Closure::class, $factories[$class]); + } + + public function testDelegatingHydratorFactoryIsInitialized() + { + $factories = $this->reflectProperty($this->manager, 'factories'); + $this->assertInstanceOf( + Hydrator\DelegatingHydratorFactory::class, + $factories[Hydrator\DelegatingHydrator::class] + ); + } + + public function testHasReturnsFalseForUnknownNames() + { + $this->assertFalse($this->manager->has('unknown-service-name')); + } + + public function knownServices() : iterable + { + foreach ($this->hydratorsWithoutConstructors() as $key => $data) { + $class = array_pop($data); + $alias = sprintf('%s alias', $key); + $fqcn = sprintf('%s class', $key); + + yield $alias => [$key, $class]; + yield $fqcn => [$class, $class]; + } + + yield 'DelegatingHydrator alias' => ['DelegatingHydrator', Hydrator\DelegatingHydrator::class]; + yield 'DelegatingHydrator class' => [Hydrator\DelegatingHydrator::class, Hydrator\DelegatingHydrator::class]; + } + + /** + * @dataProvider knownServices + */ + public function testHasReturnsTrueForKnownServices(string $service) + { + $this->assertTrue($this->manager->has($service)); + } + + public function testGetRaisesExceptionForUnknownService() + { + $this->expectException(Hydrator\Exception\MissingHydratorServiceException::class); + $this->manager->get('unknown-service-name'); + } + + /** + * @dataProvider knownServices + */ + public function testGetReturnsExpectedTypesForKnownServices(string $service, string $expectedType) + { + $instance = $this->manager->get($service); + $this->assertInstanceOf($expectedType, $instance); + } +}