Skip to content

Commit

Permalink
Merge branch 'union-types-discriminated' of github.com:idbentley/seri…
Browse files Browse the repository at this point in the history
…alizer into idbentley-union-types-discriminated
  • Loading branch information
goetas committed Aug 13, 2024
2 parents 58b8f72 + 3f4f69f commit df02675
Show file tree
Hide file tree
Showing 12 changed files with 372 additions and 37 deletions.
18 changes: 18 additions & 0 deletions doc/reference/annotations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ JMS serializer now supports PHP 8 attributes, with a few caveats:
- There is an edge case when setting this exact serialization group ``#[Groups(['value' => 'any value here'])]``.
(when there is only one item in th serialization groups array and has as key ``value`` the attribute will not work as expected,
please use the alternative syntax ``#[Groups(groups: ['value' => 'any value here'])]`` that works with no issues),
- Some support for unions exists. For unions of primitive types, the system will try to resolve them automatically. For
classes that contain union attributes, the ``#[UnionDiscriminator]`` attribute must be used to specify the type of the union.

Converting your annotations to attributes
-----------------------------------------
Expand Down Expand Up @@ -384,6 +386,22 @@ to the least super type:
`groups` is optional and is used as exclusion policy.
#[UnionDiscriminator]
~~~~~~~~~~~~~~~~~~~~~

This attribute allows deserialization of unions. The ``#[UnionDiscriminator]`` attribute has to be applied
to an attribute that can be one of many types.

.. code-block :: php
class Vehicle {
#[UnionDiscriminator(field: 'typeField', map: ['manual' => 'FullyQualified/Path/Manual', 'automatic' => 'FullyQualified/Path/Automatic'])]
private Manual|Automatic $transmission;
}
In the case of this example, both Manual and Automatic should contain a string attribute named `typeField`. The value of that field will be passed
to the `map` option to determine which class to instantiate.

#[Type]
~~~~~~~
This attribute can be defined on a property to specify the type of that property.
Expand Down
26 changes: 26 additions & 0 deletions src/Annotation/UnionDiscriminator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace JMS\Serializer\Annotation;

/**
* @Annotation
* @Target({"PROPERTY"})
*/
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class UnionDiscriminator implements SerializerAttribute
{
use AnnotationUtilsTrait;

/** @var array<string> */
public $map = [];

/** @var string */
public $field = 'type';

public function __construct(array $values = [], string $field = 'type', array $map = [])
{
$this->loadAnnotationParameters(get_defined_vars());
}
}
10 changes: 10 additions & 0 deletions src/GraphNavigator/SerializationGraphNavigator.php
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,16 @@ public function accept($data, ?array $type = null)

throw new RuntimeException($msg);

case 'union':
if (null !== $handler = $this->handlerRegistry->getHandler(GraphNavigatorInterface::DIRECTION_SERIALIZATION, $type['name'], $this->format)) {
try {
return \call_user_func($handler, $this->visitor, $data, $type, $this->context);
} catch (SkipHandlerException $e) {
// Skip handler, fallback to default behavior
}
}

break;
default:
if (null !== $data) {
if ($this->context->isVisiting($data)) {
Expand Down
84 changes: 65 additions & 19 deletions src/Handler/UnionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use JMS\Serializer\Context;
use JMS\Serializer\DeserializationContext;
use JMS\Serializer\Exception\NonVisitableTypeException;
use JMS\Serializer\Exception\RuntimeException;
use JMS\Serializer\GraphNavigatorInterface;
use JMS\Serializer\SerializationContext;
Expand Down Expand Up @@ -47,46 +48,91 @@ public function serializeUnion(
mixed $data,
array $type,
SerializationContext $context
) {
return $this->matchSimpleType($data, $type, $context);
): mixed {
if ($this->isPrimitiveType(gettype($data))) {
return $this->matchSimpleType($data, $type, $context);
} else {
$resolvedType = [
'name' => get_class($data),
'params' => [],
];

return $context->getNavigator()->accept($data, $resolvedType);
}
}

public function deserializeUnion(DeserializationVisitorInterface $visitor, mixed $data, array $type, DeserializationContext $context)
public function deserializeUnion(DeserializationVisitorInterface $visitor, mixed $data, array $type, DeserializationContext $context): mixed
{
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];
$finalType = null;
if (2 === count($type['params'])) {
if (!is_array($type['params'][0]) || !array_key_exists('name', $type['params'][0])) {
$lookupField = $type['params'][0];
$unionMap = $type['params'][1];

if (!array_key_exists($lookupField, $data)) {
throw new NonVisitableTypeException('Union Discriminator Field \'' . $lookupField . '\' not found in data');
}

$lkup = $data[$lookupField];
if (!empty($unionMap)) {
if (array_key_exists($lkup, $unionMap)) {
$finalType = [
'name' => $unionMap[$lkup],
'params' => [],
];
} else {
throw new NonVisitableTypeException('Union Discriminator Map does not contain key \'' . $lkup . '\'');
}
} else {
$finalType = [
'name' => $lkup,
'params' => [],
];
}
}
}

foreach ($type['params'] as $possibleType) {
if ($possibleType['name'] === $dataType || $possibleType['name'] === $alternativeName) {
return $context->getNavigator()->accept($data, $possibleType);
if (null !== $finalType && null !== $finalType['name']) {
return $context->getNavigator()->accept($data, $finalType);
} else {
foreach ($type['params'] as $possibleType) {
$finalType = null;

if ($this->isPrimitiveType($possibleType['name']) && $this->testPrimitive($data, $possibleType['name'], $context->getFormat())) {
return $context->getNavigator()->accept($data, $possibleType);
}
}
}

return null;
}

private function determineType(mixed $data, array $type, string $format): ?string
private function matchSimpleType(mixed $data, array $type, Context $context): mixed
{
foreach ($type['params'] as $possibleType) {
if ($this->testPrimitive($data, $possibleType['name'], $format)) {
return $possibleType['name'];
if ($this->isPrimitiveType($possibleType['name']) && !$this->testPrimitive($data, $possibleType['name'], $context->getFormat())) {
continue;
}

try {
return $context->getNavigator()->accept($data, $possibleType);
} catch (NonVisitableTypeException $e) {
continue;
}
}

return null;
}

private function isPrimitiveType(string $type): bool
{
return in_array($type, ['int', 'integer', 'float', 'double', 'bool', 'boolean', 'string']);
}

private function testPrimitive(mixed $data, string $type, string $format): bool
{
switch ($type) {
Expand Down
7 changes: 7 additions & 0 deletions src/Metadata/Driver/AnnotationOrAttributeDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use JMS\Serializer\Annotation\Since;
use JMS\Serializer\Annotation\SkipWhenEmpty;
use JMS\Serializer\Annotation\Type;
use JMS\Serializer\Annotation\UnionDiscriminator;
use JMS\Serializer\Annotation\Until;
use JMS\Serializer\Annotation\VirtualProperty;
use JMS\Serializer\Annotation\XmlAttribute;
Expand Down Expand Up @@ -258,6 +259,12 @@ public function loadMetadataForClass(\ReflectionClass $class): ?BaseClassMetadat
$propertyMetadata->xmlAttributeMap = true;
} elseif ($annot instanceof MaxDepth) {
$propertyMetadata->maxDepth = $annot->depth;
} elseif ($annot instanceof UnionDiscriminator) {
$propertyMetadata->setUnionDiscriminator($annot->field, $annot->map);
$propertyMetadata->setType([
'name' => 'union',
'params' => [$annot->field, $annot->map],
]);
}
}

Expand Down
40 changes: 23 additions & 17 deletions src/Metadata/Driver/TypedPropertiesDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,24 +102,30 @@ public function loadMetadataForClass(ReflectionClass $class): ?ClassMetadata
foreach ($classMetadata->propertyMetadata as $propertyMetadata) {
// If the inner driver provides a type, don't guess anymore.
if ($propertyMetadata->type) {
continue;
}

try {
$reflectionType = $this->getReflectionType($propertyMetadata);

if ($this->shouldTypeHint($reflectionType)) {
$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()),
]));
if ('union' === $propertyMetadata->type['name']) {
// If this union is discriminated, the params will not contain types.
// If the union isn't discriminated, the params will contain types, and we should reorder them
if (is_array($propertyMetadata->type['params'][0]) && $propertyMetadata->type['params'][0]['name']) {
$propertyMetadata->setType($this->reorderTypes($propertyMetadata->type));
}
}
} else {
try {
$reflectionType = $this->getReflectionType($propertyMetadata);

if ($this->shouldTypeHint($reflectionType)) {
$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;
}
} catch (ReflectionException $e) {
continue;
}
}

Expand Down
20 changes: 20 additions & 0 deletions src/Metadata/PropertyMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ class PropertyMetadata extends BasePropertyMetadata
*/
public $serializedName;

/**
* @var string|null
*/
public $unionDiscriminatorField;

/**
* @var array<string, string>|null
*/
public $unionDiscriminatorMap;

/**
* @var array|null
*/
Expand Down Expand Up @@ -196,6 +206,12 @@ public function setAccessor(string $type, ?string $getter = null, ?string $sette
$this->setter = $setter;
}

public function setUnionDiscriminator(string $field, array $map): void
{
$this->unionDiscriminatorField = $field;
$this->unionDiscriminatorMap = $map;
}

public function setType(array $type): void
{
$this->type = $type;
Expand Down Expand Up @@ -224,6 +240,8 @@ protected function serializeToArray(): array
$this->untilVersion,
$this->groups,
$this->serializedName,
$this->unionDiscriminatorField,
$this->unionDiscriminatorMap,
$this->type,
$this->xmlCollection,
$this->xmlCollectionInline,
Expand Down Expand Up @@ -258,6 +276,8 @@ protected function unserializeFromArray(array $data): void
$this->untilVersion,
$this->groups,
$this->serializedName,
$this->unionDiscriminatorField,
$this->unionDiscriminatorMap,
$this->type,
$this->xmlCollection,
$this->xmlCollectionInline,
Expand Down
42 changes: 42 additions & 0 deletions tests/Fixtures/DiscriminatedAuthor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace JMS\Serializer\Tests\Fixtures;

use JMS\Serializer\Annotation\SerializedName;
use JMS\Serializer\Annotation\Type;

class DiscriminatedAuthor
{
/**
* @Type("string")
* @SerializedName("full_name")
*/
#[Type(name: 'string')]
#[SerializedName(name: 'full_name')]
private $name;

/**
* @Type("string")
* @SerializedName("objectType")
*/
#[Type(name: 'string')]
#[SerializedName(name: 'objectType')]
private $objectType = 'author';

public function __construct($name)
{
$this->name = $name;
}

public function getName()
{
return $this->name;
}

public function getObjectType()
{
return $this->objectType;
}
}
Loading

0 comments on commit df02675

Please sign in to comment.