From b21f27bd7ee73b3d2222042634ca2ae56c69e875 Mon Sep 17 00:00:00 2001 From: Rudolph Gottesheim Date: Wed, 19 Jun 2024 05:05:07 +0200 Subject: [PATCH] Support object unions --- src/Converter.php | 10 +++ src/Field.php | 11 ++- src/Json.php | 82 ++++++++++--------- tests/unit/Fixtures/HasUnionType.php | 8 +- tests/unit/Fixtures/PersonVehicleUnion.php | 29 +++++++ tests/unit/Fixtures/UnionWithNoConverter.php | 15 ++++ .../Fixtures/UnionWithNoFieldAttribute.php | 13 +++ tests/unit/Fixtures/Vehicle.php | 12 +++ tests/unit/JsonTest.php | 25 ++++-- 9 files changed, 155 insertions(+), 50 deletions(-) create mode 100644 src/Converter.php create mode 100644 tests/unit/Fixtures/PersonVehicleUnion.php create mode 100644 tests/unit/Fixtures/UnionWithNoConverter.php create mode 100644 tests/unit/Fixtures/UnionWithNoFieldAttribute.php create mode 100644 tests/unit/Fixtures/Vehicle.php diff --git a/src/Converter.php b/src/Converter.php new file mode 100644 index 0000000..78d4061 --- /dev/null +++ b/src/Converter.php @@ -0,0 +1,10 @@ +|null $converter + */ + public function __construct( + public readonly string|null $name = null, + public readonly string|null $converter = null, + ) { } } diff --git a/src/Json.php b/src/Json.php index 56e3be0..801d31c 100644 --- a/src/Json.php +++ b/src/Json.php @@ -61,6 +61,41 @@ public static function decode(string $json, object|string $value): object return self::decodeClass($json, $value); } + /** + * @template T of object + * @param class-string $class + * @param array $data + * @return T + */ + public static function instantiateClass(string $class, array $data): object + { + $classReflection = new ReflectionClass($class); + $constructor = $classReflection->getConstructor(); + $arguments = []; + if ($constructor !== null) { + $parameters = $constructor->getParameters(); + foreach ($parameters as $parameter) { + $name = $parameter->getName(); + if (!array_key_exists($name, $data)) { + if ($parameter->isOptional()) { + /** @psalm-suppress MixedAssignment */ + $arguments[] = $parameter->getDefaultValue(); + continue; + } + throw JsonError::decodeFailed(sprintf('Missing required constructor argument "%s"', $name)); + } + /** @psalm-suppress MixedAssignment */ + $arguments[] = self::createConstructorArgument($parameter, $data[$name]); + unset($data[$name]); + } + } + $instance = $classReflection->newInstanceArgs($arguments); + if ($data !== []) { + self::populateObject($instance, $data); + } + return $instance; + } + /** * @psalm-suppress MixedInferredReturnType * @return string|int|float|bool|array|null @@ -258,41 +293,15 @@ private static function getPropertyObject(object $value, string $key): object return $object; } - /** - * @param class-string $class - * @param array $data - */ - private static function instantiateClass(string $class, array $data): object + private static function createConstructorArgument(ReflectionParameter $parameter, mixed $jsonValue): mixed { - $classReflection = new ReflectionClass($class); - $constructor = $classReflection->getConstructor(); - $arguments = []; - if ($constructor !== null) { - $parameters = $constructor->getParameters(); - foreach ($parameters as $parameter) { - $name = $parameter->getName(); - if (!array_key_exists($name, $data)) { - if ($parameter->isOptional()) { - /** @psalm-suppress MixedAssignment */ - $arguments[] = $parameter->getDefaultValue(); - continue; - } - throw JsonError::decodeFailed(sprintf('Missing required constructor argument "%s"', $name)); - } - /** @psalm-suppress MixedAssignment */ - $arguments[] = self::createConstructorArgument($parameter, $data[$name]); - unset($data[$name]); + $fieldAttributes = $parameter->getAttributes(Field::class); + if ($fieldAttributes !== []) { + $handler = $fieldAttributes[0]->newInstance()->converter; + if ($handler !== null) { + return $handler::fromJson($jsonValue); } } - $instance = $classReflection->newInstanceArgs($arguments); - if ($data !== []) { - self::populateObject($instance, $data); - } - return $instance; - } - - private static function createConstructorArgument(ReflectionParameter $parameter, mixed $jsonValue): mixed - { $type = $parameter->getType(); if ($type === null) { return $jsonValue; @@ -301,7 +310,9 @@ private static function createConstructorArgument(ReflectionParameter $parameter return self::createConstructorArgumentForNamedType($parameter, $jsonValue); } if ($type instanceof ReflectionUnionType) { - return self::createConstructorArgumentForUnionType(); + throw JsonError::decodeFailed( + sprintf('Property "%s" has a union type, but no converter is set', $parameter->getName()), + ); } return self::createConstructorArgumentForIntersectionType(); } @@ -357,11 +368,6 @@ private static function createConstructorArgumentForNamedType(ReflectionParamete return self::instantiateClass($typeName, $value); } - private static function createConstructorArgumentForUnionType(): mixed - { - throw JsonError::decodeFailed('Union types are not supported'); - } - private static function createConstructorArgumentForIntersectionType(): mixed { throw JsonError::decodeFailed('Intersection types are not supported'); diff --git a/tests/unit/Fixtures/HasUnionType.php b/tests/unit/Fixtures/HasUnionType.php index b8a9e4f..2d2ed7d 100644 --- a/tests/unit/Fixtures/HasUnionType.php +++ b/tests/unit/Fixtures/HasUnionType.php @@ -4,12 +4,12 @@ namespace Eventjet\Test\Unit\Json\Fixtures; -use DateInterval; -use DateTimeImmutable; +use Eventjet\Json\Field; final class HasUnionType { - public function __construct(public readonly DateTimeImmutable|DateInterval $value) - { + public function __construct( + #[Field(converter: PersonVehicleUnion::class)] public readonly Person|Vehicle $value, + ) { } } diff --git a/tests/unit/Fixtures/PersonVehicleUnion.php b/tests/unit/Fixtures/PersonVehicleUnion.php new file mode 100644 index 0000000..5e22e60 --- /dev/null +++ b/tests/unit/Fixtures/PersonVehicleUnion.php @@ -0,0 +1,29 @@ +map); }, ]; + yield 'Constructor param has union type' => [ + '{"value":{"full_name":"John"}}', + HasUnionType::class, + static function (object $object): void { + self::assertInstanceOf(HasUnionType::class, $object); + self::assertInstanceOf(Person::class, $object->value); + self::assertSame('John', $object->value->fullName); + }, + ]; } /** @@ -430,11 +441,15 @@ public function __construct(public DoesNotExist|null $nested = null) HasIntersectionType::class, 'Intersection types are not supported', ]; - // We might be able to support union types later - yield 'Constructor param has union type' => [ - '{"value":{"datetime":"2023-01-23T12:34:56+00:00"}}', - HasUnionType::class, - 'Union types are not supported', + yield 'Object union without a Field attribute' => [ + '{"value":{"full_name":"John Doe"}}', + UnionWithNoFieldAttribute::class, + 'Property "value" has a union type, but no converter is set', + ]; + yield 'Object union without a converter' => [ + '{"value":{"full_name":"John Doe"}}', + UnionWithNoConverter::class, + 'Property "value" has a union type, but no converter is set', ]; yield 'Non-array value for object constructor type' => [ '{"displayHints":"not-an-object"}',