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 0000000000..803df3c1d9 --- /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/Type/DecimalType.php b/src/Symfony/Bridge/Doctrine/Form/Type/DecimalType.php index 9956c3de10..6c67aacd4b 100644 --- a/src/Symfony/Bridge/Doctrine/Form/Type/DecimalType.php +++ b/src/Symfony/Bridge/Doctrine/Form/Type/DecimalType.php @@ -11,11 +11,11 @@ namespace Symfony\Bridge\Doctrine\Form\Type; +use Symfony\Bridge\Doctrine\Form\DataTransformer\NumberToStringTransformer; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\CallbackTransformer; -use Symfony\Component\Form\Exception\TransformationFailedException; use Symfony\Component\Form\Extension\Core\Type\NumberType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; class DecimalType extends AbstractType { @@ -24,27 +24,20 @@ class DecimalType extends AbstractType */ public function buildForm(FormBuilderInterface $builder, array $options) { - $builder->addModelTransformer(new CallbackTransformer(function ($value) { - if (null === $value) { - return null; - } + $builder->addModelTransformer(new NumberToStringTransformer($options['force_full_scale'], $options['scale'])); + } - if (!is_string($value)) { - throw new TransformationFailedException('Expected a string.'); - } - - return $value; - }, function ($value) { - if (null === $value) { - return null; - } - - if (!is_int($value) && !is_float($value)) { - throw new TransformationFailedException('Expected an int or a float.'); - } - - return (string) $value; - })); + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults(array( + 'force_full_scale' => false + )); + $resolver->setAllowedTypes('force_full_scale', array( + 'boolean' + )); } /** diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/Price.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/Price.php index bd7c645766..3601d30d03 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/Price.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/Price.php @@ -21,8 +21,11 @@ class Price /** @Id @Column(type="integer") */ public $id; - /** @Column(type="decimal") */ - public $value; + /** @Column(type="decimal", scale=2) */ + public $doesNotPreserveFullScaleValue; + + /** @Column(type="string") */ + public $preserveFullScaleValueSimulation; /** * @param int $id @@ -31,6 +34,7 @@ class Price public function __construct(int $id, float $value) { $this->id = $id; - $this->value = $value; + $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 index b7601b1a94..f2ae341107 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Form/Type/DecimalTypeTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Form/Type/DecimalTypeTest.php @@ -60,33 +60,114 @@ class DecimalTypeTest extends BaseTypeTest $this->em = null; } - public function testSubmitWithSameStringValue() + // 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() { - $price = new Price(1, 1.23); - $this->em->persist($price); + $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($price); + $this->em->refresh($fullScalePrice); + $this->em->refresh($nonFullScalePrice); - $this->assertInternalType('string', $price->value); - $stringValue = $price->value; + $this->assertInternalType('string', $fullScalePrice->doesNotPreserveFullScaleValue); + $fullScalePriceStringValue = $fullScalePrice->doesNotPreserveFullScaleValue; - $formBuilder = $this->factory->createBuilder(FormType::class, $price, array( + $formBuilder = $this->factory->createBuilder(FormType::class, $fullScalePrice, array( 'data_class' => Price::class )); - $formBuilder->add('value', static::TESTED_TYPE); + $formBuilder->add('doesNotPreserveFullScaleValue', static::TESTED_TYPE, array( + 'force_full_scale' => false + )); $form = $formBuilder->getForm(); $form->submit(array( - 'value' => $stringValue + 'doesNotPreserveFullScaleValue' => $fullScalePriceStringValue )); - $this->assertSame($stringValue, $price->value); + $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($price)); + $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)