Skip to content

Commit befbfe2

Browse files
committed
EmbeddedModelArrayField Subquerying
1 parent 4fafdeb commit befbfe2

File tree

4 files changed

+273
-2
lines changed

4 files changed

+273
-2
lines changed

django_mongodb_backend/fields/embedded_model_array.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,47 @@ def as_mql(self, compiler, connection):
141141
return {"$anyElementTrue": lhs_mql}
142142

143143

144+
class ArrayAggregationSubqueryMixin:
145+
def get_subquery_wrapping_pipeline(self, compiler, connection, field_name, expr):
146+
return [
147+
{
148+
"$facet": {
149+
"group": [
150+
{"$project": {"tmp_name": expr.as_mql(compiler, connection)}},
151+
{
152+
"$unwind": "$tmp_name",
153+
},
154+
{
155+
"$group": {
156+
"_id": None,
157+
"tmp_name": {"$addToSet": "$tmp_name"},
158+
}
159+
},
160+
]
161+
}
162+
},
163+
{
164+
"$project": {
165+
field_name: {
166+
"$ifNull": [
167+
{
168+
"$getField": {
169+
"input": {"$arrayElemAt": ["$group", 0]},
170+
"field": "tmp_name",
171+
}
172+
},
173+
[],
174+
]
175+
}
176+
}
177+
},
178+
]
179+
180+
144181
@_EmbeddedModelArrayOutputField.register_lookup
145-
class EmbeddedModelArrayFieldIn(EmbeddedModelArrayFieldBuiltinLookup, lookups.In):
182+
class EmbeddedModelArrayFieldIn(
183+
EmbeddedModelArrayFieldBuiltinLookup, lookups.In, ArrayAggregationSubqueryMixin
184+
):
146185
pass
147186

148187

docs/source/ref/models/fields.rst

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,207 @@ These indexes use 0-based indexing.
299299
As described above for :class:`EmbeddedModelField`,
300300
:djadmin:`makemigrations` does not yet detect changes to embedded models.
301301

302+
Querying ``EmbeddedModelArrayField``
303+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
304+
305+
There are a number of custom lookups and a transform for
306+
:class:`EmbeddedModelArrayField`, similar to those available
307+
for :class:`ArrayField`.
308+
We will use the following example model::
309+
310+
from django.db import models
311+
from django_mongodb_backend.fields import EmbeddedModelArrayField
312+
313+
314+
class Tag(EmbeddedModel):
315+
label = models.CharField(max_length=100)
316+
317+
class Post(models.Model):
318+
name = models.CharField(max_length=200)
319+
tags = EmbeddedModelArrayField(Tag)
320+
321+
def __str__(self):
322+
return self.name
323+
324+
Embedded field lookup
325+
^^^^^^^^^^^^^^^^^^^^^
326+
327+
Embedded field lookup for :class:`EmbeddedModelArrayField` allow querying
328+
fields of the embedded model. This is done by composing the two involved paths:
329+
the path to the ``EmbeddedModelArrayField`` and the path within the nested
330+
embedded model.
331+
This composition enables generating the appropriate query for the lookups.
332+
333+
.. fieldlookup:: embeddedmodelarrayfield.in
334+
335+
``in``
336+
^^^^^^
337+
338+
Returns objects where any of the embedded documents in the field match any of
339+
the values passed. For example:
340+
341+
.. code-block:: pycon
342+
343+
>>> Post.objects.create(
344+
... name="First post", tags=[Tag(label="thoughts"), Tag(label="django")]
345+
... )
346+
>>> Post.objects.create(name="Second post", tags=[Tag(label="thoughts")])
347+
>>> Post.objects.create(
348+
... name="Third post", tags=[Tag(label="tutorial"), Tag(label="django")]
349+
... )
350+
351+
>>> Post.objects.filter(tags__label__in=["thoughts"])
352+
<QuerySet [<Post: First post>, <Post: Second post>]>
353+
354+
>>> Post.objects.filter(tags__label__in=["tutorial", "thoughts"])
355+
<QuerySet [<Post: First post>, <Post: Second post>, <Post: Third post>]>
356+
357+
.. fieldlookup:: embeddedmodelarrayfield.len
358+
359+
``len``
360+
^^^^^^^
361+
362+
Returns the length of the embedded model array. The lookups available afterward
363+
are those available for :class:`~django.db.models.IntegerField`. For example:
364+
365+
.. code-block:: pycon
366+
367+
>>> Post.objects.create(
368+
... name="First post", tags=[Tag(label="thoughts"), Tag(label="django")]
369+
... )
370+
>>> Post.objects.create(name="Second post", tags=[Tag(label="thoughts")])
371+
372+
>>> Post.objects.filter(tags__len=1)
373+
<QuerySet [<Post: Second post>]>
374+
375+
.. fieldlookup:: embeddedmodelarrayfield.exact
376+
377+
``exact``
378+
^^^^^^^^^
379+
380+
Returns objects where **any** embedded model in the array exactly matches the
381+
given value. This acts like an existence filter on matching embedded documents.
382+
383+
.. code-block:: pycon
384+
385+
>>> Post.objects.create(
386+
... name="First post", tags=[Tag(label="thoughts"), Tag(label="django")]
387+
... )
388+
>>> Post.objects.create(name="Second post", tags=[Tag(label="tutorial")])
389+
390+
>>> Post.objects.filter(tags__label__exact="tutorial")
391+
<QuerySet [<Post: Second post>]>
392+
393+
.. fieldlookup:: embeddedmodelarrayfield.iexact
394+
395+
``iexact``
396+
^^^^^^^^^^
397+
398+
Returns objects where **any** embedded model in the array has a field that
399+
matches the given value **case-insensitively**. This works like ``exact`` but
400+
ignores letter casing.
401+
402+
.. code-block:: pycon
403+
404+
405+
>>> Post.objects.create(
406+
... name="First post", tags=[Tag(label="Thoughts"), Tag(label="Django")]
407+
... )
408+
>>> Post.objects.create(name="Second post", tags=[Tag(label="tutorial")])
409+
410+
>>> Post.objects.filter(tags__label__iexact="django")
411+
<QuerySet [<Post: First post>]>
412+
413+
>>> Post.objects.filter(tags__label__iexact="TUTORIAL")
414+
<QuerySet [<Post: Second post>]>
415+
416+
.. fieldlookup:: embeddedmodelarrayfield.gt
417+
.. fieldlookup:: embeddedmodelarrayfield.gte
418+
.. fieldlookup:: embeddedmodelarrayfield.lt
419+
.. fieldlookup:: embeddedmodelarrayfield.lte
420+
421+
``Greater Than, Greater Than or Equal, Less Than, Less Than or Equal``
422+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
423+
424+
These lookups return objects where **any** embedded document contains a value
425+
that satisfies the corresponding comparison. These are typically used on
426+
numeric or comparable fields within the embedded model.
427+
428+
Examples:
429+
430+
.. code-block:: pycon
431+
432+
Post.objects.create(
433+
name="First post", tags=[Tag(label="django", rating=5), Tag(label="rest", rating=3)]
434+
)
435+
Post.objects.create(
436+
name="Second post", tags=[Tag(label="python", rating=2)]
437+
)
438+
439+
Post.objects.filter(tags__rating__gt=3)
440+
<QuerySet [<Post: First post>]>
441+
442+
Post.objects.filter(tags__rating__gte=3)
443+
<QuerySet [<Post: First post>, <Post: Second post>]>
444+
445+
Post.objects.filter(tags__rating__lt=3)
446+
<QuerySet []>
447+
448+
Post.objects.filter(tags__rating__lte=3)
449+
<QuerySet [<Post: First post>, <Post: Second post>]>
450+
451+
.. fieldlookup:: embeddedmodelarrayfield.all
452+
453+
``all``
454+
^^^^^^^
455+
456+
Returns objects where **all** values provided on the right-hand side are
457+
present. It requires that *every* value be matched by some document in
458+
the array.
459+
460+
Example:
461+
462+
.. code-block:: pycon
463+
464+
Post.objects.create(
465+
name="First post", tags=[Tag(label="django"), Tag(label="rest")]
466+
)
467+
Post.objects.create(
468+
name="Second post", tags=[Tag(label="django")]
469+
)
470+
471+
Post.objects.filter(tags__label__all=["django", "rest"])
472+
<QuerySet [<Post: First post>]>
473+
474+
Post.objects.filter(tags__label__all=["django"])
475+
<QuerySet [<Post: First post>, <Post: Second post>]>
476+
477+
.. fieldlookup:: embeddedmodelarrayfield.contained_by
478+
479+
``contained_by``
480+
^^^^^^^^^^^^^^^^
481+
482+
Returns objects where the embedded model array is **contained by** the list of
483+
values on the right-hand side. In other words, every value in the embedded
484+
array must be present in the given list.
485+
486+
Example:
487+
488+
.. code-block:: pycon
489+
490+
Post.objects.create(
491+
name="First post", tags=[Tag(label="django"), Tag(label="rest")]
492+
)
493+
Post.objects.create(
494+
name="Second post", tags=[Tag(label="django")]
495+
)
496+
497+
Post.objects.filter(tags__label__contained_by=["django", "rest", "api"])
498+
<QuerySet [<Post: First post>, <Post: Second post>]>
499+
500+
Post.objects.filter(tags__label__contained_by=["django"])
501+
<QuerySet [<Post: Second post>]>
502+
302503
``ObjectIdAutoField``
303504
---------------------
304505

tests/model_fields_/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,11 @@ def __str__(self):
167167
return self.title
168168

169169

170+
class Audit(models.Model):
171+
related_section_number = models.IntegerField()
172+
reviewed = models.BooleanField()
173+
174+
170175
# An exhibit in the museum, composed of multiple sections.
171176
class Exhibit(models.Model):
172177
exhibit_name = models.CharField(max_length=255)

tests/model_fields_/test_embedded_model_array.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from django_mongodb_backend.fields import EmbeddedModelArrayField
99
from django_mongodb_backend.models import EmbeddedModel
1010

11-
from .models import Artifact, Exhibit, Movie, Restoration, Review, Section, Tour
11+
from .models import Artifact, Audit, Exhibit, Movie, Restoration, Review, Section, Tour
1212

1313

1414
class MethodTests(SimpleTestCase):
@@ -116,6 +116,7 @@ def setUpTestData(cls):
116116
],
117117
)
118118
],
119+
main_section=Section(section_number=2),
119120
)
120121
cls.lost_empires = Exhibit.objects.create(
121122
exhibit_name="Lost Empires",
@@ -146,6 +147,9 @@ def setUpTestData(cls):
146147
cls.egypt_tour = Tour.objects.create(guide="Amira", exhibit=cls.egypt)
147148
cls.wonders_tour = Tour.objects.create(guide="Carlos", exhibit=cls.wonders)
148149
cls.lost_tour = Tour.objects.create(guide="Yelena", exhibit=cls.lost_empires)
150+
cls.audit_1 = Audit.objects.create(related_section_number=1, reviewed=True)
151+
cls.audit_2 = Audit.objects.create(related_section_number=2, reviewed=True)
152+
cls.audit_3 = Audit.objects.create(related_section_number=5, reviewed=False)
149153

150154
def test_exact(self):
151155
self.assertCountEqual(
@@ -284,6 +288,28 @@ def test_foreign_field_with_slice(self):
284288
qs = Tour.objects.filter(exhibit__sections__0_2__section_number__in=[1, 2])
285289
self.assertCountEqual(qs, [self.wonders_tour, self.egypt_tour])
286290

291+
def test_subquery_section_number_lt(self):
292+
subq = Audit.objects.filter(
293+
related_section_number__in=models.OuterRef("sections__section_number")
294+
).values("related_section_number")[:1]
295+
self.assertCountEqual(
296+
Exhibit.objects.filter(sections__section_number=subq),
297+
[self.egypt, self.wonders, self.new_descoveries],
298+
)
299+
300+
def test_check_in_subquery(self):
301+
subquery = Audit.objects.filter(reviewed=True).values_list(
302+
"related_section_number", flat=True
303+
)
304+
result = Exhibit.objects.filter(sections__section_number__in=subquery)
305+
self.assertCountEqual(result, [self.wonders, self.egypt, self.new_descoveries])
306+
307+
def test_array_as_rhs(self):
308+
result = Exhibit.objects.filter(
309+
main_section__section_number__in=models.F("sections__section_number")
310+
)
311+
self.assertCountEqual(result, [self.new_descoveries])
312+
287313

288314
@isolate_apps("model_fields_")
289315
class CheckTests(SimpleTestCase):

0 commit comments

Comments
 (0)