feat!: improve object binding API

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, [
        // …
    ]);
```
This commit is contained in:
Romain Canon 2022-02-15 22:24:02 +01:00
parent 422e6a8b27
commit 6d427088f7
11 changed files with 212 additions and 151 deletions

View File

@ -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(),

View File

@ -15,7 +15,7 @@ final class Settings
/** @var array<class-string, callable(Shell): class-string> */
public array $interfaceMapping = [];
/** @var array<string, callable(mixed): object> */
/** @var list<callable> */
public array $objectBinding = [];
/** @var list<callable> */

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Object;
use CuyZ\Valinor\Definition\FunctionDefinition;
use function array_values;
/** @internal */
final class CallbackObjectBuilder implements ObjectBuilder
{
private FunctionDefinition $function;
/** @var callable(): object */
private $callback;
/**
* @param callable(): object $callback
*/
public function __construct(FunctionDefinition $function, callable $callback)
{
$this->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);
}
}

View File

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Object\Factory;
use CuyZ\Valinor\Definition\ClassDefinition;
use CuyZ\Valinor\Definition\FunctionDefinition;
use CuyZ\Valinor\Definition\Repository\FunctionDefinitionRepository;
use CuyZ\Valinor\Mapper\Object\CallbackObjectBuilder;
use CuyZ\Valinor\Mapper\Object\ObjectBuilder;
use CuyZ\Valinor\Mapper\Object\ObjectBuilderFilterer;
/** @internal */
final class ObjectBindingBuilderFactory implements ObjectBuilderFactory
{
private ObjectBuilderFactory $delegate;
private FunctionDefinitionRepository $functionDefinitionRepository;
private ObjectBuilderFilterer $objectBuilderFilterer;
/** @var list<callable> */
private array $callbacks;
/** @var list<FunctionDefinition> */
private array $functions;
/**
* @param list<callable> $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;
}
}

View File

@ -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 [];
}

View File

@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Tree\Visitor;
use CuyZ\Valinor\Mapper\Tree\Shell;
/** @internal */
final class ObjectBindingShellVisitor implements ShellVisitor
{
/** @var array<string, callable(mixed): object> */
private array $callbacks;
/**
* @param array<string, callable(mixed): object> $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);
}
}

View File

@ -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;
}

View File

@ -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<object>|ReflectionProperty|ReflectionFunctionAbstract $reflection
*/

View File

@ -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;

View File

@ -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 () {
});
}
}

View File

@ -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());
}
}