added order by feature as meta.

This commit is contained in:
Alejandro Nunez Capote 2019-06-04 15:17:32 -04:00
parent 25a5ceb2a8
commit 5c57ffccd7
6 changed files with 178 additions and 31 deletions

View File

@ -1,10 +1,25 @@
from elasticsearch_dsl.query import Query from elasticsearch_dsl.query import Query
from graphene_django.elasticsearch.filter.bridges import QuerysetBridge from graphene_django.elasticsearch.filter.bridges import QuerysetBridge
from graphene_django.filter import DjangoFilterConnectionField from graphene_django.filter import DjangoFilterConnectionField
class DjangoESFilterConnectionField(DjangoFilterConnectionField): class DjangoESFilterConnectionField(DjangoFilterConnectionField):
"""A Field to replace DjangoFilterConnectionField manager by QuerysetBridge""" """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): def get_manager(self):
"""Returning a QuerysetBridge to replace the direct use over the QS""" """Returning a QuerysetBridge to replace the direct use over the QS"""

View File

@ -29,7 +29,7 @@ class StringFilterES(object): # pylint: disable=R0902
self.field_name = name or attr.replace('.', '_') self.field_name = name or attr.replace('.', '_')
self.default_expr = default_expr or self.default_expr self.default_expr = default_expr or self.default_expr
self.lookup_expressions = lookup_expressions self.lookup_expressions = lookup_expressions
self.argument = String().Argument() self.argument = String()
self.fields = self.generate_fields() self.fields = self.generate_fields()
def generate_fields(self): def generate_fields(self):
@ -54,8 +54,9 @@ class StringFilterES(object): # pylint: disable=R0902
return fields 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. :param arguments: parameters of the query.
:return: Returns a elasticsearch_dsl.Q query object. :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)]) 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 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()

View File

@ -2,8 +2,10 @@
import copy import copy
from collections import OrderedDict from collections import OrderedDict
from elasticsearch_dsl import Q 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.utils import six
from django_filters.utils import try_dbfield from django_filters.utils import try_dbfield
from django_filters.filterset import BaseFilterSet 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): class FilterSetESOptions(object):
"""Basic FilterSetES options to Metadata""" """Basic FilterSetES options to Metadata"""
def __init__(self, options=None): def __init__(self, options=None):
@ -71,13 +86,33 @@ class FilterSetESOptions(object):
index = UserIndex index = UserIndex
excludes = ['username', 'last_login'] 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 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.index = getattr(options, 'index', None)
self.includes = getattr(options, 'includes', None) self.includes = getattr(options, 'includes', None)
self.excludes = getattr(options, 'excludes', None) self.excludes = getattr(options, 'excludes', None)
self.order_by = getattr(options, 'order_by', None)
if self.index is None: if self.index is None:
raise ValueError('You need provide a Index in Meta.') raise ValueError('You need provide a Index in Meta.')
@ -101,11 +136,21 @@ class FilterSetESMetaclass(type):
if issubclass(new_class, BaseFilterSet): if issubclass(new_class, BaseFilterSet):
new_class._meta = FilterSetESOptions(getattr(new_class, 'Meta', None)) new_class._meta = FilterSetESOptions(getattr(new_class, 'Meta', None))
base_filters = OrderedDict() base_filters = OrderedDict()
for name, filter_field in six.iteritems(declared_filters): for name, filter_field in six.iteritems(declared_filters):
base_filters.update(filter_field.fields) base_filters.update(filter_field.fields)
meta_filters = mcs.get_meta_filters(new_class._meta) 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 new_class.base_filters = base_filters
return new_class return new_class
@ -142,13 +187,11 @@ class FilterSetESMetaclass(type):
""" """
index_fields = mcs.get_index_fields(meta) index_fields = mcs.get_index_fields(meta)
meta_filters = [] meta_filters = OrderedDict()
for name, index_field, data in index_fields: for name, index_field, data in index_fields:
if isinstance(index_field, ObjectField): filter_class = mcs.get_filter_exp(name, index_field, data)
meta_filters.extend((name, mcs.get_filter_object(name, index_field, data))) meta_filters.update(filter_class.fields)
else:
meta_filters.append((name, mcs.get_filter_exp(name, index_field, data)))
return meta_filters return meta_filters
@ -201,13 +244,14 @@ class FilterSetESMetaclass(type):
kwargs = copy.deepcopy(extra) kwargs = copy.deepcopy(extra)
# Get lookup_expr from configuration # Get lookup_expr from configuration
if data and 'lookup_exprs' in data: if data and 'lookup_expressions' in data:
if 'lookup_exprs' in kwargs: if 'lookup_expressions' in kwargs:
kwargs['lookup_exprs'] = set(kwargs['lookup_exprs']).intersection(set(data['lookup_exprs'])) kwargs['lookup_expressions'] = set(kwargs['lookup_expressions'])\
.intersection(set(data['lookup_expressions']))
else: else:
kwargs['lookup_exprs'] = set(data['lookup_exprs']) kwargs['lookup_expressions'] = set(data['lookup_expressions'])
elif 'lookup_exprs' in kwargs: elif 'lookup_expressions' in kwargs:
kwargs['lookup_exprs'] = set(kwargs['lookup_exprs']) kwargs['lookup_expressions'] = set(kwargs['lookup_expressions'])
kwargs['name'], kwargs['attr'] = mcs.get_name(name, root, data) kwargs['name'], kwargs['attr'] = mcs.get_name(name, root, data)
return filter_class(**kwargs) return filter_class(**kwargs)
@ -223,6 +267,49 @@ class FilterSetESMetaclass(type):
attr = '{root}.{name}'.format(root=root, name=name) if root else name attr = '{root}.{name}'.format(root=root, name=name) if root else name
return field_name, attr 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)): class FilterSetES(six.with_metaclass(FilterSetESMetaclass, object)):
"""FilterSet specific for ElasticSearch.""" """FilterSet specific for ElasticSearch."""
@ -240,19 +327,34 @@ class FilterSetES(six.with_metaclass(FilterSetESMetaclass, object)):
@property @property
def qs(self): def qs(self):
"""Returning ES queryset as QS""" """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("query", query_base)
self.es_query.apply_query("source", ["id"]) 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 return self.es_query
def generate_q(self): def generate_es_query(self):
""" """
Generate a query for each filter. Generate a query for each filter.
:return: Generates a super query with bool as root, and combines all sub-queries from each argument. :return: Generates a super query with bool as root, and combines all sub-queries from each argument.
""" """
query_base = Q("bool") query_base = Q("bool")
for name, filter_es in six.iteritems(self.base_filters): # if the query have data
query_filter = filter_es.get_q(self.data) if len(self.data) else None if len(self.data):
if query_filter is not None: # for each field passed to the query
query_base += query_filter 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 return query_base

View File

@ -27,8 +27,9 @@ class ArticleFilterESAsField(FilterSetES):
"""Metaclass data""" """Metaclass data"""
index = ArticleDocument index = ArticleDocument
includes = [] includes = []
order_by = ['id']
headline = filters.StringFilterES(attr='headline') headline = filters.StringFilterES(attr='headline', lookup_expressions=['term', 'contain'])
class ArticleFilterESInMeta(FilterSetES): class ArticleFilterESInMeta(FilterSetES):
@ -46,7 +47,7 @@ class ArticleFilterESInMetaDict(FilterSetES):
index = ArticleDocument index = ArticleDocument
includes = { includes = {
'headline': { 'headline': {
'lookup_expressions': ['term', 'contains'] 'lookup_expressions': ['term', 'contain']
} }
} }

View File

@ -41,7 +41,7 @@ def fake_data():
return a1, a2 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() a1, a2 = fake_data()
query = """ query = """
@ -64,14 +64,14 @@ def filter_generation(field, query_str, spected_arguments):
with mock.patch('django_elasticsearch_dsl.search.Search.count', mock_count),\ with mock.patch('django_elasticsearch_dsl.search.Search.count', mock_count),\
mock.patch('django_elasticsearch_dsl.search.Search.__getitem__', mock_slice),\ 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) schema = Schema(query=ESFilterQuery)
result = schema.execute(query) result = schema.execute(query)
assert not result.errors 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 len(result.data[field]["edges"]) == 2
assert result.data[field]["edges"][0]["node"]["headline"] == "a1" 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(): 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(): 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(): 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"}),
)

View File

@ -18,7 +18,7 @@ def get_filtering_args_from_filterset(filterset_class, type):
field_type = convert_form_field(filter_field.field).Argument() field_type = convert_form_field(filter_field.field).Argument()
field_type.description = filter_field.label field_type.description = filter_field.label
else: else:
field_type = filter_field.argument field_type = filter_field.Argument()
args[name] = field_type args[name] = field_type