Skip to content
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

add ObjectIdField #187

Merged
merged 2 commits into from
Dec 10, 2024
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
1 change: 1 addition & 0 deletions .github/workflows/runtests.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"force_insert_update",
"foreign_object",
"forms_tests",
"forms_tests_",
"from_db_value",
"generic_inline_admin",
"generic_relations",
Expand Down
6 changes: 2 additions & 4 deletions django_mongodb/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,6 @@ class DatabaseFeatures(BaseDatabaseFeatures):
# Connection creation doesn't follow the usual Django API.
"backends.tests.ThreadTests.test_pass_connection_between_threads",
"backends.tests.ThreadTests.test_default_connection_thread_local",
# ObjectId type mismatch in a subquery:
# https://github.com/mongodb-labs/django-mongodb/issues/161
"queries.tests.RelatedLookupTypeTests.test_values_queryset_lookup",
"queries.tests.ValuesSubqueryTests.test_values_in_subquery",
# Object of type ObjectId is not JSON serializable.
"auth_tests.test_views.LoginTest.test_login_session_without_hash_session_key",
# GenericRelation.value_to_string() assumes integer pk.
Expand Down Expand Up @@ -225,6 +221,8 @@ def django_test_expected_failures(self):
"expressions.tests.BasicExpressionsTests.test_nested_subquery_outer_ref_with_autofield",
"model_fields.test_foreignkey.ForeignKeyTests.test_to_python",
"queries.test_qs_combinators.QuerySetSetOperationTests.test_order_raises_on_non_selected_column",
"queries.tests.RelatedLookupTypeTests.test_values_queryset_lookup",
"queries.tests.ValuesSubqueryTests.test_values_in_subquery",
},
"Cannot use QuerySet.delete() when querying across multiple collections on MongoDB.": {
"admin_changelist.tests.ChangeListTests.test_distinct_for_many_to_many_at_second_level_in_search_fields",
Expand Down
3 changes: 2 additions & 1 deletion django_mongodb/fields/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from .auto import ObjectIdAutoField
from .duration import register_duration_field
from .json import register_json_field
from .objectid import ObjectIdField

__all__ = ["register_fields", "ObjectIdAutoField"]
__all__ = ["register_fields", "ObjectIdAutoField", "ObjectIdField"]


def register_fields():
Expand Down
14 changes: 2 additions & 12 deletions django_mongodb/fields/auto.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,11 @@
from django.core import exceptions
from django.db.models.fields import AutoField
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _

from .objectid import ObjectIdMixin

class ObjectIdAutoField(AutoField):
default_error_messages = {
"invalid": _("“%(value)s” value must be an Object Id."),
}
description = _("Object Id")

class ObjectIdAutoField(ObjectIdMixin, AutoField):
def __init__(self, *args, **kwargs):
kwargs["db_column"] = "_id"
super().__init__(*args, **kwargs)
Expand Down Expand Up @@ -42,12 +38,6 @@ def get_prep_value(self, value):
def get_internal_type(self):
return "ObjectIdAutoField"

def db_type(self, connection):
return "objectId"

def rel_db_type(self, connection):
return "objectId"

def to_python(self, value):
if value is None or isinstance(value, int):
return value
Expand Down
54 changes: 54 additions & 0 deletions django_mongodb/fields/objectid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from bson import ObjectId, errors
from django.core import exceptions
from django.db.models.fields import Field
from django.utils.translation import gettext_lazy as _

from django_mongodb import forms


class ObjectIdMixin:
default_error_messages = {
"invalid": _("“%(value)s” is not a valid Object Id."),
}
description = _("Object Id")

def db_type(self, connection):
return "objectId"

def rel_db_type(self, connection):
return "objectId"

def get_prep_value(self, value):
value = super().get_prep_value(value)
return self.to_python(value)

def to_python(self, value):
timgraham marked this conversation as resolved.
Show resolved Hide resolved
if value is None:
return value
try:
return ObjectId(value)
except (errors.InvalidId, TypeError):
raise exceptions.ValidationError(
self.error_messages["invalid"],
code="invalid",
params={"value": value},
) from None

def formfield(self, **kwargs):
return super().formfield(
**{
"form_class": forms.ObjectIdField,
**kwargs,
}
)


class ObjectIdField(ObjectIdMixin, Field):
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if path.startswith("django_mongodb.fields.objectid"):
path = path.replace("django_mongodb.fields.objectid", "django_mongodb.fields")
return name, path, args, kwargs

def get_internal_type(self):
return "ObjectIdField"
3 changes: 3 additions & 0 deletions django_mongodb/forms/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .fields import ObjectIdField

__all__ = ["ObjectIdField"]
27 changes: 27 additions & 0 deletions django_mongodb/forms/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from bson import ObjectId
from bson.errors import InvalidId
from django.core.exceptions import ValidationError
from django.forms import Field
from django.utils.translation import gettext_lazy as _


class ObjectIdField(Field):
default_error_messages = {
"invalid": _("Enter a valid Object Id."),
}

def prepare_value(self, value):
if isinstance(value, ObjectId):
return str(value)
return value

def to_python(self, value):
value = super().to_python(value)
if value in self.empty_values:
return None
if not isinstance(value, ObjectId):
try:
value = ObjectId(value)
except InvalidId:
raise ValidationError(self.error_messages["invalid"], code="invalid") from None
return value
13 changes: 13 additions & 0 deletions docs/source/fields.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Model field reference
=====================

.. module:: django_mongodb.fields

Some MongoDB-specific fields are available in ``django_mongodb.fields``.

``ObjectIdField``
-----------------

.. class:: ObjectIdField

Stores an :class:`~bson.objectid.ObjectId`.
13 changes: 13 additions & 0 deletions docs/source/forms.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Forms API reference
===================

.. module:: django_mongodb.forms

Some MongoDB-specific fields are available in ``django_mongodb.forms``.

``ObjectIdField``
-----------------

.. class:: ObjectIdField

Stores an :class:`~bson.objectid.ObjectId`.
2 changes: 2 additions & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ django-mongodb 5.0.x documentation
:maxdepth: 1
:caption: Contents:

fields
querysets
forms

Indices and tables
==================
Expand Down
Empty file added tests/forms_tests_/__init__.py
Empty file.
33 changes: 33 additions & 0 deletions tests/forms_tests_/test_objectidfield.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from bson import ObjectId
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase

from django_mongodb.forms.fields import ObjectIdField


class ObjectIdFieldTests(SimpleTestCase):
def test_clean(self):
field = ObjectIdField()
value = field.clean("675747ec45260945758d76bc")
self.assertEqual(value, ObjectId("675747ec45260945758d76bc"))

def test_clean_objectid(self):
field = ObjectIdField()
value = field.clean(ObjectId("675747ec45260945758d76bc"))
self.assertEqual(value, ObjectId("675747ec45260945758d76bc"))

def test_clean_empty_string(self):
field = ObjectIdField(required=False)
value = field.clean("")
self.assertEqual(value, None)

def test_clean_invalid(self):
field = ObjectIdField()
with self.assertRaises(ValidationError) as cm:
field.clean("invalid")
self.assertEqual(cm.exception.messages[0], "Enter a valid Object Id.")

def test_prepare_value(self):
field = ObjectIdField()
value = field.prepare_value(ObjectId("675747ec45260945758d76bc"))
self.assertEqual(value, "675747ec45260945758d76bc")
15 changes: 15 additions & 0 deletions tests/model_fields_/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from django.db import models

from django_mongodb.fields import ObjectIdField


class ObjectIdModel(models.Model):
field = ObjectIdField()


class NullableObjectIdModel(models.Model):
field = ObjectIdField(blank=True, null=True)


class PrimaryKeyObjectIdModel(models.Model):
field = ObjectIdField(primary_key=True)
130 changes: 130 additions & 0 deletions tests/model_fields_/test_objectidfield.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import json

from bson import ObjectId
from django.core import serializers
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase, TestCase

from django_mongodb import forms
from django_mongodb.fields import ObjectIdField

from .models import NullableObjectIdModel, ObjectIdModel, PrimaryKeyObjectIdModel


class MethodTests(SimpleTestCase):
def test_deconstruct(self):
field = ObjectIdField()
name, path, args, kwargs = field.deconstruct()
self.assertEqual(path, "django_mongodb.fields.ObjectIdField")
self.assertEqual(args, [])
self.assertEqual(kwargs, {})

def test_formfield(self):
f = ObjectIdField().formfield()
self.assertIsInstance(f, forms.ObjectIdField)

def test_get_internal_type(self):
f = ObjectIdField()
self.assertEqual(f.get_internal_type(), "ObjectIdField")

def test_to_python_string(self):
value = "1" * 24
self.assertEqual(ObjectIdField().to_python(value), ObjectId(value))

def test_to_python_objectid(self):
value = ObjectId("1" * 24)
self.assertEqual(ObjectIdField().to_python(value), value)

def test_to_python_null(self):
self.assertIsNone(ObjectIdField().to_python(None))

def test_to_python_invalid_value(self):
f = ObjectIdField()
for invalid_value in ["None", "", {}, [], 123]:
with self.subTest(invalid_value=invalid_value):
msg = f"['“{invalid_value}” is not a valid Object Id.']"
with self.assertRaisesMessage(ValidationError, msg):
f.to_python(invalid_value)

def test_get_prep_value_string(self):
value = "1" * 24
self.assertEqual(ObjectIdField().get_prep_value(value), ObjectId(value))

def test_get_prep_value_objectid(self):
value = ObjectId("1" * 24)
self.assertEqual(ObjectIdField().get_prep_value(value), value)

def test_get_prep_value_null(self):
self.assertIsNone(ObjectIdField().get_prep_value(None))

def test_get_prep_value_invalid_values(self):
f = ObjectIdField()
f.name = "test"
for invalid_value in ["None", "", {}, [], 123]:
with self.subTest(invalid_value=invalid_value):
msg = f"['“{invalid_value}” is not a valid Object Id.']"
with self.assertRaisesMessage(ValidationError, msg):
f.get_prep_value(invalid_value)


class SaveLoadTests(TestCase):
def test_objectid_instance(self):
instance = ObjectIdModel.objects.create(field=ObjectId())
loaded = ObjectIdModel.objects.get()
self.assertEqual(loaded.field, instance.field)

def test_str_instance(self):
ObjectIdModel.objects.create(field="6754ed8e584bc9ceaae3c072")
loaded = ObjectIdModel.objects.get()
self.assertEqual(loaded.field, ObjectId("6754ed8e584bc9ceaae3c072"))

def test_null_handling(self):
NullableObjectIdModel.objects.create(field=None)
loaded = NullableObjectIdModel.objects.get()
self.assertIsNone(loaded.field)

def test_pk_validated(self):
with self.assertRaisesMessage(ValidationError, "is not a valid Object Id."):
PrimaryKeyObjectIdModel.objects.get(pk={})

with self.assertRaisesMessage(ValidationError, "is not a valid Object Id."):
PrimaryKeyObjectIdModel.objects.get(pk=[])

def test_wrong_lookup_type(self):
with self.assertRaisesMessage(ValidationError, "is not a valid Object Id."):
ObjectIdModel.objects.get(field="not-a-objectid")

with self.assertRaisesMessage(ValidationError, "is not a valid Object Id."):
ObjectIdModel.objects.create(field="not-a-objectid")


class SerializationTests(TestCase):
test_data = (
'[{"fields": {"field": "6754ed8e584bc9ceaae3c072"}, "model": '
'"model_fields_.objectidmodel", "pk": null}]'
)

def test_dumping(self):
instance = ObjectIdModel(field=ObjectId("6754ed8e584bc9ceaae3c072"))
data = serializers.serialize("json", [instance])
self.assertEqual(json.loads(data), json.loads(self.test_data))

def test_loading(self):
instance = next(serializers.deserialize("json", self.test_data)).object
self.assertEqual(instance.field, ObjectId("6754ed8e584bc9ceaae3c072"))


class ValidationTests(TestCase):
def test_invalid_objectid(self):
field = ObjectIdField()
with self.assertRaises(ValidationError) as cm:
field.clean("550e8400", None)
self.assertEqual(cm.exception.code, "invalid")
self.assertEqual(
cm.exception.message % cm.exception.params, "“550e8400” is not a valid Object Id."
)

def test_objectid_instance_ok(self):
value = ObjectId()
field = ObjectIdField()
self.assertEqual(field.clean(value, None), value)
Loading
Loading