Skip to content

Commit

Permalink
Merge pull request #1553 from idbentley/union-types-discriminated
Browse files Browse the repository at this point in the history
Union types discriminated
  • Loading branch information
goetas authored Aug 18, 2024
2 parents 58b8f72 + 007d9a3 commit b71da8c
Show file tree
Hide file tree
Showing 24 changed files with 476 additions and 46 deletions.
1 change: 1 addition & 0 deletions .github/workflows/static-analysis.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ jobs:
runs-on: "ubuntu-20.04"

strategy:
fail-fast: false
matrix:
php-version:
- "7.4"
Expand Down
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
7 changes: 7 additions & 0 deletions doc/reference/xml_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ XML Reference
<value>foo</value>
<value>bar</value>
</groups>
<union-discriminator field="foo">
<map>
<class key="a">SomeClassFQCN1</class>
<class key="b">SomeClassFQCN2</class>
<class key="c">SomeClassFQCN3</class>
</map>
</union-discriminator>
</property>
<callback-method name="foo" type="pre-serialize" />
<callback-method name="bar" type="post-serialize" />
Expand Down
6 changes: 6 additions & 0 deletions doc/reference/yml_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ YAML Reference
cdata: false
namespace: http://www.w3.org/2005/Atom
max_depth: 2
union_discriminator:
filed: foo
map:
a: SomeClassFQCN1
b: SomeClassFQCN2
c: SomeClassFQCN3
callback_methods:
pre_serialize: [foo, bar]
Expand Down
1 change: 0 additions & 1 deletion phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ parameters:
- '~Class Doctrine\\Common\\Persistence\\Proxy not found~'
- '~Class Doctrine\\ODM\\MongoDB\\PersistentCollection not found~'
- '~Class Symfony\\(Contracts|Component)\\Translation\\TranslatorInterface not found~'
- '#Constructor of class JMS\\Serializer\\Annotation\\.*? has an unused parameter#'
- '#Class JMS\\Serializer\\Annotation\\DeprecatedReadOnly extends @final class JMS\\Serializer\\Annotation\\ReadOnlyProperty.#'
- '#^Call to an undefined method Doctrine\\Persistence\\Mapping\\ClassMetadata\<object\>\:\:getFieldValue\(\)\.$#'
- '#^Call to an undefined method JMS\\Serializer\\Visitor\\DeserializationVisitorInterface\:\:getCurrentObject\(\)\.$#'
Expand Down
1 change: 1 addition & 0 deletions phpstan/ignore-by-php-version.neon.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
$includes = [];
if (PHP_VERSION_ID < 80000) {
$includes[] = __DIR__ . '/no-typed-prop.neon';
$includes[] = __DIR__ . '/no-unions.neon';
$includes[] = __DIR__ . '/no-attributes.neon';
$includes[] = __DIR__ . '/no-promoted-properties.neon';
}
Expand Down
3 changes: 3 additions & 0 deletions phpstan/no-unions.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
parameters:
excludePaths:
- %currentWorkingDirectory%/tests/Fixtures/TypedProperties/ComplexDiscriminatedUnion.php
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
65 changes: 48 additions & 17 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,76 @@ 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);
}
if (3 === count($type['params'])) {
$lookupField = $type['params'][1];
if (empty($data[$lookupField])) {
throw new NonVisitableTypeException(sprintf('Union Discriminator Field "%s" not found in data', $lookupField));
}

private function matchSimpleType(mixed $data, array $type, Context $context)
{
$dataType = $this->determineType($data, $type, $context->getFormat());
$alternativeName = null;
$unionMap = $type['params'][2];
$lookupValue = $data[$lookupField];
if (empty($unionMap[$lookupValue])) {
throw new NonVisitableTypeException(sprintf('Union Discriminator Map does not contain key "%s"', $lookupValue));
}

$finalType = [
'name' => $unionMap[$lookupValue],
'params' => [],
];

if (isset(static::$aliases[$dataType])) {
$alternativeName = static::$aliases[$dataType];
return $context->getNavigator()->accept($data, $finalType);
}

foreach ($type['params'] as $possibleType) {
if ($possibleType['name'] === $dataType || $possibleType['name'] === $alternativeName) {
foreach ($type['params'][0] as $possibleType) {
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'];
foreach ($type['params'][0] as $possibleType) {
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'], true);
}

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' => [null, $annot->field, $annot->map],
]);
}
}

Expand Down
20 changes: 9 additions & 11 deletions src/Metadata/Driver/TypedPropertiesDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,15 @@ public function __construct(DriverInterface $delegate, ?ParserInterface $typePar
* 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
private function reorderTypes(array $types): 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];
uasort($types, 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 ($order[$a['name']] ?? 7) <=> ($order[$b['name']] ?? 7);
});

return $type;
return $types;
}

private function getDefaultWhiteList(): array
Expand Down Expand Up @@ -113,10 +111,10 @@ public function loadMetadataForClass(ReflectionClass $class): ?ClassMetadata

$propertyMetadata->setType($this->typeParser->parse($type));
} elseif ($this->shouldTypeHintUnion($reflectionType)) {
$propertyMetadata->setType($this->reorderTypes([
$propertyMetadata->setType([
'name' => 'union',
'params' => array_map(fn (string $type) => $this->typeParser->parse($type), $reflectionType->getTypes()),
]));
'params' => [$this->reorderTypes(array_map(fn (string $type) => $this->typeParser->parse($type), $reflectionType->getTypes()))],
]);
}
} catch (ReflectionException $e) {
continue;
Expand Down
15 changes: 15 additions & 0 deletions src/Metadata/Driver/XmlDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,21 @@ protected function loadMetadataFromFile(\ReflectionClass $class, string $path):
$pMetadata->readOnly = $pMetadata->readOnly || $readOnlyClass;
}

if (isset($pElem->{'union-discriminator'})) {
$colConfig = $pElem->{'union-discriminator'};

$map = [];
foreach ($pElem->xpath('./union-discriminator/map/class') as $entry) {
$map[(string) $entry->attributes()->key] = (string) $entry;
}

$pMetadata->setUnionDiscriminator((string) $colConfig->attributes()->field, $map);
$pMetadata->setType([
'name' => 'union',
'params' => [null, (string) $colConfig->attributes()->field, $map],
]);
}

$getter = $pElem->attributes()->{'accessor-getter'};
$setter = $pElem->attributes()->{'accessor-setter'};
$pMetadata->setAccessor(
Expand Down
8 changes: 8 additions & 0 deletions src/Metadata/Driver/YamlDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,14 @@ protected function loadMetadataFromFile(ReflectionClass $class, string $file): ?
if (isset($pConfig['max_depth'])) {
$pMetadata->maxDepth = (int) $pConfig['max_depth'];
}

if (isset($pConfig['union_discriminator'])) {
$pMetadata->setUnionDiscriminator($pConfig['union_discriminator']['field'], $pConfig['union_discriminator']['map']);
$pMetadata->setType([
'name' => 'union',
'params' => [null, $pConfig['union_discriminator']['field'], $pConfig['union_discriminator']['map']],
]);
}
}

if (!$pMetadata->serializedName) {
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
Loading

0 comments on commit b71da8c

Please sign in to comment.