Opened 14 years ago
Closed 2 years ago
#13666 closed Bug (worksforme)
Updates with F objects on decimal fields raise MySQL warnings
Reported by: | KyleMac | Owned by: | nobody |
---|---|---|---|
Component: | Database layer (models, ORM) | Version: | 1.7 |
Severity: | Normal | Keywords: | |
Cc: | josh@… | Triage Stage: | Accepted |
Has patch: | no | Needs documentation: | no |
Needs tests: | no | Patch needs improvement: | no |
Easy pickings: | no | UI/UX: | no |
Description
Doing something like the following:
MyModel.objects.filter(whatever).update(price=F('price') - Decimal('1.00'))
raises an exception due to an ignorable warning from MySQL.
This is basically identical to #12293 except that the documentation would have you believe that this should work without any extra work.
Change History (14)
comment:1 by , 14 years ago
Resolution: | → worksforme |
---|---|
Status: | new → closed |
comment:2 by , 14 years ago
Resolution: | worksforme |
---|---|
Status: | closed → reopened |
The exception I get is:
Data truncated for column 'balance' at row 1
The following raises the exception for me:
Account.objects.create(name='z1', balance='10.00') Account.objects.filter(name__startswith='z').update(balance=F('balance')-Decimal('1.79'))
However, using "25" and "5.0" from your example does not raise an exception. A rounding error from MySQL perhaps?
comment:3 by , 14 years ago
Assuming that the field "balance" is a decimal field with 2 decimal places, in raw SQL the following will show the truncation warning:
UPDATE ticket SET balance = 10.00; UPDATE ticket SET balance = balance - '1.79'; SHOW WARNINGS;
However, the following will work fine:
UPDATE ticket SET balance = 10.00; UPDATE ticket SET balance = balance - 1.79; SHOW WARNINGS;
It seems that MySQL doesn't like strings as much as it likes floats and Django converts Decimal() to strings. So
Account.objects.create(name='z1', balance='10.00') Account.objects.filter(name__startswith='z').update(balance=F('balance') - Decimal('1.79'))
or
Account.objects.create(name='z1', balance='10.00') Account.objects.filter(name__startswith='z').update(balance=F('balance') - '1.79')
raise exceptions, but
Account.objects.create(name='z1', balance='10.00') Account.objects.filter(name__startswith='z').update(balance=F('balance') - float(Decimal('1.79')))
works fine.
comment:4 by , 14 years ago
Component: | Documentation → Database layer (models, ORM) |
---|---|
Triage Stage: | Unreviewed → Accepted |
Ok - I can verify the '1.79' problem; that means this isn't a documentation issue, it's a problem with the MySQL backend handling of Decimals.
comment:6 by , 14 years ago
Severity: | → Normal |
---|---|
Type: | → Bug |
comment:9 by , 12 years ago
Status: | reopened → new |
---|
comment:10 by , 12 years ago
I recently encountered this exact same issue on MySQL 5.6.10 and Django 1.4.
The following SQL (which is what the Python MySQL adapter generates) fails reliably for me:
UPDATE `main_account` SET `balance` = `main_userprofile`.`balance` - '0.00007' WHERE `main_account`.`id` = 1;
However, adding explicit casts to decimal fixes the issue.
UPDATE `main_account` SET `balance` = `main_userprofile`.`balance` - CAST('0.00007' AS DECIMAL(12, 8)) WHERE `main_account`.`id` = 1;
I assume this is because in the former case MySQL casts to a float, then subtracts, introducing precision issues, while in the latter case we explicitly cast directly to decimal of a certain precision.
I don't know what the solution is from a Django perspective, but thought the information could be useful.
comment:11 by , 10 years ago
Is patching django.db.backends.mysql.django_conversions with cast() the proper way to go?
comment:12 by , 10 years ago
Cc: | added |
---|---|
Version: | 1.2 → 1.7 |
The Django ORM definitely needs to use CAST with MySQL. Otherwise, this bug may manifest randomly on various architectures.
Read this on MySQL Type Conversion:
"""Furthermore, the conversion from string to floating-point and from integer to floating-point do not necessarily occur the same way. The integer may be converted to floating-point by the CPU, whereas the string is converted digit by digit in an operation that involves floating-point multiplications.
The results shown will vary on different systems, and can be affected by factors such as computer architecture or the compiler version or optimization level. One way to avoid such problems is to use CAST() so that a value is not converted implicitly to a float-point number:"""'
http://dev.mysql.com/doc/refman/5.6/en/type-conversion.html
comment:13 by , 10 years ago
Wouldn't it make sense to pass the number verbatim rather than stringifying it? Or could that introduce inconsistencies with floats being imprecise? What about the other backends, do they all accept string-like decimal numbers and happily call them decimals?
If we want to go with a wrapping type though, it should be possible (from 1.8/master) to provide an as_mysql()
for the Value expression.
Something like:
class Value(..): def as_mysql(self, compiler, connection): if self.output_field.get_internal_type() == 'DecimalField': c = Cast(self, self.output_field) return compiler.compile(c)
Where the Cast
type above would need to be implemented in some way. The implementation could be thorough and apply to all backends (quite a bit of work), or it could be extremely simple, and only work for Decimal types on MySQL. There's similar logic for Oracle with the Coalesce
type and conversions to NCLOB.
I'd be wary about providing the digits and precision arguments to the CAST if they aren't exactly needed. If they can be avoided, then do so. Because there's no nice way (currently) to pass that information.
comment:14 by , 2 years ago
Resolution: | → worksforme |
---|---|
Status: | new → closed |
I'm not able to reproduce anymore on MySQL 8 strict mode (minimum version planned for 4.2) so they must have sorted out their decimal arithmetic over the years so even if the Cast
could be added by CombinedExpression
or even by MySQL's combine_expression
I don't think it's worth doing at this point.
Closing as worksforme as demonstrated by this test.
-
tests/model_fields/models.py
diff --git a/tests/model_fields/models.py b/tests/model_fields/models.py index 9a64d51766..2a9bcd95cc 100644
a b 16 16 except ImportError: 17 17 Image = None 18 18 19 class Account(models.Model): 20 name = models.CharField(max_length=100) 21 balance = models.DecimalField(decimal_places=2, max_digits=12) 22 19 23 20 24 class Foo(models.Model): 21 25 a = models.CharField(max_length=10) -
tests/model_fields/test_decimalfield.py
diff --git a/tests/model_fields/test_decimalfield.py b/tests/model_fields/test_decimalfield.py index 912d55af72..a45e634ff4 100644
a b 3 3 4 4 from django.core import validators 5 5 from django.core.exceptions import ValidationError 6 from django.db import models 6 from django.db import models, connection 7 7 from django.test import TestCase 8 8 9 from .models import BigD, Foo9 from .models import Account, BigD, Foo 10 10 11 11 12 12 class DecimalFieldTests(TestCase): … … def test_roundtrip_with_trailing_zeros(self): 113 113 obj = Foo.objects.create(a="bar", d=Decimal("8.320")) 114 114 obj.refresh_from_db() 115 115 self.assertEqual(obj.d.compare_total(Decimal("8.320")), Decimal("0")) 116 117 def test_update_difference(self): 118 acc = Account.objects.create(balance="10.00") 119 acc.balance = models.F("balance") - "1.79" 120 acc.save() 121 acc.refresh_from_db() 122 self.assertEqual(acc.balance, Decimal("8.21")) 123 with connection.cursor() as cursor: 124 cursor.execute("SHOW WARNINGS") 125 self.assertIsNone(cursor.fetchone())
You've left out specifics of your model and the exception you get, so I'm not sure exactly what you are seeing. I don't get any warnings doing what you describe. Given this model:
A statement similar to what you show works fine, even with debug on, and with MySQL backend:
Nor do I see any exception when 0 rows are updated. So near as I can tell this does work without any extra work.