Skip to content

INTPYTHON-483 Add EmbeddedModelArrayField #292

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

Merged
merged 1 commit into from
Jun 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ repos:
rev: v1.1.1
hooks:
- id: doc8
args: ["--ignore=D001"] # ignore line length
# D000 Invalid class attribute value for "class" directive when using
# * (keyword-only parameters separator).
# D001 line length
args: ["--ignore=D000,D001"]
stages: [manual]

- repo: https://github.com/sirosen/check-jsonschema
Expand Down
2 changes: 2 additions & 0 deletions django_mongodb_backend/fields/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
from .auto import ObjectIdAutoField
from .duration import register_duration_field
from .embedded_model import EmbeddedModelField
from .embedded_model_array import EmbeddedModelArrayField
from .json import register_json_field
from .objectid import ObjectIdField

__all__ = [
"register_fields",
"ArrayField",
"EmbeddedModelArrayField",
"EmbeddedModelField",
"ObjectIdAutoField",
"ObjectIdField",
Expand Down
46 changes: 46 additions & 0 deletions django_mongodb_backend/fields/embedded_model_array.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from django.db.models import Field

from .. import forms
from . import EmbeddedModelField
from .array import ArrayField


class EmbeddedModelArrayField(ArrayField):
def __init__(self, embedded_model, **kwargs):
if "size" in kwargs:
raise ValueError("EmbeddedModelArrayField does not support size.")
super().__init__(EmbeddedModelField(embedded_model), **kwargs)
self.embedded_model = embedded_model

def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if path == "django_mongodb_backend.fields.embedded_model_array.EmbeddedModelArrayField":
path = "django_mongodb_backend.fields.EmbeddedModelArrayField"
kwargs["embedded_model"] = self.embedded_model
del kwargs["base_field"]
return name, path, args, kwargs

def get_db_prep_value(self, value, connection, prepared=False):
if isinstance(value, list | tuple):
# Must call get_db_prep_save() rather than get_db_prep_value()
# to transform model instances to dicts.
return [self.base_field.get_db_prep_save(i, connection) for i in value]
if value is not None:
raise TypeError(
f"Expected list of {self.embedded_model!r} instances, not {type(value)!r}."
)
return value

def formfield(self, **kwargs):
# Skip ArrayField.formfield() which has some differences, including
# unneeded "base_field", and "max_length" instead of "max_num".
return Field.formfield(
self,
**{
"form_class": forms.EmbeddedModelArrayField,
"model": self.embedded_model,
"max_num": self.max_size,
"prefix": self.name,
**kwargs,
},
)
2 changes: 2 additions & 0 deletions django_mongodb_backend/forms/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .fields import (
EmbeddedModelArrayField,
EmbeddedModelField,
ObjectIdField,
SimpleArrayField,
Expand All @@ -7,6 +8,7 @@
)

__all__ = [
"EmbeddedModelArrayField",
"EmbeddedModelField",
"SimpleArrayField",
"SplitArrayField",
Expand Down
2 changes: 2 additions & 0 deletions django_mongodb_backend/forms/fields/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from .array import SimpleArrayField, SplitArrayField, SplitArrayWidget
from .embedded_model import EmbeddedModelField
from .embedded_model_array import EmbeddedModelArrayField
from .objectid import ObjectIdField

__all__ = [
"EmbeddedModelArrayField",
"EmbeddedModelField",
"SimpleArrayField",
"SplitArrayField",
Expand Down
80 changes: 80 additions & 0 deletions django_mongodb_backend/forms/fields/embedded_model_array.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from django import forms
from django.core.exceptions import ValidationError
from django.forms import formset_factory, model_to_dict
from django.forms.models import modelform_factory
from django.utils.html import format_html, format_html_join


class EmbeddedModelArrayField(forms.Field):
def __init__(self, model, *, prefix, max_num=None, extra_forms=3, **kwargs):
self.model = model
self.prefix = prefix
self.formset = formset_factory(
form=modelform_factory(model, fields="__all__"),
can_delete=True,
max_num=max_num,
extra=extra_forms,
validate_max=True,
)
kwargs["widget"] = EmbeddedModelArrayWidget()
super().__init__(**kwargs)

def clean(self, value):
if not value:
return []
formset = self.formset(value, prefix=self.prefix_override or self.prefix)
if not formset.is_valid():
raise ValidationError(formset.errors + formset.non_form_errors())
cleaned_data = []
for data in formset.cleaned_data:
# The "delete" checkbox isn't part of model data and must be
# removed. The fallback to True skips empty forms.
if data.pop("DELETE", True):
continue
cleaned_data.append(self.model(**data))
return cleaned_data

def has_changed(self, initial, data):
formset = self.formset(data, initial=models_to_dicts(initial), prefix=self.prefix)
return formset.has_changed()

def get_bound_field(self, form, field_name):
# Nested embedded model form fields need a double prefix.
# HACK: Setting self.prefix_override makes it available in clean()
# which doesn't have access to the form.
self.prefix_override = f"{form.prefix}-{self.prefix}" if form.prefix else None
return EmbeddedModelArrayBoundField(form, self, field_name, self.prefix_override)


class EmbeddedModelArrayBoundField(forms.BoundField):
def __init__(self, form, field, name, prefix_override):
super().__init__(form, field, name)
self.formset = field.formset(
self.data if form.is_bound else None,
initial=models_to_dicts(self.initial),
prefix=prefix_override if prefix_override else self.html_name,
)

def __str__(self):
body = format_html_join(
"\n", "<tbody>{}</tbody>", ((form.as_table(),) for form in self.formset)
)
return format_html("<table>\n{}\n</table>\n{}", body, self.formset.management_form)


class EmbeddedModelArrayWidget(forms.Widget):
"""
Extract the data for EmbeddedModelArrayFormField's formset.
This widget is never rendered.
"""

def value_from_datadict(self, data, files, name):
return {field: value for field, value in data.items() if field.startswith(f"{name}-")}


def models_to_dicts(models):
"""
Convert initial data (which is a list of model instances or None) to a
list of dictionary data suitable for a formset.
"""
return [model_to_dict(model) for model in models or []]
9 changes: 9 additions & 0 deletions django_mongodb_backend/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ def get_db_converters(self, expression):
converters.append(self.convert_decimalfield_value)
elif internal_type == "EmbeddedModelField":
converters.append(self.convert_embeddedmodelfield_value)
elif internal_type == "EmbeddedModelArrayField":
converters.extend(
[
self._get_arrayfield_converter(converter)
for converter in self.get_db_converters(
Expression(output_field=expression.output_field.base_field)
)
]
)
elif internal_type == "JSONField":
converters.append(self.convert_jsonfield_value)
elif internal_type == "TimeField":
Expand Down
Empty file modified docs/make.bat
100644 → 100755
Empty file.
30 changes: 30 additions & 0 deletions docs/source/ref/forms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,36 @@ Some MongoDB-specific fields are available in ``django_mongodb_backend.forms``.
in this field's subform will have so that the names don't collide with
fields in the main form.

``EmbeddedModelArrayField``
---------------------------

.. class:: EmbeddedModelArrayField(model, *, prefix, max_num=None, extra_forms=3, **kwargs)

.. versionadded:: 5.2.0b1

A field which maps to a list of model instances. The field will render as a
:class:`ModelFormSet <django.forms.models.BaseModelFormSet>`.

.. attribute:: model

This is a required argument that specifies the model class.

.. attribute:: prefix

This is a required argument that specifies the prefix that all fields
in this field's formset will have so that the names don't collide with
fields in the main form.

.. attribute:: max_num

This is an optional argument which specifies the maximum number of
model instances that can be created.

.. attribute:: extra_forms

This argument specifies the number of blank forms that will be
rendered by the formset.

``ObjectIdField``
-----------------

Expand Down
37 changes: 34 additions & 3 deletions docs/source/ref/models/fields.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ Some MongoDB-specific fields are available in ``django_mongodb_backend.fields``.
:class:`~django.db.models.OneToOneField` and
:class:`~django.db.models.ManyToManyField`) and file fields (
:class:`~django.db.models.FileField` and
:class:`~django.db.models.ImageField`). :class:`EmbeddedModelField` is
also not (yet) supported.
:class:`~django.db.models.ImageField`). For
:class:`EmbeddedModelField`, use :class:`EmbeddedModelArrayField`.

It is possible to nest array fields - you can specify an instance of
``ArrayField`` as the ``base_field``. For example::
Expand Down Expand Up @@ -256,7 +256,8 @@ These indexes use 0-based indexing.
class Book(models.Model):
author = EmbeddedModelField(Author)

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

.. admonition:: Migrations support is limited

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

``EmbeddedModelArrayField``
---------------------------

.. class:: EmbeddedModelArrayField(embedded_model, max_size=None, **kwargs)

.. versionadded:: 5.2.0b1

Similar to :class:`EmbeddedModelField`, but stores a **list** of models of
type ``embedded_model`` rather than a single instance.

.. attribute:: embedded_model

This is a required argument that works just like
:attr:`EmbeddedModelField.embedded_model`.

.. attribute:: max_size

This is an optional argument.

If passed, the list will have a maximum size as specified, validated
by forms and model validation, but not enforced by the database.

See :ref:`the embedded model topic guide
<embedded-model-array-field-example>` for more details and examples.

.. admonition:: Migrations support is limited

As described above for :class:`EmbeddedModelField`,
:djadmin:`makemigrations` does not yet detect changes to embedded models.

``ObjectIdAutoField``
---------------------

Expand Down
6 changes: 6 additions & 0 deletions docs/source/releases/5.2.x.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ Django MongoDB Backend 5.2.x

*Unreleased*

New features
------------

- Added :class:`~.fields.EmbeddedModelArrayField` for storing a list of model
instances.

Bug fixes
---------

Expand Down
64 changes: 61 additions & 3 deletions docs/source/topics/embedded-models.rst
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
Embedded models
===============

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

.. _embedded-model-field-example:

``EmbeddedModelField``
----------------------

The basics
----------
~~~~~~~~~~

Let's consider this example::

Expand Down Expand Up @@ -47,10 +53,62 @@ Represented in BSON, Bob's structure looks like this:
}

Querying ``EmbeddedModelField``
-------------------------------
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

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

>>> Customer.objects.filter(address__city="New York")

.. _embedded-model-array-field-example:

``EmbeddedModelArrayField``
---------------------------

The basics
~~~~~~~~~~

Let's consider this example::

from django.db import models

from django_mongodb_backend.fields import EmbeddedModelArrayField
from django_mongodb_backend.models import EmbeddedModel


class Post(models.Model):
name = models.CharField(max_length=200)
tags = EmbeddedModelArrayField("Tag")

def __str__(self):
return self.name


class Tag(EmbeddedModel):
name = models.CharField(max_length=100)

def __str__(self):
return self.name


The API is similar to that of Django's relational fields::

>>> post = Post.objects.create(
... name="Hello world!",
... tags=[Tag(name="welcome"), Tag(name="test")],
... )
>>> post.tags
[<Tag: welcome>, <Tag: test>]
>>> post.tags[0].name
'welcome'

Represented in BSON, the post's structure looks like this:

.. code-block:: js

{
_id: ObjectId('683dee4c6b79670044c38e3f'),
name: 'Hello world!',
tags: [ { name: 'welcome' }, { name: 'test' } ]
}
Loading