diff --git a/graphene_django/elasticsearch/filter/fields.py b/graphene_django/elasticsearch/filter/fields.py index 5324d0b..cf1219b 100644 --- a/graphene_django/elasticsearch/filter/fields.py +++ b/graphene_django/elasticsearch/filter/fields.py @@ -1,10 +1,25 @@ from elasticsearch_dsl.query import Query + from graphene_django.elasticsearch.filter.bridges import QuerysetBridge from graphene_django.filter import DjangoFilterConnectionField class DjangoESFilterConnectionField(DjangoFilterConnectionField): """A Field to replace DjangoFilterConnectionField manager by QuerysetBridge""" + def __init__(self, object_type, *args, **kwargs): + """Validating field allowed for this connection""" + fields = kwargs.get('fields', None) + if fields is not None: + raise ValueError('DjangoESFilterConnectionField do not permit argument fields yet.') + + order_by = kwargs.get('order_by', None) + if order_by is not None: + raise ValueError('DjangoESFilterConnectionField do not permit argument order_by yet.') + + 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) def get_manager(self): """Returning a QuerysetBridge to replace the direct use over the QS""" diff --git a/graphene_django/elasticsearch/filter/filters.py b/graphene_django/elasticsearch/filter/filters.py index dbca8a9..a467cd3 100644 --- a/graphene_django/elasticsearch/filter/filters.py +++ b/graphene_django/elasticsearch/filter/filters.py @@ -29,7 +29,7 @@ class StringFilterES(object): # pylint: disable=R0902 self.field_name = name or attr.replace('.', '_') self.default_expr = default_expr or self.default_expr self.lookup_expressions = lookup_expressions - self.argument = String().Argument() + self.argument = String() self.fields = self.generate_fields() def generate_fields(self): @@ -54,8 +54,9 @@ class StringFilterES(object): # pylint: disable=R0902 return fields - def get_q(self, arguments): + def generate_es_query(self, arguments): """ + Generating a query based on the arguments passed to graphene field :param arguments: parameters of the query. :return: Returns a elasticsearch_dsl.Q query object. """ @@ -77,3 +78,10 @@ class StringFilterES(object): # pylint: disable=R0902 queries.extend([query(self.field_name, value)]) return Q("bool", must=queries[0]) if len(queries) == 1 else Q("bool", must={"bool": {"should": queries}}) + + def Argument(self): + """ + Defining graphene Argument type for this filter + :return: A Argument type + """ + return self.argument.Argument() diff --git a/graphene_django/elasticsearch/filter/filterset.py b/graphene_django/elasticsearch/filter/filterset.py index 3be0149..f49d024 100644 --- a/graphene_django/elasticsearch/filter/filterset.py +++ b/graphene_django/elasticsearch/filter/filterset.py @@ -2,8 +2,10 @@ import copy from collections import OrderedDict from elasticsearch_dsl import Q -from django_elasticsearch_dsl import ObjectField, StringField, TextField +from graphene import Enum, InputObjectType, Field +from django_elasticsearch_dsl import StringField, TextField from django.utils import six + from django_filters.utils import try_dbfield from django_filters.filterset import BaseFilterSet @@ -16,6 +18,19 @@ FILTER_FOR_ESFIELD_DEFAULTS = { } +class OrderEnum(Enum): + """Order enum to desc-asc""" + asc = 'asc' + desc = 'desc' + + @property + def description(self): + """Description to order enum""" + if self == OrderEnum.asc: + return 'Ascendant order' + return 'Descendant order' + + class FilterSetESOptions(object): """Basic FilterSetES options to Metadata""" def __init__(self, options=None): @@ -71,13 +86,33 @@ class FilterSetESOptions(object): index = UserIndex excludes = ['username', 'last_login'] + It is necessary to provide includes or excludes. You cant provide a excludes empty to generate all fields + + You can also pass sort_by to Meta to allow field be ordered + + Example: + class UserFilter(FilterSetES): + class Meta: + index = UserIndex + excludes = [] + order_by = ['username', 'last_login'] + or - It is necessary to provide includes or excludes. You cant provide a excludes empty to generate all fields + class UserFilter(FilterSetES): + class Meta: + index = UserIndex + excludes = [] + order_by = { + 'username': user.name + 'last_login': last_login + } + """ self.index = getattr(options, 'index', None) self.includes = getattr(options, 'includes', None) self.excludes = getattr(options, 'excludes', None) + self.order_by = getattr(options, 'order_by', None) if self.index is None: raise ValueError('You need provide a Index in Meta.') @@ -101,11 +136,21 @@ class FilterSetESMetaclass(type): if issubclass(new_class, BaseFilterSet): new_class._meta = FilterSetESOptions(getattr(new_class, 'Meta', None)) 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(OrderedDict(meta_filters)) + base_filters.update(meta_filters) + + 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) + base_filters['sort'] = sort_type() + + new_class.sort_fields = sort_fields new_class.base_filters = base_filters return new_class @@ -142,13 +187,11 @@ class FilterSetESMetaclass(type): """ index_fields = mcs.get_index_fields(meta) - meta_filters = [] + meta_filters = OrderedDict() for name, index_field, data in index_fields: - if isinstance(index_field, ObjectField): - meta_filters.extend((name, mcs.get_filter_object(name, index_field, data))) - else: - meta_filters.append((name, mcs.get_filter_exp(name, index_field, data))) + filter_class = mcs.get_filter_exp(name, index_field, data) + meta_filters.update(filter_class.fields) return meta_filters @@ -201,13 +244,14 @@ class FilterSetESMetaclass(type): kwargs = copy.deepcopy(extra) # Get lookup_expr from configuration - if data and 'lookup_exprs' in data: - if 'lookup_exprs' in kwargs: - kwargs['lookup_exprs'] = set(kwargs['lookup_exprs']).intersection(set(data['lookup_exprs'])) + 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_exprs'] = set(data['lookup_exprs']) - elif 'lookup_exprs' in kwargs: - kwargs['lookup_exprs'] = set(kwargs['lookup_exprs']) + 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) return filter_class(**kwargs) @@ -223,6 +267,49 @@ class FilterSetESMetaclass(type): attr = '{root}.{name}'.format(root=root, name=name) if root else name return field_name, attr + @staticmethod + def create_sort_enum(name, sort_fields): + """ + Create enum to sort by fields. + As graphene is typed, it is necessary generate a Enum by Field + to have inside, the document fields allowed to be ordered + """ + + sort_enum_name = "{}SortFields".format(name) + sort_descriptions = {field: "Sort by {field}".format(field=field) for field in + sort_fields.keys()} + sort_fields = [(field, field) for field in sort_fields.keys()] + + class EnumWithDescriptionsType(object): + """Set description to enum fields""" + + @property + def description(self): + """Description to EnumSort""" + return sort_descriptions[self.name] + + enum = Enum(sort_enum_name, sort_fields, type=EnumWithDescriptionsType) + + class SortType(InputObjectType): + """Sort Type""" + order = Field(OrderEnum) + field = Field(enum, required=True) + + sort_name = "{}Sort".format(name) + sort_type = type(sort_name, (SortType,), {}) + return sort_type + + @staticmethod + def generate_sort_field(order_by): + """To normalize the sort field data""" + if not order_by: + sort_fields = {} + elif isinstance(order_by, dict): + sort_fields = order_by.copy() + else: + sort_fields = {field: field for field in order_by} + return sort_fields + class FilterSetES(six.with_metaclass(FilterSetESMetaclass, object)): """FilterSet specific for ElasticSearch.""" @@ -240,19 +327,34 @@ class FilterSetES(six.with_metaclass(FilterSetESMetaclass, object)): @property def qs(self): """Returning ES queryset as QS""" - query_base = self.generate_q() + query_base = self.generate_es_query() self.es_query.apply_query("query", query_base) self.es_query.apply_query("source", ["id"]) + + if 'sort' in self.data: + sort_data = self.data['sort'].copy() + field_name = self.sort_fields[sort_data.pop('field')] + self.es_query.apply_query("sort", {field_name: sort_data}) + return self.es_query - def generate_q(self): + def generate_es_query(self): """ Generate a query for each filter. :return: Generates a super query with bool as root, and combines all sub-queries from each argument. """ query_base = Q("bool") - for name, filter_es in six.iteritems(self.base_filters): - query_filter = filter_es.get_q(self.data) if len(self.data) else None - if query_filter is not None: - query_base += query_filter + # 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) + # If a target filter is en FilterEs + if isinstance(filter_es, StringFilterES): + # It is generated a query or response None if the filter don't have data + query_filter = filter_es.generate_es_query(self.data) + + if query_filter is not None: + query_base += query_filter + return query_base diff --git a/graphene_django/elasticsearch/tests/filters.py b/graphene_django/elasticsearch/tests/filters.py index 5265e75..787304e 100644 --- a/graphene_django/elasticsearch/tests/filters.py +++ b/graphene_django/elasticsearch/tests/filters.py @@ -27,8 +27,9 @@ class ArticleFilterESAsField(FilterSetES): """Metaclass data""" index = ArticleDocument includes = [] + order_by = ['id'] - headline = filters.StringFilterES(attr='headline') + headline = filters.StringFilterES(attr='headline', lookup_expressions=['term', 'contain']) class ArticleFilterESInMeta(FilterSetES): @@ -46,7 +47,7 @@ class ArticleFilterESInMetaDict(FilterSetES): index = ArticleDocument includes = { 'headline': { - 'lookup_expressions': ['term', 'contains'] + 'lookup_expressions': ['term', 'contain'] } } diff --git a/graphene_django/elasticsearch/tests/test_fields.py b/graphene_django/elasticsearch/tests/test_fields.py index 6bdc6ba..e8c7d48 100644 --- a/graphene_django/elasticsearch/tests/test_fields.py +++ b/graphene_django/elasticsearch/tests/test_fields.py @@ -41,7 +41,7 @@ def fake_data(): return a1, a2 -def filter_generation(field, query_str, spected_arguments): +def filter_generation(field, query_str, expected_arguments, method_to_mock="query"): a1, a2 = fake_data() query = """ @@ -64,14 +64,14 @@ def filter_generation(field, query_str, spected_arguments): with mock.patch('django_elasticsearch_dsl.search.Search.count', mock_count),\ mock.patch('django_elasticsearch_dsl.search.Search.__getitem__', mock_slice),\ - mock.patch('elasticsearch_dsl.Search.query', mock_query): + mock.patch("elasticsearch_dsl.Search.%s" % method_to_mock, mock_query): schema = Schema(query=ESFilterQuery) result = schema.execute(query) assert not result.errors - mock_query.assert_called_with(filters.StringFilterES(attr='headline').get_q(spected_arguments)) + mock_query.assert_called_with(expected_arguments) assert len(result.data[field]["edges"]) == 2 assert result.data[field]["edges"][0]["node"]["headline"] == "a1" @@ -79,12 +79,33 @@ def filter_generation(field, query_str, spected_arguments): def test_filter_as_field(): - filter_generation("articlesAsField", "headline: \"A text\"", {"headline": "A text"}) + filter_generation( + "articlesAsField", + "headline: \"A text\"", + filters.StringFilterES(attr='headline').generate_es_query({"headline": "A text"}), + ) + + +def test_filter_as_field_order_by(): + filter_generation( + "articlesAsField", + "headline: \"A text\", sort:{order:desc, field:id}", + {'id': {'order': 'desc'}}, + "sort" + ) def test_filter_in_meta(): - filter_generation("articlesInMeta", "headline: \"A text\"", {"headline": "A text"}) + filter_generation( + "articlesInMeta", + "headline: \"A text\"", + filters.StringFilterES(attr='headline').generate_es_query({"headline": "A text"}), + ) def test_filter_in_meta_dict(): - filter_generation("articlesInMetaDict", "headline: \"A text\"", {"headline": "A text"}) + filter_generation( + "articlesInMetaDict", + "headline: \"A text\"", + filters.StringFilterES(attr='headline').generate_es_query({"headline": "A text"}), + ) diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index 3c09619..ef1310f 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -18,7 +18,7 @@ def get_filtering_args_from_filterset(filterset_class, type): field_type = convert_form_field(filter_field.field).Argument() field_type.description = filter_field.label else: - field_type = filter_field.argument + field_type = filter_field.Argument() args[name] = field_type