From 1da1bc15317cacc6517b9bdc585eaeee94f2c37f Mon Sep 17 00:00:00 2001 From: Jib Date: Thu, 21 Aug 2025 16:11:15 -0400 Subject: [PATCH 01/20] INTPYTHON-736 Convert simple $expr queries to $match queries Co-authored-by: Noah Stapp --- django_mongodb_backend/query.py | 5 +- .../query_conversion/__init__.py | 0 .../query_conversion/expression_converters.py | 235 +++++++++++++++ .../query_conversion/query_optimizer.py | 99 +++++++ tests/expression_converter_/__init__.py | 0 .../test_match_conversion.py | 216 ++++++++++++++ .../test_op_expressions.py | 278 ++++++++++++++++++ tests/lookup_/models.py | 1 + tests/lookup_/tests.py | 27 ++ tests/queries_/test_explain.py | 8 +- tests/queries_/test_mql.py | 113 ++----- 11 files changed, 886 insertions(+), 96 deletions(-) create mode 100644 django_mongodb_backend/query_conversion/__init__.py create mode 100644 django_mongodb_backend/query_conversion/expression_converters.py create mode 100644 django_mongodb_backend/query_conversion/query_optimizer.py create mode 100644 tests/expression_converter_/__init__.py create mode 100644 tests/expression_converter_/test_match_conversion.py create mode 100644 tests/expression_converter_/test_op_expressions.py diff --git a/django_mongodb_backend/query.py b/django_mongodb_backend/query.py index 8bad93463..865b4ae1f 100644 --- a/django_mongodb_backend/query.py +++ b/django_mongodb_backend/query.py @@ -11,6 +11,8 @@ from django.db.models.sql.where import AND, OR, XOR, ExtraWhere, NothingNode, WhereNode from pymongo.errors import BulkWriteError, DuplicateKeyError, PyMongoError +from django_mongodb_backend.query_conversion.query_optimizer import QueryOptimizer + def wrap_database_errors(func): @wraps(func) @@ -55,6 +57,7 @@ def __init__(self, compiler): # $lookup stage that encapsulates the pipeline for performing a nested # subquery. self.subquery_lookup = None + self.query_optimizer = QueryOptimizer() def __repr__(self): return f"" @@ -87,7 +90,7 @@ def get_pipeline(self): for query in self.subqueries or (): pipeline.extend(query.get_pipeline()) if self.match_mql: - pipeline.append({"$match": self.match_mql}) + pipeline.extend(self.query_optimizer.convert_expr_to_match(self.match_mql)) if self.aggregation_pipeline: pipeline.extend(self.aggregation_pipeline) if self.project_fields: diff --git a/django_mongodb_backend/query_conversion/__init__.py b/django_mongodb_backend/query_conversion/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/django_mongodb_backend/query_conversion/expression_converters.py b/django_mongodb_backend/query_conversion/expression_converters.py new file mode 100644 index 000000000..3c01ce13b --- /dev/null +++ b/django_mongodb_backend/query_conversion/expression_converters.py @@ -0,0 +1,235 @@ +"""Expression To Match Converters""" + + +class BaseConverter: + """ + Base class for optimizers that handle specific operations in MQL queries. + """ + + @classmethod + def convert(cls, expr): + raise NotImplementedError("Subclasses must implement this method.") + + @classmethod + def is_simple_value(cls, value): + """Is the value is a simple type (not a dict)?""" + if value is None: + return True + if isinstance(value, str) and value.startswith("$"): + return False + if isinstance(value, (list, tuple, set)): + return all(cls.is_simple_value(v) for v in value) + # TODO: Support `$getField` conversion. + return not isinstance(value, dict) + + +class BinaryConverter(BaseConverter): + """ + Base class for optimizers that handle binary expression operations in MQL queries. + """ + + operator: str + + @classmethod + def convert(cls, args): + if isinstance(args, list) and len(args) == 2: + field_expr, value = args + # Check if first argument is a simple field reference. + if ( + isinstance(field_expr, str) + and field_expr.startswith("$") + and cls.is_simple_value(value) + ): + field_name = field_expr[1:] # Remove the $ prefix. + if cls.operator == "$eq": + return {field_name: value} + return {field_name: {cls.operator: value}} + + return None + + +class EqConverter(BinaryConverter): + """Convert $eq operation to a $match compatible query. + + For example:: + "$expr": { + {"$eq": ["$status", "active"]} + } + is converted to:: + {"status": "active"} + """ + + operator = "$eq" + + +class GtConverter(BinaryConverter): + """Convert $gt operation to a $match compatible query. + + For example:: + "$expr": { + {"$gt": ["$price", 100]} + } + is converted to:: + {"$gt": ["price", 100]} + """ + + operator = "$gt" + + +class GteConverter(BinaryConverter): + """Convert $gte operation to a $match compatible query. + + For example:: + "$expr": { + {"$gte": ["$price", 100]} + } + is converted to:: + {"price": {"$gte", 100}} + """ + + operator = "$gte" + + +class LtConverter(BinaryConverter): + """Convert $lt operation to a $match compatible query. + + For example:: + "$expr": { + {"$lt": ["$price", 100]} + } + is converted to:: + {"$lt": ["price", 100]} + """ + + operator = "$lt" + + +class LteConverter(BinaryConverter): + """Convert $lte operation to a $match compatible query. + + For example:: + "$expr": { + {"$lte": ["$price", 100]} + } + is converted to:: + {"price": {"$lte", 100}} + """ + + operator = "$lte" + + +class InConverter(BaseConverter): + """Convert $in operation to a $match compatible query. + + For example:: + "$expr": { + {"$in": ["$category", ["electronics", "books"]]} + } + is converted to:: + {"category": {"$in": ["electronics", "books"]}} + """ + + @classmethod + def convert(cls, in_args): + if isinstance(in_args, list) and len(in_args) == 2: + field_expr, values = in_args + + # Check if first argument is a simple field reference + if isinstance(field_expr, str) and field_expr.startswith("$"): + field_name = field_expr[1:] # Remove the $ prefix + if isinstance(values, (list, tuple, set)) and all( + cls.is_simple_value(v) for v in values + ): + return {field_name: {"$in": values}} + + return None + + +class LogicalConverter(BaseConverter): + """Generic for converting logical operations to a $match compatible query.""" + + @classmethod + def convert(cls, combined_conditions): + if isinstance(combined_conditions, list): + optimized_conditions = [] + for condition in combined_conditions: + if isinstance(condition, dict) and len(condition) == 1: + if optimized_condition := convert_expression(condition): + optimized_conditions.append(optimized_condition) + else: + # Any failure should stop optimization + return None + if optimized_conditions: + return {cls._logical_op: optimized_conditions} + return None + + +class OrConverter(LogicalConverter): + """Convert $or operation to a $match compatible query. + + For example:: + "$expr": { + "$or": [ + {"$eq": ["$status", "active"]}, + {"$in": ["$category", ["electronics", "books"]]}, + ] + } + is converted to:: + "$or": [ + {"status": "active"}, + {"category": {"$in": ["electronics", "books"]}}, + ] + """ + + _logical_op = "$or" + + +class AndConverter(LogicalConverter): + """Convert $and operation to a $match compatible query. + + For example:: + "$expr": { + "$and": [ + {"$eq": ["$status", "active"]}, + {"$in": ["$category", ["electronics", "books"]]}, + {"$eq": ["$verified", True]}, + ] + } + is converted to:: + "$and": [ + {"status": "active"}, + {"category": {"$in": ["electronics", "books"]}}, + {"verified": True}, + ] + """ + + _logical_op = "$and" + + +OPTIMIZABLE_OPS = { + "$eq": EqConverter, + "$in": InConverter, + "$and": AndConverter, + "$or": OrConverter, + "$gt": GtConverter, + "$gte": GteConverter, + "$lt": LtConverter, + "$lte": LteConverter, +} + + +def convert_expression(expr): + """ + Optimize an MQL expression by extracting optimizable conditions. + + Args: + expr: Dictionary containing the MQL expression + + Returns: + Optimized match condition or None if not optimizable + """ + if isinstance(expr, dict) and len(expr) == 1: + op = next(iter(expr.keys())) + if op in OPTIMIZABLE_OPS: + return OPTIMIZABLE_OPS[op].convert(expr[op]) + return None diff --git a/django_mongodb_backend/query_conversion/query_optimizer.py b/django_mongodb_backend/query_conversion/query_optimizer.py new file mode 100644 index 000000000..6c0af11be --- /dev/null +++ b/django_mongodb_backend/query_conversion/query_optimizer.py @@ -0,0 +1,99 @@ +from copy import deepcopy + +from django_mongodb_backend.query_conversion.expression_converters import convert_expression + + +class QueryOptimizer: + def convert_expr_to_match(self, expr): + """ + Takes an MQL query with $expr and optimizes it by extracting + optimizable conditions into separate $match stages. + + Args: + expr_query: Dictionary containing the $expr query + + Returns: + List of optimized match conditions + """ + expr_query = deepcopy(expr) + + if "$expr" not in expr_query: + return [expr_query] + + if expr_query["$expr"] == {}: + return [{"$match": {}}] + + expr_content = expr_query["$expr"] + + # Handle the expression content + return self._process_expression(expr_content) + + def _process_expression(self, expr): + """ + Process an expression and extract optimizable conditions. + + Args: + expr: The expression to process + """ + match_conditions = [] + remaining_conditions = [] + if isinstance(expr, dict): + # Check if this is an $and operation + has_and = "$and" in expr + has_or = "$or" in expr + # Do a top-level check for $and or $or because these should inform + # If they fail, they should failover to a remaining conditions list + # There's probably a better way to do this, but this is a start + if has_and: + and_match_conditions = self._process_logical_conditions("$and", expr["$and"]) + match_conditions.extend(and_match_conditions) + if has_or: + or_match_conditions = self._process_logical_conditions("$or", expr["$or"]) + match_conditions.extend(or_match_conditions) + if not has_and and not has_or: + # Process single condition + optimized = convert_expression(expr) + if optimized: + match_conditions.append({"$match": optimized}) + else: + remaining_conditions.append({"$match": {"$expr": expr}}) + else: + # Can't optimize + remaining_conditions.append({"$expr": expr}) + return match_conditions + remaining_conditions + + def _process_logical_conditions(self, logical_op, logical_conditions): + """ + Process conditions within a logical array. + + Args: + logical_conditions: List of conditions within logical operator + """ + optimized_conditions = [] + match_conditions = [] + remaining_conditions = [] + for condition in logical_conditions: + _remaining_conditions = [] + if isinstance(condition, dict): + if optimized := convert_expression(condition): + optimized_conditions.append(optimized) + else: + _remaining_conditions.append(condition) + else: + _remaining_conditions.append(condition) + if _remaining_conditions: + # Any expressions we can't optimize must remain + # in an $expr that preserves the logical operator + if len(_remaining_conditions) > 1: + remaining_conditions.append({"$expr": {logical_op: _remaining_conditions}}) + else: + remaining_conditions.append({"$expr": _remaining_conditions[0]}) + if optimized_conditions: + optimized_conditions.extend(remaining_conditions) + if len(optimized_conditions) > 1: + match_conditions.append({"$match": {logical_op: optimized_conditions}}) + else: + match_conditions.append({"$match": optimized_conditions[0]}) + else: + match_conditions.append({"$match": {logical_op: remaining_conditions}}) + return match_conditions diff --git a/tests/expression_converter_/__init__.py b/tests/expression_converter_/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/expression_converter_/test_match_conversion.py b/tests/expression_converter_/test_match_conversion.py new file mode 100644 index 000000000..42d8cccb6 --- /dev/null +++ b/tests/expression_converter_/test_match_conversion.py @@ -0,0 +1,216 @@ +from django.test import SimpleTestCase + +from django_mongodb_backend.query_conversion.query_optimizer import QueryOptimizer + + +class QueryOptimizerTests(SimpleTestCase): + def assertOptimizerEqual(self, input, expected): + result = QueryOptimizer().convert_expr_to_match(input) + self.assertEqual(result, expected) + + def test_multiple_optimizable_conditions(self): + expr = { + "$expr": { + "$and": [ + {"$eq": ["$status", "active"]}, + {"$in": ["$category", ["electronics", "books"]]}, + {"$eq": ["$verified", True]}, + {"$gte": ["$price", 50]}, + ] + } + } + expected = [ + { + "$match": { + "$and": [ + {"status": "active"}, + {"category": {"$in": ["electronics", "books"]}}, + {"verified": True}, + {"price": {"$gte": 50}}, + ] + } + } + ] + self.assertOptimizerEqual(expr, expected) + + def test_mixed_optimizable_and_non_optimizable_conditions(self): + expr = { + "$expr": { + "$and": [ + {"$eq": ["$status", "active"]}, + {"$gt": ["$price", "$min_price"]}, # Not optimizable + {"$in": ["$category", ["electronics"]]}, + ] + } + } + expected = [ + { + "$match": { + "$and": [ + {"status": "active"}, + {"category": {"$in": ["electronics"]}}, + {"$expr": {"$gt": ["$price", "$min_price"]}}, + ], + } + } + ] + self.assertOptimizerEqual(expr, expected) + + def test_non_optimizable_condition(self): + expr = {"$expr": {"$gt": ["$price", "$min_price"]}} + expected = [ + { + "$match": { + "$expr": {"$gt": ["$price", "$min_price"]}, + } + } + ] + self.assertOptimizerEqual(expr, expected) + + def test_nested_logical_conditions(self): + expr = { + "$expr": { + "$or": [ + {"$eq": ["$status", "active"]}, + {"$in": ["$category", ["electronics", "books"]]}, + {"$and": [{"$eq": ["$verified", True]}, {"$lte": ["$price", 50]}]}, + ] + } + } + expected = [ + { + "$match": { + "$or": [ + {"status": "active"}, + {"category": {"$in": ["electronics", "books"]}}, + {"$and": [{"verified": True}, {"price": {"$lte": 50}}]}, + ] + } + } + ] + self.assertOptimizerEqual(expr, expected) + + def test_complex_nested_with_non_optimizable_parts(self): + expr = { + "$expr": { + "$and": [ + { + "$or": [ + {"$eq": ["$status", "active"]}, + {"$gt": ["$views", 1000]}, + ] + }, + {"$in": ["$category", ["electronics", "books"]]}, + {"$eq": ["$verified", True]}, + {"$gt": ["$price", "$min_price"]}, # Not optimizable + ] + } + } + expected = [ + { + "$match": { + "$and": [ + { + "$or": [ + {"status": "active"}, + {"views": {"$gt": 1000}}, + ] + }, + {"category": {"$in": ["electronics", "books"]}}, + {"verified": True}, + {"$expr": {"$gt": ["$price", "$min_price"]}}, + ] + } + } + ] + self.assertOptimizerEqual(expr, expected) + + def test_london_in_case(self): + expr = {"$expr": {"$in": ["$author_city", ["London"]]}} + expected = [{"$match": {"author_city": {"$in": ["London"]}}}] + self.assertOptimizerEqual(expr, expected) + + def test_deeply_nested_logical_operators(self): + expr = { + "$expr": { + "$and": [ + { + "$or": [ + {"$eq": ["$type", "premium"]}, + { + "$and": [ + {"$eq": ["$type", "standard"]}, + {"$in": ["$region", ["US", "CA"]]}, + ] + }, + ] + }, + {"$eq": ["$active", True]}, + ] + } + } + expected = [ + { + "$match": { + "$and": [ + { + "$or": [ + {"type": "premium"}, + { + "$and": [ + {"type": "standard"}, + {"region": {"$in": ["US", "CA"]}}, + ] + }, + ] + }, + {"active": True}, + ] + } + } + ] + self.assertOptimizerEqual(expr, expected) + + def test_deeply_nested_logical_operator_with_variable(self): + expr = { + "$expr": { + "$and": [ + { + "$or": [ + {"$eq": ["$type", "premium"]}, + { + "$and": [ + # Not optimizable because of Variable + {"$eq": ["$type", "$$standard"]}, + {"$in": ["$region", ["US", "CA"]]}, + ] + }, + ] + }, + {"$eq": ["$active", True]}, + ] + } + } + expected = [ + { + "$match": { + "$and": [ + {"active": True}, + { + "$expr": { + "$or": [ + {"$eq": ["$type", "premium"]}, + { + "$and": [ + {"$eq": ["$type", "$$standard"]}, + {"$in": ["$region", ["US", "CA"]]}, + ] + }, + ] + } + }, + ] + } + } + ] + self.assertOptimizerEqual(expr, expected) diff --git a/tests/expression_converter_/test_op_expressions.py b/tests/expression_converter_/test_op_expressions.py new file mode 100644 index 000000000..ba86bf776 --- /dev/null +++ b/tests/expression_converter_/test_op_expressions.py @@ -0,0 +1,278 @@ +import datetime +from uuid import UUID + +from bson import Decimal128 +from django.test import SimpleTestCase + +from django_mongodb_backend.query_conversion.expression_converters import convert_expression + + +class ExpressionConversionTestCase(SimpleTestCase): + CONVERTIBLE_TYPES = { + "int": 42, + "float": 3.14, + "decimal128": Decimal128("3.14"), + "boolean": True, + "NoneType": None, + "string": "string", + "datetime": datetime.datetime.now(datetime.timezone.utc), + "duration": datetime.timedelta(days=5, hours=3), + "uuid": UUID("12345678123456781234567812345678"), + } + + def assertConversionEqual(self, input, expected): + result = convert_expression(input) + self.assertEqual(result, expected) + + def assertNotOptimizable(self, input): + result = convert_expression(input) + self.assertIsNone(result) + + def _test_conversion_various_types(self, conversion_test): + for _type, val in self.CONVERTIBLE_TYPES.items(): + with self.subTest(_type=_type, val=val): + conversion_test(val) + + +class TestExpressionConversion(ExpressionConversionTestCase): + def test_non_dict_expression(self): + expr = ["$status", "active"] + self.assertNotOptimizable(expr) + + def test_empty_dict_expression(self): + expr = {} + self.assertNotOptimizable(expr) + + +class TestEqExprConversion(ExpressionConversionTestCase): + def test_eq_conversion(self): + expr = {"$eq": ["$status", "active"]} + expected = {"status": "active"} + self.assertConversionEqual(expr, expected) + + def test_eq_no_conversion_non_string_field(self): + expr = {"$eq": [123, "active"]} + self.assertNotOptimizable(expr) + + def test_eq_no_conversion_dict_value(self): + expr = {"$eq": ["$status", {"$gt": 5}]} + self.assertNotOptimizable(expr) + + def _test_eq_conversion_valid_type(self, _type): + expr = {"$eq": ["$age", _type]} + expected = {"age": _type} + self.assertConversionEqual(expr, expected) + + def _test_eq_conversion_valid_array_type(self, _type): + expr = {"$eq": ["$age", _type]} + expected = {"age": _type} + self.assertConversionEqual(expr, expected) + + def test_eq_conversion_various_types(self): + self._test_conversion_various_types(self._test_eq_conversion_valid_type) + + def test_eq_conversion_various_array_types(self): + self._test_conversion_various_types(self._test_eq_conversion_valid_array_type) + + +class TestInExprConversion(ExpressionConversionTestCase): + def test_in_conversion(self): + expr = {"$in": ["$category", ["electronics", "books", "clothing"]]} + expected = {"category": {"$in": ["electronics", "books", "clothing"]}} + self.assertConversionEqual(expr, expected) + + def test_in_no_conversion_non_string_field(self): + expr = {"$in": [123, ["electronics", "books"]]} + self.assertNotOptimizable(expr) + + def test_in_no_conversion_dict_value(self): + expr = {"$in": ["$status", [{"bad": "val"}]]} + self.assertNotOptimizable(expr) + + def _test_in_conversion_valid_type(self, _type): + expr = { + "$in": [ + "$age", + [ + _type, + ], + ] + } + expected = { + "age": { + "$in": [ + _type, + ] + } + } + self.assertConversionEqual(expr, expected) + + def test_in_conversion_various_types(self): + for _type, val in self.CONVERTIBLE_TYPES.items(): + with self.subTest(_type=_type, val=val): + self._test_in_conversion_valid_type(val) + + +class TestLogicalExpressionConversion(ExpressionConversionTestCase): + def test_logical_and_conversion(self): + expr = { + "$and": [ + {"$eq": ["$status", "active"]}, + {"$in": ["$category", ["electronics", "books"]]}, + {"$eq": ["$verified", True]}, + ] + } + expected = { + "$and": [ + {"status": "active"}, + {"category": {"$in": ["electronics", "books"]}}, + {"verified": True}, + ] + } + self.assertConversionEqual(expr, expected) + + def test_logical_or_conversion(self): + expr = { + "$or": [ + {"$eq": ["$status", "active"]}, + {"$in": ["$category", ["electronics", "books"]]}, + ] + } + expected = { + "$or": [ + {"status": "active"}, + {"category": {"$in": ["electronics", "books"]}}, + ] + } + self.assertConversionEqual(expr, expected) + + def test_logical_or_conversion_failure(self): + expr = { + "$or": [ + {"$eq": ["$status", "active"]}, + {"$in": ["$category", ["electronics", "books"]]}, + { + "$and": [ + {"verified": True}, + {"$gt": ["$price", "$min_price"]}, # Not optimizable + ] + }, + ] + } + self.assertNotOptimizable(expr) + + def test_logical_mixed_conversion(self): + expr = { + "$and": [ + { + "$or": [ + {"$eq": ["$status", "active"]}, + {"$gt": ["$views", 1000]}, + ] + }, + {"$in": ["$category", ["electronics", "books"]]}, + {"$eq": ["$verified", True]}, + {"$lte": ["$price", 2000]}, + ] + } + expected = { + "$and": [ + {"$or": [{"status": "active"}, {"views": {"$gt": 1000}}]}, + {"category": {"$in": ["electronics", "books"]}}, + {"verified": True}, + {"price": {"$lte": 2000}}, + ] + } + self.assertConversionEqual(expr, expected) + + +class TestGtExpressionConversion(ExpressionConversionTestCase): + def test_gt_conversion(self): + expr = {"$gt": ["$price", 100]} + expected = {"price": {"$gt": 100}} + self.assertConversionEqual(expr, expected) + + def test_gt_no_conversion_non_simple_field(self): + expr = {"$gt": ["$price", "$min_price"]} + self.assertNotOptimizable(expr) + + def test_gt_no_conversion_dict_value(self): + expr = {"$gt": ["$price", {}]} + self.assertNotOptimizable(expr) + + def _test_gt_conversion_valid_type(self, _type): + expr = {"$gt": ["$price", _type]} + expected = {"price": {"$gt": _type}} + self.assertConversionEqual(expr, expected) + + def test_gt_conversion_various_types(self): + self._test_conversion_various_types(self._test_gt_conversion_valid_type) + + +class TestGteExpressionConversion(ExpressionConversionTestCase): + def test_gte_conversion(self): + expr = {"$gte": ["$price", 100]} + expected = {"price": {"$gte": 100}} + self.assertConversionEqual(expr, expected) + + def test_gte_no_conversion_non_simple_field(self): + expr = {"$gte": ["$price", "$min_price"]} + self.assertNotOptimizable(expr) + + def test_gte_no_conversion_dict_value(self): + expr = {"$gte": ["$price", {}]} + self.assertNotOptimizable(expr) + + def _test_gte_conversion_valid_type(self, _type): + expr = {"$gte": ["$price", _type]} + expected = {"price": {"$gte": _type}} + self.assertConversionEqual(expr, expected) + + def test_gte_conversion_various_types(self): + self._test_conversion_various_types(self._test_gte_conversion_valid_type) + + +class TestLtExpressionConversion(ExpressionConversionTestCase): + def test_lt_conversion(self): + expr = {"$lt": ["$price", 100]} + expected = {"price": {"$lt": 100}} + self.assertConversionEqual(expr, expected) + + def test_lt_no_conversion_non_simple_field(self): + expr = {"$lt": ["$price", "$min_price"]} + self.assertNotOptimizable(expr) + + def test_lt_no_conversion_dict_value(self): + expr = {"$lt": ["$price", {}]} + self.assertNotOptimizable(expr) + + def _test_lt_conversion_valid_type(self, _type): + expr = {"$lt": ["$price", _type]} + expected = {"price": {"$lt": _type}} + self.assertConversionEqual(expr, expected) + + def test_lt_conversion_various_types(self): + self._test_conversion_various_types(self._test_lt_conversion_valid_type) + + +class TestLteExpressionConversion(ExpressionConversionTestCase): + def test_lte_conversion(self): + expr = {"$lte": ["$price", 100]} + expected = {"price": {"$lte": 100}} + self.assertConversionEqual(expr, expected) + + def test_lte_no_conversion_non_simple_field(self): + expr = {"$lte": ["$price", "$min_price"]} + self.assertNotOptimizable(expr) + + def test_lte_no_conversion_dict_value(self): + expr = {"$lte": ["$price", {}]} + self.assertNotOptimizable(expr) + + def _test_lte_conversion_valid_type(self, _type): + expr = {"$lte": ["$price", _type]} + expected = {"price": {"$lte": _type}} + self.assertConversionEqual(expr, expected) + + def test_lte_conversion_various_types(self): + self._test_conversion_various_types(self._test_lte_conversion_valid_type) diff --git a/tests/lookup_/models.py b/tests/lookup_/models.py index 61a99d8ab..e91582aa5 100644 --- a/tests/lookup_/models.py +++ b/tests/lookup_/models.py @@ -3,6 +3,7 @@ class Book(models.Model): title = models.CharField(max_length=10) + isbn = models.CharField(max_length=13) def __str__(self): return self.title diff --git a/tests/lookup_/tests.py b/tests/lookup_/tests.py index feff97aa0..6fce89942 100644 --- a/tests/lookup_/tests.py +++ b/tests/lookup_/tests.py @@ -39,3 +39,30 @@ def test_mql(self): } ], ) + + +class LookupMQLTests(MongoTestCaseMixin, TestCase): + def test_eq(self): + with self.assertNumQueries(1) as ctx: + list(Book.objects.filter(title="Moby Dick")) + self.assertAggregateQuery( + ctx.captured_queries[0]["sql"], "lookup__book", [{"$match": {"title": "Moby Dick"}}] + ) + + def test_in(self): + with self.assertNumQueries(1) as ctx: + list(Book.objects.filter(title__in=["Moby Dick"])) + self.assertAggregateQuery( + ctx.captured_queries[0]["sql"], + "lookup__book", + [{"$match": {"title": {"$in": ("Moby Dick",)}}}], + ) + + def test_eq_and_in(self): + with self.assertNumQueries(1) as ctx: + list(Book.objects.filter(title="Moby Dick", isbn__in=["12345", "56789"])) + self.assertAggregateQuery( + ctx.captured_queries[0]["sql"], + "lookup__book", + [{"$match": {"$and": [{"isbn": {"$in": ("12345", "56789")}}, {"title": "Moby Dick"}]}}], + ) diff --git a/tests/queries_/test_explain.py b/tests/queries_/test_explain.py index d0e964150..6b74379b7 100644 --- a/tests/queries_/test_explain.py +++ b/tests/queries_/test_explain.py @@ -20,9 +20,7 @@ def test_object_id(self): id = ObjectId() result = Author.objects.filter(id=id).explain() parsed = json_util.loads(result) - self.assertEqual( - parsed["command"]["pipeline"], [{"$match": {"$expr": {"$eq": ["$_id", id]}}}] - ) + self.assertEqual(parsed["command"]["pipeline"], [{"$match": {"_id": id}}]) def test_non_ascii(self): """The json is dumped with ensure_ascii=False.""" @@ -32,6 +30,4 @@ def test_non_ascii(self): # non-ASCII characters. self.assertIn(name, result) parsed = json.loads(result) - self.assertEqual( - parsed["command"]["pipeline"], [{"$match": {"$expr": {"$eq": ["$name", name]}}}] - ) + self.assertEqual(parsed["command"]["pipeline"], [{"$match": {"name": name}}]) diff --git a/tests/queries_/test_mql.py b/tests/queries_/test_mql.py index c17ed3c21..ffd1e2e32 100644 --- a/tests/queries_/test_mql.py +++ b/tests/queries_/test_mql.py @@ -12,7 +12,7 @@ def test_all(self): with self.assertNumQueries(1) as ctx: list(Author.objects.all()) self.assertAggregateQuery( - ctx.captured_queries[0]["sql"], "queries__author", [{"$match": {"$expr": {}}}] + ctx.captured_queries[0]["sql"], "queries__author", [{"$match": {}}] ) def test_join(self): @@ -42,7 +42,7 @@ def test_join(self): } }, {"$unwind": "$queries__author"}, - {"$match": {"$expr": {"$eq": ["$queries__author.name", "Bob"]}}}, + {"$match": {"queries__author.name": "Bob"}}, ], ) @@ -75,16 +75,7 @@ def test_filter_on_local_and_related_fields(self): } }, {"$unwind": "$queries__author"}, - { - "$match": { - "$expr": { - "$and": [ - {"$eq": ["$queries__author.name", "John"]}, - {"$eq": ["$title", "Don"]}, - ] - } - } - }, + {"$match": {"$and": [{"queries__author.name": "John"}, {"title": "Don"}]}}, ], ) @@ -110,16 +101,7 @@ def test_or_mixing_local_and_related_fields_is_not_pushable(self): } }, {"$unwind": "$queries__author"}, - { - "$match": { - "$expr": { - "$or": [ - {"$eq": ["$title", "Don"]}, - {"$eq": ["$queries__author.name", "John"]}, - ] - } - } - }, + {"$match": {"$or": [{"title": "Don"}, {"queries__author.name": "John"}]}}, ], ) @@ -166,12 +148,10 @@ def test_filter_on_self_join_fields(self): {"$unwind": "$T2"}, { "$match": { - "$expr": { - "$and": [ - {"$eq": ["$T2.group_id", ObjectId("6891ff7822e475eddc20f159")]}, - {"$eq": ["$T2.name", "parent"]}, - ] - } + "$and": [ + {"T2.group_id": ObjectId("6891ff7822e475eddc20f159")}, + {"T2.name": "parent"}, + ] } }, ], @@ -209,16 +189,7 @@ def test_filter_on_reverse_foreignkey_relation(self): } }, {"$unwind": "$queries__orderitem"}, - { - "$match": { - "$expr": { - "$eq": [ - "$queries__orderitem.status", - ObjectId("6891ff7822e475eddc20f159"), - ] - } - } - }, + {"$match": {"queries__orderitem.status": ObjectId("6891ff7822e475eddc20f159")}}, {"$addFields": {"_id": "$_id"}}, {"$sort": SON([("_id", 1)])}, ], @@ -284,18 +255,11 @@ def test_filter_on_local_and_nested_join_fields(self): {"$unwind": "$T3"}, { "$match": { - "$expr": { - "$and": [ - {"$eq": ["$T3.name", "My Order"]}, - { - "$eq": [ - "$queries__orderitem.status", - ObjectId("6891ff7822e475eddc20f159"), - ] - }, - {"$eq": ["$name", "My Order"]}, - ] - } + "$and": [ + {"T3.name": "My Order"}, + {"queries__orderitem.status": ObjectId("6891ff7822e475eddc20f159")}, + {"name": "My Order"}, + ] } }, {"$addFields": {"_id": "$_id"}}, @@ -336,7 +300,7 @@ def test_or_on_local_fields_only(self): ctx.captured_queries[0]["sql"], "queries__order", [ - {"$match": {"$expr": {"$or": [{"$eq": ["$name", "A"]}, {"$eq": ["$name", "B"]}]}}}, + {"$match": {"$or": [{"name": "A"}, {"name": "B"}]}}, {"$addFields": {"_id": "$_id"}}, {"$sort": SON([("_id", 1)])}, ], @@ -364,16 +328,7 @@ def test_or_with_mixed_pushable_and_non_pushable_fields(self): } }, {"$unwind": "$queries__author"}, - { - "$match": { - "$expr": { - "$or": [ - {"$eq": ["$queries__author.name", "John"]}, - {"$eq": ["$title", "Don"]}, - ] - } - } - }, + {"$match": {"$or": [{"queries__author.name": "John"}, {"title": "Don"}]}}, ], ) @@ -456,7 +411,7 @@ def test_simple_related_filter_is_pushed(self): } }, {"$unwind": "$queries__reader"}, - {"$match": {"$expr": {"$eq": ["$queries__reader.name", "Alice"]}}}, + {"$match": {"queries__reader.name": "Alice"}}, ], ) @@ -496,12 +451,10 @@ def test_subquery_join_is_pushed(self): {"$unwind": "$U2"}, { "$match": { - "$expr": { - "$and": [ - {"$eq": ["$U2.name", "Alice"]}, - {"$eq": ["$library_id", "$$parent__field__0"]}, - ] - } + "$and": [ + {"U2.name": "Alice"}, + {"$expr": {"$eq": ["$library_id", "$$parent__field__0"]}}, + ] } }, {"$project": {"a": {"$literal": 1}}}, @@ -591,16 +544,7 @@ def test_filter_on_local_and_related_fields(self): } }, {"$unwind": "$queries__reader"}, - { - "$match": { - "$expr": { - "$and": [ - {"$eq": ["$name", "Central"]}, - {"$eq": ["$queries__reader.name", "Alice"]}, - ] - } - } - }, + {"$match": {"$and": [{"name": "Central"}, {"queries__reader.name": "Alice"}]}}, ], ) @@ -684,7 +628,7 @@ def test_or_on_local_fields_only(self): } }, {"$unwind": "$queries__reader"}, - {"$match": {"$expr": {"$eq": ["$name", "Ateneo"]}}}, + {"$match": {"name": "Ateneo"}}, { "$project": { "queries__reader": {"foreing_field": "$queries__reader.name"}, @@ -771,15 +715,6 @@ def test_or_with_mixed_pushable_and_non_pushable_fields(self): } }, {"$unwind": "$queries__reader"}, - { - "$match": { - "$expr": { - "$or": [ - {"$eq": ["$queries__reader.name", "Alice"]}, - {"$eq": ["$name", "Central"]}, - ] - } - } - }, + {"$match": {"$or": [{"queries__reader.name": "Alice"}, {"name": "Central"}]}}, ], ) From 0c27833e8dd761cf5bcbc79369608564c3e57e99 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sat, 6 Sep 2025 21:13:29 -0400 Subject: [PATCH 02/20] try without deepcopy --- django_mongodb_backend/query_conversion/query_optimizer.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/django_mongodb_backend/query_conversion/query_optimizer.py b/django_mongodb_backend/query_conversion/query_optimizer.py index 6c0af11be..9f11298f0 100644 --- a/django_mongodb_backend/query_conversion/query_optimizer.py +++ b/django_mongodb_backend/query_conversion/query_optimizer.py @@ -1,10 +1,8 @@ -from copy import deepcopy - from django_mongodb_backend.query_conversion.expression_converters import convert_expression class QueryOptimizer: - def convert_expr_to_match(self, expr): + def convert_expr_to_match(self, expr_query): """ Takes an MQL query with $expr and optimizes it by extracting optimizable conditions into separate $match stages. @@ -15,8 +13,6 @@ def convert_expr_to_match(self, expr): Returns: List of optimized match conditions """ - expr_query = deepcopy(expr) - if "$expr" not in expr_query: return [expr_query] From 22a43d45ccdb224f9c34e23e159f8e229865c7a1 Mon Sep 17 00:00:00 2001 From: Jib Date: Mon, 8 Sep 2025 07:08:16 -0400 Subject: [PATCH 03/20] getField support overlay --- .../query_conversion/expression_converters.py | 60 ++++++++++++++----- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/django_mongodb_backend/query_conversion/expression_converters.py b/django_mongodb_backend/query_conversion/expression_converters.py index 3c01ce13b..b1674d904 100644 --- a/django_mongodb_backend/query_conversion/expression_converters.py +++ b/django_mongodb_backend/query_conversion/expression_converters.py @@ -10,6 +10,45 @@ class BaseConverter: def convert(cls, expr): raise NotImplementedError("Subclasses must implement this method.") + @classmethod + def is_simple_field_name(cls, field_name): + return ( + isinstance(field_name, str) + and field_name != "" + and field_name.startswith("$") + # Special case for _ separated field names + and field_name[1:].replace("_", "").isalnum() + ) + + @classmethod + def is_simple_get_field(cls, get_field_object): + if not isinstance(get_field_object, dict): + return False + + get_field_expr = get_field_object.get("$getField") + + if ( + isinstance(get_field_expr, dict) + and "input" in get_field_expr + and "field" in get_field_expr + ): + input_expr = get_field_expr["input"] + field_name = get_field_expr["field"] + return cls.convert_field_name(input_expr) and ( + isinstance(field_name, str) and not field_name.startswith("$") + ) + return False + + @classmethod + def convert_field_name(cls, field_name): + if cls.is_simple_field_name(field_name): + return field_name[1:] + if cls.is_simple_get_field(field_name): + get_field_input = field_name["$getField"]["input"] + get_field_field = field_name["$getField"]["field"] + return f"{cls.convert_field_name(get_field_input)}.{get_field_field}" + return None + @classmethod def is_simple_value(cls, value): """Is the value is a simple type (not a dict)?""" @@ -19,7 +58,6 @@ def is_simple_value(cls, value): return False if isinstance(value, (list, tuple, set)): return all(cls.is_simple_value(v) for v in value) - # TODO: Support `$getField` conversion. return not isinstance(value, dict) @@ -35,12 +73,7 @@ def convert(cls, args): if isinstance(args, list) and len(args) == 2: field_expr, value = args # Check if first argument is a simple field reference. - if ( - isinstance(field_expr, str) - and field_expr.startswith("$") - and cls.is_simple_value(value) - ): - field_name = field_expr[1:] # Remove the $ prefix. + if (field_name := cls.convert_field_name(field_expr)) and cls.is_simple_value(value): if cls.operator == "$eq": return {field_name: value} return {field_name: {cls.operator: value}} @@ -135,13 +168,12 @@ def convert(cls, in_args): field_expr, values = in_args # Check if first argument is a simple field reference - if isinstance(field_expr, str) and field_expr.startswith("$"): - field_name = field_expr[1:] # Remove the $ prefix - if isinstance(values, (list, tuple, set)) and all( - cls.is_simple_value(v) for v in values - ): - return {field_name: {"$in": values}} - + # Check if second argument is a list of simple values + if (field_name := cls.convert_field_name(field_expr)) and ( + isinstance(values, list | tuple | set) + and all(cls.is_simple_value(v) for v in values) + ): + return {field_name: {"$in": values}} return None From edc0746b9f57b4848b9f3f5c20b0b2ef71d8ab93 Mon Sep 17 00:00:00 2001 From: Jib Date: Mon, 8 Sep 2025 07:45:41 -0400 Subject: [PATCH 04/20] added getfield tests to expression conversion --- .../test_match_conversion.py | 66 +++++++++++++++ .../test_op_expressions.py | 81 +++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/tests/expression_converter_/test_match_conversion.py b/tests/expression_converter_/test_match_conversion.py index 42d8cccb6..b693fc7c5 100644 --- a/tests/expression_converter_/test_match_conversion.py +++ b/tests/expression_converter_/test_match_conversion.py @@ -214,3 +214,69 @@ def test_deeply_nested_logical_operator_with_variable(self): } ] self.assertOptimizerEqual(expr, expected) + + def test_getfield_usage_on_dual_binary_operator(self): + expr = { + "$expr": { + "$gt": [ + {"$getField": {"input": "$price", "field": "value"}}, + {"$getField": {"input": "$discounted_price", "field": "value"}}, + ] + } + } + expected = [ + { + "$match": { + "$expr": { + "$gt": [ + {"$getField": {"input": "$price", "field": "value"}}, + {"$getField": {"input": "$discounted_price", "field": "value"}}, + ] + } + } + } + ] + self.assertOptimizerEqual(expr, expected) + + def test_getfield_usage_on_onesided_binary_operator(self): + expr = {"$expr": {"$gt": [{"$getField": {"input": "$price", "field": "value"}}, 100]}} + # This should create a proper match condition with no $expr + expected = {"price.value": {"$gt": 100}} + self.assertOptimizerEqual(expr, expected) + + def test_nested_getfield_usage_on_onesided_binary(self): + expr = { + "$expr": { + "$gt": [ + { + "$getField": { + "input": {"$getField": {"input": "$item", "field": "price"}}, + "field": "value", + } + }, + 100, + ] + } + } + expected = {"item.price.value": {"$gt": 100}} + self.assertOptimizerEqual(expr, expected) + + def test_getfield_with_non_constant_field(self): + expr = {"$expr": {"$gt": [{"$getField": {"input": "$price", "field": "$field_name"}}, 100]}} + self.assertOptimizerEqual(expr, expr) + + def test_getfield_with_object_non_simple_input(self): + expr = { + "$expr": { + "$gt": [ + { + "$getField": { + "input": {"$literal": "$item"}, + "field": "price", + } + }, + 100, + ] + } + } + self.assertOptimizerEqual(expr, expr) diff --git a/tests/expression_converter_/test_op_expressions.py b/tests/expression_converter_/test_op_expressions.py index ba86bf776..cd9e8eb67 100644 --- a/tests/expression_converter_/test_op_expressions.py +++ b/tests/expression_converter_/test_op_expressions.py @@ -74,6 +74,45 @@ def test_eq_conversion_various_types(self): def test_eq_conversion_various_array_types(self): self._test_conversion_various_types(self._test_eq_conversion_valid_array_type) + def test_eq_conversion_getfield(self): + expr = {"$eq": [{"$getField": {"input": "$item", "field": "age"}}, 10]} + expected = {"item.age": 10}} + self.assertConversionEqual(expr, expected) + + def test_eq_conversion_nested_getfield(self): + expr = { + "$eq": [ + { + "$getField": { + "input": {"$getField": {"input": "$item", "field": "shelf_life"}}, + "field": "age", + } + }, + 10, + ] + } + expected = {"item.shelf_life.age": 10}} + self.assertConversionEqual(expr, expected) + + def test_eq_conversion_dual_getfield_ineligible(self): + expr = { + "$eq": [ + { + "$getField": { + "input": {"$getField": {"input": "$item", "field": "shelf_life"}}, + "field": "age", + } + }, + { + "$getField": { + "input": {"$getField": {"input": "$item", "field": "shelf_life"}}, + "field": "age", + } + }, + ] + } + self.assertNotOptimizable(expr) + class TestInExprConversion(ExpressionConversionTestCase): def test_in_conversion(self): @@ -112,6 +151,46 @@ def test_in_conversion_various_types(self): with self.subTest(_type=_type, val=val): self._test_in_conversion_valid_type(val) + def test_in_conversion_getfield(self): + expr = {"$in": [{"$getField": {"input": "$item", "field": "age"}}, [10]]} + expected = {"item.age": {"$in": [10]}} + self.assertConversionEqual(expr, expected) + + def test_in_conversion_nested_getfield(self): + expr = { + "$in": [ + { + "$getField": { + "input": {"$getField": {"input": "$item", "field": "shelf_life"}}, + "field": "age", + } + }, + 10, + ] + } + expected = {"item.shelf_life.age": {"$in": [10]}} + self.assertConversionEqual(expr, expected) + + def test_in_conversion_dual_getfield_ineligible(self): + expr = { + "$in": [ + { + "$getField": { + "input": "$root", + "field": "age", + } + }, + [{ + "$getField": { + "input": "$value", + "field": "age", + } + }], + ] + } + self.assertNotOptimizable(expr) + + class TestLogicalExpressionConversion(ExpressionConversionTestCase): def test_logical_and_conversion(self): @@ -173,6 +252,7 @@ def test_logical_mixed_conversion(self): {"$in": ["$category", ["electronics", "books"]]}, {"$eq": ["$verified", True]}, {"$lte": ["$price", 2000]}, + {"$eq": [{"$getField": {"input": "$root", "field": "age"}}, 10]} ] } expected = { @@ -181,6 +261,7 @@ def test_logical_mixed_conversion(self): {"category": {"$in": ["electronics", "books"]}}, {"verified": True}, {"price": {"$lte": 2000}}, + {"root.age": 10} ] } self.assertConversionEqual(expr, expected) From cfd13b6d9216d6461d0e5e8b0e1c8d26f0bb3c74 Mon Sep 17 00:00:00 2001 From: Jib Date: Mon, 8 Sep 2025 08:14:55 -0500 Subject: [PATCH 05/20] fixed lint issues and lingering class usage --- django_mongodb_backend/query.py | 1 - .../query_conversion/query_optimizer.py | 2 +- .../test_op_expressions.py | 19 +++++++++++-------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/django_mongodb_backend/query.py b/django_mongodb_backend/query.py index 014ca0d88..c86b8721b 100644 --- a/django_mongodb_backend/query.py +++ b/django_mongodb_backend/query.py @@ -57,7 +57,6 @@ def __init__(self, compiler): # $lookup stage that encapsulates the pipeline for performing a nested # subquery. self.subquery_lookup = None - self.query_optimizer = QueryOptimizer() def __repr__(self): return f"" diff --git a/django_mongodb_backend/query_conversion/query_optimizer.py b/django_mongodb_backend/query_conversion/query_optimizer.py index 9c072dbff..368c89504 100644 --- a/django_mongodb_backend/query_conversion/query_optimizer.py +++ b/django_mongodb_backend/query_conversion/query_optimizer.py @@ -70,4 +70,4 @@ def _process_logical_conditions(logical_op, logical_conditions): match_conditions.append({"$match": optimized_conditions[0]}) else: match_conditions.append({"$match": {logical_op: remaining_conditions}}) - return match_conditions \ No newline at end of file + return match_conditions diff --git a/tests/expression_converter_/test_op_expressions.py b/tests/expression_converter_/test_op_expressions.py index f2ffb1328..953a57973 100644 --- a/tests/expression_converter_/test_op_expressions.py +++ b/tests/expression_converter_/test_op_expressions.py @@ -64,7 +64,6 @@ def test_conversion_various_types(self): def test_conversion_various_array_types(self): self._test_conversion_various_types(self._test_conversion_valid_array_type) - def test_conversion_getfield(self): expr = {"$eq": [{"$getField": {"input": "$item", "field": "age"}}, 10]} self.assertConversionEqual(expr, {"item.age": 10}) @@ -83,6 +82,7 @@ def test_conversion_nested_getfield(self): } self.assertConversionEqual(expr, {"item.shel_life.age": 10}) + class InTests(ConversionTestCase): def test_conversion(self): expr = {"$in": ["$category", ["electronics", "books", "clothing"]]} @@ -102,6 +102,7 @@ def test_conversion_various_types(self): for _type, val in self.CONVERTIBLE_TYPES.items(): with self.subTest(_type=_type, val=val): self._test_conversion_valid_type(val) + def test_conversion_getfield(self): expr = {"$in": [{"$getField": {"input": "$item", "field": "age"}}, [10]]} expected = {"item.age": {"$in": [10]}} @@ -130,12 +131,14 @@ def test_conversion_dual_getfield_ineligible(self): "field": "age", } }, - [{ - "$getField": { - "input": "$value", - "field": "age", + [ + { + "$getField": { + "input": "$value", + "field": "age", + } } - }], + ], ] } self.assertNotOptimizable(expr) @@ -201,7 +204,7 @@ def test_mixed(self): {"$in": ["$category", ["electronics", "books"]]}, {"$eq": ["$verified", True]}, {"$lte": ["$price", 2000]}, - {"$eq": [{"$getField": {"input": "$root", "field": "age"}}, 10]} + {"$eq": [{"$getField": {"input": "$root", "field": "age"}}, 10]}, ] } expected = { @@ -210,7 +213,7 @@ def test_mixed(self): {"category": {"$in": ["electronics", "books"]}}, {"verified": True}, {"price": {"$lte": 2000}}, - {"root.age": 10} + {"root.age": 10}, ] } self.assertConversionEqual(expr, expected) From 7ab6b376ed2cc12db541cca912a3b99c7184dd26 Mon Sep 17 00:00:00 2001 From: Jib Date: Mon, 8 Sep 2025 08:28:00 -0500 Subject: [PATCH 06/20] include tests for getfield on all basic ops --- .../test_op_expressions.py | 116 +++++++++++++----- 1 file changed, 87 insertions(+), 29 deletions(-) diff --git a/tests/expression_converter_/test_op_expressions.py b/tests/expression_converter_/test_op_expressions.py index 953a57973..53d37ca81 100644 --- a/tests/expression_converter_/test_op_expressions.py +++ b/tests/expression_converter_/test_op_expressions.py @@ -33,6 +33,50 @@ def _test_conversion_various_types(self, conversion_test): with self.subTest(_type=_type, val=val): conversion_test(val) + def _test_conversion_getfield(self, logical_op): + expr = {logical_op: [{"$getField": {"input": "$item", "field": "age"}}, 10]} + self.assertConversionEqual( + expr, {"item.age": 10} if logical_op == "eq" else {"item.age": {logical_op: 10}} + ) + + def _test_conversion_nested_getfield(self, logical_op): + expr = { + logical_op: [ + { + "$getField": { + "input": {"$getField": {"input": "$item", "field": "shelf_life"}}, + "field": "age", + } + }, + 10, + ] + } + self.assertConversionEqual( + expr, + {"item.shel_life.age": 10} + if logical_op == "eq" + else {"item.shelf_life.age": {logical_op: 10}}, + ) + + def _test_conversion_dual_getfield_ineligible(self, logical_op): + expr = { + logical_op: [ + { + "$getField": { + "input": "$root", + "field": "age", + } + }, + { + "$getField": { + "input": "$value", + "field": "age", + } + }, + ] + } + self.assertNotOptimizable(expr) + class ExpressionTests(ConversionTestCase): def test_non_dict(self): @@ -65,22 +109,13 @@ def test_conversion_various_array_types(self): self._test_conversion_various_types(self._test_conversion_valid_array_type) def test_conversion_getfield(self): - expr = {"$eq": [{"$getField": {"input": "$item", "field": "age"}}, 10]} - self.assertConversionEqual(expr, {"item.age": 10}) + self._test_conversion_getfield("$eq") def test_conversion_nested_getfield(self): - expr = { - "$eq": [ - { - "$getField": { - "input": {"$getField": {"input": "$item", "field": "shelf_life"}}, - "field": "age", - } - }, - 10, - ] - } - self.assertConversionEqual(expr, {"item.shel_life.age": 10}) + self._test_conversion_nested_getfield("$eq") + + def test_conversion_dual_getfield_ineligible(self): + self._test_conversion_dual_getfield_ineligible("$eq") class InTests(ConversionTestCase): @@ -104,23 +139,10 @@ def test_conversion_various_types(self): self._test_conversion_valid_type(val) def test_conversion_getfield(self): - expr = {"$in": [{"$getField": {"input": "$item", "field": "age"}}, [10]]} - expected = {"item.age": {"$in": [10]}} - self.assertConversionEqual(expr, expected) + self._test_conversion_getfield("$in") def test_conversion_nested_getfield(self): - expr = { - "$in": [ - { - "$getField": { - "input": {"$getField": {"input": "$item", "field": "shelf_life"}}, - "field": "age", - } - }, - 10, - ] - } - self.assertConversionEqual(expr, {"item.shelf_life.age": {"$in": [10]}}) + self._test_conversion_nested_getfield("$in") def test_conversion_dual_getfield_ineligible(self): expr = { @@ -235,6 +257,15 @@ def _test_conversion_valid_type(self, _type): def test_conversion_various_types(self): self._test_conversion_various_types(self._test_conversion_valid_type) + def test_conversion_getfield(self): + self._test_conversion_getfield("$gt") + + def test_conversion_nested_getfield(self): + self._test_conversion_nested_getfield("$gt") + + def test_conversion_dual_getfield_ineligible(self): + self._test_conversion_dual_getfield_ineligible("$gt") + class GteTests(ConversionTestCase): def test_conversion(self): @@ -258,6 +289,15 @@ def _test_conversion_valid_type(self, _type): def test_conversion_various_types(self): self._test_conversion_various_types(self._test_conversion_valid_type) + def test_conversion_getfield(self): + self._test_conversion_getfield("$gte") + + def test_conversion_nested_getfield(self): + self._test_conversion_nested_getfield("$gte") + + def test_conversion_dual_getfield_ineligible(self): + self._test_conversion_dual_getfield_ineligible("$gte") + class LtTests(ConversionTestCase): def test_conversion(self): @@ -275,6 +315,15 @@ def _test_conversion_valid_type(self, _type): def test_conversion_various_types(self): self._test_conversion_various_types(self._test_conversion_valid_type) + def test_conversion_getfield(self): + self._test_conversion_getfield("$lt") + + def test_conversion_nested_getfield(self): + self._test_conversion_nested_getfield("$lt") + + def test_conversion_dual_getfield_ineligible(self): + self._test_conversion_dual_getfield_ineligible("$lt") + class LteTests(ConversionTestCase): def test_conversion(self): @@ -291,3 +340,12 @@ def _test_conversion_valid_type(self, _type): def test_conversion_various_types(self): self._test_conversion_various_types(self._test_conversion_valid_type) + + def test_conversion_getfield(self): + self._test_conversion_getfield("$lte") + + def test_conversion_nested_getfield(self): + self._test_conversion_nested_getfield("$lte") + + def test_conversion_dual_getfield_ineligible(self): + self._test_conversion_dual_getfield_ineligible("$lte") From 07e6ffb09992ec735895abefc404da96a4d657b7 Mon Sep 17 00:00:00 2001 From: Jib Date: Mon, 8 Sep 2025 09:08:21 -0500 Subject: [PATCH 07/20] fixed match and op conversion tests --- .../query_conversion/expression_converters.py | 15 +++++++- .../test_match_conversion.py | 37 ++----------------- .../test_op_expressions.py | 28 ++++++++------ 3 files changed, 33 insertions(+), 47 deletions(-) diff --git a/django_mongodb_backend/query_conversion/expression_converters.py b/django_mongodb_backend/query_conversion/expression_converters.py index e8fd03529..13a93c325 100644 --- a/django_mongodb_backend/query_conversion/expression_converters.py +++ b/django_mongodb_backend/query_conversion/expression_converters.py @@ -65,7 +65,7 @@ class BinaryConverter(BaseConverter): {"$gt": ["$price", 100]} } is converted to: - {"$gt": ["price", 100]} + {"price": {"$gt": 100}} """ operator: str @@ -79,6 +79,10 @@ def convert(cls, args): if cls.operator == "$eq": return {field_name: value} return {field_name: {cls.operator: value}} + # If simple getFields are found, still mutate the original args list + for i, arg in enumerate(args): + if cls.is_simple_get_field(arg): + args[i] = "$" + cls.convert_field_name(arg) return None @@ -136,6 +140,15 @@ def convert(cls, in_args): and all(cls.is_simple_value(v) for v in values) ): return {field_name: {"$in": values}} + # Mutate the original list + for i, arg in enumerate(in_args): + if cls.is_simple_get_field(arg): + in_args[i] = "$" + cls.convert_field_name(arg) + if isinstance(arg, list | tuple | set): + arg[i] = [ + "$" + cls.convert_field_name(v) if cls.is_simple_get_field(v) else v + for v in arg + ] return None diff --git a/tests/expression_converter_/test_match_conversion.py b/tests/expression_converter_/test_match_conversion.py index 1cf5832c7..675f07cbd 100644 --- a/tests/expression_converter_/test_match_conversion.py +++ b/tests/expression_converter_/test_match_conversion.py @@ -223,24 +223,13 @@ def test_getfield_usage_on_dual_binary_operator(self): ] } } - expected = [ - { - "$match": { - "$expr": { - "$gt": [ - {"$getField": {"input": "$price", "field": "value"}}, - {"$getField": {"input": "$discounted_price", "field": "value"}}, - ] - } - } - } - ] + expected = [{"$match": {"$expr": {"$gt": ["$price.value", "$discounted_price.value"]}}}] self.assertOptimizerEqual(expr, expected) def test_getfield_usage_on_onesided_binary_operator(self): expr = {"$expr": {"$gt": [{"$getField": {"input": "$price", "field": "value"}}, 100]}} # This should create a proper match condition with no $expr - expected = {"price.value": {"$gt": 100}} + expected = [{"$match": {"price.value": {"$gt": 100}}}] self.assertOptimizerEqual(expr, expected) def test_nested_getfield_usage_on_onesided_binary(self): @@ -257,25 +246,5 @@ def test_nested_getfield_usage_on_onesided_binary(self): ] } } - expected = {"item.price.value": {"$gt": 100}} + expected = [{"$match": {"item.price.value": {"$gt": 100}}}] self.assertOptimizerEqual(expr, expected) - - def test_getfield_with_non_constant_field(self): - expr = {"$expr": {"$gt": [{"$getField": {"input": "$price", "field": "$field_name"}}, 100]}} - self.assertOptimizerEqual(expr, expr) - - def test_getfield_with_object_non_simple_input(self): - expr = { - "$expr": { - "$gt": [ - { - "$getField": { - "input": {"$literal": "$item"}, - "field": "price", - } - }, - 100, - ] - } - } - self.assertOptimizerEqual(expr, expr) diff --git a/tests/expression_converter_/test_op_expressions.py b/tests/expression_converter_/test_op_expressions.py index 53d37ca81..204a4e283 100644 --- a/tests/expression_converter_/test_op_expressions.py +++ b/tests/expression_converter_/test_op_expressions.py @@ -33,13 +33,13 @@ def _test_conversion_various_types(self, conversion_test): with self.subTest(_type=_type, val=val): conversion_test(val) - def _test_conversion_getfield(self, logical_op): - expr = {logical_op: [{"$getField": {"input": "$item", "field": "age"}}, 10]} + def _test_conversion_getfield(self, logical_op, value=10): + expr = {logical_op: [{"$getField": {"input": "$item", "field": "age"}}, value]} self.assertConversionEqual( - expr, {"item.age": 10} if logical_op == "eq" else {"item.age": {logical_op: 10}} + expr, {"item.age": value} if logical_op == "$eq" else {"item.age": {logical_op: value}} ) - def _test_conversion_nested_getfield(self, logical_op): + def _test_conversion_nested_getfield(self, logical_op, value=10): expr = { logical_op: [ { @@ -48,14 +48,14 @@ def _test_conversion_nested_getfield(self, logical_op): "field": "age", } }, - 10, + value, ] } self.assertConversionEqual( expr, - {"item.shel_life.age": 10} - if logical_op == "eq" - else {"item.shelf_life.age": {logical_op: 10}}, + {"item.shelf_life.age": value} + if logical_op == "$eq" + else {"item.shelf_life.age": {logical_op: value}}, ) def _test_conversion_dual_getfield_ineligible(self, logical_op): @@ -75,7 +75,9 @@ def _test_conversion_dual_getfield_ineligible(self, logical_op): }, ] } - self.assertNotOptimizable(expr) + # Not optimized but should still convert getFields + expected = {logical_op: ["$root.age", "$value.age"]} + self.assertConversionEqual(expr, expected) class ExpressionTests(ConversionTestCase): @@ -139,10 +141,10 @@ def test_conversion_various_types(self): self._test_conversion_valid_type(val) def test_conversion_getfield(self): - self._test_conversion_getfield("$in") + self._test_conversion_getfield("$in", [10]) def test_conversion_nested_getfield(self): - self._test_conversion_nested_getfield("$in") + self._test_conversion_nested_getfield("$in", [10]) def test_conversion_dual_getfield_ineligible(self): expr = { @@ -163,7 +165,9 @@ def test_conversion_dual_getfield_ineligible(self): ], ] } - self.assertNotOptimizable(expr) + # Not optimized but should still convert getFields + expected = {"$in": ["$root.age", ["$value.age"]]} + self.assertConversionEqual(expr, expected) class LogicalTests(ConversionTestCase): From bdc6ae0921596142bec93e4aa19c30e01935f4b6 Mon Sep 17 00:00:00 2001 From: Jib Date: Wed, 10 Sep 2025 05:35:46 -0500 Subject: [PATCH 08/20] removed mutation of getfield even when values aren't converted --- .../query_conversion/expression_converters.py | 13 ------------- .../expression_converter_/test_match_conversion.py | 13 ++++++++++++- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/django_mongodb_backend/query_conversion/expression_converters.py b/django_mongodb_backend/query_conversion/expression_converters.py index 13a93c325..56651d14b 100644 --- a/django_mongodb_backend/query_conversion/expression_converters.py +++ b/django_mongodb_backend/query_conversion/expression_converters.py @@ -79,10 +79,6 @@ def convert(cls, args): if cls.operator == "$eq": return {field_name: value} return {field_name: {cls.operator: value}} - # If simple getFields are found, still mutate the original args list - for i, arg in enumerate(args): - if cls.is_simple_get_field(arg): - args[i] = "$" + cls.convert_field_name(arg) return None @@ -140,15 +136,6 @@ def convert(cls, in_args): and all(cls.is_simple_value(v) for v in values) ): return {field_name: {"$in": values}} - # Mutate the original list - for i, arg in enumerate(in_args): - if cls.is_simple_get_field(arg): - in_args[i] = "$" + cls.convert_field_name(arg) - if isinstance(arg, list | tuple | set): - arg[i] = [ - "$" + cls.convert_field_name(v) if cls.is_simple_get_field(v) else v - for v in arg - ] return None diff --git a/tests/expression_converter_/test_match_conversion.py b/tests/expression_converter_/test_match_conversion.py index 675f07cbd..47cc50113 100644 --- a/tests/expression_converter_/test_match_conversion.py +++ b/tests/expression_converter_/test_match_conversion.py @@ -223,7 +223,18 @@ def test_getfield_usage_on_dual_binary_operator(self): ] } } - expected = [{"$match": {"$expr": {"$gt": ["$price.value", "$discounted_price.value"]}}}] + expected = [ + { + "$match": { + "$expr": { + "$gt": [ + {"$getField": {"input": "$price", "field": "value"}}, + {"$getField": {"input": "$discounted_price", "field": "value"}}, + ] + } + } + } + ] self.assertOptimizerEqual(expr, expected) def test_getfield_usage_on_onesided_binary_operator(self): From 42575829cd90e9cf9af6dc9f1411d0a22ec3ec94 Mon Sep 17 00:00:00 2001 From: Jib Date: Wed, 10 Sep 2025 05:40:28 -0500 Subject: [PATCH 09/20] fix assert not opmtmizable test within test_op_expressions --- tests/expression_converter_/test_op_expressions.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/expression_converter_/test_op_expressions.py b/tests/expression_converter_/test_op_expressions.py index 204a4e283..95e8192e8 100644 --- a/tests/expression_converter_/test_op_expressions.py +++ b/tests/expression_converter_/test_op_expressions.py @@ -75,9 +75,7 @@ def _test_conversion_dual_getfield_ineligible(self, logical_op): }, ] } - # Not optimized but should still convert getFields - expected = {logical_op: ["$root.age", "$value.age"]} - self.assertConversionEqual(expr, expected) + self.assertNotOptimizable(expr) class ExpressionTests(ConversionTestCase): From a88fbe7e0bf7ba47d4e90319fae4c9b791366c66 Mon Sep 17 00:00:00 2001 From: Jib Date: Wed, 10 Sep 2025 05:42:17 -0500 Subject: [PATCH 10/20] removed the getfield mutation on in test --- tests/expression_converter_/test_op_expressions.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/expression_converter_/test_op_expressions.py b/tests/expression_converter_/test_op_expressions.py index 95e8192e8..bd355dc28 100644 --- a/tests/expression_converter_/test_op_expressions.py +++ b/tests/expression_converter_/test_op_expressions.py @@ -163,9 +163,7 @@ def test_conversion_dual_getfield_ineligible(self): ], ] } - # Not optimized but should still convert getFields - expected = {"$in": ["$root.age", ["$value.age"]]} - self.assertConversionEqual(expr, expected) + self.assertNotOptimizable(expr) class LogicalTests(ConversionTestCase): From e62e2995085ff9e8e4dc07a94bb83b90ba7a4763 Mon Sep 17 00:00:00 2001 From: Jib Date: Wed, 10 Sep 2025 06:31:15 -0500 Subject: [PATCH 11/20] fixed case where already nested fields (without getField) would not get converted --- .../query_conversion/expression_converters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/django_mongodb_backend/query_conversion/expression_converters.py b/django_mongodb_backend/query_conversion/expression_converters.py index 56651d14b..13035a293 100644 --- a/django_mongodb_backend/query_conversion/expression_converters.py +++ b/django_mongodb_backend/query_conversion/expression_converters.py @@ -11,8 +11,8 @@ def is_simple_field_name(cls, field_name): isinstance(field_name, str) and field_name != "" and field_name.startswith("$") - # Special case for _ separated field names - and field_name[1:].replace("_", "").isalnum() + # Case for catching variables + and not field_name.startswith("$$") ) @classmethod From 8331701c3bb8c5f86d93ccbcd1fb4466315e1abc Mon Sep 17 00:00:00 2001 From: Jib Date: Wed, 10 Sep 2025 08:34:02 -0500 Subject: [PATCH 12/20] fixed path name reconcilitation issue on in JSONField tests --- .../query_conversion/expression_converters.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/django_mongodb_backend/query_conversion/expression_converters.py b/django_mongodb_backend/query_conversion/expression_converters.py index 13035a293..5be560336 100644 --- a/django_mongodb_backend/query_conversion/expression_converters.py +++ b/django_mongodb_backend/query_conversion/expression_converters.py @@ -6,7 +6,7 @@ def convert(cls, expr): raise NotImplementedError("Subclasses must implement this method.") @classmethod - def is_simple_field_name(cls, field_name): + def is_simple_path_name(cls, field_name): return ( isinstance(field_name, str) and field_name != "" @@ -29,19 +29,19 @@ def is_simple_get_field(cls, get_field_object): ): input_expr = get_field_expr["input"] field_name = get_field_expr["field"] - return cls.convert_field_name(input_expr) and ( - isinstance(field_name, str) and not field_name.startswith("$") + return cls.convert_path_name(input_expr) and ( + isinstance(field_name, str) and "$" not in field_name and "." not in field_name ) return False @classmethod - def convert_field_name(cls, field_name): - if cls.is_simple_field_name(field_name): + def convert_path_name(cls, field_name): + if cls.is_simple_path_name(field_name): return field_name[1:] if cls.is_simple_get_field(field_name): get_field_input = field_name["$getField"]["input"] - get_field_field = field_name["$getField"]["field"] - return f"{cls.convert_field_name(get_field_input)}.{get_field_field}" + get_field_field_name = field_name["$getField"]["field"] + return f"{cls.convert_path_name(get_field_input)}.{get_field_field_name}" return None @classmethod @@ -75,7 +75,7 @@ def convert(cls, args): if isinstance(args, list) and len(args) == 2: field_expr, value = args # Check if first argument is a simple field reference. - if (field_name := cls.convert_field_name(field_expr)) and cls.is_simple_value(value): + if (field_name := cls.convert_path_name(field_expr)) and cls.is_simple_value(value): if cls.operator == "$eq": return {field_name: value} return {field_name: {cls.operator: value}} From 77f9d349a91d9f4d8dfbf8eba8c291681bf2e557 Mon Sep 17 00:00:00 2001 From: Jib Date: Wed, 10 Sep 2025 11:10:39 -0500 Subject: [PATCH 13/20] fix InExpression convert_path_name --- .../query_conversion/expression_converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_mongodb_backend/query_conversion/expression_converters.py b/django_mongodb_backend/query_conversion/expression_converters.py index 5be560336..5e8133755 100644 --- a/django_mongodb_backend/query_conversion/expression_converters.py +++ b/django_mongodb_backend/query_conversion/expression_converters.py @@ -131,7 +131,7 @@ def convert(cls, in_args): field_expr, values = in_args # Check if first argument is a simple field reference # Check if second argument is a list of simple values - if (field_name := cls.convert_field_name(field_expr)) and ( + if (field_name := cls.convert_path_name(field_expr)) and ( isinstance(values, list | tuple | set) and all(cls.is_simple_value(v) for v in values) ): From f783c8e1e365bd5678fa80f39402e17f83d5a6c8 Mon Sep 17 00:00:00 2001 From: Jib Date: Wed, 10 Sep 2025 11:37:43 -0500 Subject: [PATCH 14/20] handle the case of None being passed --- .../query_conversion/expression_converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_mongodb_backend/query_conversion/expression_converters.py b/django_mongodb_backend/query_conversion/expression_converters.py index 5e8133755..4deee9e52 100644 --- a/django_mongodb_backend/query_conversion/expression_converters.py +++ b/django_mongodb_backend/query_conversion/expression_converters.py @@ -77,7 +77,7 @@ def convert(cls, args): # Check if first argument is a simple field reference. if (field_name := cls.convert_path_name(field_expr)) and cls.is_simple_value(value): if cls.operator == "$eq": - return {field_name: value} + return {field_name: {"$exists": False} if value is None else value} return {field_name: {cls.operator: value}} return None From 797da9ef476dd7a62bd379533aaaa21abcb0f906 Mon Sep 17 00:00:00 2001 From: Jib Date: Wed, 10 Sep 2025 14:26:22 -0500 Subject: [PATCH 15/20] make converesion explicitly change for null equality --- .../query_conversion/expression_converters.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/django_mongodb_backend/query_conversion/expression_converters.py b/django_mongodb_backend/query_conversion/expression_converters.py index 4deee9e52..3d18a919e 100644 --- a/django_mongodb_backend/query_conversion/expression_converters.py +++ b/django_mongodb_backend/query_conversion/expression_converters.py @@ -77,7 +77,9 @@ def convert(cls, args): # Check if first argument is a simple field reference. if (field_name := cls.convert_path_name(field_expr)) and cls.is_simple_value(value): if cls.operator == "$eq": - return {field_name: {"$exists": False} if value is None else value} + if value is None: + return {"$and": [{field_name: {"$exists": True}}, {field_name: None}]} + return {field_name: value} return {field_name: {cls.operator: value}} return None @@ -131,7 +133,7 @@ def convert(cls, in_args): field_expr, values = in_args # Check if first argument is a simple field reference # Check if second argument is a list of simple values - if (field_name := cls.convert_path_name(field_expr)) and ( + if (field_name := cls.(field_expr)) and ( isinstance(values, list | tuple | set) and all(cls.is_simple_value(v) for v in values) ): From b8c99ca7a86d32d04ba08c811db3ae4ed83f228e Mon Sep 17 00:00:00 2001 From: Jib Date: Wed, 10 Sep 2025 14:55:37 -0500 Subject: [PATCH 16/20] add null existence checks for expr conversions --- .../query_conversion/expression_converters.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/django_mongodb_backend/query_conversion/expression_converters.py b/django_mongodb_backend/query_conversion/expression_converters.py index 3d18a919e..8b82ea2ea 100644 --- a/django_mongodb_backend/query_conversion/expression_converters.py +++ b/django_mongodb_backend/query_conversion/expression_converters.py @@ -76,11 +76,14 @@ def convert(cls, args): field_expr, value = args # Check if first argument is a simple field reference. if (field_name := cls.convert_path_name(field_expr)) and cls.is_simple_value(value): + query = {"$and": [{"$exists": True}]} if value is None else None + core_check = None if cls.operator == "$eq": - if value is None: - return {"$and": [{field_name: {"$exists": True}}, {field_name: None}]} - return {field_name: value} - return {field_name: {cls.operator: value}} + core_check = {field_name: value} + if value is None: + core_check = {field_name: {cls.operator: None}} + query = query["$and"].append(core_check) if query else core_check + return query return None @@ -133,11 +136,12 @@ def convert(cls, in_args): field_expr, values = in_args # Check if first argument is a simple field reference # Check if second argument is a list of simple values - if (field_name := cls.(field_expr)) and ( + if (field_name := cls.convert_path_name(field_expr)) and ( 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": [{"$exists": True}, core_check]} if None in values else core_check return None From db17c7a09bff541b553d02e4537d9e44af62b186 Mon Sep 17 00:00:00 2001 From: Jib Date: Wed, 10 Sep 2025 17:19:16 -0500 Subject: [PATCH 17/20] add explicit : true check --- .../query_conversion/expression_converters.py | 15 +++++---- .../test_op_expressions.py | 32 +++++++++++++++---- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/django_mongodb_backend/query_conversion/expression_converters.py b/django_mongodb_backend/query_conversion/expression_converters.py index 8b82ea2ea..9972d23ab 100644 --- a/django_mongodb_backend/query_conversion/expression_converters.py +++ b/django_mongodb_backend/query_conversion/expression_converters.py @@ -76,13 +76,12 @@ def convert(cls, args): field_expr, value = args # Check if first argument is a simple field reference. if (field_name := cls.convert_path_name(field_expr)) and cls.is_simple_value(value): - query = {"$and": [{"$exists": True}]} if value is None else None - core_check = None if cls.operator == "$eq": - core_check = {field_name: value} + query = {field_name: value} + else: + query = {field_name: {cls.operator: value}} if value is None: - core_check = {field_name: {cls.operator: None}} - query = query["$and"].append(core_check) if query else core_check + query = {"$and": [{field_name: {"$exists": True}}, query]} return query return None @@ -141,7 +140,11 @@ def convert(cls, in_args): and all(cls.is_simple_value(v) for v in values) ): core_check = {field_name: {"$in": values}} - return {"$and": [{"$exists": True}, core_check]} if None in values else core_check + 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 bd355dc28..22b444e63 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): + if _type is None: + return {"$and": [{"$exists": True}, condition]} + return condition + + class ConversionTestCase(SimpleTestCase): CONVERTIBLE_TYPES = { "int": 42, @@ -97,10 +103,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}) + ) 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}) + ) def test_conversion_various_types(self): self._test_conversion_various_types(self._test_conversion_valid_type) @@ -131,7 +141,9 @@ 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]}}) + ) def test_conversion_various_types(self): for _type, val in self.CONVERTIBLE_TYPES.items(): @@ -252,7 +264,9 @@ 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}}) + ) def test_conversion_various_types(self): self._test_conversion_various_types(self._test_conversion_valid_type) @@ -284,7 +298,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)) def test_conversion_various_types(self): self._test_conversion_various_types(self._test_conversion_valid_type) @@ -310,7 +324,9 @@ 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}}) + ) def test_conversion_various_types(self): self._test_conversion_various_types(self._test_conversion_valid_type) @@ -336,7 +352,9 @@ 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}}) + ) def test_conversion_various_types(self): self._test_conversion_various_types(self._test_conversion_valid_type) From aacdd4d757de491420c3cb95bd15c87852b710a1 Mon Sep 17 00:00:00 2001 From: Jib Date: Wed, 10 Sep 2025 17:42:22 -0500 Subject: [PATCH 18/20] provide path in the null wrapping --- .../test_op_expressions.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/expression_converter_/test_op_expressions.py b/tests/expression_converter_/test_op_expressions.py index 22b444e63..010717c2a 100644 --- a/tests/expression_converter_/test_op_expressions.py +++ b/tests/expression_converter_/test_op_expressions.py @@ -7,9 +7,9 @@ from django_mongodb_backend.query_conversion.expression_converters import convert_expression -def _wrap_condition_if_null(_type, condition): +def _wrap_condition_if_null(_type, condition, path): if _type is None: - return {"$and": [{"$exists": True}, condition]} + return {"$and": [{path: {"$exists": True}}, condition]} return condition @@ -104,12 +104,12 @@ def test_no_conversion_dict_value(self): def _test_conversion_valid_type(self, _type): self.assertConversionEqual( - {"$eq": ["$age", _type]}, _wrap_condition_if_null(_type, {"age": _type}) + {"$eq": ["$age", _type]}, _wrap_condition_if_null(_type, {"age": _type}, "age") ) def _test_conversion_valid_array_type(self, _type): self.assertConversionEqual( - {"$eq": ["$age", _type]}, _wrap_condition_if_null(_type, {"age": _type}) + {"$eq": ["$age", _type]}, _wrap_condition_if_null(_type, {"age": _type}, "age") ) def test_conversion_various_types(self): @@ -265,7 +265,8 @@ def test_no_conversion_dict_value(self): def _test_conversion_valid_type(self, _type): self.assertConversionEqual( - {"$gt": ["$price", _type]}, _wrap_condition_if_null(_type, {"price": {"$gt": _type}}) + {"$gt": ["$price", _type]}, + _wrap_condition_if_null(_type, {"price": {"$gt": _type}}, "price"), ) def test_conversion_various_types(self): @@ -298,7 +299,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, _wrap_condition_if_null(_type, 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) @@ -325,7 +326,8 @@ def test_no_conversion_dict_value(self): def _test_conversion_valid_type(self, _type): self.assertConversionEqual( - {"$lt": ["$price", _type]}, _wrap_condition_if_null(_type, {"price": {"$lt": _type}}) + {"$lt": ["$price", _type]}, + _wrap_condition_if_null(_type, {"price": {"$lt": _type}}, "price"), ) def test_conversion_various_types(self): @@ -353,7 +355,8 @@ def test_no_conversion_dict_value(self): def _test_conversion_valid_type(self, _type): self.assertConversionEqual( - {"$lte": ["$price", _type]}, _wrap_condition_if_null(_type, {"price": {"$lte": _type}}) + {"$lte": ["$price", _type]}, + _wrap_condition_if_null(_type, {"price": {"$lte": _type}}, "price"), ) def test_conversion_various_types(self): From 49c34d614fc02a68b3785405767d1b1d8f1818ee Mon Sep 17 00:00:00 2001 From: Jib Date: Wed, 10 Sep 2025 18:02:27 -0500 Subject: [PATCH 19/20] add age path to InExpression --- tests/expression_converter_/test_op_expressions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/expression_converter_/test_op_expressions.py b/tests/expression_converter_/test_op_expressions.py index 010717c2a..ea3113639 100644 --- a/tests/expression_converter_/test_op_expressions.py +++ b/tests/expression_converter_/test_op_expressions.py @@ -142,7 +142,7 @@ def test_no_conversion_dict_value(self): def _test_conversion_valid_type(self, _type): self.assertConversionEqual( - {"$in": ["$age", [_type]]}, _wrap_condition_if_null(_type, {"age": {"$in": [_type]}}) + {"$in": ["$age", [_type]]}, _wrap_condition_if_null(_type, {"age": {"$in": [_type]}}, "age") ) def test_conversion_various_types(self): From 6a14b1dd1841483c2852d443a11d42a04ec2b7c0 Mon Sep 17 00:00:00 2001 From: Jib Date: Wed, 10 Sep 2025 18:08:09 -0500 Subject: [PATCH 20/20] fix lint error --- tests/expression_converter_/test_op_expressions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/expression_converter_/test_op_expressions.py b/tests/expression_converter_/test_op_expressions.py index ea3113639..80624834e 100644 --- a/tests/expression_converter_/test_op_expressions.py +++ b/tests/expression_converter_/test_op_expressions.py @@ -142,7 +142,8 @@ def test_no_conversion_dict_value(self): def _test_conversion_valid_type(self, _type): self.assertConversionEqual( - {"$in": ["$age", [_type]]}, _wrap_condition_if_null(_type, {"age": {"$in": [_type]}}, "age") + {"$in": ["$age", [_type]]}, + _wrap_condition_if_null(_type, {"age": {"$in": [_type]}}, "age"), ) def test_conversion_various_types(self):