Skip to content

Commit d549036

Browse files
committed
INTPYTHON-483 Add EmbeddedModelArrayField
1 parent 3885833 commit d549036

File tree

13 files changed

+777
-7
lines changed

13 files changed

+777
-7
lines changed

django_mongodb_backend/fields/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
from .auto import ObjectIdAutoField
33
from .duration import register_duration_field
44
from .embedded_model import EmbeddedModelField
5+
from .embedded_model_array import EmbeddedModelArrayField
56
from .json import register_json_field
67
from .objectid import ObjectIdField
78

89
__all__ = [
910
"register_fields",
1011
"ArrayField",
12+
"EmbeddedModelArrayField",
1113
"EmbeddedModelField",
1214
"ObjectIdAutoField",
1315
"ObjectIdField",
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from django.db.models import Field
2+
3+
from .. import forms
4+
from . import EmbeddedModelField
5+
from .array import ArrayField
6+
7+
8+
class EmbeddedModelArrayField(ArrayField):
9+
def __init__(self, embedded_model, **kwargs):
10+
if kwargs.get("size") is not None:
11+
raise ValueError("EmbeddedModelArrayField does not support size.")
12+
super().__init__(EmbeddedModelField(embedded_model), **kwargs)
13+
self.embedded_model = embedded_model
14+
15+
def deconstruct(self):
16+
name, path, args, kwargs = super().deconstruct()
17+
if path == "django_mongodb_backend.fields.embedded_model_array.EmbeddedModelArrayField":
18+
path = "django_mongodb_backend.fields.EmbeddedModelArrayField"
19+
kwargs.update(
20+
{
21+
"embedded_model": self.embedded_model,
22+
}
23+
)
24+
del kwargs["base_field"]
25+
return name, path, args, kwargs
26+
27+
def get_db_prep_value(self, value, connection, prepared=False):
28+
if isinstance(value, list | tuple):
29+
# Must call get_db_prep_save() rather than get_db_prep_value()
30+
# to transform model instances to dicts.
31+
return [self.base_field.get_db_prep_save(i, connection) for i in value]
32+
if value is not None:
33+
raise TypeError(
34+
f"Expected list of {self.embedded_model!r} instances, not {type(value)!r}."
35+
)
36+
return value
37+
38+
def formfield(self, **kwargs):
39+
# Skip ArrayField.formfield() which has unneeded base_field.
40+
return Field.formfield(
41+
self,
42+
**{
43+
"form_class": forms.EmbeddedModelArrayField,
44+
"model": self.base_field.embedded_model,
45+
"max_length": self.max_size,
46+
"prefix": self.name,
47+
**kwargs,
48+
},
49+
)

django_mongodb_backend/forms/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .fields import (
2+
EmbeddedModelArrayField,
23
EmbeddedModelField,
34
ObjectIdField,
45
SimpleArrayField,
@@ -7,6 +8,7 @@
78
)
89

910
__all__ = [
11+
"EmbeddedModelArrayField",
1012
"EmbeddedModelField",
1113
"SimpleArrayField",
1214
"SplitArrayField",

django_mongodb_backend/forms/fields/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from .array import SimpleArrayField, SplitArrayField, SplitArrayWidget
22
from .embedded_model import EmbeddedModelField
3+
from .embedded_model_array import EmbeddedModelArrayField
34
from .objectid import ObjectIdField
45

56
__all__ = [
7+
"EmbeddedModelArrayField",
68
"EmbeddedModelField",
79
"SimpleArrayField",
810
"SplitArrayField",
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from django import forms
2+
from django.core.exceptions import ValidationError
3+
from django.forms import formset_factory, model_to_dict
4+
from django.forms.models import modelform_factory
5+
from django.utils.html import format_html, format_html_join
6+
7+
8+
def models_to_dicts(models):
9+
"""
10+
Convert initial data (which is a list of model instances or None) to a
11+
list of dictionary data suitable for a formset.
12+
"""
13+
return [model_to_dict(model) for model in models or []]
14+
15+
16+
class EmbeddedModelArrayField(forms.Field):
17+
def __init__(self, model, prefix, max_length=None, *args, **kwargs):
18+
self.model = model
19+
self.prefix = prefix
20+
self.formset = formset_factory(
21+
form=modelform_factory(model, fields="__all__"),
22+
can_delete=True,
23+
max_num=max_length,
24+
extra=3,
25+
validate_max=True,
26+
)
27+
kwargs["widget"] = EmbeddedModelArrayWidget()
28+
super().__init__(*args, **kwargs)
29+
30+
def clean(self, value):
31+
if not value:
32+
return []
33+
formset = self.formset(value, prefix=self.prefix)
34+
if not formset.is_valid():
35+
raise ValidationError(formset.errors + formset.non_form_errors())
36+
cleaned_data = []
37+
for data in formset.cleaned_data:
38+
# The fallback to True skips empty forms.
39+
if data.get("DELETE", True):
40+
continue
41+
data.pop("DELETE") # The "delete" checkbox isn't part of model data.
42+
cleaned_data.append(self.model(**data))
43+
return cleaned_data
44+
45+
def has_changed(self, initial, data):
46+
formset = self.formset(data, initial=models_to_dicts(initial), prefix=self.prefix)
47+
return formset.has_changed()
48+
49+
def get_bound_field(self, form, field_name):
50+
return EmbeddedModelArrayBoundField(form, self, field_name)
51+
52+
53+
class EmbeddedModelArrayBoundField(forms.BoundField):
54+
def __init__(self, form, field, name):
55+
super().__init__(form, field, name)
56+
self.formset = field.formset(
57+
self.data if form.is_bound else None,
58+
initial=models_to_dicts(self.initial),
59+
prefix=self.html_name,
60+
)
61+
62+
def __str__(self):
63+
body = format_html_join(
64+
"\n", "<tbody>{}</tbody>", ((form.as_table(),) for form in self.formset)
65+
)
66+
return format_html("<table>\n{}\n</table>\n{}", body, self.formset.management_form)
67+
68+
69+
class EmbeddedModelArrayWidget(forms.Widget):
70+
"""
71+
This widget extracts the data for EmbeddedModelArrayFormField's formset.
72+
It is never rendered.
73+
"""
74+
75+
def value_from_datadict(self, data, files, name):
76+
return {key: data[key] for key in data if key.startswith(f"{name}-")}

django_mongodb_backend/operations.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,15 @@ def get_db_converters(self, expression):
111111
converters.append(self.convert_decimalfield_value)
112112
elif internal_type == "EmbeddedModelField":
113113
converters.append(self.convert_embeddedmodelfield_value)
114+
elif internal_type == "EmbeddedModelArrayField":
115+
converters.extend(
116+
[
117+
self._get_arrayfield_converter(converter)
118+
for converter in self.get_db_converters(
119+
Expression(output_field=expression.output_field.base_field)
120+
)
121+
]
122+
)
114123
elif internal_type == "JSONField":
115124
converters.append(self.convert_jsonfield_value)
116125
elif internal_type == "TimeField":

docs/source/ref/forms.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,31 @@ Some MongoDB-specific fields are available in ``django_mongodb_backend.forms``.
2323
in this field's subform will have so that the names don't collide with
2424
fields in the main form.
2525

26+
``EmbeddedModelArrayField``
27+
---------------------------
28+
29+
.. class:: EmbeddedModelArrayField(model, prefix, max_length=None, **kwargs)
30+
31+
.. versionadded:: 5.2.0b1
32+
33+
A field which maps to a model. The field will render as a
34+
:class:`~django.forms.ModelFormSet`.
35+
36+
.. attribute:: model
37+
38+
This is a required argument that specifies the model class.
39+
40+
.. attribute:: prefix
41+
42+
This is a required argument that specifies the prefix that all fields
43+
in this field's subform will have so that the names don't collide with
44+
fields in the main form.
45+
46+
.. attribute:: max_length
47+
48+
This is an optional argument which validates that the array does not
49+
exceed the stated length.
50+
2651
``ObjectIdField``
2752
-----------------
2853

docs/source/ref/models/fields.rst

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ Some MongoDB-specific fields are available in ``django_mongodb_backend.fields``.
3535
:class:`~django.db.models.OneToOneField` and
3636
:class:`~django.db.models.ManyToManyField`) and file fields (
3737
:class:`~django.db.models.FileField` and
38-
:class:`~django.db.models.ImageField`). :class:`EmbeddedModelField` is
39-
also not (yet) supported.
38+
:class:`~django.db.models.ImageField`). For
39+
:class:`EmbeddedModelField`, use :class:`EmbeddedModelArrayField`.
4040

4141
It is possible to nest array fields - you can specify an instance of
4242
``ArrayField`` as the ``base_field``. For example::
@@ -178,7 +178,7 @@ Index transforms
178178
Index transforms index into the array. Any non-negative integer can be used.
179179
There are no errors if it exceeds the :attr:`max_size <ArrayField.max_size>` of
180180
the array. The lookups available after the transform are those from the
181-
:attr:`base_field <ArrayField.base_field>`. For example:
181+
:attr:`~ArrayField.base_field`. For example:
182182

183183
.. code-block:: pycon
184184
@@ -224,7 +224,7 @@ These indexes use 0-based indexing.
224224

225225
.. class:: EmbeddedModelField(embedded_model, **kwargs)
226226

227-
Stores a model of type ``embedded_model``.
227+
Stores a model of type ``embedded_model``.
228228

229229
.. attribute:: embedded_model
230230

@@ -268,6 +268,27 @@ See :doc:`/topics/embedded-models` for more details and examples.
268268
created these models and then added an indexed field to ``Address``,
269269
the index created in the nested ``Book`` embed is not created.
270270

271+
``EmbeddedModelArrayField``
272+
---------------------------
273+
274+
.. class:: EmbeddedModelArrayField(embedded_model, max_size=None, **kwargs)
275+
276+
.. versionadded:: 5.2.0b1
277+
278+
Stores a list of models of type ``embedded_model``.
279+
280+
.. attribute:: embedded_model
281+
282+
This is a required argument, similar to
283+
:attr:`EmbeddedModelField.embedded_model`.
284+
285+
.. attribute:: max_size
286+
287+
This is an optional argument.
288+
289+
If passed, the array will have a maximum size as specified, validated
290+
by forms and model validation, but not enforced by the database.
291+
271292
``ObjectIdAutoField``
272293
---------------------
273294

tests/model_fields_/models.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
from django.db import models
44

5-
from django_mongodb_backend.fields import ArrayField, EmbeddedModelField, ObjectIdField
5+
from django_mongodb_backend.fields import (
6+
ArrayField,
7+
EmbeddedModelArrayField,
8+
EmbeddedModelField,
9+
ObjectIdField,
10+
)
611
from django_mongodb_backend.models import EmbeddedModel
712

813

@@ -143,3 +148,20 @@ class Library(models.Model):
143148

144149
def __str__(self):
145150
return self.name
151+
152+
153+
# EmbeddedModelArrayField
154+
class Review(EmbeddedModel):
155+
title = models.CharField(max_length=255)
156+
rating = models.DecimalField(max_digits=6, decimal_places=1)
157+
158+
def __str__(self):
159+
return self.title
160+
161+
162+
class Movie(models.Model):
163+
title = models.CharField(max_length=255)
164+
reviews = EmbeddedModelArrayField(Review, null=True)
165+
166+
def __str__(self):
167+
return self.title

0 commit comments

Comments
 (0)