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/__init__.py +6 -0
- drf_haystack/constants.py +5 -0
- drf_haystack/fields.py +102 -0
- drf_haystack/filters.py +257 -0
- drf_haystack/generics.py +102 -0
- drf_haystack/mixins.py +116 -0
- drf_haystack/query.py +331 -0
- drf_haystack/serializers.py +489 -0
- drf_haystack/utils.py +26 -0
- drf_haystack/viewsets.py +11 -0
- drf_haystack-1.9.3.dist-info/METADATA +101 -0
- drf_haystack-1.9.3.dist-info/RECORD +13 -0
- drf_haystack-1.9.3.dist-info/WHEEL +4 -0
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
|