Skip to content

Commit 1002678

Browse files
committed
Fix DurationField, IntegerField unique constraints for values < or > 32 bits
Whether values less than or greater than 32 bits were problematic depends on the field.
1 parent d74db72 commit 1002678

File tree

8 files changed

+135
-3
lines changed

8 files changed

+135
-3
lines changed

django_mongodb_backend/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,11 @@ class DatabaseWrapper(BaseDatabaseWrapper):
5252
"FileField": "string",
5353
"FilePathField": "string",
5454
"FloatField": "double",
55-
"IntegerField": "int",
55+
"IntegerField": "long",
5656
"BigIntegerField": "long",
5757
"GenericIPAddressField": "string",
5858
"JSONField": "object",
59-
"PositiveBigIntegerField": "int",
59+
"PositiveBigIntegerField": "long",
6060
"PositiveIntegerField": "long",
6161
"PositiveSmallIntegerField": "int",
6262
"SlugField": "string",

django_mongodb_backend/fields/duration.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from bson import Int64
12
from django.db.models.fields import DurationField
23

34
_get_db_prep_value = DurationField.get_db_prep_value
@@ -8,6 +9,8 @@ def get_db_prep_value(self, value, connection, prepared=False):
89
value = _get_db_prep_value(self, value, connection, prepared)
910
if connection.vendor == "mongodb" and value is not None:
1011
value //= 1000
12+
# Store value as Int64 (long).
13+
value = Int64(value)
1114
return value
1215

1316

django_mongodb_backend/operations.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import uuid
55
from decimal import Decimal
66

7-
from bson.decimal128 import Decimal128
7+
from bson import Decimal128, Int64
88
from django.conf import settings
99
from django.core.exceptions import ImproperlyConfigured
1010
from django.db import DataError
@@ -66,6 +66,14 @@ def adapt_decimalfield_value(self, value, max_digits=None, decimal_places=None):
6666
return None
6767
return Decimal128(value)
6868

69+
def adapt_integerfield_value(self, value, internal_type):
70+
"""Store non-SmallIntegerField variants as Int64 (long)."""
71+
if value is None:
72+
return None
73+
if "Small" not in internal_type:
74+
return Int64(value)
75+
return value
76+
6977
def adapt_json_value(self, value, encoder):
7078
if encoder is None:
7179
return value
@@ -131,8 +139,16 @@ def get_db_converters(self, expression):
131139
converters.append(self.convert_timefield_value)
132140
elif internal_type == "UUIDField":
133141
converters.append(self.convert_uuidfield_value)
142+
elif "IntegerField" in internal_type and "Small" not in internal_type:
143+
converters.append(self.convert_integerfield_value)
134144
return converters
135145

146+
def convert_integerfield_value(self, value, expression, connection):
147+
if value is not None:
148+
# from Int64 to int
149+
value = int(value)
150+
return value
151+
136152
def convert_datefield_value(self, value, expression, connection):
137153
if value is not None:
138154
value = value.date()

docs/source/ref/models/fields.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ A few notes about some of the other fields:
2727
prohibit inserting values outside of the supported range and unique
2828
constraints don't work for values outside of the 32-bit range of the BSON
2929
``int`` type.
30+
- :class:`~django.db.models.IntegerField`,
31+
:class:`~django.db.models.BigIntegerField` and
32+
:class:`~django.db.models.PositiveSmallIntegerField`, and
33+
:class:`~django.db.models.PositiveBigIntegerField` support 64 bit values
34+
(ranges ``(-9223372036854775808, 9223372036854775807)`` and
35+
``(0, 9223372036854775807)``, respectively), validated by forms and model
36+
validation. If you're inserting data outside of the ORM, you must cast all
37+
values to :class:`bson.int64.Int64`, otherwise values less then 32 bits will
38+
be stored as ``int`` and won't be validated by unique constraints.
39+
- Similarly, all :class:`~django.db.models.DurationField` values are stored as
40+
:class:`bson.int64.Int64`.
3041

3142
MongoDB-specific model fields
3243
=============================

docs/source/releases/5.2.x.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ Bug fixes
3737
databases.
3838
- :meth:`QuerySet.explain() <django.db.models.query.QuerySet.explain>` now
3939
:ref:`returns a string that can be parsed as JSON <queryset-explain>`.
40+
- Fixed unique constraint generation for :class:`~django.db.models.IntegerField`
41+
and :class:`~django.db.models.PositiveBigIntegerField`, which incorrectly
42+
allowed duplicate values larger than 32 bits. Existing unique constraints
43+
must be recreated to use ``$type: long`` instead of ``int``.
44+
- :class:`~django.db.models.IntegerField`,
45+
:class:`~django.db.models.BigIntegerField` (as well as the
46+
``Positive`` versions of these fields), and
47+
:class:`~django.db.models.DurationField` values are now sent to MongoDB as
48+
:class:`bson.int64.Int64`, which fixes unique constraints on values less than
49+
32 bits for ``BigIntegerField``, ``PositiveIntegerField``, and
50+
``DurationField``. Existing data must be converted to ``Int64``.
4051

4152
Performance improvements
4253
------------------------

tests/model_fields_/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515

1616
class UniqueIntegers(models.Model):
1717
small = models.SmallIntegerField(unique=True, blank=True, null=True)
18+
plain = models.IntegerField(unique=True, blank=True, null=True)
19+
big = models.BigIntegerField(unique=True, blank=True, null=True)
1820
positive_small = models.PositiveSmallIntegerField(unique=True, blank=True, null=True)
21+
positive = models.PositiveIntegerField(unique=True, blank=True, null=True)
22+
positive_big = models.PositiveBigIntegerField(unique=True, blank=True, null=True)
23+
duration = models.DurationField(unique=True, blank=True, null=True)
1924

2025

2126
# ObjectIdField
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from datetime import timedelta
2+
3+
from django.db import IntegrityError
4+
from django.test import TestCase
5+
6+
from .models import UniqueIntegers
7+
8+
9+
class UniqueTests(TestCase):
10+
def test_small_value(self):
11+
"""
12+
Duplicate values < 32 bits are prohibited. This confirms DurationField
13+
values are cast to Int64 so MongoDB stores them as long. Otherwise, the
14+
partialFilterExpression: {$type: long} unique constraint doesn't work.
15+
"""
16+
UniqueIntegers.objects.create(duration=timedelta(1))
17+
with self.assertRaises(IntegrityError):
18+
UniqueIntegers.objects.create(duration=timedelta(1))
19+
20+
def test_large_value(self):
21+
"""
22+
Duplicate values > 32 bits are prohibited. This confirms DurationField
23+
uses the long db_type() rather than the 32 bit int type.
24+
"""
25+
UniqueIntegers.objects.create(duration=timedelta(1000000))
26+
with self.assertRaises(IntegrityError):
27+
UniqueIntegers.objects.create(duration=timedelta(1000000))

tests/model_fields_/test_integerfield.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,62 @@ def test_validate_min_value(self):
7373
msg = "{'positive_small': ['Ensure this value is greater than or equal to 0.']"
7474
with self.assertRaisesMessage(ValidationError, msg):
7575
UniqueIntegers(positive_small=self.min_value - 1).full_clean()
76+
77+
78+
class SmallUniqueTests(TestCase):
79+
"""
80+
Duplicate values < 32 bits are prohibited. This confirms integer field
81+
values are cast to Int64 so MongoDB stores it as long. Otherwise, the
82+
partialFilterExpression: {$type: long} unique constraint doesn't work.
83+
"""
84+
85+
test_value = 123
86+
87+
def test_integerfield(self):
88+
UniqueIntegers.objects.create(plain=self.test_value)
89+
with self.assertRaises(IntegrityError):
90+
UniqueIntegers.objects.create(plain=self.test_value)
91+
92+
def test_bigintegerfield(self):
93+
UniqueIntegers.objects.create(big=self.test_value)
94+
with self.assertRaises(IntegrityError):
95+
UniqueIntegers.objects.create(big=self.test_value)
96+
97+
def test_positiveintegerfield(self):
98+
UniqueIntegers.objects.create(positive=self.test_value)
99+
with self.assertRaises(IntegrityError):
100+
UniqueIntegers.objects.create(positive=self.test_value)
101+
102+
def test_positivebigintegerfield(self):
103+
UniqueIntegers.objects.create(positive_big=self.test_value)
104+
with self.assertRaises(IntegrityError):
105+
UniqueIntegers.objects.create(positive_big=self.test_value)
106+
107+
108+
class LargeUniqueTests(TestCase):
109+
"""
110+
Duplicate values > 32 bits are prohibited. This confirms each field uses
111+
the long db_type() rather than the 32 bit int type.
112+
"""
113+
114+
test_value = 2**63 - 1
115+
116+
def test_integerfield(self):
117+
UniqueIntegers.objects.create(plain=self.test_value)
118+
with self.assertRaises(IntegrityError):
119+
UniqueIntegers.objects.create(plain=self.test_value)
120+
121+
def test_bigintegerfield(self):
122+
UniqueIntegers.objects.create(big=self.test_value)
123+
with self.assertRaises(IntegrityError):
124+
UniqueIntegers.objects.create(big=self.test_value)
125+
126+
def test_positiveintegerfield(self):
127+
UniqueIntegers.objects.create(positive=self.test_value)
128+
with self.assertRaises(IntegrityError):
129+
UniqueIntegers.objects.create(positive=self.test_value)
130+
131+
def test_positivebigintegerfield(self):
132+
UniqueIntegers.objects.create(positive_big=self.test_value)
133+
with self.assertRaises(IntegrityError):
134+
UniqueIntegers.objects.create(positive_big=self.test_value)

0 commit comments

Comments
 (0)