Skip to content

Commit 825ffca

Browse files
committed
INTPYTHON-483 Add EmbeddedModelArrayField
1 parent c5bfebf commit 825ffca

File tree

16 files changed

+859
-7
lines changed

16 files changed

+859
-7
lines changed

.pre-commit-config.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ repos:
6161
rev: v1.1.1
6262
hooks:
6363
- id: doc8
64-
args: ["--ignore=D001"] # ignore line length
64+
# D000 Invalid class attribute value for "class" directive when using
65+
# * (keyword-only parameters separator).
66+
# D001 line length
67+
args: ["--ignore=D000,D001"]
6568
stages: [manual]
6669

6770
- repo: https://github.com/sirosen/check-jsonschema

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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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 "size" in kwargs:
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["embedded_model"] = self.embedded_model
20+
del kwargs["base_field"]
21+
return name, path, args, kwargs
22+
23+
def get_db_prep_value(self, value, connection, prepared=False):
24+
if isinstance(value, list | tuple):
25+
# Must call get_db_prep_save() rather than get_db_prep_value()
26+
# to transform model instances to dicts.
27+
return [self.base_field.get_db_prep_save(i, connection) for i in value]
28+
if value is not None:
29+
raise TypeError(
30+
f"Expected list of {self.embedded_model!r} instances, not {type(value)!r}."
31+
)
32+
return value
33+
34+
def formfield(self, **kwargs):
35+
# Skip ArrayField.formfield() which has some differeences, including
36+
# unneeded "base_field" and "max_length" instead of "max_num".
37+
return Field.formfield(
38+
self,
39+
**{
40+
"form_class": forms.EmbeddedModelArrayField,
41+
"model": self.base_field.embedded_model,
42+
"max_num": self.max_size,
43+
"prefix": self.name,
44+
**kwargs,
45+
},
46+
)

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

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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,36 @@ 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_num=None, extra_forms=3, **kwargs)
30+
31+
.. versionadded:: 5.2.0b1
32+
33+
A field which maps to a list of model instances. The field will render as a
34+
:class:`ModelFormSet <django.forms.models.BaseModelFormSet>`.
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 formset will have so that the names don't collide with
44+
fields in the main form.
45+
46+
.. attribute:: max_num
47+
48+
This is an optional argument which specifies the maximum number of
49+
model instances that can be created.
50+
51+
.. attribute:: extra_forms
52+
53+
This argument specifies the number of blank forms that will be
54+
rendered by the formset.
55+
2656
``ObjectIdField``
2757
-----------------
2858

docs/source/ref/models/fields.rst

Lines changed: 29 additions & 2 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::
@@ -268,6 +268,33 @@ These indexes use 0-based indexing.
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+
Similar to :class:`EmbeddedModelField`, but stores a list of models of type
279+
``embedded_model`` rather than a single instance.
280+
281+
.. attribute:: embedded_model
282+
283+
This is a required argument, similar to
284+
:attr:`EmbeddedModelField.embedded_model`.
285+
286+
.. attribute:: max_size
287+
288+
This is an optional argument.
289+
290+
If passed, the array will have a maximum size as specified, validated
291+
by forms and model validation, but not enforced by the database.
292+
293+
.. admonition:: Migrations support is limited
294+
295+
As described above for :class:`EmbeddedModelField`,
296+
:djadmin:`makemigrations` does not yet detect changes to embedded models.
297+
271298
``ObjectIdAutoField``
272299
---------------------
273300

docs/source/releases/5.2.x.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ Django MongoDB Backend 5.2.x
77

88
*Unreleased*
99

10+
New features
11+
------------
12+
13+
- Added :class:`~.fields.EmbeddedModelArrayField` for storing data as an array
14+
of model instances.
15+
1016
Bug fixes
1117
---------
1218

docs/source/topics/embedded-models.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
Embedded models
22
===============
33

4-
Use :class:`~django_mongodb_backend.fields.EmbeddedModelField` to structure
4+
Use :class:`~django_mongodb_backend.fields.EmbeddedModelField` and
5+
:class:`~django_mongodb_backend.fields.EmbeddedModelArrayField` to structure
56
your data using `embedded documents
67
<https://www.mongodb.com/docs/manual/data-modeling/#embedded-data>`_.
78

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)