Skip to content

Commit 93244ac

Browse files
committed
Clean ups.
1 parent 479d094 commit 93244ac

File tree

4 files changed

+406
-91
lines changed

4 files changed

+406
-91
lines changed

django_mongodb_backend/compiler.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ def _prepare_search_query_for_aggregation_pipeline(self, order_by):
128128
self._prepare_search_expressions_for_pipeline(
129129
self.having, annotation_group_idx, replacements
130130
)
131+
self._prepare_search_expressions_for_pipeline(
132+
self.get_where(), annotation_group_idx, replacements
133+
)
131134
return replacements
132135

133136
def _prepare_annotations_for_aggregation_pipeline(self, order_by):
@@ -206,9 +209,6 @@ def _get_group_id_expressions(self, order_by):
206209
ids = self.get_project_fields(tuple(columns), force_expression=True)
207210
return ids, replacements
208211

209-
def _build_search_pipeline(self, search_queries):
210-
pass
211-
212212
def _build_aggregation_pipeline(self, ids, group):
213213
"""Build the aggregation pipeline for grouping."""
214214
pipeline = []
@@ -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
}
@@ -291,6 +312,9 @@ def pre_sql_setup(self, with_col_aliases=False):
291312
for target, expr in self.query.annotation_select.items()
292313
}
293314
self.order_by_objs = [expr.replace_expressions(all_replacements) for expr, _ in order_by]
315+
if (where := self.get_where()) and search_replacements:
316+
where = where.replace_expressions(search_replacements)
317+
self.set_where(where)
294318
return extra_select, order_by, group_by
295319

296320
def execute_sql(
@@ -692,6 +716,9 @@ def _get_ordering(self):
692716
def get_where(self):
693717
return getattr(self, "where", self.query.where)
694718

719+
def set_where(self, value):
720+
self.where = value
721+
695722
def explain_query(self):
696723
# Validate format (none supported) and options.
697724
options = self.connection.ops.explain_query_prefix(

django_mongodb_backend/expressions/search.py

Lines changed: 74 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from django.db import NotSupportedError
2-
from django.db.models import Expression, FloatField
2+
from django.db.models import CharField, Expression, FloatField, TextField
33
from django.db.models.expressions import F, Value
4+
from django.db.models.lookups import Lookup
5+
6+
from ..query_utils import process_lhs, process_rhs
47

58

69
def cast_as_value(value):
@@ -68,7 +71,7 @@ def __or__(self, other):
6871
return self._combine(other, Operator(Operator.OR))
6972

7073
def __ror__(self, other):
71-
return self._combine(self, Operator(Operator.OR), other)
74+
return self._combine(other, Operator(Operator.OR))
7275

7376

7477
class SearchExpression(SearchCombinable, Expression):
@@ -86,22 +89,23 @@ def __str__(self):
8689
cls = self.identity[0]
8790
kwargs = dict(self.identity[1:])
8891
arg_str = ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
89-
return f"{cls.__name__}({arg_str})"
92+
return f"<{cls.__name__}({arg_str})>"
9093

9194
def __repr__(self):
9295
return str(self)
9396

9497
def as_sql(self, compiler, connection):
9598
return "", []
9699

97-
def get_source_expressions(self):
98-
return []
99-
100100
def _get_indexed_fields(self, mappings):
101-
for field, definition in mappings.get("fields", {}).items():
102-
yield field
103-
for path in self._get_indexed_fields(definition):
104-
yield f"{field}.{path}"
101+
if isinstance(mappings, list):
102+
for definition in mappings:
103+
yield from self._get_indexed_fields(definition)
104+
else:
105+
for field, definition in mappings.get("fields", {}).items():
106+
yield field
107+
for path in self._get_indexed_fields(definition):
108+
yield f"{field}.{path}"
105109

106110
def _get_query_index(self, fields, compiler):
107111
fields = set(fields)
@@ -139,9 +143,7 @@ class SearchAutocomplete(SearchExpression):
139143
any-order token matching.
140144
score: Optional expression to adjust score relevance (e.g., `{"boost": {"value": 5}}`).
141145
142-
Notes:
143-
* Requires an Atlas Search index with `autocomplete` mappings.
144-
* The operator is injected under the `$search` stage in the aggregation pipeline.
146+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/autocomplete/
145147
"""
146148

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

199198
def __init__(self, path, value, score=None):
@@ -236,9 +235,7 @@ class SearchExists(SearchExpression):
236235
path: The document path to check (as string or expression).
237236
score: Optional expression to modify the relevance score.
238237
239-
Notes:
240-
* The target field must be mapped in the Atlas Search index.
241-
* This does not test for null—only for presence.
238+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/exists/
242239
"""
243240

244241
def __init__(self, path, score=None):
@@ -260,11 +257,28 @@ def search_operator(self, compiler, connection):
260257
"path": self.path.as_mql(compiler, connection, as_path=True),
261258
}
262259
if self.score is not None:
263-
params["score"] = self.score.definitions
260+
params["score"] = self.score.as_mql(compiler, connection)
264261
return {"exists": params}
265262

266263

267264
class SearchIn(SearchExpression):
265+
"""
266+
Atlas Search expression that matches documents where the field value is in a given list.
267+
268+
This expression uses the **in** operator to match documents whose field
269+
contains a value from the provided array of values.
270+
271+
Example:
272+
SearchIn("status", ["pending", "approved", "rejected"])
273+
274+
Args:
275+
path: The document path to match against (as string or expression).
276+
value: A list of values to check for membership.
277+
score: Optional expression to adjust the relevance score.
278+
279+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/in/
280+
"""
281+
268282
def __init__(self, path, value, score=None):
269283
self.path = cast_as_field(path)
270284
self.value = cast_as_value(value)
@@ -294,7 +308,7 @@ class SearchPhrase(SearchExpression):
294308
"""
295309
Atlas Search expression that matches a phrase in the specified field.
296310
297-
This expression uses the **phrase** operator to search for exact or near-exact
311+
This expression uses the **phrase** operator to search for exact or near exact
298312
sequences of terms. It supports optional slop (word distance) and synonym sets.
299313
300314
Example:
@@ -307,10 +321,7 @@ class SearchPhrase(SearchExpression):
307321
synonyms: Optional name of a synonym mapping defined in the Atlas index.
308322
score: Optional expression to modify the relevance score.
309323
310-
Notes:
311-
* The field must be mapped as `"type": "string"` with appropriate analyzers.
312-
* Slop allows flexibility in word positioning, like `"quick brown fox"`
313-
matching `"quick fox"` if `slop=1`.
324+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/phrase/
314325
"""
315326

316327
def __init__(self, path, query, slop=None, synonyms=None, score=None):
@@ -360,9 +371,7 @@ class SearchQueryString(SearchExpression):
360371
query: The Lucene-style query string.
361372
score: Optional expression to modify the relevance score.
362373
363-
Notes:
364-
* The query string syntax must conform to Atlas Search rules.
365-
* This operator is powerful but can be harder to validate or sanitize.
374+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/queryString/
366375
"""
367376

368377
def __init__(self, path, query, score=None):
@@ -408,9 +417,7 @@ class SearchRange(SearchExpression):
408417
gte: Optional inclusive lower bound (`>=`).
409418
score: Optional expression to modify the relevance score.
410419
411-
Notes:
412-
* At least one of `lt`, `lte`, `gt`, or `gte` must be provided.
413-
* The field must be mapped in the Atlas Search index as a comparable type.
420+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/range/
414421
"""
415422

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

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

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

581579
def __init__(self, path, query, allow_analyzed_field=None, score=None):
@@ -600,7 +598,7 @@ def search_operator(self, compiler, connection):
600598
"query": self.query.value,
601599
}
602600
if self.score:
603-
params["score"] = self.score.query.as_mql(compiler, connection)
601+
params["score"] = self.score.as_mql(compiler, connection)
604602
if self.allow_analyzed_field is not None:
605603
params["allowAnalyzedField"] = self.allow_analyzed_field.value
606604
return {"wildcard": params}
@@ -622,9 +620,7 @@ class SearchGeoShape(SearchExpression):
622620
geometry: The GeoJSON geometry to compare against.
623621
score: Optional expression to modify the relevance score.
624622
625-
Notes:
626-
* The field must be indexed as a geo shape type in Atlas Search.
627-
* Geometry must conform to GeoJSON specification.
623+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/geoShape/
628624
"""
629625

630626
def __init__(self, path, relation, geometry, score=None):
@@ -671,9 +667,7 @@ class SearchGeoWithin(SearchExpression):
671667
geo_object: The GeoJSON geometry defining the boundary.
672668
score: Optional expression to adjust the relevance score.
673669
674-
Notes:
675-
* The geo field must be indexed appropriately in the Atlas Search index.
676-
* The geometry must follow GeoJSON format.
670+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/geoWithin/
677671
"""
678672

679673
def __init__(self, path, kind, geo_object, score=None):
@@ -716,9 +710,7 @@ class SearchMoreLikeThis(SearchExpression):
716710
documents: A list of example documents or expressions to find similar documents.
717711
score: Optional expression to modify the relevance scoring.
718712
719-
Notes:
720-
* The documents should be representative examples to base similarity on.
721-
* Supports various field types depending on the Atlas Search configuration.
713+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/morelikethis/
722714
"""
723715

724716
def __init__(self, documents, score=None):
@@ -771,9 +763,7 @@ class CompoundExpression(SearchExpression):
771763
score: Optional expression to adjust scoring.
772764
minimum_should_match: Minimum number of `should` clauses that must match.
773765
774-
Notes:
775-
* This is the most flexible way to build complex Atlas Search queries.
776-
* Supports nesting of expressions to any depth.
766+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/compound/
777767
"""
778768

779769
def __init__(
@@ -856,10 +846,6 @@ class CombinedSearchExpression(SearchExpression):
856846
lhs: The left-hand search expression.
857847
operator: The boolean operator as a string (e.g., "and", "or", "not").
858848
rhs: The right-hand search expression.
859-
860-
Notes:
861-
* The operator must be supported by MongoDB Atlas Search boolean logic.
862-
* This class enables building complex nested search queries.
863849
"""
864850

865851
def __init__(self, lhs, operator, rhs):
@@ -914,10 +900,7 @@ class SearchVector(SearchExpression):
914900
exact: Optional flag to enforce exact matching.
915901
filter: Optional filter expression to narrow candidate documents.
916902
917-
Notes:
918-
* The vector field must be indexed as a vector type in Atlas Search.
919-
* Parameters like `num_candidates` and `exact` control search
920-
performance and accuracy trade-offs.
903+
Reference: https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-stage/
921904
"""
922905

923906
def __init__(
@@ -1005,7 +988,31 @@ class SearchScoreOption(Expression):
1005988
"""Class to mutate scoring on a search operation"""
1006989

1007990
def __init__(self, definitions=None):
1008-
self.definitions = definitions
991+
self._definitions = definitions
992+
993+
def as_mql(self, compiler, connection):
994+
return self._definitions
995+
996+
997+
class SearchTextLookup(Lookup):
998+
lookup_name = "search"
999+
1000+
def __init__(self, lhs, rhs):
1001+
super().__init__(lhs, rhs)
1002+
self.lhs = SearchText(self.lhs, self.rhs)
1003+
self.rhs = Value(0)
1004+
1005+
def __str__(self):
1006+
return f"SearchText({self.lhs}, {self.rhs})"
1007+
1008+
def __repr__(self):
1009+
return f"SearchText({self.lhs}, {self.rhs})"
10091010

10101011
def as_mql(self, compiler, connection):
1011-
return self.definitions
1012+
lhs_mql = process_lhs(self, compiler, connection)
1013+
value = process_rhs(self, compiler, connection)
1014+
return {"$gte": [lhs_mql, value]}
1015+
1016+
1017+
CharField.register_lookup(SearchTextLookup)
1018+
TextField.register_lookup(SearchTextLookup)

django_mongodb_backend/query_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55

66
def is_direct_value(node):
7-
return not hasattr(node, "as_sql") and not hasattr(node, "as_mql")
7+
return not hasattr(node, "as_sql")
88

99

1010
def process_lhs(node, compiler, connection):

0 commit comments

Comments
 (0)