drf-haystack 1.9.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
drf_haystack/query.py ADDED
@@ -0,0 +1,331 @@
1
+ import operator
2
+ import warnings
3
+ from functools import reduce
4
+ from itertools import chain
5
+
6
+ from dateutil import parser
7
+ from django.core.exceptions import ImproperlyConfigured
8
+
9
+ from drf_haystack import constants
10
+ from drf_haystack.utils import merge_dict
11
+
12
+
13
+ class BaseQueryBuilder:
14
+ """
15
+ Query builder base class.
16
+ """
17
+
18
+ def __init__(self, backend, view):
19
+ self.backend = backend
20
+ self.view = view
21
+
22
+ def build_query(self, **filters):
23
+ """
24
+ :param dict[str, list[str]] filters: is an expanded QueryDict or
25
+ a mapping of keys to a list of parameters.
26
+ """
27
+ raise NotImplementedError("You should override this method in subclasses.")
28
+
29
+ @staticmethod
30
+ def tokenize(stream, separator):
31
+ """
32
+ Tokenize and yield query parameter values.
33
+
34
+ :param stream: Input value
35
+ :param separator: Character to use to separate the tokens.
36
+ :return:
37
+ """
38
+ for value in stream:
39
+ for token in value.split(separator):
40
+ if token:
41
+ yield token.strip()
42
+
43
+
44
+ class BoostQueryBuilder(BaseQueryBuilder):
45
+ """
46
+ Query builder class for adding boost to queries.
47
+ """
48
+
49
+ def build_query(self, **filters):
50
+
51
+ applicable_filters = None
52
+ query_param = getattr(self.backend, "query_param", None)
53
+
54
+ value = filters.pop(query_param, None)
55
+ if value:
56
+ try:
57
+ term, val = chain.from_iterable(zip(self.tokenize(value, self.view.lookup_sep)))
58
+ except ValueError:
59
+ raise ValueError(f"Cannot convert the '{query_param}' query parameter to a valid boost filter.")
60
+ else:
61
+ try:
62
+ applicable_filters = {"term": term, "boost": float(val)}
63
+ except ValueError:
64
+ raise ValueError(
65
+ "Cannot convert boost to float value. Make sure to provide a numerical boost value."
66
+ )
67
+
68
+ return applicable_filters
69
+
70
+
71
+ class FilterQueryBuilder(BaseQueryBuilder):
72
+ """
73
+ Query builder class suitable for doing basic filtering.
74
+ """
75
+
76
+ def __init__(self, backend, view):
77
+ super().__init__(backend, view)
78
+
79
+ assert getattr(self.backend, "default_operator", None) in (operator.and_, operator.or_), (
80
+ f"{self.backend.__class__.__name__}.default_operator must be either 'operator.and_' or 'operator.or_'."
81
+ )
82
+ self.default_operator = self.backend.default_operator
83
+ self.default_same_param_operator = getattr(self.backend, "default_same_param_operator", self.default_operator)
84
+
85
+ def get_same_param_operator(self, param):
86
+ """
87
+ Helper method to allow per param configuration of which operator should be used when multiple filters for the
88
+ same param are found.
89
+
90
+ :param str param: is the param for which you want to get the operator
91
+ :return: Either operator.or_ or operator.and_
92
+ """
93
+ return self.default_same_param_operator
94
+
95
+ def build_query(self, **filters):
96
+ """
97
+ Creates a single SQ filter from querystring parameters that correspond to the SearchIndex fields
98
+ that have been "registered" in `view.fields`.
99
+
100
+ Default behavior is to `OR` terms for the same parameters, and `AND` between parameters. Any
101
+ querystring parameters that are not registered in `view.fields` will be ignored.
102
+
103
+ :param dict[str, list[str]] filters: is an expanded QueryDict or a mapping of keys to a list of
104
+ parameters.
105
+ """
106
+
107
+ applicable_filters = []
108
+ applicable_exclusions = []
109
+
110
+ for param, value in filters.items():
111
+ excluding_term = False
112
+ param_parts = param.split("__")
113
+ base_param = param_parts[0] # only test against field without lookup
114
+ negation_keyword = constants.DRF_HAYSTACK_NEGATION_KEYWORD
115
+ if len(param_parts) > 1 and param_parts[1] == negation_keyword:
116
+ excluding_term = True
117
+ param = param.replace(f"__{negation_keyword}", "") # haystack wouldn't understand our negation
118
+
119
+ if self.view.serializer_class:
120
+ if hasattr(self.view.serializer_class.Meta, "field_aliases"):
121
+ old_base = base_param
122
+ base_param = self.view.serializer_class.Meta.field_aliases.get(base_param, base_param)
123
+ param = param.replace(old_base, base_param) # need to replace the alias
124
+
125
+ fields = getattr(self.view.serializer_class.Meta, "fields", [])
126
+ exclude = getattr(self.view.serializer_class.Meta, "exclude", [])
127
+ search_fields = getattr(self.view.serializer_class.Meta, "search_fields", [])
128
+
129
+ # Skip if the parameter is not listed in the serializer's `fields`
130
+ # or if it's in the `exclude` list.
131
+ if (
132
+ ((fields or search_fields) and base_param not in chain(fields, search_fields))
133
+ or base_param in exclude
134
+ or not value
135
+ ):
136
+ continue
137
+
138
+ param_queries = []
139
+ if len(param_parts) > 1 and param_parts[-1] in ("in", "range"):
140
+ # `in` and `range` filters expects a list of values
141
+ param_queries.append(self.view.query_object((param, list(self.tokenize(value, self.view.lookup_sep)))))
142
+ else:
143
+ for token in self.tokenize(value, self.view.lookup_sep):
144
+ param_queries.append(self.view.query_object((param, token)))
145
+
146
+ param_queries = [pq for pq in param_queries if pq]
147
+ if len(param_queries) > 0:
148
+ term = reduce(self.get_same_param_operator(param), param_queries)
149
+ if excluding_term:
150
+ applicable_exclusions.append(term)
151
+ else:
152
+ applicable_filters.append(term)
153
+
154
+ applicable_filters = (
155
+ reduce(self.default_operator, filter(lambda x: x, applicable_filters))
156
+ if applicable_filters
157
+ else self.view.query_object()
158
+ )
159
+
160
+ applicable_exclusions = (
161
+ reduce(self.default_operator, filter(lambda x: x, applicable_exclusions))
162
+ if applicable_exclusions
163
+ else self.view.query_object()
164
+ )
165
+
166
+ return applicable_filters, applicable_exclusions
167
+
168
+
169
+ class FacetQueryBuilder(BaseQueryBuilder):
170
+ """
171
+ Query builder class suitable for constructing faceted queries.
172
+ """
173
+
174
+ def build_query(self, **filters):
175
+ """
176
+ Creates a dict of dictionaries suitable for passing to the SearchQuerySet `facet`,
177
+ `date_facet` or `query_facet` method. All key word arguments should be wrapped in a list.
178
+
179
+ :param view: API View
180
+ :param dict[str, list[str]] filters: is an expanded QueryDict or a mapping
181
+ of keys to a list of parameters.
182
+ """
183
+ field_facets = {}
184
+ date_facets = {}
185
+ query_facets = {}
186
+ facet_serializer_cls = self.view.get_facet_serializer_class()
187
+
188
+ if self.view.lookup_sep == ":":
189
+ raise AttributeError(
190
+ f"The {self.view.__class__.__name__}.lookup_sep attribute conflicts with the HaystackFacetFilter "
191
+ "query parameter parser. Please choose another `lookup_sep` attribute "
192
+ f"for {self.view.__class__.__name__}."
193
+ )
194
+
195
+ fields = facet_serializer_cls.Meta.fields
196
+ exclude = facet_serializer_cls.Meta.exclude
197
+ field_options = facet_serializer_cls.Meta.field_options
198
+
199
+ for field, options in filters.items():
200
+ if field not in fields or field in exclude:
201
+ continue
202
+
203
+ field_options = merge_dict(field_options, {field: self.parse_field_options(self.view.lookup_sep, *options)})
204
+
205
+ valid_gap = ("year", "month", "day", "hour", "minute", "second")
206
+ for field, options in field_options.items():
207
+ if any(k in options for k in ("start_date", "end_date", "gap_by", "gap_amount")):
208
+ if not all(("start_date", "end_date", "gap_by" in options)):
209
+ raise ValueError("Date faceting requires at least 'start_date', 'end_date' and 'gap_by' to be set.")
210
+
211
+ if options["gap_by"] not in valid_gap:
212
+ raise ValueError("The 'gap_by' parameter must be one of {}.".format(", ".join(valid_gap)))
213
+
214
+ options.setdefault("gap_amount", 1)
215
+ date_facets[field] = options
216
+
217
+ else:
218
+ field_facets[field] = options
219
+
220
+ return {"date_facets": date_facets, "field_facets": field_facets, "query_facets": query_facets}
221
+
222
+ def parse_field_options(self, *options):
223
+ """
224
+ Parse the field options query string and return it as a dictionary.
225
+ """
226
+ defaults = {}
227
+ for option in options:
228
+ if isinstance(option, str):
229
+ tokens = [token.strip() for token in option.split(self.view.lookup_sep)]
230
+
231
+ for token in tokens:
232
+ if len(token.split(":")) != 2:
233
+ warnings.warn(
234
+ f"The {token} token is not properly formatted. Tokens need to be "
235
+ "formatted as 'token:value' pairs."
236
+ )
237
+ continue
238
+
239
+ param, value = token.split(":", 1)
240
+
241
+ if any(k == param for k in ("start_date", "end_date", "gap_amount")):
242
+ if param in ("start_date", "end_date"):
243
+ value = parser.parse(value)
244
+
245
+ if param == "gap_amount":
246
+ value = int(value)
247
+
248
+ defaults[param] = value
249
+
250
+ return defaults
251
+
252
+
253
+ class SpatialQueryBuilder(BaseQueryBuilder):
254
+ """
255
+ Query builder class suitable for construction spatial queries.
256
+ """
257
+
258
+ def __init__(self, backend, view):
259
+ super().__init__(backend, view)
260
+
261
+ assert getattr(self.backend, "point_field", None) is not None, (
262
+ f"{self.backend.__class__.__name__}.point_field cannot be None. Set the {self.backend.__class__.__name__}.point_field "
263
+ "to the name of the `LocationField` you want to filter on your index class."
264
+ )
265
+
266
+ try:
267
+ from django.contrib.gis.geos import Point
268
+ from django.contrib.gis.measure import D
269
+
270
+ self.D = D
271
+ self.Point = Point
272
+ except ImproperlyConfigured:
273
+ warnings.warn(
274
+ "Make sure you've installed the ``GDAL`` library (which also pulls in GEOS). "
275
+ "Run `apt install gdal-bin` on debian based linux systems, "
276
+ "or `brew install gdal` on OS X."
277
+ )
278
+ raise
279
+
280
+ def build_query(self, **filters):
281
+ """
282
+ Build queries for geo spatial filtering.
283
+
284
+ Expected query parameters are:
285
+ - a `unit=value` parameter where the unit is a valid UNIT in the
286
+ `django.contrib.gis.measure.Distance` class.
287
+ - `from` which must be a comma separated latitude and longitude.
288
+
289
+ Example query:
290
+ /api/v1/search/?km=10&from=59.744076,10.152045
291
+
292
+ Will perform a `dwithin` query within 10 km from the point
293
+ with latitude 59.744076 and longitude 10.152045.
294
+ """
295
+
296
+ applicable_filters = None
297
+
298
+ filters = {
299
+ k: filters[k]
300
+ for k in chain(self.D.UNITS.keys(), [constants.DRF_HAYSTACK_SPATIAL_QUERY_PARAM])
301
+ if k in filters
302
+ }
303
+ distance = {k: v for k, v in filters.items() if k in self.D.UNITS}
304
+
305
+ try:
306
+ latitude, longitude = map(
307
+ float, self.tokenize(filters[constants.DRF_HAYSTACK_SPATIAL_QUERY_PARAM], self.view.lookup_sep)
308
+ )
309
+ point = self.Point(longitude, latitude, srid=constants.GEO_SRID)
310
+ except ValueError:
311
+ raise ValueError(
312
+ "Cannot convert `from=latitude,longitude` query parameter to "
313
+ "float values. Make sure to provide numerical values only!"
314
+ )
315
+ except KeyError:
316
+ # If the user has not provided any `from` query string parameter,
317
+ # just return.
318
+ pass
319
+ else:
320
+ for unit, value in distance.items():
321
+ if not len(value) == 1:
322
+ raise ValueError("Each unit must have exactly one value.")
323
+ distance[unit] = float(value[0])
324
+
325
+ if point and distance:
326
+ applicable_filters = {
327
+ "dwithin": {"field": self.backend.point_field, "point": point, "distance": self.D(**distance)},
328
+ "distance": {"field": self.backend.point_field, "point": point},
329
+ }
330
+
331
+ return applicable_filters