Skip to content

Commit f528213

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

File tree

17 files changed

+1671
-10
lines changed

17 files changed

+1671
-10
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 differences, 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.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: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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_override or 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+
# Nested embedded model form fields need a double prefix.
43+
# HACK: Setting self.prefix_override makes it available in clean()
44+
# which doesn't have access to the form.
45+
self.prefix_override = f"{form.prefix}-{self.prefix}" if form.prefix else None
46+
return EmbeddedModelArrayBoundField(form, self, field_name, self.prefix_override)
47+
48+
49+
class EmbeddedModelArrayBoundField(forms.BoundField):
50+
def __init__(self, form, field, name, prefix_override):
51+
super().__init__(form, field, name)
52+
self.formset = field.formset(
53+
self.data if form.is_bound else None,
54+
initial=models_to_dicts(self.initial),
55+
prefix=prefix_override if prefix_override else self.html_name,
56+
)
57+
58+
def __str__(self):
59+
body = format_html_join(
60+
"\n", "<tbody>{}</tbody>", ((form.as_table(),) for form in self.formset)
61+
)
62+
return format_html("<table>\n{}\n</table>\n{}", body, self.formset.management_form)
63+
64+
65+
class EmbeddedModelArrayWidget(forms.Widget):
66+
"""
67+
Extract the data for EmbeddedModelArrayFormField's formset.
68+
This widget is never rendered.
69+
"""
70+
71+
def value_from_datadict(self, data, files, name):
72+
return {field: value for field, value in data.items() if field.startswith(f"{name}-")}
73+
74+
75+
def models_to_dicts(models):
76+
"""
77+
Convert initial data (which is a list of model instances or None) to a
78+
list of dictionary data suitable for a formset.
79+
"""
80+
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/make.bat

100644100755
File mode changed.

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: 34 additions & 3 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::
@@ -256,7 +256,8 @@ These indexes use 0-based indexing.
256256
class Book(models.Model):
257257
author = EmbeddedModelField(Author)
258258

259-
See :doc:`/topics/embedded-models` for more details and examples.
259+
See :ref:`the embedded model topic guide <embedded-model-field-example>`
260+
for more details and examples.
260261

261262
.. admonition:: Migrations support is limited
262263

@@ -268,6 +269,36 @@ These indexes use 0-based indexing.
268269
created these models and then added an indexed field to ``Address``,
269270
the index created in the nested ``Book`` embed is not created.
270271

272+
``EmbeddedModelArrayField``
273+
---------------------------
274+
275+
.. class:: EmbeddedModelArrayField(embedded_model, max_size=None, **kwargs)
276+
277+
.. versionadded:: 5.2.0b1
278+
279+
Similar to :class:`EmbeddedModelField`, but stores a **list** of models of
280+
type ``embedded_model`` rather than a single instance.
281+
282+
.. attribute:: embedded_model
283+
284+
This is a required argument that works just like
285+
:attr:`EmbeddedModelField.embedded_model`.
286+
287+
.. attribute:: max_size
288+
289+
This is an optional argument.
290+
291+
If passed, the list will have a maximum size as specified, validated
292+
by forms and model validation, but not enforced by the database.
293+
294+
See :ref:`the embedded model topic guide
295+
<embedded-model-array-field-example>` for more details and examples.
296+
297+
.. admonition:: Migrations support is limited
298+
299+
As described above for :class:`EmbeddedModelField`,
300+
:djadmin:`makemigrations` does not yet detect changes to embedded models.
301+
271302
``ObjectIdAutoField``
272303
---------------------
273304

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 a list of model
14+
instances.
15+
1016
Bug fixes
1117
---------
1218

docs/source/topics/embedded-models.rst

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
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

9+
.. _embedded-model-field-example:
10+
11+
``EmbeddedModelField``
12+
----------------------
13+
814
The basics
9-
----------
15+
~~~~~~~~~~
1016

1117
Let's consider this example::
1218

@@ -47,10 +53,57 @@ Represented in BSON, Bob's structure looks like this:
4753
}
4854
4955
Querying ``EmbeddedModelField``
50-
-------------------------------
56+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
5157

5258
You can query into an embedded model using the same double underscore syntax
5359
as relational fields. For example, to retrieve all customers who have an
5460
address with the city "New York"::
5561

5662
>>> Customer.objects.filter(address__city="New York")
63+
64+
.. _embedded-model-array-field-example:
65+
66+
``EmbeddedModelArrayField``
67+
---------------------------
68+
69+
The basics
70+
~~~~~~~~~~
71+
72+
Let's consider this example::
73+
74+
from django.db import models
75+
from django_mongodb_backend.fields import EmbeddedModelArrayField
76+
from django_mongodb_backend.models import EmbeddedModel
77+
78+
class Post(models.Model):
79+
name = models.CharField(max_length=200)
80+
tags = EmbeddedModelArrayField("Tag")
81+
82+
def __str__(self):
83+
return self.name
84+
85+
86+
class Tag(EmbeddedModel):
87+
name = models.CharField(max_length=100)
88+
89+
def __str__(self):
90+
return self.name
91+
92+
93+
The API is similar to that of Django's relational fields::
94+
95+
>>> post = Post.objects.create(name="Hello world!", tags=[Tag(name="welcome"), Tag(name="test")])
96+
>>> post.tags
97+
[<Tag: welcome>, <Tag: test>]
98+
>>> post.tags[0].name
99+
'welcome'
100+
101+
Represented in BSON, the post's structure looks like this:
102+
103+
.. code-block:: js
104+
105+
{
106+
_id: ObjectId('683dee4c6b79670044c38e3f'),
107+
name: 'Hello world!',
108+
tags: [ { name: 'welcome' }, { name: 'test' } ]
109+
}

0 commit comments

Comments
 (0)