Skip to content

Commit b71da8c

Browse files
authored
Merge pull request #1553 from idbentley/union-types-discriminated
Union types discriminated
2 parents 58b8f72 + 007d9a3 commit b71da8c

24 files changed

+476
-46
lines changed

.github/workflows/static-analysis.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ jobs:
1212
runs-on: "ubuntu-20.04"
1313

1414
strategy:
15+
fail-fast: false
1516
matrix:
1617
php-version:
1718
- "7.4"

doc/reference/annotations.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ JMS serializer now supports PHP 8 attributes, with a few caveats:
99
- There is an edge case when setting this exact serialization group ``#[Groups(['value' => 'any value here'])]``.
1010
(when there is only one item in th serialization groups array and has as key ``value`` the attribute will not work as expected,
1111
please use the alternative syntax ``#[Groups(groups: ['value' => 'any value here'])]`` that works with no issues),
12+
- Some support for unions exists. For unions of primitive types, the system will try to resolve them automatically. For
13+
classes that contain union attributes, the ``#[UnionDiscriminator]`` attribute must be used to specify the type of the union.
1214

1315
Converting your annotations to attributes
1416
-----------------------------------------
@@ -384,6 +386,22 @@ to the least super type:
384386
385387
`groups` is optional and is used as exclusion policy.
386388
389+
#[UnionDiscriminator]
390+
~~~~~~~~~~~~~~~~~~~~~
391+
392+
This attribute allows deserialization of unions. The ``#[UnionDiscriminator]`` attribute has to be applied
393+
to an attribute that can be one of many types.
394+
395+
.. code-block :: php
396+
397+
class Vehicle {
398+
#[UnionDiscriminator(field: 'typeField', map: ['manual' => 'FullyQualified/Path/Manual', 'automatic' => 'FullyQualified/Path/Automatic'])]
399+
private Manual|Automatic $transmission;
400+
}
401+
402+
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
403+
to the `map` option to determine which class to instantiate.
404+
387405
#[Type]
388406
~~~~~~~
389407
This attribute can be defined on a property to specify the type of that property.

doc/reference/xml_reference.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ XML Reference
4646
<value>foo</value>
4747
<value>bar</value>
4848
</groups>
49+
<union-discriminator field="foo">
50+
<map>
51+
<class key="a">SomeClassFQCN1</class>
52+
<class key="b">SomeClassFQCN2</class>
53+
<class key="c">SomeClassFQCN3</class>
54+
</map>
55+
</union-discriminator>
4956
</property>
5057
<callback-method name="foo" type="pre-serialize" />
5158
<callback-method name="bar" type="post-serialize" />

doc/reference/yml_reference.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ YAML Reference
7272
cdata: false
7373
namespace: http://www.w3.org/2005/Atom
7474
max_depth: 2
75+
union_discriminator:
76+
filed: foo
77+
map:
78+
a: SomeClassFQCN1
79+
b: SomeClassFQCN2
80+
c: SomeClassFQCN3
7581
7682
callback_methods:
7783
pre_serialize: [foo, bar]

phpstan.neon.dist

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ parameters:
66
- '~Class Doctrine\\Common\\Persistence\\Proxy not found~'
77
- '~Class Doctrine\\ODM\\MongoDB\\PersistentCollection not found~'
88
- '~Class Symfony\\(Contracts|Component)\\Translation\\TranslatorInterface not found~'
9-
- '#Constructor of class JMS\\Serializer\\Annotation\\.*? has an unused parameter#'
109
- '#Class JMS\\Serializer\\Annotation\\DeprecatedReadOnly extends @final class JMS\\Serializer\\Annotation\\ReadOnlyProperty.#'
1110
- '#^Call to an undefined method Doctrine\\Persistence\\Mapping\\ClassMetadata\<object\>\:\:getFieldValue\(\)\.$#'
1211
- '#^Call to an undefined method JMS\\Serializer\\Visitor\\DeserializationVisitorInterface\:\:getCurrentObject\(\)\.$#'

phpstan/ignore-by-php-version.neon.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
$includes = [];
77
if (PHP_VERSION_ID < 80000) {
88
$includes[] = __DIR__ . '/no-typed-prop.neon';
9+
$includes[] = __DIR__ . '/no-unions.neon';
910
$includes[] = __DIR__ . '/no-attributes.neon';
1011
$includes[] = __DIR__ . '/no-promoted-properties.neon';
1112
}

phpstan/no-unions.neon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
parameters:
2+
excludePaths:
3+
- %currentWorkingDirectory%/tests/Fixtures/TypedProperties/ComplexDiscriminatedUnion.php

src/Annotation/UnionDiscriminator.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JMS\Serializer\Annotation;
6+
7+
/**
8+
* @Annotation
9+
* @Target({"PROPERTY"})
10+
*/
11+
#[\Attribute(\Attribute::TARGET_PROPERTY)]
12+
final class UnionDiscriminator implements SerializerAttribute
13+
{
14+
use AnnotationUtilsTrait;
15+
16+
/** @var array<string> */
17+
public $map = [];
18+
19+
/** @var string */
20+
public $field = 'type';
21+
22+
public function __construct(array $values = [], string $field = 'type', array $map = [])
23+
{
24+
$this->loadAnnotationParameters(get_defined_vars());
25+
}
26+
}

src/GraphNavigator/SerializationGraphNavigator.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,16 @@ public function accept($data, ?array $type = null)
177177

178178
throw new RuntimeException($msg);
179179

180+
case 'union':
181+
if (null !== $handler = $this->handlerRegistry->getHandler(GraphNavigatorInterface::DIRECTION_SERIALIZATION, $type['name'], $this->format)) {
182+
try {
183+
return \call_user_func($handler, $this->visitor, $data, $type, $this->context);
184+
} catch (SkipHandlerException $e) {
185+
// Skip handler, fallback to default behavior
186+
}
187+
}
188+
189+
break;
180190
default:
181191
if (null !== $data) {
182192
if ($this->context->isVisiting($data)) {

src/Handler/UnionHandler.php

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use JMS\Serializer\Context;
88
use JMS\Serializer\DeserializationContext;
9+
use JMS\Serializer\Exception\NonVisitableTypeException;
910
use JMS\Serializer\Exception\RuntimeException;
1011
use JMS\Serializer\GraphNavigatorInterface;
1112
use JMS\Serializer\SerializationContext;
@@ -47,46 +48,76 @@ public function serializeUnion(
4748
mixed $data,
4849
array $type,
4950
SerializationContext $context
50-
) {
51-
return $this->matchSimpleType($data, $type, $context);
51+
): mixed {
52+
if ($this->isPrimitiveType(gettype($data))) {
53+
return $this->matchSimpleType($data, $type, $context);
54+
} else {
55+
$resolvedType = [
56+
'name' => get_class($data),
57+
'params' => [],
58+
];
59+
60+
return $context->getNavigator()->accept($data, $resolvedType);
61+
}
5262
}
5363

54-
public function deserializeUnion(DeserializationVisitorInterface $visitor, mixed $data, array $type, DeserializationContext $context)
64+
public function deserializeUnion(DeserializationVisitorInterface $visitor, mixed $data, array $type, DeserializationContext $context): mixed
5565
{
5666
if ($data instanceof \SimpleXMLElement) {
5767
throw new RuntimeException('XML deserialisation into union types is not supported yet.');
5868
}
5969

60-
return $this->matchSimpleType($data, $type, $context);
61-
}
70+
if (3 === count($type['params'])) {
71+
$lookupField = $type['params'][1];
72+
if (empty($data[$lookupField])) {
73+
throw new NonVisitableTypeException(sprintf('Union Discriminator Field "%s" not found in data', $lookupField));
74+
}
6275

63-
private function matchSimpleType(mixed $data, array $type, Context $context)
64-
{
65-
$dataType = $this->determineType($data, $type, $context->getFormat());
66-
$alternativeName = null;
76+
$unionMap = $type['params'][2];
77+
$lookupValue = $data[$lookupField];
78+
if (empty($unionMap[$lookupValue])) {
79+
throw new NonVisitableTypeException(sprintf('Union Discriminator Map does not contain key "%s"', $lookupValue));
80+
}
81+
82+
$finalType = [
83+
'name' => $unionMap[$lookupValue],
84+
'params' => [],
85+
];
6786

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

72-
foreach ($type['params'] as $possibleType) {
73-
if ($possibleType['name'] === $dataType || $possibleType['name'] === $alternativeName) {
90+
foreach ($type['params'][0] as $possibleType) {
91+
if ($this->isPrimitiveType($possibleType['name']) && $this->testPrimitive($data, $possibleType['name'], $context->getFormat())) {
7492
return $context->getNavigator()->accept($data, $possibleType);
7593
}
7694
}
95+
96+
return null;
7797
}
7898

79-
private function determineType(mixed $data, array $type, string $format): ?string
99+
private function matchSimpleType(mixed $data, array $type, Context $context): mixed
80100
{
81-
foreach ($type['params'] as $possibleType) {
82-
if ($this->testPrimitive($data, $possibleType['name'], $format)) {
83-
return $possibleType['name'];
101+
foreach ($type['params'][0] as $possibleType) {
102+
if ($this->isPrimitiveType($possibleType['name']) && !$this->testPrimitive($data, $possibleType['name'], $context->getFormat())) {
103+
continue;
104+
}
105+
106+
try {
107+
return $context->getNavigator()->accept($data, $possibleType);
108+
} catch (NonVisitableTypeException $e) {
109+
continue;
84110
}
85111
}
86112

87113
return null;
88114
}
89115

116+
private function isPrimitiveType(string $type): bool
117+
{
118+
return in_array($type, ['int', 'integer', 'float', 'double', 'bool', 'boolean', 'string'], true);
119+
}
120+
90121
private function testPrimitive(mixed $data, string $type, string $format): bool
91122
{
92123
switch ($type) {

src/Metadata/Driver/AnnotationOrAttributeDriver.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use JMS\Serializer\Annotation\Since;
2525
use JMS\Serializer\Annotation\SkipWhenEmpty;
2626
use JMS\Serializer\Annotation\Type;
27+
use JMS\Serializer\Annotation\UnionDiscriminator;
2728
use JMS\Serializer\Annotation\Until;
2829
use JMS\Serializer\Annotation\VirtualProperty;
2930
use JMS\Serializer\Annotation\XmlAttribute;
@@ -258,6 +259,12 @@ public function loadMetadataForClass(\ReflectionClass $class): ?BaseClassMetadat
258259
$propertyMetadata->xmlAttributeMap = true;
259260
} elseif ($annot instanceof MaxDepth) {
260261
$propertyMetadata->maxDepth = $annot->depth;
262+
} elseif ($annot instanceof UnionDiscriminator) {
263+
$propertyMetadata->setUnionDiscriminator($annot->field, $annot->map);
264+
$propertyMetadata->setType([
265+
'name' => 'union',
266+
'params' => [null, $annot->field, $annot->map],
267+
]);
261268
}
262269
}
263270

src/Metadata/Driver/TypedPropertiesDriver.php

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,15 @@ public function __construct(DriverInterface $delegate, ?ParserInterface $typePar
5757
* For determining types of primitives, it is necessary to reorder primitives so that they are tested from lowest specificity to highest:
5858
* i.e. null, true, false, int, float, bool, string
5959
*/
60-
private function reorderTypes(array $type): array
60+
private function reorderTypes(array $types): array
6161
{
62-
if ($type['params']) {
63-
uasort($type['params'], static function ($a, $b) {
64-
$order = ['null' => 0, 'true' => 1, 'false' => 2, 'bool' => 3, 'int' => 4, 'float' => 5, 'string' => 6];
62+
uasort($types, static function ($a, $b) {
63+
$order = ['null' => 0, 'true' => 1, 'false' => 2, 'bool' => 3, 'int' => 4, 'float' => 5, 'string' => 6];
6564

66-
return ($order[$a['name']] ?? 7) <=> ($order[$b['name']] ?? 7);
67-
});
68-
}
65+
return ($order[$a['name']] ?? 7) <=> ($order[$b['name']] ?? 7);
66+
});
6967

70-
return $type;
68+
return $types;
7169
}
7270

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

114112
$propertyMetadata->setType($this->typeParser->parse($type));
115113
} elseif ($this->shouldTypeHintUnion($reflectionType)) {
116-
$propertyMetadata->setType($this->reorderTypes([
114+
$propertyMetadata->setType([
117115
'name' => 'union',
118-
'params' => array_map(fn (string $type) => $this->typeParser->parse($type), $reflectionType->getTypes()),
119-
]));
116+
'params' => [$this->reorderTypes(array_map(fn (string $type) => $this->typeParser->parse($type), $reflectionType->getTypes()))],
117+
]);
120118
}
121119
} catch (ReflectionException $e) {
122120
continue;

src/Metadata/Driver/XmlDriver.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,21 @@ protected function loadMetadataFromFile(\ReflectionClass $class, string $path):
321321
$pMetadata->readOnly = $pMetadata->readOnly || $readOnlyClass;
322322
}
323323

324+
if (isset($pElem->{'union-discriminator'})) {
325+
$colConfig = $pElem->{'union-discriminator'};
326+
327+
$map = [];
328+
foreach ($pElem->xpath('./union-discriminator/map/class') as $entry) {
329+
$map[(string) $entry->attributes()->key] = (string) $entry;
330+
}
331+
332+
$pMetadata->setUnionDiscriminator((string) $colConfig->attributes()->field, $map);
333+
$pMetadata->setType([
334+
'name' => 'union',
335+
'params' => [null, (string) $colConfig->attributes()->field, $map],
336+
]);
337+
}
338+
324339
$getter = $pElem->attributes()->{'accessor-getter'};
325340
$setter = $pElem->attributes()->{'accessor-setter'};
326341
$pMetadata->setAccessor(

src/Metadata/Driver/YamlDriver.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,14 @@ protected function loadMetadataFromFile(ReflectionClass $class, string $file): ?
300300
if (isset($pConfig['max_depth'])) {
301301
$pMetadata->maxDepth = (int) $pConfig['max_depth'];
302302
}
303+
304+
if (isset($pConfig['union_discriminator'])) {
305+
$pMetadata->setUnionDiscriminator($pConfig['union_discriminator']['field'], $pConfig['union_discriminator']['map']);
306+
$pMetadata->setType([
307+
'name' => 'union',
308+
'params' => [null, $pConfig['union_discriminator']['field'], $pConfig['union_discriminator']['map']],
309+
]);
310+
}
303311
}
304312

305313
if (!$pMetadata->serializedName) {

src/Metadata/PropertyMetadata.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ class PropertyMetadata extends BasePropertyMetadata
3333
*/
3434
public $serializedName;
3535

36+
/**
37+
* @var string|null
38+
*/
39+
public $unionDiscriminatorField;
40+
41+
/**
42+
* @var array<string, string>|null
43+
*/
44+
public $unionDiscriminatorMap;
45+
3646
/**
3747
* @var array|null
3848
*/
@@ -196,6 +206,12 @@ public function setAccessor(string $type, ?string $getter = null, ?string $sette
196206
$this->setter = $setter;
197207
}
198208

209+
public function setUnionDiscriminator(string $field, array $map): void
210+
{
211+
$this->unionDiscriminatorField = $field;
212+
$this->unionDiscriminatorMap = $map;
213+
}
214+
199215
public function setType(array $type): void
200216
{
201217
$this->type = $type;
@@ -224,6 +240,8 @@ protected function serializeToArray(): array
224240
$this->untilVersion,
225241
$this->groups,
226242
$this->serializedName,
243+
$this->unionDiscriminatorField,
244+
$this->unionDiscriminatorMap,
227245
$this->type,
228246
$this->xmlCollection,
229247
$this->xmlCollectionInline,
@@ -258,6 +276,8 @@ protected function unserializeFromArray(array $data): void
258276
$this->untilVersion,
259277
$this->groups,
260278
$this->serializedName,
279+
$this->unionDiscriminatorField,
280+
$this->unionDiscriminatorMap,
261281
$this->type,
262282
$this->xmlCollection,
263283
$this->xmlCollectionInline,

0 commit comments

Comments
 (0)