Skip to content

Commit 6686477

Browse files
committed
Add Autocomplete operator.
1 parent 310a8cc commit 6686477

File tree

1 file changed

+108
-4
lines changed

1 file changed

+108
-4
lines changed
Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,115 @@
1-
from django.db.models import Expression
1+
from django.db.models import Expression, FloatField
2+
from django.db.models.expressions import F, Value
3+
4+
5+
def cast_as_value(value):
6+
if value is None:
7+
return None
8+
return Value(value) if not hasattr(value, "resolve_expression") else value
9+
10+
11+
def cast_as_field(path):
12+
return F(path) if isinstance(path, str) else path
213

314

415
class SearchExpression(Expression):
5-
"""Base expression node for MongoDB Atlas `$search` stages."""
16+
"""Base expression node for MongoDB Atlas `$search` stages.
17+
18+
This class bridges Django's `Expression` API with the MongoDB Atlas
19+
Search engine. Subclasses produce the operator document placed under
20+
**$search** and expose the stage to queryset methods such as
21+
`annotate()`, `filter()`, or `order_by()`.
22+
"""
23+
24+
output_field = FloatField()
25+
26+
def __str__(self):
27+
cls = self.identity[0]
28+
kwargs = dict(self.identity[1:])
29+
arg_str = ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
30+
return f"<{cls.__name__}({arg_str})>"
31+
32+
def __repr__(self):
33+
return str(self)
34+
35+
def as_sql(self, compiler, connection):
36+
return "", []
37+
38+
def _get_indexed_fields(self, mappings):
39+
if isinstance(mappings, list):
40+
for definition in mappings:
41+
yield from self._get_indexed_fields(definition)
42+
else:
43+
for field, definition in mappings.get("fields", {}).items():
44+
yield field
45+
for path in self._get_indexed_fields(definition):
46+
yield f"{field}.{path}"
47+
48+
def _get_query_index(self, fields, compiler):
49+
fields = set(fields)
50+
for search_indexes in compiler.collection.list_search_indexes():
51+
mappings = search_indexes["latestDefinition"]["mappings"]
52+
indexed_fields = set(self._get_indexed_fields(mappings))
53+
if mappings["dynamic"] or fields.issubset(indexed_fields):
54+
return search_indexes["name"]
55+
return "default"
656

57+
def search_operator(self, compiler, connection):
58+
raise NotImplementedError
759

8-
class SearchVector(SearchExpression):
60+
def as_mql(self, compiler, connection):
61+
index = self._get_query_index(self.get_search_fields(compiler, connection), compiler)
62+
return {"$search": {**self.search_operator(compiler, connection), "index": index}}
63+
64+
65+
class SearchAutocomplete(SearchExpression):
966
"""
10-
Atlas Search expression that performs vector similarity search on embedded vectors.
67+
Atlas Search expression that matches input using the `autocomplete` operator.
68+
69+
This expression enables autocomplete behavior by querying against a field
70+
indexed as `"type": "autocomplete"` in MongoDB Atlas. It can be used in
71+
`filter()`, `annotate()` or any context that accepts a Django expression.
72+
73+
Example:
74+
SearchAutocomplete("title", "harry", fuzzy={"maxEdits": 1})
75+
76+
Args:
77+
path: The document path to search (as string or expression).
78+
query: The input string to autocomplete.
79+
fuzzy: Optional dictionary of fuzzy matching parameters.
80+
token_order: Optional value for `"tokenOrder"`; controls sequential vs.
81+
any-order token matching.
82+
score: Optional expression to adjust score relevance (e.g., `{"boost": {"value": 5}}`).
83+
84+
Reference: https://www.mongodb.com/docs/atlas/atlas-search/autocomplete/
1185
"""
86+
87+
def __init__(self, path, query, fuzzy=None, token_order=None, score=None):
88+
self.path = cast_as_field(path)
89+
self.query = cast_as_value(query)
90+
self.fuzzy = cast_as_value(fuzzy)
91+
self.token_order = cast_as_value(token_order)
92+
self.score = score
93+
super().__init__()
94+
95+
def get_source_expressions(self):
96+
return [self.path, self.query, self.fuzzy, self.token_order]
97+
98+
def set_source_expressions(self, exprs):
99+
self.path, self.query, self.fuzzy, self.token_order = exprs
100+
101+
def get_search_fields(self, compiler, connection):
102+
return {self.path.as_mql(compiler, connection, as_path=True)}
103+
104+
def search_operator(self, compiler, connection):
105+
params = {
106+
"path": self.path.as_mql(compiler, connection, as_path=True),
107+
"query": self.query.value,
108+
}
109+
if self.score is not None:
110+
params["score"] = self.score.as_mql(compiler, connection)
111+
if self.fuzzy is not None:
112+
params["fuzzy"] = self.fuzzy.value
113+
if self.token_order is not None:
114+
params["tokenOrder"] = self.token_order.value
115+
return {"autocomplete": params}

0 commit comments

Comments
 (0)