Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Union Types Complex #1

Closed
wants to merge 14 commits into from
2 changes: 1 addition & 1 deletion src/Annotation/Discriminator.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class Discriminator implements SerializerAttribute
{
use AnnotationUtilsTrait;

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

/** @var string */
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());
}
}
9 changes: 9 additions & 0 deletions src/Exception/PropertyMissingException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace JMS\Serializer\Exception;

final class PropertyMissingException extends RuntimeException
{
}
27 changes: 27 additions & 0 deletions src/GraphNavigator/DeserializationGraphNavigator.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use JMS\Serializer\Exception\ExpressionLanguageRequiredException;
use JMS\Serializer\Exception\LogicException;
use JMS\Serializer\Exception\NotAcceptableException;
use JMS\Serializer\Exception\PropertyMissingException;
use JMS\Serializer\Exception\RuntimeException;
use JMS\Serializer\Exception\SkipHandlerException;
use JMS\Serializer\Exclusion\ExpressionLanguageExclusionStrategy;
Expand Down Expand Up @@ -197,6 +198,7 @@ public function accept($data, ?array $type = null)

$this->visitor->startVisitingObject($metadata, $object, $type);
foreach ($metadata->propertyMetadata as $propertyMetadata) {
$allowsNull = null === $propertyMetadata->type ? true : $this->allowsNull($propertyMetadata->type);
if (null !== $this->exclusionStrategy && $this->exclusionStrategy->shouldSkipProperty($propertyMetadata, $this->context)) {
continue;
}
Expand All @@ -212,12 +214,21 @@ public function accept($data, ?array $type = null)
$this->context->pushPropertyMetadata($propertyMetadata);
try {
$v = $this->visitor->visitProperty($propertyMetadata, $data);

$this->accessor->setValue($object, $v, $propertyMetadata, $this->context);
} catch (NotAcceptableException $e) {
if (true === $propertyMetadata->hasDefault) {
$cloned = clone $propertyMetadata;
$cloned->setter = null;
$this->accessor->setValue($object, $cloned->defaultValue, $cloned, $this->context);
} elseif (!$allowsNull && $this->visitor->getRequireAllRequiredProperties()) {
$this->visitor->endVisitingObject($metadata, $data, $type);

throw new PropertyMissingException("Property $propertyMetadata->name is missing from data ");
} elseif ($this->visitor->getRequireAllRequiredProperties()) {
$this->visitor->endVisitingObject($metadata, $data, $type);

throw $e;
}
}

Expand All @@ -231,6 +242,22 @@ public function accept($data, ?array $type = null)
}
}

private function allowsNull(array $type)
{
$allowsNull = false;
if ('union' === $type['name']) {
foreach ($type['params'] as $param) {
if ('NULL' === $param['name']) {
$allowsNull = true;
}
}
} elseif ('NULL' === $type['name']) {
$allowsNull = true;
}

return $allowsNull;
}

/**
* @param mixed $data
*/
Expand Down
12 changes: 12 additions & 0 deletions src/GraphNavigator/SerializationGraphNavigator.php
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,18 @@ 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
} catch (NotAcceptableException $e) {
$this->context->stopVisiting($data);

throw $e;
}
}
default:
if (null !== $data) {
if ($this->context->isVisiting($data)) {
Expand Down
116 changes: 98 additions & 18 deletions src/Handler/UnionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

use JMS\Serializer\Context;
use JMS\Serializer\DeserializationContext;
use JMS\Serializer\Exception\NonFloatCastableTypeException;
use JMS\Serializer\Exception\NonIntCastableTypeException;
use JMS\Serializer\Exception\NonStringCastableTypeException;
use JMS\Serializer\Exception\NonVisitableTypeException;
use JMS\Serializer\Exception\PropertyMissingException;
use JMS\Serializer\Exception\RuntimeException;
use JMS\Serializer\GraphNavigatorInterface;
use JMS\Serializer\SerializationContext;
Expand All @@ -15,6 +20,12 @@
final class UnionHandler implements SubscribingHandlerInterface
{
private static $aliases = ['boolean' => 'bool', 'integer' => 'int', 'double' => 'float'];
private bool $requireAllProperties;

public function __construct(bool $requireAllProperties = false)
{
$this->requireAllProperties = $requireAllProperties;
}

/**
* {@inheritdoc}
Expand Down Expand Up @@ -47,46 +58,115 @@ 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];
}

foreach ($type['params'] as $possibleType) {
if ($possibleType['name'] === $dataType || $possibleType['name'] === $alternativeName) {
return $context->getNavigator()->accept($data, $possibleType);
$propertyMetadata = $context->getMetadataStack()->top();
$finalType = null;
if (null !== $propertyMetadata->unionDiscriminatorField) {
if (!array_key_exists($propertyMetadata->unionDiscriminatorField, $data)) {
throw new NonVisitableTypeException("Union Discriminator Field '$propertyMetadata->unionDiscriminatorField' not found in data1");
}

$lkup = $data[$propertyMetadata->unionDiscriminatorField];
if (!empty($propertyMetadata->unionDiscriminatorMap)) {
if (array_key_exists($lkup, $propertyMetadata->unionDiscriminatorMap)) {
$finalType = [
'name' => $propertyMetadata->unionDiscriminatorMap[$lkup],
'params' => [],
];
} else {
throw new NonVisitableTypeException("Union Discriminator Map does not contain key '$lkup'");
}
} else {
$finalType = [
'name' => $lkup,
'params' => [],
];
}
}

if (null !== $finalType && null !== $finalType['name']) {
return $context->getNavigator()->accept($data, $finalType);
} else {
try {
$previousVisitorRequireSetting = $visitor->getRequireAllRequiredProperties();
if ($this->requireAllProperties) {
$visitor->setRequireAllRequiredProperties($this->requireAllProperties);
}

if ($this->isPrimitiveType($possibleType['name']) && (is_array($data) || !$this->testPrimitive($data, $possibleType['name'], $context->getFormat()))) {
continue;
}

$accept = $context->getNavigator()->accept($data, $possibleType);
if ($this->requireAllProperties) {
$visitor->setRequireAllRequiredProperties($previousVisitorRequireSetting);
}

return $accept;
} catch (NonVisitableTypeException $e) {
continue;
} catch (PropertyMissingException $e) {
continue;
} catch (NonStringCastableTypeException $e) {
continue;
} catch (NonIntCastableTypeException $e) {
continue;
} catch (NonFloatCastableTypeException $e) {
continue;
}
}
}

return null;
}

private function determineType(mixed $data, array $type, string $format): ?string
private function matchSimpleType(mixed $data, array $type, Context $context): mixed
{
$alternativeName = null;

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;
} catch (PropertyMissingException $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
17 changes: 17 additions & 0 deletions src/JsonDeserializationStrictVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,30 @@ final class JsonDeserializationStrictVisitor extends AbstractVisitor implements
/** @var JsonDeserializationVisitor */
private $wrappedDeserializationVisitor;

/**
* THIS IS ONLY USED FOR UNION DESERIALIZATION WHICH IS NOT SUPPORTED IN XML
*
* @var bool
*/
private $requireAllRequiredProperties = false;

public function __construct(
int $options = 0,
int $depth = 512
) {
$this->wrappedDeserializationVisitor = new JsonDeserializationVisitor($options, $depth);
}

public function setRequireAllRequiredProperties(bool $requireAllRequiredProperties): void
{
$this->requireAllRequiredProperties = $requireAllRequiredProperties;
}

public function getRequireAllRequiredProperties(): bool
{
return $this->requireAllRequiredProperties;
}

public function setNavigator(GraphNavigatorInterface $navigator): void
{
parent::setNavigator($navigator);
Expand Down
26 changes: 25 additions & 1 deletion src/JsonDeserializationVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,30 @@ final class JsonDeserializationVisitor extends AbstractVisitor implements Deseri
*/
private $currentObject;

/**
* @var bool
*/
private $requireAllRequiredProperties;

public function __construct(
int $options = 0,
int $depth = 512
int $depth = 512,
bool $requireAllRequiredProperties = false
) {
$this->objectStack = new \SplStack();
$this->options = $options;
$this->depth = $depth;
$this->requireAllRequiredProperties = $requireAllRequiredProperties;
}

public function setRequireAllRequiredProperties(bool $requireAllRequiredProperties): void
{
$this->requireAllRequiredProperties = $requireAllRequiredProperties;
}

public function getRequireAllRequiredProperties(): bool
{
return $this->requireAllRequiredProperties;
}

/**
Expand Down Expand Up @@ -151,6 +168,9 @@ public function visitDiscriminatorMapProperty($data, ClassMetadata $metadata): s
*/
public function startVisitingObject(ClassMetadata $metadata, object $object, array $type): void
{
$cur = $this->getCurrentObject() ? get_class($this->getCurrentObject()) : 'null';
$objtype = $object ? get_class($object) : 'null';
$stacksize = $this->objectStack->count();
$this->setCurrentObject($object);
}

Expand Down Expand Up @@ -194,7 +214,11 @@ public function visitProperty(PropertyMetadata $metadata, $data)
public function endVisitingObject(ClassMetadata $metadata, $data, array $type): object
{
$obj = $this->currentObject;
$prevObj = $this->objectStack->top();
$prevObjType = $prevObj ? get_class($prevObj) : 'null';

$this->revertCurrentObject();
$stacksize = $this->objectStack->count();

return $obj;
}
Expand Down
3 changes: 3 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,8 @@ 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);
}
}

Expand Down
Loading