Skip to content

Commit

Permalink
feat(filters): implement new order filter
Browse files Browse the repository at this point in the history
This introduces an ordering feature analog to the rewritten filtering.
What this allows us to do is to order by a chain of arbitrary fields,
choosing ascending/descending on the way. New fields can be added
easily by extending an `OrderSet` class - similar to `FilterSet` classes
we already have.
  • Loading branch information
David Vogt committed Oct 17, 2019
1 parent cbb8ac9 commit 012d1f1
Show file tree
Hide file tree
Showing 3 changed files with 275 additions and 35 deletions.
144 changes: 109 additions & 35 deletions caluma/core/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
GlobalIDMultipleChoiceField,
SlugMultipleChoiceField,
)
from .ordering import CalumaOrdering
from .relay import extract_global_id
from .types import DjangoConnectionField

Expand Down Expand Up @@ -64,7 +65,73 @@ def clean(self, data):
return data


def FilterCollectionFactory(filterset_class):
class AscDesc(Enum):
ASC = "ASC"
DESC = "DESC"


class FilterCollectionFilter(Filter):
def filter(self, qs, value):
if value in EMPTY_VALUES:
return qs

filter_coll = self.filterset_class()
for flt in value:
invert = flt.pop("invert", False)
flt_key = list(flt.keys())[0]
flt_val = flt[flt_key]
filter = filter_coll.get_filters()[flt_key]

new_qs = filter.filter(qs, flt_val)

if invert:
qs = qs.exclude(pk__in=new_qs)
else:
qs = new_qs

return qs


class FilterCollectionOrdering(Filter):
def _order_part(self, qs, ord, filter_coll):
direction = ord.pop("direction", "ASC")

assert len(ord) == 1
filt_name = list(ord.keys())[0]
filter = filter_coll.get_filters()[filt_name]
qs, field = filter.get_ordering_value(qs, ord[filt_name])

# Normally, people use ascending order, and in this context it seems
# natural to have NULL entries at the end.
# Making the `nulls_first`/`nulls_last` parameter accessible in the
# GraphQL interface would be overkill, at least for now.
return (
qs,
OrderBy(
field,
descending=(direction == "DESC"),
nulls_first=(direction == "DESC"),
nulls_last=(direction == "ASC"),
),
)

def filter(self, qs, value):
if value in EMPTY_VALUES:
return qs
filter_coll = self.filterset_class()

order_by = []
for ord in value:
qs, order_field = self._order_part(qs, ord, filter_coll)
order_by.append(order_field)

if order_by:
qs = qs.order_by(*order_by)

return qs


def FilterCollectionFactory(filterset_class, ordering): # noqa:C901
"""
Build a single filter from a `FilterSet`.
Expand All @@ -78,39 +145,17 @@ def FilterCollectionFactory(filterset_class):
Usage:
>>> class MyFilterSet(FilterSet):
... filter = FilterCollectionFactory(FilterSetWithActualFilters)
... filter = FilterCollectionFactory(FilterSetWithActualFilters, ordering=False)
"""

field_class_name = f"{filterset_class.__name__}Field"
field_type_name = f"{filterset_class.__name__}Type"
collection_name = f"{filterset_class.__name__}Collection"

# The field class.
custom_field_class = type(field_class_name, (CompositeFieldClass,), {})

class FilterCollection(Filter):
field_class = custom_field_class

def filter(self, qs, value):
if value in EMPTY_VALUES:
return qs

filter_coll = filterset_class()
for flt in value:
invert = flt.pop("invert", False)
flt_key = list(flt.keys())[0]
flt_val = flt[flt_key]
filter = filter_coll.get_filters()[flt_key]

new_qs = filter.filter(qs, flt_val)

if invert:
qs = qs.exclude(pk__in=new_qs)
else:
qs = new_qs

return qs

@convert_form_field.register(custom_field_class)
def convert_field(field):

Expand All @@ -124,28 +169,50 @@ def convert_field(field):
def _get_or_make_field(name, filt):
return convert_form_field(filt.field)

def _should_include_filter(filt):
# if we're in ordering mode, we want
# to return True for all CalumaOrdering types,
# and if it's false, we want the opposite
return ordering == isinstance(filt, CalumaOrdering)

filter_fields = {
name: _get_or_make_field(name, filt)
for name, filt in _filter_coll.filters.items()
if not isinstance(filt, OrderingFilter)
# exclude orderBy in our fields. We want only new-style order filters
if _should_include_filter(filt) and name != "orderBy"
}

filter_fields["invert"] = graphene.Boolean(required=False, default=False)
if ordering:
filter_fields["direction"] = AscDesc(default=AscDesc.ASC, required=False)
else:
filter_fields["invert"] = graphene.Boolean(required=False, default=False)

filter_type = type(field_type_name, (InputObjectType,), filter_fields)

converted = List(filter_type)
registry.register_converted_field(field, converted)
return converted

return FilterCollection()
filter_impl = FilterCollectionOrdering if ordering else FilterCollectionFilter

filter_coll = type(
collection_name,
(filter_impl,),
{"field_class": custom_field_class, "filterset_class": filterset_class},
)
return filter_coll()


def CollectionFilterSetFactory(filterset_class):
def CollectionFilterSetFactory(filterset_class, orderset_class=None):
"""
Build single-filter filterset classes.
Use this in place of a regular filterset_class parametrisation.
Use this in place of a regular filterset_class parametrisation in
the serializers.
If you pass the optional `orderset_class` parameter, it is used for
an `order` filter. The filters defined in the `orderset_class` must
inherit from `caluma.core.ordering.CalumaOrdering` and provide a
`get_ordering_value()` method.
Example:
>>> all_documents = DjangoFilterConnectionField(
Expand All @@ -154,20 +221,27 @@ def CollectionFilterSetFactory(filterset_class):
"""

if filterset_class.__name__ in CollectionFilterSetFactory._cache:
return CollectionFilterSetFactory._cache[filterset_class.__name__]
suffix = "" if orderset_class else "NoOrdering"
cache_key = filterset_class.__name__ + suffix

if cache_key in CollectionFilterSetFactory._cache:
return CollectionFilterSetFactory._cache[cache_key]

coll_fields = {"filter": FilterCollectionFactory(filterset_class, ordering=False)}
if orderset_class:
coll_fields["order"] = FilterCollectionFactory(orderset_class, ordering=True)

ret = CollectionFilterSetFactory._cache[filterset_class.__name__] = type(
ret = CollectionFilterSetFactory._cache[cache_key] = type(
f"{filterset_class.__name__}Collection",
(filterset_class, FilterSet),
{
"filter": FilterCollectionFactory(filterset_class),
**coll_fields,
"Meta": type(
"Meta",
(filterset_class.Meta,),
{
"model": filterset_class.Meta.model,
"fields": filterset_class.Meta.fields + ("filter",),
"fields": filterset_class.Meta.fields + tuple(coll_fields.keys()),
},
),
},
Expand Down
101 changes: 101 additions & 0 deletions caluma/core/ordering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from typing import Any, Tuple, Union

from django import forms
from django.db.models.expressions import CombinedExpression, F, Value
from django.db.models.query import QuerySet
from django_filters.fields import ChoiceField
from django_filters.rest_framework import Filter
from graphene import Enum
from graphene_django.forms.converter import convert_form_field
from graphene_django.registry import get_global_registry
from rest_framework import exceptions

OrderingFieldType = Union[F, str]


class CalumaOrdering(Filter):
"""Base class for new-style ordering filters.
This is not really a filter in the rest framework sense,
but we use it's infrastructure to get conversion to GraphQL
types.
This is an abstract base class. To use it, you need to
implement the `get_ordering_value()` method to return a tuple
of `(queryset, ordering_field)`. The queryset may be modified,
for example if you need to annotate it to get to the desired
ordering field.
"""

def get_ordering_value(
self, qs: QuerySet, value: Any
) -> Tuple[QuerySet, OrderingFieldType]: # pragma: no cover
raise NotImplementedError("get_ordering_value() needs to be implemented")


class MetaFieldOrdering(CalumaOrdering):
"""Ordering filter for ordering by `meta` values."""

def __init__(self, field_name="meta"):
super().__init__()
self.field_name = field_name

field_class = forms.CharField

def get_ordering_value(
self, qs: QuerySet, value: Any
) -> Tuple[QuerySet, OrderingFieldType]:

return qs, CombinedExpression(F(self.field_name), "->", Value(value))


class AttributeOrderingMixin(CalumaOrdering):
def get_ordering_value(self, qs, value):
if value not in self._fields: # pragma: no cover
# this is normally covered by graphene enforcing its schema,
# but we still need to handle it
raise exceptions.ValidationError(
f"Field '{value}' not available for sorting on {qs.model.__name}"
)
return qs, F(value)


def AttributeOrderingFactory(model, fields=None, exclude_fields=None):
"""Build ordering field for a given model.
Used to define an ordering field that is presented as an enum
"""
if not exclude_fields:
exclude_fields = []

if not fields:
fields = [f.name for f in model._meta.fields if f.name not in exclude_fields]

field_enum = type(
f"Sortable{model.__name__}Attributes",
(Enum,),
{field.upper(): field for field in fields},
)

field_class = type(f"{model.__name__}AttributeField", (ChoiceField,), {})

ordering_class = type(
f"{model.__name__}AttributeOrdering",
(AttributeOrderingMixin, CalumaOrdering),
{"field_class": field_class, "_fields": fields},
)

@convert_form_field.register(field_class)
def to_enum(field):
registry = get_global_registry()
converted = registry.get_converted_field(field)

if converted: # pragma: no cover
return converted

converted = field_enum()

registry.register_converted_field(field, converted)
return converted

return ordering_class()
65 changes: 65 additions & 0 deletions caluma/form/ordering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from typing import Any, Tuple

from django import forms
from django.db.models import OuterRef, Subquery
from django.db.models.expressions import F
from django.db.models.query import QuerySet
from rest_framework import exceptions

from caluma.core.ordering import CalumaOrdering, OrderingFieldType
from caluma.form.models import Answer, Question


class AnswerValueOrdering(CalumaOrdering):
"""Ordering filter for ordering documents by value of an answer.
This can also be used for ordering other things that contain documents,
such as cases, work items etc.
"""

field_class = forms.CharField

def __init__(self, document_via=None, *args, **kwargs):
super().__init__(*args, **kwargs)

self._document_locator_prefix = (
"" if document_via is None else f"{document_via}__"
)

def get_ordering_value(
self, qs: QuerySet, value: Any
) -> Tuple[QuerySet, OrderingFieldType]:
# First, join the requested answer, then annotate the QS accordingly.
# Last, return a field corresponding to the value
#
question = Question.objects.get(pk=value)
QUESTION_TYPE_TO_FIELD = {
Question.TYPE_INTEGER: "value",
Question.TYPE_FLOAT: "value",
Question.TYPE_DATE: "date",
Question.TYPE_CHOICE: "value",
Question.TYPE_TEXTAREA: "value",
Question.TYPE_TEXT: "value",
Question.TYPE_FILE: "file",
Question.TYPE_DYNAMIC_CHOICE: "value",
Question.TYPE_STATIC: "value",
}

try:
value_field = QUESTION_TYPE_TO_FIELD[question.type]
except KeyError: # pragma: no cover
raise exceptions.ValidationError(
f"Question '{question.slug}' has unsupported type {question.type} for ordering"
)

answers_subquery = Subquery(
Answer.objects.filter(question=question, document=OuterRef("pk")).values(
value_field
)
)
ann_name = f"order_{value}"

qs = qs.annotate(**{ann_name: answers_subquery})

# TODO: respect document_via
return qs, F(ann_name)

0 comments on commit 012d1f1

Please sign in to comment.