Skip to content

Commit d348536

Browse files
committed
INTPYTHON-658 Add PolymorphicEmbeddedModelArrayField
1 parent 7bd39ce commit d348536

File tree

8 files changed

+585
-3
lines changed

8 files changed

+585
-3
lines changed

django_mongodb_backend/fields/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .json import register_json_field
77
from .objectid import ObjectIdField
88
from .polymorphic_embedded_model import PolymorphicEmbeddedModelField
9+
from .polymorphic_embedded_model_array import PolymorphicEmbeddedModelArrayField
910

1011
__all__ = [
1112
"register_fields",
@@ -15,6 +16,7 @@
1516
"ObjectIdAutoField",
1617
"ObjectIdField",
1718
"PolymorphicEmbeddedModelField",
19+
"PolymorphicEmbeddedModelArrayField",
1820
]
1921

2022

django_mongodb_backend/fields/embedded_model_array.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,8 @@ class EmbeddedModelArrayFieldLessThanOrEqual(
224224

225225

226226
class KeyTransform(Transform):
227+
field_class_name = "EmbeddedModelArrayField"
228+
227229
def __init__(self, key_name, array_field, *args, **kwargs):
228230
super().__init__(*args, **kwargs)
229231
self.array_field = array_field
@@ -269,7 +271,7 @@ def get_transform(self, name):
269271
suggestion = ""
270272
raise FieldDoesNotExist(
271273
f"Unsupported lookup '{name}' for "
272-
f"EmbeddedModelArrayField of '{output_field.__class__.__name__}'"
274+
f"{self.field_class_name} of '{output_field.__class__.__name__}'"
273275
f"{suggestion}"
274276
)
275277

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import contextlib
2+
3+
from django.core.exceptions import FieldDoesNotExist
4+
from django.db.models.expressions import Col
5+
from django.db.models.fields.related import lazy_related_operation
6+
from django.db.models.lookups import Lookup, Transform
7+
8+
from . import PolymorphicEmbeddedModelField
9+
from .array import ArrayField, ArrayLenTransform
10+
from .embedded_model_array import KeyTransform as ArrayFieldKeyTransform
11+
from .embedded_model_array import KeyTransformFactory as ArrayFieldKeyTransformFactory
12+
13+
14+
class PolymorphicEmbeddedModelArrayField(ArrayField):
15+
def __init__(self, embedded_models, **kwargs):
16+
if "size" in kwargs:
17+
raise ValueError("PolymorphicEmbeddedModelArrayField does not support size.")
18+
kwargs["editable"] = False
19+
super().__init__(PolymorphicEmbeddedModelField(embedded_models), **kwargs)
20+
self.embedded_models = embedded_models
21+
22+
def contribute_to_class(self, cls, name, private_only=False, **kwargs):
23+
super().contribute_to_class(cls, name, private_only=private_only, **kwargs)
24+
25+
if not cls._meta.abstract:
26+
# If the embedded_model argument is a string, resolve it to the
27+
# actual model class.
28+
def _resolve_lookup(_, *resolved_models):
29+
self.embedded_models = resolved_models
30+
31+
lazy_related_operation(_resolve_lookup, cls, *self.embedded_models)
32+
33+
def deconstruct(self):
34+
name, path, args, kwargs = super().deconstruct()
35+
if path == (
36+
"django_mongodb_backend.fields.polymorphic_embedded_model_array."
37+
"PolymorphicEmbeddedModelArrayField"
38+
):
39+
path = "django_mongodb_backend.fields.PolymorphicEmbeddedModelArrayField"
40+
kwargs["embedded_models"] = self.embedded_models
41+
del kwargs["base_field"]
42+
del kwargs["editable"]
43+
return name, path, args, kwargs
44+
45+
def get_db_prep_value(self, value, connection, prepared=False):
46+
if isinstance(value, list | tuple):
47+
# Must call get_db_prep_save() rather than get_db_prep_value()
48+
# to transform model instances to dicts.
49+
return [self.base_field.get_db_prep_save(i, connection) for i in value]
50+
if value is not None:
51+
raise TypeError(
52+
f"Expected list of {self.embedded_models!r} instances, not {type(value)!r}."
53+
)
54+
return value
55+
56+
def formfield(self, **kwargs):
57+
raise NotImplementedError("PolymorphicEmbeddedModelField does not support forms.")
58+
59+
_get_model_from_label = PolymorphicEmbeddedModelField._get_model_from_label
60+
61+
def get_transform(self, name):
62+
transform = super().get_transform(name)
63+
if transform:
64+
return transform
65+
return KeyTransformFactory(name, self)
66+
67+
def _get_lookup(self, lookup_name):
68+
lookup = super()._get_lookup(lookup_name)
69+
if lookup is None or lookup is ArrayLenTransform:
70+
return lookup
71+
72+
class EmbeddedModelArrayFieldLookups(Lookup):
73+
def as_mql(self, compiler, connection):
74+
raise ValueError(
75+
"Lookups aren't supported on PolymorphicEmbeddedModelArrayField. "
76+
"Try querying one of its embedded fields instead."
77+
)
78+
79+
return EmbeddedModelArrayFieldLookups
80+
81+
82+
class KeyTransform(ArrayFieldKeyTransform):
83+
field_class_name = "PolymorphicEmbeddedModelArrayField"
84+
85+
def __init__(self, key_name, array_field, *args, **kwargs):
86+
# Skip ArrayFieldKeyTransform.__init__()
87+
Transform.__init__(self, *args, **kwargs)
88+
self.array_field = array_field
89+
self.key_name = key_name
90+
for model in array_field.base_field.embedded_models:
91+
with contextlib.suppress(FieldDoesNotExist):
92+
field = model._meta.get_field(key_name)
93+
break
94+
else:
95+
raise FieldDoesNotExist(
96+
f"The models of field '{array_field.name}' have no field named '{key_name}'."
97+
)
98+
# Lookups iterate over the array of embedded models. A virtual column
99+
# of the queried field's type represents each element.
100+
column_target = field.clone()
101+
column_name = f"$item.{key_name}"
102+
column_target.db_column = column_name
103+
column_target.set_attributes_from_name(column_name)
104+
self._lhs = Col(None, column_target)
105+
self._sub_transform = None
106+
107+
108+
class KeyTransformFactory(ArrayFieldKeyTransformFactory):
109+
def __call__(self, *args, **kwargs):
110+
return KeyTransform(self.key_name, self.base_field, *args, **kwargs)

docs/source/ref/models/fields.rst

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,3 +343,39 @@ These indexes use 0-based indexing.
343343
.. admonition:: Forms are not supported
344344

345345
``PolymorphicEmbeddedModelField``\s don't appear in model forms.
346+
347+
``PolymorphicEmbeddedModelArrayField``
348+
--------------------------------------
349+
350+
.. class:: PolymorphicEmbeddedModelArrayField(embedded_models, **kwargs)
351+
352+
.. versionadded:: 5.2.0b2
353+
354+
Similar to :class:`PolymorphicEmbeddedModelField`, but stores a **list** of
355+
models of type ``embedded_models`` rather than a single instance.
356+
357+
.. attribute:: embedded_models
358+
359+
This is a required argument that works just like
360+
:attr:`PolymorphicEmbeddedModelField.embedded_models`.
361+
362+
.. attribute:: max_size
363+
364+
This is an optional argument.
365+
366+
If passed, the list will have a maximum size as specified, validated
367+
by forms and model validation, but not enforced by the database.
368+
369+
See :ref:`the embedded model topic guide
370+
<polymorphic-embedded-model-array-field-example>` for more details and
371+
examples.
372+
373+
.. admonition:: Migrations support is limited
374+
375+
:djadmin:`makemigrations` does not yet detect changes to embedded models,
376+
nor does it create indexes or constraints for embedded models referenced
377+
by ``PolymorphicEmbeddedModelArrayField``.
378+
379+
.. admonition:: Forms are not supported
380+
381+
``PolymorphicEmbeddedModelArrayField``\s don't appear in model forms.

docs/source/releases/5.2.x.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ New features
1414
- Added the ``options`` parameter to
1515
:func:`~django_mongodb_backend.utils.parse_uri`.
1616
- Added support for :ref:`database transactions <transactions>`.
17-
- Added :class:`~.fields.PolymorphicEmbeddedModelField` for storing a model
18-
instance that may be of more than one model class.
17+
- Added :class:`~.fields.PolymorphicEmbeddedModelField` and
18+
:class:`~.fields.PolymorphicEmbeddedModelArrayField` for storing a model
19+
instance or list of model instances that may be of more than one model class.
1920

2021
5.2.0 beta 1
2122
============

docs/source/topics/embedded-models.rst

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ Represented in BSON, the post's structure looks like this:
116116
tags: [ { name: 'welcome' }, { name: 'test' } ]
117117
}
118118
119+
.. _querying-embedded-model-array-field:
120+
119121
Querying ``EmbeddedModelArrayField``
120122
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
121123

@@ -264,6 +266,8 @@ bark::
264266

265267
>>> Person.objects.filter(pet__barks=True)
266268

269+
.. _polymorphic-embedded-model-field-clashing-field-names:
270+
267271
Clashing field names
268272
~~~~~~~~~~~~~~~~~~~~
269273

@@ -292,3 +296,87 @@ field it finds, ``Target1.number`` in this case.
292296
Similarly, querying into nested embedded model fields with the same name isn't
293297
well supported: the first model in ``embedded_models`` is the one that will be
294298
used for nested lookups.
299+
300+
.. _polymorphic-embedded-model-array-field-example:
301+
302+
``PolymorphicEmbeddedModelArrayField``
303+
--------------------------------------
304+
305+
The basics
306+
~~~~~~~~~~
307+
308+
Let's consider this example::
309+
310+
from django.db import models
311+
312+
from django_mongodb_backend.fields import PolymorphicEmbeddedModelArrayField
313+
from django_mongodb_backend.models import EmbeddedModel
314+
315+
316+
class Person(models.Model):
317+
name = models.CharField(max_length=255)
318+
pets = PolymorphicEmbeddedModelArrayField(["Cat", "Dog"])
319+
320+
def __str__(self):
321+
return self.name
322+
323+
324+
class Cat(EmbeddedModel):
325+
name = models.CharField(max_length=255)
326+
purrs = models.BooleanField(default=True)
327+
328+
def __str__(self):
329+
return self.name
330+
331+
332+
class Dog(EmbeddedModel):
333+
name = models.CharField(max_length=255)
334+
barks = models.BooleanField(default=True)
335+
336+
def __str__(self):
337+
return self.name
338+
339+
340+
The API is similar to that of Django's relational fields::
341+
342+
>>> bob = Person.objects.create(
343+
... name="Bob",
344+
... pets=[Dog(name="Woofer"), Cat(name="Phoebe")],
345+
... )
346+
>>> bob.pets
347+
[<Dog: Woofer>, <Cat: Phoebe>]
348+
>>> bob.pets[0].name
349+
'Woofer'
350+
351+
Represented in BSON, the Bob's structure looks like this:
352+
353+
.. code-block:: js
354+
355+
{
356+
_id: ObjectId('6875605cf6dc6f95cadf2d75'),
357+
name: 'Bob',
358+
pets: [
359+
{ name: 'Woofer', barks: true, _label: 'polymorphic_array.Dog' },
360+
{ name: 'Phoebe', purrs: true, _label: 'polymorphic_array.Cat' }
361+
]
362+
}
363+
364+
The ``_label`` field tracks each model's :attr:`~django.db.models.Options.label`
365+
so that the models can be initialized properly.
366+
367+
Querying ``PolymorphicEmbeddedModelArrayField``
368+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
369+
370+
You can query into an embedded model array using :ref:`the same syntax and operators
371+
<querying-embedded-model-array-field>` as :class:`~.fields.EmbeddedModelArrayField`.
372+
373+
Like :class:`~.fields.PolymorphicEmbeddedModelField`, if you filter on fields that aren't shared
374+
among the embedded models, you'll only get back objects that have embedded models with
375+
those fields.
376+
377+
Clashing field names
378+
~~~~~~~~~~~~~~~~~~~~
379+
380+
As with :class:`~.fields.PolymorphicEmbeddedModelField`, take care that your embedded
381+
models don't use :ref:`clashing field names
382+
<polymorphic-embedded-model-field-clashing-field-names>`.

tests/model_fields_/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
EmbeddedModelArrayField,
88
EmbeddedModelField,
99
ObjectIdField,
10+
PolymorphicEmbeddedModelArrayField,
1011
PolymorphicEmbeddedModelField,
1112
)
1213
from django_mongodb_backend.models import EmbeddedModel
@@ -240,6 +241,7 @@ class Dog(EmbeddedModel):
240241
created_at = models.DateTimeField(auto_now_add=True)
241242
updated_at = models.DateTimeField(auto_now=True)
242243
favorite_toy = PolymorphicEmbeddedModelField(["Bone"], blank=True, null=True)
244+
toys = PolymorphicEmbeddedModelArrayField(["Bone"], blank=True, null=True)
243245

244246
def __str__(self):
245247
return self.name
@@ -250,6 +252,7 @@ class Cat(EmbeddedModel):
250252
purs = models.BooleanField(default=True)
251253
weight = models.DecimalField(max_digits=4, decimal_places=2, blank=True, null=True)
252254
favorite_toy = PolymorphicEmbeddedModelField(["Mouse"], blank=True, null=True)
255+
toys = PolymorphicEmbeddedModelArrayField(["Mouse"], blank=True, null=True)
253256

254257
def __str__(self):
255258
return self.name
@@ -267,3 +270,12 @@ class Mouse(EmbeddedModel):
267270

268271
def __str__(self):
269272
return self.manufacturer
273+
274+
275+
# PolymorphicEmbeddedModelArrayField
276+
class Owner(models.Model):
277+
name = models.CharField(max_length=100)
278+
pets = PolymorphicEmbeddedModelArrayField(("Dog", "Cat"), blank=True, null=True)
279+
280+
def __str__(self):
281+
return self.name

0 commit comments

Comments
 (0)