From 8a74147d4c1f78696049c2dfb9acf979ef6e4689 Mon Sep 17 00:00:00 2001 From: Romain Canon Date: Fri, 7 Jan 2022 13:21:43 +0100 Subject: [PATCH] misc!: allow object builder to yield arguments without source The `Argument` class must now be instantiated with one of the `required` or `optional` static constructors. --- src/Mapper/Object/Argument.php | 47 +++++++++++---- src/Mapper/Object/DateTimeObjectBuilder.php | 43 +++++++++---- src/Mapper/Object/MethodObjectBuilder.php | 60 ++++--------------- src/Mapper/Object/ObjectBuilder.php | 3 +- src/Mapper/Object/ReflectionObjectBuilder.php | 49 ++------------- src/Mapper/Tree/Builder/ClassNodeBuilder.php | 48 ++++++++++++++- .../Fake/Mapper/Object/FakeObjectBuilder.php | 2 +- .../Mapping/Object/DateTimeMappingTest.php | 5 +- .../Object/ObjectValuesMappingTest.php | 22 ++++++- .../Mapper/Object/MethodObjectBuilderTest.php | 23 ------- .../Object/ReflectionObjectBuilderTest.php | 23 ------- 11 files changed, 152 insertions(+), 173 deletions(-) diff --git a/src/Mapper/Object/Argument.php b/src/Mapper/Object/Argument.php index df948900..0b372ee5 100644 --- a/src/Mapper/Object/Argument.php +++ b/src/Mapper/Object/Argument.php @@ -15,19 +15,41 @@ final class Argument private Type $type; /** @var mixed */ - private $value; + private $defaultValue; - private ?Attributes $attributes; + private bool $isRequired = true; - /** - * @param mixed $value - */ - public function __construct(string $name, Type $type, $value, Attributes $attributes = null) + private Attributes $attributes; + + private function __construct(string $name, Type $type) { $this->name = $name; $this->type = $type; - $this->value = $value; - $this->attributes = $attributes; + } + + public static function required(string $name, Type $type): self + { + return new self($name, $type); + } + + /** + * @param mixed $defaultValue + */ + public static function optional(string $name, Type $type, $defaultValue): self + { + $instance = new self($name, $type); + $instance->defaultValue = $defaultValue; + $instance->isRequired = false; + + return $instance; + } + + public function withAttributes(Attributes $attributes): self + { + $clone = clone $this; + $clone->attributes = $attributes; + + return $clone; } public function name(): string @@ -43,9 +65,14 @@ public function type(): Type /** * @return mixed */ - public function value() + public function defaultValue() + { + return $this->defaultValue; + } + + public function isRequired(): bool { - return $this->value; + return $this->isRequired; } public function attributes(): Attributes diff --git a/src/Mapper/Object/DateTimeObjectBuilder.php b/src/Mapper/Object/DateTimeObjectBuilder.php index 11214800..cca508b1 100644 --- a/src/Mapper/Object/DateTimeObjectBuilder.php +++ b/src/Mapper/Object/DateTimeObjectBuilder.php @@ -5,9 +5,13 @@ namespace CuyZ\Valinor\Mapper\Object; use CuyZ\Valinor\Mapper\Object\Exception\CannotParseToDateTime; +use CuyZ\Valinor\Type\Type; use CuyZ\Valinor\Type\Types\NonEmptyStringType; use CuyZ\Valinor\Type\Types\NullType; use CuyZ\Valinor\Type\Types\PositiveIntegerType; +use CuyZ\Valinor\Type\Types\ShapedArrayElement; +use CuyZ\Valinor\Type\Types\ShapedArrayType; +use CuyZ\Valinor\Type\Types\StringValueType; use CuyZ\Valinor\Type\Types\UnionType; use DateTime; use DateTimeImmutable; @@ -24,6 +28,8 @@ final class DateTimeObjectBuilder implements ObjectBuilder public const DATE_MYSQL = 'Y-m-d H:i:s'; public const DATE_PGSQL = 'Y-m-d H:i:s.u'; + private static Type $argumentType; + /** @var class-string */ private string $className; @@ -35,24 +41,35 @@ public function __construct(string $className) $this->className = $className; } - public function describeArguments($source): iterable + public function describeArguments(): iterable { - $datetime = $source; - $format = null; - - if (is_array($source)) { - $datetime = $source['datetime'] ?? null; - $format = $source['format'] ?? null; - } - - yield new Argument('datetime', new UnionType(PositiveIntegerType::get(), NonEmptyStringType::get()), $datetime); - yield new Argument('format', new UnionType(NullType::get(), NonEmptyStringType::get()), $format); + self::$argumentType ??= new UnionType( + new UnionType(PositiveIntegerType::get(), NonEmptyStringType::get()), + new ShapedArrayType( + new ShapedArrayElement( + new StringValueType('datetime'), + new UnionType(PositiveIntegerType::get(), NonEmptyStringType::get()) + ), + new ShapedArrayElement( + new StringValueType('format'), + new UnionType(NullType::get(), NonEmptyStringType::get()), + true + ), + ) + ); + + yield Argument::required('value', self::$argumentType); } public function build(array $arguments): DateTimeInterface { - $datetime = $arguments['datetime']; - $format = $arguments['format']; + $datetime = $arguments['value']; + $format = null; + + if (is_array($datetime)) { + $format = $datetime['format']; + $datetime = $datetime['datetime']; + } assert(is_string($datetime) || is_int($datetime)); assert(is_string($format) || is_null($format)); diff --git a/src/Mapper/Object/MethodObjectBuilder.php b/src/Mapper/Object/MethodObjectBuilder.php index 1d774978..386543e8 100644 --- a/src/Mapper/Object/MethodObjectBuilder.php +++ b/src/Mapper/Object/MethodObjectBuilder.php @@ -10,21 +10,14 @@ use CuyZ\Valinor\Mapper\Object\Exception\ConstructorMethodIsNotStatic; use CuyZ\Valinor\Mapper\Object\Exception\InvalidConstructorMethodClassReturnType; use CuyZ\Valinor\Mapper\Object\Exception\InvalidConstructorMethodReturnType; -use CuyZ\Valinor\Mapper\Object\Exception\InvalidSourceForObject; use CuyZ\Valinor\Mapper\Object\Exception\MethodNotFound; use CuyZ\Valinor\Mapper\Object\Exception\MissingMethodArgument; use CuyZ\Valinor\Mapper\Tree\Message\ThrowableMessage; use CuyZ\Valinor\Type\Types\ClassType; use Exception; -use function array_key_exists; -use function array_keys; use function array_values; -use function count; use function is_a; -use function is_array; -use function is_iterable; -use function iterator_to_array; final class MethodObjectBuilder implements ObjectBuilder { @@ -66,17 +59,14 @@ public function __construct(ClassDefinition $class, string $methodName) } } - public function describeArguments($source): iterable + public function describeArguments(): iterable { - $source = $this->transformSource($source); - foreach ($this->method->parameters() as $parameter) { - $name = $parameter->name(); - $type = $parameter->type(); - $attributes = $parameter->attributes(); - $value = array_key_exists($name, $source) ? $source[$name] : $parameter->defaultValue(); + $argument = $parameter->isOptional() + ? Argument::optional($parameter->name(), $parameter->type(), $parameter->defaultValue()) + : Argument::required($parameter->name(), $parameter->type()); - yield new Argument($name, $type, $value, $attributes); + yield $argument->withAttributes($parameter->attributes()); } } @@ -92,48 +82,18 @@ public function build(array $arguments): object $methodName = $this->method->name(); try { + // @PHP8.0 `array_values` can be removed + $arguments = array_values($arguments); + if (! $this->method->isStatic()) { - // @PHP8.0 `array_values` can be removed /** @infection-ignore-all */ - return new $className(...array_values($arguments)); + return new $className(...$arguments); } - // @PHP8.0 `array_values` can be removed /** @infection-ignore-all */ - return $className::$methodName(...array_values($arguments)); // @phpstan-ignore-line + return $className::$methodName(...$arguments); // @phpstan-ignore-line } catch (Exception $exception) { throw ThrowableMessage::from($exception); } } - - /** - * @param mixed $source - * @return mixed[] - */ - private function transformSource($source): array - { - if ($source === null) { - return []; - } - - if (is_iterable($source) && ! is_array($source)) { - $source = iterator_to_array($source); - } - - $parameters = $this->method->parameters(); - - if (count($parameters) === 1) { - $name = array_keys(iterator_to_array($parameters))[0]; - - if (! is_array($source) || ! array_key_exists($name, $source)) { - $source = [$name => $source]; - } - } - - if (! is_array($source)) { - throw new InvalidSourceForObject($source); - } - - return $source; - } } diff --git a/src/Mapper/Object/ObjectBuilder.php b/src/Mapper/Object/ObjectBuilder.php index d88cf909..4b06d555 100644 --- a/src/Mapper/Object/ObjectBuilder.php +++ b/src/Mapper/Object/ObjectBuilder.php @@ -7,10 +7,9 @@ interface ObjectBuilder { /** - * @param mixed $source * @return iterable */ - public function describeArguments($source): iterable; + public function describeArguments(): iterable; /** * @param array $arguments diff --git a/src/Mapper/Object/ReflectionObjectBuilder.php b/src/Mapper/Object/ReflectionObjectBuilder.php index 7da570d9..cb1d528e 100644 --- a/src/Mapper/Object/ReflectionObjectBuilder.php +++ b/src/Mapper/Object/ReflectionObjectBuilder.php @@ -5,14 +5,9 @@ namespace CuyZ\Valinor\Mapper\Object; use CuyZ\Valinor\Definition\ClassDefinition; -use CuyZ\Valinor\Mapper\Object\Exception\InvalidSourceForObject; use CuyZ\Valinor\Mapper\Object\Exception\MissingPropertyArgument; use function array_key_exists; -use function array_keys; -use function count; -use function is_array; -use function iterator_to_array; final class ReflectionObjectBuilder implements ObjectBuilder { @@ -23,17 +18,14 @@ public function __construct(ClassDefinition $class) $this->class = $class; } - public function describeArguments($source): iterable + public function describeArguments(): iterable { - $source = $this->transformSource($source); - foreach ($this->class->properties() as $property) { - $name = $property->name(); - $type = $property->type(); - $attributes = $property->attributes(); - $value = array_key_exists($name, $source) ? $source[$name] : $property->defaultValue(); + $argument = $property->hasDefaultValue() + ? Argument::optional($property->name(), $property->type(), $property->defaultValue()) + : Argument::required($property->name(), $property->type()); - yield new Argument($name, $type, $value, $attributes); + yield $argument->withAttributes($property->attributes()); } } @@ -57,35 +49,4 @@ public function build(array $arguments): object return $object; } - - /** - * @param mixed $source - * @return mixed[] - */ - private function transformSource($source): array - { - if ($source === null) { - return []; - } - - if (is_iterable($source) && ! is_array($source)) { - $source = iterator_to_array($source); - } - - $properties = $this->class->properties(); - - if (count($properties) === 1) { - $name = array_keys(iterator_to_array($properties))[0]; - - if (! is_array($source) || ! array_key_exists($name, $source)) { - $source = [$name => $source]; - } - } - - if (! is_array($source)) { - throw new InvalidSourceForObject($source); - } - - return $source; - } } diff --git a/src/Mapper/Tree/Builder/ClassNodeBuilder.php b/src/Mapper/Tree/Builder/ClassNodeBuilder.php index c1561056..afc82643 100644 --- a/src/Mapper/Tree/Builder/ClassNodeBuilder.php +++ b/src/Mapper/Tree/Builder/ClassNodeBuilder.php @@ -5,13 +5,20 @@ namespace CuyZ\Valinor\Mapper\Tree\Builder; use CuyZ\Valinor\Definition\Repository\ClassDefinitionRepository; +use CuyZ\Valinor\Mapper\Object\Argument; +use CuyZ\Valinor\Mapper\Object\Exception\InvalidSourceForObject; use CuyZ\Valinor\Mapper\Object\Factory\ObjectBuilderFactory; use CuyZ\Valinor\Mapper\Object\ObjectBuilder; use CuyZ\Valinor\Mapper\Tree\Node; use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Type\Types\ClassType; +use function array_key_exists; use function assert; +use function count; +use function is_array; +use function is_iterable; +use function iterator_to_array; final class ClassNodeBuilder implements NodeBuilder { @@ -28,7 +35,7 @@ public function __construct(ClassDefinitionRepository $classDefinitionRepository public function build(Shell $shell, RootNodeBuilder $rootBuilder): Node { $type = $shell->type(); - $value = $shell->value(); + $source = $shell->value(); assert($type instanceof ClassType); @@ -36,10 +43,16 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): Node $builder = $this->objectBuilderFactory->for($class); $children = []; + $arguments = [...$builder->describeArguments()]; + $source = $this->transformSource($source, ...$arguments); - foreach ($builder->describeArguments($value) as $arg) { - $child = $shell->child($arg->name(), $arg->type(), $arg->value(), $arg->attributes()); + foreach ($arguments as $argument) { + $name = $argument->name(); + $type = $argument->type(); + $attributes = $argument->attributes(); + $value = array_key_exists($name, $source) ? $source[$name] : $argument->defaultValue(); + $child = $shell->child($name, $type, $value, $attributes); $children[] = $rootBuilder->build($child); } @@ -48,6 +61,35 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): Node return Node::branch($shell, $object, $children); } + /** + * @param mixed $source + * @return mixed[] + */ + private function transformSource($source, Argument ...$arguments): array + { + if ($source === null) { + return []; + } + + if (is_iterable($source) && ! is_array($source)) { + $source = iterator_to_array($source); + } + + if (count($arguments) === 1) { + $name = $arguments[0]->name(); + + if (! is_array($source) || ! array_key_exists($name, $source)) { + $source = [$name => $source]; + } + } + + if (! is_array($source)) { + throw new InvalidSourceForObject($source); + } + + return $source; + } + /** * @param Node[] $children */ diff --git a/tests/Fake/Mapper/Object/FakeObjectBuilder.php b/tests/Fake/Mapper/Object/FakeObjectBuilder.php index 9bade1b8..6a2f0b26 100644 --- a/tests/Fake/Mapper/Object/FakeObjectBuilder.php +++ b/tests/Fake/Mapper/Object/FakeObjectBuilder.php @@ -9,7 +9,7 @@ final class FakeObjectBuilder implements ObjectBuilder { - public function describeArguments($source): iterable + public function describeArguments(): iterable { return []; } diff --git a/tests/Integration/Mapping/Object/DateTimeMappingTest.php b/tests/Integration/Mapping/Object/DateTimeMappingTest.php index 8ae0419a..64c2068e 100644 --- a/tests/Integration/Mapping/Object/DateTimeMappingTest.php +++ b/tests/Integration/Mapping/Object/DateTimeMappingTest.php @@ -103,10 +103,9 @@ public function test_invalid_array_source_throws_exception(): void ], ]); } catch (MappingError $exception) { - $error = $exception->node()->children()['dateTime']->children()['datetime']->messages()[0]; + $error = $exception->node()->children()['dateTime']->children()['value']->messages()[0]; - self::assertSame('1618742357', $error->code()); - self::assertSame('Cannot assign an empty value to union type `positive-int|non-empty-string`.', (string)$error); + self::assertSame('1607027306', $error->code()); } } } diff --git a/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php b/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php index 89a5ca4a..dbf422ba 100644 --- a/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php +++ b/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php @@ -13,6 +13,7 @@ final class ObjectValuesMappingTest extends IntegrationTest public function test_values_are_mapped_properly(): void { $source = [ + 'string' => 'foo', 'object' => [ 'value' => 'foo', ], @@ -28,17 +29,36 @@ public function test_values_are_mapped_properly(): void self::assertSame('foo', $result->object->value); } } + + public function test_invalid_iterable_source_throws_exception(): void + { + $source = 'foo'; + + foreach ([ObjectValues::class, ObjectValuesWithConstructor::class] as $class) { + try { + $this->mapperBuilder->mapper()->map($class, $source); + } catch (MappingError $exception) { + $error = $exception->node()->messages()[0]; + + self::assertSame('1632903281', $error->code()); + self::assertSame('Invalid source type `string`, it must be an iterable.', (string)$error); + } + } + } } class ObjectValues { public SimpleObject $object; + + public string $string; } class ObjectValuesWithConstructor extends ObjectValues { - public function __construct(SimpleObject $object) + public function __construct(SimpleObject $object, string $string) { $this->object = $object; + $this->string = $string; } } diff --git a/tests/Unit/Mapper/Object/MethodObjectBuilderTest.php b/tests/Unit/Mapper/Object/MethodObjectBuilderTest.php index a50631d4..7ecd404e 100644 --- a/tests/Unit/Mapper/Object/MethodObjectBuilderTest.php +++ b/tests/Unit/Mapper/Object/MethodObjectBuilderTest.php @@ -4,18 +4,15 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Object; -use CuyZ\Valinor\Mapper\Object\Argument; use CuyZ\Valinor\Mapper\Object\Exception\ConstructorMethodIsNotPublic; use CuyZ\Valinor\Mapper\Object\Exception\ConstructorMethodIsNotStatic; use CuyZ\Valinor\Mapper\Object\Exception\InvalidConstructorMethodClassReturnType; use CuyZ\Valinor\Mapper\Object\Exception\InvalidConstructorMethodReturnType; -use CuyZ\Valinor\Mapper\Object\Exception\InvalidSourceForObject; use CuyZ\Valinor\Mapper\Object\Exception\MethodNotFound; use CuyZ\Valinor\Mapper\Object\Exception\MissingMethodArgument; use CuyZ\Valinor\Mapper\Object\MethodObjectBuilder; use CuyZ\Valinor\Mapper\Tree\Message\ThrowableMessage; use CuyZ\Valinor\Tests\Fake\Definition\FakeClassDefinition; -use Generator; use PHPUnit\Framework\TestCase; use ReflectionClass; use RuntimeException; @@ -118,26 +115,6 @@ public static function invalidConstructor(): stdClass new MethodObjectBuilder($class, 'invalidConstructor'); } - public function test_invalid_source_type_throws_exception(): void - { - $object = new class () { - public function __construct() - { - } - }; - - $class = FakeClassDefinition::fromReflection(new ReflectionClass($object)); - $objectBuilder = new MethodObjectBuilder($class, '__construct'); - - $this->expectException(InvalidSourceForObject::class); - $this->expectExceptionCode(1632903281); - $this->expectExceptionMessage('Invalid source type `string`, it must be an iterable.'); - - /** @var Generator $arguments */ - $arguments = $objectBuilder->describeArguments('foo'); - $arguments->current(); - } - public function test_missing_arguments_throws_exception(): void { $object = new class ('foo') { diff --git a/tests/Unit/Mapper/Object/ReflectionObjectBuilderTest.php b/tests/Unit/Mapper/Object/ReflectionObjectBuilderTest.php index 5ff6df36..90c4d3d2 100644 --- a/tests/Unit/Mapper/Object/ReflectionObjectBuilderTest.php +++ b/tests/Unit/Mapper/Object/ReflectionObjectBuilderTest.php @@ -4,12 +4,9 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Object; -use CuyZ\Valinor\Mapper\Object\Argument; -use CuyZ\Valinor\Mapper\Object\Exception\InvalidSourceForObject; use CuyZ\Valinor\Mapper\Object\Exception\MissingPropertyArgument; use CuyZ\Valinor\Mapper\Object\ReflectionObjectBuilder; use CuyZ\Valinor\Tests\Fake\Definition\FakeClassDefinition; -use Generator; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -53,26 +50,6 @@ public function valueC(): string self::assertSame('valueC', $result->valueC()); // @phpstan-ignore-line } - public function test_invalid_source_type_throws_exception(): void - { - $object = new class () { - public function __construct() - { - } - }; - - $class = FakeClassDefinition::fromReflection(new ReflectionClass($object)); - $objectBuilder = new ReflectionObjectBuilder($class); - - $this->expectException(InvalidSourceForObject::class); - $this->expectExceptionCode(1632903281); - $this->expectExceptionMessage('Invalid source type `string`, it must be an iterable.'); - - /** @var Generator $arguments */ - $arguments = $objectBuilder->describeArguments('foo'); - $arguments->current(); - } - public function test_missing_arguments_throws_exception(): void { $object = new class () {