From d3e3dfe09f448e72ebc600a2aa783fca13df9d76 Mon Sep 17 00:00:00 2001 From: valtzu Date: Sat, 1 Feb 2025 18:47:33 +0200 Subject: [PATCH] Add `NumberNormalizer` --- psalm.xml | 2 + .../FrameworkExtension.php | 6 + .../Resources/config/serializer.php | 4 + src/Symfony/Component/Serializer/CHANGELOG.md | 1 + .../Normalizer/NumberNormalizer.php | 79 +++++++++ .../Tests/Normalizer/NumberNormalizerTest.php | 150 ++++++++++++++++++ 6 files changed, 242 insertions(+) create mode 100644 src/Symfony/Component/Serializer/Normalizer/NumberNormalizer.php create mode 100644 src/Symfony/Component/Serializer/Tests/Normalizer/NumberNormalizerTest.php diff --git a/psalm.xml b/psalm.xml index f5f9c5b4c4e88..54c7cb03e6c4b 100644 --- a/psalm.xml +++ b/psalm.xml @@ -32,6 +32,8 @@ + + diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index f6160d49315b0..51699f298d6b3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -170,6 +170,7 @@ use Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NumberNormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Stopwatch\Stopwatch; use Symfony\Component\String\LazyString; @@ -1933,6 +1934,11 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder $container->removeDefinition('serializer.normalizer.mime_message'); } + // BC layer Serializer < 7.3 + if (!class_exists(NumberNormalizer::class)) { + $container->removeDefinition('serializer.normalizer.number'); + } + // BC layer Serializer < 7.2 if (!class_exists(SnakeCaseToCamelCaseNameConverter::class)) { $container->removeDefinition('serializer.name_converter.snake_case_to_camel_case'); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php index 4686a88f662d6..01b9a3eb0dd58 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php @@ -44,6 +44,7 @@ use Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer; use Symfony\Component\Serializer\Normalizer\MimeMessageNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NumberNormalizer; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Normalizer\ProblemNormalizer; use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; @@ -221,5 +222,8 @@ ->set('serializer.normalizer.backed_enum', BackedEnumNormalizer::class) ->tag('serializer.normalizer', ['built_in' => true, 'priority' => -915]) + + ->set('serializer.normalizer.number', NumberNormalizer::class) + ->tag('serializer.normalizer', ['built_in' => true, 'priority' => -915]) ; }; diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index a4fc951f18ec1..2286d5848a3a7 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Deprecate the `CompiledClassMetadataFactory` and `CompiledClassMetadataCacheWarmer` classes * Register `NormalizerInterface` and `DenormalizerInterface` aliases for named serializers + * Add `NumberNormalizer` to normalize `BcMath\Number` and `GMP` as `string` 7.2 --- diff --git a/src/Symfony/Component/Serializer/Normalizer/NumberNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/NumberNormalizer.php new file mode 100644 index 0000000000000..de68a406ea4ed --- /dev/null +++ b/src/Symfony/Component/Serializer/Normalizer/NumberNormalizer.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Normalizer; + +use BcMath\Number; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; + +/** + * Normalizes {@see Number} and {@see \GMP} to a string. + */ +final class NumberNormalizer implements NormalizerInterface, DenormalizerInterface +{ + public function getSupportedTypes(?string $format): array + { + return [ + Number::class => true, + \GMP::class => true, + ]; + } + + public function normalize(mixed $data, ?string $format = null, array $context = []): string + { + if (!$data instanceof Number && !$data instanceof \GMP) { + throw new InvalidArgumentException(\sprintf('The data must be an instance of "%s" or "%s".', Number::class, \GMP::class)); + } + + return (string) $data; + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof Number || $data instanceof \GMP; + } + + /** + * @throws NotNormalizableValueException + */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): Number|\GMP + { + if (!\is_string($data) && !\is_int($data)) { + throw $this->createNotNormalizableValueException($type, $data, $context); + } + + try { + return match ($type) { + Number::class => new Number($data), + \GMP::class => new \GMP($data), + default => throw new InvalidArgumentException(\sprintf('Only "%s" and "%s" types are supported.', Number::class, \GMP::class)), + }; + } catch (\ValueError $e) { + throw $this->createNotNormalizableValueException($type, $data, $context, $e); + } + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return \in_array($type, [Number::class, \GMP::class], true) && null !== $data; + } + + private function createNotNormalizableValueException(string $type, mixed $data, array $context, ?\Throwable $previous = null): NotNormalizableValueException + { + $message = match ($type) { + Number::class => 'The data must be a "string" representing a decimal number, or an "int".', + \GMP::class => 'The data must be a "string" representing an integer, or an "int".', + }; + + return NotNormalizableValueException::createForUnexpectedDataType($message, $data, ['string', 'int'], $context['deserialization_path'] ?? null, true, 0, $previous); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/NumberNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/NumberNormalizerTest.php new file mode 100644 index 0000000000000..be97588d00e6a --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/NumberNormalizerTest.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Normalizer; + +use BcMath\Number; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Normalizer\NumberNormalizer; + +/** + * @requires PHP 8.4 + * @requires extension bcmath + * @requires extension gmp + */ +class NumberNormalizerTest extends TestCase +{ + private NumberNormalizer $normalizer; + + protected function setUp(): void + { + $this->normalizer = new NumberNormalizer(); + } + + /** + * @dataProvider supportsNormalizationProvider + */ + public function testSupportsNormalization(mixed $data, bool $expected) + { + $this->assertSame($expected, $this->normalizer->supportsNormalization($data)); + } + + public static function supportsNormalizationProvider(): iterable + { + yield 'GMP object' => [new \GMP('0b111'), true]; + yield 'Number object' => [new Number('1.23'), true]; + yield 'object with similar properties as Number' => [(object) ['value' => '1.23', 'scale' => 2], false]; + yield 'stdClass' => [new \stdClass(), false]; + yield 'string' => ['1.23', false]; + yield 'float' => [1.23, false]; + yield 'null' => [null, false]; + } + + /** + * @dataProvider normalizeGoodValueProvider + */ + public function testNormalize(mixed $data, mixed $expected) + { + $this->assertSame($expected, $this->normalizer->normalize($data)); + } + + public static function normalizeGoodValueProvider(): iterable + { + yield 'Number with scale=2' => [new Number('1.23'), '1.23']; + yield 'Number with scale=0' => [new Number('1'), '1']; + yield 'Number with integer' => [new Number(123), '123']; + yield 'GMP hex' => [new \GMP('0x10'), '16']; + yield 'GMP base=10' => [new \GMP('10'), '10']; + } + + /** + * @dataProvider normalizeBadValueProvider + */ + public function testNormalizeBadValueThrows(mixed $data) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The data must be an instance of "BcMath\Number" or "GMP".'); + + $this->normalizer->normalize($data); + } + + public static function normalizeBadValueProvider(): iterable + { + yield 'stdClass' => [new \stdClass()]; + yield 'string' => ['1.23']; + yield 'null' => [null]; + } + + /** + * @dataProvider supportsDenormalizationProvider + */ + public function testSupportsDenormalization(mixed $data, string $type, bool $expected) + { + $this->assertSame($expected, $this->normalizer->supportsDenormalization($data, $type)); + } + + public static function supportsDenormalizationProvider(): iterable + { + yield 'null value, Number' => [null, Number::class, false]; + yield 'null value, GMP' => [null, \GMP::class, false]; + yield 'null value, unmatching type' => [null, \stdClass::class, false]; + } + + /** + * @dataProvider denormalizeGoodValueProvider + */ + public function testDenormalize(mixed $data, string $type, mixed $expected) + { + $this->assertEquals($expected, $this->normalizer->denormalize($data, $type)); + } + + public static function denormalizeGoodValueProvider(): iterable + { + yield 'Number, string with decimal point' => ['1.23', Number::class, new Number('1.23')]; + yield 'Number, integer as string' => ['123', Number::class, new Number('123')]; + yield 'Number, integer' => [123, Number::class, new Number('123')]; + yield 'GMP, large number' => ['9223372036854775808', \GMP::class, new \GMP('9223372036854775808')]; + yield 'GMP, integer' => [123, \GMP::class, new \GMP('123')]; + } + + /** + * @dataProvider denormalizeBadValueProvider + */ + public function testDenormalizeBadValueThrows(mixed $data, string $type, string $expectedException, string $expectedExceptionMessage) + { + $this->expectException($expectedException); + $this->expectExceptionMessage($expectedExceptionMessage); + + $this->normalizer->denormalize($data, $type); + } + + public static function denormalizeBadValueProvider(): iterable + { + $stringOrDecimalExpectedMessage = 'The data must be a "string" representing a decimal number, or an "int".'; + yield 'Number, null' => [null, Number::class, NotNormalizableValueException::class, $stringOrDecimalExpectedMessage]; + yield 'Number, boolean' => [true, Number::class, NotNormalizableValueException::class, $stringOrDecimalExpectedMessage]; + yield 'Number, object' => [new \stdClass(), Number::class, NotNormalizableValueException::class, $stringOrDecimalExpectedMessage]; + yield 'Number, non-numeric string' => ['foobar', Number::class, NotNormalizableValueException::class, $stringOrDecimalExpectedMessage]; + yield 'Number, float' => [1.23, Number::class, NotNormalizableValueException::class, $stringOrDecimalExpectedMessage]; + + $stringOrIntExpectedMessage = 'The data must be a "string" representing an integer, or an "int".'; + yield 'GMP, null' => [null, \GMP::class, NotNormalizableValueException::class, $stringOrIntExpectedMessage]; + yield 'GMP, boolean' => [true, \GMP::class, NotNormalizableValueException::class, $stringOrIntExpectedMessage]; + yield 'GMP, object' => [new \stdClass(), \GMP::class, NotNormalizableValueException::class, $stringOrIntExpectedMessage]; + yield 'GMP, non-numeric string' => ['foobar', \GMP::class, NotNormalizableValueException::class, $stringOrIntExpectedMessage]; + yield 'GMP, scale > 0' => ['1.23', \GMP::class, NotNormalizableValueException::class, $stringOrIntExpectedMessage]; + yield 'GMP, float' => [1.23, \GMP::class, NotNormalizableValueException::class, $stringOrIntExpectedMessage]; + + yield 'unsupported type' => ['1.23', \stdClass::class, InvalidArgumentException::class, 'Only "BcMath\Number" and "GMP" types are supported.']; + } +}