diff --git a/src/Symfony/Bridge/Doctrine/Form/DataTransformer/NumberToStringTransformer.php b/src/Symfony/Bridge/Doctrine/Form/DataTransformer/NumberToStringTransformer.php new file mode 100644 index 0000000000000..803df3c1d9935 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Form/DataTransformer/NumberToStringTransformer.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Form\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; + +class NumberToStringTransformer implements DataTransformerInterface +{ + /** + * @var bool + */ + private $forceFullScale; + + /** + * @var int|null + */ + private $scale; + + /** + * @param bool $forceFullScale + * @param int|null $scale + */ + public function __construct($forceFullScale = false, $scale = null) + { + $this->forceFullScale = $forceFullScale; + $this->scale = $scale; + } + + /** + * @param mixed $value + * + * @return string|null + */ + public function transform($value) + { + if (null === $value) { + return null; + } + + if (!is_string($value)) { + throw new TransformationFailedException('Expected a string.'); + } + + return $value; + } + + /** + * @param mixed $value + * + * @return string|null + */ + public function reverseTransform($value) + { + if (null === $value) { + return null; + } + + if (is_string($value)) { + return $value; + } + + $valueIsInt = is_int($value); + if (!$valueIsInt && !is_float($value)) { + throw new TransformationFailedException('Expected an int or a float.'); + } + + if ($this->forceFullScale && is_int($this->scale)) { + if ($valueIsInt) { + $value = floatval($value); + } + + return number_format($value, $this->scale, '.', ''); + } + + try { + return (string) $value; + } catch (\Exception $e) { + throw new TransformationFailedException(); + } + } +} diff --git a/src/Symfony/Bridge/Doctrine/Form/DoctrineOrmTypeGuesser.php b/src/Symfony/Bridge/Doctrine/Form/DoctrineOrmTypeGuesser.php index 86d96f08a172a..50b5ba92b192e 100644 --- a/src/Symfony/Bridge/Doctrine/Form/DoctrineOrmTypeGuesser.php +++ b/src/Symfony/Bridge/Doctrine/Form/DoctrineOrmTypeGuesser.php @@ -16,6 +16,7 @@ use Doctrine\DBAL\Types\Type; use Doctrine\ORM\Mapping\ClassMetadataInfo; use Doctrine\ORM\Mapping\MappingException as LegacyMappingException; +use Symfony\Bridge\Doctrine\Form\Type\DecimalType; use Symfony\Component\Form\FormTypeGuesserInterface; use Symfony\Component\Form\Guess\Guess; use Symfony\Component\Form\Guess\TypeGuess; @@ -74,6 +75,7 @@ public function guessType($class, $property) case 'time_immutable': return new TypeGuess('Symfony\Component\Form\Extension\Core\Type\TimeType', array('input' => 'datetime_immutable'), Guess::HIGH_CONFIDENCE); case Type::DECIMAL: + return new TypeGuess(DecimalType::class, array(), Guess::HIGH_CONFIDENCE); case Type::FLOAT: return new TypeGuess('Symfony\Component\Form\Extension\Core\Type\NumberType', array(), Guess::MEDIUM_CONFIDENCE); case Type::INTEGER: diff --git a/src/Symfony/Bridge/Doctrine/Form/Type/DecimalType.php b/src/Symfony/Bridge/Doctrine/Form/Type/DecimalType.php new file mode 100644 index 0000000000000..6c67aacd4bcbb --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Form/Type/DecimalType.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Form\Type; + +use Symfony\Bridge\Doctrine\Form\DataTransformer\NumberToStringTransformer; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\NumberType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class DecimalType extends AbstractType +{ + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->addModelTransformer(new NumberToStringTransformer($options['force_full_scale'], $options['scale'])); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults(array( + 'force_full_scale' => false + )); + $resolver->setAllowedTypes('force_full_scale', array( + 'boolean' + )); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return NumberType::class; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/Price.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/Price.php new file mode 100644 index 0000000000000..3601d30d03fa2 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/Price.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +use Doctrine\ORM\Mapping\Entity; +use Doctrine\ORM\Mapping\Id; +use Doctrine\ORM\Mapping\Column; + +/** @Entity */ +class Price +{ + /** @Id @Column(type="integer") */ + public $id; + + /** @Column(type="decimal", scale=2) */ + public $doesNotPreserveFullScaleValue; + + /** @Column(type="string") */ + public $preserveFullScaleValueSimulation; + + /** + * @param int $id + * @param float $value + */ + public function __construct(int $id, float $value) + { + $this->id = $id; + $this->doesNotPreserveFullScaleValue = $value; + $this->preserveFullScaleValueSimulation = number_format($value, 2, '.', ''); + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Form/Type/DecimalTypeTest.php b/src/Symfony/Bridge/Doctrine/Tests/Form/Type/DecimalTypeTest.php new file mode 100644 index 0000000000000..f2ae341107941 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Form/Type/DecimalTypeTest.php @@ -0,0 +1,177 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Form\Type; + +use Doctrine\ORM\EntityManager; +use Doctrine\ORM\Tools\SchemaTool; +use Symfony\Bridge\Doctrine\Form\Type\DecimalType; +use Symfony\Bridge\Doctrine\Test\DoctrineTestHelper; +use Symfony\Bridge\Doctrine\Tests\Fixtures\Price; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Tests\Extension\Core\Type\BaseTypeTest; + +class DecimalTypeTest extends BaseTypeTest +{ + /** + * @var string + */ + const TESTED_TYPE = DecimalType::class; + + /** + * @var EntityManager + */ + private $em; + + protected function setUp() + { + $this->em = DoctrineTestHelper::createTestEntityManager(); + + parent::setUp(); + + $schemaTool = new SchemaTool($this->em); + $classes = array( + $this->em->getClassMetadata(Price::class) + ); + + try { + $schemaTool->dropSchema($classes); + } catch (\Exception $e) { + } + + try { + $schemaTool->createSchema($classes); + } catch (\Exception $e) { + } + } + + protected function tearDown() + { + parent::tearDown(); + + $this->em = null; + } + + // On some platforms, fetched decimal values are rounded (the full scale is not preserved) + // eg : on SQLite, inserted float value 4.50 will be fetched as string value "4.5" + public function testSubmitWithSameStringValueOnAPlatformThatDoesNotPreserveFullScaleValueWithoutForceFullScale() + { + $fullScalePrice = new Price(1, 1.23); + $nonFullScalePrice = new Price(2, 4.50); + $this->em->persist($fullScalePrice); + $this->em->persist($nonFullScalePrice); + $this->em->flush(); + + $this->em->refresh($fullScalePrice); + $this->em->refresh($nonFullScalePrice); + + $this->assertInternalType('string', $fullScalePrice->doesNotPreserveFullScaleValue); + $fullScalePriceStringValue = $fullScalePrice->doesNotPreserveFullScaleValue; + + $formBuilder = $this->factory->createBuilder(FormType::class, $fullScalePrice, array( + 'data_class' => Price::class + )); + $formBuilder->add('doesNotPreserveFullScaleValue', static::TESTED_TYPE, array( + 'force_full_scale' => false + )); + + $form = $formBuilder->getForm(); + $form->submit(array( + 'doesNotPreserveFullScaleValue' => $fullScalePriceStringValue + )); + + $this->assertSame($fullScalePriceStringValue, $fullScalePrice->doesNotPreserveFullScaleValue); + + $this->assertInternalType('string', $nonFullScalePrice->doesNotPreserveFullScaleValue); + $nonFullScalePriceStringValue = $nonFullScalePrice->doesNotPreserveFullScaleValue; + + $formBuilder = $this->factory->createBuilder(FormType::class, $nonFullScalePrice, array( + 'data_class' => Price::class + )); + $formBuilder->add('doesNotPreserveFullScaleValue', static::TESTED_TYPE, array( + 'force_full_scale' => false + )); + + $form = $formBuilder->getForm(); + $form->submit(array( + 'doesNotPreserveFullScaleValue' => $nonFullScalePriceStringValue + )); + + $this->assertSame($nonFullScalePriceStringValue, $nonFullScalePrice->doesNotPreserveFullScaleValue); + + $unitOfWork = $this->em->getUnitOfWork(); + $unitOfWork->computeChangeSets(); + + $this->assertSame(array(), $unitOfWork->getEntityChangeSet($fullScalePrice)); + $this->assertSame(array(), $unitOfWork->getEntityChangeSet($nonFullScalePrice)); + } + + // On some platforms, fetched decimal values are not rounded at all (the full scale is preserved) + // eg : on PostgreSQL, inserted float value 4.50 will be fetched as string value "4.50" + public function testSubmitWithSameStringValueOnAPlatformThatPreserveFullScaleValueWithForceFullScale() + { + $fullScalePrice = new Price(1, 1.23); + $nonFullScalePrice = new Price(2, 4.50); + $this->em->persist($fullScalePrice); + $this->em->persist($nonFullScalePrice); + $this->em->flush(); + + $this->em->refresh($fullScalePrice); + $this->em->refresh($nonFullScalePrice); + + $this->assertInternalType('string', $fullScalePrice->preserveFullScaleValueSimulation); + $fullScalePriceStringValue = $fullScalePrice->preserveFullScaleValueSimulation; + + $formBuilder = $this->factory->createBuilder(FormType::class, $fullScalePrice, array( + 'data_class' => Price::class + )); + $formBuilder->add('preserveFullScaleValueSimulation', static::TESTED_TYPE, array( + 'force_full_scale' => true, + 'scale' => 2 + )); + + $form = $formBuilder->getForm(); + $form->submit(array( + 'preserveFullScaleValueSimulation' => $fullScalePriceStringValue + )); + + $this->assertSame($fullScalePriceStringValue, $fullScalePrice->preserveFullScaleValueSimulation); + + $this->assertInternalType('string', $nonFullScalePrice->preserveFullScaleValueSimulation); + $nonFullScalePriceStringValue = $nonFullScalePrice->preserveFullScaleValueSimulation; + + $formBuilder = $this->factory->createBuilder(FormType::class, $nonFullScalePrice, array( + 'data_class' => Price::class + )); + $formBuilder->add('preserveFullScaleValueSimulation', static::TESTED_TYPE, array( + 'force_full_scale' => true, + 'scale' => 2 + )); + + $form = $formBuilder->getForm(); + $form->submit(array( + 'preserveFullScaleValueSimulation' => $nonFullScalePriceStringValue + )); + + $this->assertSame($nonFullScalePriceStringValue, $nonFullScalePrice->preserveFullScaleValueSimulation); + + $unitOfWork = $this->em->getUnitOfWork(); + $unitOfWork->computeChangeSets(); + + $this->assertSame(array(), $unitOfWork->getEntityChangeSet($fullScalePrice)); + $this->assertSame(array(), $unitOfWork->getEntityChangeSet($nonFullScalePrice)); + } + + public function testSubmitNull($expected = null, $norm = null, $view = null) + { + parent::testSubmitNull($expected, $norm, ''); + } +}