8000 feature #59670 [Serializer] Add `NumberNormalizer` (valtzu) · symfony/symfony@e08f9af · GitHub
[go: up one dir, main page]

Skip to content

Commit e08f9af

Browse files
committed
feature #59670 [Serializer] Add NumberNormalizer (valtzu)
This PR was merged into the 7.3 branch. Discussion ---------- [Serializer] Add `NumberNormalizer` | Q | A | ------------- | --- | Branch? | 7.3 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | Fix #59651 | License | MIT Normalize `BcMath\Number` to `string`. A few changes in comparison to the original issue description: 1. `float`s not supported at all. No point using precision math if you start with non-precise value. 2. `Number` is always normalized to a `string`. If we wanted the `int` cast, it should be opt-in with some context key like `cast_zero_scale_to_int` – though I personally don't see a need for it. Commits ------- d3e3dfe Add `NumberNormalizer`
2 parents 277486a + d3e3dfe commit e08f9af

File tree

6 files changed

+242
-0
lines changed

6 files changed

+242
-0
lines changed

psalm.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
<referencedClass name="UnitEnum"/>
3333
<!-- These classes have been added in PHP 8.2 -->
3434
<referencedClass name="Random\*"/>
35+
<!-- These classes have been added in PHP 8.4 -->
36+
<referencedClass name="BcMath\Number"/>
3537
</errorLevel>
3638
</UndefinedClass>
3739
<UndefinedDocblockClass>

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@
170170
use Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter;
171171
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
172172
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
173+
use Symfony\Component\Serializer\Normalizer\NumberNormalizer;
173174
use Symfony\Component\Serializer\Serializer;
174175
use Symfony\Component\Stopwatch\Stopwatch;
175176
use Symfony\Component\String\LazyString;
@@ -1933,6 +1934,11 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder
19331934
$container->removeDefinition('serializer.normalizer.mime_message');
19341935
}
19351936

1937+
// BC layer Serializer < 7.3
1938+
if (!class_exists(NumberNormalizer::class)) {
1939+
$container->removeDefinition('serializer.normalizer.number');
1940+
}
1941+
19361942
// BC layer Serializer < 7.2
19371943
if (!class_exists(SnakeCaseToCamelCaseNameConverter::class)) {
19381944
$container->removeDefinition('serializer.name_converter.snake_case_to_camel_case');

src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
use Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer;
4545
use Symfony\Component\Serializer\Normalizer\MimeMessageNormalizer;
4646
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
47+
use Symfony\Component\Serializer\Normalizer\NumberNormalizer;
4748
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
4849
use Symfony\Component\Serializer\Normalizer\ProblemNormalizer;
4950
use Symfony\Component\Serializer\Normalizer\PropertyNormalizer;
@@ -221,5 +222,8 @@
221222

222223
->set('serializer.normalizer.backed_enum', BackedEnumNormalizer::class)
223224
->tag('serializer.normalizer', ['built_in' => true, 'priority' => -915])
225+
226+
->set('serializer.normalizer.number', NumberNormalizer::class)
227+
->tag('serializer.normalizer', ['built_in' => true, 'priority' => -915])
224228
;
225229
};

src/Symfony/Component/Serializer/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ CHANGELOG
66

77
* Deprecate the `CompiledClassMetadataFactory` and `CompiledClassMetadataCacheWarmer` classes
88
* Register `NormalizerInterface` and `DenormalizerInterface` aliases for named serializers
9+
* Add `NumberNormalizer` to normalize `BcMath\Number` and `GMP` as `string`
910

1011
7.2
1112
---
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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 BcMath\Number;
15+
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
16+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
17+
18+
/**
19+
* Normalizes {@see Number} and {@see \GMP} to a string.
20+
*/
21+
final class NumberNormalizer implements NormalizerInterface, DenormalizerInterface
22+
{
23+
public function getSupportedTypes(?string $format): array
24+
{
25+
return [
26+
Number::class => true,
27+
\GMP::class => true,
28+
];
29+
}
30+
31+
public function normalize(mixed $data, ?string $format = null, array $context = []): string
32+
{
33+
if (!$data instanceof Number && !$data instanceof \GMP) {
34+
throw new InvalidArgumentException(\sprintf('The data must be an instance of "%s" or "%s".', Number::class, \GMP::class));
35+
}
36+
37+
return (string) $data;
38+
}
39+
40+
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
41+
{
42+
return $data instanceof Number || $data instanceof \GMP;
43+
}
44+
45+
/**
46+
* @throws NotNormalizableValueException
47+
*/
48+
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): Number|\GMP
49+
{
50+
if (!\is_string($data) && !\is_int($data)) {
51+
throw $this->createNotNormalizableValueException($type, $data, $context);
52+
}
53+
54+
try {
55+
return match ($type) {
56+
Number::class => new Number($data),
57+
\GMP::class => new \GMP($data),
58+
default => throw new InvalidArgumentException(\sprintf('Only "%s" and "%s" types are supported.', Number::class, \GMP::class)),
59+
};
60+
} catch (\ValueError $e) {
61+
throw $this->createNotNormalizableValueException($type, $data, $context, $e);
62+
}
63+
}
64+
65+
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
66+
{
67+
return \in_array($type, [Number::class, \GMP::class], true) && null !== $data;
68+
}
69+
70+
private function createNotNormalizableValueException(string $type, mixed $data, array $context, ?\Throwable $previous = null): NotNormalizableValueException
71+
{
72+
$message = match ($type) {
73+
Number::class => 'The data must be a "string" representing a decimal number, or an "int".',
74+
\GMP::class => 'The data must be a "string" representing an integer, or an "int".',
75+
};
76+
77+
return NotNormalizableValueException::createForUnexpectedDataType($message, $data, ['string', 'int'], $context['deserialization_path'] ?? null, true, 0, $previous);
78+
}
79+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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\Tests\Normalizer;
13+
14+
use BcMath\Number;
15+
use PHPUnit\Framework\TestCase;
16+
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
17+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
18+
use Symfony\Component\Serializer\Normalizer\NumberNormalizer;
19+
20+
/**
21+
* @requires PHP 8.4
22+
* @requires extension bcmath
23+
* @requires extension gmp
24+
*/
25+
class NumberNormalizerTest extends TestCase
26+
{
27+
private NumberNormalizer $normalizer;
28+
29+
protected function setUp(): void
30+
{
31+
$this->normalizer = new NumberNormalizer();
32+
}
33+
34+
/**
35+
* @dataProvider supportsNormalizationProvider
36+
*/
37+
public function testSupportsNormalization(mixed $data, bool $expected)
38+
{
39+
$this->assertSame($expected, $this->normalizer->supportsNormalization($data));
40+
}
41+
42+
public static function supportsNormalizationProvider(): iterable
43+
{
44+
yield 'GMP object' => [new \GMP('0b111'), true];
45+
yield 'Number object' => [new Number('1.23'), true];
46+
yield 'object with similar properties as Number' => [(object) ['value' => '1.23', 'scale' => 2], false];
47+
yield 'stdClass' => [new \stdClass(), false];
48+
yield 'string' => ['1.23', false];
49+
yield 'float' => [1.23, false];
50+
yield 'null' => [null, false];
51+
}
52+
53+
/**
54+
* @dataProvider normalizeGoodValueProvider
55+
*/
56+
public function testNormalize(mixed $data, mixed $expected)
57+
{
58+
$this->assertSame($expected, $this->normalizer->normalize($data));
59+
}
60+
61+
public static function normalizeGoodValueProvider(): iterable
62+
{
63+
yield 'Number with scale=2' => [new Number('1.23'), '1.23'];
64+
yield 'Number with scale=0' => [new Number('1'), '1'];
65+
yield 'Number with integer' => [new Number(123), '123'];
66+
yield 'GMP hex' => [new \GMP('0x10'), '16'];
67+
yield 'GMP base=10' => [new \GMP('10'), '10'];
68+
}
69+
70+
/**
71+
* @dataProvider normalizeBadValueProvider
72+
*/
73+
public function testNormalizeBadValueThrows(mixed $data)
74+
{
75+
$this->expectException(InvalidArgumentException::class);
76+
$this->expectExceptionMessage('The data must be an instance of "BcMath\Number" or "GMP".');
77+
78+
$this->normalizer->normalize($data);
79+
}
80+
81+
public static function normalizeBadValueProvider(): iterable
82+
{
83+
yield 'stdClass' => [new \stdClass()];
84+
yield 'string' => ['1.23'];
85+
yield 'null' => [null];
86+
}
87+
88+
/**
89+
* @dataProvider supportsDenormalizationProvider
90+
*/
91+
public function testSupportsDenormalization(mixed $data, string $type, bool $expected)
92+
{
93+
$this->assertSame($expected, $this->normalizer->supportsDenormalization($data, $type));
94+
}
95+
96+
public static function supportsDenormalizationProvider(): iterable
97+
{
98+
yield 'null value, Number' => [null, Number::class, false];
99+
yield 'null value, GMP' => [null, \GMP::class, false];
100+
yield 'null value, unmatching type' => [null, \stdClass::class, false];
101+
}
102+
103+
/**
104+
* @dataProvider denormalizeGoodValueProvider
105+
*/
106+
public function testDenormalize(mixed $data, string $type, mixed $expected)
107+
{
108+
$this->assertEquals($expected, $this->normalizer->denormalize($data, $type));
109+
}
110+
111+
public static function denormalizeGoodValueProvider(): iterable
112+
{
113+
yield 'Number, string with decimal point' => ['1.23', Number::class, new Number('1.23')];
114+
yield 'Number, integer as string' => ['123', Number::class, new Number('123')];
115+
yield 'Number, integer' => [123, Number::class, new Number('123')];
116+
yield 'GMP, large number' => ['9223372036854775808', \GMP::class, new \GMP('9223372036854775808')];
117+
yield 'GMP, integer' => [123, \GMP::class, new \GMP('123')];
118+
}
119+
120+
/**
121+
* @dataProvider denormalizeBadValueProvider
122+
*/
123+
public function testDenormalizeBadValueThrows(mixed $data, string $type, string $expectedException, string $expectedExceptionMessage)
124+
{
125+
$this->expectException($expectedException);
126+
$this->expectExceptionMessage($expectedExceptionMessage);
127+
128+
$this->normalizer->denormalize($data, $type);
129+
}
130+
131+
public static function denormalizeBadValueProvider(): iterable
132+
{
133+
$stringOrDecimalExpectedMessage = 'The data must be a "string" representing a decimal number, or an "int".';
134+
yield 'Number, null' => [null, Number::class, NotNormalizableValueException::class, $stringOrDecimalExpectedMessage];
135+
yield 'Number, boolean' => [true, Number::class, NotNormalizableValueException::class, $stringOrDecimalExpectedMessage];
136+
yield 'Number, object' => [new \stdClass(), Number::class, NotNormalizableValueException::class, $stringOrDecimalExpectedMessage];
137+
yield 'Number, non-numeric string' => ['foobar', Number::class, NotNormalizableValueException::class, $stringOrDecimalExpectedMessage];
138+
yield 'Number, float' => [1.23, Number::class, NotNormalizableValueException::class, $stringOrDecimalExpectedMessage];
139+
140+
$stringOrIntExpectedMessage = 'The data must be a "string" representing an integer, or an "int".';
141+
yield 'GMP, null' => [null, \GMP::class, NotNormalizableValueException::class, $stringOrIntExpectedMessage];
142+
yield 'GMP, boolean' => [true, \GMP::class, NotNormalizableValueException::class, $stringOrIntExpectedMessage];
143+
yield 'GMP, object' => [new \stdClass(), \GMP::class, NotNormalizableValueException::class, $stringOrIntExpectedMessage];
144+
yield 'GMP, non-numeric string' => ['foobar', \GMP::class, NotNormalizableValueException::class, $stringOrIntExpectedMessage];
145+
yield 'GMP, scale > 0' => ['1.23', \GMP::class, NotNormalizableValueException::class, $stringOrIntExpectedMessage];
146+
yield 'GMP, float' => [1.23, \GMP::class, NotNormalizableValueException::class, $stringOrIntExpectedMessage];
147+
148+
yield 'unsupported type' => ['1.23', \stdClass::class, InvalidArgumentException::class, 'Only "BcMath\Number" and "GMP" types are supported.'];
149+
}
150+
}

0 commit comments

Comments
 (0)
0