From 6f2052a49b4b8e6abfeeb6eab3493d60c2536d00 Mon Sep 17 00:00:00 2001 From: Jib Date: Thu, 11 Sep 2025 13:21:57 -0400 Subject: [PATCH 01/10] Fix bug in null matching on queryoptimizer --- .../query_conversion/expression_converters.py | 15 ++++++-- .../test_op_expressions.py | 36 +++++++++++++++---- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/django_mongodb_backend/query_conversion/expression_converters.py b/django_mongodb_backend/query_conversion/expression_converters.py index b8362f3e..fdb99b23 100644 --- a/django_mongodb_backend/query_conversion/expression_converters.py +++ b/django_mongodb_backend/query_conversion/expression_converters.py @@ -44,8 +44,12 @@ def convert(cls, args): ): field_name = field_expr[1:] # Remove the $ prefix. if cls.operator == "$eq": - return {field_name: value} - return {field_name: {cls.operator: value}} + query = {field_name: value} + else: + query = {field_name: {cls.operator: value}} + if value is None: + query = {"$and": [{field_name: {"$exists": True}}, query]} + return query return None @@ -102,7 +106,12 @@ def convert(cls, in_args): if isinstance(values, (list, tuple, set)) and all( cls.is_simple_value(v) for v in values ): - return {field_name: {"$in": values}} + core_check = {field_name: {"$in": values}} + return ( + {"$and": [{field_name: {"$exists": True}}, core_check]} + if None in values + else core_check + ) return None diff --git a/tests/expression_converter_/test_op_expressions.py b/tests/expression_converter_/test_op_expressions.py index ce4caf2d..7c60f096 100644 --- a/tests/expression_converter_/test_op_expressions.py +++ b/tests/expression_converter_/test_op_expressions.py @@ -7,6 +7,12 @@ from django_mongodb_backend.query_conversion.expression_converters import convert_expression +def _wrap_condition_if_null(_type, condition, path): + if _type is None: + return {"$and": [{path: {"$exists": True}}, condition]} + return condition + + class ConversionTestCase(SimpleTestCase): CONVERTIBLE_TYPES = { "int": 42, @@ -53,10 +59,14 @@ def test_no_conversion_dict_value(self): self.assertNotOptimizable({"$eq": ["$status", {"$gt": 5}]}) def _test_conversion_valid_type(self, _type): - self.assertConversionEqual({"$eq": ["$age", _type]}, {"age": _type}) + self.assertConversionEqual( + {"$eq": ["$age", _type]}, _wrap_condition_if_null(_type, {"age": _type}, "age") + ) def _test_conversion_valid_array_type(self, _type): - self.assertConversionEqual({"$eq": ["$age", _type]}, {"age": _type}) + self.assertConversionEqual( + {"$eq": ["$age", _type]}, _wrap_condition_if_null(_type, {"age": _type}, "age") + ) def test_conversion_various_types(self): self._test_conversion_various_types(self._test_conversion_valid_type) @@ -78,7 +88,10 @@ def test_no_conversion_dict_value(self): self.assertNotOptimizable({"$in": ["$status", [{"bad": "val"}]]}) def _test_conversion_valid_type(self, _type): - self.assertConversionEqual({"$in": ["$age", [_type]]}, {"age": {"$in": [_type]}}) + self.assertConversionEqual( + {"$in": ["$age", [_type]]}, + _wrap_condition_if_null(_type, {"age": {"$in": [_type]}}, "age"), + ) def test_conversion_various_types(self): for _type, val in self.CONVERTIBLE_TYPES.items(): @@ -170,7 +183,10 @@ def test_no_conversion_dict_value(self): self.assertNotOptimizable({"$gt": ["$price", {}]}) def _test_conversion_valid_type(self, _type): - self.assertConversionEqual({"$gt": ["$price", _type]}, {"price": {"$gt": _type}}) + self.assertConversionEqual( + {"$gt": ["$price", _type]}, + _wrap_condition_if_null(_type, {"price": {"$gt": _type}}, "price"), + ) def test_conversion_various_types(self): self._test_conversion_various_types(self._test_conversion_valid_type) @@ -193,7 +209,7 @@ def test_no_conversion_dict_value(self): def _test_conversion_valid_type(self, _type): expr = {"$gte": ["$price", _type]} expected = {"price": {"$gte": _type}} - self.assertConversionEqual(expr, expected) + self.assertConversionEqual(expr, _wrap_condition_if_null(_type, expected, "price")) def test_conversion_various_types(self): self._test_conversion_various_types(self._test_conversion_valid_type) @@ -210,7 +226,10 @@ def test_no_conversion_dict_value(self): self.assertNotOptimizable({"$lt": ["$price", {}]}) def _test_conversion_valid_type(self, _type): - self.assertConversionEqual({"$lt": ["$price", _type]}, {"price": {"$lt": _type}}) + self.assertConversionEqual( + {"$lt": ["$price", _type]}, + _wrap_condition_if_null(_type, {"price": {"$lt": _type}}, "price"), + ) def test_conversion_various_types(self): self._test_conversion_various_types(self._test_conversion_valid_type) @@ -227,7 +246,10 @@ def test_no_conversion_dict_value(self): self.assertNotOptimizable({"$lte": ["$price", {}]}) def _test_conversion_valid_type(self, _type): - self.assertConversionEqual({"$lte": ["$price", _type]}, {"price": {"$lte": _type}}) + self.assertConversionEqual( + {"$lte": ["$price", _type]}, + _wrap_condition_if_null(_type, {"price": {"$lte": _type}}, "price"), + ) def test_conversion_various_types(self): self._test_conversion_various_types(self._test_conversion_valid_type) From 139d0b426a22d0ac7e0ae0545c33df8c5d7ee2c9 Mon Sep 17 00:00:00 2001 From: Jib Date: Thu, 11 Sep 2025 17:11:59 -0400 Subject: [PATCH 02/10] include none tests for objects.filter --- tests/expression_converter_/models.py | 15 ++++++++ .../test_filter_conversion.py | 34 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 tests/expression_converter_/models.py create mode 100644 tests/expression_converter_/test_filter_conversion.py diff --git a/tests/expression_converter_/models.py b/tests/expression_converter_/models.py new file mode 100644 index 00000000..95470837 --- /dev/null +++ b/tests/expression_converter_/models.py @@ -0,0 +1,15 @@ +from django.db import models + + +class NullableJSONModel(models.Model): + value = models.JSONField(blank=True, null=True) + + class Meta: + required_db_features = {"supports_json_field"} + + +class Tag(models.Model): + name = models.CharField(max_length=10) + + def __str__(self): + return self.name diff --git a/tests/expression_converter_/test_filter_conversion.py b/tests/expression_converter_/test_filter_conversion.py new file mode 100644 index 00000000..8d4d941d --- /dev/null +++ b/tests/expression_converter_/test_filter_conversion.py @@ -0,0 +1,34 @@ +from django.test import TestCase + +from django_mongodb_backend.test import MongoTestCaseMixin + +from .models import NullableJSONModel, Tag + + +class MQLTests(MongoTestCaseMixin, TestCase): + def test_none_filter_nullable_json(self): + with self.assertNumQueries(1) as ctx: + list(NullableJSONModel.objects.filter(value=None)) + self.assertAggregateQuery( + ctx.captured_queries[0]["sql"], + "queries__nullablejsonmodel", + [{"$match": {"$and": [{"$exists": False}, {"value": None}]}}], + ) + + def test_none_filter(self): + with self.assertNumQueries(1) as ctx: + list(Tag.objects.filter(name=None)) + self.assertAggregateQuery( + ctx.captured_queries[0]["sql"], + "queries__nullablejsonmodel", + [ + { + "$match": { + "$or": [ + {"$and": [{"name": {"$exists": True}}, {"name": None}]}, + {"$expr": {"$eq": [{"$type": "$name"}, "missing"]}}, + ] + } + } + ], + ) From fafe4795557b14d9b144539be1e55c918a7ab294 Mon Sep 17 00:00:00 2001 From: Jib Date: Fri, 12 Sep 2025 13:57:57 -0400 Subject: [PATCH 03/10] migrated tests to lookup_ and added data --- tests/expression_converter_/models.py | 15 ---- .../test_filter_conversion.py | 34 --------- tests/lookup_/models.py | 7 ++ tests/lookup_/tests.py | 71 ++++++++++++++++++- 4 files changed, 77 insertions(+), 50 deletions(-) delete mode 100644 tests/expression_converter_/models.py delete mode 100644 tests/expression_converter_/test_filter_conversion.py diff --git a/tests/expression_converter_/models.py b/tests/expression_converter_/models.py deleted file mode 100644 index 95470837..00000000 --- a/tests/expression_converter_/models.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.db import models - - -class NullableJSONModel(models.Model): - value = models.JSONField(blank=True, null=True) - - class Meta: - required_db_features = {"supports_json_field"} - - -class Tag(models.Model): - name = models.CharField(max_length=10) - - def __str__(self): - return self.name diff --git a/tests/expression_converter_/test_filter_conversion.py b/tests/expression_converter_/test_filter_conversion.py deleted file mode 100644 index 8d4d941d..00000000 --- a/tests/expression_converter_/test_filter_conversion.py +++ /dev/null @@ -1,34 +0,0 @@ -from django.test import TestCase - -from django_mongodb_backend.test import MongoTestCaseMixin - -from .models import NullableJSONModel, Tag - - -class MQLTests(MongoTestCaseMixin, TestCase): - def test_none_filter_nullable_json(self): - with self.assertNumQueries(1) as ctx: - list(NullableJSONModel.objects.filter(value=None)) - self.assertAggregateQuery( - ctx.captured_queries[0]["sql"], - "queries__nullablejsonmodel", - [{"$match": {"$and": [{"$exists": False}, {"value": None}]}}], - ) - - def test_none_filter(self): - with self.assertNumQueries(1) as ctx: - list(Tag.objects.filter(name=None)) - self.assertAggregateQuery( - ctx.captured_queries[0]["sql"], - "queries__nullablejsonmodel", - [ - { - "$match": { - "$or": [ - {"$and": [{"name": {"$exists": True}}, {"name": None}]}, - {"$expr": {"$eq": [{"$type": "$name"}, "missing"]}}, - ] - } - } - ], - ) diff --git a/tests/lookup_/models.py b/tests/lookup_/models.py index e91582aa..ff3f8085 100644 --- a/tests/lookup_/models.py +++ b/tests/lookup_/models.py @@ -17,3 +17,10 @@ class Meta: def __str__(self): return str(self.num) + + +class NullableJSONModel(models.Model): + value = models.JSONField(blank=True, null=True) + + class Meta: + required_db_features = {"supports_json_field"} diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index 6fce8994..049b2981 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -1,8 +1,10 @@ +from bson import ObjectId +from django.db import connection from django.test import TestCase from django_mongodb_backend.test import MongoTestCaseMixin -from .models import Book, Number +from .models import Book, NullableJSONModel, Number class NumericLookupTests(TestCase): @@ -66,3 +68,70 @@ def test_eq_and_in(self): "lookup__book", [{"$match": {"$and": [{"isbn": {"$in": ("12345", "56789")}}, {"title": "Moby Dick"}]}}], ) + + +class NullValueLookupTests(MongoTestCaseMixin, TestCase): + _OPERATOR_PREDICATE_MAP = { + "eq": lambda field: {field: None}, + "in": lambda field: {field: {"$in": [None]}}, + } + + @classmethod + def setUpTestData(cls): + cls.book_objs = Book.objects.bulk_create( + Book(title=f"Book {i}", isbn=str(i)) for i in range(5) + ) + + cls.null_objs = NullableJSONModel.objects.bulk_create(NullableJSONModel() for _ in range(5)) + cls.null_objs.append(NullableJSONModel.objects.create(value={"name": None})) + cls.unique_id = ObjectId() + + def _test_none_filter_nullable_json(self, op, predicate, field): + with self.assertNumQueries(1) as ctx: + list(NullableJSONModel.objects.filter(**{f"{field}__{op}": None})) + self.assertAggregateQuery( + ctx.captured_queries[0]["sql"], + "lookup__nullablejsonmodel", + [{"$match": {"$and": [{"$exists": False}, predicate(field)]}}], + ) + self.assertQuerySetEqual( + NullableJSONModel.objects.filter(**{f"{field}__{op}": None}), + [], + ) + + def _test_none_filter_binary_operator(self, op, predicate, field): + with self.assertNumQueries(1) as ctx: + list(Book.objects.filter(**{f"{field}__{op}": None})) + self.assertAggregateQuery( + ctx.captured_queries[0]["sql"], + "lookup__book", + [ + { + "$match": { + "$or": [ + {"$and": [{field: {"$exists": True}}, predicate(field)]}, + {"$expr": {"$eq": [{"$type": f"${field}"}, "missing"]}}, + ] + } + } + ], + ) + self.assertQuerySetEqual(Book.objects.filter(**{f"{field}__{op}": None}), []) + + def _test_with_raw_data(self, model, test_function): + collection = connection.database.get_collection(model._meta.db_table) + try: + collection.insert_one({"_id": self.unique_id}) + + for op, predicate in self._OPERATOR_PREDICATE_MAP.items(): + with self.subTest(op=op): + test_function(op, predicate) + + finally: + collection.delete_one({"_id": self.unique_id}) + + def test_none_filter_nullable_json(self): + self._test_with_raw_data(NullableJSONModel, self._test_none_filter_nullable_json) + + def test_none_filter_binary_operator(self): + self._test_with_raw_data(Book, self._test_none_filter_binary_operator) From 50177bc2871fce17e44528d118b755334234f90c Mon Sep 17 00:00:00 2001 From: Jib Date: Fri, 12 Sep 2025 14:05:10 -0400 Subject: [PATCH 04/10] call filter once in each test --- tests/lookup_/tests.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index 049b2981..9fdda9f8 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -88,20 +88,19 @@ def setUpTestData(cls): def _test_none_filter_nullable_json(self, op, predicate, field): with self.assertNumQueries(1) as ctx: - list(NullableJSONModel.objects.filter(**{f"{field}__{op}": None})) + self.assertQuerySetEqual( + NullableJSONModel.objects.filter(**{f"{field}__{op}": None}), + [], + ) self.assertAggregateQuery( ctx.captured_queries[0]["sql"], "lookup__nullablejsonmodel", [{"$match": {"$and": [{"$exists": False}, predicate(field)]}}], ) - self.assertQuerySetEqual( - NullableJSONModel.objects.filter(**{f"{field}__{op}": None}), - [], - ) def _test_none_filter_binary_operator(self, op, predicate, field): with self.assertNumQueries(1) as ctx: - list(Book.objects.filter(**{f"{field}__{op}": None})) + self.assertQuerySetEqual(Book.objects.filter(**{f"{field}__{op}": None}), []) self.assertAggregateQuery( ctx.captured_queries[0]["sql"], "lookup__book", @@ -116,7 +115,6 @@ def _test_none_filter_binary_operator(self, op, predicate, field): } ], ) - self.assertQuerySetEqual(Book.objects.filter(**{f"{field}__{op}": None}), []) def _test_with_raw_data(self, model, test_function): collection = connection.database.get_collection(model._meta.db_table) From e8011b9b762e1a20a3b663b0f0436a2a861d1a2b Mon Sep 17 00:00:00 2001 From: Jib Date: Fri, 12 Sep 2025 16:50:00 -0400 Subject: [PATCH 05/10] include missing field specifier --- 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 9fdda9f8..19db91a3 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -123,7 +123,7 @@ def _test_with_raw_data(self, model, test_function): for op, predicate in self._OPERATOR_PREDICATE_MAP.items(): with self.subTest(op=op): - test_function(op, predicate) + test_function(op, predicate, "title") finally: collection.delete_one({"_id": self.unique_id}) From 0ca2a5ec97d4a68e668064e522677de5e94464b5 Mon Sep 17 00:00:00 2001 From: Jib Date: Fri, 12 Sep 2025 17:24:14 -0400 Subject: [PATCH 06/10] fix field resolution and value lookup --- tests/lookup_/tests.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index 19db91a3..8c31a458 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -72,7 +72,7 @@ def test_eq_and_in(self): class NullValueLookupTests(MongoTestCaseMixin, TestCase): _OPERATOR_PREDICATE_MAP = { - "eq": lambda field: {field: None}, + "exact": lambda field: {field: None}, "in": lambda field: {field: {"$in": [None]}}, } @@ -89,7 +89,9 @@ def setUpTestData(cls): def _test_none_filter_nullable_json(self, op, predicate, field): with self.assertNumQueries(1) as ctx: self.assertQuerySetEqual( - NullableJSONModel.objects.filter(**{f"{field}__{op}": None}), + NullableJSONModel.objects.filter( + **{f"{field}__{op}": [None] if op == "in" else None} + ), [], ) self.assertAggregateQuery( @@ -100,7 +102,9 @@ def _test_none_filter_nullable_json(self, op, predicate, field): def _test_none_filter_binary_operator(self, op, predicate, field): with self.assertNumQueries(1) as ctx: - self.assertQuerySetEqual(Book.objects.filter(**{f"{field}__{op}": None}), []) + self.assertQuerySetEqual( + Book.objects.filter(**{f"{field}__{op}": [None] if op == "in" else None}), [] + ) self.assertAggregateQuery( ctx.captured_queries[0]["sql"], "lookup__book", @@ -116,20 +120,20 @@ def _test_none_filter_binary_operator(self, op, predicate, field): ], ) - def _test_with_raw_data(self, model, test_function): + def _test_with_raw_data(self, model, test_function, field): collection = connection.database.get_collection(model._meta.db_table) try: collection.insert_one({"_id": self.unique_id}) for op, predicate in self._OPERATOR_PREDICATE_MAP.items(): with self.subTest(op=op): - test_function(op, predicate, "title") + test_function(op, predicate, field) finally: collection.delete_one({"_id": self.unique_id}) def test_none_filter_nullable_json(self): - self._test_with_raw_data(NullableJSONModel, self._test_none_filter_nullable_json) + self._test_with_raw_data(NullableJSONModel, self._test_none_filter_nullable_json, "value") def test_none_filter_binary_operator(self): - self._test_with_raw_data(Book, self._test_none_filter_binary_operator) + self._test_with_raw_data(Book, self._test_none_filter_binary_operator, "title") From 4af823a937eab27d9e868f6f4a93128c471c46c7 Mon Sep 17 00:00:00 2001 From: Jib Date: Mon, 15 Sep 2025 11:40:22 -0400 Subject: [PATCH 07/10] refactored the tests to remove generics --- tests/lookup_/models.py | 7 ++--- tests/lookup_/tests.py | 66 ++++++++++++++++++++++++----------------- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/tests/lookup_/models.py b/tests/lookup_/models.py index ff3f8085..9153b34c 100644 --- a/tests/lookup_/models.py +++ b/tests/lookup_/models.py @@ -2,8 +2,8 @@ class Book(models.Model): - title = models.CharField(max_length=10) - isbn = models.CharField(max_length=13) + title = models.CharField(max_length=10, default=None) + isbn = models.CharField(max_length=13, default=None) def __str__(self): return self.title @@ -21,6 +21,3 @@ def __str__(self): class NullableJSONModel(models.Model): value = models.JSONField(blank=True, null=True) - - class Meta: - required_db_features = {"supports_json_field"} diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index 8c31a458..a14624cb 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -83,28 +83,38 @@ def setUpTestData(cls): ) cls.null_objs = NullableJSONModel.objects.bulk_create(NullableJSONModel() for _ in range(5)) - cls.null_objs.append(NullableJSONModel.objects.create(value={"name": None})) cls.unique_id = ObjectId() + for model in (Book, NullableJSONModel): + collection = connection.database.get_collection(model._meta.db_table) + collection.insert_one({"_id": cls.unique_id}) - def _test_none_filter_nullable_json(self, op, predicate, field): + def test_none_filter_nullable_json_exact(self): with self.assertNumQueries(1) as ctx: self.assertQuerySetEqual( - NullableJSONModel.objects.filter( - **{f"{field}__{op}": [None] if op == "in" else None} - ), - [], + NullableJSONModel.objects.filter(value=None), + self.null_objs[:-1], ) self.assertAggregateQuery( ctx.captured_queries[0]["sql"], "lookup__nullablejsonmodel", - [{"$match": {"$and": [{"$exists": False}, predicate(field)]}}], + [{"$match": {"$and": [{"value": {"$exists": True}}, {"value": None}]}}], ) - def _test_none_filter_binary_operator(self, op, predicate, field): + def test_none_filter_nullable_json_in(self): with self.assertNumQueries(1) as ctx: self.assertQuerySetEqual( - Book.objects.filter(**{f"{field}__{op}": [None] if op == "in" else None}), [] + NullableJSONModel.objects.filter(value__in=[None]), + self.null_objs[:-1], ) + self.assertAggregateQuery( + ctx.captured_queries[0]["sql"], + "lookup__nullablejsonmodel", + [{"$match": {"$and": [{"value": {"$exists": True}}, {"value": {"$in": [None]}}]}}], + ) + + def test_none_filter_binary_operator_exact(self): + with self.assertNumQueries(1) as ctx: + self.assertQuerySetEqual(Book.objects.filter(title=None), []) self.assertAggregateQuery( ctx.captured_queries[0]["sql"], "lookup__book", @@ -112,28 +122,28 @@ def _test_none_filter_binary_operator(self, op, predicate, field): { "$match": { "$or": [ - {"$and": [{field: {"$exists": True}}, predicate(field)]}, - {"$expr": {"$eq": [{"$type": f"${field}"}, "missing"]}}, + {"$and": [{"title": {"$exists": True}}, {"title": None}]}, + {"$expr": {"$eq": [{"$type": "$title"}, "missing"]}}, ] } } ], ) - def _test_with_raw_data(self, model, test_function, field): - collection = connection.database.get_collection(model._meta.db_table) - try: - collection.insert_one({"_id": self.unique_id}) - - for op, predicate in self._OPERATOR_PREDICATE_MAP.items(): - with self.subTest(op=op): - test_function(op, predicate, field) - - finally: - collection.delete_one({"_id": self.unique_id}) - - def test_none_filter_nullable_json(self): - self._test_with_raw_data(NullableJSONModel, self._test_none_filter_nullable_json, "value") - - def test_none_filter_binary_operator(self): - self._test_with_raw_data(Book, self._test_none_filter_binary_operator, "title") + def test_none_filter_binary_operator_in(self): + with self.assertNumQueries(1) as ctx: + self.assertQuerySetEqual(Book.objects.filter(title__in=[None]), []) + self.assertAggregateQuery( + ctx.captured_queries[0]["sql"], + "lookup__book", + [ + { + "$match": { + "$or": [ + {"$and": [{"title": {"$exists": True}}, {"title": None}]}, + {"$expr": {"$eq": [{"$type": "$title"}, "missing"]}}, + ] + } + } + ], + ) From 0161dfba8d05633e9475df2681a51bb670c421bf Mon Sep 17 00:00:00 2001 From: Jib Date: Mon, 15 Sep 2025 11:42:36 -0400 Subject: [PATCH 08/10] remove the CONSTANT dictionary --- tests/lookup_/tests.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index a14624cb..faa0b05f 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -71,11 +71,6 @@ def test_eq_and_in(self): class NullValueLookupTests(MongoTestCaseMixin, TestCase): - _OPERATOR_PREDICATE_MAP = { - "exact": lambda field: {field: None}, - "in": lambda field: {field: {"$in": [None]}}, - } - @classmethod def setUpTestData(cls): cls.book_objs = Book.objects.bulk_create( From 0b292639656b4b2f25a952aeef177bef2a297116 Mon Sep 17 00:00:00 2001 From: Jib Date: Mon, 15 Sep 2025 13:11:12 -0400 Subject: [PATCH 09/10] update tests to support ordering and have a default for self.title --- tests/lookup_/models.py | 5 ++++- tests/lookup_/tests.py | 24 ++++++++++++++---------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/tests/lookup_/models.py b/tests/lookup_/models.py index 9153b34c..55cb24ae 100644 --- a/tests/lookup_/models.py +++ b/tests/lookup_/models.py @@ -5,8 +5,11 @@ class Book(models.Model): title = models.CharField(max_length=10, default=None) isbn = models.CharField(max_length=13, default=None) + class Meta: + ordering = ("title",) + def __str__(self): - return self.title + return self.title or "Title Not Found" class Number(models.Model): diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index faa0b05f..5c780de7 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -85,31 +85,33 @@ def setUpTestData(cls): def test_none_filter_nullable_json_exact(self): with self.assertNumQueries(1) as ctx: - self.assertQuerySetEqual( - NullableJSONModel.objects.filter(value=None), - self.null_objs[:-1], - ) + list(NullableJSONModel.objects.filter(value=None)) self.assertAggregateQuery( ctx.captured_queries[0]["sql"], "lookup__nullablejsonmodel", [{"$match": {"$and": [{"value": {"$exists": True}}, {"value": None}]}}], ) + self.assertQuerySetEqual( + NullableJSONModel.objects.filter(value=None), + self.null_objs[:-1], + ) def test_none_filter_nullable_json_in(self): with self.assertNumQueries(1) as ctx: - self.assertQuerySetEqual( - NullableJSONModel.objects.filter(value__in=[None]), - self.null_objs[:-1], - ) + list(NullableJSONModel.objects.filter(value__in=[None])) self.assertAggregateQuery( ctx.captured_queries[0]["sql"], "lookup__nullablejsonmodel", [{"$match": {"$and": [{"value": {"$exists": True}}, {"value": {"$in": [None]}}]}}], ) + self.assertQuerySetEqual( + NullableJSONModel.objects.filter(value__in=[None]), + self.null_objs[:-1], + ) def test_none_filter_binary_operator_exact(self): with self.assertNumQueries(1) as ctx: - self.assertQuerySetEqual(Book.objects.filter(title=None), []) + list(Book.objects.filter(title=None)) self.assertAggregateQuery( ctx.captured_queries[0]["sql"], "lookup__book", @@ -124,10 +126,11 @@ def test_none_filter_binary_operator_exact(self): } ], ) + self.assertQuerySetEqual(Book.objects.filter(title=None), []) def test_none_filter_binary_operator_in(self): with self.assertNumQueries(1) as ctx: - self.assertQuerySetEqual(Book.objects.filter(title__in=[None]), []) + list(Book.objects.filter(title__in=[None])) self.assertAggregateQuery( ctx.captured_queries[0]["sql"], "lookup__book", @@ -142,3 +145,4 @@ def test_none_filter_binary_operator_in(self): } ], ) + self.assertQuerySetEqual(Book.objects.filter(title__in=[None]), []) From 780289675e903bf7dfc68b4f7e56a0c7a9dec095 Mon Sep 17 00:00:00 2001 From: Jib Date: Mon, 15 Sep 2025 16:17:28 -0400 Subject: [PATCH 10/10] added a string safety-check + allowed unordered lists to be compared --- tests/lookup_/models.py | 7 ++----- tests/lookup_/tests.py | 14 +++++--------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/tests/lookup_/models.py b/tests/lookup_/models.py index 55cb24ae..581ff6a1 100644 --- a/tests/lookup_/models.py +++ b/tests/lookup_/models.py @@ -2,11 +2,8 @@ class Book(models.Model): - title = models.CharField(max_length=10, default=None) - isbn = models.CharField(max_length=13, default=None) - - class Meta: - ordering = ("title",) + title = models.CharField(max_length=10) + isbn = models.CharField(max_length=13) def __str__(self): return self.title or "Title Not Found" diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index 5c780de7..ab1c2922 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -92,22 +92,18 @@ def test_none_filter_nullable_json_exact(self): [{"$match": {"$and": [{"value": {"$exists": True}}, {"value": None}]}}], ) self.assertQuerySetEqual( - NullableJSONModel.objects.filter(value=None), - self.null_objs[:-1], + NullableJSONModel.objects.filter(value=None), self.null_objs[:-1], ordered=False ) def test_none_filter_nullable_json_in(self): with self.assertNumQueries(1) as ctx: - list(NullableJSONModel.objects.filter(value__in=[None])) + objs = list(NullableJSONModel.objects.filter(value__in=[None])) self.assertAggregateQuery( ctx.captured_queries[0]["sql"], "lookup__nullablejsonmodel", [{"$match": {"$and": [{"value": {"$exists": True}}, {"value": {"$in": [None]}}]}}], ) - self.assertQuerySetEqual( - NullableJSONModel.objects.filter(value__in=[None]), - self.null_objs[:-1], - ) + self.assertQuerySetEqual(objs, self.null_objs[:-1], ordered=False) def test_none_filter_binary_operator_exact(self): with self.assertNumQueries(1) as ctx: @@ -126,7 +122,7 @@ def test_none_filter_binary_operator_exact(self): } ], ) - self.assertQuerySetEqual(Book.objects.filter(title=None), []) + self.assertQuerySetEqual(Book.objects.filter(title=None), [], ordered=False) def test_none_filter_binary_operator_in(self): with self.assertNumQueries(1) as ctx: @@ -145,4 +141,4 @@ def test_none_filter_binary_operator_in(self): } ], ) - self.assertQuerySetEqual(Book.objects.filter(title__in=[None]), []) + self.assertQuerySetEqual(Book.objects.filter(title__in=[None]), [], ordered=False)