diff --git a/graphene_django/elasticsearch/filter/bridges.py b/graphene_django/elasticsearch/filter/bridges.py index 0de147c..a987712 100644 --- a/graphene_django/elasticsearch/filter/bridges.py +++ b/graphene_django/elasticsearch/filter/bridges.py @@ -6,10 +6,6 @@ class QuerysetBridge(object): """Taking as search, the ES search resolved by DjangoESFilterConnectionField""" self.search = search - def get_queryset(self): - """Returning self as Queryset to be the bridge""" - return self - def apply_query(self, method, *args, **kwargs): """Helper method to apply mutation to ES Query""" if hasattr(self.search, method): @@ -23,3 +19,15 @@ class QuerysetBridge(object): """Applying slice to ES and generating a QS from that""" _slice = self.search.__getitem__(k) return _slice.to_queryset() + + +class ManagerBridge(object): + """Bridge to Queryset through ES query""" + + def __init__(self, search_manager): + """Taking as search, the ES search resolved by DjangoESFilterConnectionField""" + self.search_manager = search_manager + + def get_queryset(self): + """Returning self as Queryset to be the bridge""" + return QuerysetBridge(search=self.search_manager()) diff --git a/graphene_django/elasticsearch/filter/fields.py b/graphene_django/elasticsearch/filter/fields.py index cf1219b..dfce2c8 100644 --- a/graphene_django/elasticsearch/filter/fields.py +++ b/graphene_django/elasticsearch/filter/fields.py @@ -1,6 +1,6 @@ from elasticsearch_dsl.query import Query -from graphene_django.elasticsearch.filter.bridges import QuerysetBridge +from graphene_django.elasticsearch.filter.bridges import ManagerBridge from graphene_django.filter import DjangoFilterConnectionField @@ -19,11 +19,14 @@ class DjangoESFilterConnectionField(DjangoFilterConnectionField): filterset_class = kwargs.get('filterset_class', None) if filterset_class is None: raise ValueError('You should provide a FilterSetES as filterset_class argument.') + super(DjangoESFilterConnectionField, self).__init__(object_type, *args, **kwargs) + self.manager = ManagerBridge(search_manager=self.filterset_class._meta.index.search) + def get_manager(self): - """Returning a QuerysetBridge to replace the direct use over the QS""" - return QuerysetBridge(search=self.filterset_class._meta.index.search()) + """Returning a ManagerBridge to replace the direct use over the Model manager""" + return self.manager def merge_querysets(cls, default_queryset, queryset): """Merge ES queries""" diff --git a/graphene_django/elasticsearch/filter/filters.py b/graphene_django/elasticsearch/filter/filters.py index a467cd3..fc34c0c 100644 --- a/graphene_django/elasticsearch/filter/filters.py +++ b/graphene_django/elasticsearch/filter/filters.py @@ -1,58 +1,47 @@ """Filters to ElasticSearch""" -from collections import OrderedDict - -import six -from elasticsearch_dsl import Q -from graphene import String +from graphene import String, Boolean, Int +from graphene_django.elasticsearch.filter.processors import PROCESSORS -class StringFilterES(object): # pylint: disable=R0902 - """String Fields specific to ElasticSearch.""" +class FilterES(object): + """Fields specific to ElasticSearch.""" + default_processor = 'term' + default_argument = String() - default_expr = 'contain' - variants = { - "contain": lambda name, value: Q('match', - **{name: { - "query": value, - "fuzziness": "auto" - }}), - - "term": lambda name, value: Q('term', **{name: value}), - } - - def __init__(self, name=None, attr=None, lookup_expressions=None, default_expr=None): + def __init__(self, field_name, field_name_es=None, lookup_expressions=None, + default_processor=None, argument=None): """ :param name: Name of the field. This is the name that will be exported. :param attr: Path to the index attr that will be used as filter. """ - assert name or attr, "At least the field name or the field attr should be passed" - self.field_name = name or attr.replace('.', '_') - self.default_expr = default_expr or self.default_expr + self.field_name = field_name + + if isinstance(field_name_es, list): + self.field_name_es = field_name_es + else: + self.field_name_es = [field_name_es or field_name] + + self.default_filter_processor = default_processor or self.default_processor + self.lookup_expressions = lookup_expressions - self.argument = String() - self.fields = self.generate_fields() - def generate_fields(self): - """ - All FilterSet objects should specify its fields for the introspection. - - :return: A mapping of field to Filter type of field with all the suffix - expressions combinations. - """ - fields = OrderedDict() + self.processor = None if self.lookup_expressions: - for variant in self.lookup_expressions: - if variant in self.variants: - variant_name = self.field_name if variant in ["default", self.default_expr] \ - else "%s_%s" % (self.field_name, variant) - fields[variant_name] = self + if variant in PROCESSORS: + self.processor = self.build_processor(variant) + else: + raise ValueError('We do not have processor: %s.' % variant) else: - variant_name = self.field_name - fields[variant_name] = self + self.processor = self.build_processor(self.default_processor) - return fields + self.fields = self.processor.generate_field() + self.argument = argument or self.default_argument + + def build_processor(self, variant): + processor_class = PROCESSORS[variant] + return processor_class(self, self.processor) def generate_es_query(self, arguments): """ @@ -60,24 +49,7 @@ class StringFilterES(object): # pylint: disable=R0902 :param arguments: parameters of the query. :return: Returns a elasticsearch_dsl.Q query object. """ - queries = [] - - for argument, value in six.iteritems(arguments): - if argument in self.fields: - - if argument == self.field_name: - suffix_expr = self.default_expr or 'default' - else: - argument_split = argument.split("_") - suffix_expr = argument_split[len(argument_split) - 1] - - if suffix_expr in self.variants: - query = self.variants.get(suffix_expr, None) - - if query: - queries.extend([query(self.field_name, value)]) - - return Q("bool", must=queries[0]) if len(queries) == 1 else Q("bool", must={"bool": {"should": queries}}) + return self.processor.generate_es_query(arguments) def Argument(self): """ @@ -85,3 +57,18 @@ class StringFilterES(object): # pylint: disable=R0902 :return: A Argument type """ return self.argument.Argument() + + +class StringFilterES(FilterES): + """String Fields specific to ElasticSearch.""" + default_processor = 'contains' + + +class BoolFilterES(FilterES): + """Boolean filter to ES""" + default_argument = Boolean() + + +class NumberFilterES(FilterES): + """Filter to an numeric value to ES""" + default_argument = Int() diff --git a/graphene_django/elasticsearch/filter/filterset.py b/graphene_django/elasticsearch/filter/filterset.py index f49d024..94199b7 100644 --- a/graphene_django/elasticsearch/filter/filterset.py +++ b/graphene_django/elasticsearch/filter/filterset.py @@ -2,19 +2,40 @@ import copy from collections import OrderedDict from elasticsearch_dsl import Q -from graphene import Enum, InputObjectType, Field -from django_elasticsearch_dsl import StringField, TextField +from graphene import Enum, InputObjectType, Field, Int, Float +from django_elasticsearch_dsl import StringField, TextField, BooleanField, IntegerField, FloatField, LongField, \ + ShortField, DoubleField, DateField, KeywordField from django.utils import six from django_filters.utils import try_dbfield from django_filters.filterset import BaseFilterSet -from .filters import StringFilterES +from .filters import StringFilterES, FilterES, BoolFilterES, NumberFilterES # Basic conversion from ES fields to FilterES fields FILTER_FOR_ESFIELD_DEFAULTS = { StringField: {'filter_class': StringFilterES}, TextField: {'filter_class': StringFilterES}, + BooleanField: {'filter_class': BoolFilterES}, + IntegerField: {'filter_class': NumberFilterES}, + FloatField: {'filter_class': NumberFilterES, + 'extra': { + 'argument': Int() + }}, + LongField: {'filter_class': NumberFilterES, + 'extra': { + 'argument': Int() + }}, + ShortField: {'filter_class': NumberFilterES, + 'extra': { + 'argument': Int() + }}, + DoubleField: {'filter_class': NumberFilterES, + 'extra': { + 'argument': Float() + }}, + DateField: {'filter_class': StringFilterES}, + KeywordField: {'filter_class': StringFilterES}, } @@ -54,9 +75,12 @@ class FilterSetESOptions(object): class Meta: index = UserIndex includes = { - 'username': ['term'] - 'last_login': ['lte', 'gte] - } + 'username': { + 'field_name': 'graphene_field', + 'field_name_es': 'elasticsearch_field', + 'lookup_expressions': ['term', 'contains'] + } + } The list syntax will create an filter with a behavior by default, for each field included in includes. The dictionary syntax will @@ -68,11 +92,12 @@ class FilterSetESOptions(object): Example: class UserFilter(FilterSetES): - username = StringFieldES('username', core_type='text', expr=['partial']) + username = StringFieldES(field_name='username', lookup_expressions=['contains']) class Meta: index = UserIndex includes = { - 'username': ['term', 'word'] + 'username': { + 'lookup_expressions': ['term', 'contains'] } A query with username as a parameter, will match those words with the @@ -127,7 +152,7 @@ class FilterSetESMetaclass(type): def __new__(mcs, name, bases, attrs): """Get filters declared explicitly in the class""" - + # get declared as field declared_filters = mcs.get_declared_filters(bases, attrs) attrs['declared_filters'] = declared_filters @@ -135,16 +160,20 @@ class FilterSetESMetaclass(type): if issubclass(new_class, BaseFilterSet): new_class._meta = FilterSetESOptions(getattr(new_class, 'Meta', None)) - base_filters = OrderedDict() + # get declared as meta + meta_filters = mcs.get_meta_filters(new_class._meta) + + declared_filters.update(meta_filters) + new_class.filters_es = declared_filters + + # recollecting registered graphene fields + base_filters = OrderedDict() for name, filter_field in six.iteritems(declared_filters): base_filters.update(filter_field.fields) - meta_filters = mcs.get_meta_filters(new_class._meta) - base_filters.update(meta_filters) - + # adding sort field sort_fields = {} - if new_class._meta.order_by is not None: sort_fields = mcs.generate_sort_field(new_class._meta.order_by) sort_type = mcs.create_sort_enum(name, sort_fields) @@ -166,9 +195,9 @@ class FilterSetESMetaclass(type): # List of filters declared in the class as static fields. filters = [ - (filter_name, attrs.pop(filter_name)) + (obj.field_name, attrs.pop(filter_name)) for filter_name, obj in list(attrs.items()) - if isinstance(obj, StringFilterES) + if isinstance(obj, FilterES) ] # Merge declared filters from base classes @@ -191,7 +220,7 @@ class FilterSetESMetaclass(type): for name, index_field, data in index_fields: filter_class = mcs.get_filter_exp(name, index_field, data) - meta_filters.update(filter_class.fields) + meta_filters.update({name: filter_class}) return meta_filters @@ -229,7 +258,6 @@ class FilterSetESMetaclass(type): # This inner field is not filterable continue inner_data = data[inner_name] if data else None - index_fields.append(mcs.get_filter_exp(inner_name, inner_field, inner_data, root=name)) return index_fields @@ -245,27 +273,23 @@ class FilterSetESMetaclass(type): # Get lookup_expr from configuration if data and 'lookup_expressions' in data: - if 'lookup_expressions' in kwargs: - kwargs['lookup_expressions'] = set(kwargs['lookup_expressions'])\ - .intersection(set(data['lookup_expressions'])) - else: - kwargs['lookup_expressions'] = set(data['lookup_expressions']) + kwargs['lookup_expressions'] = set(data['lookup_expressions']) elif 'lookup_expressions' in kwargs: kwargs['lookup_expressions'] = set(kwargs['lookup_expressions']) - kwargs['name'], kwargs['attr'] = mcs.get_name(name, root, data) + kwargs['field_name'], kwargs['field_name_es'] = mcs.get_name(name, root, data) return filter_class(**kwargs) @staticmethod def get_name(name, root, data): """Get names of the field and the path to resolve it""" - field_name = data.get('name', None) if data else None - attr = data.get('attr', None) if data else None + field_name = data.get('field_name', None) if data else None + field_name_es = data.get('field_name_es', None) if data else None if not field_name: field_name = '{root}_{name}'.format(root=root, name=name) if root else name - if not attr: - attr = '{root}.{name}'.format(root=root, name=name) if root else name - return field_name, attr + if not field_name_es: + field_name_es = '{root}.{name}'.format(root=root, name=name) if root else name + return field_name, field_name_es @staticmethod def create_sort_enum(name, sort_fields): @@ -347,12 +371,11 @@ class FilterSetES(six.with_metaclass(FilterSetESMetaclass, object)): # if the query have data if len(self.data): # for each field passed to the query - for name in self.data: - filter_es = self.base_filters.get(name) + for name, filter in six.iteritems(self.filters_es): # If a target filter is en FilterEs - if isinstance(filter_es, StringFilterES): + if isinstance(filter, FilterES): # It is generated a query or response None if the filter don't have data - query_filter = filter_es.generate_es_query(self.data) + query_filter = filter.generate_es_query(self.data) if query_filter is not None: query_base += query_filter diff --git a/graphene_django/elasticsearch/filter/processors.py b/graphene_django/elasticsearch/filter/processors.py new file mode 100644 index 0000000..ecd037f --- /dev/null +++ b/graphene_django/elasticsearch/filter/processors.py @@ -0,0 +1,167 @@ +from collections import OrderedDict + +from elasticsearch_dsl import Q +from graphene import List + + +class Processor(object): + suffix_expr = 'term' + + def __init__(self, filter_es, parent_processor=None): + """ + Abstract processor to generate graphene field and ES query to lookups + :type filter_es: graphene_django.elasticsearch.filter.filterset.FilterES + :type parent_processor: graphene_django.elasticsearch.filter.filterset.Processor + """ + self.filter_es = filter_es + self.parent_processor = parent_processor + self.variant_name = self._get_variant_name() + + def generate_field(self): + """Field Decorator""" + self_field = self._build_field() + + if self.parent_processor is not None: + parent_fields = self.parent_processor.generate_field() + parent_fields.update(self_field) + return parent_fields + + else: + return self_field + + def get_type(self): + return self.filter_es.argument + + def generate_es_query(self, data): + + if self.variant_name in data: + value = data.get(self.variant_name) + self_query = self._build_query(value) + else: + self_query = Q("bool") + + if self.parent_processor is not None: + parent_query = self.parent_processor.generate_es_query(data) + parent_query += self_query + return parent_query + + else: + return self_query + + def _build_field(self): + variant_name = self.variant_name + + return OrderedDict({variant_name: self.filter_es}) + + def _get_variant_name(self): + if self.suffix_expr == self.filter_es.default_filter_processor: + variant_name = self.filter_es.field_name + + else: + variant_name = "%s_%s" % (self.filter_es.field_name, self.suffix_expr) + + return variant_name + + def _build_query(self, value): + result = len(self.filter_es.field_name_es) + + if result > 1: + queries = [self._get_query(name, value) for name in self.filter_es.field_name_es] + return Q("bool", must={"bool": {"should": queries}}) + + return Q("bool", must=self._get_query(self.filter_es.field_name_es[0], value)) + + @staticmethod + def _get_query(name, value): + return Q('term', **{name: value}) + + +class TermProcessor(Processor): + pass + + +class ContainsProcessor(Processor): + suffix_expr = 'contains' + + @staticmethod + def _get_query(name, value): + return Q('match', + **{name: { + "query": value, + "fuzziness": "auto" + }}) + + +class RegexProcessor(Processor): + suffix_expr = 'regex' + + @staticmethod + def _get_query(name, value): + return Q('wildcard', **{name: value}) + + +class PhraseProcessor(Processor): + suffix_expr = 'phrase' + + @staticmethod + def _get_query(name, value): + return Q('match_phrase', + **{name: { + "query": value + }}) + + +class PrefixProcessor(Processor): + suffix_expr = 'prefix' + + @staticmethod + def _get_query(name, value): + return Q('match_phrase_prefix', + **{name: { + "query": value + }}) + + +class InProcessor(Processor): + suffix_expr = 'in' + + def get_type(self): + return List(self.filter_es.argument.Argument().type) + + +class ExitsProcessor(Processor): + suffix_expr = 'exits' + + @staticmethod + def _get_query(name, value): + return Q('bool', **{ + 'must' if value else 'must_not': {'exists': {'field': name}} + }) + + +class LteProcessor(Processor): + suffix_expr = 'lte' + + @staticmethod + def _get_query(name, value): + return Q("bool", must={'range': {name: {'lte': value}}}) + + +class GteProcessor(Processor): + suffix_expr = 'gte' + + @staticmethod + def _get_query(name, value): + return Q("bool", must={'range': {name: {'gte': value}}}) + + +PROCESSORS = { + "contains": ContainsProcessor, + "term": TermProcessor, + "regex": RegexProcessor, + "phrase": PhraseProcessor, + "prefix": PrefixProcessor, + "in": InProcessor, + "lte": LteProcessor, + "gte": GteProcessor, +} diff --git a/graphene_django/elasticsearch/tests/filters.py b/graphene_django/elasticsearch/tests/filters.py index 787304e..1a01e8d 100644 --- a/graphene_django/elasticsearch/tests/filters.py +++ b/graphene_django/elasticsearch/tests/filters.py @@ -29,7 +29,7 @@ class ArticleFilterESAsField(FilterSetES): includes = [] order_by = ['id'] - headline = filters.StringFilterES(attr='headline', lookup_expressions=['term', 'contain']) + headline = filters.StringFilterES(field_name='headline', lookup_expressions=['term', 'contains']) class ArticleFilterESInMeta(FilterSetES): @@ -47,11 +47,25 @@ class ArticleFilterESInMetaDict(FilterSetES): index = ArticleDocument includes = { 'headline': { - 'lookup_expressions': ['term', 'contain'] + 'lookup_expressions': ['term', 'contains'] } } +class ArticleFilterMultiField(FilterSetES): + """Article Filter for ES""" + class Meta(object): + """Metaclass data""" + index = ArticleDocument + includes = [] + + headline = filters.StringFilterES( + field_name='contain', + field_name_es=['headline', 'lang'], + lookup_expressions=['contains'] + ) + + class ESFilterQuery(ObjectType): """A query for ES fields""" articles_as_field = DjangoESFilterConnectionField( @@ -63,3 +77,6 @@ class ESFilterQuery(ObjectType): articles_in_meta_dict = DjangoESFilterConnectionField( ArticleNode, filterset_class=ArticleFilterESInMetaDict ) + articles_in_multi_field = DjangoESFilterConnectionField( + ArticleNode, filterset_class=ArticleFilterMultiField + ) diff --git a/graphene_django/elasticsearch/tests/test_fields.py b/graphene_django/elasticsearch/tests/test_fields.py index e8c7d48..dc30d00 100644 --- a/graphene_django/elasticsearch/tests/test_fields.py +++ b/graphene_django/elasticsearch/tests/test_fields.py @@ -78,11 +78,19 @@ def filter_generation(field, query_str, expected_arguments, method_to_mock="quer assert result.data[field]["edges"][1]["node"]["headline"] == "a2" -def test_filter_as_field(): +def test_filter_string(): filter_generation( "articlesAsField", "headline: \"A text\"", - filters.StringFilterES(attr='headline').generate_es_query({"headline": "A text"}), + filters.StringFilterES(field_name='headline').generate_es_query({"headline": "A text"}), + ) + + +def test_filter_string_date(): + filter_generation( + "articlesAsField", + "headline: \"A text\"", + filters.StringFilterES(field_name='headline').generate_es_query({"headline": "A text"}), ) @@ -99,7 +107,7 @@ def test_filter_in_meta(): filter_generation( "articlesInMeta", "headline: \"A text\"", - filters.StringFilterES(attr='headline').generate_es_query({"headline": "A text"}), + filters.StringFilterES(field_name='headline').generate_es_query({"headline": "A text"}), ) @@ -107,5 +115,16 @@ def test_filter_in_meta_dict(): filter_generation( "articlesInMetaDict", "headline: \"A text\"", - filters.StringFilterES(attr='headline').generate_es_query({"headline": "A text"}), + filters.StringFilterES(field_name='headline').generate_es_query({"headline": "A text"}), + ) + + +def test_filter_in_multi_field(): + filter_generation( + "articlesInMultiField", + "contain: \"A text\"", + filters.StringFilterES( + field_name='contain', + field_name_es=['headline', 'lang'], + ).generate_es_query({"contain": "A text"}), )