From 6532bdadfe9e2e11e4923a0b4f66ff714e1aa729 Mon Sep 17 00:00:00 2001 From: Marcin Czarnecki Date: Sun, 30 Jul 2023 13:39:45 +0200 Subject: [PATCH] feat(union): Add deserialisation of Union types from JSON --- src/Handler/UnionHandler.php | 99 +++++++++++++++++++ src/Metadata/Driver/TypedPropertiesDriver.php | 23 +++++ src/SerializerBuilder.php | 5 + .../Driver/UnionTypedPropertiesDriverTest.php | 14 ++- .../Serializer/BaseSerializationTestCase.php | 18 +--- tests/Serializer/JsonSerializationTest.php | 14 +++ tests/Serializer/XmlSerializationTest.php | 16 +++ 7 files changed, 173 insertions(+), 16 deletions(-) create mode 100644 src/Handler/UnionHandler.php diff --git a/src/Handler/UnionHandler.php b/src/Handler/UnionHandler.php new file mode 100644 index 000000000..b0d84a70a --- /dev/null +++ b/src/Handler/UnionHandler.php @@ -0,0 +1,99 @@ + 'union', + 'direction' => GraphNavigatorInterface::DIRECTION_DESERIALIZATION, + 'format' => 'json', + 'method' => 'deserializeUnion', + ], + [ + 'type' => 'union', + 'direction' => GraphNavigatorInterface::DIRECTION_DESERIALIZATION, + 'format' => 'xml', + 'method' => 'deserializeUnion', + ], + [ + 'type' => 'union', + 'format' => 'json', + 'direction' => GraphNavigatorInterface::DIRECTION_SERIALIZATION, + 'method' => 'serializeUnion', + ], + [ + 'type' => 'union', + 'format' => 'xml', + 'direction' => GraphNavigatorInterface::DIRECTION_SERIALIZATION, + 'method' => 'serializeUnion', + ], + ]; + } + + public function serializeUnion( + SerializationVisitorInterface $visitor, + $data, + array $type, + SerializationContext $context + ) { + return $this->matchSimpleType($data, $type, $context); + } + + /** + * @param int|string|\SimpleXMLElement $data + * @param array $type + */ + public function deserializeUnion(DeserializationVisitorInterface $visitor, $data, array $type, DeserializationContext $context) + { + if ($data instanceof \SimpleXMLElement) { + throw new RuntimeException('XML deserialisation into union types is not supported yet.'); + } + + return $this->matchSimpleType($data, $type, $context); + } + + private function matchSimpleType($data, array $type, Context $context) + { + $dataType = gettype($data); + $alternativeName = null; + switch ($dataType) { + case 'boolean': + $alternativeName = 'bool'; + break; + case 'integer': + $alternativeName = 'int'; + break; + case 'double': + $alternativeName = 'float'; + break; + case 'array': + case 'string': + break; + default: + throw new RuntimeException(); + } + + foreach ($type['params'] as $possibleType) { + if ($possibleType['name'] === $dataType || $possibleType['name'] === $alternativeName) { + return $context->getNavigator()->accept($data, $possibleType); + } + } + } +} diff --git a/src/Metadata/Driver/TypedPropertiesDriver.php b/src/Metadata/Driver/TypedPropertiesDriver.php index c3ebbefe4..951199b23 100644 --- a/src/Metadata/Driver/TypedPropertiesDriver.php +++ b/src/Metadata/Driver/TypedPropertiesDriver.php @@ -93,6 +93,11 @@ public function loadMetadataForClass(ReflectionClass $class): ?ClassMetadata $type = $reflectionType->getName(); $propertyMetadata->setType($this->typeParser->parse($type)); + } elseif ($this->shouldTypeHintUnion($reflectionType)) { + $propertyMetadata->setType([ + 'name' => 'union', + 'params' => array_map(fn (string $type) => $this->typeParser->parse($type), $reflectionType->getTypes()), + ]); } } catch (ReflectionException $e) { continue; @@ -139,4 +144,22 @@ private function shouldTypeHint(?ReflectionType $reflectionType): bool return class_exists($reflectionType->getName()) || interface_exists($reflectionType->getName()); } + + /** + * @phpstan-assert-if-true \ReflectionUnionType $reflectionType + */ + private function shouldTypeHintUnion(?ReflectionType $reflectionType) + { + if (!$reflectionType instanceof \ReflectionUnionType) { + return false; + } + + foreach ($reflectionType->getTypes() as $type) { + if ($this->shouldTypeHint($type)) { + return true; + } + } + + return false; + } } diff --git a/src/SerializerBuilder.php b/src/SerializerBuilder.php index 47fa2d299..2fe6281c4 100644 --- a/src/SerializerBuilder.php +++ b/src/SerializerBuilder.php @@ -38,6 +38,7 @@ use JMS\Serializer\Handler\HandlerRegistryInterface; use JMS\Serializer\Handler\IteratorHandler; use JMS\Serializer\Handler\StdClassHandler; +use JMS\Serializer\Handler\UnionHandler; use JMS\Serializer\Naming\CamelCaseNamingStrategy; use JMS\Serializer\Naming\PropertyNamingStrategyInterface; use JMS\Serializer\Naming\SerializedNameAnnotationStrategy; @@ -283,6 +284,10 @@ public function addDefaultHandlers(): self $this->handlerRegistry->registerSubscribingHandler(new EnumHandler()); } + if (PHP_VERSION_ID >= 80000) { + $this->handlerRegistry->registerSubscribingHandler(new UnionHandler()); + } + return $this; } diff --git a/tests/Metadata/Driver/UnionTypedPropertiesDriverTest.php b/tests/Metadata/Driver/UnionTypedPropertiesDriverTest.php index f4d1363f6..150bf2245 100644 --- a/tests/Metadata/Driver/UnionTypedPropertiesDriverTest.php +++ b/tests/Metadata/Driver/UnionTypedPropertiesDriverTest.php @@ -27,7 +27,19 @@ public function testInferUnionTypesShouldResultInNoType() $m = $this->resolve(UnionTypedProperties::class); self::assertEquals( - null, + [ + 'name' => 'union', + 'params' => [ + [ + 'name' => 'string', + 'params' => [], + ], + [ + 'name' => 'int', + 'params' => [], + ], + ], + ], $m->propertyMetadata['data']->type ); } diff --git a/tests/Serializer/BaseSerializationTestCase.php b/tests/Serializer/BaseSerializationTestCase.php index c30ba90af..9ce0d5cdb 100644 --- a/tests/Serializer/BaseSerializationTestCase.php +++ b/tests/Serializer/BaseSerializationTestCase.php @@ -30,6 +30,7 @@ use JMS\Serializer\Handler\IteratorHandler; use JMS\Serializer\Handler\StdClassHandler; use JMS\Serializer\Handler\SymfonyUidHandler; +use JMS\Serializer\Handler\UnionHandler; use JMS\Serializer\Metadata\Driver\TypedPropertiesDriver; use JMS\Serializer\SerializationContext; use JMS\Serializer\Serializer; @@ -1989,20 +1990,6 @@ public function testSerializingUnionTypedProperties() self::assertEquals(static::getContent('data_integer'), $this->serialize($object)); } - public function testThrowingExceptionWhenDeserializingUnionProperties() - { - if (PHP_VERSION_ID < 80000) { - $this->markTestSkipped(sprintf('%s requires PHP 8.0', TypedPropertiesDriver::class)); - - return; - } - - $this->expectException(RuntimeException::class); - - $object = new TypedProperties\UnionTypedProperties(10000); - self::assertEquals($object, $this->deserialize(static::getContent('data_integer'), TypedProperties\UnionTypedProperties::class)); - } - public function testSerializingUnionDocBlockTypesProperties() { $object = new UnionTypedDocBLockProperty(10000); @@ -2021,7 +2008,7 @@ public function testThrowingExceptionWhenDeserializingUnionDocBlockTypes() $this->expectException(RuntimeException::class); $object = new UnionTypedDocBLockProperty(10000); - self::assertEquals($object, $this->deserialize(static::getContent('data_integer'), TypedProperties\UnionTypedProperties::class)); + self::assertEquals($object, $this->deserialize(static::getContent('data_integer'), UnionTypedDocBLockProperty::class)); } public function testIterable(): void @@ -2138,6 +2125,7 @@ protected function setUp(): void $this->handlerRegistry->registerSubscribingHandler(new IteratorHandler()); $this->handlerRegistry->registerSubscribingHandler(new SymfonyUidHandler()); $this->handlerRegistry->registerSubscribingHandler(new EnumHandler()); + $this->handlerRegistry->registerSubscribingHandler(new UnionHandler()); $this->handlerRegistry->registerHandler( GraphNavigatorInterface::DIRECTION_SERIALIZATION, 'AuthorList', diff --git a/tests/Serializer/JsonSerializationTest.php b/tests/Serializer/JsonSerializationTest.php index af8c57996..12cebabd3 100644 --- a/tests/Serializer/JsonSerializationTest.php +++ b/tests/Serializer/JsonSerializationTest.php @@ -10,6 +10,7 @@ use JMS\Serializer\EventDispatcher\ObjectEvent; use JMS\Serializer\Exception\RuntimeException; use JMS\Serializer\GraphNavigatorInterface; +use JMS\Serializer\Metadata\Driver\TypedPropertiesDriver; use JMS\Serializer\SerializationContext; use JMS\Serializer\Tests\Fixtures\Author; use JMS\Serializer\Tests\Fixtures\AuthorList; @@ -17,6 +18,7 @@ use JMS\Serializer\Tests\Fixtures\ObjectWithEmptyArrayAndHash; use JMS\Serializer\Tests\Fixtures\ObjectWithInlineArray; use JMS\Serializer\Tests\Fixtures\Tag; +use JMS\Serializer\Tests\Fixtures\TypedProperties\UnionTypedProperties; use JMS\Serializer\Visitor\Factory\JsonSerializationVisitorFactory; use JMS\Serializer\Visitor\SerializationVisitorInterface; @@ -434,6 +436,18 @@ public static function getTypeHintedArraysAndStdClass() ]; } + public function testDeserializingUnionProperties() + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped(sprintf('%s requires PHP 8.0', TypedPropertiesDriver::class)); + + return; + } + + $object = new UnionTypedProperties(10000); + self::assertEquals($object, $this->deserialize(static::getContent('data_integer'), UnionTypedProperties::class)); + } + /** * @param array $array * @param string $expected diff --git a/tests/Serializer/XmlSerializationTest.php b/tests/Serializer/XmlSerializationTest.php index 3701d95e3..7495b88c9 100644 --- a/tests/Serializer/XmlSerializationTest.php +++ b/tests/Serializer/XmlSerializationTest.php @@ -12,6 +12,7 @@ use JMS\Serializer\Handler\DateHandler; use JMS\Serializer\Handler\HandlerRegistryInterface; use JMS\Serializer\Metadata\ClassMetadata; +use JMS\Serializer\Metadata\Driver\TypedPropertiesDriver; use JMS\Serializer\Metadata\StaticPropertyMetadata; use JMS\Serializer\SerializationContext; use JMS\Serializer\SerializerBuilder; @@ -43,6 +44,7 @@ use JMS\Serializer\Tests\Fixtures\PersonLocation; use JMS\Serializer\Tests\Fixtures\SimpleClassObject; use JMS\Serializer\Tests\Fixtures\SimpleSubClassObject; +use JMS\Serializer\Tests\Fixtures\TypedProperties\UnionTypedProperties; use JMS\Serializer\Visitor\Factory\XmlDeserializationVisitorFactory; use JMS\Serializer\Visitor\Factory\XmlSerializationVisitorFactory; use JMS\Serializer\XmlSerializationVisitor; @@ -597,6 +599,20 @@ public function testSerialisationWithPrecisionForFloat(): void ); } + public function testThrowingExceptionWhenDeserializingUnionProperties() + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped(sprintf('%s requires PHP 8.0', TypedPropertiesDriver::class)); + + return; + } + + $this->expectException(RuntimeException::class); + + $object = new UnionTypedProperties(10000); + self::assertEquals($object, $this->deserialize(static::getContent('data_integer'), UnionTypedProperties::class)); + } + private function xpathFirstToString(\SimpleXMLElement $xml, $xpath) { $nodes = $xml->xpath($xpath);