Skip to content

Commit d1bc370

Browse files
Merge branch '4.4' into 5.4
* 4.4: [Serializer] Fix denormalizing union types [Mailer] Preserve case of headers
2 parents 30f2213 + 8efe86f commit d1bc370

File tree

2 files changed

+158
-94
lines changed

2 files changed

+158
-94
lines changed

Normalizer/AbstractObjectNormalizer.php

Lines changed: 106 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ abstract protected function setAttributeValue(object $object, string $attribute,
442442
private function validateAndDenormalize(array $types, string $currentClass, string $attribute, $data, ?string $format, array $context)
443443
{
444444
$expectedTypes = [];
445+
$isUnionType = \count($types) > 1;
445446
foreach ($types as $type) {
446447
if (null === $data && $type->isNullable()) {
447448
return null;
@@ -455,117 +456,128 @@ private function validateAndDenormalize(array $types, string $currentClass, stri
455456
$data = [$data];
456457
}
457458

458-
// In XML and CSV all basic datatypes are represented as strings, it is e.g. not possible to determine,
459-
// if a value is meant to be a string, float, int or a boolean value from the serialized representation.
460-
// That's why we have to transform the values, if one of these non-string basic datatypes is expected.
461-
if (\is_string($data) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) {
462-
if ('' === $data) {
463-
if (Type::BUILTIN_TYPE_ARRAY === $builtinType = $type->getBuiltinType()) {
464-
return [];
465-
}
466-
467-
if ($type->isNullable() && \in_array($builtinType, [Type::BUILTIN_TYPE_BOOL, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true)) {
468-
return null;
469-
}
470-
}
471-
472-
switch ($builtinType ?? $type->getBuiltinType()) {
473-
case Type::BUILTIN_TYPE_BOOL:
474-
// according to https://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1"
475-
if ('false' === $data || '0' === $data) {
476-
$data = false;
477-
} elseif ('true' === $data || '1' === $data) {
478-
$data = true;
479-
} else {
480-
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_BOOL], $context['deserialization_path'] ?? null);
481-
}
482-
break;
483-
case Type::BUILTIN_TYPE_INT:
484-
if (ctype_digit($data) || '-' === $data[0] && ctype_digit(substr($data, 1))) {
485-
$data = (int) $data;
486-
} else {
487-
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_INT], $context['deserialization_path'] ?? null);
488-
}
489-
break;
490-
case Type::BUILTIN_TYPE_FLOAT:
491-
if (is_numeric($data)) {
492-
return (float) $data;
459+
// This try-catch should cover all NotNormalizableValueException (and all return branches after the first
460+
// exception) so we could try denormalizing all types of an union type. If the target type is not an union
461+
// type, we will just re-throw the catched exception.
462+
// In the case of no denormalization succeeds with an union type, it will fall back to the default exception
463+
// with the acceptable types list.
464+
try {
465+
// In XML and CSV all basic datatypes are represented as strings, it is e.g. not possible to determine,
466+
// if a value is meant to be a string, float, int or a boolean value from the serialized representation.
467+
// That's why we have to transform the values, if one of these non-string basic datatypes is expected.
468+
if (\is_string($data) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) {
469+
if ('' === $data) {
470+
if (Type::BUILTIN_TYPE_ARRAY === $builtinType = $type->getBuiltinType()) {
471+
return [];
493472
}
494473

495-
switch ($data) {
496-
case 'NaN':
497-
return \NAN;
498-
case 'INF':
499-
return \INF;
500-
case '-INF':
501-
return -\INF;
502-
default:
503-
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_FLOAT], $context['deserialization_path'] ?? null);
474+
if ($type->isNullable() && \in_array($builtinType, [Type::BUILTIN_TYPE_BOOL, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true)) {
475+
return null;
504476
}
477+
}
478+
479+
switch ($builtinType ?? $type->getBuiltinType()) {
480+
case Type::BUILTIN_TYPE_BOOL:
481+
// according to https://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1"
482+
if ('false' === $data || '0' === $data) {
483+
$data = false;
484+
} elseif ('true' === $data || '1' === $data) {
485+
$data = true;
486+
} else {
487+
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_BOOL], $context['deserialization_path'] ?? null);
488+
}
489+
break;
490+
case Type::BUILTIN_TYPE_INT:
491+
if (ctype_digit($data) || '-' === $data[0] && ctype_digit(substr($data, 1))) {
492+
$data = (int) $data;
493+
} else {
494+
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_INT], $context['deserialization_path'] ?? null);
495+
}
496+
break;
497+
case Type::BUILTIN_TYPE_FLOAT:
498+
if (is_numeric($data)) {
499+
return (float) $data;
500+
}
501+
502+
switch ($data) {
503+
case 'NaN':
504+
return \NAN;
505+
case 'INF':
506+
return \INF;
507+
case '-INF':
508+
return -\INF;
509+
default:
510+
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_FLOAT], $context['deserialization_path'] ?? null);
511+
}
512+
}
505513
}
506-
}
507514

508-
if (null !== $collectionValueType && Type::BUILTIN_TYPE_OBJECT === $collectionValueType->getBuiltinType()) {
509-
$builtinType = Type::BUILTIN_TYPE_OBJECT;
510-
$class = $collectionValueType->getClassName().'[]';
515+
if (null !== $collectionValueType && Type::BUILTIN_TYPE_OBJECT === $collectionValueType->getBuiltinType()) {
516+
$builtinType = Type::BUILTIN_TYPE_OBJECT;
517+
$class = $collectionValueType->getClassName().'[]';
511518

512-
if (\count($collectionKeyType = $type->getCollectionKeyTypes()) > 0) {
513-
[$context['key_type']] = $collectionKeyType;
514-
}
515-
} elseif ($type->isCollection() && \count($collectionValueType = $type->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $collectionValueType[0]->getBuiltinType()) {
516-
// get inner type for any nested array
517-
[$innerType] = $collectionValueType;
518-
519-
// note that it will break for any other builtinType
520-
$dimensions = '[]';
521-
while (\count($innerType->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $innerType->getBuiltinType()) {
522-
$dimensions .= '[]';
523-
[$innerType] = $innerType->getCollectionValueTypes();
524-
}
519+
if (\count($collectionKeyType = $type->getCollectionKeyTypes()) > 0) {
520+
[$context['key_type']] = $collectionKeyType;
521+
}
522+
} elseif ($type->isCollection() && \count($collectionValueType = $type->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $collectionValueType[0]->getBuiltinType()) {
523+
// get inner type for any nested array
524+
[$innerType] = $collectionValueType;
525+
526+
// note that it will break for any other builtinType
527+
$dimensions = '[]';
528+
while (\count($innerType->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $innerType->getBuiltinType()) {
529+
$dimensions .= '[]';
530+
[$innerType] = $innerType->getCollectionValueTypes();
531+
}
525532

526-
if (null !== $innerType->getClassName()) {
527-
// the builtinType is the inner one and the class is the class followed by []...[]
528-
$builtinType = $innerType->getBuiltinType();
529-
$class = $innerType->getClassName().$dimensions;
533+
if (null !== $innerType->getClassName()) {
534+
// the builtinType is the inner one and the class is the class followed by []...[]
535+
$builtinType = $innerType->getBuiltinType();
536+
$class = $innerType->getClassName().$dimensions;
537+
} else {
538+
// default fallback (keep it as array)
539+
$builtinType = $type->getBuiltinType();
540+
$class = $type->getClassName();
541+
}
530542
} else {
531-
// default fallback (keep it as array)
532543
$builtinType = $type->getBuiltinType();
533544
$class = $type->getClassName();
534545
}
535-
} else {
536-
$builtinType = $type->getBuiltinType();
537-
$class = $type->getClassName();
538-
}
539546

540-
$expectedTypes[Type::BUILTIN_TYPE_OBJECT === $builtinType && $class ? $class : $builtinType] = true;
547+
$expectedTypes[Type::BUILTIN_TYPE_OBJECT === $builtinType && $class ? $class : $builtinType] = true;
541548

542-
if (Type::BUILTIN_TYPE_OBJECT === $builtinType) {
543-
if (!$this->serializer instanceof DenormalizerInterface) {
544-
throw new LogicException(sprintf('Cannot denormalize attribute "%s" for class "%s" because injected serializer is not a denormalizer.', $attribute, $class));
545-
}
549+
if (Type::BUILTIN_TYPE_OBJECT === $builtinType) {
550+
if (!$this->serializer instanceof DenormalizerInterface) {
551+
throw new LogicException(sprintf('Cannot denormalize attribute "%s" for class "%s" because injected serializer is not a denormalizer.', $attribute, $class));
552+
}
546553

547-
$childContext = $this->createChildContext($context, $attribute, $format);
548-
if ($this->serializer->supportsDenormalization($data, $class, $format, $childContext)) {
549-
return $this->serializer->denormalize($data, $class, $format, $childContext);
554+
$childContext = $this->createChildContext($context, $attribute, $format);
555+
if ($this->serializer->supportsDenormalization($data, $class, $format, $childContext)) {
556+
return $this->serializer->denormalize($data, $class, $format, $childContext);
557+
}
550558
}
551-
}
552559

553-
// JSON only has a Number type corresponding to both int and float PHP types.
554-
// PHP's json_encode, JavaScript's JSON.stringify, Go's json.Marshal as well as most other JSON encoders convert
555-
// floating-point numbers like 12.0 to 12 (the decimal part is dropped when possible).
556-
// PHP's json_decode automatically converts Numbers without a decimal part to integers.
557-
// To circumvent this behavior, integers are converted to floats when denormalizing JSON based formats and when
558-
// a float is expected.
559-
if (Type::BUILTIN_TYPE_FLOAT === $builtinType && \is_int($data) && null !== $format && str_contains($format, JsonEncoder::FORMAT)) {
560-
return (float) $data;
561-
}
560+
// JSON only has a Number type corresponding to both int and float PHP types.
561+
// PHP's json_encode, JavaScript's JSON.stringify, Go's json.Marshal as well as most other JSON encoders convert
562+
// floating-point numbers like 12.0 to 12 (the decimal part is dropped when possible).
563+
// PHP's json_decode automatically converts Numbers without a decimal part to integers.
564+
// To circumvent this behavior, integers are converted to floats when denormalizing JSON based formats and when
565+
// a float is expected.
566+
if (Type::BUILTIN_TYPE_FLOAT === $builtinType && \is_int($data) && null !== $format && str_contains($format, JsonEncoder::FORMAT)) {
567+
return (float) $data;
568+
}
562569

563-
if (Type::BUILTIN_TYPE_FALSE === $builtinType && false === $data) {
564-
return $data;
565-
}
570+
if (Type::BUILTIN_TYPE_FALSE === $builtinType && false === $data) {
571+
return $data;
572+
}
566573

567-
if (('is_'.$builtinType)($data)) {
568-
return $data;
574+
if (('is_'.$builtinType)($data)) {
575+
return $data;
576+
}
577+
} catch (NotNormalizableValueException $e) {
578+
if (!$isUnionType) {
579+
throw $e;
580+
}
569581
}
570582
}
571583

@@ -717,7 +729,7 @@ private function getCacheKey(?string $format, array $context)
717729
'context' => $context,
718730
'ignored' => $context[self::IGNORED_ATTRIBUTES] ?? $this->defaultContext[self::IGNORED_ATTRIBUTES],
719731
]));
720-
} catch (\Exception $exception) {
732+
} catch (\Exception $e) {
721733
// The context cannot be serialized, skip the cache
722734
return false;
723735
}

Tests/SerializerTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,38 @@ public function testDeserializeWrappedScalar()
718718
$this->assertSame(42, $serializer->deserialize('{"wrapper": 42}', 'int', 'json', [UnwrappingDenormalizer::UNWRAP_PATH => '[wrapper]']));
719719
}
720720

721+
public function testUnionTypeDeserializable()
722+
{
723+
$classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()));
724+
$extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]);
725+
$serializer = new Serializer(
726+
[
727+
new DateTimeNormalizer(),
728+
new ObjectNormalizer($classMetadataFactory, null, null, $extractor, new ClassDiscriminatorFromClassMetadata($classMetadataFactory)),
729+
],
730+
['json' => new JsonEncoder()]
731+
);
732+
733+
$actual = $serializer->deserialize('{ "changed": null }', DummyUnionType::class, 'json', [
734+
DateTimeNormalizer::FORMAT_KEY => \DateTime::ISO8601,
735+
]);
736+
737+
$this->assertEquals((new DummyUnionType())->setChanged(null), $actual, 'Union type denormalization first case failed.');
738+
739+
$actual = $serializer->deserialize('{ "changed": "2022-03-22T16:15:05+0000" }', DummyUnionType::class, 'json', [
740+
DateTimeNormalizer::FORMAT_KEY => \DateTime::ISO8601,
741+
]);
742+
743+
$expectedDateTime = \DateTime::createFromFormat(\DateTime::ISO8601, '2022-03-22T16:15:05+0000');
744+
$this->assertEquals((new DummyUnionType())->setChanged($expectedDateTime), $actual, 'Union type denormalization second case failed.');
745+
746+
$actual = $serializer->deserialize('{ "changed": false }', DummyUnionType::class, 'json', [
747+
DateTimeNormalizer::FORMAT_KEY => \DateTime::ISO8601,
748+
]);
749+
750+
$this->assertEquals(new DummyUnionType(), $actual, 'Union type denormalization third case failed.');
751+
}
752+
721753
private function serializerWithClassDiscriminator()
722754
{
723755
$classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()));
@@ -1155,6 +1187,26 @@ public function __construct($value)
11551187
}
11561188
}
11571189

1190+
class DummyUnionType
1191+
{
1192+
/**
1193+
* @var \DateTime|bool|null
1194+
*/
1195+
public $changed = false;
1196+
1197+
/**
1198+
* @param \DateTime|bool|null
1199+
*
1200+
* @return $this
1201+
*/
1202+
public function setChanged($changed): self
1203+
{
1204+
$this->changed = $changed;
1205+
1206+
return $this;
1207+
}
1208+
}
1209+
11581210
class Baz
11591211
{
11601212
public $list;

0 commit comments

Comments
 (0)