We're storing all our money related values as cents in our database (ODM but ORM will likely behave the same). We're using MoneyType to convert user facing values (12,34€) into their cents representation (1234c). The typical float precision problem arises here: due to insufficient precision there are many cases that create rounding errors that are merely visible when debugging. MoneyType will convert incoming strings to floats that may be not precise ("1765" => 1764.9999999998).
Things get bad as soon as you persist these values:
class Price {
/**
* @var int
* @MongoDB\Field(type="int")
**/
protected $cents;
}
will transform the incoming values (which are float!) like:
namespace Doctrine\ODM\MongoDB\Types;
class IntType extends Type
{
public function convertToDatabaseValue($value)
{
return $value !== null ? (integer) $value : null;
}
}
The (integer) cast will strip off the value's mantissa instead of rounding the value, effectively leading to writing wrong values into the database (1764 instead of 1765 when "1765" is internally 1764.9999999998).
Here's an unit test that should display the issue from within any Symfony2 container:
//for better debugging: set ini_set('precision', 17);
class PrecisionTest extends WebTestCase
{
private function buildForm() {
$builder = $this->getContainer()->get('form.factory')->createBuilder(FormType::class, null, []);
$form = $builder->add('money', MoneyType::class, [
'divisor' => 100
])->getForm();
return $form;
}
// high-level symptom
public function testMoneyType() {
$form = $this->buildForm();
$form->submit(['money' => '12,34']);
$data = $form->getData();
$this->assertEquals(1234, $data['money']);
$this->assertEquals(1234, (int)$data['money']);
$form = $this->buildForm();
$form->submit(['money' => '17,65']);
$data = $form->getData();
$this->assertEquals(1765, $data['money']);
$this->assertEquals(1765, (int)$data['money']); //fails: data[money] === 1764
}
//root cause
public function testParsedIntegerPrecision() {
$string = "17,65";
$transformer = new MoneyToLocalizedStringTransformer(2, false,null, 100);
$value = $transformer->reverseTransform($string);
$int = (integer) $value;
$float = (float) $value;
$this->assertEquals(1765, (float)$float);
$this->assertEquals(1765, $int); //fails: $int === 1764
}
}
Note, that this issue is not always visible! As you can see "12,34" is working well, "17,65" or "18,65" will fail.
What is the best way to work around here (in terms of Symfony Forms / Doctrine)? The NumberTransformer or MoneyType aren't supposed to return integer values - people might also want to save floats so we cannot solve the issue there. I thought about overriding the IntType in the persistence layer, effectively rounding every incoming integer value instead of casting. Another approach would be to store the field as float in MongoDB...
The basic PHP problem is discussed here.
For now I decided to go with my own MoneyType that calls "round" on integers internally.
<?php
namespace AcmeBundle\Form;
use Symfony\Component\Form\FormBuilderInterface;
class MoneyToLocalizedStringTransformer extends \Symfony\Component\Form\Extension\Core\DataTransformer\MoneyToLocalizedStringTransformer {
public function reverseTransform($value)
{
return round(parent::reverseTransform($value));
}
}
class MoneyType extends \Symfony\Component\Form\Extension\Core\Type\MoneyType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->addViewTransformer(new MoneyToLocalizedStringTransformer(
$options['scale'],
$options['grouping'],
null,
$options['divisor']
))
;
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With