diff --git a/django_mongodb_backend/query.py b/django_mongodb_backend/query.py index 8bad93463..c86b8721b 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 .query_conversion.query_optimizer import convert_expr_to_match + def wrap_database_errors(func): @wraps(func) @@ -87,7 +89,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(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..b8362f3ed --- /dev/null +++ b/django_mongodb_backend/query_conversion/expression_converters.py @@ -0,0 +1,172 @@ +class BaseConverter: + """Base class for $expr to $match converters.""" + + @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 converting binary operations. + + For example: + "$expr": { + {"$gt": ["$price", 100]} + } + is converted to: + {"$gt": ["price", 100]} + """ + + 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 query. + + For example: + "$expr": { + {"$eq": ["$status", "active"]} + } + is converted to: + {"status": "active"} + """ + + operator = "$eq" + + +class GtConverter(BinaryConverter): + operator = "$gt" + + +class GteConverter(BinaryConverter): + operator = "$gte" + + +class LtConverter(BinaryConverter): + operator = "$lt" + + +class LteConverter(BinaryConverter): + operator = "$lte" + + +class InConverter(BaseConverter): + """ + Convert $in operation to a $match 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): + """ + Base class for converting logical operations to a $match query. + + For example: + "$expr": { + "$or": [ + {"$eq": ["$status", "active"]}, + {"$in": ["$category", ["electronics", "books"]]}, + ] + } + is converted to: + "$or": [ + {"status": "active"}, + {"category": {"$in": ["electronics", "books"]}}, + ] + """ + + @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): + _logical_op = "$or" + + +class AndConverter(LogicalConverter): + _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 MQL by converting an $expr condition to $match. Return the $match + MQL, 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..368c89504 --- /dev/null +++ b/django_mongodb_backend/query_conversion/query_optimizer.py @@ -0,0 +1,73 @@ +from .expression_converters import convert_expression + + +def convert_expr_to_match(query): + """ + Optimize an MQL query by converting conditions into a list of $match + stages. + """ + if "$expr" not in query: + return [query] + if query["$expr"] == {}: + return [{"$match": {}}] + return _process_expression(query["$expr"]) + + +def _process_expression(expr): + """Process an expression and extract optimizable conditions.""" + match_conditions = [] + remaining_conditions = [] + if isinstance(expr, dict): + 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. + if has_and: + and_match_conditions = _process_logical_conditions("$and", expr["$and"]) + match_conditions.extend(and_match_conditions) + if has_or: + or_match_conditions = _process_logical_conditions("$or", expr["$or"]) + match_conditions.extend(or_match_conditions) + if not has_and and not has_or: + # Process single condition. + if optimized := convert_expression(expr): + 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(logical_op, logical_conditions): + """Process conditions within a logical array.""" + 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 that can't be optimized must remain in a $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/docs/releases/5.2.x.rst b/docs/releases/5.2.x.rst index 8079ac439..35f699904 100644 --- a/docs/releases/5.2.x.rst +++ b/docs/releases/5.2.x.rst @@ -22,6 +22,13 @@ Bug fixes operation is completed on the server to prevent conflicts when running multiple operations sequentially. +Performance improvements +------------------------ + +- Made simple queries that use ``$eq``, ``$in``, ``$and``, ``$or``, ``$gt``, + ``$gte``, ``$lt``, and/or ``$lte`` use ``$match`` instead of ``$expr`` so + that they can use indexes. + 5.2.0 ===== 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..e78e5c0cc --- /dev/null +++ b/tests/expression_converter_/test_match_conversion.py @@ -0,0 +1,215 @@ +from django.test import SimpleTestCase + +from django_mongodb_backend.query_conversion.query_optimizer import convert_expr_to_match + + +class ConvertExprToMatchTests(SimpleTestCase): + def assertOptimizerEqual(self, input, expected): + result = 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": [ + {"$eq": ["$type", "$$standard"]}, # Not optimizable + {"$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..ce4caf2d4 --- /dev/null +++ b/tests/expression_converter_/test_op_expressions.py @@ -0,0 +1,233 @@ +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 ConversionTestCase(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 ExpressionTests(ConversionTestCase): + def test_non_dict(self): + self.assertNotOptimizable(["$status", "active"]) + + def test_empty_dict(self): + self.assertNotOptimizable({}) + + +class EqTests(ConversionTestCase): + def test_conversion(self): + self.assertConversionEqual({"$eq": ["$status", "active"]}, {"status": "active"}) + + def test_no_conversion_non_string_field(self): + self.assertNotOptimizable({"$eq": [123, "active"]}) + + 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}) + + def _test_conversion_valid_array_type(self, _type): + self.assertConversionEqual({"$eq": ["$age", _type]}, {"age": _type}) + + def test_conversion_various_types(self): + self._test_conversion_various_types(self._test_conversion_valid_type) + + def test_conversion_various_array_types(self): + self._test_conversion_various_types(self._test_conversion_valid_array_type) + + +class InTests(ConversionTestCase): + def test_conversion(self): + expr = {"$in": ["$category", ["electronics", "books", "clothing"]]} + expected = {"category": {"$in": ["electronics", "books", "clothing"]}} + self.assertConversionEqual(expr, expected) + + def test_no_conversion_non_string_field(self): + self.assertNotOptimizable({"$in": [123, ["electronics", "books"]]}) + + 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]}}) + + 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) + + +class LogicalTests(ConversionTestCase): + def test_and(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_or(self): + expr = { + "$or": [ + {"$eq": ["$status", "active"]}, + {"$in": ["$category", ["electronics", "books"]]}, + ] + } + expected = { + "$or": [ + {"status": "active"}, + {"category": {"$in": ["electronics", "books"]}}, + ] + } + self.assertConversionEqual(expr, expected) + + def test_or_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_mixed(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 GtTests(ConversionTestCase): + def test_conversion(self): + self.assertConversionEqual({"$gt": ["$price", 100]}, {"price": {"$gt": 100}}) + + def test_no_conversion_non_simple_field(self): + self.assertNotOptimizable({"$gt": ["$price", "$min_price"]}) + + 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}}) + + def test_conversion_various_types(self): + self._test_conversion_various_types(self._test_conversion_valid_type) + + +class GteTests(ConversionTestCase): + def test_conversion(self): + expr = {"$gte": ["$price", 100]} + expected = {"price": {"$gte": 100}} + self.assertConversionEqual(expr, expected) + + def test_no_conversion_non_simple_field(self): + expr = {"$gte": ["$price", "$min_price"]} + self.assertNotOptimizable(expr) + + def test_no_conversion_dict_value(self): + expr = {"$gte": ["$price", {}]} + self.assertNotOptimizable(expr) + + def _test_conversion_valid_type(self, _type): + expr = {"$gte": ["$price", _type]} + expected = {"price": {"$gte": _type}} + self.assertConversionEqual(expr, expected) + + def test_conversion_various_types(self): + self._test_conversion_various_types(self._test_conversion_valid_type) + + +class LtTests(ConversionTestCase): + def test_conversion(self): + self.assertConversionEqual({"$lt": ["$price", 100]}, {"price": {"$lt": 100}}) + + def test_no_conversion_non_simple_field(self): + self.assertNotOptimizable({"$lt": ["$price", "$min_price"]}) + + 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}}) + + def test_conversion_various_types(self): + self._test_conversion_various_types(self._test_conversion_valid_type) + + +class LteTests(ConversionTestCase): + def test_conversion(self): + self.assertConversionEqual({"$lte": ["$price", 100]}, {"price": {"$lte": 100}}) + + def test_no_conversion_non_simple_field(self): + self.assertNotOptimizable({"$lte": ["$price", "$min_price"]}) + + 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}}) + + def test_conversion_various_types(self): + self._test_conversion_various_types(self._test_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"}]}}, ], )