diff --git a/docs/Modules.md b/docs/Modules.md index c980f36..505b837 100644 --- a/docs/Modules.md +++ b/docs/Modules.md @@ -101,6 +101,125 @@ class ModuleWhichProvidesExtensions implements ExtendingModule } ``` +### Extending by type + +Sometimes it is desirable to extend a service by its type. Extending modules can do that as well: + +```php +use Inpsyde\Modularity\Module\ExtendingModule; +use Psr\Log\{LoggerInterface, LoggerAwareInterface}; + +class LoggerAwareExtensionModule implements ExtendingModule +{ + public function extensions() : array + { + return [ + '@instanceof' => static function( + LoggerAwareInterface $service, + ContainerInterface $c + ): ExtendedService { + + if ($c->has(LoggerInterface::class)) { + $service->setLogger($c->get(LoggerInterface::class)); + } + return $service; + } + ]; + } +} +``` + +#### Types and subtypes + +The `@instanceof` syntax works with class and interface names, targeting the given type and any +of its subtypes. + +For example, assuming the following objects: + +```php +interface Animal {} +class Dog implements Animal {} +class BullDog extends Dog {} +``` + +and the following module: + +```php +class AnimalsExtensionModule implements ExtendingModule +{ + public function extensions() : array + { + return [ + '@instanceof' => fn(Animal $animal) => $animal, + '@instanceof' => fn(Dog $dog) => $dog, + '@instanceof' => fn(BullDog $bullDog) => $bullDog, + ]; + } +} +``` + +A service of type `BullDog` would go through all the 3 extensions. + +Note how extending callbacks can always safely declare the parameter type using in the signature +the type they have in `@instanceof`. + +#### Precedence + +The precedence of extensions-by-type resolution goes as follows: + +1. Extensions added to exact class +2. Extensions added to any parent class +3. Extensions added to any implemented interface + +Inside each of the three "groups", extensions are processed in _FIFO_ mode: the first added are the +first processed. + +#### Name helper + +The syntax `"@instanceof"` is an hardcoded string that might be error prone to type. + +The method `use Inpsyde\Modularity\Container\ServiceExtensions::typeId()` might be used to avoid +using hardcode strings. For example: + +```php +use npsyde\Modularity\Container\ServiceExtensions; + +class AnimalsExtensionModule implements ExtendingModule +{ + public function extensions() : array + { + return [ + ServiceExtensions::typeId(Animal::class) => fn(Animal $animal) => $animal, + ServiceExtensions::typeId(Dog::class) => fn(Dog $dog) => $dog, + ServiceExtensions::typeId(BullDog::class) => fn(BullDog $bullDog) => $bullDog, + ]; + } +} +``` + +#### Only for objects + +Extensions-by-type only work for objects. Any usage of `@instanceof` syntax with a string that is +a class/interface name will be ignored. +That means it is not possible to extend by type scalar/array services nor pseudo-types like +`iterable` or `callable`. + +#### Possibly recursive + +Extensions by type might be recursive. For example, an extension for type `A` that returns an +instance of `B` will prevent further extensions to type `A` to execute (unless `B` is a child of `A`) +and will trigger execution of extensions for type `B`. +**Infinite recursion is prevented**. So if extensions for `A` return `B` and extensions for `B` +return `A` that's where it stops, returning an `A` instance. + +#### Use carefully + +**Please note**: extensions-by-type have a performance impact especially when type extensions are +used to return a different type, because of possible recursions. +As a reference, it was measured that resolving 10000 objects in the container, each having 9 +extensions-by-type callbacks, on a very fast server, on PHP 8, for one concurrent user, takes +between 80 and 90 milliseconds. + ## ExecutableModule If there is functionality that needs to be executed, you can make the Module executable like following: diff --git a/src/Container/ContainerConfigurator.php b/src/Container/ContainerConfigurator.php index a6d849d..159afc1 100644 --- a/src/Container/ContainerConfigurator.php +++ b/src/Container/ContainerConfigurator.php @@ -19,9 +19,9 @@ class ContainerConfigurator private $factoryIds = []; /** - * @var array> + * @var ServiceExtensions */ - private $extensions = []; + private $extensions; /** * @var ContainerInterface[] @@ -38,9 +38,10 @@ class ContainerConfigurator * * @param ContainerInterface[] $containers */ - public function __construct(array $containers = []) + public function __construct(array $containers = [], ?ServiceExtensions $extensions = null) { array_map([$this, 'addContainer'], $containers); + $this->extensions = $extensions ?? new ServiceExtensions(); } /** @@ -115,11 +116,7 @@ public function hasService(string $id): bool */ public function addExtension(string $id, callable $extender): void { - if (!isset($this->extensions[$id])) { - $this->extensions[$id] = []; - } - - $this->extensions[$id][] = $extender; + $this->extensions->add($id, $extender); } /** @@ -129,7 +126,7 @@ public function addExtension(string $id, callable $extender): void */ public function hasExtension(string $id): bool { - return isset($this->extensions[$id]); + return $this->extensions->has($id); } /** diff --git a/src/Container/ReadOnlyContainer.php b/src/Container/ReadOnlyContainer.php index 99dd12a..40256bb 100644 --- a/src/Container/ReadOnlyContainer.php +++ b/src/Container/ReadOnlyContainer.php @@ -20,7 +20,7 @@ class ReadOnlyContainer implements ContainerInterface private $factoryIds; /** - * @var array> + * @var ServiceExtensions */ private $extensions; @@ -41,18 +41,18 @@ class ReadOnlyContainer implements ContainerInterface * * @param array $services * @param array $factoryIds - * @param array> $extensions + * @param ServiceExtensions|array $extensions * @param ContainerInterface[] $containers */ public function __construct( array $services, array $factoryIds, - array $extensions, + $extensions, array $containers ) { $this->services = $services; $this->factoryIds = $factoryIds; - $this->extensions = $extensions; + $this->extensions = $this->configureServiceExtensions($extensions); $this->containers = $containers; } @@ -69,7 +69,7 @@ public function get(string $id) if (array_key_exists($id, $this->services)) { $service = $this->services[$id]($this); - $resolved = $this->resolveExtensions($id, $service); + $resolved = $this->extensions->resolve($service, $id, $this); if (!isset($this->factoryIds[$id])) { $this->resolvedServices[$id] = $resolved; @@ -83,7 +83,7 @@ public function get(string $id) if ($container->has($id)) { $service = $container->get($id); - return $this->resolveExtensions($id, $service); + return $this->extensions->resolve($service, $id, $this); } } @@ -118,21 +118,42 @@ public function has(string $id): bool } /** - * @param string $id - * @param mixed $service + * Support extensions as array or ServiceExtensions instance for backward compatibility. * - * @return mixed + * With PHP 8+ we could use an actual union type, but when we bump to PHP 8 as min supported + * version, we will probably bump major version as well, so we can just get rid of support + * for array. + * + * @param mixed $extensions + * @return ServiceExtensions */ - private function resolveExtensions(string $id, $service) + private function configureServiceExtensions($extensions): ServiceExtensions { - if (!isset($this->extensions[$id])) { - return $service; + if ($extensions instanceof ServiceExtensions) { + return $extensions; + } + + if (!is_array($extensions)) { + throw new \TypeError( + sprintf( + '%s::%s(): Argument #3 ($extensions) must be of type %s|array, %s given', + __CLASS__, + '__construct', + ServiceExtensions::class, + gettype($extensions) + ) + ); } - foreach ($this->extensions[$id] as $extender) { - $service = $extender($service, $this); + $servicesExtensions = new ServiceExtensions(); + foreach ($extensions as $id => $callback) { + /** + * @var string $id + * @var callable(mixed,ContainerInterface):mixed $callback + */ + $servicesExtensions->add($id, $callback); } - return $service; + return $servicesExtensions; } } diff --git a/src/Container/ServiceExtensions.php b/src/Container/ServiceExtensions.php new file mode 100644 index 0000000..94c8909 --- /dev/null +++ b/src/Container/ServiceExtensions.php @@ -0,0 +1,172 @@ +> + */ + protected $extensions = []; + + /** + * @param string $type + * @return string + */ + final public static function typeId(string $type): string + { + return "@instanceof<{$type}>"; + } + + /** + * @param string $extensionId + * @param callable $extender + * @return static + */ + public function add(string $extensionId, callable $extender): ServiceExtensions + { + isset($this->extensions[$extensionId]) or $this->extensions[$extensionId] = []; + $this->extensions[$extensionId][] = $extender; + + return $this; + } + + /** + * @param string $extensionId + * @return bool + */ + public function has(string $extensionId): bool + { + return isset($this->extensions[$extensionId]); + } + + /** + * @param mixed $service + * @param string $id + * @param Container $container + * @return mixed + */ + final public function resolve($service, string $id, Container $container) + { + $service = $this->resolveById($id, $service, $container); + + return is_object($service) + ? $this->resolveByType(get_class($service), $service, $container) + : $service; + } + + /** + * @param string $id + * @param mixed $service + * @param Container $container + * @return mixed + */ + protected function resolveById(string $id, $service, Container $container) + { + foreach ($this->extensions[$id] ?? [] as $extender) { + $service = $extender($service, $container); + } + + return $service; + } + + /** + * @param string $className + * @param object $service + * @param Container $container + * @param array $extendedClasses + * @return mixed + */ + protected function resolveByType( + string $className, + object $service, + Container $container, + array $extendedClasses = [] + ) { + + $extendedClasses[] = $className; + + /** @var array> $allCallbacks */ + $allCallbacks = []; + + // 1st group of extensions: targeting exact class + $byClass = $this->extensions[self::typeId($className)] ?? null; + $byClass and $allCallbacks[$className] = $byClass; + + // 2nd group of extensions: targeting parent classes + /** @var class-string $parentName */ + foreach (class_parents($service, false) ?: [] as $parentName) { + $byParent = $this->extensions[self::typeId($parentName)] ?? null; + $byParent and $allCallbacks[$parentName] = $byParent; + } + + // 3rd group of extensions: targeting implemented interfaces + /** @var class-string $interfaceName */ + foreach (class_implements($service, false) ?: [] as $interfaceName) { + $byInterface = $this->extensions[self::typeId($interfaceName)] ?? null; + $byInterface and $allCallbacks[$interfaceName] = $byInterface; + } + + $resultType = self::SERVICE_TYPE_NOT_CHANGED; + /** @var class-string $type */ + foreach ($allCallbacks as $type => $extenders) { + // When the previous group of callbacks resulted in a type change, we need to check + // type before processing next group. + if (($resultType === self::SERVICE_TYPE_CHANGED) && !is_a($service, $type)) { + continue; + } + [$service, $resultType] = $this->extendByType($type, $service, $container, $extenders); + if ($resultType === self::SERVICE_TYPE_NOT_OBJECT) { + // Service is not an object anymore, let's return it. + return $service; + } + } + + // If type changed since beginning, let's start over. + // We check if class was already extended to avoid infinite recursion. E.g. instead of: + // `-> extend(A): B -> extend(B): A -> *loop* ->` + // we have: + // `-> extend(A): B -> extend(B): A -> return A`. + $newClassName = get_class($service); + if (!in_array($newClassName, $extendedClasses, true)) { + return $this->resolveByType($newClassName, $service, $container, $extendedClasses); + } + + return $service; + } + + /** + * @param class-string $type + * @param object $service + * @param Container $container + * @param array $extenders + * @return array{mixed, int} + */ + private function extendByType( + string $type, + object $service, + Container $container, + array $extenders + ): array { + + foreach ($extenders as $extender) { + $service = $extender($service, $container); + if (!is_object($service)) { + return [$service, self::SERVICE_TYPE_NOT_OBJECT]; + } + if (!is_a($service, $type)) { + return [$service, self::SERVICE_TYPE_CHANGED]; + } + } + + return [$service, self::SERVICE_TYPE_NOT_CHANGED]; + } +} diff --git a/tests/unit/Container/ContainerConfiguratorTest.php b/tests/unit/Container/ContainerConfiguratorTest.php index cc2d46c..f0a8c09 100644 --- a/tests/unit/Container/ContainerConfiguratorTest.php +++ b/tests/unit/Container/ContainerConfiguratorTest.php @@ -241,7 +241,7 @@ public function has(string $id): bool /** * @test */ - public function testAddExtension(): void + public function testExtensionById(): void { $testee = new ContainerConfigurator(); @@ -288,6 +288,248 @@ function ($previous) use ($expectedOriginalValue, $expectedExtendedValue) { static::assertSame($expectedExtendedValue, $testee->createReadOnlyContainer()->get($expectedKey)); } + /** + * @test + */ + public function testExtensionByType(): void + { + $string = 'Test'; + $array = ['test' => 'Test']; + $iterator = new \ArrayIterator($array); + $object = (object)$array; + $int = 0; + + $configurator = new ContainerConfigurator(); + + $services = compact('string', 'array', 'iterator', 'object', 'int'); + $container = new class ($services) extends \ArrayObject implements ContainerInterface + { + public function get(string $id) + { + return $this[$id] ?? null; + } + + public function has(string $id): bool + { + return $this->offsetExists($id); + } + }; + + $configurator->addContainer($container); + + $configurator->addExtension( + '@instanceof', + function (\ArrayIterator $object): array { + $array = $object->getArrayCopy(); + $array['works'] = 'Works!'; + + return $array; + } + ); + + $configurator->addExtension( + '@instanceof', + function (): string { + throw new \Error('Failed!'); + } + ); + // Invalid code does not break resolution + $configurator->addExtension( + '@instanceof', + function (): array { + throw new \Error('Failed!'); + } + ); + // Undefined classes are ignored + $configurator->addExtension( + '@instanceof', + function (): array { + throw new \Error('Failed!'); + } + ); + // This is fine, but we don't expect it running because there are no stdClass in services + $configurator->addExtension( + '@instanceof', + function (\stdClass $object): \stdClass { + $array = get_object_vars($object); + $array['works'] = 'Works!'; + return (object)$array; + } + ); + $configurator->addExtension( + '@instanceof', + function (): bool { + throw new \Error('Failed!'); + } + ); + $configurator->addExtension( + '@instanceof', + function (): int { + throw new \Error('Failed!'); + } + ); + + $container = $configurator->createReadOnlyContainer(); + + static::assertSame( + ['test' => 'Test', 'works' => 'Works!'], + $container->get('iterator') + ); + + static::assertSame( + ['test' => 'Test', 'works' => 'Works!'], + (array)$container->get('object') + ); + + static::assertSame('Test', $container->get('string')); + static::assertSame(['test' => 'Test'], $container->get('array')); + static::assertSame(0, $container->get('int')); + } + + /** + * @test + * @runInSeparateProcess + * + * @noinspection PhpUndefinedClassInspection + */ + public function testExtensionByTypeNoInfiniteRecursion(): void + { + // We can't declare classes inside a class, but we can eval it. + $php = <<<'PHP' +class A {} +class B extends A {} +PHP; + + eval($php); + + $called = []; + + $configurator = new ContainerConfigurator(); + $configurator->addService('test', static function (): \A { + return new \A(); + }); + $configurator->addExtension( + '@instanceof', + static function (\B $object) use (&$called): \B { + $called[] = 'instanceof'; + return $object; + } + ); + $configurator->addExtension( + '@instanceof', + static function () use (&$called): \B { + $called[] = 'instanceof'; + return new \B(); + } + ); + + $object = $configurator->createReadOnlyContainer()->get('test'); + static::assertTrue($object instanceof \B); + static::assertSame(['instanceof', 'instanceof', 'instanceof'], $called); + } + + /** + * @test + * @runInSeparateProcess + * + * @noinspection PhpUndefinedClassInspection + * @noinspection PhpIncompatibleReturnTypeInspection + */ + public function testExtensionByTypeNested(): void + { + $logs = []; + $log = static function (object $object, int ...$nums) use (&$logs): object { + foreach ($nums as $num) { + if (!in_array($num, $logs, true)) { + $logs[] = $num; + break; + } + } + return $object; + }; + + $configurator = new ContainerConfigurator(); + $configurator->addService('test', function () { + return new \ArrayObject(); + }); + + // We can't declare classes inside a class, but we can eval it. + $php = <<<'PHP' +class A {} +class B extends A {} +class C {} +class D {}; +class E extends D {}; +PHP; + eval($php); + + $configurator->addExtension( + '@instanceof', static function (\D $o) use (&$log): \E { + return $log(new \E(), 6, 9); + } + ); + $configurator->addExtension( + '@instanceof', + static function (\A $o) use (&$log): \A { + return $log($o, -1); // we never expect this to run + } + ); + $configurator->addExtension( + '@instanceof', + static function (\ArrayAccess $o) use (&$log): \ArrayAccess { + return $log($o, 2); + } + ); + $configurator->addExtension( + "@instanceof", + static function (\B $o) use (&$log): \C { + return $log(new \C(), 4); + } + ); + $configurator->addExtension( + 'test', + static function (object $o) use ($log): object { + return $log($o, 0); + } + ); + $configurator->addExtension( + '@instanceof', + static function (\ArrayObject $o) use (&$log): \ArrayObject { + return $log($o, 1); + } + ); + $configurator->addExtension( + '@instanceof', + static function (\C $o) use (&$log): \D { + return $log(new \D(), 5); + } + ); + $configurator->addExtension( + '@instanceof', + static function (\ArrayAccess $o) use (&$log): \B { + return $log(new \B(), 3); + } + ); + $configurator->addExtension( + "@instanceof", + static function (\E $o) use (&$log): \E { + return $log($o, 8); + } + ); + $configurator->addExtension( + "@instanceof", + static function (\D $o) use (&$log): \D { + return $log($o, 7, 10); + } + ); + + $service = $configurator->createReadOnlyContainer()->get('test'); + + static::assertTrue($service instanceof \E); + // test the order of callbacks was the one expected + static::assertSame(range(0, 10), $logs); + } + /** * @test */ diff --git a/tests/unit/Container/ReadOnlyContainerTest.php b/tests/unit/Container/ReadOnlyContainerTest.php index d00fae7..4c77837 100644 --- a/tests/unit/Container/ReadOnlyContainerTest.php +++ b/tests/unit/Container/ReadOnlyContainerTest.php @@ -5,6 +5,7 @@ namespace Inpsyde\Modularity\Tests\Unit\Container; use Inpsyde\Modularity\Container\ReadOnlyContainer as Container; +use Inpsyde\Modularity\Container\ServiceExtensions; use Inpsyde\Modularity\Tests\TestCase; use Psr\Container\ContainerInterface; @@ -105,7 +106,7 @@ public function has(string $id): bool } }; - $testee = $this->createContainer([], [], [], [$childContainer]); + $testee = $this->createContainer([], [], [$childContainer]); // check in child Container static::assertTrue($testee->has($expectedServiceKey)); @@ -115,35 +116,6 @@ public function has(string $id): bool static::assertTrue($testee->has($expectedServiceKey)); } - /** - * @test - */ - public function testExtensions(): void - { - $expectedServiceKey = 'service'; - $expectedInitialService = new \stdClass(); - $extendedService = new \stdClass(); - - $services = [ - $expectedServiceKey => function () use ($expectedInitialService) { - return $expectedInitialService; - }, - ]; - $extensions = [ - $expectedServiceKey => [ - function ($initialService) use ($expectedInitialService, $extendedService) { - static::assertSame($expectedInitialService, $initialService); - - return $extendedService; - }, - ], - ]; - - $testee = $this->createContainer($services, [], $extensions); - - static::assertSame($extendedService, $testee->get($expectedServiceKey)); - } - /** * @test */ @@ -191,10 +163,42 @@ public function count(): int static::assertSame(1, $testee->get($expectedFactoryKey)->count()); } + /** + * @test + */ + public function testServiceExtensionsBackwardCompatibility(): void + { + $service = static function (): object { + return (object) ['count' => 0]; + }; + + $extension = static function (object $thing): object { + $thing->count++; + + return $thing; + }; + + $container = new Container(['thing' => $service], [], ['thing' => $extension], []); + + $resolved = $container->get('thing'); + + static::assertInstanceOf(\stdClass::class, $resolved); + static::assertSame(1, $resolved->count); + } + + /** + * @test + */ + public function testServiceExtensionsBackwardCompatibilityBreaksOnWrongType(): void + { + $this->expectException(\TypeError::class); + + new Container([], [], ServiceExtensions::class, []); + } + /** * @param array $services * @param array $factoryIds - * @param array $extensions * @param array $containers * * @return Container @@ -202,9 +206,9 @@ public function count(): int private function createContainer( array $services = [], array $factoryIds = [], - array $extensions = [], array $containers = [] ): Container { - return new Container($services, $factoryIds, $extensions, $containers); + + return new Container($services, $factoryIds, new ServiceExtensions(), $containers); } } diff --git a/tests/unit/Container/ServiceExtensionsTest.php b/tests/unit/Container/ServiceExtensionsTest.php new file mode 100644 index 0000000..1a8e95e --- /dev/null +++ b/tests/unit/Container/ServiceExtensionsTest.php @@ -0,0 +1,98 @@ +add( + 'thing', + static function (object $thing) use (&$expected): object { + $thing->count++; + $expected++; + + return $thing; + } + ); + + $serviceExtensions->add( + 'nothing', + static function (object $thing): object { + $thing->count++; + + return $thing; + } + ); + + $serviceExtensions->add( + 'thing', + static function (object $thing) use (&$expected): object { + $thing->count++; + $expected++; + + return $thing; + } + ); + + $serviceExtensions->add( + ServiceExtensions::typeId(\stdClass::class), + static function (object $thing) use (&$expected): object { + $thing->count++; + $expected++; + + return $thing; + } + ); + + $serviceExtensions->add( + ServiceExtensions::typeId(\ArrayObject::class), + static function (object $thing): object { + $thing->count++; + + return $thing; + } + ); + + $container = $this->stubContainer(); + $thing = $serviceExtensions->resolve((object) ['count' => 0], 'thing', $container); + + static::assertTrue($serviceExtensions->has('thing')); + static::assertTrue($serviceExtensions->has(ServiceExtensions::typeId(\stdClass::class))); + static::assertSame($expected, $thing->count); + } + + /** + * @return ContainerInterface + */ + private function stubContainer(): ContainerInterface + { + return new class implements ContainerInterface + { + public function get(string $id) + { + throw new class () extends \Exception implements NotFoundExceptionInterface + { + }; + } + + public function has(string $id): bool + { + return false; + } + }; + } +}