From 6d427088f7141a860ce89e5874fdcdf3abb528b6 Mon Sep 17 00:00:00 2001 From: Romain Canon Date: Tue, 15 Feb 2022 22:24:02 +0100 Subject: [PATCH] feat!: improve object binding API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method `MapperBuilder::bind()` can be used to define a custom way to build an object during the mapping. The return type of the callback will be resolved by the mapping to know when to use it. The callback can take any arguments, that will automatically be mapped using the given source. These arguments can then be used to instantiate the object in the desired way. Example: ```php (new \CuyZ\Valinor\MapperBuilder()) ->bind(function(string $string, OtherClass $otherClass): SomeClass { $someClass = new SomeClass($string); $someClass->addOtherClass($otherClass); return $someClass; }) ->mapper() ->map(SomeClass::class, [ // … ]); ``` --- src/Library/Container.php | 24 +++--- src/Library/Settings.php | 2 +- src/Mapper/Object/CallbackObjectBuilder.php | 47 ++++++++++++ .../Factory/ObjectBindingBuilderFactory.php | 76 +++++++++++++++++++ src/Mapper/Tree/Builder/ClassNodeBuilder.php | 2 +- .../Visitor/ObjectBindingShellVisitor.php | 36 --------- src/MapperBuilder.php | 42 +++++----- src/Utility/Reflection/Reflection.php | 21 ----- .../Mapping/ObjectBindingMappingTest.php | 58 +++++++++++--- tests/Unit/MapperBuilderTest.php | 11 --- .../Utility/Reflection/ReflectionTest.php | 44 ----------- 11 files changed, 212 insertions(+), 151 deletions(-) create mode 100644 src/Mapper/Object/CallbackObjectBuilder.php create mode 100644 src/Mapper/Object/Factory/ObjectBindingBuilderFactory.php delete mode 100644 src/Mapper/Tree/Visitor/ObjectBindingShellVisitor.php diff --git a/src/Library/Container.php b/src/Library/Container.php index ded16ad..74b63a3 100644 --- a/src/Library/Container.php +++ b/src/Library/Container.php @@ -25,6 +25,7 @@ use CuyZ\Valinor\Definition\Repository\Reflection\ReflectionFunctionDefinitionRe use CuyZ\Valinor\Mapper\Object\Factory\AttributeObjectBuilderFactory; use CuyZ\Valinor\Mapper\Object\Factory\ConstructorObjectBuilderFactory; use CuyZ\Valinor\Mapper\Object\Factory\DateTimeObjectBuilderFactory; +use CuyZ\Valinor\Mapper\Object\Factory\ObjectBindingBuilderFactory; use CuyZ\Valinor\Mapper\Object\Factory\ObjectBuilderFactory; use CuyZ\Valinor\Mapper\Object\ObjectBuilderFilterer; use CuyZ\Valinor\Mapper\Tree\Builder\ArrayNodeBuilder; @@ -45,7 +46,6 @@ use CuyZ\Valinor\Mapper\Tree\Builder\VisitorNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Visitor\AggregateShellVisitor; use CuyZ\Valinor\Mapper\Tree\Visitor\AttributeShellVisitor; use CuyZ\Valinor\Mapper\Tree\Visitor\InterfaceShellVisitor; -use CuyZ\Valinor\Mapper\Tree\Visitor\ObjectBindingShellVisitor; use CuyZ\Valinor\Mapper\Tree\Visitor\ShellVisitor; use CuyZ\Valinor\Mapper\TreeMapper; use CuyZ\Valinor\Mapper\TreeMapperContainer; @@ -102,7 +102,6 @@ final class Container $this->get(TypeParser::class), ), new AttributeShellVisitor(), - new ObjectBindingShellVisitor($settings->objectBinding), ); }, @@ -142,14 +141,21 @@ final class Container return new ErrorCatcherNodeBuilder($builder); }, - ObjectBuilderFactory::class => function (): ObjectBuilderFactory { - return new AttributeObjectBuilderFactory( - new DateTimeObjectBuilderFactory( - new ConstructorObjectBuilderFactory( - $this->get(ObjectBuilderFilterer::class) - ) - ) + ObjectBuilderFactory::class => function () use ($settings): ObjectBuilderFactory { + $factory = new ConstructorObjectBuilderFactory( + $this->get(ObjectBuilderFilterer::class) ); + + $factory = new DateTimeObjectBuilderFactory($factory); + + $factory = new ObjectBindingBuilderFactory( + $factory, + $this->get(FunctionDefinitionRepository::class), + $this->get(ObjectBuilderFilterer::class), + $settings->objectBinding, + ); + + return new AttributeObjectBuilderFactory($factory); }, ObjectBuilderFilterer::class => fn () => new ObjectBuilderFilterer(), diff --git a/src/Library/Settings.php b/src/Library/Settings.php index 6a585cd..bb73cfc 100644 --- a/src/Library/Settings.php +++ b/src/Library/Settings.php @@ -15,7 +15,7 @@ final class Settings /** @var array */ public array $interfaceMapping = []; - /** @var array */ + /** @var list */ public array $objectBinding = []; /** @var list */ diff --git a/src/Mapper/Object/CallbackObjectBuilder.php b/src/Mapper/Object/CallbackObjectBuilder.php new file mode 100644 index 0000000..1105111 --- /dev/null +++ b/src/Mapper/Object/CallbackObjectBuilder.php @@ -0,0 +1,47 @@ +function = $function; + $this->callback = $callback; + } + + public function describeArguments(): iterable + { + foreach ($this->function->parameters() as $parameter) { + $argument = $parameter->isOptional() + ? Argument::optional($parameter->name(), $parameter->type(), $parameter->defaultValue()) + : Argument::required($parameter->name(), $parameter->type()); + + yield $argument->withAttributes($parameter->attributes()); + } + } + + public function build(array $arguments): object + { + // @PHP8.0 `array_values` can be removed + /** @infection-ignore-all */ + $arguments = array_values($arguments); + + return ($this->callback)(...$arguments); + } +} diff --git a/src/Mapper/Object/Factory/ObjectBindingBuilderFactory.php b/src/Mapper/Object/Factory/ObjectBindingBuilderFactory.php new file mode 100644 index 0000000..567ab91 --- /dev/null +++ b/src/Mapper/Object/Factory/ObjectBindingBuilderFactory.php @@ -0,0 +1,76 @@ + */ + private array $callbacks; + + /** @var list */ + private array $functions; + + /** + * @param list $callbacks + */ + public function __construct( + ObjectBuilderFactory $delegate, + FunctionDefinitionRepository $functionDefinitionRepository, + ObjectBuilderFilterer $objectBuilderFilterer, + array $callbacks + ) { + $this->delegate = $delegate; + $this->functionDefinitionRepository = $functionDefinitionRepository; + $this->objectBuilderFilterer = $objectBuilderFilterer; + $this->callbacks = $callbacks; + } + + public function for(ClassDefinition $class, $source): ObjectBuilder + { + $builders = []; + + foreach ($this->functions() as $key => $function) { + if ($function->returnType()->matches($class->type())) { + $builders[] = new CallbackObjectBuilder($function, $this->callbacks[$key]); + } + } + + if (empty($builders)) { + return $this->delegate->for($class, $source); + } + + return $this->objectBuilderFilterer->filter($source, ...$builders); + } + + /** + * @return FunctionDefinition[] + */ + private function functions(): array + { + if (! isset($this->functions)) { + $this->functions = []; + + foreach ($this->callbacks as $key => $callback) { + $this->functions[$key] = $this->functionDefinitionRepository->for($callback); + } + } + + return $this->functions; + } +} diff --git a/src/Mapper/Tree/Builder/ClassNodeBuilder.php b/src/Mapper/Tree/Builder/ClassNodeBuilder.php index 9ee8939..59f3935 100644 --- a/src/Mapper/Tree/Builder/ClassNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ClassNodeBuilder.php @@ -122,7 +122,7 @@ final class ClassNodeBuilder implements NodeBuilder */ private function transformSource($source, Argument ...$arguments): array { - if ($source === null) { + if ($source === null || count($arguments) === 0) { return []; } diff --git a/src/Mapper/Tree/Visitor/ObjectBindingShellVisitor.php b/src/Mapper/Tree/Visitor/ObjectBindingShellVisitor.php deleted file mode 100644 index 78df4dc..0000000 --- a/src/Mapper/Tree/Visitor/ObjectBindingShellVisitor.php +++ /dev/null @@ -1,36 +0,0 @@ - */ - private array $callbacks; - - /** - * @param array $callbacks - */ - public function __construct(array $callbacks) - { - $this->callbacks = $callbacks; - } - - public function visit(Shell $shell): Shell - { - $value = $shell->value(); - $signature = (string)$shell->type(); - - if (! isset($this->callbacks[$signature])) { - return $shell; - } - - $value = $this->callbacks[$signature]($value); - - return $shell->withValue($value); - } -} diff --git a/src/MapperBuilder.php b/src/MapperBuilder.php index a3e4e26..88b10d2 100644 --- a/src/MapperBuilder.php +++ b/src/MapperBuilder.php @@ -9,8 +9,6 @@ use CuyZ\Valinor\Library\Settings; use CuyZ\Valinor\Mapper\Tree\Node; use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Mapper\TreeMapper; -use CuyZ\Valinor\Utility\Reflection\Reflection; -use LogicException; /** @api */ final class MapperBuilder @@ -37,25 +35,35 @@ final class MapperBuilder } /** - * @param callable(mixed): object $callback + * Defines a custom way to build an object during the mapping. + * + * The return type of the callback will be resolved by the mapping to know + * when to use it. + * + * The callback can take any arguments, that will automatically be mapped + * using the given source. These arguments can then be used to instantiate + * the object in the desired way. + * + * Example: + * + * ``` + * (new \CuyZ\Valinor\MapperBuilder()) + * ->bind(function(string $string, OtherClass $otherClass): SomeClass { + * $someClass = new SomeClass($string); + * $someClass->addOtherClass($otherClass); + * + * return $someClass; + * }) + * ->mapper() + * ->map(SomeClass::class, [ + * // … + * ]); + * ``` */ public function bind(callable $callback): self { - $reflection = Reflection::ofCallable($callback); - - $nativeType = $reflection->getReturnType(); - $typeFromDocBlock = Reflection::docBlockReturnType($reflection); - - if ($typeFromDocBlock) { - $type = $typeFromDocBlock; - } elseif ($nativeType) { - $type = Reflection::flattenType($nativeType); - } else { - throw new LogicException('No return type was found for this callable.'); - } - $clone = clone $this; - $clone->settings->objectBinding[$type] = $callback; + $clone->settings->objectBinding[] = $callback; return $clone; } diff --git a/src/Utility/Reflection/Reflection.php b/src/Utility/Reflection/Reflection.php index 93d7ccf..d47230f 100644 --- a/src/Utility/Reflection/Reflection.php +++ b/src/Utility/Reflection/Reflection.php @@ -173,27 +173,6 @@ final class Reflection return $types; } - public static function ofCallable(callable $callable): ReflectionFunctionAbstract - { - if ($callable instanceof Closure) { - return new ReflectionFunction($callable); - } - - if (is_string($callable)) { - $parts = explode('::', $callable); - - return count($parts) > 1 - ? new ReflectionMethod($parts[0], $parts[1]) - : new ReflectionFunction($callable); - } - - if (! is_array($callable)) { - $callable = [$callable, '__invoke']; - } - - return new ReflectionMethod($callable[0], $callable[1]); - } - /** * @param ReflectionClass|ReflectionProperty|ReflectionFunctionAbstract $reflection */ diff --git a/tests/Integration/Mapping/ObjectBindingMappingTest.php b/tests/Integration/Mapping/ObjectBindingMappingTest.php index 92fb987..0639a0e 100644 --- a/tests/Integration/Mapping/ObjectBindingMappingTest.php +++ b/tests/Integration/Mapping/ObjectBindingMappingTest.php @@ -8,7 +8,6 @@ use CuyZ\Valinor\Mapper\MappingError; use CuyZ\Valinor\Tests\Integration\IntegrationTest; use DateTime; use DateTimeImmutable; -use DateTimeInterface; use stdClass; use function get_class; @@ -26,9 +25,7 @@ final class ObjectBindingMappingTest extends IntegrationTest $result = $this->mapperBuilder ->bind(fn (): stdClass => $object) ->mapper() - ->map(get_class($class), [ - 'object' => new stdClass(), - ]); + ->map(get_class($class), []); } catch (MappingError $error) { $this->mappingFail($error); } @@ -47,9 +44,7 @@ final class ObjectBindingMappingTest extends IntegrationTest $result = $this->mapperBuilder ->bind(/** @return stdClass */ fn () => $object) ->mapper() - ->map(get_class($class), [ - 'object' => new stdClass(), - ]); + ->map(get_class($class), []); } catch (MappingError $error) { $this->mappingFail($error); } @@ -68,7 +63,6 @@ final class ObjectBindingMappingTest extends IntegrationTest ->bind(fn (): DateTimeImmutable => $defaultImmutable) ->mapper() ->map(SimpleDateTimeValues::class, [ - 'dateTimeInterface' => 1357047105, 'dateTimeImmutable' => 1357047105, 'dateTime' => 1357047105, ]); @@ -76,16 +70,58 @@ final class ObjectBindingMappingTest extends IntegrationTest $this->mappingFail($error); } - self::assertSame($defaultImmutable, $result->dateTimeInterface); self::assertSame($defaultImmutable, $result->dateTimeImmutable); self::assertSame($default, $result->dateTime); } + + public function test_bind_object_with_one_argument_binds_object(): void + { + try { + $result = $this->mapperBuilder + ->bind(function (int $int): stdClass { + $class = new stdClass(); + $class->int = $int; + + return $class; + }) + ->mapper() + ->map(stdClass::class, 1337); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame(1337, $result->int); + } + + public function test_bind_object_with_arguments_binds_object(): void + { + try { + $result = $this->mapperBuilder + ->bind(function (string $string, int $int, float $float = 1337.404): stdClass { + $class = new stdClass(); + $class->string = $string; + $class->int = $int; + $class->float = $float; + + return $class; + }) + ->mapper() + ->map(stdClass::class, [ + 'string' => 'foo', + 'int' => 42, + ]); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame('foo', $result->string); + self::assertSame(42, $result->int); + self::assertSame(1337.404, $result->float); + } } final class SimpleDateTimeValues { - public DateTimeInterface $dateTimeInterface; - public DateTimeImmutable $dateTimeImmutable; public DateTime $dateTime; diff --git a/tests/Unit/MapperBuilderTest.php b/tests/Unit/MapperBuilderTest.php index b22a5d1..6e56d9d 100644 --- a/tests/Unit/MapperBuilderTest.php +++ b/tests/Unit/MapperBuilderTest.php @@ -7,7 +7,6 @@ namespace CuyZ\Valinor\Tests\Unit; use CuyZ\Valinor\MapperBuilder; use DateTime; use DateTimeInterface; -use LogicException; use PHPUnit\Framework\TestCase; use stdClass; @@ -44,14 +43,4 @@ final class MapperBuilderTest extends TestCase { self::assertSame($this->mapperBuilder->mapper(), $this->mapperBuilder->mapper()); } - - public function test_bind_with_callable_with_no_return_type_throws_exception(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('No return type was found for this callable.'); - - // @phpstan-ignore-next-line - $this->mapperBuilder->bind(static function () { - }); - } } diff --git a/tests/Unit/Utility/Reflection/ReflectionTest.php b/tests/Unit/Utility/Reflection/ReflectionTest.php index 24fde3b..1a371e2 100644 --- a/tests/Unit/Utility/Reflection/ReflectionTest.php +++ b/tests/Unit/Utility/Reflection/ReflectionTest.php @@ -166,48 +166,4 @@ final class ReflectionTest extends TestCase self::assertNull($type); } - - public function test_reflection_of_closure_is_correct(): void - { - $callable = static function (): void { - }; - - $reflection = Reflection::ofCallable($callable); - - self::assertTrue($reflection->isClosure()); - } - - public function test_reflection_of_function_is_correct(): void - { - $reflection = Reflection::ofCallable('strlen'); - - self::assertSame('strlen', $reflection->getShortName()); - } - - public function test_reflection_of_static_method_is_correct(): void - { - $class = new class () { - public static function someMethod(): void - { - } - }; - - // @phpstan-ignore-next-line - $reflection = Reflection::ofCallable(get_class($class) . '::someMethod'); - - self::assertSame('someMethod', $reflection->getShortName()); - } - - public function test_reflection_of_callable_class_is_correct(): void - { - $class = new class () { - public function __invoke(): void - { - } - }; - - $reflection = Reflection::ofCallable($class); - - self::assertSame('__invoke', $reflection->getShortName()); - } }