Skip to content

Commit 30c2485

Browse files
authored
Merge pull request #2721 from bagerard/clone_jschlyter_decimal128
[Clone] implement Decimal128Field
2 parents 34bd87c + 1d09a26 commit 30c2485

File tree

4 files changed

+208
-5
lines changed

4 files changed

+208
-5
lines changed

docs/changelog.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ Development
1515
- Added meta ``auto_create_index_on_save`` so you can enable index creation
1616
on :meth:`~mongoengine.Document.save()` (as it was < 0.26.0).
1717
- BREAKING CHANGE: remove deprecated method ``ensure_index`` (replaced by ``create_index`` long time ago).
18-
18+
- Addition of Decimal128Field: :class:`~mongoengine.fields.Decimal128Field` for accurate representation of Decimals (much better than the legacy field DecimalField).
19+
Although it could work to switch an existing DecimalField to Decimal128Field without applying a migration script,
20+
it is not recommended to do so (DecimalField uses float/str to store the value, Decimal128Field uses Decimal128).
1921

2022
Changes in 0.25.0
2123
=================

mongoengine/document.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -938,7 +938,6 @@ def list_indexes(cls):
938938
classes = []
939939

940940
def get_classes(cls):
941-
942941
if cls not in classes and isinstance(cls, TopLevelDocumentMetaclass):
943942
classes.append(cls)
944943

@@ -965,7 +964,7 @@ def get_classes(cls):
965964

966965
get_classes(cls)
967966

968-
# get the indexes spec for all of the gathered classes
967+
# get the indexes spec for all the gathered classes
969968
def get_indexes_spec(cls):
970969
indexes = []
971970

@@ -1000,8 +999,10 @@ def compare_indexes(cls):
1000999
required = cls.list_indexes()
10011000

10021001
existing = []
1003-
for info in cls._get_collection().index_information().values():
1002+
collection = cls._get_collection()
1003+
for info in collection.index_information().values():
10041004
if "_fts" in info["key"][0]:
1005+
# Useful for text indexes (but not only)
10051006
index_type = info["key"][0][1]
10061007
text_index_fields = info.get("weights").keys()
10071008
existing.append([(key, index_type) for key in text_index_fields])

mongoengine/fields.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import gridfs
1313
import pymongo
1414
from bson import SON, Binary, DBRef, ObjectId
15+
from bson.decimal128 import Decimal128, create_decimal128_context
1516
from bson.int64 import Int64
1617
from pymongo import ReturnDocument
1718

@@ -95,6 +96,7 @@
9596
"MultiLineStringField",
9697
"MultiPolygonField",
9798
"GeoJsonBaseField",
99+
"Decimal128Field",
98100
)
99101

100102
RECURSIVE_REFERENCE_CONSTANT = "self"
@@ -438,7 +440,10 @@ def prepare_query_value(self, op, value):
438440

439441

440442
class DecimalField(BaseField):
441-
"""Fixed-point decimal number field. Stores the value as a float by default unless `force_string` is used.
443+
"""Disclaimer: This field is kept for historical reason but since it converts the values to float, it
444+
is not suitable for true decimal storage. Consider using :class:`~mongoengine.fields.Decimal128Field`.
445+
446+
Fixed-point decimal number field. Stores the value as a float by default unless `force_string` is used.
442447
If using floats, beware of Decimal to float conversion (potential precision loss)
443448
"""
444449

@@ -2650,3 +2655,49 @@ def to_mongo(self, document):
26502655
)
26512656
else:
26522657
return super().to_mongo(document)
2658+
2659+
2660+
class Decimal128Field(BaseField):
2661+
"""
2662+
128-bit decimal-based floating-point field capable of emulating decimal
2663+
rounding with exact precision. This field will expose decimal.Decimal but stores the value as a
2664+
`bson.Decimal128` behind the scene, this field is intended for monetary data, scientific computations, etc.
2665+
"""
2666+
2667+
DECIMAL_CONTEXT = create_decimal128_context()
2668+
2669+
def __init__(self, min_value=None, max_value=None, **kwargs):
2670+
self.min_value = min_value
2671+
self.max_value = max_value
2672+
super().__init__(**kwargs)
2673+
2674+
def to_mongo(self, value):
2675+
if value is None:
2676+
return None
2677+
if isinstance(value, Decimal128):
2678+
return value
2679+
if not isinstance(value, decimal.Decimal):
2680+
with decimal.localcontext(self.DECIMAL_CONTEXT) as ctx:
2681+
value = ctx.create_decimal(value)
2682+
return Decimal128(value)
2683+
2684+
def to_python(self, value):
2685+
if value is None:
2686+
return None
2687+
return self.to_mongo(value).to_decimal()
2688+
2689+
def validate(self, value):
2690+
if not isinstance(value, Decimal128):
2691+
try:
2692+
value = Decimal128(value)
2693+
except (TypeError, ValueError, decimal.InvalidOperation) as exc:
2694+
self.error("Could not convert value to Decimal128: %s" % exc)
2695+
2696+
if self.min_value is not None and value.to_decimal() < self.min_value:
2697+
self.error("Decimal value is too small")
2698+
2699+
if self.max_value is not None and value.to_decimal() > self.max_value:
2700+
self.error("Decimal value is too large")
2701+
2702+
def prepare_query_value(self, op, value):
2703+
return super().prepare_query_value(op, self.to_mongo(value))

tests/fields/test_decimal128_field.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import json
2+
import random
3+
from decimal import Decimal
4+
5+
import pytest
6+
from bson.decimal128 import Decimal128
7+
8+
from mongoengine import Decimal128Field, Document, ValidationError
9+
from tests.utils import MongoDBTestCase, get_as_pymongo
10+
11+
12+
class Decimal128Document(Document):
13+
dec128_fld = Decimal128Field()
14+
dec128_min_0 = Decimal128Field(min_value=0)
15+
dec128_max_100 = Decimal128Field(max_value=100)
16+
17+
18+
def generate_test_cls() -> Document:
19+
Decimal128Document.drop_collection()
20+
Decimal128Document(dec128_fld=None).save()
21+
Decimal128Document(dec128_fld=Decimal(1)).save()
22+
return Decimal128Document
23+
24+
25+
class TestDecimal128Field(MongoDBTestCase):
26+
def test_decimal128_validation_good(self):
27+
doc = Decimal128Document()
28+
29+
doc.dec128_fld = Decimal(0)
30+
doc.validate()
31+
32+
doc.dec128_fld = Decimal(50)
33+
doc.validate()
34+
35+
doc.dec128_fld = Decimal(110)
36+
doc.validate()
37+
38+
doc.dec128_fld = Decimal("110")
39+
doc.validate()
40+
41+
def test_decimal128_validation_invalid(self):
42+
"""Ensure that invalid values cannot be assigned."""
43+
44+
doc = Decimal128Document()
45+
46+
doc.dec128_fld = "ten"
47+
48+
with pytest.raises(ValidationError):
49+
doc.validate()
50+
51+
def test_decimal128_validation_min(self):
52+
"""Ensure that out of bounds values cannot be assigned."""
53+
54+
doc = Decimal128Document()
55+
56+
doc.dec128_min_0 = Decimal(50)
57+
doc.validate()
58+
59+
doc.dec128_min_0 = Decimal(-1)
60+
with pytest.raises(ValidationError):
61+
doc.validate()
62+
63+
def test_decimal128_validation_max(self):
64+
"""Ensure that out of bounds values cannot be assigned."""
65+
66+
doc = Decimal128Document()
67+
68+
doc.dec128_max_100 = Decimal(50)
69+
doc.validate()
70+
71+
doc.dec128_max_100 = Decimal(101)
72+
with pytest.raises(ValidationError):
73+
doc.validate()
74+
75+
def test_eq_operator(self):
76+
cls = generate_test_cls()
77+
assert cls.objects(dec128_fld=1.0).count() == 1
78+
assert cls.objects(dec128_fld=2.0).count() == 0
79+
80+
def test_ne_operator(self):
81+
cls = generate_test_cls()
82+
assert cls.objects(dec128_fld__ne=None).count() == 1
83+
assert cls.objects(dec128_fld__ne=1).count() == 1
84+
assert cls.objects(dec128_fld__ne=1.0).count() == 1
85+
86+
def test_gt_operator(self):
87+
cls = generate_test_cls()
88+
assert cls.objects(dec128_fld__gt=0.5).count() == 1
89+
90+
def test_lt_operator(self):
91+
cls = generate_test_cls()
92+
assert cls.objects(dec128_fld__lt=1.5).count() == 1
93+
94+
def test_field_exposed_as_python_Decimal(self):
95+
# from int
96+
model = Decimal128Document(dec128_fld=100).save()
97+
assert isinstance(model.dec128_fld, Decimal)
98+
model = Decimal128Document.objects.get(id=model.id)
99+
assert isinstance(model.dec128_fld, Decimal)
100+
assert model.dec128_fld == Decimal("100")
101+
102+
def test_storage(self):
103+
# from int
104+
model = Decimal128Document(dec128_fld=100).save()
105+
assert get_as_pymongo(model) == {
106+
"_id": model.id,
107+
"dec128_fld": Decimal128("100"),
108+
}
109+
110+
# from str
111+
model = Decimal128Document(dec128_fld="100.0").save()
112+
assert get_as_pymongo(model) == {
113+
"_id": model.id,
114+
"dec128_fld": Decimal128("100.0"),
115+
}
116+
117+
# from float
118+
model = Decimal128Document(dec128_fld=100.0).save()
119+
assert get_as_pymongo(model) == {
120+
"_id": model.id,
121+
"dec128_fld": Decimal128("100"),
122+
}
123+
124+
# from Decimal
125+
model = Decimal128Document(dec128_fld=Decimal(100)).save()
126+
assert get_as_pymongo(model) == {
127+
"_id": model.id,
128+
"dec128_fld": Decimal128("100"),
129+
}
130+
model = Decimal128Document(dec128_fld=Decimal("100.0")).save()
131+
assert get_as_pymongo(model) == {
132+
"_id": model.id,
133+
"dec128_fld": Decimal128("100.0"),
134+
}
135+
136+
# from Decimal128
137+
model = Decimal128Document(dec128_fld=Decimal128("100")).save()
138+
assert get_as_pymongo(model) == {
139+
"_id": model.id,
140+
"dec128_fld": Decimal128("100"),
141+
}
142+
143+
def test_json(self):
144+
Decimal128Document.drop_collection()
145+
f = str(random.random())
146+
Decimal128Document(dec128_fld=f).save()
147+
json_str = Decimal128Document.objects.to_json()
148+
array = json.loads(json_str)
149+
assert array[0]["dec128_fld"] == {"$numberDecimal": str(f)}

0 commit comments

Comments
 (0)