-
Notifications
You must be signed in to change notification settings - Fork 23
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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", | ||
|
@@ -13,6 +14,7 @@ | |
"EmbeddedModelField", | ||
"ObjectIdAutoField", | ||
"ObjectIdField", | ||
"PolymorphicEmbeddedModelField", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Drive by comment. I am not trying to convince anyone of anything, but I would love to use this feature if it were called
Unlike my concern about using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TL;DR -- I'm against the use of
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks! |
||
] | ||
|
||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,195 @@ | ||
import contextlib | ||
|
||
from django.core import checks | ||
from django.core.exceptions import FieldDoesNotExist, ValidationError | ||
from django.db import connections, models | ||
from django.db.models.fields.related import lazy_related_operation | ||
|
||
from .embedded_model import KeyTransformFactory | ||
|
||
|
||
class PolymorphicEmbeddedModelField(models.Field): | ||
"""Field that stores a model instance of varying type.""" | ||
|
||
stores_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) | ||
embedded_fields = {} | ||
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", | ||
) | ||
) | ||
field_name = field.name | ||
if existing_field := embedded_fields.get(field.name): | ||
connection = _get_mongodb_connection() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This check should run anytime? or only when a migration is applied on the database? 🤔 Edit: change the way that field types are compared is viable? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's intended to run at the |
||
if existing_field.db_type(connection) != field.db_type(connection): | ||
errors.append( | ||
checks.Warning( | ||
f"Embedded models {existing_field.model._meta.label} " | ||
f"and {field.model._meta.label} both have field " | ||
f"'{field_name}' of different type.", | ||
obj=self, | ||
id="django_mongodb_backend.embedded_model.E003", | ||
hint="It may be impossible to query both fields.", | ||
) | ||
) | ||
|
||
else: | ||
embedded_fields[field_name] = field | ||
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 relational 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. | ||
timgraham marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
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 | ||
# Store the model's label to know the class to use for initializing | ||
# upon retrieval. | ||
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 | ||
for model in self.embedded_models: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what if multiple submodes has the same field name? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This would only matter in the case of nested polymorphic fields, right? I think we can maintain that polymorphic fields of the same name should also require the same embedded models to define them? This then keeps us in alignment with the requirement that all name conflicts "are of the same type". class Colonial(EmbeddedModel):
...
class Townhome(EmbeddedModel):
...
class Condo(EmbeddedModel):
...
class AmericanAddress(EmbeddedModel):
city = models.CharField(...)
state = models.CharField(...)
zip_code = models.CharField(...)
home_specifications = PolymorphicEmbeddedModelField([Colonial, Townhome, Condo],...)
class CanadianAddress(EmbeddedModel):
city = models.CharField(...)
province = models.CharField(...)
zip_code = models.CharField(...)
home_specifications = PolymorphicEmbeddedModelField([Colonial, Townhome, Condo],...)
class Home(models.Model):
address = PolymorphicEmbeddedModelField([AmericanAddress, CanadianAddress], ...)
num_inhabitants = models.IntegerField(...) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That seems like a reasonable restriction. Whether it's worth adding a system check for that at this stage, I'm doubtful. There are many other cases where the current system check falls short. For example, the |
||
with contextlib.suppress(FieldDoesNotExist): | ||
field = model._meta.get_field(name) | ||
break | ||
else: | ||
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 next(model for model in self.embedded_models if model._meta.label == label) | ||
|
||
|
||
def _get_mongodb_connection(): | ||
for alias in connections: | ||
if connections[alias].vendor == "mongodb": | ||
return connections[alias] | ||
return None |
Uh oh!
There was an error while loading. Please reload this page.