Skip to content

Commit

Permalink
Merge pull request #1546 from idbentley/union-types-deserialisation
Browse files Browse the repository at this point in the history
Primitive union types deserialisation
  • Loading branch information
scyzoryck authored Jul 9, 2024
2 parents 4478166 + ffa7b5e commit d0a24b7
Show file tree
Hide file tree
Showing 11 changed files with 260 additions and 25 deletions.
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 ($order[$a['name']] ?? 7) <=> ($order[$b['name']] ?? 7);
});
}

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

0 comments on commit d0a24b7

Please sign in to comment.