From f8d43c0d4cde9db0ab5688d9e770bf26dad427c4 Mon Sep 17 00:00:00 2001 From: Matthew Simpson Date: Mon, 7 Apr 2025 13:56:02 -0400 Subject: [PATCH 1/2] Move $search and $vectorSearch to beginning of pipelines --- docs/changelog.rst | 1 + mongoengine/queryset/base.py | 7 ++--- tests/queryset/test_queryset_aggregation.py | 30 +++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ff2dd38c1..13eb6ccd7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -16,6 +16,7 @@ Development - BREAKING CHANGE: wrap _document_registry (normally not used by end users) with _DocumentRegistry which acts as a singleton to access the registry - Log a warning in case users creates multiple Document classes with the same name as it can lead to unexpected behavior #1778 - Fix use of $geoNear or $collStats in aggregate #2493 +- Fix use of $search or $vectorSearch in aggregate #2878 - BREAKING CHANGE: Further to the deprecation warning, remove ability to use an unpacked list to `Queryset.aggregate(*pipeline)`, a plain list must be provided instead `Queryset.aggregate(pipeline)`, as it's closer to pymongo interface - BREAKING CHANGE: Further to the deprecation warning, remove `full_response` from `QuerySet.modify` as it wasn't supported with Pymongo 3+ - Fixed stacklevel of many warnings (to point places emitting the warning more accurately) diff --git a/mongoengine/queryset/base.py b/mongoengine/queryset/base.py index 2db97ddb7..f077ad83b 100644 --- a/mongoengine/queryset/base.py +++ b/mongoengine/queryset/base.py @@ -1398,9 +1398,10 @@ def aggregate(self, pipeline, **kwargs): first_step = [] new_user_pipeline = [] for step_step in pipeline: - if "$geoNear" in step_step: - first_step.append(step_step) - elif "$collStats" in step_step: + if any( + el in step_step + for el in ["$geoNear", "$collStats", "$search", "$vectorSearch"] + ): first_step.append(step_step) else: new_user_pipeline.append(step_step) diff --git a/tests/queryset/test_queryset_aggregation.py b/tests/queryset/test_queryset_aggregation.py index 7e390e35a..283680ac0 100644 --- a/tests/queryset/test_queryset_aggregation.py +++ b/tests/queryset/test_queryset_aggregation.py @@ -1,3 +1,6 @@ +import sys +from unittest.mock import patch + import pytest from pymongo.read_preferences import ReadPreference @@ -373,3 +376,30 @@ class SomeDoc(Document): res = list(SomeDoc.objects.aggregate(pipeline)) assert len(res) == 1 assert res[0]["count"] == 2 + + def test_aggregate_search_used_as_initial_step_before_cls_implicit_step(self): + class SearchableDoc(Document): + first_name = StringField() + last_name = StringField() + + privileged_step = { + "$search": { + "index": "default", + "autocomplete": { + "query": "foo", + "path": "first_name", + }, + } + } + pipeline = [privileged_step] + + # Search requires an Atlas instance, so we instead mock the aggregation call to inspect the final pipeline + with patch.object(SearchableDoc, "_collection") as coll_mk: + SearchableDoc.objects(last_name="bar").aggregate(pipeline) + if sys.version_info < (3, 8): + final_pipeline = coll_mk.aggregate.call_args_list[0][0][0] + else: + final_pipeline = coll_mk.aggregate.call_args_list[0].args[0] + assert len(final_pipeline) == 2 + # $search moved before the $match on last_name + assert final_pipeline[0] == privileged_step From e0baf095e5d56f25ea076336fb3dbe1e71e49296 Mon Sep 17 00:00:00 2001 From: Bastien Gerard Date: Sun, 23 Aug 2026 16:08:49 +0200 Subject: [PATCH 2/2] polish fx related with search_first_in_pipeline --- docs/changelog.rst | 2 +- mongoengine/queryset/base.py | 12 +++++-- tests/queryset/test_queryset_aggregation.py | 38 ++++++++------------- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5d4b61641..b8e99a727 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -24,7 +24,7 @@ Changes in 1.0.0 - BREAKING CHANGE: wrap _document_registry (normally not used by end users) with _DocumentRegistry which acts as a singleton to access the registry - Log a warning in case users creates multiple Document classes with the same name as it can lead to unexpected behavior #1778 - Fix use of $geoNear or $collStats in aggregate #2493 -- Fix use of $search or $vectorSearch in aggregate #2878 +- Fix use of $search, $searchMeta, or $vectorSearch in aggregate #2878 - BREAKING CHANGE: Further to the deprecation warning, remove ability to use an unpacked list to `Queryset.aggregate(*pipeline)`, a plain list must be provided instead `Queryset.aggregate(pipeline)`, as it's closer to pymongo interface - BREAKING CHANGE: Further to the deprecation warning, remove `full_response` from `QuerySet.modify` as it wasn't supported with Pymongo 3+ - BREAKING CHANGE: Remove deprecated ``QuerySet.snapshot``, which had no effect with PyMongo 3+. Remove calls to ``.snapshot(...)``; there is no direct replacement. diff --git a/mongoengine/queryset/base.py b/mongoengine/queryset/base.py index b2962f885..b2b183d0a 100644 --- a/mongoengine/queryset/base.py +++ b/mongoengine/queryset/base.py @@ -1382,13 +1382,19 @@ def aggregate(self, pipeline, **kwargs): if self._skip is not None: initial_pipeline.append({"$skip": self._skip}) - # geoNear and collStats must be the first stages in the pipeline if present + # Some aggregation stages must precede MongoEngine's implicit stages. first_step = [] new_user_pipeline = [] for step_step in pipeline: if any( - el in step_step - for el in ["$geoNear", "$collStats", "$search", "$vectorSearch"] + operator in step_step + for operator in ( + "$geoNear", + "$collStats", + "$search", + "$searchMeta", + "$vectorSearch", + ) ): first_step.append(step_step) else: diff --git a/tests/queryset/test_queryset_aggregation.py b/tests/queryset/test_queryset_aggregation.py index 283680ac0..f2fc0139c 100644 --- a/tests/queryset/test_queryset_aggregation.py +++ b/tests/queryset/test_queryset_aggregation.py @@ -1,4 +1,3 @@ -import sys from unittest.mock import patch import pytest @@ -377,29 +376,20 @@ class SomeDoc(Document): assert len(res) == 1 assert res[0]["count"] == 2 - def test_aggregate_search_used_as_initial_step_before_cls_implicit_step(self): + def test_aggregate__search_stage__precedes_implicit_match(self): class SearchableDoc(Document): - first_name = StringField() last_name = StringField() - privileged_step = { - "$search": { - "index": "default", - "autocomplete": { - "query": "foo", - "path": "first_name", - }, - } - } - pipeline = [privileged_step] - - # Search requires an Atlas instance, so we instead mock the aggregation call to inspect the final pipeline - with patch.object(SearchableDoc, "_collection") as coll_mk: - SearchableDoc.objects(last_name="bar").aggregate(pipeline) - if sys.version_info < (3, 8): - final_pipeline = coll_mk.aggregate.call_args_list[0][0][0] - else: - final_pipeline = coll_mk.aggregate.call_args_list[0].args[0] - assert len(final_pipeline) == 2 - # $search moved before the $match on last_name - assert final_pipeline[0] == privileged_step + for search_stage in ("$search", "$searchMeta", "$vectorSearch"): + with self.subTest(search_stage=search_stage): + search_step = {search_stage: {}} + + # End-to-end execution requires MongoDB Search (mongot), so mock + # the collection and verify the pipeline passed to PyMongo. + with patch.object(SearchableDoc, "_collection") as collection: + SearchableDoc.objects(last_name="bar").aggregate([search_step]) + + assert collection.aggregate.call_args.args[0] == [ + search_step, + {"$match": {"last_name": "bar"}}, + ]