Skip to content

INTPYTHON-624 Add PolymorphicEmbeddedModelField #327

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

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
2 changes: 1 addition & 1 deletion django_mongodb_backend/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,7 @@ def execute_sql(self, result_type):
elif hasattr(value, "prepare_database_save"):
if field.remote_field:
value = value.prepare_database_save(field)
elif not hasattr(field, "embedded_model"):
elif not getattr(field, "value_is_model_instance", False):
raise TypeError(
f"Tried to update field {field} with a model "
f"instance, {value!r}. Use a value compatible with "
Expand Down
2 changes: 2 additions & 0 deletions django_mongodb_backend/fields/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .embedded_model_array import EmbeddedModelArrayField
from .json import register_json_field
from .objectid import ObjectIdField
from .polymorphic_embedded_model import PolymorphicEmbeddedModelField

__all__ = [
"register_fields",
Expand All @@ -13,6 +14,7 @@
"EmbeddedModelField",
"ObjectIdAutoField",
"ObjectIdField",
"PolymorphicEmbeddedModelField",
]


Expand Down
2 changes: 2 additions & 0 deletions django_mongodb_backend/fields/embedded_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
class EmbeddedModelField(models.Field):
"""Field that stores a model instance."""

value_is_model_instance = True

def __init__(self, embedded_model, *args, **kwargs):
"""
`embedded_model` is the model class of the instance to be stored.
Expand Down
2 changes: 2 additions & 0 deletions django_mongodb_backend/fields/embedded_model_array.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@


class EmbeddedModelArrayField(ArrayField):
value_is_model_instance = True

def __init__(self, embedded_model, **kwargs):
if "size" in kwargs:
raise ValueError("EmbeddedModelArrayField does not support size.")
Expand Down
221 changes: 221 additions & 0 deletions django_mongodb_backend/fields/polymorphic_embedded_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import contextlib
import difflib

from django.core import checks
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.db import models
from django.db.models.fields.related import lazy_related_operation
from django.db.models.lookups import Transform


class PolymorphicEmbeddedModelField(models.Field):
"""Field that stores a model instance."""

value_is_model_instance = True

def __init__(self, embedded_models, *args, **kwargs):
"""
`embedded_models` is a list of possible model classes to be stored.
Like other relational fields, each model may also be passed as a
string.
"""
self.embedded_models = embedded_models
kwargs["editable"] = False
super().__init__(*args, **kwargs)

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

def check(self, **kwargs):
from ..models import EmbeddedModel

errors = super().check(**kwargs)
for model in self.embedded_models:
if not issubclass(model, EmbeddedModel):
return [
checks.Error(
"Embedded models must be a subclass of "
"django_mongodb_backend.models.EmbeddedModel.",
obj=self,
hint="{model} doesn't subclass EmbeddedModel.",
id="django_mongodb_backend.embedded_model.E002",
)
]
for field in model._meta.fields:
if field.remote_field:
errors.append(
checks.Error(
"Embedded models cannot have relational fields "
f"({model().__class__.__name__}.{field.name} "
f"is a {field.__class__.__name__}).",
obj=self,
id="django_mongodb_backend.embedded_model.E001",
)
)
return errors

def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if path.startswith("django_mongodb_backend.fields.polymorphic_embedded_model"):
path = path.replace(
"django_mongodb_backend.fields.polymorphic_embedded_model",
"django_mongodb_backend.fields",
)
kwargs["embedded_models"] = self.embedded_models
del kwargs["editable"]
return name, path, args, kwargs

def get_internal_type(self):
return "PolymorphicEmbeddedModelField"

def _set_model(self, model):
"""
Resolve embedded model classes once the field knows the model it
belongs to. If any of the items in __init__()'s embedded_models
argument are strings, resolve each to the actual model class,
similar to relation fields.
"""
self._model = model
if model is not None:
for embedded_model in self.embedded_models:
if isinstance(embedded_model, str):

def _resolve_lookup(_, *resolved_models):
self.embedded_models = resolved_models

lazy_related_operation(_resolve_lookup, model, *self.embedded_models)

model = property(lambda self: self._model, _set_model)

def from_db_value(self, value, expression, connection):
return self.to_python(value)

def to_python(self, value):
"""
Pass embedded model fields' values through each field's to_python() and
reinstantiate the embedded instance.
"""
if value is None:
return None
if not isinstance(value, dict):
return value
model_class = self._get_model_from_label(value.pop("_label"))
instance = model_class(
**{
field.attname: field.to_python(value[field.attname])
for field in model_class._meta.fields
if field.attname in value
}
)
instance._state.adding = False
return instance

def get_db_prep_save(self, embedded_instance, connection):
"""
Apply pre_save() and get_db_prep_save() of embedded instance fields and
create the {field: value} dict to be saved.
"""
if embedded_instance is None:
return None
if not isinstance(embedded_instance, self.embedded_models):
raise TypeError(
f"Expected instance of type {self.embedded_models!r}, not "
f"{type(embedded_instance)!r}."
)
field_values = {}
add = embedded_instance._state.adding
for field in embedded_instance._meta.fields:
value = field.get_db_prep_save(
field.pre_save(embedded_instance, add), connection=connection
)
# Exclude unset primary keys (e.g. {'id': None}).
if field.primary_key and value is None:
continue
field_values[field.attname] = value
field_values["_label"] = embedded_instance._meta.label
# This instance will exist in the database soon.
embedded_instance._state.adding = False
return field_values

def get_transform(self, name):
transform = super().get_transform(name)
if transform:
return transform
field = None
for model in self.embedded_models:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if multiple submodes has the same field name?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I called this out in the design doc. I think we'll have to use the system check framework to enforce that common field names at least use the same type, otherwise, we won't know how to prepare the lookup value (e.g. the DatabaseOperations.adapt_<field>_value() methods).

Thinking about it some more, there is also the possibility that nested embedded documents share a field name. In that case, we won't know which field to traverse for the nested lookups that follow.

with contextlib.suppress(FieldDoesNotExist):
field = model._meta.get_field(name)
if field is None:
raise FieldDoesNotExist(
f"The models of field '{self.name}' have no field named '{name}'."
)
return KeyTransformFactory(name, field)

def validate(self, value, model_instance):
super().validate(value, model_instance)
if not isinstance(value, self.embedded_models):
raise ValidationError(
f"Expected instance of type {self.embedded_models!r}, not {type(value)!r}."
)
for field in value._meta.fields:
attname = field.attname
field.validate(getattr(value, attname), model_instance)

def formfield(self, **kwargs):
raise NotImplementedError("PolymorphicEmbeddedModelField does not support forms.")

def _get_model_from_label(self, label):
return {model._meta.label: model for model in self.embedded_models}[label]


class KeyTransform(Transform):
def __init__(self, key_name, ref_field, *args, **kwargs):
super().__init__(*args, **kwargs)
self.key_name = str(key_name)
self.ref_field = ref_field

def get_lookup(self, name):
return self.ref_field.get_lookup(name)

def get_transform(self, name):
"""
Validate that `name` is either a field of an embedded model or a
lookup on an embedded model's field.
"""
if transform := self.ref_field.get_transform(name):
return transform
suggested_lookups = difflib.get_close_matches(name, self.ref_field.get_lookups())
if suggested_lookups:
suggested_lookups = " or ".join(suggested_lookups)
suggestion = f", perhaps you meant {suggested_lookups}?"
else:
suggestion = "."
raise FieldDoesNotExist(
f"Unsupported lookup '{name}' for "
f"{self.ref_field.__class__.__name__} '{self.ref_field.name}'"
f"{suggestion}"
)

def as_mql(self, compiler, connection):
previous = self
key_transforms = []
while isinstance(previous, KeyTransform):
key_transforms.insert(0, previous.key_name)
previous = previous.lhs
mql = previous.as_mql(compiler, connection)
for key in key_transforms:
mql = {"$getField": {"input": mql, "field": key}}
return mql

@property
def output_field(self):
return self.ref_field


class KeyTransformFactory:
def __init__(self, key_name, ref_field):
self.key_name = key_name
self.ref_field = ref_field

def __call__(self, *args, **kwargs):
return KeyTransform(self.key_name, self.ref_field, *args, **kwargs)
15 changes: 15 additions & 0 deletions django_mongodb_backend/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ def get_db_converters(self, expression):
)
elif internal_type == "JSONField":
converters.append(self.convert_jsonfield_value)
elif internal_type == "PolymorphicEmbeddedModelField":
converters.append(self.convert_polymorphicembeddedmodelfield_value)
elif internal_type == "TimeField":
# Trunc(... output_field="TimeField") values must remain datetime
# until Trunc.convert_value() so they can be converted from UTC
Expand Down Expand Up @@ -182,6 +184,19 @@ def convert_jsonfield_value(self, value, expression, connection):
"""
return json.dumps(value)

def convert_polymorphicembeddedmodelfield_value(self, value, expression, connection):
if value is not None:
model_class = expression.output_field._get_model_from_label(value["_label"])
# Apply database converters to each field of the embedded model.
for field in model_class._meta.fields:
field_expr = Expression(output_field=field)
converters = connection.ops.get_db_converters(
field_expr
) + field_expr.get_db_converters(connection)
for converter in converters:
value[field.attname] = converter(value[field.attname], field_expr, connection)
return value

def convert_timefield_value(self, value, expression, connection):
if value is not None:
value = value.time()
Expand Down
29 changes: 29 additions & 0 deletions docs/source/ref/models/fields.rst
Original file line number Diff line number Diff line change
Expand Up @@ -313,3 +313,32 @@ These indexes use 0-based indexing.
.. class:: ObjectIdField

Stores an :class:`~bson.objectid.ObjectId`.

``PolymorphicEmbeddedModelField``
---------------------------------

.. class:: PolymorphicEmbeddedModelField(embedded_models, **kwargs)

.. versionadded:: 5.2.0b2

Stores a model of type ``embedded_models``.

.. attribute:: embedded_models

This is a required argument that specifies a list of model classes
that may be embedded.

Each model class reference works just like
:attr:`.EmbeddedModelField.embedded_model`.

See :ref:`the embedded model topic guide
<polymorphic-embedded-model-field-example>` for more details and examples.

.. admonition:: Migrations support is limited

:djadmin:`makemigrations` does not yet detect changes to embedded models,
nor does it create indexes or constraints for embedded models.

.. admonition:: Forms are not supported

``PolymorphicEmbeddedModelField``\s don't appear in model forms.
Loading
Loading