|
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 |
2 | 13 |
|
3 | 14 |
|
4 | 15 | 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" |
6 | 56 |
|
| 57 | + def search_operator(self, compiler, connection): |
| 58 | + raise NotImplementedError |
7 | 59 |
|
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): |
9 | 66 | """
|
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/ |
11 | 85 | """
|
| 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