forked from mongodb/django-mongodb-backend
-
Notifications
You must be signed in to change notification settings - Fork 0
schema checks and unit tests. #3
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
Closed
WaVEV
wants to merge
24
commits into
timgraham:embedded-model-field
from
WaVEV:embedded-model-field-schema-checks
Closed
Changes from 22 commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
4d36422
add support for EmbeddedModelField
timgraham 9a42362
remove generic support
timgraham 570acab
fix datetime in tests
timgraham ddb41c8
move test
timgraham 1d8963d
beginnings of querying
timgraham 2e7e277
fix nested querying
timgraham 452fd0b
wip schema changes
timgraham 5743eb9
add support for Meta.indexes on embedded models
timgraham b68a2ea
add support for Meta.constraints and Meta.index_together
timgraham 9b6b6ba
refactor tests
timgraham a52c33e
AddField support
timgraham 4d9c6c6
wip
timgraham 3d4d020
add unique_together
timgraham 4602191
add/remove field complete
timgraham 62cafa8
wip forms support
timgraham 9a24853
schema checks and unit tests.
WaVEV 7aa33c0
Embedded field does not generates join ref counts.
WaVEV c97c7c3
Fix clone issue when the output field takes arguments.
WaVEV 8dac716
refactor.
WaVEV a4a8025
Validate embedded model fields.
WaVEV b6261c5
Add group by and order tests.
WaVEV d6f7812
Add subquery and foreign field unit tests.
WaVEV 93d684b
Edit class name.
WaVEV 0af7241
Fix exception message when a subfield does not have a selected field
WaVEV File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,13 @@ | ||
from .auto import ObjectIdAutoField | ||
from .duration import register_duration_field | ||
from .embedded_model import EmbeddedModelField, register_embedded_model_field | ||
from .json import register_json_field | ||
from .objectid import ObjectIdField | ||
|
||
__all__ = ["register_fields", "ObjectIdAutoField", "ObjectIdField"] | ||
__all__ = ["register_fields", "EmbeddedModelField", "ObjectIdAutoField", "ObjectIdField"] | ||
|
||
|
||
def register_fields(): | ||
register_duration_field() | ||
register_embedded_model_field() | ||
register_json_field() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,219 @@ | ||
from django.core.exceptions import FieldDoesNotExist | ||
from django.db import models | ||
from django.db.models.fields.related import lazy_related_operation | ||
from django.db.models.lookups import Transform | ||
|
||
from .. import forms | ||
|
||
|
||
class EmbeddedModelField(models.Field): | ||
"""Field that stores a model instance.""" | ||
|
||
def __init__(self, embedded_model, *args, **kwargs): | ||
""" | ||
`embedded_model` is the model class of the instance that will be | ||
stored. Like other relational fields, it may also be passed as a | ||
string. | ||
""" | ||
if not isinstance(embedded_model, str): | ||
self._validate_embedded_field(self, embedded_model) | ||
|
||
self.embedded_model = embedded_model | ||
super().__init__(*args, **kwargs) | ||
|
||
def deconstruct(self): | ||
name, path, args, kwargs = super().deconstruct() | ||
if path.startswith("django_mongodb.fields.embedded_model"): | ||
path = path.replace("django_mongodb.fields.embedded_model", "django_mongodb.fields") | ||
kwargs["embedded_model"] = self.embedded_model | ||
return name, path, args, kwargs | ||
|
||
def get_internal_type(self): | ||
return "EmbeddedModelField" | ||
|
||
@staticmethod | ||
def _validate_embedded_field(_, model): | ||
for field in model._meta.local_fields: | ||
if isinstance(field, models.ForeignKey | models.OneToOneField): | ||
raise TypeError( | ||
f"Field of type {type(field)!r} is not supported within an EmbeddedModelField." | ||
) | ||
|
||
def _set_model(self, model): | ||
""" | ||
Resolve embedded model class once the field knows the model it belongs | ||
to. | ||
|
||
If the model argument passed to __init__() was a string, resolve that | ||
string to the corresponding model class, similar to relation fields. | ||
However, we need to know our own model to generate a valid key | ||
for the embedded model class lookup and EmbeddedModelFields are | ||
not contributed_to_class if used in iterable fields. Thus the | ||
collection field sets this field's "model" attribute in its | ||
contribute_to_class(). | ||
""" | ||
self._model = model | ||
if model is not None and isinstance(self.embedded_model, str): | ||
|
||
def _resolve_lookup(_, resolved_model): | ||
self.embedded_model = resolved_model | ||
|
||
lazy_related_operation(_resolve_lookup, model, self.embedded_model) | ||
lazy_related_operation(self._validate_embedded_field, model, self.embedded_model) | ||
|
||
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): | ||
""" | ||
Passes embedded model fields' values through embedded fields | ||
to_python() and reinstiatates the embedded instance. | ||
""" | ||
if value is None: | ||
return None | ||
if not isinstance(value, dict): | ||
return value | ||
# Create the model instance. | ||
instance = self.embedded_model( | ||
**{ | ||
# Pass values through respective fields' to_python(), leaving | ||
# fields for which no value is specified uninitialized. | ||
field.attname: field.to_python(value[field.attname]) | ||
for field in self.embedded_model._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 passes a field => value mapping down to database | ||
type conversions. | ||
|
||
The embedded instance will be saved as a column => value dict, but | ||
because we need to apply database type conversions on embedded instance | ||
fields' values and for these we need to know fields those values come | ||
from, we need to entrust the database layer with creating the dict. | ||
""" | ||
if embedded_instance is None: | ||
return None | ||
if not isinstance(embedded_instance, self.embedded_model): | ||
raise TypeError( | ||
f"Expected instance of type {self.embedded_model!r}, not " | ||
f"{type(embedded_instance)!r}." | ||
) | ||
# Apply pre_save() and get_db_prep_save() of embedded instance | ||
# fields, create the field => value mapping to be passed to | ||
# storage preprocessing. | ||
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 | ||
# This instance will exist in the database soon. | ||
# TODO.XXX: Ensure that this doesn't cause race conditions. | ||
embedded_instance._state.adding = False | ||
return field_values | ||
|
||
def get_transform(self, name): | ||
transform = super().get_transform(name) | ||
if transform: | ||
return transform | ||
field = self.embedded_model._meta.get_field(name) | ||
return KeyTransformFactory(name, field) | ||
|
||
def validate(self, value, model_instance): | ||
super().validate(value, model_instance) | ||
if self.embedded_model is None: | ||
return | ||
for field in self.embedded_model._meta.fields: | ||
attname = field.attname | ||
field.validate(getattr(value, attname), model_instance) | ||
|
||
def formfield(self, **kwargs): | ||
return super().formfield( | ||
**{ | ||
"form_class": forms.EmbeddedModelFormField, | ||
"model": self.embedded_model, | ||
"name": self.name, | ||
**kwargs, | ||
} | ||
) | ||
|
||
|
||
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_transform(self, name): | ||
result = None | ||
if isinstance(self.ref_field, EmbeddedModelField): | ||
opts = self.ref_field.embedded_model._meta | ||
new_field = opts.get_field(name) | ||
result = KeyTransformFactory(name, new_field) | ||
else: | ||
if self.ref_field.get_transform(name) is None: | ||
raise FieldDoesNotExist( | ||
f"{self.ref_field.model._meta.object_name} has no field named '{name}'" | ||
) | ||
result = KeyTransformFactory(name, self.ref_field) | ||
return result | ||
|
||
def preprocess_lhs(self, compiler, connection): | ||
previous = self | ||
embedded_key_transforms = [] | ||
json_key_transforms = [] | ||
while isinstance(previous, KeyTransform): | ||
if isinstance(previous.ref_field, EmbeddedModelField): | ||
embedded_key_transforms.insert(0, previous.key_name) | ||
else: | ||
json_key_transforms.insert(0, previous.key_name) | ||
previous = previous.lhs | ||
mql = previous.as_mql(compiler, connection) | ||
embedded_key_transforms.append(json_key_transforms.pop(0)) | ||
return mql, embedded_key_transforms, json_key_transforms | ||
|
||
|
||
def key_transform(self, compiler, connection): | ||
mql, key_transforms, json_key_transforms = self.preprocess_lhs(compiler, connection) | ||
transforms = ".".join(key_transforms) | ||
result = f"{mql}.{transforms}" | ||
for key in json_key_transforms: | ||
get_field = {"$getField": {"input": result, "field": key}} | ||
# Handle array indexing if the key is a digit. If key is something | ||
# like '001', it's not an array index despite isdigit() returning True. | ||
if key.isdigit() and str(int(key)) == key: | ||
result = { | ||
"$cond": { | ||
"if": {"$isArray": result}, | ||
"then": {"$arrayElemAt": [result, int(key)]}, | ||
"else": get_field, | ||
} | ||
} | ||
else: | ||
result = get_field | ||
return result | ||
|
||
|
||
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) | ||
|
||
|
||
def register_embedded_model_field(): | ||
KeyTransform.as_mql = key_transform |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
from django import forms | ||
from django.forms.models import modelform_factory | ||
from django.utils.safestring import mark_safe | ||
from django.utils.translation import gettext_lazy as _ | ||
|
||
|
||
class EmbeddedModelWidget(forms.MultiWidget): | ||
def __init__(self, field_names, *args, **kwargs): | ||
self.field_names = field_names | ||
super().__init__(*args, **kwargs) | ||
# The default widget names are "_0", "_1", etc. Use the field names | ||
# instead since that's how they'll be rendered by the model form. | ||
self.widgets_names = ["-" + name for name in field_names] | ||
|
||
def decompress(self, value): | ||
if value is None: | ||
return [] | ||
# Get the data from `value` (a model) for each field. | ||
return [getattr(value, name) for name in self.field_names] | ||
|
||
|
||
class EmbeddedModelBoundField(forms.BoundField): | ||
def __str__(self): | ||
"""Render the model form as the representation for this field.""" | ||
form = self.field.model_form_cls(instance=self.value(), **self.field.form_kwargs) | ||
return mark_safe(f"{form.as_div()}") # noqa: S308 | ||
|
||
|
||
class EmbeddedModelFormField(forms.MultiValueField): | ||
default_error_messages = { | ||
"invalid": _("Enter a list of values."), | ||
"incomplete": _("Enter all required values."), | ||
} | ||
|
||
def __init__(self, model, name, *args, **kwargs): | ||
form_kwargs = {} | ||
# The field must be prefixed with the name of the field. | ||
form_kwargs["prefix"] = name | ||
self.form_kwargs = form_kwargs | ||
self.model_form_cls = modelform_factory(model, fields="__all__") | ||
self.model_form = self.model_form_cls(**form_kwargs) | ||
self.field_names = list(self.model_form.fields.keys()) | ||
fields = self.model_form.fields.values() | ||
widgets = [field.widget for field in fields] | ||
widget = EmbeddedModelWidget(self.field_names, widgets) | ||
super().__init__(*args, fields=fields, widget=widget, require_all_fields=False, **kwargs) | ||
|
||
def compress(self, data_dict): | ||
if not data_dict: | ||
return None | ||
values = dict(zip(self.field_names, data_dict, strict=False)) | ||
return self.model_form._meta.model(**values) | ||
|
||
def get_bound_field(self, form, field_name): | ||
return EmbeddedModelBoundField(form, self, field_name) | ||
|
||
def bound_data(self, data, initial): | ||
if self.disabled: | ||
return initial | ||
# The bound data must be transformed into a model instance. | ||
return self.compress(data) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.