Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Union types deserialisation #1546

Merged
merged 12 commits into from
Jul 9, 2024
111 changes: 111 additions & 0 deletions src/Handler/UnionHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

namespace JMS\Serializer\Handler;

use JMS\Serializer\Context;
use JMS\Serializer\DeserializationContext;
use JMS\Serializer\Exception\RuntimeException;
use JMS\Serializer\GraphNavigatorInterface;
use JMS\Serializer\SerializationContext;
use JMS\Serializer\Visitor\DeserializationVisitorInterface;
use JMS\Serializer\Visitor\SerializationVisitorInterface;

final class UnionHandler implements SubscribingHandlerInterface
{
private static $aliases = ['boolean' => '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;
}
}
46 changes: 46 additions & 0 deletions src/Metadata/Driver/TypedPropertiesDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 (array_key_exists($a['name'], $order) ? $order[$a['name']] : 7) <=> (array_key_exists($b['name'], $order) ? $order[$b['name']] : 7);
idbentley marked this conversation as resolved.
Show resolved Hide resolved
});
}

return $type;
}

private function getDefaultWhiteList(): array
{
return [
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
5 changes: 5 additions & 0 deletions src/SerializerBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

namespace JMS\Serializer\Tests\Fixtures\DocBlockType;

class UnionTypedDocBLockProperty
class UnionTypedDocBlockProperty
{
/**
* @var int|string
* @var int|bool|float|string
*/
private $data;

Expand Down
2 changes: 1 addition & 1 deletion tests/Fixtures/TypedProperties/UnionTypedProperties.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

class UnionTypedProperties
{
private string|int $data;
private int|bool|float|string $data;

public function __construct($data)
{
Expand Down
4 changes: 2 additions & 2 deletions tests/Metadata/Driver/DocBlockDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -289,7 +289,7 @@ public function testInferTypeForNonCollectionFromDifferentNamespaceType()

public function testInferTypeForNonUnionDocblockType()
{
$m = $this->resolve(UnionTypedDocBLockProperty::class);
$m = $this->resolve(UnionTypedDocBlockProperty::class);

self::assertEquals(
null,
Expand Down
24 changes: 22 additions & 2 deletions tests/Metadata/Driver/UnionTypedPropertiesDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Expand Down
31 changes: 13 additions & 18 deletions tests/Serializer/BaseSerializationTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading