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.
This commit is contained in:
Romain Canon 2022-01-07 13:21:43 +01:00
parent e834cdc5d3
commit 8a74147d4c
11 changed files with 151 additions and 172 deletions

View File

@ -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 @@ final class Argument
/**
* @return mixed
*/
public function value()
public function defaultValue()
{
return $this->value;
return $this->defaultValue;
}
public function isRequired(): bool
{
return $this->isRequired;
}
public function attributes(): Attributes

View File

@ -5,9 +5,13 @@ declare(strict_types=1);
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<DateTime|DateTimeImmutable> */
private string $className;
@ -35,24 +41,35 @@ final class DateTimeObjectBuilder implements ObjectBuilder
$this->className = $className;
}
public function describeArguments($source): iterable
public function describeArguments(): iterable
{
$datetime = $source;
$format = null;
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
),
)
);
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);
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));

View File

@ -10,21 +10,14 @@ 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\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 @@ final class MethodObjectBuilder implements ObjectBuilder
}
}
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 @@ final class MethodObjectBuilder implements ObjectBuilder
$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;
}
}

View File

@ -7,10 +7,9 @@ namespace CuyZ\Valinor\Mapper\Object;
interface ObjectBuilder
{
/**
* @param mixed $source
* @return iterable<Argument>
*/
public function describeArguments($source): iterable;
public function describeArguments(): iterable;
/**
* @param array<string, mixed> $arguments

View File

@ -5,14 +5,9 @@ declare(strict_types=1);
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 @@ final class ReflectionObjectBuilder implements ObjectBuilder
$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 @@ final class ReflectionObjectBuilder implements ObjectBuilder
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;
}
}

View File

@ -5,13 +5,20 @@ declare(strict_types=1);
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 @@ final class ClassNodeBuilder implements NodeBuilder
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 @@ final class ClassNodeBuilder implements NodeBuilder
$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 @@ final class ClassNodeBuilder implements NodeBuilder
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
*/

View File

@ -9,7 +9,7 @@ use stdClass;
final class FakeObjectBuilder implements ObjectBuilder
{
public function describeArguments($source): iterable
public function describeArguments(): iterable
{
return [];
}

View File

@ -103,10 +103,9 @@ final class DateTimeMappingTest extends IntegrationTest
],
]);
} 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());
}
}
}

View File

@ -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 @@ final class ObjectValuesMappingTest extends IntegrationTest
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;
}
}

View File

@ -4,18 +4,15 @@ declare(strict_types=1);
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 @@ final class MethodObjectBuilderTest extends TestCase
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<Argument> $arguments */
$arguments = $objectBuilder->describeArguments('foo');
$arguments->current();
}
public function test_missing_arguments_throws_exception(): void
{
$object = new class ('foo') {

View File

@ -4,12 +4,9 @@ declare(strict_types=1);
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 @@ final class ReflectionObjectBuilderTest extends TestCase
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<Argument> $arguments */
$arguments = $objectBuilder->describeArguments('foo');
$arguments->current();
}
public function test_missing_arguments_throws_exception(): void
{
$object = new class () {