Skip to content
This repository was archived by the owner on Jan 12, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,27 @@ class MyModel(models.Model):
date_field = models.DateField(auto_now_add=True)
```

#### DecimalField Support

Django-mongo-backend supports `DecimalField` for storing decimal values as BSON Decimal128:

- **Django 5.2+**: Use the built-in `django.db.models.DecimalField` directly
- **Django < 5.2**: Use `django_mongodb.models.DecimalField` for proper MongoDB compatibility

```python
# For Django 5.2+
from django.db import models

class Product(models.Model):
price = models.DecimalField(max_digits=10, decimal_places=2)

# For Django < 5.2
from django_mongodb.models import DecimalField

class Product(models.Model):
price = DecimalField(max_digits=10, decimal_places=2)
```

Single table inheritance

```python
Expand Down
23 changes: 23 additions & 0 deletions django_mongodb/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from django.db import models


class DecimalField(models.DecimalField):
"""
Custom DecimalField that properly converts Python Decimal to BSON Decimal128
for MongoDB operations.

This custom field is required for Django < 5.2 to ensure proper conversion
of Decimal values during query operations. Starting from Django 5.2, the
built-in DecimalField should handle this conversion automatically.

Note: When Django 5.2+ is the minimum supported version, this custom field
can be deprecated in favor of Django's built-in DecimalField.
"""

def get_db_prep_value(self, value, connection, prepared=False):
value = super().get_db_prep_value(value, connection, prepared)
if hasattr(connection.ops, "adapt_decimalfield_value"):
return connection.ops.adapt_decimalfield_value(
value, max_digits=self.max_digits, decimal_places=self.decimal_places
)
return value
5 changes: 5 additions & 0 deletions django_mongodb/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _

from django_mongodb.fields import DecimalField


class ObjectIdFieldMixin:
description = "MongoDB ObjectIdField"
Expand Down Expand Up @@ -57,3 +59,6 @@ def get_internal_type(self):

def rel_db_type(self, connection):
return ObjectIdField().db_type(connection=connection)


__all__ = ["ObjectIdField", "ObjectIdAutoField", "DecimalField"]
49 changes: 49 additions & 0 deletions django_mongodb/operations.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import datetime
from decimal import Decimal

from bson import ObjectId
from bson.decimal128 import Decimal128
from django.conf import settings
from django.db.backends.base.operations import BaseDatabaseOperations
from django.utils.timezone import is_aware, make_aware
Expand Down Expand Up @@ -59,6 +61,29 @@ def convert_datetime_value(self, value, expression, connection):
return make_aware(value)
return value

def convert_decimalfield_value(self, value, expression, connection):
"""
Convert BSON Decimal128 back to Python Decimal.
Also handles string values for backward compatibility.
"""
if value is None:
return None

# If it's a Decimal128, convert to Python Decimal
if isinstance(value, Decimal128):
return value.to_decimal()

# If it's a string, convert to Decimal
if isinstance(value, str):
return Decimal(value)

# If it's already a Decimal, return as is
if isinstance(value, Decimal):
return value

# Fallback to Decimal conversion
return Decimal(str(value))

def get_db_converters(self, expression):
converters = super().get_db_converters(expression)
internal_type = expression.output_field.get_internal_type()
Expand All @@ -69,4 +94,28 @@ def get_db_converters(self, expression):
converters.append(self.convert_date_value)
case "DateTimeField":
converters.append(self.convert_datetime_value)
case "DecimalField":
converters.append(self.convert_decimalfield_value)
return converters

def adapt_decimalfield_value(self, value, max_digits=None, decimal_places=None):
"""
Transform a decimal.Decimal value to a BSON Decimal128 object.
"""
if value is None:
return None

# If it's already a Decimal128, return as is
if isinstance(value, Decimal128):
return value

# Convert to Decimal if it's a string
if isinstance(value, str):
value = Decimal(value)

# Convert Python Decimal to BSON Decimal128
if isinstance(value, Decimal):
return Decimal128(str(value))

# Fallback to string representation
return Decimal128(str(value))
7 changes: 7 additions & 0 deletions django_mongodb/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ def _get_mongo_query(self, compiler, connection, is_search=False) -> dict:
rhs = self.rhs
if is_search and self.mongo_meta["search_fields"].get(lhs.attname):
return {}
# Convert the rhs value using the field's database conversion method
if hasattr(lhs, "get_db_prep_value"):
# For In lookups, rhs is a list and we need to convert each item
if self.filter_operator == "$in" and isinstance(rhs, list | tuple):
rhs = [lhs.get_db_prep_value(item, connection) for item in rhs]
else:
rhs = lhs.get_db_prep_value(rhs, connection)
return {lhs.column: {self.filter_operator: rhs}}

def get_mongo_search(self, compiler, connection) -> dict:
Expand Down
13 changes: 8 additions & 5 deletions test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,16 @@ def clean_db():

@pytest.fixture()
def search_index():
# Ensure the collection exists by creating a dummy document and then deleting it
collection = connections["mongodb"].cursor().connection["testapp_foomodel"]
collection.insert_one({"_id": "dummy"})
collection.delete_one({"_id": "dummy"})

try:
connections["mongodb"].cursor().connection["testapp_foomodel"].drop_search_index("default")
collection.drop_search_index("default")
except Exception:
pass
connections["mongodb"].cursor().connection["testapp_foomodel"].create_search_index(
collection.create_search_index(
SearchIndexModel(
{
"analyzer": "lucene.standard",
Expand Down Expand Up @@ -49,9 +54,7 @@ def search_index():
# we need to wait until the index is ready
while True:
time.sleep(1.0)
indexes = list(
connections["mongodb"].cursor().connection["testapp_foomodel"].list_search_indexes()
)
indexes = list(collection.list_search_indexes())
if any(index["status"] == "READY" for index in indexes):
break
i += 1
Expand Down
122 changes: 122 additions & 0 deletions test/test_decimal_field_compatibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""
Tests to ensure DecimalField compatibility across Django versions.
For Django < 5.2, we need to use the custom DecimalField from django_mongodb.
For Django >= 5.2, the built-in DecimalField should work correctly.
"""

from decimal import Decimal

import django
import pytest
from django.db import models

from django_mongodb.models import DecimalField as MongoDecimalField


# Test model using built-in Django DecimalField
class BuiltinDecimalModel(models.Model):
value = models.DecimalField(max_digits=10, decimal_places=2)

class Meta:
app_label = "testapp"
db_table = "test_builtin_decimal"


# Test model using custom MongoDB DecimalField
class MongoDecimalModel(models.Model):
value = MongoDecimalField(max_digits=10, decimal_places=2)

class Meta:
app_label = "testapp"
db_table = "test_mongo_decimal"


@pytest.mark.django_db(databases=["mongodb"])
def test_custom_decimal_field_always_works():
"""Custom MongoDB DecimalField should work on all Django versions."""
# Create and save
obj = MongoDecimalModel(value=Decimal("123.45"))
obj.save()

# Retrieve
retrieved = MongoDecimalModel.objects.get(pk=obj.pk)
assert retrieved.value == Decimal("123.45")

# Filter
filtered = MongoDecimalModel.objects.filter(value=Decimal("123.45"))
assert filtered.count() == 1

# Cleanup
MongoDecimalModel.objects.all().delete()


@pytest.mark.django_db(databases=["mongodb"])
def test_builtin_decimal_field_compatibility():
"""
Built-in DecimalField should work on Django >= 5.2.
For Django < 5.2, this test demonstrates the issue that requires
using the custom DecimalField.
"""
django_version = tuple(map(int, django.__version__.split(".")[:2]))

# Create and save (this should always work)
obj = BuiltinDecimalModel(value=Decimal("456.78"))
obj.save()

# Retrieve (this should always work)
retrieved = BuiltinDecimalModel.objects.get(pk=obj.pk)
assert retrieved.value == Decimal("456.78")

# Filter - this is where the issue occurs on Django < 5.2
if django_version >= (5, 2):
# On Django 5.2+, built-in DecimalField should work
filtered = BuiltinDecimalModel.objects.filter(value=Decimal("456.78"))
assert filtered.count() == 1
else:
# On Django < 5.2, filtering with built-in DecimalField fails
# with BSON encoding error
with pytest.raises(Exception) as exc_info:
list(BuiltinDecimalModel.objects.filter(value=Decimal("456.78")))
assert "cannot encode object" in str(exc_info.value) or "Decimal" in str(exc_info.value)

# Cleanup
BuiltinDecimalModel.objects.all().delete()


@pytest.mark.django_db(databases=["mongodb"])
def test_decimal_field_recommendation():
"""
This test documents the recommended approach for different Django versions.
"""
django_version = tuple(map(int, django.__version__.split(".")[:2]))

if django_version >= (5, 2):
# For Django 5.2+, both should work
recommendation = "Django >= 5.2: You can use either django.db.models.DecimalField or django_mongodb.models.DecimalField"
else:
# For Django < 5.2, custom field is required
recommendation = "Django < 5.2: You must use django_mongodb.models.DecimalField for proper MongoDB compatibility"

# This assertion always passes - it's just to document the recommendation
assert recommendation is not None

# Verify the recommendation by testing both fields
test_value = Decimal("999.99")

# Custom field should always work
mongo_obj = MongoDecimalModel.objects.create(value=test_value)
assert MongoDecimalModel.objects.filter(value=test_value).count() == 1
mongo_obj.delete()

# Built-in field behavior depends on Django version
builtin_obj = BuiltinDecimalModel.objects.create(value=test_value)
if django_version >= (5, 2):
assert BuiltinDecimalModel.objects.filter(value=test_value).count() == 1
else:
try:
# This will fail on Django < 5.2
list(BuiltinDecimalModel.objects.filter(value=test_value))
raise AssertionError("Expected BSON encoding error on Django < 5.2")
except Exception as e:
assert "cannot encode object" in str(e) or "Decimal" in str(e)
builtin_obj.delete()
Loading