Skip to content

Commit bc290a0

Browse files
committed
INTPYTHON-624 Add PolymorphicEmbeddedModelField
1 parent 1927cc3 commit bc290a0

File tree

10 files changed

+668
-2
lines changed

10 files changed

+668
-2
lines changed

django_mongodb_backend/base.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,10 @@ def _isnull_operator(a, b):
121121
"gte": lambda a, b: {"$gte": [a, b]},
122122
# MongoDB considers null less than zero. Exclude null values to match
123123
# SQL behavior.
124-
"lt": lambda a, b: {"$and": [{"$lt": [a, b]}, {"$ne": [a, None]}]},
125-
"lte": lambda a, b: {"$and": [{"$lte": [a, b]}, {"$ne": [a, None]}]},
124+
"lt": lambda a, b: {"$and": [{"$lt": [a, b]}, DatabaseWrapper._isnull_operator(a, False)]},
125+
"lte": lambda a, b: {
126+
"$and": [{"$lte": [a, b]}, DatabaseWrapper._isnull_operator(a, False)]
127+
},
126128
"in": lambda a, b: {"$in": [a, b]},
127129
"isnull": _isnull_operator,
128130
"range": lambda a, b: {

django_mongodb_backend/fields/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .embedded_model_array import EmbeddedModelArrayField
66
from .json import register_json_field
77
from .objectid import ObjectIdField
8+
from .polymorphic_embedded_model import PolymorphicEmbeddedModelField
89

910
__all__ = [
1011
"register_fields",
@@ -13,6 +14,7 @@
1314
"EmbeddedModelField",
1415
"ObjectIdAutoField",
1516
"ObjectIdField",
17+
"PolymorphicEmbeddedModelField",
1618
]
1719

1820

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import contextlib
2+
3+
from django.core import checks
4+
from django.core.exceptions import FieldDoesNotExist, ValidationError
5+
from django.db import models
6+
from django.db.models.fields.related import lazy_related_operation
7+
8+
from .embedded_model import KeyTransformFactory
9+
from .utils import get_mongodb_connection
10+
11+
12+
class PolymorphicEmbeddedModelField(models.Field):
13+
"""Field that stores a model instance of varying type."""
14+
15+
stores_model_instance = True
16+
17+
def __init__(self, embedded_models, *args, **kwargs):
18+
"""
19+
`embedded_models` is a list of possible model classes to be stored.
20+
Like other relational fields, each model may also be passed as a
21+
string.
22+
"""
23+
self.embedded_models = embedded_models
24+
kwargs["editable"] = False
25+
super().__init__(*args, **kwargs)
26+
27+
def db_type(self, connection):
28+
return "embeddedDocuments"
29+
30+
def check(self, **kwargs):
31+
from ..models import EmbeddedModel
32+
33+
errors = super().check(**kwargs)
34+
embedded_fields = {}
35+
for model in self.embedded_models:
36+
if not issubclass(model, EmbeddedModel):
37+
return [
38+
checks.Error(
39+
"Embedded models must be a subclass of "
40+
"django_mongodb_backend.models.EmbeddedModel.",
41+
obj=self,
42+
hint="{model} doesn't subclass EmbeddedModel.",
43+
id="django_mongodb_backend.embedded_model.E002",
44+
)
45+
]
46+
for field in model._meta.fields:
47+
if field.remote_field:
48+
errors.append(
49+
checks.Error(
50+
"Embedded models cannot have relational fields "
51+
f"({model().__class__.__name__}.{field.name} "
52+
f"is a {field.__class__.__name__}).",
53+
obj=self,
54+
id="django_mongodb_backend.embedded_model.E001",
55+
)
56+
)
57+
field_name = field.name
58+
if existing_field := embedded_fields.get(field.name):
59+
connection = get_mongodb_connection()
60+
if existing_field.db_type(connection) != field.db_type(connection):
61+
errors.append(
62+
checks.Warning(
63+
f"Embedded models {existing_field.model._meta.label} "
64+
f"and {field.model._meta.label} both have field "
65+
f"'{field_name}' of different type.",
66+
obj=self,
67+
id="django_mongodb_backend.embedded_model.E003",
68+
hint="It may be impossible to query both fields.",
69+
)
70+
)
71+
72+
else:
73+
embedded_fields[field_name] = field
74+
return errors
75+
76+
def deconstruct(self):
77+
name, path, args, kwargs = super().deconstruct()
78+
if path.startswith("django_mongodb_backend.fields.polymorphic_embedded_model"):
79+
path = path.replace(
80+
"django_mongodb_backend.fields.polymorphic_embedded_model",
81+
"django_mongodb_backend.fields",
82+
)
83+
kwargs["embedded_models"] = self.embedded_models
84+
del kwargs["editable"]
85+
return name, path, args, kwargs
86+
87+
def get_internal_type(self):
88+
return "PolymorphicEmbeddedModelField"
89+
90+
def _set_model(self, model):
91+
"""
92+
Resolve embedded model classes once the field knows the model it
93+
belongs to. If any of the items in __init__()'s embedded_models
94+
argument are strings, resolve each to the actual model class, similar
95+
to relational fields.
96+
"""
97+
self._model = model
98+
if model is not None:
99+
for embedded_model in self.embedded_models:
100+
if isinstance(embedded_model, str):
101+
102+
def _resolve_lookup(_, *resolved_models):
103+
self.embedded_models = resolved_models
104+
105+
lazy_related_operation(_resolve_lookup, model, *self.embedded_models)
106+
107+
model = property(lambda self: self._model, _set_model)
108+
109+
def from_db_value(self, value, expression, connection):
110+
return self.to_python(value)
111+
112+
def to_python(self, value):
113+
"""
114+
Pass embedded model fields' values through each field's to_python() and
115+
reinstantiate the embedded instance.
116+
"""
117+
if value is None:
118+
return None
119+
if not isinstance(value, dict):
120+
return value
121+
model_class = self._get_model_from_label(value.pop("_label"))
122+
instance = model_class(
123+
**{
124+
field.attname: field.to_python(value[field.attname])
125+
for field in model_class._meta.fields
126+
if field.attname in value
127+
}
128+
)
129+
instance._state.adding = False
130+
return instance
131+
132+
def get_db_prep_save(self, embedded_instance, connection):
133+
"""
134+
Apply pre_save() and get_db_prep_save() of embedded instance fields and
135+
create the {field: value} dict to be saved.
136+
"""
137+
if embedded_instance is None:
138+
return None
139+
if not isinstance(embedded_instance, self.embedded_models):
140+
raise TypeError(
141+
f"Expected instance of type {self.embedded_models!r}, not "
142+
f"{type(embedded_instance)!r}."
143+
)
144+
field_values = {}
145+
add = embedded_instance._state.adding
146+
for field in embedded_instance._meta.fields:
147+
value = field.get_db_prep_save(
148+
field.pre_save(embedded_instance, add), connection=connection
149+
)
150+
# Exclude unset primary keys (e.g. {'id': None}).
151+
if field.primary_key and value is None:
152+
continue
153+
field_values[field.attname] = value
154+
# Store the model's label to know the class to use for initializing
155+
# upon retrieval.
156+
field_values["_label"] = embedded_instance._meta.label
157+
# This instance will exist in the database soon.
158+
embedded_instance._state.adding = False
159+
return field_values
160+
161+
def get_transform(self, name):
162+
transform = super().get_transform(name)
163+
if transform:
164+
return transform
165+
for model in self.embedded_models:
166+
with contextlib.suppress(FieldDoesNotExist):
167+
field = model._meta.get_field(name)
168+
break
169+
else:
170+
raise FieldDoesNotExist(
171+
f"The models of field '{self.name}' have no field named '{name}'."
172+
)
173+
return KeyTransformFactory(name, field)
174+
175+
def validate(self, value, model_instance):
176+
super().validate(value, model_instance)
177+
if not isinstance(value, self.embedded_models):
178+
raise ValidationError(
179+
f"Expected instance of type {self.embedded_models!r}, not {type(value)!r}."
180+
)
181+
for field in value._meta.fields:
182+
attname = field.attname
183+
field.validate(getattr(value, attname), model_instance)
184+
185+
def formfield(self, **kwargs):
186+
raise NotImplementedError("PolymorphicEmbeddedModelField does not support forms.")
187+
188+
def _get_model_from_label(self, label):
189+
return next(model for model in self.embedded_models if model._meta.label == label)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from django.db import connections
2+
3+
4+
def get_mongodb_connection():
5+
for alias in connections:
6+
if connections[alias].vendor == "mongodb":
7+
return connections[alias]
8+
return None

django_mongodb_backend/operations.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ def get_db_converters(self, expression):
122122
)
123123
elif internal_type == "JSONField":
124124
converters.append(self.convert_jsonfield_value)
125+
elif internal_type == "PolymorphicEmbeddedModelField":
126+
converters.append(self.convert_polymorphicembeddedmodelfield_value)
125127
elif internal_type == "TimeField":
126128
# Trunc(... output_field="TimeField") values must remain datetime
127129
# until Trunc.convert_value() so they can be converted from UTC
@@ -182,6 +184,19 @@ def convert_jsonfield_value(self, value, expression, connection):
182184
"""
183185
return json.dumps(value)
184186

187+
def convert_polymorphicembeddedmodelfield_value(self, value, expression, connection):
188+
if value is not None:
189+
model_class = expression.output_field._get_model_from_label(value["_label"])
190+
# Apply database converters to each field of the embedded model.
191+
for field in model_class._meta.fields:
192+
field_expr = Expression(output_field=field)
193+
converters = connection.ops.get_db_converters(
194+
field_expr
195+
) + field_expr.get_db_converters(connection)
196+
for converter in converters:
197+
value[field.attname] = converter(value[field.attname], field_expr, connection)
198+
return value
199+
185200
def convert_timefield_value(self, value, expression, connection):
186201
if value is not None:
187202
value = value.time()

docs/source/ref/models/fields.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,3 +313,33 @@ These indexes use 0-based indexing.
313313
.. class:: ObjectIdField
314314

315315
Stores an :class:`~bson.objectid.ObjectId`.
316+
317+
``PolymorphicEmbeddedModelField``
318+
---------------------------------
319+
320+
.. class:: PolymorphicEmbeddedModelField(embedded_models, **kwargs)
321+
322+
.. versionadded:: 5.2.0b2
323+
324+
Stores a model of one of the types in ``embedded_models``.
325+
326+
.. attribute:: embedded_models
327+
328+
This is a required argument that specifies a list of model classes
329+
that may be embedded.
330+
331+
Each model class reference works just like
332+
:attr:`.EmbeddedModelField.embedded_model`.
333+
334+
See :ref:`the embedded model topic guide
335+
<polymorphic-embedded-model-field-example>` for more details and examples.
336+
337+
.. admonition:: Migrations support is limited
338+
339+
:djadmin:`makemigrations` does not yet detect changes to embedded models,
340+
nor does it create indexes or constraints for embedded models referenced
341+
by ``PolymorphicEmbeddedModelField``.
342+
343+
.. admonition:: Forms are not supported
344+
345+
``PolymorphicEmbeddedModelField``\s don't appear in model forms.

docs/source/releases/5.2.x.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ New features
1414
- Added the ``options`` parameter to
1515
:func:`~django_mongodb_backend.utils.parse_uri`.
1616
- Added support for :ref:`database transactions <transactions>`.
17+
- Added :class:`~.fields.PolymorphicEmbeddedModelField` for storing a model
18+
instance that may be of more than one model class.
1719

1820
5.2.0 beta 1
1921
============

0 commit comments

Comments
 (0)