diff --git a/doc/reference/annotations.rst b/doc/reference/annotations.rst index 180120708..a6728af27 100644 --- a/doc/reference/annotations.rst +++ b/doc/reference/annotations.rst @@ -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 ----------------------------------------- @@ -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. diff --git a/src/Annotation/UnionDiscriminator.php b/src/Annotation/UnionDiscriminator.php new file mode 100644 index 000000000..ddaa9ba97 --- /dev/null +++ b/src/Annotation/UnionDiscriminator.php @@ -0,0 +1,26 @@ + */ + public $map = []; + + /** @var string */ + public $field = 'type'; + + public function __construct(array $values = [], string $field = 'type', array $map = []) + { + $this->loadAnnotationParameters(get_defined_vars()); + } +} diff --git a/src/GraphNavigator/SerializationGraphNavigator.php b/src/GraphNavigator/SerializationGraphNavigator.php index 1de6e3bb7..52d1543da 100644 --- a/src/GraphNavigator/SerializationGraphNavigator.php +++ b/src/GraphNavigator/SerializationGraphNavigator.php @@ -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)) { diff --git a/src/Handler/UnionHandler.php b/src/Handler/UnionHandler.php index ad7f4339a..086a3548f 100644 --- a/src/Handler/UnionHandler.php +++ b/src/Handler/UnionHandler.php @@ -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; @@ -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) { diff --git a/src/Metadata/Driver/AnnotationOrAttributeDriver.php b/src/Metadata/Driver/AnnotationOrAttributeDriver.php index 92becb724..e477fbf00 100644 --- a/src/Metadata/Driver/AnnotationOrAttributeDriver.php +++ b/src/Metadata/Driver/AnnotationOrAttributeDriver.php @@ -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; @@ -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], + ]); } } diff --git a/src/Metadata/Driver/TypedPropertiesDriver.php b/src/Metadata/Driver/TypedPropertiesDriver.php index 99f93cbaa..560069c4c 100644 --- a/src/Metadata/Driver/TypedPropertiesDriver.php +++ b/src/Metadata/Driver/TypedPropertiesDriver.php @@ -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; } } diff --git a/src/Metadata/PropertyMetadata.php b/src/Metadata/PropertyMetadata.php index ff41c949f..ccd096a58 100644 --- a/src/Metadata/PropertyMetadata.php +++ b/src/Metadata/PropertyMetadata.php @@ -33,6 +33,16 @@ class PropertyMetadata extends BasePropertyMetadata */ public $serializedName; + /** + * @var string|null + */ + public $unionDiscriminatorField; + + /** + * @var array|null + */ + public $unionDiscriminatorMap; + /** * @var array|null */ @@ -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; @@ -224,6 +240,8 @@ protected function serializeToArray(): array $this->untilVersion, $this->groups, $this->serializedName, + $this->unionDiscriminatorField, + $this->unionDiscriminatorMap, $this->type, $this->xmlCollection, $this->xmlCollectionInline, @@ -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, diff --git a/tests/Fixtures/DiscriminatedAuthor.php b/tests/Fixtures/DiscriminatedAuthor.php new file mode 100644 index 000000000..f646362f8 --- /dev/null +++ b/tests/Fixtures/DiscriminatedAuthor.php @@ -0,0 +1,42 @@ +name = $name; + } + + public function getName() + { + return $this->name; + } + + public function getObjectType() + { + return $this->objectType; + } +} diff --git a/tests/Fixtures/DiscriminatedComment.php b/tests/Fixtures/DiscriminatedComment.php new file mode 100644 index 000000000..e3251fc94 --- /dev/null +++ b/tests/Fixtures/DiscriminatedComment.php @@ -0,0 +1,47 @@ +author = $author; + $this->text = $text; + } + + public function getAuthor() + { + return $this->author; + } + + public function getObjectType() + { + return $this->objectType; + } +} diff --git a/tests/Fixtures/MoreSpecificAuthor.php b/tests/Fixtures/MoreSpecificAuthor.php new file mode 100644 index 000000000..c48974712 --- /dev/null +++ b/tests/Fixtures/MoreSpecificAuthor.php @@ -0,0 +1,41 @@ +name = $name; + $this->isMoreSpecific = $isMoreSpecific; + } + + public function getName() + { + return $this->name; + } + + public function getIsMoreSpecific() + { + return $this->isMoreSpecific; + } +} diff --git a/tests/Fixtures/TypedProperties/ComplexDiscriminatedUnion.php b/tests/Fixtures/TypedProperties/ComplexDiscriminatedUnion.php new file mode 100644 index 000000000..06994780b --- /dev/null +++ b/tests/Fixtures/TypedProperties/ComplexDiscriminatedUnion.php @@ -0,0 +1,25 @@ + 'JMS\Serializer\Tests\Fixtures\DiscriminatedAuthor', 'comment' => 'JMS\Serializer\Tests\Fixtures\DiscriminatedComment'])] + private DiscriminatedAuthor|DiscriminatedComment $data; + + public function __construct($data) + { + $this->data = $data; + } + + public function getData(): DiscriminatedAuthor|DiscriminatedComment + { + return $this->data; + } +} diff --git a/tests/Serializer/JsonSerializationTest.php b/tests/Serializer/JsonSerializationTest.php index 25cb0cf17..c6ebc11f7 100644 --- a/tests/Serializer/JsonSerializationTest.php +++ b/tests/Serializer/JsonSerializationTest.php @@ -14,11 +14,14 @@ use JMS\Serializer\SerializationContext; use JMS\Serializer\Tests\Fixtures\Author; use JMS\Serializer\Tests\Fixtures\AuthorList; +use JMS\Serializer\Tests\Fixtures\DiscriminatedAuthor; +use JMS\Serializer\Tests\Fixtures\DiscriminatedComment; use JMS\Serializer\Tests\Fixtures\FirstClassMapCollection; use JMS\Serializer\Tests\Fixtures\ObjectWithEmptyArrayAndHash; use JMS\Serializer\Tests\Fixtures\ObjectWithInlineArray; use JMS\Serializer\Tests\Fixtures\ObjectWithObjectProperty; use JMS\Serializer\Tests\Fixtures\Tag; +use JMS\Serializer\Tests\Fixtures\TypedProperties\ComplexDiscriminatedUnion; use JMS\Serializer\Tests\Fixtures\TypedProperties\UnionTypedProperties; use JMS\Serializer\Visitor\Factory\JsonSerializationVisitorFactory; use JMS\Serializer\Visitor\SerializationVisitorInterface; @@ -147,6 +150,10 @@ protected static function getContent($key) $outputs['data_float'] = '{"data":1.236}'; $outputs['data_bool'] = '{"data":false}'; $outputs['data_string'] = '{"data":"foo"}'; + $outputs['data_author'] = '{"data":{"full_name":"foo"}}'; + $outputs['data_comment'] = '{"data":{"author":{"full_name":"foo"},"text":"bar"}}'; + $outputs['data_discriminated_author'] = '{"data":{"full_name":"foo","objectType":"author"}}'; + $outputs['data_discriminated_comment'] = '{"data":{"author":{"full_name":"foo"},"text":"bar","objectType":"comment"}}'; $outputs['uid'] = '"66b3177c-e03b-4a22-9dee-ddd7d37a04d5"'; $outputs['object_with_enums'] = '{"ordinary":"Clubs","backed_value":"C","backed_without_param":"C","ordinary_array":["Clubs","Spades"],"backed_array":["C","H"],"backed_array_without_param":["C","H"],"ordinary_auto_detect":"Clubs","backed_auto_detect":"C","backed_int_auto_detect":3,"backed_int":3,"backed_name":"C","backed_int_forced_str":3}'; $outputs['object_with_autodetect_enums'] = '{"ordinary_array_auto_detect":["Clubs","Spades"],"backed_array_auto_detect":["C","H"],"mixed_array_auto_detect":["Clubs","H"]}'; @@ -449,7 +456,7 @@ public function testDeserializingUnionProperties() self::assertEquals($object, $this->deserialize(static::getContent('data_string'), UnionTypedProperties::class)); } - public function testSerializeUnionProperties() + public function testSerializingUnionProperties() { if (PHP_VERSION_ID < 80000) { $this->markTestSkipped(sprintf('%s requires PHP 8.0', TypedPropertiesDriver::class)); @@ -459,6 +466,46 @@ public function testSerializeUnionProperties() $serialized = $this->serialize(new UnionTypedProperties(10000)); self::assertEquals(static::getContent('data_integer'), $serialized); + + $serialized = $this->serialize(new UnionTypedProperties(1.236)); + self::assertEquals(static::getContent('data_float'), $serialized); + + $serialized = $this->serialize(new UnionTypedProperties(false)); + self::assertEquals(static::getContent('data_bool'), $serialized); + + $serialized = $this->serialize(new UnionTypedProperties('foo')); + self::assertEquals(static::getContent('data_string'), $serialized); + } + + public function testDeserializingComplexDiscriminatedUnionProperties() + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped(sprintf('%s requires PHP 8.0', TypedPropertiesDriver::class)); + + return; + } + + $authorUnion = new ComplexDiscriminatedUnion(new DiscriminatedAuthor('foo')); + self::assertEquals($authorUnion, $this->deserialize(static::getContent('data_discriminated_author'), ComplexDiscriminatedUnion::class)); + + $commentUnion = new ComplexDiscriminatedUnion(new DiscriminatedComment(new Author('foo'), 'bar')); + + self::assertEquals($commentUnion, $this->deserialize(static::getContent('data_discriminated_comment'), ComplexDiscriminatedUnion::class)); + } + + public function testSerializeingComplexDiscriminatedUnionProperties() + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped(sprintf('%s requires PHP 8.0', TypedPropertiesDriver::class)); + + return; + } + + $serialized = $this->serialize(new ComplexDiscriminatedUnion(new DiscriminatedAuthor('foo'))); + self::assertEquals(static::getContent('data_discriminated_author'), $serialized); + + $serialized = $this->serialize(new ComplexDiscriminatedUnion(new DiscriminatedComment(new Author('foo'), 'bar'))); + self::assertEquals(static::getContent('data_discriminated_comment'), $serialized); } /**