Skip to content

Commit 62eac09

Browse files
committed
Edits.
1 parent 6ae6af6 commit 62eac09

File tree

3 files changed

+127
-63
lines changed

3 files changed

+127
-63
lines changed

django_mongodb_backend/compiler.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,28 @@ def _compound_searches_queries(self, search_replacements):
241241
if not search_replacements:
242242
return []
243243
if len(search_replacements) > 1:
244-
raise ValueError("Cannot perform more than one search operation.")
244+
has_search = any(not isinstance(search, SearchVector) for search in search_replacements)
245+
has_vector_search = any(
246+
isinstance(search, SearchVector) for search in search_replacements
247+
)
248+
if has_search and has_vector_search:
249+
raise ValueError(
250+
"Cannot combine a `$vectorSearch` with a `$search` operator. "
251+
"If you need to combine them, consider restructuring your query logic or "
252+
"running them as separate queries."
253+
)
254+
if not has_search:
255+
raise ValueError(
256+
"Cannot combine two `$vectorSearch` operator. "
257+
"If you need to combine them, consider restructuring your query logic or "
258+
"running them as separate queries."
259+
)
260+
raise ValueError(
261+
"Only one $search operation is allowed per query. "
262+
f"Received {len(search_replacements)} search expressions. "
263+
"To combine multiple search expressions, use either a CompoundExpression for "
264+
"fine-grained control or CombinedSearchExpression for simple logical combinations."
265+
)
245266
pipeline = []
246267
for search, result_col in search_replacements.items():
247268
score_function = (
@@ -252,7 +273,7 @@ def _compound_searches_queries(self, search_replacements):
252273
search.as_mql(self, self.connection),
253274
{
254275
"$addFields": {
255-
result_col.as_mql(self, self.connection).removeprefix("$"): {
276+
result_col.as_mql(self, self.connection, as_path=True): {
256277
"$meta": score_function
257278
}
258279
}

django_mongodb_backend/expressions/search.py

Lines changed: 41 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def __or__(self, other):
7171
return self._combine(other, Operator(Operator.OR))
7272

7373
def __ror__(self, other):
74-
return self._combine(self, Operator(Operator.OR), other)
74+
return self._combine(other, Operator(Operator.OR))
7575

7676

7777
class SearchExpression(SearchCombinable, Expression):
@@ -101,10 +101,14 @@ def get_source_expressions(self):
101101
return []
102102

103103
def _get_indexed_fields(self, mappings):
104-
for field, definition in mappings.get("fields", {}).items():
105-
yield field
106-
for path in self._get_indexed_fields(definition):
107-
yield f"{field}.{path}"
104+
if isinstance(mappings, list):
105+
for definition in mappings:
106+
yield from self._get_indexed_fields(definition)
107+
else:
108+
for field, definition in mappings.get("fields", {}).items():
109+
yield field
110+
for path in self._get_indexed_fields(definition):
111+
yield f"{field}.{path}"
108112

109113
def _get_query_index(self, fields, compiler):
110114
fields = set(fields)
@@ -142,9 +146,7 @@ class SearchAutocomplete(SearchExpression):
142146
any-order token matching.
143147
score: Optional expression to adjust score relevance (e.g., `{"boost": {"value": 5}}`).
144148
145-
Notes:
146-
* Requires an Atlas Search index with `autocomplete` mappings.
147-
* The operator is injected under the `$search` stage in the aggregation pipeline.
149+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/autocomplete/
148150
"""
149151

150152
def __init__(self, path, query, fuzzy=None, token_order=None, score=None):
@@ -193,10 +195,7 @@ class SearchEquals(SearchExpression):
193195
value: The exact value to match against.
194196
score: Optional expression to modify the relevance score.
195197
196-
Notes:
197-
* The field must be indexed with a supported type for `equals`.
198-
* Supports numeric, string, boolean, and date values.
199-
* Score boosting can be applied using the `score` parameter.
198+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/equals/
200199
"""
201200

202201
def __init__(self, path, value, score=None):
@@ -239,9 +238,7 @@ class SearchExists(SearchExpression):
239238
path: The document path to check (as string or expression).
240239
score: Optional expression to modify the relevance score.
241240
242-
Notes:
243-
* The target field must be mapped in the Atlas Search index.
244-
* This does not test for null—only for presence.
241+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/exists/
245242
"""
246243

247244
def __init__(self, path, score=None):
@@ -268,6 +265,23 @@ def search_operator(self, compiler, connection):
268265

269266

270267
class SearchIn(SearchExpression):
268+
"""
269+
Atlas Search expression that matches documents where the field value is in a given list.
270+
271+
This expression uses the **in** operator to match documents whose field
272+
contains a value from the provided array of values.
273+
274+
Example:
275+
SearchIn("status", ["pending", "approved", "rejected"])
276+
277+
Args:
278+
path: The document path to match against (as string or expression).
279+
value: A list of values to check for membership.
280+
score: Optional expression to adjust the relevance score.
281+
282+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/in/
283+
"""
284+
271285
def __init__(self, path, value, score=None):
272286
self.path = cast_as_field(path)
273287
self.value = cast_as_value(value)
@@ -297,7 +311,7 @@ class SearchPhrase(SearchExpression):
297311
"""
298312
Atlas Search expression that matches a phrase in the specified field.
299313
300-
This expression uses the **phrase** operator to search for exact or near-exact
314+
This expression uses the **phrase** operator to search for exact or near exact
301315
sequences of terms. It supports optional slop (word distance) and synonym sets.
302316
303317
Example:
@@ -310,10 +324,7 @@ class SearchPhrase(SearchExpression):
310324
synonyms: Optional name of a synonym mapping defined in the Atlas index.
311325
score: Optional expression to modify the relevance score.
312326
313-
Notes:
314-
* The field must be mapped as `"type": "string"` with appropriate analyzers.
315-
* Slop allows flexibility in word positioning, like `"quick brown fox"`
316-
matching `"quick fox"` if `slop=1`.
327+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/phrase/
317328
"""
318329

319330
def __init__(self, path, query, slop=None, synonyms=None, score=None):
@@ -363,9 +374,7 @@ class SearchQueryString(SearchExpression):
363374
query: The Lucene-style query string.
364375
score: Optional expression to modify the relevance score.
365376
366-
Notes:
367-
* The query string syntax must conform to Atlas Search rules.
368-
* This operator is powerful but can be harder to validate or sanitize.
377+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/queryString/
369378
"""
370379

371380
def __init__(self, path, query, score=None):
@@ -411,9 +420,7 @@ class SearchRange(SearchExpression):
411420
gte: Optional inclusive lower bound (`>=`).
412421
score: Optional expression to modify the relevance score.
413422
414-
Notes:
415-
* At least one of `lt`, `lte`, `gt`, or `gte` must be provided.
416-
* The field must be mapped in the Atlas Search index as a comparable type.
423+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/range/
417424
"""
418425

419426
def __init__(self, path, lt=None, lte=None, gt=None, gte=None, score=None):
@@ -467,10 +474,7 @@ class SearchRegex(SearchExpression):
467474
allow_analyzed_field: Whether to allow matching against analyzed fields (default is False).
468475
score: Optional expression to modify the relevance score.
469476
470-
Notes:
471-
* Regular expressions must follow JavaScript regex syntax.
472-
* By default, the field must be mapped as `"analyzer": "keyword"`
473-
unless `allow_analyzed_field=True`.
477+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/regex/
474478
"""
475479

476480
def __init__(self, path, query, allow_analyzed_field=None, score=None):
@@ -519,9 +523,7 @@ class SearchText(SearchExpression):
519523
synonyms: Optional name of a synonym mapping defined in the Atlas index.
520524
score: Optional expression to adjust relevance scoring.
521525
522-
Notes:
523-
* The target field must be indexed for full-text search in Atlas.
524-
* Fuzzy matching helps match terms with minor typos or variations.
526+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/text/
525527
"""
526528

527529
def __init__(self, path, query, fuzzy=None, match_criteria=None, synonyms=None, score=None):
@@ -574,11 +576,7 @@ class SearchWildcard(SearchExpression):
574576
allow_analyzed_field: Whether to allow matching against analyzed fields (default is False).
575577
score: Optional expression to modify the relevance score.
576578
577-
Notes:
578-
* Wildcard patterns follow standard syntax, where `*` matches any sequence of characters
579-
and `?` matches a single character.
580-
* By default, the field should be keyword or unanalyzed
581-
unless `allow_analyzed_field=True`.
579+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/wildcard/
582580
"""
583581

584582
def __init__(self, path, query, allow_analyzed_field=None, score=None):
@@ -625,9 +623,7 @@ class SearchGeoShape(SearchExpression):
625623
geometry: The GeoJSON geometry to compare against.
626624
score: Optional expression to modify the relevance score.
627625
628-
Notes:
629-
* The field must be indexed as a geo shape type in Atlas Search.
630-
* Geometry must conform to GeoJSON specification.
626+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/geoShape/
631627
"""
632628

633629
def __init__(self, path, relation, geometry, score=None):
@@ -674,9 +670,7 @@ class SearchGeoWithin(SearchExpression):
674670
geo_object: The GeoJSON geometry defining the boundary.
675671
score: Optional expression to adjust the relevance score.
676672
677-
Notes:
678-
* The geo field must be indexed appropriately in the Atlas Search index.
679-
* The geometry must follow GeoJSON format.
673+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/geoWithin/
680674
"""
681675

682676
def __init__(self, path, kind, geo_object, score=None):
@@ -719,9 +713,7 @@ class SearchMoreLikeThis(SearchExpression):
719713
documents: A list of example documents or expressions to find similar documents.
720714
score: Optional expression to modify the relevance scoring.
721715
722-
Notes:
723-
* The documents should be representative examples to base similarity on.
724-
* Supports various field types depending on the Atlas Search configuration.
716+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/morelikethis/
725717
"""
726718

727719
def __init__(self, documents, score=None):
@@ -774,9 +766,7 @@ class CompoundExpression(SearchExpression):
774766
score: Optional expression to adjust scoring.
775767
minimum_should_match: Minimum number of `should` clauses that must match.
776768
777-
Notes:
778-
* This is the most flexible way to build complex Atlas Search queries.
779-
* Supports nesting of expressions to any depth.
769+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/compound/
780770
"""
781771

782772
def __init__(
@@ -859,10 +849,6 @@ class CombinedSearchExpression(SearchExpression):
859849
lhs: The left-hand search expression.
860850
operator: The boolean operator as a string (e.g., "and", "or", "not").
861851
rhs: The right-hand search expression.
862-
863-
Notes:
864-
* The operator must be supported by MongoDB Atlas Search boolean logic.
865-
* This class enables building complex nested search queries.
866852
"""
867853

868854
def __init__(self, lhs, operator, rhs):
@@ -917,10 +903,7 @@ class SearchVector(SearchExpression):
917903
exact: Optional flag to enforce exact matching.
918904
filter: Optional filter expression to narrow candidate documents.
919905
920-
Notes:
921-
* The vector field must be indexed as a vector type in Atlas Search.
922-
* Parameters like `num_candidates` and `exact` control search
923-
performance and accuracy trade-offs.
906+
Reference: https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-stage/
924907
"""
925908

926909
def __init__(

tests/queries_/test_search.py

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from time import monotonic, sleep
44

55
from django.db import connection
6+
from django.db.models import Q
67
from django.db.utils import DatabaseError
78
from django.test import TransactionTestCase, skipUnlessDBFeature
89
from pymongo.operations import SearchIndexModel
@@ -463,7 +464,7 @@ def setUp(self):
463464
"mappings": {
464465
"dynamic": False,
465466
"fields": {
466-
"headline": {"type": "token"},
467+
"headline": [{"type": "token"}, {"type": "string"}],
467468
"body": {"type": "string"},
468469
"number": {"type": "number"},
469470
},
@@ -498,7 +499,7 @@ def tearDown(self):
498499
self._tear_down(Article)
499500
super().tearDown()
500501

501-
def test_compound_expression(self):
502+
def test_expression(self):
502503
must_expr = SearchEquals(path="headline", value="space exploration")
503504
must_not_expr = SearchPhrase(path="body", query="icy moons")
504505
should_expr = SearchPhrase(path="body", query="exoplanets")
@@ -513,7 +514,7 @@ def test_compound_expression(self):
513514
qs = Article.objects.annotate(score=compound).order_by("score")
514515
self.wait_for_assertion(lambda: self.assertCountEqual(qs.all(), [self.exoplanet]))
515516

516-
def test_compound_operations(self):
517+
def test_operations(self):
517518
expr = SearchEquals(path="headline", value="space exploration") & ~SearchEquals(
518519
path="number", value=3
519520
)
@@ -522,6 +523,65 @@ def test_compound_operations(self):
522523
lambda: self.assertCountEqual(qs.all(), [self.mars_mission, self.exoplanet])
523524
)
524525

526+
def test_multiple_search(self):
527+
msg = (
528+
"Only one $search operation is allowed per query. Received 2 search expressions. "
529+
"To combine multiple search expressions, use either a CompoundExpression for "
530+
"fine-grained control or CombinedSearchExpression for simple logical combinations."
531+
)
532+
with self.assertRaisesMessage(ValueError, msg):
533+
Article.objects.annotate(
534+
score1=SearchEquals(path="headline", value="space exploration"),
535+
score2=~SearchEquals(path="number", value=3),
536+
).order_by("score1", "score2").first()
537+
538+
with self.assertRaisesMessage(ValueError, msg):
539+
Article.objects.filter(
540+
Q(headline__search="space exploration"), Q(headline__search="space exploration 2")
541+
).first()
542+
543+
def test_multiple_type_search(self):
544+
msg = (
545+
"Cannot combine a `$vectorSearch` with a `$search` operator. "
546+
"If you need to combine them, consider "
547+
"restructuring your query logic or running them as separate queries."
548+
)
549+
with self.assertRaisesMessage(ValueError, msg):
550+
Article.objects.annotate(
551+
score1=SearchEquals(path="headline", value="space exploration"),
552+
score2=SearchVector(
553+
path="headline",
554+
query_vector=[1, 2, 3],
555+
num_candidates=5,
556+
limit=2,
557+
),
558+
).order_by("score1", "score2").first()
559+
560+
def test_multiple_vector_search(self):
561+
msg = (
562+
"Cannot combine two `$vectorSearch` operator. If you need to combine them, "
563+
"consider restructuring your query logic or running them as separate queries."
564+
)
565+
with self.assertRaisesMessage(ValueError, msg):
566+
Article.objects.annotate(
567+
score1=SearchVector(
568+
path="headline",
569+
query_vector=[1, 2, 3],
570+
num_candidates=5,
571+
limit=2,
572+
),
573+
score2=SearchVector(
574+
path="headline",
575+
query_vector=[1, 2, 4],
576+
num_candidates=5,
577+
limit=2,
578+
),
579+
).order_by("score1", "score2").first()
580+
581+
def test_search_and_filter(self):
582+
qs = Article.objects.filter(headline__search="space exploration", number__gt=2)
583+
self.wait_for_assertion(lambda: self.assertCountEqual(qs.all(), [self.icy_moons]))
584+
525585

526586
@skipUnlessDBFeature("supports_atlas_search")
527587
class SearchVectorTest(SearchUtilsMixin):

0 commit comments

Comments
 (0)