Source code for drf_haystack.query

import operator
import warnings
from functools import reduce
from itertools import chain

from dateutil import parser
from django.core.exceptions import ImproperlyConfigured

from drf_haystack import constants
from drf_haystack.utils import merge_dict


[docs] class BaseQueryBuilder: """ Query builder base class. """ def __init__(self, backend, view): self.backend = backend self.view = view
[docs] def build_query(self, **filters): """ :param dict[str, list[str]] filters: is an expanded QueryDict or a mapping of keys to a list of parameters. """ raise NotImplementedError("You should override this method in subclasses.")
[docs] @staticmethod def tokenize(stream, separator): """ Tokenize and yield query parameter values. :param stream: Input value :param separator: Character to use to separate the tokens. :return: """ for value in stream: for token in value.split(separator): if token: yield token.strip()
[docs] class BoostQueryBuilder(BaseQueryBuilder): """ Query builder class for adding boost to queries. """
[docs] def build_query(self, **filters): applicable_filters = None query_param = getattr(self.backend, "query_param", None) value = filters.pop(query_param, None) if value: try: term, val = chain.from_iterable(zip(self.tokenize(value, self.view.lookup_sep))) except ValueError: raise ValueError(f"Cannot convert the '{query_param}' query parameter to a valid boost filter.") else: try: applicable_filters = {"term": term, "boost": float(val)} except ValueError: raise ValueError( "Cannot convert boost to float value. Make sure to provide a numerical boost value." ) return applicable_filters
[docs] class FilterQueryBuilder(BaseQueryBuilder): """ Query builder class suitable for doing basic filtering. """ def __init__(self, backend, view): super().__init__(backend, view) assert getattr(self.backend, "default_operator", None) in (operator.and_, operator.or_), ( f"{self.backend.__class__.__name__}.default_operator must be either 'operator.and_' or 'operator.or_'." ) self.default_operator = self.backend.default_operator self.default_same_param_operator = getattr(self.backend, "default_same_param_operator", self.default_operator)
[docs] def get_same_param_operator(self, param): """ Helper method to allow per param configuration of which operator should be used when multiple filters for the same param are found. :param str param: is the param for which you want to get the operator :return: Either operator.or_ or operator.and_ """ return self.default_same_param_operator
[docs] def build_query(self, **filters): """ Creates a single SQ filter from querystring parameters that correspond to the SearchIndex fields that have been "registered" in `view.fields`. Default behavior is to `OR` terms for the same parameters, and `AND` between parameters. Any querystring parameters that are not registered in `view.fields` will be ignored. :param dict[str, list[str]] filters: is an expanded QueryDict or a mapping of keys to a list of parameters. """ applicable_filters = [] applicable_exclusions = [] for param, value in filters.items(): excluding_term = False param_parts = param.split("__") base_param = param_parts[0] # only test against field without lookup negation_keyword = constants.DRF_HAYSTACK_NEGATION_KEYWORD if len(param_parts) > 1 and param_parts[1] == negation_keyword: excluding_term = True param = param.replace(f"__{negation_keyword}", "") # haystack wouldn't understand our negation if self.view.serializer_class: if hasattr(self.view.serializer_class.Meta, "field_aliases"): old_base = base_param base_param = self.view.serializer_class.Meta.field_aliases.get(base_param, base_param) param = param.replace(old_base, base_param) # need to replace the alias fields = getattr(self.view.serializer_class.Meta, "fields", []) exclude = getattr(self.view.serializer_class.Meta, "exclude", []) search_fields = getattr(self.view.serializer_class.Meta, "search_fields", []) # Skip if the parameter is not listed in the serializer's `fields` # or if it's in the `exclude` list. if ( ((fields or search_fields) and base_param not in chain(fields, search_fields)) or base_param in exclude or not value ): continue param_queries = [] if len(param_parts) > 1 and param_parts[-1] in ("in", "range"): # `in` and `range` filters expects a list of values param_queries.append(self.view.query_object((param, list(self.tokenize(value, self.view.lookup_sep))))) else: for token in self.tokenize(value, self.view.lookup_sep): param_queries.append(self.view.query_object((param, token))) param_queries = [pq for pq in param_queries if pq] if len(param_queries) > 0: term = reduce(self.get_same_param_operator(param), param_queries) if excluding_term: applicable_exclusions.append(term) else: applicable_filters.append(term) applicable_filters = ( reduce(self.default_operator, filter(lambda x: x, applicable_filters)) if applicable_filters else self.view.query_object() ) applicable_exclusions = ( reduce(self.default_operator, filter(lambda x: x, applicable_exclusions)) if applicable_exclusions else self.view.query_object() ) return applicable_filters, applicable_exclusions
[docs] class FacetQueryBuilder(BaseQueryBuilder): """ Query builder class suitable for constructing faceted queries. """
[docs] def build_query(self, **filters): """ Creates a dict of dictionaries suitable for passing to the SearchQuerySet `facet`, `date_facet` or `query_facet` method. All key word arguments should be wrapped in a list. :param view: API View :param dict[str, list[str]] filters: is an expanded QueryDict or a mapping of keys to a list of parameters. """ field_facets = {} date_facets = {} query_facets = {} facet_serializer_cls = self.view.get_facet_serializer_class() if self.view.lookup_sep == ":": raise AttributeError( f"The {self.view.__class__.__name__}.lookup_sep attribute conflicts with the HaystackFacetFilter " "query parameter parser. Please choose another `lookup_sep` attribute " f"for {self.view.__class__.__name__}." ) fields = facet_serializer_cls.Meta.fields exclude = facet_serializer_cls.Meta.exclude field_options = facet_serializer_cls.Meta.field_options for field, options in filters.items(): if field not in fields or field in exclude: continue field_options = merge_dict(field_options, {field: self.parse_field_options(self.view.lookup_sep, *options)}) valid_gap = ("year", "month", "day", "hour", "minute", "second") for field, options in field_options.items(): if any(k in options for k in ("start_date", "end_date", "gap_by", "gap_amount")): if not all(("start_date", "end_date", "gap_by" in options)): raise ValueError("Date faceting requires at least 'start_date', 'end_date' and 'gap_by' to be set.") if options["gap_by"] not in valid_gap: raise ValueError("The 'gap_by' parameter must be one of {}.".format(", ".join(valid_gap))) options.setdefault("gap_amount", 1) date_facets[field] = options else: field_facets[field] = options return {"date_facets": date_facets, "field_facets": field_facets, "query_facets": query_facets}
[docs] def parse_field_options(self, *options): """ Parse the field options query string and return it as a dictionary. """ defaults = {} for option in options: if isinstance(option, str): tokens = [token.strip() for token in option.split(self.view.lookup_sep)] for token in tokens: if len(token.split(":")) != 2: warnings.warn( f"The {token} token is not properly formatted. Tokens need to be " "formatted as 'token:value' pairs." ) continue param, value = token.split(":", 1) if any(k == param for k in ("start_date", "end_date", "gap_amount")): if param in ("start_date", "end_date"): value = parser.parse(value) if param == "gap_amount": value = int(value) defaults[param] = value return defaults
[docs] class SpatialQueryBuilder(BaseQueryBuilder): """ Query builder class suitable for construction spatial queries. """ def __init__(self, backend, view): super().__init__(backend, view) assert getattr(self.backend, "point_field", None) is not None, ( f"{self.backend.__class__.__name__}.point_field cannot be None. Set the {self.backend.__class__.__name__}.point_field " "to the name of the `LocationField` you want to filter on your index class." ) try: from django.contrib.gis.geos import Point from django.contrib.gis.measure import D self.D = D self.Point = Point except ImproperlyConfigured: warnings.warn( "Make sure you've installed the ``GDAL`` library (which also pulls in GEOS). " "Run `apt install gdal-bin` on debian based linux systems, " "or `brew install gdal` on OS X." ) raise
[docs] def build_query(self, **filters): """ Build queries for geo spatial filtering. Expected query parameters are: - a `unit=value` parameter where the unit is a valid UNIT in the `django.contrib.gis.measure.Distance` class. - `from` which must be a comma separated latitude and longitude. Example query: /api/v1/search/?km=10&from=59.744076,10.152045 Will perform a `dwithin` query within 10 km from the point with latitude 59.744076 and longitude 10.152045. """ applicable_filters = None filters = { k: filters[k] for k in chain(self.D.UNITS.keys(), [constants.DRF_HAYSTACK_SPATIAL_QUERY_PARAM]) if k in filters } distance = {k: v for k, v in filters.items() if k in self.D.UNITS} try: latitude, longitude = map( float, self.tokenize(filters[constants.DRF_HAYSTACK_SPATIAL_QUERY_PARAM], self.view.lookup_sep) ) point = self.Point(longitude, latitude, srid=constants.GEO_SRID) except ValueError: raise ValueError( "Cannot convert `from=latitude,longitude` query parameter to " "float values. Make sure to provide numerical values only!" ) except KeyError: # If the user has not provided any `from` query string parameter, # just return. pass else: for unit, value in distance.items(): if not len(value) == 1: raise ValueError("Each unit must have exactly one value.") distance[unit] = float(value[0]) if point and distance: applicable_filters = { "dwithin": {"field": self.backend.point_field, "point": point, "distance": self.D(**distance)}, "distance": {"field": self.backend.point_field, "point": point}, } return applicable_filters