diff --git a/django_mongodb_backend/fields/array.py b/django_mongodb_backend/fields/array.py index 4f9515146..8a9f7e0a0 100644 --- a/django_mongodb_backend/fields/array.py +++ b/django_mongodb_backend/fields/array.py @@ -338,7 +338,7 @@ class ArrayLenTransform(Transform): def as_mql(self, compiler, connection): lhs_mql = process_lhs(self, compiler, connection) - return {"$cond": {"if": {"$eq": [lhs_mql, None]}, "then": None, "else": {"$size": lhs_mql}}} + return {"$cond": {"if": {"$isArray": lhs_mql}, "then": {"$size": lhs_mql}, "else": None}} @ArrayField.register_lookup diff --git a/django_mongodb_backend/fields/embedded_model.py b/django_mongodb_backend/fields/embedded_model.py index 57bbd3f50..590fd5f8d 100644 --- a/django_mongodb_backend/fields/embedded_model.py +++ b/django_mongodb_backend/fields/embedded_model.py @@ -186,8 +186,9 @@ def as_mql(self, compiler, connection): key_transforms.insert(0, previous.key_name) previous = previous.lhs mql = previous.as_mql(compiler, connection) - transforms = ".".join(key_transforms) - return f"{mql}.{transforms}" + for key in key_transforms: + mql = {"$getField": {"input": mql, "field": key}} + return mql @property def output_field(self): diff --git a/django_mongodb_backend/fields/embedded_model_array.py b/django_mongodb_backend/fields/embedded_model_array.py index 1eff06a6c..7eb9579a0 100644 --- a/django_mongodb_backend/fields/embedded_model_array.py +++ b/django_mongodb_backend/fields/embedded_model_array.py @@ -1,8 +1,14 @@ -from django.db.models import Field +import difflib + +from django.core.exceptions import FieldDoesNotExist +from django.db.models import Field, lookups +from django.db.models.expressions import Col +from django.db.models.lookups import Lookup, Transform from .. import forms +from ..query_utils import process_lhs, process_rhs from . import EmbeddedModelField -from .array import ArrayField +from .array import ArrayField, ArrayLenTransform class EmbeddedModelArrayField(ArrayField): @@ -44,3 +50,233 @@ def formfield(self, **kwargs): **kwargs, }, ) + + def get_transform(self, name): + transform = super().get_transform(name) + if transform: + return transform + return KeyTransformFactory(name, self) + + def _get_lookup(self, lookup_name): + lookup = super()._get_lookup(lookup_name) + if lookup is None or lookup is ArrayLenTransform: + return lookup + + class EmbeddedModelArrayFieldLookups(Lookup): + def as_mql(self, compiler, connection): + raise ValueError( + "Cannot apply this lookup directly to EmbeddedModelArrayField. " + "Try querying one of its embedded fields instead." + ) + + return EmbeddedModelArrayFieldLookups + + +class _EmbeddedModelArrayOutputField(ArrayField): + """ + Represents the output of an EmbeddedModelArrayField when traversed in a query path. + + This field is not meant to be used directly in model definitions. It exists solely to + support query output resolution; when an EmbeddedModelArrayField is accessed in a query, + the result should behave like an array of the embedded model's target type. + + While it mimics ArrayField's lookups behavior, the way those lookups are resolved + follows the semantics of EmbeddedModelArrayField rather than native array behavior. + """ + + ALLOWED_LOOKUPS = { + "in", + "exact", + "iexact", + "gt", + "gte", + "lt", + "lte", + } + + def get_lookup(self, name): + return super().get_lookup(name) if name in self.ALLOWED_LOOKUPS else None + + +class EmbeddedModelArrayFieldBuiltinLookup(Lookup): + def process_rhs(self, compiler, connection): + value = self.rhs + if not self.get_db_prep_lookup_value_is_iterable: + value = [value] + # Value must be serialized based on the query target. + # If querying a subfield inside the array (i.e., a nested KeyTransform), use the output + # field of the subfield. Otherwise, use the base field of the array itself. + get_db_prep_value = self.lhs._lhs.output_field.get_db_prep_value + return None, [ + v if hasattr(v, "as_mql") else get_db_prep_value(v, connection, prepared=True) + for v in value + ] + + def as_mql(self, compiler, connection): + # Querying a subfield within the array elements (via nested KeyTransform). + # Replicates MongoDB's implicit ANY-match by mapping over the array and applying + # `$in` on the subfield. + lhs_mql = process_lhs(self, compiler, connection) + inner_lhs_mql = lhs_mql["$ifNull"][0]["$map"]["in"] + values = process_rhs(self, compiler, connection) + lhs_mql["$ifNull"][0]["$map"]["in"] = connection.mongo_operators[self.lookup_name]( + inner_lhs_mql, values + ) + return {"$anyElementTrue": lhs_mql} + + +class ArrayAggregationSubqueryMixin: + def get_subquery_wrapping_pipeline(self, compiler, connection, field_name, expr): + return [ + { + "$facet": { + "group": [ + {"$project": {"tmp_name": expr.as_mql(compiler, connection)}}, + { + "$unwind": "$tmp_name", + }, + { + "$group": { + "_id": None, + "tmp_name": {"$addToSet": "$tmp_name"}, + } + }, + ] + } + }, + { + "$project": { + field_name: { + "$ifNull": [ + { + "$getField": { + "input": {"$arrayElemAt": ["$group", 0]}, + "field": "tmp_name", + } + }, + [], + ] + } + } + }, + ] + + +@_EmbeddedModelArrayOutputField.register_lookup +class EmbeddedModelArrayFieldIn( + EmbeddedModelArrayFieldBuiltinLookup, lookups.In, ArrayAggregationSubqueryMixin +): + pass + + +@_EmbeddedModelArrayOutputField.register_lookup +class EmbeddedModelArrayFieldExact(EmbeddedModelArrayFieldBuiltinLookup, lookups.Exact): + pass + + +@_EmbeddedModelArrayOutputField.register_lookup +class EmbeddedModelArrayFieldIExact(EmbeddedModelArrayFieldBuiltinLookup, lookups.IExact): + get_db_prep_lookup_value_is_iterable = False + + +@_EmbeddedModelArrayOutputField.register_lookup +class EmbeddedModelArrayFieldGreaterThan(EmbeddedModelArrayFieldBuiltinLookup, lookups.GreaterThan): + pass + + +@_EmbeddedModelArrayOutputField.register_lookup +class EmbeddedModelArrayFieldGreaterThanOrEqual( + EmbeddedModelArrayFieldBuiltinLookup, lookups.GreaterThanOrEqual +): + pass + + +@_EmbeddedModelArrayOutputField.register_lookup +class EmbeddedModelArrayFieldLessThan(EmbeddedModelArrayFieldBuiltinLookup, lookups.LessThan): + pass + + +@_EmbeddedModelArrayOutputField.register_lookup +class EmbeddedModelArrayFieldLessThanOrEqual( + EmbeddedModelArrayFieldBuiltinLookup, lookups.LessThanOrEqual +): + pass + + +class KeyTransform(Transform): + def __init__(self, key_name, array_field, *args, **kwargs): + super().__init__(*args, **kwargs) + self.array_field = array_field + self.key_name = key_name + # The iteration items begins from the base_field, a virtual column with + # base field output type is created. + column_target = array_field.embedded_model._meta.get_field(key_name).clone() + column_name = f"$item.{key_name}" + column_target.db_column = column_name + column_target.set_attributes_from_name(column_name) + self._lhs = Col(None, column_target) + self._sub_transform = None + + def __call__(self, this, *args, **kwargs): + self._lhs = self._sub_transform(self._lhs, *args, **kwargs) + return self + + def get_lookup(self, name): + return self.output_field.get_lookup(name) + + def get_transform(self, name): + """ + Validate that `name` is either a field of an embedded model or a + lookup on an embedded model's field. + """ + # Once the sub lhs is a transform, all the filter are applied over it. + # Otherwise get transform from EMF. + if transform := self._lhs.get_transform(name): + if isinstance(transform, KeyTransformFactory): + raise ValueError("Cannot perform multiple levels of array traversal in a query.") + self._sub_transform = transform + return self + output_field = self._lhs.output_field + allowed_lookups = self.output_field.ALLOWED_LOOKUPS.intersection( + set(output_field.get_lookups()) + ) + suggested_lookups = difflib.get_close_matches(name, allowed_lookups) + if suggested_lookups: + suggested_lookups = " or ".join(suggested_lookups) + suggestion = f", perhaps you meant {suggested_lookups}?" + else: + suggestion = "" + raise FieldDoesNotExist( + f"Unsupported lookup '{name}' for " + f"EmbeddedModelArrayField of '{output_field.__class__.__name__}'" + f"{suggestion}" + ) + + def as_mql(self, compiler, connection): + inner_lhs_mql = self._lhs.as_mql(compiler, connection) + lhs_mql = process_lhs(self, compiler, connection) + return { + "$ifNull": [ + { + "$map": { + "input": lhs_mql, + "as": "item", + "in": inner_lhs_mql, + } + }, + [], + ] + } + + @property + def output_field(self): + return _EmbeddedModelArrayOutputField(self._lhs.output_field) + + +class KeyTransformFactory: + def __init__(self, key_name, base_field): + self.key_name = key_name + self.base_field = base_field + + def __call__(self, *args, **kwargs): + return KeyTransform(self.key_name, self.base_field, *args, **kwargs) diff --git a/docs/source/ref/models/fields.rst b/docs/source/ref/models/fields.rst index a4de529a2..f0f67cad0 100644 --- a/docs/source/ref/models/fields.rst +++ b/docs/source/ref/models/fields.rst @@ -299,6 +299,207 @@ These indexes use 0-based indexing. As described above for :class:`EmbeddedModelField`, :djadmin:`makemigrations` does not yet detect changes to embedded models. +Querying ``EmbeddedModelArrayField`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There are a number of custom lookups and a transform for +:class:`EmbeddedModelArrayField`, similar to those available +for :class:`ArrayField`. +We will use the following example model:: + + from django.db import models + from django_mongodb_backend.fields import EmbeddedModelArrayField + + + class Tag(EmbeddedModel): + label = models.CharField(max_length=100) + + class Post(models.Model): + name = models.CharField(max_length=200) + tags = EmbeddedModelArrayField(Tag) + + def __str__(self): + return self.name + +Embedded field lookup +^^^^^^^^^^^^^^^^^^^^^ + +Embedded field lookup for :class:`EmbeddedModelArrayField` allow querying +fields of the embedded model. This is done by composing the two involved paths: +the path to the ``EmbeddedModelArrayField`` and the path within the nested +embedded model. +This composition enables generating the appropriate query for the lookups. + +.. fieldlookup:: embeddedmodelarrayfield.in + +``in`` +^^^^^^ + +Returns objects where any of the embedded documents in the field match any of +the values passed. For example: + +.. code-block:: pycon + + >>> Post.objects.create( + ... name="First post", tags=[Tag(label="thoughts"), Tag(label="django")] + ... ) + >>> Post.objects.create(name="Second post", tags=[Tag(label="thoughts")]) + >>> Post.objects.create( + ... name="Third post", tags=[Tag(label="tutorial"), Tag(label="django")] + ... ) + + >>> Post.objects.filter(tags__label__in=["thoughts"]) + , ]> + + >>> Post.objects.filter(tags__label__in=["tutorial", "thoughts"]) + , , ]> + +.. fieldlookup:: embeddedmodelarrayfield.len + +``len`` +^^^^^^^ + +Returns the length of the embedded model array. The lookups available afterward +are those available for :class:`~django.db.models.IntegerField`. For example: + +.. code-block:: pycon + + >>> Post.objects.create( + ... name="First post", tags=[Tag(label="thoughts"), Tag(label="django")] + ... ) + >>> Post.objects.create(name="Second post", tags=[Tag(label="thoughts")]) + + >>> Post.objects.filter(tags__len=1) + ]> + +.. fieldlookup:: embeddedmodelarrayfield.exact + +``exact`` +^^^^^^^^^ + +Returns objects where **any** embedded model in the array exactly matches the +given value. This acts like an existence filter on matching embedded documents. + +.. code-block:: pycon + + >>> Post.objects.create( + ... name="First post", tags=[Tag(label="thoughts"), Tag(label="django")] + ... ) + >>> Post.objects.create(name="Second post", tags=[Tag(label="tutorial")]) + + >>> Post.objects.filter(tags__label__exact="tutorial") + ]> + +.. fieldlookup:: embeddedmodelarrayfield.iexact + +``iexact`` +^^^^^^^^^^ + +Returns objects where **any** embedded model in the array has a field that +matches the given value **case-insensitively**. This works like ``exact`` but +ignores letter casing. + +.. code-block:: pycon + + + >>> Post.objects.create( + ... name="First post", tags=[Tag(label="Thoughts"), Tag(label="Django")] + ... ) + >>> Post.objects.create(name="Second post", tags=[Tag(label="tutorial")]) + + >>> Post.objects.filter(tags__label__iexact="django") + ]> + + >>> Post.objects.filter(tags__label__iexact="TUTORIAL") + ]> + +.. fieldlookup:: embeddedmodelarrayfield.gt +.. fieldlookup:: embeddedmodelarrayfield.gte +.. fieldlookup:: embeddedmodelarrayfield.lt +.. fieldlookup:: embeddedmodelarrayfield.lte + +``Greater Than, Greater Than or Equal, Less Than, Less Than or Equal`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +These lookups return objects where **any** embedded document contains a value +that satisfies the corresponding comparison. These are typically used on +numeric or comparable fields within the embedded model. + +Examples: + +.. code-block:: pycon + + Post.objects.create( + name="First post", tags=[Tag(label="django", rating=5), Tag(label="rest", rating=3)] + ) + Post.objects.create( + name="Second post", tags=[Tag(label="python", rating=2)] + ) + + Post.objects.filter(tags__rating__gt=3) + ]> + + Post.objects.filter(tags__rating__gte=3) + , ]> + + Post.objects.filter(tags__rating__lt=3) + + + Post.objects.filter(tags__rating__lte=3) + , ]> + +.. fieldlookup:: embeddedmodelarrayfield.all + +``all`` +^^^^^^^ + +Returns objects where **all** values provided on the right-hand side are +present. It requires that *every* value be matched by some document in +the array. + +Example: + +.. code-block:: pycon + + Post.objects.create( + name="First post", tags=[Tag(label="django"), Tag(label="rest")] + ) + Post.objects.create( + name="Second post", tags=[Tag(label="django")] + ) + + Post.objects.filter(tags__label__all=["django", "rest"]) + ]> + + Post.objects.filter(tags__label__all=["django"]) + , ]> + +.. fieldlookup:: embeddedmodelarrayfield.contained_by + +``contained_by`` +^^^^^^^^^^^^^^^^ + +Returns objects where the embedded model array is **contained by** the list of +values on the right-hand side. In other words, every value in the embedded +array must be present in the given list. + +Example: + +.. code-block:: pycon + + Post.objects.create( + name="First post", tags=[Tag(label="django"), Tag(label="rest")] + ) + Post.objects.create( + name="Second post", tags=[Tag(label="django")] + ) + + Post.objects.filter(tags__label__contained_by=["django", "rest", "api"]) + , ]> + + Post.objects.filter(tags__label__contained_by=["django"]) + ]> + ``ObjectIdAutoField`` --------------------- diff --git a/tests/model_fields_/models.py b/tests/model_fields_/models.py index 3cf074a23..5500648be 100644 --- a/tests/model_fields_/models.py +++ b/tests/model_fields_/models.py @@ -165,3 +165,45 @@ class Movie(models.Model): def __str__(self): return self.title + + +class RestorationRecord(EmbeddedModel): + date = models.DateField() + restored_by = models.CharField(max_length=255) + + +# Details about a specific artifact. +class ArtifactDetail(EmbeddedModel): + name = models.CharField(max_length=255) + metadata = models.JSONField() + restorations = EmbeddedModelArrayField(RestorationRecord, null=True) + last_restoration = EmbeddedModelField(RestorationRecord, null=True) + + +class ExhibitAudit(models.Model): + related_section_number = models.IntegerField() + reviewed = models.BooleanField() + + +# A section within an exhibit, containing multiple artifacts. +class ExhibitSection(EmbeddedModel): + section_number = models.IntegerField() + artifacts = EmbeddedModelArrayField(ArtifactDetail, null=True) + + +# An exhibit in the museum, composed of multiple sections. +class MuseumExhibit(models.Model): + exhibit_name = models.CharField(max_length=255) + sections = EmbeddedModelArrayField(ExhibitSection, null=True) + main_section = EmbeddedModelField(ExhibitSection, null=True) + + def __str__(self): + return self.exhibit_name + + +class Tour(models.Model): + guide = models.CharField(max_length=100) + exhibit = models.ForeignKey(MuseumExhibit, on_delete=models.CASCADE) + + def __str__(self): + return f"Tour by {self.guide}" diff --git a/tests/model_fields_/test_embedded_model_array.py b/tests/model_fields_/test_embedded_model_array.py index 892d2e182..990488413 100644 --- a/tests/model_fields_/test_embedded_model_array.py +++ b/tests/model_fields_/test_embedded_model_array.py @@ -1,11 +1,23 @@ -from django.db import models +from datetime import date + +from django.core.exceptions import FieldDoesNotExist +from django.db import connection, models from django.test import SimpleTestCase, TestCase -from django.test.utils import isolate_apps +from django.test.utils import CaptureQueriesContext, isolate_apps from django_mongodb_backend.fields import EmbeddedModelArrayField from django_mongodb_backend.models import EmbeddedModel -from .models import Movie, Review +from .models import ( + ArtifactDetail, + ExhibitAudit, + ExhibitSection, + Movie, + MuseumExhibit, + RestorationRecord, + Review, + Tour, +) class MethodTests(SimpleTestCase): @@ -55,6 +67,264 @@ def test_save_load_null(self): self.assertIsNone(movie.reviews) +class QueryingTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.egypt = MuseumExhibit.objects.create( + exhibit_name="Ancient Egypt", + sections=[ + ExhibitSection( + section_number=1, + artifacts=[ + ArtifactDetail( + name="Ptolemaic Crown", + metadata={ + "origin": "Egypt", + }, + ) + ], + ) + ], + ) + cls.wonders = MuseumExhibit.objects.create( + exhibit_name="Wonders of the Ancient World", + sections=[ + ExhibitSection( + section_number=1, + artifacts=[ + ArtifactDetail( + name="Statue of Zeus", + metadata={"location": "Olympia", "height_m": 12}, + ), + ArtifactDetail( + name="Hanging Gardens", + ), + ], + ), + ExhibitSection( + section_number=2, + artifacts=[ + ArtifactDetail( + name="Lighthouse of Alexandria", + metadata={"height_m": 100, "built": "3rd century BC"}, + ) + ], + ), + ], + ) + cls.new_descoveries = MuseumExhibit.objects.create( + exhibit_name="New Discoveries", + sections=[ + ExhibitSection( + section_number=2, + artifacts=[ + ArtifactDetail( + name="Lighthouse of Alexandria", + metadata={"height_m": 100, "built": "3rd century BC"}, + ) + ], + ) + ], + main_section=ExhibitSection(section_number=2), + ) + cls.lost_empires = MuseumExhibit.objects.create( + exhibit_name="Lost Empires", + main_section=ExhibitSection( + section_number=3, + artifacts=[ + ArtifactDetail( + name="Bronze Statue", + metadata={"origin": "Pergamon"}, + restorations=[ + RestorationRecord( + date=date(1998, 4, 15), + restored_by="Zacarias", + ), + RestorationRecord( + date=date(2010, 7, 22), + restored_by="Vicente", + ), + ], + last_restoration=RestorationRecord( + date=date(2010, 7, 22), + restored_by="Monzon", + ), + ) + ], + ), + ) + cls.egypt_tour = Tour.objects.create(guide="Amira", exhibit=cls.egypt) + cls.wonders_tour = Tour.objects.create(guide="Carlos", exhibit=cls.wonders) + cls.lost_tour = Tour.objects.create(guide="Yelena", exhibit=cls.lost_empires) + cls.audit_1 = ExhibitAudit.objects.create(related_section_number=1, reviewed=True) + cls.audit_2 = ExhibitAudit.objects.create(related_section_number=2, reviewed=True) + cls.audit_3 = ExhibitAudit.objects.create(related_section_number=5, reviewed=False) + + def test_filter_with_field(self): + self.assertCountEqual( + MuseumExhibit.objects.filter(sections__section_number=1), [self.egypt, self.wonders] + ) + + def test_filter_with_embeddedfield_path(self): + self.assertCountEqual( + MuseumExhibit.objects.filter(sections__0__section_number=1), + [self.egypt, self.wonders], + ) + + def test_filter_with_embeddedfield_array_path(self): + self.assertCountEqual( + MuseumExhibit.objects.filter( + main_section__artifacts__restorations__0__restored_by="Zacarias" + ), + [self.lost_empires], + ) + + def test_filter_unsupported_lookups(self): + # handle the unsupported lookups as key in a keytransform + for lookup in ["contains", "range"]: + kwargs = {f"main_section__artifacts__metadata__origin__{lookup}": ["Pergamon", "Egypt"]} + with CaptureQueriesContext(connection) as captured_queries: + self.assertCountEqual(MuseumExhibit.objects.filter(**kwargs), []) + self.assertIn(f"'field': '{lookup}'", captured_queries[0]["sql"]) + + def test_len_filter(self): + self.assertCountEqual(MuseumExhibit.objects.filter(sections__len=10), []) + self.assertCountEqual( + MuseumExhibit.objects.filter(sections__len=1), + [self.egypt, self.new_descoveries], + ) + # Nested EMF + self.assertCountEqual( + MuseumExhibit.objects.filter(main_section__artifacts__len=1), [self.lost_empires] + ) + self.assertCountEqual(MuseumExhibit.objects.filter(main_section__artifacts__len=2), []) + # Nested Indexed Array + self.assertCountEqual( + MuseumExhibit.objects.filter(sections__0__artifacts__len=2), [self.wonders] + ) + self.assertCountEqual(MuseumExhibit.objects.filter(sections__0__artifacts__len=0), []) + self.assertCountEqual( + MuseumExhibit.objects.filter(sections__1__artifacts__len=1), [self.wonders] + ) + + def test_in_filter(self): + self.assertCountEqual(MuseumExhibit.objects.filter(sections__section_number__in=[10]), []) + self.assertCountEqual( + MuseumExhibit.objects.filter(sections__section_number__in=[1]), + [self.egypt, self.wonders], + ) + self.assertCountEqual( + MuseumExhibit.objects.filter(sections__section_number__in=[2]), + [self.new_descoveries, self.wonders], + ) + self.assertCountEqual(MuseumExhibit.objects.filter(sections__section_number__in=[3]), []) + + def test_iexact_filter(self): + self.assertCountEqual( + MuseumExhibit.objects.filter( + sections__artifacts__0__name__iexact="lightHOuse of aLexandriA" + ), + [self.new_descoveries, self.wonders], + ) + + def test_gt_filter(self): + self.assertCountEqual( + MuseumExhibit.objects.filter(sections__section_number__gt=1), + [self.new_descoveries, self.wonders], + ) + + def test_gte_filter(self): + self.assertCountEqual( + MuseumExhibit.objects.filter(sections__section_number__gte=1), + [self.egypt, self.new_descoveries, self.wonders], + ) + + def test_lt_filter(self): + self.assertCountEqual( + MuseumExhibit.objects.filter(sections__section_number__lt=2), [self.egypt, self.wonders] + ) + + def test_lte_filter(self): + self.assertCountEqual( + MuseumExhibit.objects.filter(sections__section_number__lte=2), + [self.egypt, self.wonders, self.new_descoveries], + ) + + def test_query_array_not_allowed(self): + msg = ( + "Cannot apply this lookup directly to EmbeddedModelArrayField. " + "Try querying one of its embedded fields instead." + ) + with self.assertRaisesMessage(ValueError, msg): + MuseumExhibit.objects.filter(sections=10).first() + + with self.assertRaisesMessage(ValueError, msg): + MuseumExhibit.objects.filter(sections__0_1=10).first() + + def test_missing_field(self): + msg = "ExhibitSection has no field named 'section'" + with self.assertRaisesMessage(FieldDoesNotExist, msg): + MuseumExhibit.objects.filter(sections__section__in=[10]).first() + + def test_missing_lookup(self): + msg = "Unsupported lookup 'return' for EmbeddedModelArrayField of 'IntegerField'" + with self.assertRaisesMessage(FieldDoesNotExist, msg): + MuseumExhibit.objects.filter(sections__section_number__return=3) + + def test_missing_operation(self): + msg = "Unsupported lookup 'rage' for EmbeddedModelArrayField of 'IntegerField'" + with self.assertRaisesMessage(FieldDoesNotExist, msg): + MuseumExhibit.objects.filter(sections__section_number__rage=[10]) + + def test_missing_lookup_suggestions(self): + msg = ( + "Unsupported lookup 'ltee' for EmbeddedModelArrayField of 'IntegerField', " + "perhaps you meant lte or lt?" + ) + with self.assertRaisesMessage(FieldDoesNotExist, msg): + MuseumExhibit.objects.filter(sections__section_number__ltee=3) + + def test_double_emfarray_transform(self): + msg = "Cannot perform multiple levels of array traversal in a query." + with self.assertRaisesMessage(ValueError, msg): + MuseumExhibit.objects.filter(sections__artifacts__name="") + + def test_slice(self): + self.assertSequenceEqual( + MuseumExhibit.objects.filter(sections__0_1__section_number=2), [self.new_descoveries] + ) + + def test_foreign_field_exact(self): + qs = Tour.objects.filter(exhibit__sections__section_number=1) + self.assertCountEqual(qs, [self.egypt_tour, self.wonders_tour]) + + def test_foreign_field_with_slice(self): + qs = Tour.objects.filter(exhibit__sections__0_2__section_number__in=[1, 2]) + self.assertCountEqual(qs, [self.wonders_tour, self.egypt_tour]) + + def test_subquery_section_number_lt(self): + subq = ExhibitAudit.objects.filter( + related_section_number__in=models.OuterRef("sections__section_number") + ).values("related_section_number")[:1] + self.assertCountEqual( + MuseumExhibit.objects.filter(sections__section_number=subq), + [self.egypt, self.wonders, self.new_descoveries], + ) + + def test_check_in_subquery(self): + subquery = ExhibitAudit.objects.filter(reviewed=True).values_list( + "related_section_number", flat=True + ) + result = MuseumExhibit.objects.filter(sections__section_number__in=subquery) + self.assertCountEqual(result, [self.wonders, self.egypt, self.new_descoveries]) + + def test_array_as_rhs(self): + result = MuseumExhibit.objects.filter( + main_section__section_number__in=models.F("sections__section_number") + ) + self.assertCountEqual(result, [self.new_descoveries]) + + @isolate_apps("model_fields_") class CheckTests(SimpleTestCase): def test_no_relational_fields(self):