Skip to content

Commit

Permalink
Merge branch '4.4' into 5.4
Browse files Browse the repository at this point in the history
* 4.4:
  [Serializer] Fix denormalizing union types
  [Mailer] Preserve case of headers
  • Loading branch information
nicolas-grekas committed Mar 24, 2022
2 parents 30f2213 + 8efe86f commit d1bc370
Show file tree
Hide file tree
Showing 2 changed files with 158 additions and 94 deletions.
200 changes: 106 additions & 94 deletions Normalizer/AbstractObjectNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ abstract protected function setAttributeValue(object $object, string $attribute,
private function validateAndDenormalize(array $types, string $currentClass, string $attribute, $data, ?string $format, array $context)
{
$expectedTypes = [];
$isUnionType = \count($types) > 1;
foreach ($types as $type) {
if (null === $data && $type->isNullable()) {
return null;
Expand All @@ -455,117 +456,128 @@ private function validateAndDenormalize(array $types, string $currentClass, stri
$data = [$data];
}

// In XML and CSV all basic datatypes are represented as strings, it is e.g. not possible to determine,
// if a value is meant to be a string, float, int or a boolean value from the serialized representation.
// That's why we have to transform the values, if one of these non-string basic datatypes is expected.
if (\is_string($data) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) {
if ('' === $data) {
if (Type::BUILTIN_TYPE_ARRAY === $builtinType = $type->getBuiltinType()) {
return [];
}

if ($type->isNullable() && \in_array($builtinType, [Type::BUILTIN_TYPE_BOOL, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true)) {
return null;
}
}

switch ($builtinType ?? $type->getBuiltinType()) {
case Type::BUILTIN_TYPE_BOOL:
// according to https://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1"
if ('false' === $data || '0' === $data) {
$data = false;
} elseif ('true' === $data || '1' === $data) {
$data = true;
} else {
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);
}
break;
case Type::BUILTIN_TYPE_INT:
if (ctype_digit($data) || '-' === $data[0] && ctype_digit(substr($data, 1))) {
$data = (int) $data;
} else {
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);
}
break;
case Type::BUILTIN_TYPE_FLOAT:
if (is_numeric($data)) {
return (float) $data;
// This try-catch should cover all NotNormalizableValueException (and all return branches after the first
// exception) so we could try denormalizing all types of an union type. If the target type is not an union
// type, we will just re-throw the catched exception.
// In the case of no denormalization succeeds with an union type, it will fall back to the default exception
// with the acceptable types list.
try {
// In XML and CSV all basic datatypes are represented as strings, it is e.g. not possible to determine,
// if a value is meant to be a string, float, int or a boolean value from the serialized representation.
// That's why we have to transform the values, if one of these non-string basic datatypes is expected.
if (\is_string($data) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) {
if ('' === $data) {
if (Type::BUILTIN_TYPE_ARRAY === $builtinType = $type->getBuiltinType()) {
return [];
}

switch ($data) {
case 'NaN':
return \NAN;
case 'INF':
return \INF;
case '-INF':
return -\INF;
default:
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);
if ($type->isNullable() && \in_array($builtinType, [Type::BUILTIN_TYPE_BOOL, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true)) {
return null;
}
}

switch ($builtinType ?? $type->getBuiltinType()) {
case Type::BUILTIN_TYPE_BOOL:
// according to https://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1"
if ('false' === $data || '0' === $data) {
$data = false;
} elseif ('true' === $data || '1' === $data) {
$data = true;
} else {
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);
}
break;
case Type::BUILTIN_TYPE_INT:
if (ctype_digit($data) || '-' === $data[0] && ctype_digit(substr($data, 1))) {
$data = (int) $data;
} else {
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);
}
break;
case Type::BUILTIN_TYPE_FLOAT:
if (is_numeric($data)) {
return (float) $data;
}

switch ($data) {
case 'NaN':
return \NAN;
case 'INF':
return \INF;
case '-INF':
return -\INF;
default:
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);
}
}
}
}

if (null !== $collectionValueType && Type::BUILTIN_TYPE_OBJECT === $collectionValueType->getBuiltinType()) {
$builtinType = Type::BUILTIN_TYPE_OBJECT;
$class = $collectionValueType->getClassName().'[]';
if (null !== $collectionValueType && Type::BUILTIN_TYPE_OBJECT === $collectionValueType->getBuiltinType()) {
$builtinType = Type::BUILTIN_TYPE_OBJECT;
$class = $collectionValueType->getClassName().'[]';

if (\count($collectionKeyType = $type->getCollectionKeyTypes()) > 0) {
[$context['key_type']] = $collectionKeyType;
}
} elseif ($type->isCollection() && \count($collectionValueType = $type->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $collectionValueType[0]->getBuiltinType()) {
// get inner type for any nested array
[$innerType] = $collectionValueType;

// note that it will break for any other builtinType
$dimensions = '[]';
while (\count($innerType->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $innerType->getBuiltinType()) {
$dimensions .= '[]';
[$innerType] = $innerType->getCollectionValueTypes();
}
if (\count($collectionKeyType = $type->getCollectionKeyTypes()) > 0) {
[$context['key_type']] = $collectionKeyType;
}
} elseif ($type->isCollection() && \count($collectionValueType = $type->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $collectionValueType[0]->getBuiltinType()) {
// get inner type for any nested array
[$innerType] = $collectionValueType;

// note that it will break for any other builtinType
$dimensions = '[]';
while (\count($innerType->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $innerType->getBuiltinType()) {
$dimensions .= '[]';
[$innerType] = $innerType->getCollectionValueTypes();
}

if (null !== $innerType->getClassName()) {
// the builtinType is the inner one and the class is the class followed by []...[]
$builtinType = $innerType->getBuiltinType();
$class = $innerType->getClassName().$dimensions;
if (null !== $innerType->getClassName()) {
// the builtinType is the inner one and the class is the class followed by []...[]
$builtinType = $innerType->getBuiltinType();
$class = $innerType->getClassName().$dimensions;
} else {
// default fallback (keep it as array)
$builtinType = $type->getBuiltinType();
$class = $type->getClassName();
}
} else {
// default fallback (keep it as array)
$builtinType = $type->getBuiltinType();
$class = $type->getClassName();
}
} else {
$builtinType = $type->getBuiltinType();
$class = $type->getClassName();
}

$expectedTypes[Type::BUILTIN_TYPE_OBJECT === $builtinType && $class ? $class : $builtinType] = true;
$expectedTypes[Type::BUILTIN_TYPE_OBJECT === $builtinType && $class ? $class : $builtinType] = true;

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

$childContext = $this->createChildContext($context, $attribute, $format);
if ($this->serializer->supportsDenormalization($data, $class, $format, $childContext)) {
return $this->serializer->denormalize($data, $class, $format, $childContext);
$childContext = $this->createChildContext($context, $attribute, $format);
if ($this->serializer->supportsDenormalization($data, $class, $format, $childContext)) {
return $this->serializer->denormalize($data, $class, $format, $childContext);
}
}
}

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

if (Type::BUILTIN_TYPE_FALSE === $builtinType && false === $data) {
return $data;
}
if (Type::BUILTIN_TYPE_FALSE === $builtinType && false === $data) {
return $data;
}

if (('is_'.$builtinType)($data)) {
return $data;
if (('is_'.$builtinType)($data)) {
return $data;
}
} catch (NotNormalizableValueException $e) {
if (!$isUnionType) {
throw $e;
}
}
}

Expand Down Expand Up @@ -717,7 +729,7 @@ private function getCacheKey(?string $format, array $context)
'context' => $context,
'ignored' => $context[self::IGNORED_ATTRIBUTES] ?? $this->defaultContext[self::IGNORED_ATTRIBUTES],
]));
} catch (\Exception $exception) {
} catch (\Exception $e) {
// The context cannot be serialized, skip the cache
return false;
}
Expand Down
52 changes: 52 additions & 0 deletions Tests/SerializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,38 @@ public function testDeserializeWrappedScalar()
$this->assertSame(42, $serializer->deserialize('{"wrapper": 42}', 'int', 'json', [UnwrappingDenormalizer::UNWRAP_PATH => '[wrapper]']));
}

public function testUnionTypeDeserializable()
{
$classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()));
$extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]);
$serializer = new Serializer(
[
new DateTimeNormalizer(),
new ObjectNormalizer($classMetadataFactory, null, null, $extractor, new ClassDiscriminatorFromClassMetadata($classMetadataFactory)),
],
['json' => new JsonEncoder()]
);

$actual = $serializer->deserialize('{ "changed": null }', DummyUnionType::class, 'json', [
DateTimeNormalizer::FORMAT_KEY => \DateTime::ISO8601,
]);

$this->assertEquals((new DummyUnionType())->setChanged(null), $actual, 'Union type denormalization first case failed.');

$actual = $serializer->deserialize('{ "changed": "2022-03-22T16:15:05+0000" }', DummyUnionType::class, 'json', [
DateTimeNormalizer::FORMAT_KEY => \DateTime::ISO8601,
]);

$expectedDateTime = \DateTime::createFromFormat(\DateTime::ISO8601, '2022-03-22T16:15:05+0000');
$this->assertEquals((new DummyUnionType())->setChanged($expectedDateTime), $actual, 'Union type denormalization second case failed.');

$actual = $serializer->deserialize('{ "changed": false }', DummyUnionType::class, 'json', [
DateTimeNormalizer::FORMAT_KEY => \DateTime::ISO8601,
]);

$this->assertEquals(new DummyUnionType(), $actual, 'Union type denormalization third case failed.');
}

private function serializerWithClassDiscriminator()
{
$classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()));
Expand Down Expand Up @@ -1155,6 +1187,26 @@ public function __construct($value)
}
}

class DummyUnionType
{
/**
* @var \DateTime|bool|null
*/
public $changed = false;

/**
* @param \DateTime|bool|null
*
* @return $this
*/
public function setChanged($changed): self
{
$this->changed = $changed;

return $this;
}
}

class Baz
{
public $list;
Expand Down

0 comments on commit d1bc370

Please sign in to comment.