feature #30893 Add "input" option to NumberType (fancyweb, Bernhard Schussek)

This PR was merged into the 4.3-dev branch.

Discussion
----------

Add "input" option to NumberType

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #24793
| License       | MIT
| Doc PR        | TODO

This PR replaces #24793 in (partially) fixing how Doctrine's DECIMAL type is handled by the Form component.

Previously, DECIMAL was mapped to the regular NumberType. That confuses Doctrine's change detection, depending on the DB platform.

Examples:

| DB | DB value | Doctrine entity before submit | Form input | Doctrine entity after submit
| --- | --- | --- | --- | ---
| SQLite | 8.000 | '8' | 8 | 8
| SQLite | 8.123 | '8.123' | 8.123 | 8.123
| PostgreSQL | 8.000 | '8.000' | 8 | 8
| PostgreSQL | 8.123 | '8.123' | 8.123 | 8.123

The value in the Doctrine entity changes before and after submit. Hence Doctrine believes an update is necessary.

This PR introduces an `input` option to NumberType (similar to DateType), that can be set to `'number'` (default) or `'string'`. If set to `'string'`, the conversion is as follows:

| DB | DB value | Doctrine entity before submit | Form input | Doctrine entity after submit
| --- | --- | --- | --- | ---
| SQLite | 8.000 | **'8'** | 8 | **'8.000'**
| SQLite | 8.123 | '8.123' | 8.123 | '8.123'
| PostgreSQL | 8.000 | '8.000' | 8 | '8.000'
| PostgreSQL | 8.123 | '8.123' | 8.123 | '8.123'

You see that this does not completely solve this issue for SQLite. However, @Ocramius and I agree that this is something to be fixed by Doctrine, since Doctrine is providing the database abstraction.

That fix should be done in the SqlitePlatform object in Doctrine as part of another PR and should be backwards compatible with the current Doctrine version (i.e. opt in via configuration).

Compared to #24793, this PR does not introduce a new type but instead makes the NumberType more flexible. Also, this PR does not introduce the `force_full_scale` option since that should be solved by Doctrine as described above.

Commits
-------

3f25734647 Added new option "input" to NumberType
fb2b37a8f3 add force_full_scale option to handle all cases
40f25121c3 [DoctrineBridge] Add decimal form type
This commit is contained in:
Fabien Potencier 2019-04-06 20:17:43 +02:00
commit 4c78e60ad5
7 changed files with 243 additions and 5 deletions

View File

@ -1,6 +1,11 @@
CHANGELOG
=========
4.3.0
-----
* changed guessing of DECIMAL to set the `input` option of `NumberType` to string
4.2.0
-----

View File

@ -75,6 +75,7 @@ class DoctrineOrmTypeGuesser implements FormTypeGuesserInterface
case 'time_immutable':
return new TypeGuess('Symfony\Component\Form\Extension\Core\Type\TimeType', ['input' => 'datetime_immutable'], Guess::HIGH_CONFIDENCE);
case Type::DECIMAL:
return new TypeGuess('Symfony\Component\Form\Extension\Core\Type\NumberType', ['input' => 'string'], Guess::MEDIUM_CONFIDENCE);
case Type::FLOAT:
return new TypeGuess('Symfony\Component\Form\Extension\Core\Type\NumberType', [], Guess::MEDIUM_CONFIDENCE);
case Type::INTEGER:

View File

@ -47,6 +47,7 @@ CHANGELOG
* dispatch `PostSubmitEvent` on `form.post_submit`
* dispatch `PreSetDataEvent` on `form.pre_set_data`
* dispatch `PostSetDataEvent` on `form.post_set_data`
* added an `input` option to `NumberType`
4.2.0
-----

View File

@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Form\Extension\Core\DataTransformer;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
class StringToFloatTransformer implements DataTransformerInterface
{
private $scale;
public function __construct(int $scale = null)
{
$this->scale = $scale;
}
/**
* @param mixed $value
*
* @return float|null
*/
public function transform($value)
{
if (null === $value) {
return null;
}
if (!\is_string($value) || !is_numeric($value)) {
throw new TransformationFailedException('Expected a numeric string.');
}
return (float) $value;
}
/**
* @param mixed $value
*
* @return string|null
*/
public function reverseTransform($value)
{
if (null === $value) {
return null;
}
if (!\is_int($value) && !\is_float($value)) {
throw new TransformationFailedException('Expected a numeric.');
}
if ($this->scale > 0) {
return number_format((float) $value, $this->scale, '.', '');
}
return (string) $value;
}
}

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\Form\Extension\Core\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Exception\LogicException;
use Symfony\Component\Form\Extension\Core\DataTransformer\NumberToLocalizedStringTransformer;
use Symfony\Component\Form\Extension\Core\DataTransformer\StringToFloatTransformer;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
@ -33,6 +34,10 @@ class NumberType extends AbstractType
$options['rounding_mode'],
$options['html5'] ? 'en' : null
));
if ('string' === $options['input']) {
$builder->addModelTransformer(new StringToFloatTransformer($options['scale']));
}
}
/**
@ -56,6 +61,7 @@ class NumberType extends AbstractType
'grouping' => false,
'rounding_mode' => NumberToLocalizedStringTransformer::ROUND_HALF_UP,
'compound' => false,
'input' => 'number',
'html5' => false,
]);
@ -68,7 +74,7 @@ class NumberType extends AbstractType
NumberToLocalizedStringTransformer::ROUND_UP,
NumberToLocalizedStringTransformer::ROUND_CEILING,
]);
$resolver->setAllowedValues('input', ['number', 'string']);
$resolver->setAllowedTypes('scale', ['null', 'int']);
$resolver->setAllowedTypes('html5', 'bool');

View File

@ -0,0 +1,104 @@
<?php
/*
* This file is part of the symfony/symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Form\Tests\Extension\Core\DataTransformer;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Extension\Core\DataTransformer\StringToFloatTransformer;
class StringToFloatTransformerTest extends TestCase
{
private $transformer;
protected function setUp()
{
$this->transformer = new StringToFloatTransformer();
}
protected function tearDown()
{
$this->transformer = null;
}
public function provideTransformations(): array
{
return [
[null, null],
['1', 1.],
['1.', 1.],
['1.0', 1.],
['1.23', 1.23],
];
}
/**
* @dataProvider provideTransformations
*/
public function testTransform($from, $to): void
{
$transformer = new StringToFloatTransformer();
$this->assertSame($to, $transformer->transform($from));
}
/**
* @expectedException \Symfony\Component\Form\Exception\TransformationFailedException
*/
public function testFailIfTransformingANonString(): void
{
$transformer = new StringToFloatTransformer();
$transformer->transform(1.0);
}
/**
* @expectedException \Symfony\Component\Form\Exception\TransformationFailedException
*/
public function testFailIfTransformingANonNumericString(): void
{
$transformer = new StringToFloatTransformer();
$transformer->transform('foobar');
}
public function provideReverseTransformations(): array
{
return [
[null, null],
[1, '1'],
[1., '1'],
[1.0, '1'],
[1.23, '1.23'],
[1, '1.000', 3],
[1.0, '1.000', 3],
[1.23, '1.230', 3],
[1.2344, '1.234', 3],
[1.2345, '1.235', 3],
];
}
/**
* @dataProvider provideReverseTransformations
*/
public function testReverseTransform($from, $to, int $scale = null): void
{
$transformer = new StringToFloatTransformer($scale);
$this->assertSame($to, $transformer->reverseTransform($from));
}
/**
* @expectedException \Symfony\Component\Form\Exception\TransformationFailedException
*/
public function testFailIfReverseTransformingANonNumeric(): void
{
$transformer = new StringToFloatTransformer();
$transformer->reverseTransform('foobar');
}
}

View File

@ -27,7 +27,7 @@ class NumberTypeTest extends BaseTypeTest
\Locale::setDefault('de_DE');
}
public function testDefaultFormatting()
public function testDefaultFormatting(): void
{
$form = $this->factory->create(static::TESTED_TYPE);
$form->setData('12345.67890');
@ -35,7 +35,7 @@ class NumberTypeTest extends BaseTypeTest
$this->assertSame('12345,679', $form->createView()->vars['value']);
}
public function testDefaultFormattingWithGrouping()
public function testDefaultFormattingWithGrouping(): void
{
$form = $this->factory->create(static::TESTED_TYPE, null, ['grouping' => true]);
$form->setData('12345.67890');
@ -43,7 +43,7 @@ class NumberTypeTest extends BaseTypeTest
$this->assertSame('12.345,679', $form->createView()->vars['value']);
}
public function testDefaultFormattingWithScale()
public function testDefaultFormattingWithScale(): void
{
$form = $this->factory->create(static::TESTED_TYPE, null, ['scale' => 2]);
$form->setData('12345.67890');
@ -51,7 +51,23 @@ class NumberTypeTest extends BaseTypeTest
$this->assertSame('12345,68', $form->createView()->vars['value']);
}
public function testDefaultFormattingWithRounding()
public function testDefaultFormattingWithScaleFloat(): void
{
$form = $this->factory->create(static::TESTED_TYPE, null, ['scale' => 2]);
$form->setData(12345.67890);
$this->assertSame('12345,68', $form->createView()->vars['value']);
}
public function testDefaultFormattingWithScaleAndStringInput(): void
{
$form = $this->factory->create(static::TESTED_TYPE, null, ['scale' => 2, 'input' => 'string']);
$form->setData('12345.67890');
$this->assertSame('12345,68', $form->createView()->vars['value']);
}
public function testDefaultFormattingWithRounding(): void
{
$form = $this->factory->create(static::TESTED_TYPE, null, ['scale' => 0, 'rounding_mode' => \NumberFormatter::ROUND_UP]);
$form->setData('12345.54321');
@ -76,6 +92,46 @@ class NumberTypeTest extends BaseTypeTest
$this->assertSame($expectedData, $form->getData());
}
public function testSubmitNumericInput(): void
{
$form = $this->factory->create(static::TESTED_TYPE, null, ['input' => 'number']);
$form->submit('1,234');
$this->assertSame(1.234, $form->getData());
$this->assertSame(1.234, $form->getNormData());
$this->assertSame('1,234', $form->getViewData());
}
public function testSubmitNumericInputWithScale(): void
{
$form = $this->factory->create(static::TESTED_TYPE, null, ['input' => 'number', 'scale' => 2]);
$form->submit('1,234');
$this->assertSame(1.23, $form->getData());
$this->assertSame(1.23, $form->getNormData());
$this->assertSame('1,23', $form->getViewData());
}
public function testSubmitStringInput(): void
{
$form = $this->factory->create(static::TESTED_TYPE, null, ['input' => 'string']);
$form->submit('1,234');
$this->assertSame('1.234', $form->getData());
$this->assertSame(1.234, $form->getNormData());
$this->assertSame('1,234', $form->getViewData());
}
public function testSubmitStringInputWithScale(): void
{
$form = $this->factory->create(static::TESTED_TYPE, null, ['input' => 'string', 'scale' => 2]);
$form->submit('1,234');
$this->assertSame('1.23', $form->getData());
$this->assertSame(1.23, $form->getNormData());
$this->assertSame('1,23', $form->getViewData());
}
public function testIgnoresDefaultLocaleToRenderHtml5NumberWidgets()
{
$form = $this->factory->create(static::TESTED_TYPE, null, [