diff --git a/src/Handler/UnionHandler.php b/src/Handler/UnionHandler.php new file mode 100644 index 000000000..ad7f4339a --- /dev/null +++ b/src/Handler/UnionHandler.php @@ -0,0 +1,111 @@ + 'bool', 'integer' => 'int', 'double' => 'float']; + + /** + * {@inheritdoc} + */ + public static function getSubscribingMethods() + { + $methods = []; + $formats = ['json', 'xml']; + + foreach ($formats as $format) { + $methods[] = [ + 'type' => 'union', + 'format' => $format, + 'direction' => GraphNavigatorInterface::DIRECTION_DESERIALIZATION, + 'method' => 'deserializeUnion', + ]; + $methods[] = [ + 'type' => 'union', + 'format' => $format, + 'direction' => GraphNavigatorInterface::DIRECTION_SERIALIZATION, + 'method' => 'serializeUnion', + ]; + } + + return $methods; + } + + public function serializeUnion( + SerializationVisitorInterface $visitor, + mixed $data, + array $type, + SerializationContext $context + ) { + return $this->matchSimpleType($data, $type, $context); + } + + public function deserializeUnion(DeserializationVisitorInterface $visitor, mixed $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(mixed $data, array $type, Context $context) + { + $dataType = $this->determineType($data, $type, $context->getFormat()); + $alternativeName = null; + + if (isset(static::$aliases[$dataType])) { + $alternativeName = static::$aliases[$dataType]; + } + + foreach ($type['params'] as $possibleType) { + if ($possibleType['name'] === $dataType || $possibleType['name'] === $alternativeName) { + return $context->getNavigator()->accept($data, $possibleType); + } + } + } + + private function determineType(mixed $data, array $type, string $format): ?string + { + foreach ($type['params'] as $possibleType) { + if ($this->testPrimitive($data, $possibleType['name'], $format)) { + return $possibleType['name']; + } + } + + return null; + } + + private function testPrimitive(mixed $data, string $type, string $format): bool + { + switch ($type) { + case 'integer': + case 'int': + return (string) (int) $data === (string) $data; + + case 'double': + case 'float': + return (string) (float) $data === (string) $data; + + case 'bool': + case 'boolean': + return (string) (bool) $data === (string) $data; + + case 'string': + return (string) $data === (string) $data; + } + + return false; + } +} diff --git a/src/Metadata/Driver/TypedPropertiesDriver.php b/src/Metadata/Driver/TypedPropertiesDriver.php index ed41807eb..99f93cbaa 100644 --- a/src/Metadata/Driver/TypedPropertiesDriver.php +++ b/src/Metadata/Driver/TypedPropertiesDriver.php @@ -47,6 +47,29 @@ public function __construct(DriverInterface $delegate, ?ParserInterface $typePar $this->allowList = array_merge($allowList, $this->getDefaultWhiteList()); } + /** + * ReflectionUnionType::getTypes() returns the types sorted according to these rules: + * - Classes, interfaces, traits, iterable (replaced by Traversable), ReflectionIntersectionType objects, parent and self: + * these types will be returned first, in the order in which they were declared. + * - static and all built-in types (iterable replaced by array) will come next. They will always be returned in this order: + * static, callable, array, string, int, float, bool (or false or true), null. + * + * For determining types of primitives, it is necessary to reorder primitives so that they are tested from lowest specificity to highest: + * i.e. null, true, false, int, float, bool, string + */ + private function reorderTypes(array $type): array + { + if ($type['params']) { + uasort($type['params'], static function ($a, $b) { + $order = ['null' => 0, 'true' => 1, 'false' => 2, 'bool' => 3, 'int' => 4, 'float' => 5, 'string' => 6]; + + return ($order[$a['name']] ?? 7) <=> ($order[$b['name']] ?? 7); + }); + } + + return $type; + } + private function getDefaultWhiteList(): array { return [ @@ -89,6 +112,11 @@ public function loadMetadataForClass(ReflectionClass $class): ?ClassMetadata $type = $reflectionType->getName(); $propertyMetadata->setType($this->typeParser->parse($type)); + } elseif ($this->shouldTypeHintUnion($reflectionType)) { + $propertyMetadata->setType($this->reorderTypes([ + 'name' => 'union', + 'params' => array_map(fn (string $type) => $this->typeParser->parse($type), $reflectionType->getTypes()), + ])); } } catch (ReflectionException $e) { continue; @@ -135,4 +163,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 996078599..f5d9c5404 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/Fixtures/DocBlockType/UnionTypedDocBLockProperty.php b/tests/Fixtures/DocBlockType/UnionTypedDocBlockProperty.php similarity index 75% rename from tests/Fixtures/DocBlockType/UnionTypedDocBLockProperty.php rename to tests/Fixtures/DocBlockType/UnionTypedDocBlockProperty.php index bee1063ea..f7720b504 100644 --- a/tests/Fixtures/DocBlockType/UnionTypedDocBLockProperty.php +++ b/tests/Fixtures/DocBlockType/UnionTypedDocBlockProperty.php @@ -4,10 +4,10 @@ namespace JMS\Serializer\Tests\Fixtures\DocBlockType; -class UnionTypedDocBLockProperty +class UnionTypedDocBlockProperty { /** - * @var int|string + * @var int|bool|float|string */ private $data; diff --git a/tests/Fixtures/TypedProperties/UnionTypedProperties.php b/tests/Fixtures/TypedProperties/UnionTypedProperties.php index 75bc6379a..86ab59b6e 100644 --- a/tests/Fixtures/TypedProperties/UnionTypedProperties.php +++ b/tests/Fixtures/TypedProperties/UnionTypedProperties.php @@ -6,7 +6,7 @@ class UnionTypedProperties { - private string|int $data; + private int|bool|float|string $data; public function __construct($data) { diff --git a/tests/Metadata/Driver/DocBlockDriverTest.php b/tests/Metadata/Driver/DocBlockDriverTest.php index 414aa4a78..b772333d6 100644 --- a/tests/Metadata/Driver/DocBlockDriverTest.php +++ b/tests/Metadata/Driver/DocBlockDriverTest.php @@ -46,7 +46,7 @@ use JMS\Serializer\Tests\Fixtures\DocBlockType\Phpstan\ProductType; use JMS\Serializer\Tests\Fixtures\DocBlockType\SingleClassFromDifferentNamespaceTypeHint; use JMS\Serializer\Tests\Fixtures\DocBlockType\SingleClassFromGlobalNamespaceTypeHint; -use JMS\Serializer\Tests\Fixtures\DocBlockType\UnionTypedDocBLockProperty; +use JMS\Serializer\Tests\Fixtures\DocBlockType\UnionTypedDocBlockProperty; use JMS\Serializer\Tests\Fixtures\DocBlockType\VirtualPropertyGetter; use Metadata\Driver\DriverChain; use PHPUnit\Framework\TestCase; @@ -289,7 +289,7 @@ public function testInferTypeForNonCollectionFromDifferentNamespaceType() public function testInferTypeForNonUnionDocblockType() { - $m = $this->resolve(UnionTypedDocBLockProperty::class); + $m = $this->resolve(UnionTypedDocBlockProperty::class); self::assertEquals( null, diff --git a/tests/Metadata/Driver/UnionTypedPropertiesDriverTest.php b/tests/Metadata/Driver/UnionTypedPropertiesDriverTest.php index cd3257aa4..138efc1b4 100644 --- a/tests/Metadata/Driver/UnionTypedPropertiesDriverTest.php +++ b/tests/Metadata/Driver/UnionTypedPropertiesDriverTest.php @@ -24,12 +24,32 @@ protected function setUp(): void } } - public function testInferUnionTypesShouldResultInNoType() + public function testInferUnionTypesShouldResultInManyTypes() { $m = $this->resolve(UnionTypedProperties::class); self::assertEquals( - null, + [ + 'name' => 'union', + 'params' => [ + [ + 'name' => 'string', + 'params' => [], + ], + [ + 'name' => 'int', + 'params' => [], + ], + [ + 'name' => 'float', + 'params' => [], + ], + [ + 'name' => 'bool', + 'params' => [], + ], + ], + ], $m->propertyMetadata['data']->type, ); } diff --git a/tests/Serializer/BaseSerializationTestCase.php b/tests/Serializer/BaseSerializationTestCase.php index d9acd9574..52626e14e 100644 --- a/tests/Serializer/BaseSerializationTestCase.php +++ b/tests/Serializer/BaseSerializationTestCase.php @@ -31,6 +31,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; @@ -65,7 +66,7 @@ use JMS\Serializer\Tests\Fixtures\Discriminator\Serialization\User; use JMS\Serializer\Tests\Fixtures\Discriminator\Vehicle; use JMS\Serializer\Tests\Fixtures\DiscriminatorGroup\Car as DiscriminatorGroupCar; -use JMS\Serializer\Tests\Fixtures\DocBlockType\UnionTypedDocBLockProperty; +use JMS\Serializer\Tests\Fixtures\DocBlockType\UnionTypedDocBlockProperty; use JMS\Serializer\Tests\Fixtures\ExclusionStrategy\AlwaysExcludeExclusionStrategy; use JMS\Serializer\Tests\Fixtures\FirstClassListCollection; use JMS\Serializer\Tests\Fixtures\Garage; @@ -1977,25 +1978,15 @@ 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); + $object = new UnionTypedDocBlockProperty(10000); self::assertEquals(static::getContent('data_integer'), $this->serialize($object)); + + $object = new UnionTypedDocBlockProperty(1.236); + + self::assertEquals(static::getContent('data_float'), $this->serialize($object)); } public function testThrowingExceptionWhenDeserializingUnionDocBlockTypes() @@ -2008,8 +1999,8 @@ public function testThrowingExceptionWhenDeserializingUnionDocBlockTypes() $this->expectException(RuntimeException::class); - $object = new UnionTypedDocBLockProperty(10000); - self::assertEquals($object, $this->deserialize(static::getContent('data_integer'), TypedProperties\UnionTypedProperties::class)); + $object = new UnionTypedDocBlockProperty(10000); + $deserialized = $this->deserialize(static::getContent('data_integer'), UnionTypedDocBlockProperty::class); } public function testIterable(): void @@ -2126,6 +2117,10 @@ protected function setUp(): void $this->handlerRegistry->registerSubscribingHandler(new IteratorHandler()); $this->handlerRegistry->registerSubscribingHandler(new SymfonyUidHandler()); $this->handlerRegistry->registerSubscribingHandler(new EnumHandler()); + if (PHP_VERSION_ID >= 80000) { + $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 4a2fcd1d9..25cb0cf17 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; @@ -18,6 +19,7 @@ use JMS\Serializer\Tests\Fixtures\ObjectWithInlineArray; use JMS\Serializer\Tests\Fixtures\ObjectWithObjectProperty; use JMS\Serializer\Tests\Fixtures\Tag; +use JMS\Serializer\Tests\Fixtures\TypedProperties\UnionTypedProperties; use JMS\Serializer\Visitor\Factory\JsonSerializationVisitorFactory; use JMS\Serializer\Visitor\SerializationVisitorInterface; use PHPUnit\Framework\Attributes\DataProvider; @@ -142,6 +144,9 @@ protected static function getContent($key) $outputs['uninitialized_typed_props'] = '{"virtual_role":{},"id":1,"role":{},"tags":[]}'; $outputs['custom_datetimeinterface'] = '{"custom":"2021-09-07"}'; $outputs['data_integer'] = '{"data":10000}'; + $outputs['data_float'] = '{"data":1.236}'; + $outputs['data_bool'] = '{"data":false}'; + $outputs['data_string'] = '{"data":"foo"}'; $outputs['uid'] = '"66b3177c-e03b-4a22-9dee-ddd7d37a04d5"'; $outputs['object_with_enums'] = '{"ordinary":"Clubs","backed_value":"C","backed_without_param":"C","ordinary_array":["Clubs","Spades"],"backed_array":["C","H"],"backed_array_without_param":["C","H"],"ordinary_auto_detect":"Clubs","backed_auto_detect":"C","backed_int_auto_detect":3,"backed_int":3,"backed_name":"C","backed_int_forced_str":3}'; $outputs['object_with_autodetect_enums'] = '{"ordinary_array_auto_detect":["Clubs","Spades"],"backed_array_auto_detect":["C","H"],"mixed_array_auto_detect":["Clubs","H"]}'; @@ -423,6 +428,39 @@ 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)); + + $object = new UnionTypedProperties(1.236); + self::assertEquals($object, $this->deserialize(static::getContent('data_float'), UnionTypedProperties::class)); + + $object = new UnionTypedProperties(false); + self::assertEquals($object, $this->deserialize(static::getContent('data_bool'), UnionTypedProperties::class)); + + $object = new UnionTypedProperties('foo'); + self::assertEquals($object, $this->deserialize(static::getContent('data_string'), UnionTypedProperties::class)); + } + + public function testSerializeUnionProperties() + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped(sprintf('%s requires PHP 8.0', TypedPropertiesDriver::class)); + + return; + } + + $serialized = $this->serialize(new UnionTypedProperties(10000)); + self::assertEquals(static::getContent('data_integer'), $serialized); + } + /** * @dataProvider getTypeHintedArraysAndStdClass */ diff --git a/tests/Serializer/XmlSerializationTest.php b/tests/Serializer/XmlSerializationTest.php index 201a748cf..833a2a06f 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; @@ -604,6 +606,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); diff --git a/tests/Serializer/xml/data_float.xml b/tests/Serializer/xml/data_float.xml new file mode 100644 index 000000000..d2da18a86 --- /dev/null +++ b/tests/Serializer/xml/data_float.xml @@ -0,0 +1,4 @@ + + + 1.236 +