added processors for all type of es query

This commit is contained in:
Alejandro Nunez Capote 2019-06-06 00:36:14 -04:00
parent 3698b4b370
commit 4e4387d674
7 changed files with 328 additions and 104 deletions

View File

@ -6,10 +6,6 @@ class QuerysetBridge(object):
"""Taking as search, the ES search resolved by DjangoESFilterConnectionField""" """Taking as search, the ES search resolved by DjangoESFilterConnectionField"""
self.search = search self.search = search
def get_queryset(self):
"""Returning self as Queryset to be the bridge"""
return self
def apply_query(self, method, *args, **kwargs): def apply_query(self, method, *args, **kwargs):
"""Helper method to apply mutation to ES Query""" """Helper method to apply mutation to ES Query"""
if hasattr(self.search, method): if hasattr(self.search, method):
@ -23,3 +19,15 @@ class QuerysetBridge(object):
"""Applying slice to ES and generating a QS from that""" """Applying slice to ES and generating a QS from that"""
_slice = self.search.__getitem__(k) _slice = self.search.__getitem__(k)
return _slice.to_queryset() 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())

View File

@ -1,6 +1,6 @@
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 ManagerBridge
from graphene_django.filter import DjangoFilterConnectionField from graphene_django.filter import DjangoFilterConnectionField
@ -19,11 +19,14 @@ class DjangoESFilterConnectionField(DjangoFilterConnectionField):
filterset_class = kwargs.get('filterset_class', None) filterset_class = kwargs.get('filterset_class', None)
if filterset_class is None: if filterset_class is None:
raise ValueError('You should provide a FilterSetES as filterset_class argument.') raise ValueError('You should provide a FilterSetES as filterset_class argument.')
super(DjangoESFilterConnectionField, self).__init__(object_type, *args, **kwargs) super(DjangoESFilterConnectionField, self).__init__(object_type, *args, **kwargs)
self.manager = ManagerBridge(search_manager=self.filterset_class._meta.index.search)
def get_manager(self): def get_manager(self):
"""Returning a QuerysetBridge to replace the direct use over the QS""" """Returning a ManagerBridge to replace the direct use over the Model manager"""
return QuerysetBridge(search=self.filterset_class._meta.index.search()) return self.manager
def merge_querysets(cls, default_queryset, queryset): def merge_querysets(cls, default_queryset, queryset):
"""Merge ES queries""" """Merge ES queries"""

View File

@ -1,58 +1,47 @@
"""Filters to ElasticSearch""" """Filters to ElasticSearch"""
from collections import OrderedDict from graphene import String, Boolean, Int
from graphene_django.elasticsearch.filter.processors import PROCESSORS
import six
from elasticsearch_dsl import Q
from graphene import String
class StringFilterES(object): # pylint: disable=R0902 class FilterES(object):
"""String Fields specific to ElasticSearch.""" """Fields specific to ElasticSearch."""
default_processor = 'term'
default_argument = String()
default_expr = 'contain' def __init__(self, field_name, field_name_es=None, lookup_expressions=None,
variants = { default_processor=None, argument=None):
"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):
""" """
:param name: Name of the field. This is the name that will be exported. :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. :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 = field_name
self.field_name = name or attr.replace('.', '_')
self.default_expr = default_expr or self.default_expr 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.lookup_expressions = lookup_expressions
self.argument = String()
self.fields = self.generate_fields()
def generate_fields(self): self.processor = None
"""
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()
if self.lookup_expressions: if self.lookup_expressions:
for variant in self.lookup_expressions: for variant in self.lookup_expressions:
if variant in self.variants: if variant in PROCESSORS:
variant_name = self.field_name if variant in ["default", self.default_expr] \ self.processor = self.build_processor(variant)
else "%s_%s" % (self.field_name, variant) else:
fields[variant_name] = self raise ValueError('We do not have processor: %s.' % variant)
else: else:
variant_name = self.field_name self.processor = self.build_processor(self.default_processor)
fields[variant_name] = self
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): def generate_es_query(self, arguments):
""" """
@ -60,24 +49,7 @@ class StringFilterES(object): # pylint: disable=R0902
: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.
""" """
queries = [] return self.processor.generate_es_query(arguments)
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}})
def Argument(self): def Argument(self):
""" """
@ -85,3 +57,18 @@ class StringFilterES(object): # pylint: disable=R0902
:return: A Argument type :return: A Argument type
""" """
return self.argument.Argument() 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()

View File

@ -2,19 +2,40 @@
import copy import copy
from collections import OrderedDict from collections import OrderedDict
from elasticsearch_dsl import Q from elasticsearch_dsl import Q
from graphene import Enum, InputObjectType, Field from graphene import Enum, InputObjectType, Field, Int, Float
from django_elasticsearch_dsl import StringField, TextField from django_elasticsearch_dsl import StringField, TextField, BooleanField, IntegerField, FloatField, LongField, \
ShortField, DoubleField, DateField, KeywordField
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
from .filters import StringFilterES from .filters import StringFilterES, FilterES, BoolFilterES, NumberFilterES
# Basic conversion from ES fields to FilterES fields # Basic conversion from ES fields to FilterES fields
FILTER_FOR_ESFIELD_DEFAULTS = { FILTER_FOR_ESFIELD_DEFAULTS = {
StringField: {'filter_class': StringFilterES}, StringField: {'filter_class': StringFilterES},
TextField: {'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: class Meta:
index = UserIndex index = UserIndex
includes = { includes = {
'username': ['term'] 'username': {
'last_login': ['lte', 'gte] '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, The list syntax will create an filter with a behavior by default,
for each field included in includes. The dictionary syntax will for each field included in includes. The dictionary syntax will
@ -68,11 +92,12 @@ class FilterSetESOptions(object):
Example: Example:
class UserFilter(FilterSetES): class UserFilter(FilterSetES):
username = StringFieldES('username', core_type='text', expr=['partial']) username = StringFieldES(field_name='username', lookup_expressions=['contains'])
class Meta: class Meta:
index = UserIndex index = UserIndex
includes = { includes = {
'username': ['term', 'word'] 'username': {
'lookup_expressions': ['term', 'contains']
} }
A query with username as a parameter, will match those words with the 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): def __new__(mcs, name, bases, attrs):
"""Get filters declared explicitly in the class""" """Get filters declared explicitly in the class"""
# get declared as field
declared_filters = mcs.get_declared_filters(bases, attrs) declared_filters = mcs.get_declared_filters(bases, attrs)
attrs['declared_filters'] = declared_filters attrs['declared_filters'] = declared_filters
@ -135,16 +160,20 @@ 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()
# 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): 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) # adding sort field
base_filters.update(meta_filters)
sort_fields = {} sort_fields = {}
if new_class._meta.order_by is not None: if new_class._meta.order_by is not None:
sort_fields = mcs.generate_sort_field(new_class._meta.order_by) sort_fields = mcs.generate_sort_field(new_class._meta.order_by)
sort_type = mcs.create_sort_enum(name, sort_fields) 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. # List of filters declared in the class as static fields.
filters = [ filters = [
(filter_name, attrs.pop(filter_name)) (obj.field_name, attrs.pop(filter_name))
for filter_name, obj in list(attrs.items()) for filter_name, obj in list(attrs.items())
if isinstance(obj, StringFilterES) if isinstance(obj, FilterES)
] ]
# Merge declared filters from base classes # Merge declared filters from base classes
@ -191,7 +220,7 @@ class FilterSetESMetaclass(type):
for name, index_field, data in index_fields: for name, index_field, data in index_fields:
filter_class = mcs.get_filter_exp(name, index_field, data) 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 return meta_filters
@ -229,7 +258,6 @@ class FilterSetESMetaclass(type):
# This inner field is not filterable # This inner field is not filterable
continue continue
inner_data = data[inner_name] if data else None inner_data = data[inner_name] if data else None
index_fields.append(mcs.get_filter_exp(inner_name, inner_field, inner_data, root=name)) index_fields.append(mcs.get_filter_exp(inner_name, inner_field, inner_data, root=name))
return index_fields return index_fields
@ -245,27 +273,23 @@ class FilterSetESMetaclass(type):
# Get lookup_expr from configuration # Get lookup_expr from configuration
if data and 'lookup_expressions' in data: if data and 'lookup_expressions' in data:
if 'lookup_expressions' in kwargs: kwargs['lookup_expressions'] = set(data['lookup_expressions'])
kwargs['lookup_expressions'] = set(kwargs['lookup_expressions'])\
.intersection(set(data['lookup_expressions']))
else:
kwargs['lookup_expressions'] = set(data['lookup_expressions'])
elif 'lookup_expressions' in kwargs: elif 'lookup_expressions' in kwargs:
kwargs['lookup_expressions'] = set(kwargs['lookup_expressions']) 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) return filter_class(**kwargs)
@staticmethod @staticmethod
def get_name(name, root, data): def get_name(name, root, data):
"""Get names of the field and the path to resolve it""" """Get names of the field and the path to resolve it"""
field_name = data.get('name', None) if data else None field_name = data.get('field_name', None) if data else None
attr = data.get('attr', None) if data else None field_name_es = data.get('field_name_es', None) if data else None
if not field_name: if not field_name:
field_name = '{root}_{name}'.format(root=root, name=name) if root else name field_name = '{root}_{name}'.format(root=root, name=name) if root else name
if not attr: if not field_name_es:
attr = '{root}.{name}'.format(root=root, name=name) if root else name field_name_es = '{root}.{name}'.format(root=root, name=name) if root else name
return field_name, attr return field_name, field_name_es
@staticmethod @staticmethod
def create_sort_enum(name, sort_fields): def create_sort_enum(name, sort_fields):
@ -347,12 +371,11 @@ class FilterSetES(six.with_metaclass(FilterSetESMetaclass, object)):
# if the query have data # if the query have data
if len(self.data): if len(self.data):
# for each field passed to the query # for each field passed to the query
for name in self.data: for name, filter in six.iteritems(self.filters_es):
filter_es = self.base_filters.get(name)
# If a target filter is en FilterEs # 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 # 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: if query_filter is not None:
query_base += query_filter query_base += query_filter

View File

@ -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,
}

View File

@ -29,7 +29,7 @@ class ArticleFilterESAsField(FilterSetES):
includes = [] includes = []
order_by = ['id'] 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): class ArticleFilterESInMeta(FilterSetES):
@ -47,11 +47,25 @@ class ArticleFilterESInMetaDict(FilterSetES):
index = ArticleDocument index = ArticleDocument
includes = { includes = {
'headline': { '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): class ESFilterQuery(ObjectType):
"""A query for ES fields""" """A query for ES fields"""
articles_as_field = DjangoESFilterConnectionField( articles_as_field = DjangoESFilterConnectionField(
@ -63,3 +77,6 @@ class ESFilterQuery(ObjectType):
articles_in_meta_dict = DjangoESFilterConnectionField( articles_in_meta_dict = DjangoESFilterConnectionField(
ArticleNode, filterset_class=ArticleFilterESInMetaDict ArticleNode, filterset_class=ArticleFilterESInMetaDict
) )
articles_in_multi_field = DjangoESFilterConnectionField(
ArticleNode, filterset_class=ArticleFilterMultiField
)

View File

@ -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" assert result.data[field]["edges"][1]["node"]["headline"] == "a2"
def test_filter_as_field(): def test_filter_string():
filter_generation( filter_generation(
"articlesAsField", "articlesAsField",
"headline: \"A text\"", "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( filter_generation(
"articlesInMeta", "articlesInMeta",
"headline: \"A text\"", "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( filter_generation(
"articlesInMetaDict", "articlesInMetaDict",
"headline: \"A text\"", "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"}),
) )