From ef1e198eff4773e2ff8dd5c971c419f3037c28ff Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Thu, 28 May 2026 10:32:13 -0400 Subject: [PATCH 01/27] fix bug add test --- django_mongodb_backend/lookups.py | 28 ++++++++++++++++++++++ tests/lookup_/models.py | 17 +++++++++++++ tests/lookup_/tests.py | 40 ++++++++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/django_mongodb_backend/lookups.py b/django_mongodb_backend/lookups.py index 18d0e7551..d79eee2b8 100644 --- a/django_mongodb_backend/lookups.py +++ b/django_mongodb_backend/lookups.py @@ -2,6 +2,7 @@ from django.db.models.fields.related_lookups import In, RelatedIn from django.db.models.lookups import ( BuiltinLookup, + Exact, FieldGetDbPrepValueIterableMixin, IsNull, LessThan, @@ -26,6 +27,32 @@ def builtin_lookup_path(self, compiler, connection): return connection.mongo_operators[self.lookup_name](lhs_mql, value) +def _type_filter_for_partial_unique_index(self, compiler, connection): + """ + Return a $type predicate matching the backend-generated partial unique index + filter for simple exact lookups, or None if it can't be determined. + """ + lhs_mql = process_lhs(self, compiler, connection) + + output_field = getattr(self.lhs, "output_field", None) + if output_field is None: + return None + + db_type = output_field.db_type(connection) + if db_type is None: + return None + + return {lhs_mql: {"$type": db_type}} + + +def exact_path(self, compiler, connection): + query = builtin_lookup_path(self, compiler, connection) + type_filter = _type_filter_for_partial_unique_index(self, compiler, connection) + if type_filter is None: + return query + return {"$and": [query, type_filter]} + + _field_resolve_expression_parameter = FieldGetDbPrepValueIterableMixin.resolve_expression_parameter @@ -172,6 +199,7 @@ def uuid_text_mixin(self, compiler, connection, as_expr=False): # noqa: ARG001 def register_lookups(): BuiltinLookup.as_mql_expr = builtin_lookup_expr BuiltinLookup.as_mql_path = builtin_lookup_path + Exact.as_mql_path = exact_path FieldGetDbPrepValueIterableMixin.resolve_expression_parameter = ( field_resolve_expression_parameter ) diff --git a/tests/lookup_/models.py b/tests/lookup_/models.py index e91582aa5..8e4ed9844 100644 --- a/tests/lookup_/models.py +++ b/tests/lookup_/models.py @@ -17,3 +17,20 @@ class Meta: def __str__(self): return str(self.num) + + +class UniqueAuthor(models.Model): + name = models.TextField(unique=True) + +class UniqueBook(models.Model): + author = models.ForeignKey(UniqueAuthor, on_delete=models.CASCADE) + version = models.IntegerField() + name = models.TextField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["version", "name"], + name="unique_book_version", + ) + ] diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index 94b63f833..e38a7bc91 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -4,7 +4,7 @@ from django_mongodb_backend.test import MongoTestCaseMixin -from .models import Book, Number +from .models import Book, Number, UniqueAuthor, UniqueBook class NumericLookupTests(MongoTestCaseMixin, TestCase): @@ -170,3 +170,41 @@ def test_subquery_filter_constant(self): {"$sort": SON([("num", 1)])}, ], ) + +class PartialUniqueIndexLookupTests(TestCase): + def _find_ixscan(self, winning_plan): + plan = winning_plan + while plan: + if plan.get("stage") == "IXSCAN": + return plan + plan = plan.get("inputStage") + return None + + def test_exact_lookup_uses_partial_unique_index(self): + UniqueAuthor.objects.create(name="JK Rowling") + + plan = json_util.loads( + UniqueAuthor.objects.filter(name="JK Rowling").explain() + )["queryPlanner"]["winningPlan"] + ixscan = self._find_ixscan(plan) + + self.assertIsNotNone(ixscan) + self.assertEqual(ixscan["keyPattern"], {"name": 1}) + self.assertTrue(ixscan["isUnique"]) + self.assertTrue(ixscan["isPartial"]) + + def test_compound_exact_lookup_uses_partial_unique_index(self): + author = UniqueAuthor.objects.create(name="JK Rowling") + UniqueBook.objects.create(author=author, version=3, name="Harry Potter") + UniqueBook.objects.create(author=author, version=4, name="Harry Potter") + + plan = json_util.loads( + UniqueBook.objects.filter(version=3, name="Harry Potter").explain() + )["queryPlanner"]["winningPlan"] + ixscan = self._find_ixscan(plan) + + self.assertIsNotNone(ixscan) + self.assertEqual(ixscan["indexName"], "unique_book_version") + self.assertEqual(ixscan["keyPattern"], {"version": 1, "name": 1}) + self.assertTrue(ixscan["isUnique"]) + self.assertTrue(ixscan["isPartial"]) From ee11639b947e82b138f9e88b0c86cc1a5752ebd9 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Thu, 28 May 2026 10:50:47 -0400 Subject: [PATCH 02/27] linting --- tests/lookup_/models.py | 1 + tests/lookup_/tests.py | 15 ++++++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/lookup_/models.py b/tests/lookup_/models.py index 8e4ed9844..9cc07cdc4 100644 --- a/tests/lookup_/models.py +++ b/tests/lookup_/models.py @@ -22,6 +22,7 @@ def __str__(self): class UniqueAuthor(models.Model): name = models.TextField(unique=True) + class UniqueBook(models.Model): author = models.ForeignKey(UniqueAuthor, on_delete=models.CASCADE) version = models.IntegerField() diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index e38a7bc91..018004ca5 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -1,4 +1,4 @@ -from bson import SON +from bson import SON, json_util from django.db.models import Sum from django.test import TestCase @@ -171,6 +171,7 @@ def test_subquery_filter_constant(self): ], ) + class PartialUniqueIndexLookupTests(TestCase): def _find_ixscan(self, winning_plan): plan = winning_plan @@ -183,9 +184,9 @@ def _find_ixscan(self, winning_plan): def test_exact_lookup_uses_partial_unique_index(self): UniqueAuthor.objects.create(name="JK Rowling") - plan = json_util.loads( - UniqueAuthor.objects.filter(name="JK Rowling").explain() - )["queryPlanner"]["winningPlan"] + plan = json_util.loads(UniqueAuthor.objects.filter(name="JK Rowling").explain())[ + "queryPlanner" + ]["winningPlan"] ixscan = self._find_ixscan(plan) self.assertIsNotNone(ixscan) @@ -198,9 +199,9 @@ def test_compound_exact_lookup_uses_partial_unique_index(self): UniqueBook.objects.create(author=author, version=3, name="Harry Potter") UniqueBook.objects.create(author=author, version=4, name="Harry Potter") - plan = json_util.loads( - UniqueBook.objects.filter(version=3, name="Harry Potter").explain() - )["queryPlanner"]["winningPlan"] + plan = json_util.loads(UniqueBook.objects.filter(version=3, name="Harry Potter").explain())[ + "queryPlanner" + ]["winningPlan"] ixscan = self._find_ixscan(plan) self.assertIsNotNone(ixscan) From 8e30de9b8e868bed029d68b1496ae62d596df2b9 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Thu, 28 May 2026 12:28:40 -0400 Subject: [PATCH 03/27] constraints instead of lookups --- django_mongodb_backend/constraints.py | 42 ++++++++++++++++- django_mongodb_backend/lookups.py | 29 +----------- tests/constraints_/test_unique_indexes.py | 57 +++++++++++++++++++++++ 3 files changed, 98 insertions(+), 30 deletions(-) create mode 100644 tests/constraints_/test_unique_indexes.py diff --git a/django_mongodb_backend/constraints.py b/django_mongodb_backend/constraints.py index 60eef58a4..dd3ce4738 100644 --- a/django_mongodb_backend/constraints.py +++ b/django_mongodb_backend/constraints.py @@ -1,3 +1,4 @@ +import datetime from collections import defaultdict from django.core import checks @@ -9,6 +10,41 @@ from .fields import EmbeddedModelArrayField, PolymorphicEmbeddedModelArrayField from .indexes import EmbeddedFieldIndexMixin, _get_condition_mql, get_field +def _get_partial_unique_filter(field, connection): + db_type = field.db_type(connection) + if db_type is None: + return {"$exists": True} + + match db_type: + case "string": + return {"$gte": ""} + case "int": + return {"$gte": -2147483648, "$lte": 2147483647} + case "long": + return { + "$gte": -9223372036854775808, + "$lte": 9223372036854775807, + } + case "decimal": + return { + "$gte": -9223372036854775808, + "$lte": 9223372036854775807, + } + case "double": + return { + "$gte": -9223372036854775808, + "$lte": 9223372036854775807, + } + case "bool": + return {"$in": [True, False]} + case "date": + return { + "$gte": datetime.datetime.min, + "$lte": datetime.datetime.max, + } + case _: + return {"$exists": True} + def get_pymongo_index_model(self, model, schema_editor, field=None, column_prefix=""): """Return a pymongo IndexModel for this UniqueConstraint.""" @@ -25,12 +61,14 @@ def get_pymongo_index_model(self, model, schema_editor, field=None, column_prefi # Field(unique=True) or Meta.unique_together. if field: column = column_prefix + field.column - filter_expression[column].update({"$type": field.db_type(schema_editor.connection)}) + filter_expression[column].update( + _get_partial_unique_filter(field, schema_editor.connection) + ) else: for field_name in self.fields: field_ = get_field(model, field_name) filter_expression[field_.column].update( - {"$type": field_.field.db_type(schema_editor.connection)} + _get_partial_unique_filter(field_, schema_editor.connection) ) if filter_expression: kwargs["partialFilterExpression"] = filter_expression diff --git a/django_mongodb_backend/lookups.py b/django_mongodb_backend/lookups.py index d79eee2b8..d5749372e 100644 --- a/django_mongodb_backend/lookups.py +++ b/django_mongodb_backend/lookups.py @@ -2,7 +2,6 @@ from django.db.models.fields.related_lookups import In, RelatedIn from django.db.models.lookups import ( BuiltinLookup, - Exact, FieldGetDbPrepValueIterableMixin, IsNull, LessThan, @@ -27,32 +26,6 @@ def builtin_lookup_path(self, compiler, connection): return connection.mongo_operators[self.lookup_name](lhs_mql, value) -def _type_filter_for_partial_unique_index(self, compiler, connection): - """ - Return a $type predicate matching the backend-generated partial unique index - filter for simple exact lookups, or None if it can't be determined. - """ - lhs_mql = process_lhs(self, compiler, connection) - - output_field = getattr(self.lhs, "output_field", None) - if output_field is None: - return None - - db_type = output_field.db_type(connection) - if db_type is None: - return None - - return {lhs_mql: {"$type": db_type}} - - -def exact_path(self, compiler, connection): - query = builtin_lookup_path(self, compiler, connection) - type_filter = _type_filter_for_partial_unique_index(self, compiler, connection) - if type_filter is None: - return query - return {"$and": [query, type_filter]} - - _field_resolve_expression_parameter = FieldGetDbPrepValueIterableMixin.resolve_expression_parameter @@ -199,7 +172,6 @@ def uuid_text_mixin(self, compiler, connection, as_expr=False): # noqa: ARG001 def register_lookups(): BuiltinLookup.as_mql_expr = builtin_lookup_expr BuiltinLookup.as_mql_path = builtin_lookup_path - Exact.as_mql_path = exact_path FieldGetDbPrepValueIterableMixin.resolve_expression_parameter = ( field_resolve_expression_parameter ) @@ -214,3 +186,4 @@ def register_lookups(): PatternLookup.as_mql_expr = pattern_lookup_expr PatternLookup.as_mql_path = pattern_lookup_path UUIDTextMixin.as_mql = uuid_text_mixin + \ No newline at end of file diff --git a/tests/constraints_/test_unique_indexes.py b/tests/constraints_/test_unique_indexes.py new file mode 100644 index 000000000..b3281d590 --- /dev/null +++ b/tests/constraints_/test_unique_indexes.py @@ -0,0 +1,57 @@ +from django.db import connection, models +from django.test import SimpleTestCase +from django.test.utils import isolate_apps + +@isolate_apps("constraints_") +class UniqueIndexTests(SimpleTestCase): + def test_single_field_unique_index_filter(self): + class Author(models.Model): + name = models.TextField(unique=True) + + class Meta: + app_label = "constraints_" + + field = Author._meta.get_field("name") + constraint = models.UniqueConstraint(fields=["name"], name="author_name_uniq") + + with connection.schema_editor() as editor: + index = constraint.get_pymongo_index_model( + Author, + schema_editor=editor, + field=field, + ) + + self.assertEqual( + index.document["partialFilterExpression"], + {"name": {"$gte": ""}}, + ) + + def test_multi_field_unique_index_filter(self): + class Book(models.Model): + version = models.IntegerField() + name = models.TextField() + + class Meta: + app_label = "constraints_" + constraints = [ + models.UniqueConstraint( + fields=["version", "name"], + name="unique_book_version", + ) + ] + + constraint = Book._meta.constraints[0] + + with connection.schema_editor() as editor: + index = constraint.get_pymongo_index_model(Book, schema_editor=editor) + + self.assertEqual( + index.document["partialFilterExpression"], + { + "version": { + "$gte": -2147483648, + "$lte": 2147483647, + }, + "name": {"$gte": ""}, + }, + ) From c76a655319a5387411becb9a39a3488a68453eca Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Thu, 28 May 2026 12:47:59 -0400 Subject: [PATCH 04/27] error fix --- django_mongodb_backend/constraints.py | 2 ++ django_mongodb_backend/lookups.py | 1 - tests/constraints_/test_unique_indexes.py | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/django_mongodb_backend/constraints.py b/django_mongodb_backend/constraints.py index dd3ce4738..4863e7c49 100644 --- a/django_mongodb_backend/constraints.py +++ b/django_mongodb_backend/constraints.py @@ -10,7 +10,9 @@ from .fields import EmbeddedModelArrayField, PolymorphicEmbeddedModelArrayField from .indexes import EmbeddedFieldIndexMixin, _get_condition_mql, get_field + def _get_partial_unique_filter(field, connection): + field = getattr(field, "field", field) db_type = field.db_type(connection) if db_type is None: return {"$exists": True} diff --git a/django_mongodb_backend/lookups.py b/django_mongodb_backend/lookups.py index d5749372e..18d0e7551 100644 --- a/django_mongodb_backend/lookups.py +++ b/django_mongodb_backend/lookups.py @@ -186,4 +186,3 @@ def register_lookups(): PatternLookup.as_mql_expr = pattern_lookup_expr PatternLookup.as_mql_path = pattern_lookup_path UUIDTextMixin.as_mql = uuid_text_mixin - \ No newline at end of file diff --git a/tests/constraints_/test_unique_indexes.py b/tests/constraints_/test_unique_indexes.py index b3281d590..2f63ac94c 100644 --- a/tests/constraints_/test_unique_indexes.py +++ b/tests/constraints_/test_unique_indexes.py @@ -2,6 +2,7 @@ from django.test import SimpleTestCase from django.test.utils import isolate_apps + @isolate_apps("constraints_") class UniqueIndexTests(SimpleTestCase): def test_single_field_unique_index_filter(self): From b6556b8899f0668beecf75d344124cb275436605 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Mon, 1 Jun 2026 12:51:09 -0400 Subject: [PATCH 05/27] remove extra conditional --- django_mongodb_backend/constraints.py | 20 ++------------------ tests/constraints_/test_unique_indexes.py | 9 ++++----- 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/django_mongodb_backend/constraints.py b/django_mongodb_backend/constraints.py index 4863e7c49..1e4e64879 100644 --- a/django_mongodb_backend/constraints.py +++ b/django_mongodb_backend/constraints.py @@ -10,12 +10,9 @@ from .fields import EmbeddedModelArrayField, PolymorphicEmbeddedModelArrayField from .indexes import EmbeddedFieldIndexMixin, _get_condition_mql, get_field - def _get_partial_unique_filter(field, connection): field = getattr(field, "field", field) db_type = field.db_type(connection) - if db_type is None: - return {"$exists": True} match db_type: case "string": @@ -27,16 +24,6 @@ def _get_partial_unique_filter(field, connection): "$gte": -9223372036854775808, "$lte": 9223372036854775807, } - case "decimal": - return { - "$gte": -9223372036854775808, - "$lte": 9223372036854775807, - } - case "double": - return { - "$gte": -9223372036854775808, - "$lte": 9223372036854775807, - } case "bool": return {"$in": [True, False]} case "date": @@ -45,8 +32,7 @@ def _get_partial_unique_filter(field, connection): "$lte": datetime.datetime.max, } case _: - return {"$exists": True} - + return {"$type": db_type} def get_pymongo_index_model(self, model, schema_editor, field=None, column_prefix=""): """Return a pymongo IndexModel for this UniqueConstraint.""" @@ -84,7 +70,6 @@ def get_pymongo_index_model(self, model, schema_editor, field=None, column_prefi ) return IndexModel(index_orders, name=self.name, unique=True, **kwargs) - class EmbeddedFieldUniqueConstraint(EmbeddedFieldIndexMixin, UniqueConstraint): meta_option_name = "constraints" @@ -102,7 +87,7 @@ def check(self, model, connection): field = model._meta.get_field(local_field_name) except FieldDoesNotExist: continue - if isinstance(field, (EmbeddedModelArrayField, PolymorphicEmbeddedModelArrayField)): + if isinstance(field,(EmbeddedModelArrayField, PolymorphicEmbeddedModelArrayField)): errors.append( checks.Error( f"EmbeddedFieldUniqueConstraint {self.name!r} must " @@ -114,7 +99,6 @@ def check(self, model, connection): ) return errors - def register_constraints(): UniqueConstraint.get_pymongo_index_model = get_pymongo_index_model UniqueConstraint._get_condition_mql = _get_condition_mql diff --git a/tests/constraints_/test_unique_indexes.py b/tests/constraints_/test_unique_indexes.py index 2f63ac94c..114babd1c 100644 --- a/tests/constraints_/test_unique_indexes.py +++ b/tests/constraints_/test_unique_indexes.py @@ -2,7 +2,6 @@ from django.test import SimpleTestCase from django.test.utils import isolate_apps - @isolate_apps("constraints_") class UniqueIndexTests(SimpleTestCase): def test_single_field_unique_index_filter(self): @@ -23,7 +22,7 @@ class Meta: ) self.assertEqual( - index.document["partialFilterExpression"], + dict(index.document["partialFilterExpression"]), {"name": {"$gte": ""}}, ) @@ -47,11 +46,11 @@ class Meta: index = constraint.get_pymongo_index_model(Book, schema_editor=editor) self.assertEqual( - index.document["partialFilterExpression"], + dict(index.document["partialFilterExpression"]), { "version": { - "$gte": -2147483648, - "$lte": 2147483647, + "$gte": -9223372036854775808, + "$lte": 9223372036854775807, }, "name": {"$gte": ""}, }, From c85f84496f0246838cb8ed76a0b38b587692d544 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Mon, 1 Jun 2026 12:54:50 -0400 Subject: [PATCH 06/27] lint --- django_mongodb_backend/constraints.py | 6 +++++- tests/constraints_/test_unique_indexes.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/django_mongodb_backend/constraints.py b/django_mongodb_backend/constraints.py index 1e4e64879..8e9566178 100644 --- a/django_mongodb_backend/constraints.py +++ b/django_mongodb_backend/constraints.py @@ -10,6 +10,7 @@ from .fields import EmbeddedModelArrayField, PolymorphicEmbeddedModelArrayField from .indexes import EmbeddedFieldIndexMixin, _get_condition_mql, get_field + def _get_partial_unique_filter(field, connection): field = getattr(field, "field", field) db_type = field.db_type(connection) @@ -34,6 +35,7 @@ def _get_partial_unique_filter(field, connection): case _: return {"$type": db_type} + def get_pymongo_index_model(self, model, schema_editor, field=None, column_prefix=""): """Return a pymongo IndexModel for this UniqueConstraint.""" if self.contains_expressions: @@ -70,6 +72,7 @@ def get_pymongo_index_model(self, model, schema_editor, field=None, column_prefi ) return IndexModel(index_orders, name=self.name, unique=True, **kwargs) + class EmbeddedFieldUniqueConstraint(EmbeddedFieldIndexMixin, UniqueConstraint): meta_option_name = "constraints" @@ -87,7 +90,7 @@ def check(self, model, connection): field = model._meta.get_field(local_field_name) except FieldDoesNotExist: continue - if isinstance(field,(EmbeddedModelArrayField, PolymorphicEmbeddedModelArrayField)): + if isinstance(field, (EmbeddedModelArrayField, PolymorphicEmbeddedModelArrayField)): errors.append( checks.Error( f"EmbeddedFieldUniqueConstraint {self.name!r} must " @@ -99,6 +102,7 @@ def check(self, model, connection): ) return errors + def register_constraints(): UniqueConstraint.get_pymongo_index_model = get_pymongo_index_model UniqueConstraint._get_condition_mql = _get_condition_mql diff --git a/tests/constraints_/test_unique_indexes.py b/tests/constraints_/test_unique_indexes.py index 114babd1c..bef2de3ea 100644 --- a/tests/constraints_/test_unique_indexes.py +++ b/tests/constraints_/test_unique_indexes.py @@ -2,6 +2,7 @@ from django.test import SimpleTestCase from django.test.utils import isolate_apps + @isolate_apps("constraints_") class UniqueIndexTests(SimpleTestCase): def test_single_field_unique_index_filter(self): From c4c3a6171e6d7801a944afbf7cb8a65d8daec62d Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Mon, 1 Jun 2026 15:53:21 -0400 Subject: [PATCH 07/27] single field cases --- django_mongodb_backend/constraints.py | 32 ++++++++++++-------- tests/constraints_/test_unique_indexes.py | 36 +++++++++++++++-------- tests/lookup_/tests.py | 8 ++--- 3 files changed, 45 insertions(+), 31 deletions(-) diff --git a/django_mongodb_backend/constraints.py b/django_mongodb_backend/constraints.py index 8e9566178..05de8b8be 100644 --- a/django_mongodb_backend/constraints.py +++ b/django_mongodb_backend/constraints.py @@ -11,8 +11,8 @@ from .indexes import EmbeddedFieldIndexMixin, _get_condition_mql, get_field -def _get_partial_unique_filter(field, connection): - field = getattr(field, "field", field) +def _get_partial_unique_filter(field_or_field_column, connection): + field = getattr(field_or_field_column, "field", field_or_field_column) db_type = field.db_type(connection) match db_type: @@ -36,6 +36,13 @@ def _get_partial_unique_filter(field, connection): return {"$type": db_type} +def _add_nullable_unique_filter(filter_expression, field_or_field_column, connection, column): + field = getattr(field_or_field_column, "field", field_or_field_column) + if not field.null: + return + filter_expression[column].update(_get_partial_unique_filter(field_or_field_column, connection)) + + def get_pymongo_index_model(self, model, schema_editor, field=None, column_prefix=""): """Return a pymongo IndexModel for this UniqueConstraint.""" if self.contains_expressions: @@ -45,20 +52,17 @@ def get_pymongo_index_model(self, model, schema_editor, field=None, column_prefi if self.condition: filter_expression.update(self._get_condition_mql(model, schema_editor)) if self.nulls_distinct is None or self.nulls_distinct: - # Indexing on $type matches the value of most SQL databases by allowing - # multiple null values for the unique constraint. nulls_distinct will - # be True or False for a UniqueConstraint, or None for - # Field(unique=True) or Meta.unique_together. if field: column = column_prefix + field.column - filter_expression[column].update( - _get_partial_unique_filter(field, schema_editor.connection) - ) + _add_nullable_unique_filter(filter_expression, field, schema_editor.connection, column) else: for field_name in self.fields: field_ = get_field(model, field_name) - filter_expression[field_.column].update( - _get_partial_unique_filter(field_, schema_editor.connection) + _add_nullable_unique_filter( + filter_expression, + field_, + schema_editor.connection, + field_.column, ) if filter_expression: kwargs["partialFilterExpression"] = filter_expression @@ -84,13 +88,15 @@ def check(self, model, connection): # index doesn't need to be created with a partialFilterExpression). if self.nulls_distinct is not False: for field_name in self.fields: - # Get the top-level field, removing paths to embedded fields. local_field_name, *_ = field_name.split(".") try: field = model._meta.get_field(local_field_name) except FieldDoesNotExist: continue - if isinstance(field, (EmbeddedModelArrayField, PolymorphicEmbeddedModelArrayField)): + if isinstance( + field, + (EmbeddedModelArrayField, PolymorphicEmbeddedModelArrayField), + ): errors.append( checks.Error( f"EmbeddedFieldUniqueConstraint {self.name!r} must " diff --git a/tests/constraints_/test_unique_indexes.py b/tests/constraints_/test_unique_indexes.py index bef2de3ea..2ee302629 100644 --- a/tests/constraints_/test_unique_indexes.py +++ b/tests/constraints_/test_unique_indexes.py @@ -5,7 +5,7 @@ @isolate_apps("constraints_") class UniqueIndexTests(SimpleTestCase): - def test_single_field_unique_index_filter(self): + def test_single_field_unique_index_has_no_partial_filter_when_not_nullable(self): class Author(models.Model): name = models.TextField(unique=True) @@ -22,12 +22,9 @@ class Meta: field=field, ) - self.assertEqual( - dict(index.document["partialFilterExpression"]), - {"name": {"$gte": ""}}, - ) + self.assertNotIn("partialFilterExpression", index.document) - def test_multi_field_unique_index_filter(self): + def test_multi_field_unique_index_has_no_partial_filter_when_not_nullable(self): class Book(models.Model): version = models.IntegerField() name = models.TextField() @@ -46,13 +43,26 @@ class Meta: with connection.schema_editor() as editor: index = constraint.get_pymongo_index_model(Book, schema_editor=editor) + self.assertNotIn("partialFilterExpression", index.document) + + def test_nullable_unique_index_uses_partial_filter(self): + class Place(models.Model): + code = models.TextField(unique=True, null=True) + + class Meta: + app_label = "constraints_" + + field = Place._meta.get_field("code") + constraint = models.UniqueConstraint(fields=["code"], name="place_code_uniq") + + with connection.schema_editor() as editor: + index = constraint.get_pymongo_index_model( + Place, + schema_editor=editor, + field=field, + ) + self.assertEqual( dict(index.document["partialFilterExpression"]), - { - "version": { - "$gte": -9223372036854775808, - "$lte": 9223372036854775807, - }, - "name": {"$gte": ""}, - }, + {"code": {"$gte": ""}}, ) diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index 018004ca5..d170727a9 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -172,7 +172,7 @@ def test_subquery_filter_constant(self): ) -class PartialUniqueIndexLookupTests(TestCase): +class UniqueIndexLookupTests(TestCase): def _find_ixscan(self, winning_plan): plan = winning_plan while plan: @@ -181,7 +181,7 @@ def _find_ixscan(self, winning_plan): plan = plan.get("inputStage") return None - def test_exact_lookup_uses_partial_unique_index(self): + def test_exact_lookup_uses_unique_index(self): UniqueAuthor.objects.create(name="JK Rowling") plan = json_util.loads(UniqueAuthor.objects.filter(name="JK Rowling").explain())[ @@ -192,9 +192,8 @@ def test_exact_lookup_uses_partial_unique_index(self): self.assertIsNotNone(ixscan) self.assertEqual(ixscan["keyPattern"], {"name": 1}) self.assertTrue(ixscan["isUnique"]) - self.assertTrue(ixscan["isPartial"]) - def test_compound_exact_lookup_uses_partial_unique_index(self): + def test_compound_exact_lookup_uses_unique_index(self): author = UniqueAuthor.objects.create(name="JK Rowling") UniqueBook.objects.create(author=author, version=3, name="Harry Potter") UniqueBook.objects.create(author=author, version=4, name="Harry Potter") @@ -208,4 +207,3 @@ def test_compound_exact_lookup_uses_partial_unique_index(self): self.assertEqual(ixscan["indexName"], "unique_book_version") self.assertEqual(ixscan["keyPattern"], {"version": 1, "name": 1}) self.assertTrue(ixscan["isUnique"]) - self.assertTrue(ixscan["isPartial"]) From 81ac966a46a0019a3e30cfca70dd74f05fa19d92 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Tue, 2 Jun 2026 09:21:25 -0400 Subject: [PATCH 08/27] Revert "single field cases" This reverts commit c4c3a6171e6d7801a944afbf7cb8a65d8daec62d. --- django_mongodb_backend/constraints.py | 32 ++++++++------------ tests/constraints_/test_unique_indexes.py | 36 ++++++++--------------- tests/lookup_/tests.py | 8 +++-- 3 files changed, 31 insertions(+), 45 deletions(-) diff --git a/django_mongodb_backend/constraints.py b/django_mongodb_backend/constraints.py index 05de8b8be..8e9566178 100644 --- a/django_mongodb_backend/constraints.py +++ b/django_mongodb_backend/constraints.py @@ -11,8 +11,8 @@ from .indexes import EmbeddedFieldIndexMixin, _get_condition_mql, get_field -def _get_partial_unique_filter(field_or_field_column, connection): - field = getattr(field_or_field_column, "field", field_or_field_column) +def _get_partial_unique_filter(field, connection): + field = getattr(field, "field", field) db_type = field.db_type(connection) match db_type: @@ -36,13 +36,6 @@ def _get_partial_unique_filter(field_or_field_column, connection): return {"$type": db_type} -def _add_nullable_unique_filter(filter_expression, field_or_field_column, connection, column): - field = getattr(field_or_field_column, "field", field_or_field_column) - if not field.null: - return - filter_expression[column].update(_get_partial_unique_filter(field_or_field_column, connection)) - - def get_pymongo_index_model(self, model, schema_editor, field=None, column_prefix=""): """Return a pymongo IndexModel for this UniqueConstraint.""" if self.contains_expressions: @@ -52,17 +45,20 @@ def get_pymongo_index_model(self, model, schema_editor, field=None, column_prefi if self.condition: filter_expression.update(self._get_condition_mql(model, schema_editor)) if self.nulls_distinct is None or self.nulls_distinct: + # Indexing on $type matches the value of most SQL databases by allowing + # multiple null values for the unique constraint. nulls_distinct will + # be True or False for a UniqueConstraint, or None for + # Field(unique=True) or Meta.unique_together. if field: column = column_prefix + field.column - _add_nullable_unique_filter(filter_expression, field, schema_editor.connection, column) + filter_expression[column].update( + _get_partial_unique_filter(field, schema_editor.connection) + ) else: for field_name in self.fields: field_ = get_field(model, field_name) - _add_nullable_unique_filter( - filter_expression, - field_, - schema_editor.connection, - field_.column, + filter_expression[field_.column].update( + _get_partial_unique_filter(field_, schema_editor.connection) ) if filter_expression: kwargs["partialFilterExpression"] = filter_expression @@ -88,15 +84,13 @@ def check(self, model, connection): # index doesn't need to be created with a partialFilterExpression). if self.nulls_distinct is not False: for field_name in self.fields: + # Get the top-level field, removing paths to embedded fields. local_field_name, *_ = field_name.split(".") try: field = model._meta.get_field(local_field_name) except FieldDoesNotExist: continue - if isinstance( - field, - (EmbeddedModelArrayField, PolymorphicEmbeddedModelArrayField), - ): + if isinstance(field, (EmbeddedModelArrayField, PolymorphicEmbeddedModelArrayField)): errors.append( checks.Error( f"EmbeddedFieldUniqueConstraint {self.name!r} must " diff --git a/tests/constraints_/test_unique_indexes.py b/tests/constraints_/test_unique_indexes.py index 2ee302629..bef2de3ea 100644 --- a/tests/constraints_/test_unique_indexes.py +++ b/tests/constraints_/test_unique_indexes.py @@ -5,7 +5,7 @@ @isolate_apps("constraints_") class UniqueIndexTests(SimpleTestCase): - def test_single_field_unique_index_has_no_partial_filter_when_not_nullable(self): + def test_single_field_unique_index_filter(self): class Author(models.Model): name = models.TextField(unique=True) @@ -22,9 +22,12 @@ class Meta: field=field, ) - self.assertNotIn("partialFilterExpression", index.document) + self.assertEqual( + dict(index.document["partialFilterExpression"]), + {"name": {"$gte": ""}}, + ) - def test_multi_field_unique_index_has_no_partial_filter_when_not_nullable(self): + def test_multi_field_unique_index_filter(self): class Book(models.Model): version = models.IntegerField() name = models.TextField() @@ -43,26 +46,13 @@ class Meta: with connection.schema_editor() as editor: index = constraint.get_pymongo_index_model(Book, schema_editor=editor) - self.assertNotIn("partialFilterExpression", index.document) - - def test_nullable_unique_index_uses_partial_filter(self): - class Place(models.Model): - code = models.TextField(unique=True, null=True) - - class Meta: - app_label = "constraints_" - - field = Place._meta.get_field("code") - constraint = models.UniqueConstraint(fields=["code"], name="place_code_uniq") - - with connection.schema_editor() as editor: - index = constraint.get_pymongo_index_model( - Place, - schema_editor=editor, - field=field, - ) - self.assertEqual( dict(index.document["partialFilterExpression"]), - {"code": {"$gte": ""}}, + { + "version": { + "$gte": -9223372036854775808, + "$lte": 9223372036854775807, + }, + "name": {"$gte": ""}, + }, ) diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index d170727a9..018004ca5 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -172,7 +172,7 @@ def test_subquery_filter_constant(self): ) -class UniqueIndexLookupTests(TestCase): +class PartialUniqueIndexLookupTests(TestCase): def _find_ixscan(self, winning_plan): plan = winning_plan while plan: @@ -181,7 +181,7 @@ def _find_ixscan(self, winning_plan): plan = plan.get("inputStage") return None - def test_exact_lookup_uses_unique_index(self): + def test_exact_lookup_uses_partial_unique_index(self): UniqueAuthor.objects.create(name="JK Rowling") plan = json_util.loads(UniqueAuthor.objects.filter(name="JK Rowling").explain())[ @@ -192,8 +192,9 @@ def test_exact_lookup_uses_unique_index(self): self.assertIsNotNone(ixscan) self.assertEqual(ixscan["keyPattern"], {"name": 1}) self.assertTrue(ixscan["isUnique"]) + self.assertTrue(ixscan["isPartial"]) - def test_compound_exact_lookup_uses_unique_index(self): + def test_compound_exact_lookup_uses_partial_unique_index(self): author = UniqueAuthor.objects.create(name="JK Rowling") UniqueBook.objects.create(author=author, version=3, name="Harry Potter") UniqueBook.objects.create(author=author, version=4, name="Harry Potter") @@ -207,3 +208,4 @@ def test_compound_exact_lookup_uses_unique_index(self): self.assertEqual(ixscan["indexName"], "unique_book_version") self.assertEqual(ixscan["keyPattern"], {"version": 1, "name": 1}) self.assertTrue(ixscan["isUnique"]) + self.assertTrue(ixscan["isPartial"]) From be8fadbcd153e7562a6664023ee8f37a76ccf160 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Tue, 2 Jun 2026 10:03:41 -0400 Subject: [PATCH 09/27] predicate approach to fixing failure --- django_mongodb_backend/lookups.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/django_mongodb_backend/lookups.py b/django_mongodb_backend/lookups.py index 18d0e7551..a07d3cd25 100644 --- a/django_mongodb_backend/lookups.py +++ b/django_mongodb_backend/lookups.py @@ -2,6 +2,7 @@ from django.db.models.fields.related_lookups import In, RelatedIn from django.db.models.lookups import ( BuiltinLookup, + Exact, FieldGetDbPrepValueIterableMixin, IsNull, LessThan, @@ -14,6 +15,30 @@ from .query_utils import is_constant_value, process_lhs, process_rhs +def _exact_partial_filter(self, compiler, connection): + if self.rhs is None: + return None + + output_field = getattr(self.lhs, "output_field", None) + if output_field is None: + return None + + db_type = output_field.db_type(connection) + if db_type != "string": + return None + + lhs_mql = process_lhs(self, compiler, connection) + return {lhs_mql: {"$type": "string"}} + + +def exact_path(self, compiler, connection): + query = builtin_lookup_path(self, compiler, connection) + partial_filter = _exact_partial_filter(self, compiler, connection) + if partial_filter is None: + return query + return {"$and": [query, partial_filter]} + + def builtin_lookup_expr(self, compiler, connection): lhs_mql = process_lhs(self, compiler, connection, as_expr=True) value = process_rhs(self, compiler, connection, as_expr=True) @@ -172,6 +197,7 @@ def uuid_text_mixin(self, compiler, connection, as_expr=False): # noqa: ARG001 def register_lookups(): BuiltinLookup.as_mql_expr = builtin_lookup_expr BuiltinLookup.as_mql_path = builtin_lookup_path + Exact.as_mql_path = exact_path FieldGetDbPrepValueIterableMixin.resolve_expression_parameter = ( field_resolve_expression_parameter ) From 15bf5334843b2ae5fe3c2940b8fea34c99267baa Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Tue, 9 Jun 2026 09:46:05 -0400 Subject: [PATCH 10/27] Revert "predicate approach to fixing failure" --- django_mongodb_backend/lookups.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/django_mongodb_backend/lookups.py b/django_mongodb_backend/lookups.py index a07d3cd25..18d0e7551 100644 --- a/django_mongodb_backend/lookups.py +++ b/django_mongodb_backend/lookups.py @@ -2,7 +2,6 @@ from django.db.models.fields.related_lookups import In, RelatedIn from django.db.models.lookups import ( BuiltinLookup, - Exact, FieldGetDbPrepValueIterableMixin, IsNull, LessThan, @@ -15,30 +14,6 @@ from .query_utils import is_constant_value, process_lhs, process_rhs -def _exact_partial_filter(self, compiler, connection): - if self.rhs is None: - return None - - output_field = getattr(self.lhs, "output_field", None) - if output_field is None: - return None - - db_type = output_field.db_type(connection) - if db_type != "string": - return None - - lhs_mql = process_lhs(self, compiler, connection) - return {lhs_mql: {"$type": "string"}} - - -def exact_path(self, compiler, connection): - query = builtin_lookup_path(self, compiler, connection) - partial_filter = _exact_partial_filter(self, compiler, connection) - if partial_filter is None: - return query - return {"$and": [query, partial_filter]} - - def builtin_lookup_expr(self, compiler, connection): lhs_mql = process_lhs(self, compiler, connection, as_expr=True) value = process_rhs(self, compiler, connection, as_expr=True) @@ -197,7 +172,6 @@ def uuid_text_mixin(self, compiler, connection, as_expr=False): # noqa: ARG001 def register_lookups(): BuiltinLookup.as_mql_expr = builtin_lookup_expr BuiltinLookup.as_mql_path = builtin_lookup_path - Exact.as_mql_path = exact_path FieldGetDbPrepValueIterableMixin.resolve_expression_parameter = ( field_resolve_expression_parameter ) From 0bf45438870c712ad705197c5f909566bc58729d Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Tue, 9 Jun 2026 09:55:38 -0400 Subject: [PATCH 11/27] try exists true for string --- django_mongodb_backend/constraints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_mongodb_backend/constraints.py b/django_mongodb_backend/constraints.py index 8e9566178..9005a9d3d 100644 --- a/django_mongodb_backend/constraints.py +++ b/django_mongodb_backend/constraints.py @@ -17,7 +17,7 @@ def _get_partial_unique_filter(field, connection): match db_type: case "string": - return {"$gte": ""} + return {"$gte": "", "$exists": True} case "int": return {"$gte": -2147483648, "$lte": 2147483647} case "long": From aa34ec7de617a663ddd0bb67d73e2686e200a0c4 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Tue, 9 Jun 2026 11:21:50 -0400 Subject: [PATCH 12/27] update test for string exists --- tests/constraints_/test_unique_indexes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/constraints_/test_unique_indexes.py b/tests/constraints_/test_unique_indexes.py index bef2de3ea..61f775eb7 100644 --- a/tests/constraints_/test_unique_indexes.py +++ b/tests/constraints_/test_unique_indexes.py @@ -24,7 +24,7 @@ class Meta: self.assertEqual( dict(index.document["partialFilterExpression"]), - {"name": {"$gte": ""}}, + {"name": {"$gte": "", "$exists": True}}, ) def test_multi_field_unique_index_filter(self): @@ -53,6 +53,6 @@ class Meta: "$gte": -9223372036854775808, "$lte": 9223372036854775807, }, - "name": {"$gte": ""}, + "name": {"$gte": "", "$exists": True}, }, ) From 78fb2fb748005cc0e5d421c322211e199876a229 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Tue, 9 Jun 2026 13:03:25 -0400 Subject: [PATCH 13/27] try type string? --- django_mongodb_backend/constraints.py | 2 +- tests/constraints_/test_unique_indexes.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/django_mongodb_backend/constraints.py b/django_mongodb_backend/constraints.py index 9005a9d3d..4e7b56922 100644 --- a/django_mongodb_backend/constraints.py +++ b/django_mongodb_backend/constraints.py @@ -17,7 +17,7 @@ def _get_partial_unique_filter(field, connection): match db_type: case "string": - return {"$gte": "", "$exists": True} + return {"$type": "string"} case "int": return {"$gte": -2147483648, "$lte": 2147483647} case "long": diff --git a/tests/constraints_/test_unique_indexes.py b/tests/constraints_/test_unique_indexes.py index 61f775eb7..1ce0eadcd 100644 --- a/tests/constraints_/test_unique_indexes.py +++ b/tests/constraints_/test_unique_indexes.py @@ -24,7 +24,7 @@ class Meta: self.assertEqual( dict(index.document["partialFilterExpression"]), - {"name": {"$gte": "", "$exists": True}}, + {"name": {"$type": "string"}}, ) def test_multi_field_unique_index_filter(self): @@ -53,6 +53,6 @@ class Meta: "$gte": -9223372036854775808, "$lte": 9223372036854775807, }, - "name": {"$gte": "", "$exists": True}, + "name": {"$type": "string"}, }, ) From d8ca0396a511a83291219053cdb02a2a7caa9357 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Tue, 9 Jun 2026 14:37:06 -0400 Subject: [PATCH 14/27] revert --- django_mongodb_backend/constraints.py | 2 +- tests/constraints_/test_unique_indexes.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/django_mongodb_backend/constraints.py b/django_mongodb_backend/constraints.py index 4e7b56922..8e9566178 100644 --- a/django_mongodb_backend/constraints.py +++ b/django_mongodb_backend/constraints.py @@ -17,7 +17,7 @@ def _get_partial_unique_filter(field, connection): match db_type: case "string": - return {"$type": "string"} + return {"$gte": ""} case "int": return {"$gte": -2147483648, "$lte": 2147483647} case "long": diff --git a/tests/constraints_/test_unique_indexes.py b/tests/constraints_/test_unique_indexes.py index 1ce0eadcd..bef2de3ea 100644 --- a/tests/constraints_/test_unique_indexes.py +++ b/tests/constraints_/test_unique_indexes.py @@ -24,7 +24,7 @@ class Meta: self.assertEqual( dict(index.document["partialFilterExpression"]), - {"name": {"$type": "string"}}, + {"name": {"$gte": ""}}, ) def test_multi_field_unique_index_filter(self): @@ -53,6 +53,6 @@ class Meta: "$gte": -9223372036854775808, "$lte": 9223372036854775807, }, - "name": {"$type": "string"}, + "name": {"$gte": ""}, }, ) From 7f75ac1ff0fcf607ed3b4b9bfc006cd558e151ef Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Tue, 9 Jun 2026 14:57:57 -0400 Subject: [PATCH 15/27] add print statement --- tests/lookup_/tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index 018004ca5..d9906df2b 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -187,6 +187,7 @@ def test_exact_lookup_uses_partial_unique_index(self): plan = json_util.loads(UniqueAuthor.objects.filter(name="JK Rowling").explain())[ "queryPlanner" ]["winningPlan"] + print("plan:", plan) ixscan = self._find_ixscan(plan) self.assertIsNotNone(ixscan) From 2a5fce48edf00bda91a255f2687c5255f0809911 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Tue, 9 Jun 2026 15:44:21 -0400 Subject: [PATCH 16/27] index agnostic test --- tests/lookup_/tests.py | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index d9906df2b..6049ce699 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -172,14 +172,15 @@ def test_subquery_filter_constant(self): ) -class PartialUniqueIndexLookupTests(TestCase): - def _find_ixscan(self, winning_plan): - plan = winning_plan - while plan: +class IndexLookupTests(TestCase): + def _plan_contains_ixscan(self, plan): + if isinstance(plan, dict): if plan.get("stage") == "IXSCAN": - return plan - plan = plan.get("inputStage") - return None + return True + return any(self._plan_contains_ixscan(value) for value in plan.values()) + if isinstance(plan, list): + return any(self._plan_contains_ixscan(item) for item in plan) + return False def test_exact_lookup_uses_partial_unique_index(self): UniqueAuthor.objects.create(name="JK Rowling") @@ -187,13 +188,9 @@ def test_exact_lookup_uses_partial_unique_index(self): plan = json_util.loads(UniqueAuthor.objects.filter(name="JK Rowling").explain())[ "queryPlanner" ]["winningPlan"] - print("plan:", plan) - ixscan = self._find_ixscan(plan) + print("plan:", plan) - self.assertIsNotNone(ixscan) - self.assertEqual(ixscan["keyPattern"], {"name": 1}) - self.assertTrue(ixscan["isUnique"]) - self.assertTrue(ixscan["isPartial"]) + self.assertTrue(self._plan_contains_ixscan(plan)) def test_compound_exact_lookup_uses_partial_unique_index(self): author = UniqueAuthor.objects.create(name="JK Rowling") @@ -203,10 +200,5 @@ def test_compound_exact_lookup_uses_partial_unique_index(self): plan = json_util.loads(UniqueBook.objects.filter(version=3, name="Harry Potter").explain())[ "queryPlanner" ]["winningPlan"] - ixscan = self._find_ixscan(plan) - self.assertIsNotNone(ixscan) - self.assertEqual(ixscan["indexName"], "unique_book_version") - self.assertEqual(ixscan["keyPattern"], {"version": 1, "name": 1}) - self.assertTrue(ixscan["isUnique"]) - self.assertTrue(ixscan["isPartial"]) + self.assertTrue(self._plan_contains_ixscan(plan)) From 1a86477968503c05a93429854632247e2a552627 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Tue, 9 Jun 2026 15:49:04 -0400 Subject: [PATCH 17/27] remove print statement --- tests/lookup_/tests.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index 6049ce699..8e1c7e604 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -188,7 +188,6 @@ def test_exact_lookup_uses_partial_unique_index(self): plan = json_util.loads(UniqueAuthor.objects.filter(name="JK Rowling").explain())[ "queryPlanner" ]["winningPlan"] - print("plan:", plan) self.assertTrue(self._plan_contains_ixscan(plan)) From 7b3e5f12af830806ddd2eb87622535c74f9df54d Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Tue, 9 Jun 2026 16:41:23 -0400 Subject: [PATCH 18/27] contains IXSCAN --- tests/lookup_/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index 8e1c7e604..9ef669097 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -175,7 +175,7 @@ def test_subquery_filter_constant(self): class IndexLookupTests(TestCase): def _plan_contains_ixscan(self, plan): if isinstance(plan, dict): - if plan.get("stage") == "IXSCAN": + if "IXSCAN" in (plan.get("stage") or ""): return True return any(self._plan_contains_ixscan(value) for value in plan.values()) if isinstance(plan, list): From 56ca21dc35d7d517fcd0dedda3787e6db6258c3d Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Wed, 10 Jun 2026 15:29:54 -0400 Subject: [PATCH 19/27] more tests --- django_mongodb_backend/constraints.py | 23 +++- tests/constraints_/test_unique_indexes.py | 154 ++++++++++++++++++++++ 2 files changed, 172 insertions(+), 5 deletions(-) diff --git a/django_mongodb_backend/constraints.py b/django_mongodb_backend/constraints.py index 8e9566178..2e97079b7 100644 --- a/django_mongodb_backend/constraints.py +++ b/django_mongodb_backend/constraints.py @@ -17,15 +17,28 @@ def _get_partial_unique_filter(field, connection): match db_type: case "string": - return {"$gte": ""} + return {"$gte": ""} # Matches all strings including empty string case "int": - return {"$gte": -2147483648, "$lte": 2147483647} + return { + "$gte": -2147483648, # Min 32-bit integer + "$lte": 2147483647, # Max 32-bit integer + } case "long": return { - "$gte": -9223372036854775808, - "$lte": 9223372036854775807, + "$gte": -9223372036854775808, # Min 64-bit integer + "$lte": 9223372036854775807, # Max 64-bit integer + } + case "decimal": + return { + "$gte": -9223372036854775808, # Min 64-bit integer + "$lte": 9223372036854775807, # Max 64-bit integer + } + case "double": + return { + "$gte": -9223372036854775808, # Min 64-bit integer + "$lte": 9223372036854775807, # Max 64-bit integer } - case "bool": + case "bool": # For consistency return {"$in": [True, False]} case "date": return { diff --git a/tests/constraints_/test_unique_indexes.py b/tests/constraints_/test_unique_indexes.py index bef2de3ea..2905f3024 100644 --- a/tests/constraints_/test_unique_indexes.py +++ b/tests/constraints_/test_unique_indexes.py @@ -1,3 +1,5 @@ +import datetime + from django.db import connection, models from django.test import SimpleTestCase from django.test.utils import isolate_apps @@ -56,3 +58,155 @@ class Meta: "name": {"$gte": ""}, }, ) + + def test_single_field_small_integer_unique_index_filter(self): + class Inventory(models.Model): + count = models.SmallIntegerField(unique=True) + + class Meta: + app_label = "constraints_" + + field = Inventory._meta.get_field("count") + constraint = models.UniqueConstraint(fields=["count"], name="inventory_count_uniq") + + with connection.schema_editor() as editor: + index = constraint.get_pymongo_index_model( + Inventory, + schema_editor=editor, + field=field, + ) + + self.assertEqual( + dict(index.document["partialFilterExpression"]), + { + "count": { + "$gte": -2147483648, + "$lte": 2147483647, + } + }, + ) + + def test_single_field_float_unique_index_filter(self): + class Measurement(models.Model): + value = models.FloatField(unique=True) + + class Meta: + app_label = "constraints_" + + field = Measurement._meta.get_field("value") + constraint = models.UniqueConstraint(fields=["value"], name="measurement_value_uniq") + + with connection.schema_editor() as editor: + index = constraint.get_pymongo_index_model( + Measurement, + schema_editor=editor, + field=field, + ) + + self.assertEqual( + dict(index.document["partialFilterExpression"]), + { + "value": { + "$gte": -9223372036854775808, + "$lte": 9223372036854775807, + } + }, + ) + + def test_single_field_decimal_unique_index_filter(self): + class Price(models.Model): + amount = models.DecimalField(max_digits=6, decimal_places=2, unique=True) + + class Meta: + app_label = "constraints_" + + field = Price._meta.get_field("amount") + constraint = models.UniqueConstraint(fields=["amount"], name="price_amount_uniq") + + with connection.schema_editor() as editor: + index = constraint.get_pymongo_index_model( + Price, + schema_editor=editor, + field=field, + ) + + self.assertEqual( + dict(index.document["partialFilterExpression"]), + { + "amount": { + "$gte": -9223372036854775808, + "$lte": 9223372036854775807, + } + }, + ) + + def test_single_field_boolean_unique_index_filter(self): + class Flag(models.Model): + enabled = models.BooleanField(unique=True) + + class Meta: + app_label = "constraints_" + + field = Flag._meta.get_field("enabled") + constraint = models.UniqueConstraint(fields=["enabled"], name="flag_enabled_uniq") + + with connection.schema_editor() as editor: + index = constraint.get_pymongo_index_model( + Flag, + schema_editor=editor, + field=field, + ) + + self.assertEqual( + dict(index.document["partialFilterExpression"]), + {"enabled": {"$in": [True, False]}}, + ) + + def test_single_field_date_unique_index_filter(self): + class Event(models.Model): + starts_on = models.DateField(unique=True) + + class Meta: + app_label = "constraints_" + + field = Event._meta.get_field("starts_on") + constraint = models.UniqueConstraint(fields=["starts_on"], name="event_starts_on_uniq") + + with connection.schema_editor() as editor: + index = constraint.get_pymongo_index_model( + Event, + schema_editor=editor, + field=field, + ) + + self.assertEqual( + dict(index.document["partialFilterExpression"]), + { + "starts_on": { + "$gte": datetime.datetime.min, + "$lte": datetime.datetime.max, + } + }, + ) + + def test_single_field_fallback_unique_index_filter(self): + class Document(models.Model): + payload = models.JSONField(unique=True) + + class Meta: + app_label = "constraints_" + + field = Document._meta.get_field("payload") + constraint = models.UniqueConstraint(fields=["payload"], name="document_payload_uniq") + + with connection.schema_editor() as editor: + index = constraint.get_pymongo_index_model( + Document, + schema_editor=editor, + field=field, + ) + + self.assertEqual( + dict(index.document["partialFilterExpression"]), + {"payload": {"$type": "object"}}, + ) From d8fec74ab1940b059d73a7ad33157e7aba907a68 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Wed, 10 Jun 2026 15:30:02 -0400 Subject: [PATCH 20/27] documentation --- docs/ref/models/constraints.rst | 8 ++++++++ docs/releases/6.0.x.rst | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/docs/ref/models/constraints.rst b/docs/ref/models/constraints.rst index f7ffc4295..734e8f99e 100644 --- a/docs/ref/models/constraints.rst +++ b/docs/ref/models/constraints.rst @@ -23,6 +23,14 @@ If you wish to only allow one document with a ``NULL`` value, use a Support for :attr:`UniqueConstraint.nulls_distinct ` was added. +.. versionchanged:: 6.0.4 + + More optimized unique constraint filters for exact lookups were added, + allowing the query planner to use indexes for ``string``, ``int``, + ``long``, ``double``, ``decimal``, ``bool``, and ``date`` fields. Unique + constraints that rely on ``$type`` filters aren't used by the query planner. + + MongoDB-specific constraints ============================ diff --git a/docs/releases/6.0.x.rst b/docs/releases/6.0.x.rst index 7a6935d5c..5da07241e 100644 --- a/docs/releases/6.0.x.rst +++ b/docs/releases/6.0.x.rst @@ -32,6 +32,17 @@ Bug fixes - Added debug logging of ``QuerySet.explain()`` queries. +- Ensure unique indexes for ``string``, ``int``, ``long``, ``double``, + ``decimal``, ``bool``, and ``date`` fieldsare being used by the query planner. + +Backwards incompatible changes +------------------------------ + +- Users will need to drop and recreate unique indexes on ``string``, ``int``, + ``long``, ``double``, ``decimal``, ``bool``, and ``date`` fields in order to + benefit from the more optimized unique constraint filters added in 6.0.4. + + 6.0.3 ===== From 594aace8886ddaa404943a21f164545fe6771398 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Wed, 10 Jun 2026 18:04:30 -0400 Subject: [PATCH 21/27] fix type ranges --- django_mongodb_backend/constraints.py | 22 ++++++++++++---------- tests/constraints_/test_unique_indexes.py | 14 ++++++++------ 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/django_mongodb_backend/constraints.py b/django_mongodb_backend/constraints.py index 2e97079b7..422edadb3 100644 --- a/django_mongodb_backend/constraints.py +++ b/django_mongodb_backend/constraints.py @@ -1,6 +1,8 @@ import datetime +import sys from collections import defaultdict +from bson.decimal128 import Decimal128 from django.core import checks from django.core.exceptions import FieldDoesNotExist from django.db.models import UniqueConstraint @@ -20,23 +22,23 @@ def _get_partial_unique_filter(field, connection): return {"$gte": ""} # Matches all strings including empty string case "int": return { - "$gte": -2147483648, # Min 32-bit integer - "$lte": 2147483647, # Max 32-bit integer + "$gte": -2147483648, + "$lte": 2147483647, } case "long": return { - "$gte": -9223372036854775808, # Min 64-bit integer - "$lte": 9223372036854775807, # Max 64-bit integer + "$gte": -sys.float_info.max, + "$lte": sys.float_info.max, } - case "decimal": + case "double": return { - "$gte": -9223372036854775808, # Min 64-bit integer - "$lte": 9223372036854775807, # Max 64-bit integer + "$gte": -sys.float_info.max, + "$lte": sys.float_info.max, } - case "double": + case "decimal": return { - "$gte": -9223372036854775808, # Min 64-bit integer - "$lte": 9223372036854775807, # Max 64-bit integer + "$gte": Decimal128("-9999999999999999999999999999999999E6111"), + "$lte": Decimal128("9999999999999999999999999999999999E6111"), } case "bool": # For consistency return {"$in": [True, False]} diff --git a/tests/constraints_/test_unique_indexes.py b/tests/constraints_/test_unique_indexes.py index 2905f3024..2f62b0cb6 100644 --- a/tests/constraints_/test_unique_indexes.py +++ b/tests/constraints_/test_unique_indexes.py @@ -1,5 +1,7 @@ import datetime +import sys +from bson.decimal128 import Decimal128 from django.db import connection, models from django.test import SimpleTestCase from django.test.utils import isolate_apps @@ -52,8 +54,8 @@ class Meta: dict(index.document["partialFilterExpression"]), { "version": { - "$gte": -9223372036854775808, - "$lte": 9223372036854775807, + "$gte": -sys.float_info.max, + "$lte": sys.float_info.max, }, "name": {"$gte": ""}, }, @@ -107,8 +109,8 @@ class Meta: dict(index.document["partialFilterExpression"]), { "value": { - "$gte": -9223372036854775808, - "$lte": 9223372036854775807, + "$gte": -sys.float_info.max, + "$lte": sys.float_info.max, } }, ) @@ -134,8 +136,8 @@ class Meta: dict(index.document["partialFilterExpression"]), { "amount": { - "$gte": -9223372036854775808, - "$lte": 9223372036854775807, + "$gte": Decimal128("-9999999999999999999999999999999999E6111"), + "$lte": Decimal128("9999999999999999999999999999999999E6111"), } }, ) From 47a61520031c7d1ea905ce16dfbb198276971c68 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Thu, 11 Jun 2026 09:40:59 -0400 Subject: [PATCH 22/27] changelog --- docs/ref/models/constraints.rst | 8 +++----- docs/releases/6.0.x.rst | 9 +++------ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/docs/ref/models/constraints.rst b/docs/ref/models/constraints.rst index 734e8f99e..ddd90b468 100644 --- a/docs/ref/models/constraints.rst +++ b/docs/ref/models/constraints.rst @@ -25,11 +25,9 @@ If you wish to only allow one document with a ``NULL`` value, use a .. versionchanged:: 6.0.4 - More optimized unique constraint filters for exact lookups were added, - allowing the query planner to use indexes for ``string``, ``int``, - ``long``, ``double``, ``decimal``, ``bool``, and ``date`` fields. Unique - constraints that rely on ``$type`` filters aren't used by the query planner. - + Unique constraint filters for exact lookups were added, allowing the query + planner to use indexes for scoped fields. Unique constraints that rely on + ``$type`` filters are still not used by the query planner. MongoDB-specific constraints ============================ diff --git a/docs/releases/6.0.x.rst b/docs/releases/6.0.x.rst index 65a67f747..df757ee43 100644 --- a/docs/releases/6.0.x.rst +++ b/docs/releases/6.0.x.rst @@ -48,9 +48,6 @@ Bug fixes :class:`~django_mongodb_backend.fields.PolymorphicEmbeddedModelArrayField` that specifies ``embedded_models`` as a list of concrete models. -- Ensure unique indexes for ``string``, ``int``, ``long``, ``double``, - ``decimal``, ``bool``, and ``date`` fields are being used by the query planner. - Backwards incompatible changes ------------------------------ @@ -82,9 +79,9 @@ Backwards incompatible changes :setting:`INSTALLED_APPS` setting in order to generate migration operations for embedded models. -- Users will need to drop and recreate unique indexes on ``string``, ``int``, - ``long``, ``double``, ``decimal``, ``bool``, and ``date`` fields in order to - benefit from the more optimized unique constraint filters. +- You will need to drop and recreate unique indexes on scoped fields in order to + ensure unique indexes are used by the query planner. Unique constraints that + rely on ``$type`` filters still are not used by the query planner. 6.0.3 ===== From e429e3651cf6fa4c38611ae36276c13344d840b1 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Thu, 11 Jun 2026 09:41:17 -0400 Subject: [PATCH 23/27] long fix --- django_mongodb_backend/constraints.py | 5 ++--- tests/lookup_/tests.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/django_mongodb_backend/constraints.py b/django_mongodb_backend/constraints.py index 422edadb3..71169005b 100644 --- a/django_mongodb_backend/constraints.py +++ b/django_mongodb_backend/constraints.py @@ -16,7 +16,6 @@ def _get_partial_unique_filter(field, connection): field = getattr(field, "field", field) db_type = field.db_type(connection) - match db_type: case "string": return {"$gte": ""} # Matches all strings including empty string @@ -27,8 +26,8 @@ def _get_partial_unique_filter(field, connection): } case "long": return { - "$gte": -sys.float_info.max, - "$lte": sys.float_info.max, + "$gte": -9223372036854775808, + "$lte": 9223372036854775807, } case "double": return { diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index 9ef669097..1fe357909 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -175,7 +175,7 @@ def test_subquery_filter_constant(self): class IndexLookupTests(TestCase): def _plan_contains_ixscan(self, plan): if isinstance(plan, dict): - if "IXSCAN" in (plan.get("stage") or ""): + if "IXSCAN" in plan.get("stage", ""): return True return any(self._plan_contains_ixscan(value) for value in plan.values()) if isinstance(plan, list): From b0a226d0626b5dfde356f2fd1db87d04da6ab233 Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Thu, 11 Jun 2026 09:59:43 -0400 Subject: [PATCH 24/27] test refactor --- docs/ref/models/constraints.rst | 4 +- docs/releases/6.0.x.rst | 2 +- tests/constraints_/test_unique_indexes.py | 214 ---------------------- tests/lookup_/models.py | 18 -- tests/lookup_/tests.py | 66 +++++-- 5 files changed, 50 insertions(+), 254 deletions(-) delete mode 100644 tests/constraints_/test_unique_indexes.py diff --git a/docs/ref/models/constraints.rst b/docs/ref/models/constraints.rst index ddd90b468..5c922cac8 100644 --- a/docs/ref/models/constraints.rst +++ b/docs/ref/models/constraints.rst @@ -25,8 +25,8 @@ If you wish to only allow one document with a ``NULL`` value, use a .. versionchanged:: 6.0.4 - Unique constraint filters for exact lookups were added, allowing the query - planner to use indexes for scoped fields. Unique constraints that rely on + Unique constraint filters for exact lookups were added, allowing the query + planner to use indexes for scoped fields. Unique constraints that rely on ``$type`` filters are still not used by the query planner. MongoDB-specific constraints diff --git a/docs/releases/6.0.x.rst b/docs/releases/6.0.x.rst index df757ee43..8c759e505 100644 --- a/docs/releases/6.0.x.rst +++ b/docs/releases/6.0.x.rst @@ -80,7 +80,7 @@ Backwards incompatible changes for embedded models. - You will need to drop and recreate unique indexes on scoped fields in order to - ensure unique indexes are used by the query planner. Unique constraints that + ensure unique indexes are used by the query planner. Unique constraints that rely on ``$type`` filters still are not used by the query planner. 6.0.3 diff --git a/tests/constraints_/test_unique_indexes.py b/tests/constraints_/test_unique_indexes.py deleted file mode 100644 index 2f62b0cb6..000000000 --- a/tests/constraints_/test_unique_indexes.py +++ /dev/null @@ -1,214 +0,0 @@ -import datetime -import sys - -from bson.decimal128 import Decimal128 -from django.db import connection, models -from django.test import SimpleTestCase -from django.test.utils import isolate_apps - - -@isolate_apps("constraints_") -class UniqueIndexTests(SimpleTestCase): - def test_single_field_unique_index_filter(self): - class Author(models.Model): - name = models.TextField(unique=True) - - class Meta: - app_label = "constraints_" - - field = Author._meta.get_field("name") - constraint = models.UniqueConstraint(fields=["name"], name="author_name_uniq") - - with connection.schema_editor() as editor: - index = constraint.get_pymongo_index_model( - Author, - schema_editor=editor, - field=field, - ) - - self.assertEqual( - dict(index.document["partialFilterExpression"]), - {"name": {"$gte": ""}}, - ) - - def test_multi_field_unique_index_filter(self): - class Book(models.Model): - version = models.IntegerField() - name = models.TextField() - - class Meta: - app_label = "constraints_" - constraints = [ - models.UniqueConstraint( - fields=["version", "name"], - name="unique_book_version", - ) - ] - - constraint = Book._meta.constraints[0] - - with connection.schema_editor() as editor: - index = constraint.get_pymongo_index_model(Book, schema_editor=editor) - - self.assertEqual( - dict(index.document["partialFilterExpression"]), - { - "version": { - "$gte": -sys.float_info.max, - "$lte": sys.float_info.max, - }, - "name": {"$gte": ""}, - }, - ) - - def test_single_field_small_integer_unique_index_filter(self): - class Inventory(models.Model): - count = models.SmallIntegerField(unique=True) - - class Meta: - app_label = "constraints_" - - field = Inventory._meta.get_field("count") - constraint = models.UniqueConstraint(fields=["count"], name="inventory_count_uniq") - - with connection.schema_editor() as editor: - index = constraint.get_pymongo_index_model( - Inventory, - schema_editor=editor, - field=field, - ) - - self.assertEqual( - dict(index.document["partialFilterExpression"]), - { - "count": { - "$gte": -2147483648, - "$lte": 2147483647, - } - }, - ) - - def test_single_field_float_unique_index_filter(self): - class Measurement(models.Model): - value = models.FloatField(unique=True) - - class Meta: - app_label = "constraints_" - - field = Measurement._meta.get_field("value") - constraint = models.UniqueConstraint(fields=["value"], name="measurement_value_uniq") - - with connection.schema_editor() as editor: - index = constraint.get_pymongo_index_model( - Measurement, - schema_editor=editor, - field=field, - ) - - self.assertEqual( - dict(index.document["partialFilterExpression"]), - { - "value": { - "$gte": -sys.float_info.max, - "$lte": sys.float_info.max, - } - }, - ) - - def test_single_field_decimal_unique_index_filter(self): - class Price(models.Model): - amount = models.DecimalField(max_digits=6, decimal_places=2, unique=True) - - class Meta: - app_label = "constraints_" - - field = Price._meta.get_field("amount") - constraint = models.UniqueConstraint(fields=["amount"], name="price_amount_uniq") - - with connection.schema_editor() as editor: - index = constraint.get_pymongo_index_model( - Price, - schema_editor=editor, - field=field, - ) - - self.assertEqual( - dict(index.document["partialFilterExpression"]), - { - "amount": { - "$gte": Decimal128("-9999999999999999999999999999999999E6111"), - "$lte": Decimal128("9999999999999999999999999999999999E6111"), - } - }, - ) - - def test_single_field_boolean_unique_index_filter(self): - class Flag(models.Model): - enabled = models.BooleanField(unique=True) - - class Meta: - app_label = "constraints_" - - field = Flag._meta.get_field("enabled") - constraint = models.UniqueConstraint(fields=["enabled"], name="flag_enabled_uniq") - - with connection.schema_editor() as editor: - index = constraint.get_pymongo_index_model( - Flag, - schema_editor=editor, - field=field, - ) - - self.assertEqual( - dict(index.document["partialFilterExpression"]), - {"enabled": {"$in": [True, False]}}, - ) - - def test_single_field_date_unique_index_filter(self): - class Event(models.Model): - starts_on = models.DateField(unique=True) - - class Meta: - app_label = "constraints_" - - field = Event._meta.get_field("starts_on") - constraint = models.UniqueConstraint(fields=["starts_on"], name="event_starts_on_uniq") - - with connection.schema_editor() as editor: - index = constraint.get_pymongo_index_model( - Event, - schema_editor=editor, - field=field, - ) - - self.assertEqual( - dict(index.document["partialFilterExpression"]), - { - "starts_on": { - "$gte": datetime.datetime.min, - "$lte": datetime.datetime.max, - } - }, - ) - - def test_single_field_fallback_unique_index_filter(self): - class Document(models.Model): - payload = models.JSONField(unique=True) - - class Meta: - app_label = "constraints_" - - field = Document._meta.get_field("payload") - constraint = models.UniqueConstraint(fields=["payload"], name="document_payload_uniq") - - with connection.schema_editor() as editor: - index = constraint.get_pymongo_index_model( - Document, - schema_editor=editor, - field=field, - ) - - self.assertEqual( - dict(index.document["partialFilterExpression"]), - {"payload": {"$type": "object"}}, - ) diff --git a/tests/lookup_/models.py b/tests/lookup_/models.py index 9cc07cdc4..e91582aa5 100644 --- a/tests/lookup_/models.py +++ b/tests/lookup_/models.py @@ -17,21 +17,3 @@ class Meta: def __str__(self): return str(self.num) - - -class UniqueAuthor(models.Model): - name = models.TextField(unique=True) - - -class UniqueBook(models.Model): - author = models.ForeignKey(UniqueAuthor, on_delete=models.CASCADE) - version = models.IntegerField() - name = models.TextField() - - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["version", "name"], - name="unique_book_version", - ) - ] diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index 1fe357909..adbeb1825 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -1,10 +1,15 @@ +import datetime +from decimal import Decimal + from bson import SON, json_util +from bson.decimal128 import Decimal128 +from django.db import models from django.db.models import Sum from django.test import TestCase from django_mongodb_backend.test import MongoTestCaseMixin -from .models import Book, Number, UniqueAuthor, UniqueBook +from .models import Book, Number class NumericLookupTests(MongoTestCaseMixin, TestCase): @@ -183,21 +188,44 @@ def _plan_contains_ixscan(self, plan): return False def test_exact_lookup_uses_partial_unique_index(self): - UniqueAuthor.objects.create(name="JK Rowling") - - plan = json_util.loads(UniqueAuthor.objects.filter(name="JK Rowling").explain())[ - "queryPlanner" - ]["winningPlan"] - - self.assertTrue(self._plan_contains_ixscan(plan)) - - def test_compound_exact_lookup_uses_partial_unique_index(self): - author = UniqueAuthor.objects.create(name="JK Rowling") - UniqueBook.objects.create(author=author, version=3, name="Harry Potter") - UniqueBook.objects.create(author=author, version=4, name="Harry Potter") - - plan = json_util.loads(UniqueBook.objects.filter(version=3, name="Harry Potter").explain())[ - "queryPlanner" - ]["winningPlan"] - - self.assertTrue(self._plan_contains_ixscan(plan)) + class UniqueFields(models.Model): + text = models.TextField(unique=True, null=True) + small_int = models.SmallIntegerField(unique=True, null=True) + integer = models.IntegerField(unique=True, null=True) + float_value = models.FloatField(unique=True, null=True) + decimal_value = models.DecimalField( + max_digits=6, + decimal_places=2, + unique=True, + null=True, + ) + boolean = models.BooleanField(unique=True, null=True) + date_value = models.DateField(unique=True, null=True) + + class Meta: + app_label = "lookup_" + + row = UniqueFields.objects.create( + text="hello", + small_int=7, + integer=42, + float_value=1.5, + decimal_value=Decimal("12.34"), + boolean=True, + date_value=datetime.date(2024, 1, 1), + ) + cases = [ + ("text", "hello"), + ("small_int", 7), + ("integer", 42), + ("float_value", 1.5), + ("decimal_value", Decimal128("12.34")), + ("boolean", True), + ("date_value", datetime.date(2024, 1, 1)), + ] + for field_name in cases: + with self.subTest(field=field_name): + plan = json_util.loads( + UniqueFields.objects.filter(**{field_name: getattr(row, field_name)}).explain() + )["queryPlanner"]["winningPlan"] + self.assertTrue(self._plan_contains_ixscan(plan)) From 4fcc698b8c3bb4006961ac5845614ef02a9b634f Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Thu, 11 Jun 2026 10:09:08 -0400 Subject: [PATCH 25/27] cleanup --- tests/lookup_/tests.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index adbeb1825..c2e0ce4c7 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -2,7 +2,6 @@ from decimal import Decimal from bson import SON, json_util -from bson.decimal128 import Decimal128 from django.db import models from django.db.models import Sum from django.test import TestCase @@ -194,10 +193,7 @@ class UniqueFields(models.Model): integer = models.IntegerField(unique=True, null=True) float_value = models.FloatField(unique=True, null=True) decimal_value = models.DecimalField( - max_digits=6, - decimal_places=2, - unique=True, - null=True, + max_digits=6, decimal_places=2, unique=True, null=True ) boolean = models.BooleanField(unique=True, null=True) date_value = models.DateField(unique=True, null=True) @@ -214,16 +210,15 @@ class Meta: boolean=True, date_value=datetime.date(2024, 1, 1), ) - cases = [ - ("text", "hello"), - ("small_int", 7), - ("integer", 42), - ("float_value", 1.5), - ("decimal_value", Decimal128("12.34")), - ("boolean", True), - ("date_value", datetime.date(2024, 1, 1)), - ] - for field_name in cases: + for field_name in [ + "text", + "small_int", + "integer", + "float_value", + "decimal_value", + "boolean", + "date_value", + ]: with self.subTest(field=field_name): plan = json_util.loads( UniqueFields.objects.filter(**{field_name: getattr(row, field_name)}).explain() From 0dc6f7ba8663b78f9e5aa6c77275b5b6b7e6483c Mon Sep 17 00:00:00 2001 From: Sophia Yang Date: Thu, 11 Jun 2026 10:49:44 -0400 Subject: [PATCH 26/27] fix --- tests/lookup_/models.py | 18 ++++++++++++++++++ tests/lookup_/tests.py | 17 +---------------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/tests/lookup_/models.py b/tests/lookup_/models.py index e91582aa5..fb7df5dc1 100644 --- a/tests/lookup_/models.py +++ b/tests/lookup_/models.py @@ -17,3 +17,21 @@ class Meta: def __str__(self): return str(self.num) + + +class UniqueFields(models.Model): + text = models.TextField(unique=True, null=True) + small_int = models.SmallIntegerField(unique=True, null=True) + integer = models.IntegerField(unique=True, null=True) + float_value = models.FloatField(unique=True, null=True) + decimal_value = models.DecimalField( + max_digits=6, + decimal_places=2, + unique=True, + null=True, + ) + boolean = models.BooleanField(unique=True, null=True) + date_value = models.DateField(unique=True, null=True) + + class Meta: + app_label = "lookup_" diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index c2e0ce4c7..4d3f895c5 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -2,13 +2,12 @@ from decimal import Decimal from bson import SON, json_util -from django.db import models from django.db.models import Sum from django.test import TestCase from django_mongodb_backend.test import MongoTestCaseMixin -from .models import Book, Number +from .models import Book, Number, UniqueFields class NumericLookupTests(MongoTestCaseMixin, TestCase): @@ -187,20 +186,6 @@ def _plan_contains_ixscan(self, plan): return False def test_exact_lookup_uses_partial_unique_index(self): - class UniqueFields(models.Model): - text = models.TextField(unique=True, null=True) - small_int = models.SmallIntegerField(unique=True, null=True) - integer = models.IntegerField(unique=True, null=True) - float_value = models.FloatField(unique=True, null=True) - decimal_value = models.DecimalField( - max_digits=6, decimal_places=2, unique=True, null=True - ) - boolean = models.BooleanField(unique=True, null=True) - date_value = models.DateField(unique=True, null=True) - - class Meta: - app_label = "lookup_" - row = UniqueFields.objects.create( text="hello", small_int=7, From b404fa30faa747a2c8cb5983920a92d656022d7e Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 11 Jun 2026 16:18:25 -0400 Subject: [PATCH 27/27] Edits --- django_mongodb_backend/constraints.py | 49 ++++++++++++-------- docs/ref/models/constraints.rst | 13 ++++-- docs/releases/6.0.x.rst | 10 ++-- tests/constraints_/models.py | 21 +++++++++ tests/constraints_/test_unique.py | 66 +++++++++++++++++++++++++++ tests/lookup_/models.py | 18 -------- tests/lookup_/tests.py | 43 +---------------- 7 files changed, 136 insertions(+), 84 deletions(-) create mode 100644 tests/constraints_/test_unique.py diff --git a/django_mongodb_backend/constraints.py b/django_mongodb_backend/constraints.py index 71169005b..8341488ac 100644 --- a/django_mongodb_backend/constraints.py +++ b/django_mongodb_backend/constraints.py @@ -2,6 +2,7 @@ import sys from collections import defaultdict +from bson import ObjectId from bson.decimal128 import Decimal128 from django.core import checks from django.core.exceptions import FieldDoesNotExist @@ -14,39 +15,49 @@ def _get_partial_unique_filter(field, connection): - field = getattr(field, "field", field) + """ + Create unique constraints in a way that the query planner can use to avoid + a collection scan. Works for all of the built-in field types except array, + binData, and object. + """ db_type = field.db_type(connection) match db_type: - case "string": - return {"$gte": ""} # Matches all strings including empty string - case "int": + case "bool": + return {"$in": [True, False]} + case "date": return { - "$gte": -2147483648, - "$lte": 2147483647, + "$gte": datetime.datetime.min, + "$lte": datetime.datetime.max, } - case "long": + case "decimal": return { - "$gte": -9223372036854775808, - "$lte": 9223372036854775807, + "$gte": Decimal128("-9999999999999999999999999999999999E6111"), + "$lte": Decimal128("9999999999999999999999999999999999E6111"), } case "double": return { "$gte": -sys.float_info.max, "$lte": sys.float_info.max, } - case "decimal": + case "int": return { - "$gte": Decimal128("-9999999999999999999999999999999999E6111"), - "$lte": Decimal128("9999999999999999999999999999999999E6111"), + "$gte": -2147483648, # 32 bit integer range + "$lte": 2147483647, } - case "bool": # For consistency - return {"$in": [True, False]} - case "date": + case "long": return { - "$gte": datetime.datetime.min, - "$lte": datetime.datetime.max, + "$gte": -9223372036854775808, # 64 bit integer range + "$lte": 9223372036854775807, } - case _: + case "objectId": + return { + "$gte": ObjectId("000000000000000000000000"), + "$lte": ObjectId("ffffffffffffffffffffffff"), + } + case "string": + return {"$gte": ""} # Match all strings, including empty string. + case _: # e.g. array, binData, object + # $type isn't used by the query planner. return {"$type": db_type} @@ -72,7 +83,7 @@ def get_pymongo_index_model(self, model, schema_editor, field=None, column_prefi for field_name in self.fields: field_ = get_field(model, field_name) filter_expression[field_.column].update( - _get_partial_unique_filter(field_, schema_editor.connection) + _get_partial_unique_filter(field_.field, schema_editor.connection) ) if filter_expression: kwargs["partialFilterExpression"] = filter_expression diff --git a/docs/ref/models/constraints.rst b/docs/ref/models/constraints.rst index 5c922cac8..32c563fee 100644 --- a/docs/ref/models/constraints.rst +++ b/docs/ref/models/constraints.rst @@ -18,6 +18,12 @@ If you wish to only allow one document with a ``NULL`` value, use a :class:`~django.db.models.UniqueConstraint` with :attr:`~django.db.models.UniqueConstraint.nulls_distinct` set to ``False``. +The MongoDB query planner will use unique constraints to avoid a collection +scan for queries of all built-in fields except +:class:`~django_mongodb_backend.fields.ArrayField`, +:class:`~django.db.models.BinaryField`, and +:class:`~django.db.models.JSONField`. + .. versionadded:: 6.0.2 Support for :attr:`UniqueConstraint.nulls_distinct @@ -25,9 +31,10 @@ If you wish to only allow one document with a ``NULL`` value, use a .. versionchanged:: 6.0.4 - Unique constraint filters for exact lookups were added, allowing the query - planner to use indexes for scoped fields. Unique constraints that rely on - ``$type`` filters are still not used by the query planner. + The query planner won't use unique constraints created using older versions + of Django MongoDB Backend to avoid a collection scan. If you have a + constraint created using an older version, you'll need to drop and + re-create it if you want to take advantage of the new constraint format. MongoDB-specific constraints ============================ diff --git a/docs/releases/6.0.x.rst b/docs/releases/6.0.x.rst index 8c759e505..0b948bd79 100644 --- a/docs/releases/6.0.x.rst +++ b/docs/releases/6.0.x.rst @@ -79,9 +79,13 @@ Backwards incompatible changes :setting:`INSTALLED_APPS` setting in order to generate migration operations for embedded models. -- You will need to drop and recreate unique indexes on scoped fields in order to - ensure unique indexes are used by the query planner. Unique constraints that - rely on ``$type`` filters still are not used by the query planner. +- Unique constraints are now created in a way that allows the MongoDB query + planner to avoid a collection scan for queries of all built-in fields except + :class:`~django_mongodb_backend.fields.ArrayField`, + :class:`~django.db.models.BinaryField`, and + :class:`~django.db.models.JSONField`. You'll have to drop and recreate + constraints created using older versions of Django MongoDB Backend if you + want to take advantage of this. 6.0.3 ===== diff --git a/tests/constraints_/models.py b/tests/constraints_/models.py index 87433a16e..b5ab50af8 100644 --- a/tests/constraints_/models.py +++ b/tests/constraints_/models.py @@ -1,14 +1,35 @@ from django.db import models from django_mongodb_backend.fields import ( + ArrayField, EmbeddedModelArrayField, EmbeddedModelField, + ObjectIdField, PolymorphicEmbeddedModelArrayField, PolymorphicEmbeddedModelField, ) from django_mongodb_backend.models import EmbeddedModel +class UniqueFields(models.Model): + array_value = ArrayField(models.IntegerField(), unique=True, null=True) + binary = models.BinaryField(unique=True, null=True) + boolean = models.BooleanField(unique=True, null=True) + data = models.JSONField(unique=True, default=dict) + date_value = models.DateField(unique=True, null=True) + decimal_value = models.DecimalField( + max_digits=6, + decimal_places=2, + unique=True, + null=True, + ) + float_value = models.FloatField(unique=True, null=True) + integer = models.IntegerField(unique=True, null=True) + object_id = ObjectIdField(unique=True, null=True) + small_int = models.SmallIntegerField(unique=True, null=True) + text = models.TextField(unique=True, null=True) + + class Data(EmbeddedModel): integer = models.IntegerField(null=True) diff --git a/tests/constraints_/test_unique.py b/tests/constraints_/test_unique.py new file mode 100644 index 000000000..d28bd2377 --- /dev/null +++ b/tests/constraints_/test_unique.py @@ -0,0 +1,66 @@ +import datetime +from decimal import Decimal + +from bson import ObjectId, json_util +from django.db import connection +from django.test import TestCase + +from .models import UniqueFields + + +class UniqueConstraintLookupTests(TestCase): + def _plan_contains_ixscan(self, plan): + if not connection.features.is_mongodb_8_0: # MongoDB 7.0 format. + try: + return plan["inputStage"]["stage"] == "IXSCAN" + except KeyError: + pass + return "IXSCAN" in plan["stage"] + + def test_exact_lookup_uses_partial_unique_index(self): + """ + Unique constraints are created in a way that the query planner will use + to avoid a collection scan. + """ + row = UniqueFields.objects.create( + boolean=True, + date_value=datetime.date(2024, 1, 1), + decimal_value=Decimal("12.34"), + float_value=1.5, + integer=42, + object_id=ObjectId("6754ed8e584bc9ceaae3c072"), + small_int=7, + text="hello", + ) + for field_name in ( + "boolean", + "date_value", + "decimal_value", + "float_value", + "integer", + "object_id", + "small_int", + "text", + ): + with self.subTest(field=field_name): + plan = json_util.loads( + UniqueFields.objects.filter(**{field_name: getattr(row, field_name)}).explain() + )["queryPlanner"]["winningPlan"] + self.assertTrue(self._plan_contains_ixscan(plan)) + + def test_exact_lookup_does_not_use_partial_unique_index(self): + """ + Unique constraints on array, binData, and object aren't used by the + query planner. + """ + row = UniqueFields.objects.create( + array_value=[1, 2, 3], + binary=b"xxx", + data={"a": "b"}, + ) + for field_name in ("array_value", "binary", "data"): + with self.subTest(field=field_name): + plan = json_util.loads( + UniqueFields.objects.filter(**{field_name: getattr(row, field_name)}).explain() + )["queryPlanner"]["winningPlan"] + self.assertFalse(self._plan_contains_ixscan(plan)) diff --git a/tests/lookup_/models.py b/tests/lookup_/models.py index fb7df5dc1..e91582aa5 100644 --- a/tests/lookup_/models.py +++ b/tests/lookup_/models.py @@ -17,21 +17,3 @@ class Meta: def __str__(self): return str(self.num) - - -class UniqueFields(models.Model): - text = models.TextField(unique=True, null=True) - small_int = models.SmallIntegerField(unique=True, null=True) - integer = models.IntegerField(unique=True, null=True) - float_value = models.FloatField(unique=True, null=True) - decimal_value = models.DecimalField( - max_digits=6, - decimal_places=2, - unique=True, - null=True, - ) - boolean = models.BooleanField(unique=True, null=True) - date_value = models.DateField(unique=True, null=True) - - class Meta: - app_label = "lookup_" diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index 4d3f895c5..94b63f833 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -1,13 +1,10 @@ -import datetime -from decimal import Decimal - -from bson import SON, json_util +from bson import SON from django.db.models import Sum from django.test import TestCase from django_mongodb_backend.test import MongoTestCaseMixin -from .models import Book, Number, UniqueFields +from .models import Book, Number class NumericLookupTests(MongoTestCaseMixin, TestCase): @@ -173,39 +170,3 @@ def test_subquery_filter_constant(self): {"$sort": SON([("num", 1)])}, ], ) - - -class IndexLookupTests(TestCase): - def _plan_contains_ixscan(self, plan): - if isinstance(plan, dict): - if "IXSCAN" in plan.get("stage", ""): - return True - return any(self._plan_contains_ixscan(value) for value in plan.values()) - if isinstance(plan, list): - return any(self._plan_contains_ixscan(item) for item in plan) - return False - - def test_exact_lookup_uses_partial_unique_index(self): - row = UniqueFields.objects.create( - text="hello", - small_int=7, - integer=42, - float_value=1.5, - decimal_value=Decimal("12.34"), - boolean=True, - date_value=datetime.date(2024, 1, 1), - ) - for field_name in [ - "text", - "small_int", - "integer", - "float_value", - "decimal_value", - "boolean", - "date_value", - ]: - with self.subTest(field=field_name): - plan = json_util.loads( - UniqueFields.objects.filter(**{field_name: getattr(row, field_name)}).explain() - )["queryPlanner"]["winningPlan"] - self.assertTrue(self._plan_contains_ixscan(plan))