Skip to content

Commit 3fdd925

Browse files
committed
add BuiltinTypeDenormalizer
handle builtin types and not just scalars Looking towards the possible refactoring of the AbstractObjectNormalizer it will be more convenient to have a denormalizer capable of handling builtin types instead of just scalar ones. Except for `null`, `iterable`, `array` and `object` types: - `null` could be handled here with little work but I'm not sure it's a good idea - `iterable` does not provide enough information to validte the items so it might be better to not handle it so that the user gave a "better" type - `array` and `object`, it's simplier to not support them so that we don't have to deal with a complex handling of priority within the normalizers
1 parent e972955 commit 3fdd925

File tree

8 files changed

+334
-8
lines changed

8 files changed

+334
-8
lines changed

UPGRADE-5.3.md

+5
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,8 @@ Security
2222
--------
2323

2424
* Deprecated voters that do not return a valid decision when calling the `vote` method.
25+
26+
Serializer
27+
----------
28+
29+
* Deprecated denormalizing scalar values without registering the `BuiltinTypeDenormalizer`

UPGRADE-6.0.md

+5
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,11 @@ Validator
226226
->addDefaultDoctrineAnnotationReader();
227227
```
228228

229+
Serializer
230+
----------
231+
232+
* Removed the denormalization of scalar values without normalizer, add the `BuiltinTypeDenormalizer` to the `Serializer`
233+
229234
Yaml
230235
----
231236

src/Symfony/Component/Serializer/CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
5.3.0
5+
-----
6+
7+
* [DEPRECATION] denormalizing scalar values without registering the `BuiltinTypeDenormalizer`
8+
49
5.2.0
510
-----
611

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Serializer\Normalizer;
13+
14+
use Symfony\Component\Serializer\Encoder\CsvEncoder;
15+
use Symfony\Component\Serializer\Encoder\JsonEncoder;
16+
use Symfony\Component\Serializer\Encoder\XmlEncoder;
17+
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
18+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
19+
20+
final class BuiltinTypeDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface
21+
{
22+
private const TYPE_INT = 'int';
23+
private const TYPE_FLOAT = 'float';
24+
private const TYPE_STRING = 'string';
25+
private const TYPE_BOOL = 'bool';
26+
private const TYPE_RESOURCE = 'resource';
27+
private const TYPE_CALLABLE = 'callable';
28+
29+
private const SUPPORTED_TYPES = [
30+
self::TYPE_INT => true,
31+
self::TYPE_BOOL => true,
32+
self::TYPE_FLOAT => true,
33+
self::TYPE_STRING => true,
34+
self::TYPE_RESOURCE => true,
35+
self::TYPE_CALLABLE => true,
36+
];
37+
38+
/**
39+
* {@inheritdoc}
40+
*/
41+
public function denormalize($data, string $type, string $format = null, array $context = [])
42+
{
43+
$dataType = get_debug_type($data);
44+
45+
if (!(isset(self::SUPPORTED_TYPES[$dataType]) || 0 === strpos($dataType, self::TYPE_RESOURCE) || \is_callable($data))) {
46+
throw new InvalidArgumentException(sprintf('Data expected to be of one of the types in "%s" ("%s" given).', implode(', ', array_keys(self::SUPPORTED_TYPES)), get_debug_type($data)));
47+
}
48+
49+
// In XML and CSV all basic datatypes are represented as strings, it is e.g. not possible to determine,
50+
// if a value is meant to be a string, float, int or a boolean value from the serialized representation.
51+
// That's why we have to transform the values, if one of these non-string basic datatypes is expected.
52+
if (\is_string($data) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) {
53+
switch ($type) {
54+
case self::TYPE_BOOL:
55+
// according to https://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1"
56+
if ('false' === $data || '0' === $data) {
57+
return false;
58+
}
59+
if ('true' === $data || '1' === $data) {
60+
return true;
61+
}
62+
63+
throw new NotNormalizableValueException(sprintf('Data expected to be of type "%s" ("%s" given).', $type, $data));
64+
case self::TYPE_INT:
65+
if (ctype_digit($data) || '-' === $data[0] && ctype_digit(substr($data, 1))) {
66+
return (int) $data;
67+
}
68+
69+
throw new NotNormalizableValueException(sprintf('Data expected to be of type "%s" ("%s" given).', $type, $data));
70+
case self::TYPE_FLOAT:
71+
if (is_numeric($data)) {
72+
return (float) $data;
73+
}
74+
75+
switch ($data) {
76+
case 'NaN':
77+
return \NAN;
78+
case 'INF':
79+
return \INF;
80+
case '-INF':
81+
return -\INF;
82+
default:
83+
throw new NotNormalizableValueException(sprintf('Data expected to be of type "%s" ("%s" given).', $type, $data));
84+
}
85+
}
86+
}
87+
88+
// JSON only has a Number type corresponding to both int and float PHP types.
89+
// PHP's json_encode, JavaScript's JSON.stringify, Go's json.Marshal as well as most other JSON encoders convert
90+
// floating-point numbers like 12.0 to 12 (the decimal part is dropped when possible).
91+
// PHP's json_decode automatically converts Numbers without a decimal part to integers.
92+
// To circumvent this behavior, integers are converted to floats when denormalizing JSON based formats and when
93+
// a float is expected.
94+
if (self::TYPE_FLOAT === $type && \is_int($data) && false !== strpos($format, JsonEncoder::FORMAT)) {
95+
return (float) $data;
96+
}
97+
98+
if (!('is_'.$type)($data)) {
99+
throw new NotNormalizableValueException(sprintf('Data expected to be of type "%s" ("%s" given).', $type, get_debug_type($data)));
100+
}
101+
102+
return $data;
103+
}
104+
105+
/**
106+
* {@inheritdoc}
107+
*/
108+
public function supportsDenormalization($data, string $type, string $format = null)
109+
{
110+
return isset(self::SUPPORTED_TYPES[$type]);
111+
}
112+
113+
public function hasCacheableSupportsMethod(): bool
114+
{
115+
return true;
116+
}
117+
}

src/Symfony/Component/Serializer/Serializer.php

+3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Symfony\Component\Serializer\Exception\NotEncodableValueException;
2323
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
2424
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
25+
use Symfony\Component\Serializer\Normalizer\BuiltinTypeDenormalizer;
2526
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
2627
use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface;
2728
use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
@@ -193,6 +194,8 @@ public function denormalize($data, string $type, string $format = null, array $c
193194

194195
// Check for a denormalizer first, e.g. the data is wrapped
195196
if (!$normalizer && isset(self::SCALAR_TYPES[$type])) {
197+
trigger_deprecation('symfony/serializer', '5.2', 'Denormalizing scalar values without registering the "%s" is deprecated.', BuiltinTypeDenormalizer::class);
198+
196199
if (!('is_'.$type)($data)) {
197200
throw new NotNormalizableValueException(sprintf('Data expected to be of type "%s" ("%s" given).', $type, get_debug_type($data)));
198201
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<?php
2+
3+
namespace Symfony\Component\Serializer\Tests\Normalizer;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
7+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
8+
use Symfony\Component\Serializer\Normalizer\BuiltinTypeDenormalizer;
9+
10+
class BuiltinTypeDenormalizerTest extends TestCase
11+
{
12+
/**
13+
* @var BuiltinTypeDenormalizer
14+
*/
15+
private $denormalizer;
16+
17+
protected function setUp(): void
18+
{
19+
$this->denormalizer = new BuiltinTypeDenormalizer();
20+
}
21+
22+
/**
23+
* @dataProvider provideSupportedTypes
24+
*/
25+
public function testSupportsDenormalization(string $supportedType): void
26+
{
27+
$this->assertTrue($this->denormalizer->supportsDenormalization(null, $supportedType));
28+
}
29+
30+
public function provideSupportedTypes(): iterable
31+
{
32+
return [['int'], ['float'], ['string'], ['bool'], ['resource'], ['callable']];
33+
}
34+
35+
/**
36+
* @dataProvider provideUnsupportedTypes
37+
*/
38+
public function testUnsupportsDenormalization(string $unsupportedType): void
39+
{
40+
$this->assertFalse($this->denormalizer->supportsDenormalization(null, $unsupportedType));
41+
}
42+
43+
public function provideUnsupportedTypes(): iterable
44+
{
45+
return [['null'], ['array'], ['iterable'], ['object'], ['int[]']];
46+
}
47+
48+
/**
49+
* @dataProvider provideInvalidData
50+
*/
51+
public function testDenormalizeInvalidDataThrowsException($invalidData): void
52+
{
53+
$this->expectException(InvalidArgumentException::class);
54+
$this->denormalizer->denormalize($invalidData, 'int');
55+
}
56+
57+
public function provideInvalidData(): iterable
58+
{
59+
return [
60+
'array' => [[1, 2]],
61+
'object' => [new \stdClass()],
62+
'null' => [null],
63+
];
64+
}
65+
66+
/**
67+
* @dataProvider provideNotNormalizableData
68+
*/
69+
public function testDenormalizeNotNormalizableDataThrowsException($data, string $type, string $format): void
70+
{
71+
$this->expectException(NotNormalizableValueException::class);
72+
$this->denormalizer->denormalize($data, $type, $format);
73+
}
74+
75+
public function provideNotNormalizableData(): iterable
76+
{
77+
return [
78+
'not a string' => [true, 'string', 'json'],
79+
'not an integer' => [3.1, 'int', 'json'],
80+
'not an integer (xml/csv)' => ['+12', 'int', 'xml'],
81+
'not a float' => [false, 'float', 'json'],
82+
'not a float (xml/csv)' => ['nan', 'float', 'xml'],
83+
'not a boolean (json)' => [0, 'bool', 'json'],
84+
'not a boolean (xml/csv)' => ['test', 'bool', 'xml'],
85+
];
86+
}
87+
88+
/**
89+
* @dataProvider provideNormalizableData
90+
*/
91+
public function testDenormalize($expectedResult, $data, string $type, string $format = null): void
92+
{
93+
$result = $this->denormalizer->denormalize($data, $type, $format);
94+
95+
if (\is_float($expectedResult) && is_nan($expectedResult)) {
96+
$this->assertNan($result);
97+
} else {
98+
$this->assertSame($expectedResult, $result);
99+
}
100+
}
101+
102+
public function provideNormalizableData(): iterable
103+
{
104+
return [
105+
'string' => ['1', '1', 'string', 'json'],
106+
'integer' => [-3, -3, 'int', 'json'],
107+
'integer (xml/csv)' => [-12, '-12', 'int', 'xml'],
108+
'float' => [3.14, 3.14, 'float', 'json'],
109+
'float without decimals' => [3.0, 3, 'float', 'json'],
110+
'NaN (xml/csv)' => [\NAN, 'NaN', 'float', 'xml'],
111+
'INF (xml/csv)' => [\INF, 'INF', 'float', 'xml'],
112+
'-INF (xml/csv)' => [-\INF, '-INF', 'float', 'xml'],
113+
'boolean: true (json)' => [true, true, 'bool', 'json'],
114+
'boolean: false (json)' => [false, false, 'bool', 'json'],
115+
"boolean: 'true' (xml/csv)" => [true, 'true', 'bool', 'xml'],
116+
"boolean: '1' (xml/csv)" => [true, '1', 'bool', 'xml'],
117+
"boolean: 'false' (xml/csv)" => [false, 'false', 'bool', 'xml'],
118+
"boolean: '0' (xml/csv)" => [false, '0', 'bool', 'xml'],
119+
'callable' => [[$this, 'provideInvalidData'], [$this, 'provideInvalidData'], 'callable', null],
120+
'resource' => [$r = fopen(__FILE__, 'r'), $r, 'resource', null],
121+
];
122+
}
123+
}

0 commit comments

Comments
 (0)