diff --git a/doc/fields/NumberField.rst b/doc/fields/NumberField.rst index 53b96ef67f..9d2f6c784b 100644 --- a/doc/fields/NumberField.rst +++ b/doc/fields/NumberField.rst @@ -13,8 +13,8 @@ Basic Information ----------------- * **PHP Class**: ``EasyCorp\Bundle\EasyAdminBundle\Field\NumberField`` -* **Doctrine DBAL Type** used to store this value: ``decimal``, ``float`` or - ``string`` +* **Doctrine DBAL Type** used to store this value: ``decimal``, ``float``, + ``string`` or ``number`` (``\BcMath\Number``, PHP 8.4+) * **Symfony Form Type** used to render the field: `NumberType`_ * **Rendered as**: @@ -71,6 +71,25 @@ constants of `PHP NumberFormatter class`_:: yield NumberField::new('...')->setRoundingMode(\NumberFormatter::ROUND_CEILING); +setStoredAsBcMathNumber +~~~~~~~~~~~~~~~~~~~~~~~ + +If your entity property uses ``\BcMath\Number`` (available in PHP 8.4+), use this +option to handle the conversion between the form input and the ``\BcMath\Number`` +object automatically:: + + yield NumberField::new('...')->setStoredAsBcMathNumber(); + +This is useful when your Doctrine entity uses the ``number`` column type:: + + #[ORM\Column(type: Types::NUMBER, precision: 8, scale: 2, nullable: true)] + public ?\BcMath\Number $price = null; + +.. caution:: + + This option requires PHP 8.4 or higher and cannot be combined with + ``setStoredAsString()``. + setStoredAsString ~~~~~~~~~~~~~~~~~ diff --git a/phpstan.neon.dist b/phpstan.neon.dist index efa20b779e..fa54e1d680 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -20,6 +20,9 @@ parameters: - '#Property EasyCorp\\Bundle\\EasyAdminBundle\\Twig\\EasyAdminTwigExtension::\$uxIconRuntime has unknown class Symfony\\UX\\Icons\\Twig\\UXIconRuntime as its type#' - '#Parameter \$uxIconRuntime of method EasyCorp\\Bundle\\EasyAdminBundle\\Twig\\EasyAdminTwigExtension::__construct\(\) has invalid type Symfony\\UX\\Icons\\Twig\\UXIconRuntime#' - '#Call to method renderIcon\(\) on an unknown class Symfony\\UX\\Icons\\Twig\\UXIconRuntime#' + - '#Class BcMath\\Number not found\.#' + - '#has invalid return type BcMath\\Number\.#' + - '#Instantiated class BcMath\\Number not found\.#' - identifier: missingType.generics treatPhpDocTypesAsCertain: false diff --git a/src/Field/Configurator/NumberConfigurator.php b/src/Field/Configurator/NumberConfigurator.php index d3d4940e84..d7fa2a53b7 100644 --- a/src/Field/Configurator/NumberConfigurator.php +++ b/src/Field/Configurator/NumberConfigurator.php @@ -28,15 +28,36 @@ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $c $scale = $field->getCustomOption(NumberField::OPTION_NUM_DECIMALS); $roundingMode = $field->getCustomOption(NumberField::OPTION_ROUNDING_MODE); $isStoredAsString = true === $field->getCustomOption(NumberField::OPTION_STORED_AS_STRING); + $isStoredAsBcMathNumber = true === $field->getCustomOption(NumberField::OPTION_STORED_AS_BCMATH_NUMBER); - $field->setFormTypeOptionIfNotSet('input', $isStoredAsString ? 'string' : 'number'); + if ($isStoredAsString && $isStoredAsBcMathNumber) { + throw new \InvalidArgumentException(sprintf('The "%s" field cannot use both "setStoredAsString()" and "setStoredAsBcMathNumber()" options at the same time.', $field->getProperty())); + } + + if ($isStoredAsBcMathNumber && \PHP_VERSION_ID < 80400) { + throw new \LogicException('The "setStoredAsBcMathNumber()" option requires PHP 8.4 or higher.'); + } + + $input = match (true) { + $isStoredAsString, $isStoredAsBcMathNumber => 'string', + default => 'number', + }; + $field->setFormTypeOptionIfNotSet('input', $input); $field->setFormTypeOptionIfNotSet('rounding_mode', $roundingMode); $field->setFormTypeOptionIfNotSet('scale', $scale); + if ($isStoredAsBcMathNumber) { + $field->setFormTypeOption('ea_bcmath_number', true); + } + if (null === $value = $field->getValue()) { return; } + if ($isStoredAsBcMathNumber) { + $value = (float) (string) $value; + } + $formatterAttributes = ['rounding_mode' => $this->getRoundingModeAsString($roundingMode)]; if (null !== $scale) { $formatterAttributes['fraction_digit'] = $scale; diff --git a/src/Field/NumberField.php b/src/Field/NumberField.php index 4459d31f0e..b4ceaa772a 100644 --- a/src/Field/NumberField.php +++ b/src/Field/NumberField.php @@ -3,7 +3,7 @@ namespace EasyCorp\Bundle\EasyAdminBundle\Field; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface; -use Symfony\Component\Form\Extension\Core\Type\NumberType; +use EasyCorp\Bundle\EasyAdminBundle\Form\Type\EaNumberType; use Symfony\Contracts\Translation\TranslatableInterface; /** @@ -16,6 +16,7 @@ final class NumberField implements FieldInterface public const OPTION_NUM_DECIMALS = 'numDecimals'; public const OPTION_ROUNDING_MODE = 'roundingMode'; public const OPTION_STORED_AS_STRING = 'storedAsString'; + public const OPTION_STORED_AS_BCMATH_NUMBER = 'storedAsBcMathNumber'; public const OPTION_NUMBER_FORMAT = 'numberFormat'; public const OPTION_THOUSANDS_SEPARATOR = 'thousandsSeparator'; public const OPTION_DECIMAL_SEPARATOR = 'decimalSeparator'; @@ -26,12 +27,13 @@ public static function new(string $propertyName, TranslatableInterface|string|bo ->setProperty($propertyName) ->setLabel($label) ->setTemplateName('crud/field/number') - ->setFormType(NumberType::class) + ->setFormType(EaNumberType::class) ->addCssClass('field-number') ->setDefaultColumns('col-md-4 col-xxl-3') ->setCustomOption(self::OPTION_NUM_DECIMALS, null) ->setCustomOption(self::OPTION_ROUNDING_MODE, \NumberFormatter::ROUND_HALFUP) ->setCustomOption(self::OPTION_STORED_AS_STRING, false) + ->setCustomOption(self::OPTION_STORED_AS_BCMATH_NUMBER, false) ->setCustomOption(self::OPTION_NUMBER_FORMAT, null) ->setCustomOption(self::OPTION_THOUSANDS_SEPARATOR, null) ->setCustomOption(self::OPTION_DECIMAL_SEPARATOR, null); @@ -76,6 +78,17 @@ public function setStoredAsString(bool $asString = true): self return $this; } + /** + * If true, the field value is stored as a \BcMath\Number instance (requires PHP 8.4+). + * This cannot be combined with setStoredAsString(). + */ + public function setStoredAsBcMathNumber(bool $asBcMathNumber = true): self + { + $this->setCustomOption(self::OPTION_STORED_AS_BCMATH_NUMBER, $asBcMathNumber); + + return $this; + } + // If set, all the other formatting options are ignored. This format is passed // directly to the first argument of `sprintf()` to format the number before displaying it public function setNumberFormat(string $sprintfFormat): self diff --git a/src/Form/DataTransformer/StringToBcMathNumberTransformer.php b/src/Form/DataTransformer/StringToBcMathNumberTransformer.php new file mode 100644 index 0000000000..f01d1a4494 --- /dev/null +++ b/src/Form/DataTransformer/StringToBcMathNumberTransformer.php @@ -0,0 +1,33 @@ +addModelTransformer(new StringToBcMathNumberTransformer()); + } + } + + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + + $resolver->setDefault('ea_bcmath_number', false); + $resolver->setAllowedTypes('ea_bcmath_number', 'bool'); + } + + public function getBlockPrefix(): string + { + return 'number'; + } +} diff --git a/tests/Unit/Field/NumberFieldTest.php b/tests/Unit/Field/NumberFieldTest.php index cd910afca3..064df9c40c 100644 --- a/tests/Unit/Field/NumberFieldTest.php +++ b/tests/Unit/Field/NumberFieldTest.php @@ -4,8 +4,8 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\NumberConfigurator; use EasyCorp\Bundle\EasyAdminBundle\Field\NumberField; +use EasyCorp\Bundle\EasyAdminBundle\Form\Type\EaNumberType; use EasyCorp\Bundle\EasyAdminBundle\Intl\IntlFormatter; -use Symfony\Component\Form\Extension\Core\Type\NumberType; class NumberFieldTest extends AbstractFieldTest { @@ -24,10 +24,11 @@ public function testDefaultOptions(): void self::assertNull($fieldDto->getCustomOption(NumberField::OPTION_NUM_DECIMALS)); self::assertSame(\NumberFormatter::ROUND_HALFUP, $fieldDto->getCustomOption(NumberField::OPTION_ROUNDING_MODE)); self::assertFalse($fieldDto->getCustomOption(NumberField::OPTION_STORED_AS_STRING)); + self::assertFalse($fieldDto->getCustomOption(NumberField::OPTION_STORED_AS_BCMATH_NUMBER)); self::assertNull($fieldDto->getCustomOption(NumberField::OPTION_NUMBER_FORMAT)); self::assertNull($fieldDto->getCustomOption(NumberField::OPTION_THOUSANDS_SEPARATOR)); self::assertNull($fieldDto->getCustomOption(NumberField::OPTION_DECIMAL_SEPARATOR)); - self::assertSame(NumberType::class, $fieldDto->getFormType()); + self::assertSame(EaNumberType::class, $fieldDto->getFormType()); self::assertStringContainsString('field-number', $fieldDto->getCssClass()); } @@ -254,4 +255,120 @@ public function testFormattedValueWithSmallDecimal(): void self::assertMatchesRegularExpression('/0[.,]00123/', $fieldDto->getFormattedValue()); } + + public function testSetStoredAsBcMathNumber(): void + { + if (\PHP_VERSION_ID < 80400) { + $this->markTestSkipped('BcMath\Number requires PHP 8.4 or higher.'); + } + + $field = NumberField::new('foo'); + $field->setStoredAsBcMathNumber(); + $fieldDto = $this->configure($field); + + self::assertTrue($fieldDto->getCustomOption(NumberField::OPTION_STORED_AS_BCMATH_NUMBER)); + self::assertSame('string', $fieldDto->getFormTypeOption('input')); + self::assertTrue($fieldDto->getFormTypeOption('ea_bcmath_number')); + } + + public function testSetStoredAsBcMathNumberFalse(): void + { + if (\PHP_VERSION_ID < 80400) { + $this->markTestSkipped('BcMath\Number requires PHP 8.4 or higher.'); + } + + $field = NumberField::new('foo'); + $field->setStoredAsBcMathNumber(false); + $fieldDto = $this->configure($field); + + self::assertFalse($fieldDto->getCustomOption(NumberField::OPTION_STORED_AS_BCMATH_NUMBER)); + self::assertSame('number', $fieldDto->getFormTypeOption('input')); + } + + public function testStoredAsBcMathNumberAndStoredAsStringThrowsException(): void + { + if (\PHP_VERSION_ID < 80400) { + $this->markTestSkipped('BcMath\Number requires PHP 8.4 or higher.'); + } + + $this->expectException(\InvalidArgumentException::class); + + $field = NumberField::new('foo'); + $field->setStoredAsString(); + $field->setStoredAsBcMathNumber(); + $this->configure($field); + } + + public function testFieldWithBcMathNumberValue(): void + { + if (\PHP_VERSION_ID < 80400) { + $this->markTestSkipped('BcMath\Number requires PHP 8.4 or higher.'); + } + + $field = NumberField::new('foo'); + $field->setValue(new \BcMath\Number('123.45')); + $field->setStoredAsBcMathNumber(); + $field->setNumDecimals(2); + $fieldDto = $this->configure($field); + + self::assertMatchesRegularExpression('/123[.,]45/', $fieldDto->getFormattedValue()); + } + + public function testFieldWithBcMathNumberNullValue(): void + { + if (\PHP_VERSION_ID < 80400) { + $this->markTestSkipped('BcMath\Number requires PHP 8.4 or higher.'); + } + + $field = NumberField::new('foo'); + $field->setValue(null); + $field->setStoredAsBcMathNumber(); + $fieldDto = $this->configure($field); + + self::assertNull($fieldDto->getValue()); + } + + public function testBcMathNumberWithThousandsSeparator(): void + { + if (\PHP_VERSION_ID < 80400) { + $this->markTestSkipped('BcMath\Number requires PHP 8.4 or higher.'); + } + + $field = NumberField::new('foo'); + $field->setValue(new \BcMath\Number('1234567.89')); + $field->setStoredAsBcMathNumber(); + $field->setThousandsSeparator(' '); + $field->setNumDecimals(2); + $fieldDto = $this->configure($field); + + self::assertStringContainsString('1 234 567', $fieldDto->getFormattedValue()); + } + + public function testBcMathNumberWithNumberFormat(): void + { + if (\PHP_VERSION_ID < 80400) { + $this->markTestSkipped('BcMath\Number requires PHP 8.4 or higher.'); + } + + $field = NumberField::new('foo'); + $field->setValue(new \BcMath\Number('42.5')); + $field->setStoredAsBcMathNumber(); + $field->setNumberFormat('%.3f'); + $fieldDto = $this->configure($field); + + self::assertSame('42.500', $fieldDto->getFormattedValue()); + } + + public function testStoredAsBcMathNumberRequiresPhp84(): void + { + if (\PHP_VERSION_ID >= 80400) { + $this->markTestSkipped('This test verifies that setStoredAsBcMathNumber() throws LogicException on PHP < 8.4 where BcMath\Number is not available.'); + } + + $this->expectException(\LogicException::class); + + $field = NumberField::new('foo'); + $field->setStoredAsBcMathNumber(); + $this->configure($field); + } } diff --git a/tests/Unit/Form/DataTransformer/StringToBcMathNumberTransformerTest.php b/tests/Unit/Form/DataTransformer/StringToBcMathNumberTransformerTest.php new file mode 100644 index 0000000000..3791193dbb --- /dev/null +++ b/tests/Unit/Form/DataTransformer/StringToBcMathNumberTransformerTest.php @@ -0,0 +1,73 @@ +markTestSkipped('BcMath\Number requires PHP 8.4 or higher.'); + } + + $this->transformer = new StringToBcMathNumberTransformer(); + } + + public function testTransformNull(): void + { + self::assertNull($this->transformer->transform(null)); + } + + public function testTransformBcMathNumber(): void + { + $number = new \BcMath\Number('123.45'); + + self::assertSame('123.45', $this->transformer->transform($number)); + } + + public function testTransformInvalidTypeThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->transformer->transform(123.45); + } + + public function testReverseTransformNull(): void + { + self::assertNull($this->transformer->reverseTransform(null)); + } + + public function testReverseTransformEmptyString(): void + { + self::assertNull($this->transformer->reverseTransform('')); + } + + public function testReverseTransformString(): void + { + $result = $this->transformer->reverseTransform('123.45'); + + self::assertInstanceOf(\BcMath\Number::class, $result); + self::assertSame('123.45', (string) $result); + } + + public function testReverseTransformInteger(): void + { + $result = $this->transformer->reverseTransform('42'); + + self::assertInstanceOf(\BcMath\Number::class, $result); + self::assertSame('42', (string) $result); + } + + public function testReverseTransformNegativeNumber(): void + { + $result = $this->transformer->reverseTransform('-99.99'); + + self::assertInstanceOf(\BcMath\Number::class, $result); + self::assertSame('-99.99', (string) $result); + } +}