-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[Form] Fix precision of MoneyToLocalizedStringTransformer's divisions and multiplications #24036
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
@@ -11,6 +11,7 @@ | |||
|
|||
namespace Symfony\Component\Form\Extension\Core\DataTransformer; | |||
|
|||
use function function_exists; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should be removed
$transformer = new MoneyToLocalizedStringTransformer(null, null, null, 100); | ||
|
||
if (!function_exists('bcmul')) { | ||
$this->markTestSkipped('Test will only work when bcmath-extension is enabled'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use @requires function bcmul
(or @requires extension bcmath
) instead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should be removed now that the code doesn't use bcmath functions anymore
IMO this is a bugfix and should then be based on the |
and even if we decide to consider it as a feature, it should go in 3.4, not in master (which is currently 4.0) |
if (!function_exists('bcmul')) { | ||
$this->markTestSkipped('Test will only work when bcmath-extension is enabled'); | ||
} | ||
$this->assertEquals(3655, $transformer->reverseTransform('36.55')); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I don't need this assertions because $this->assertSame(3655, (int) $transformer->reverseTransform('36.55'));
should fullfill this case. I will remove it if you don't have objections.
We should try harder to fix the issue for everyone without resorting to bcmath: the |
@nicolas-grekas polyfill for bcmath? ;) Not that trivial I assume. |
@@ -80,7 +87,7 @@ public function reverseTransform($value) | |||
$value = parent::reverseTransform($value); | |||
|
|||
if (null !== $value) { | |||
$value *= $this->divisor; | |||
$value = function_exists('bcmul') ? bcmul($value, $this->divisor, $this->scale) : $value * $this->divisor; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This changes the return value to string (which bcmath works with) which could be a bc break.
@Tobion I think bcmath-polyfill wouldn't be out of the question. I have seen stuff like adding/multiplying large numbers as code kata before. I think it's more of a question of whether the trade off precision vs. performance is worth it? After all, if I know this issue I can avoid it in other ways in my application which better suit my needs. For example I could be doing edit: But I would certainly be interested in seeing a more generic solution to this. I'm interested to see what @nicolas-grekas or someone else can come up with. 🙂 |
@@ -68,6 +73,8 @@ public function transform($value) | |||
/** | |||
* Transforms a localized money string into a normalized format. | |||
* | |||
* @requires extension bcmath |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should be removed or replaced with @uses.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it's not needed anymore
I think we can work around the issue by enforcing a precision of 14, which is way enough for a money transformer, and is the minimal precision to represent most float accurately (the max is 17, but we don't care for the potential precision loss at this scale in this specific case). |
When working with money/currency and precision is required, the usage of strings and proper libraries should be enforced. The usage of floats/doubles and simply using |
See #21769 for a similar issue that we solved using cast-to-string. |
@nicolas-grekas thank you for digging down through the older PRs.
I mean we could do it the other way around and cast the string from my solution to float to stick to the return type:
But this looks a bit weird in my opinion ^^. What do you prefer? |
public function reverseTransform($value)
{
$value = parent::reverseTransform($value);
if (null !== $value) {
$value = (string) ($value * $this->divisor);
if (ctype_digit($value)) {
$value = (int) $value;
} else {
$value = (float) $value;
}
}
return $value;
} What about something like this? |
} | ||
|
||
/** | ||
* Transforms a normalized format into a localized money string. | ||
* | ||
* @requires extension bcmath |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
that's now not true anymore
@@ -23,6 +23,8 @@ class MoneyToLocalizedStringTransformer extends NumberToLocalizedStringTransform | |||
{ | |||
private $divisor; | |||
|
|||
private $scale; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be removed
@@ -40,11 +42,14 @@ public function __construct($scale = 2, $grouping = true, $roundingMode = self:: | |||
} | |||
|
|||
$this->divisor = $divisor; | |||
$this->scale = $scale; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be removed
@smoench. Thank you for that hint. I removed it. |
$value = (string) ($value / $this->divisor); | ||
|
||
if (ctype_digit($value)) { | ||
$value = (int) $value; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you don't need the integer logic as it doesn't matter for parent::transform
whether it's int or float. just cast it to float as it was a float before as well.
$value = (string) ($value * $this->divisor); | ||
|
||
if (ctype_digit($value)) { | ||
$value = (int) $value; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it returned a float always due to the multiplikation with the divisor. changing that to int is a behavior change. I'd recommend to just cast to to float always.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would be $value = (float) (string) ($value * $this->divisor)
okay as solution? @Tobion
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe the string convert is not necessary? I tested it and if we always convert to float, everything seems fine. https://3v4l.org/8S9Hg
But we have to change the return typ to float only. Is that a bc?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The return value has always been float. The problem is when you cast the return value to int in user-land code. There has never been a "bug" in the class itself. I thought you know what you are trying to fix.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am aware of what I am trying to contribute here. Its the same problem as here #21769 (thanks to nicolas-grekas, who linked that PR in his comment).
I will cast the string to float afterwards to fulfill your objection.
@@ -80,7 +86,13 @@ public function reverseTransform($value) | |||
$value = parent::reverseTransform($value); | |||
|
|||
if (null !== $value) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd propose to change this to if (null !== $value && 1 !== $this-divisor)
as the whole divisor logic does not make sense when it's 1 (the default value). please do the same in the transform
method as well.
Could this be merged? |
if (!is_numeric($value)) { | ||
throw new TransformationFailedException('Expected a numeric.'); | ||
} | ||
|
||
$value /= $this->divisor; | ||
$value = (string) ($value / $this->divisor); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks wrong to me. Previously, $value
was a float here, but now it is a string.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I changed it from (float) (string) ($value / $this->divisor);
to (string) ($value / $this->divisor);
because this functions purpose is to Transforms a normalized format into a localized money string. according to the PHPDoc-Block. The return type is also string, thats why I changed it. Should I put a float cast in front of it again?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
that is fine. the parent transform should be able handle both string or float.
Some background: We know floats cannot be represented perfectly so there is some lost precision. I wondered how many common montary values are affected by this difference and if it's reliable. Or if there are numbers where the string cast approach actually doesn't work as expected as well. I wrote a little script: https://3v4l.org/JdfjZ For numbers between 1 and 99999. |
That makes me wonder by PHP by default does not do this automatically, i.e. |
Thank you @Rubinum. |
…s divisions and multiplications (Rubinum) This PR was merged into the 2.7 branch. Discussion ---------- [Form] Fix precision of MoneyToLocalizedStringTransformer's divisions and multiplications | Q | A | ------------- | --- | Branch? | master | Bug fix? | yes | New feature? | no | BC breaks? | no | Deprecations? | no | Tests pass? | no | License | MIT There is a [PHP Bug](https://bugs.php.net/bug.php?id=75004) with the accuracy of divisions and multiplications when `/=` and `*=` are used. Here is the proof: https://3v4l.org/u1DkX It would be better to use `bcmul()` and `bcdiv()` in the `MoneyToLocalizedStringTransformer.php` to prevent this bug. Commits ------- ab47c78 Added improvement for accuracy in MoneyToLocalizedStringTransformer.
I've found out the behavior also depends on the
|
Actually, this change is not reliable at all what I was fearing the whole time. It only works because the |
Neither of that is a proper solution. The only one is using fixed precision calculation with bcmath. |
Then bcmath for ppl having it and precision 14 for others? I don't think we can add a strong requirement for bcmath, do you? |
Using bcmath and precision as fallback seems fine to not break BC. But IMO we should add requirement for bcmath in Symfony 4 so we can remove that ugly workaround. Bcmath is included by default in php so that shouldn't cause much trouble. |
It's included really? Cool, so we can trigger a deprecation on 3.4 and throw on 4.0. |
…s divisions on transform() (syastrebov) This PR was merged into the 2.7 branch. Discussion ---------- [Form] Fix precision of MoneyToLocalizedStringTransformer's divisions on transform() | Q | A | ------------- | --- | Branch? | 2.7 | Bug fix? | yes | New feature? | no | BC breaks? | no | Deprecations? | no | Tests pass? | yes | Fixed tickets | no | License | MIT | Doc PR | Related issue #21026. Previous PR #24036. Similar fix for `transform()` method. Commits ------- f94b7aa fix rounding from string
…s divisions on transform() (syastrebov) This PR was merged into the 2.7 branch. Discussion ---------- [Form] Fix precision of MoneyToLocalizedStringTransformer's divisions on transform() | Q | A | ------------- | --- | Branch? | 2.7 | Bug fix? | yes | New feature? | no | BC breaks? | no | Deprecations? | no | Tests pass? | yes | Fixed tickets | no | License | MIT | Doc PR | Related issue symfony/symfony#21026. Previous PR symfony/symfony#24036. Similar fix for `transform()` method. Commits ------- f94b7aadd3 fix rounding from string
There is another problem, Im doing a cents to decimal, using MoneyType, so I setted the divisor to 100, but when persisinting an entity, it puts the decimals, like 100.0, thats a problem, when you have the same value, like the entity has 100 and then it tries to update to 100.0 (what is the same thing), but it triggers an useless update that hits the database. |
@arthurlauck apply the appropriate casting in your entity setter then |
@stof I ended up, extending the MoneyType, would it be better to cast in the entity?
|
There is a PHP Bug with the accuracy of divisions and multiplications when
/=
and*=
are used.Here is the proof: https://3v4l.org/u1DkX
It would be better to use
bcmul()
andbcdiv()
in theMoneyToLocalizedStringTransformer.php
to prevent this bug.