Django-filter: [OrderingFilter] Вопрос / запрос функции: разрешить указывать произвольное выражение Django ORM для заказа

Созданный на 8 янв. 2019  ·  14Комментарии  ·  Источник: carltongibson/django-filter

Привет! Прежде всего, я хотел бы поблагодарить вас за этот проект, который оказался чрезвычайно полезным в сочетании с Django REST Framework в нашем приложении.

В настоящее время я борюсь с проблемой сериализации, фильтрации и упорядочения по полям связанных моделей. В этом выпуске я хотел бы остановиться только на упорядочении.

Позвольте мне проиллюстрировать мои намерения следующей моделью:

# models.py

from django.contrib.auth.models import User
from django.db import models


class Order(models.Model):
    user = models.ForeignKey(User, on_delete=models.PROTECT, null=False, blank=False)
    created = models.DateTimeField(null=False, blank=False, auto_now_add=True)
    submitted = models.DateTimeField(null=True, blank=True)

    def __str__(self):
        return f'Order #{self.id}'


class Product(models.Model):
    name = models.CharField(null=False, blank=False, max_length=256)
    price = models.DecimalField(null=False, blank=False, decimal_places=2, max_digits=12)

    def __str__(self):
        return self.name


class OrderLine(models.Model):
    product = models.ForeignKey(Product, on_delete=models.PROTECT, null=False, blank=False, related_name='order_lines')
    quantity = models.IntegerField(null=False, blank=False)
    product_price = models.DecimalField(null=False, blank=False, decimal_places=2, max_digits=12)
    total_price = models.DecimalField(null=False, blank=False, decimal_places=2, max_digits=12)
    order = models.ForeignKey(Order, on_delete=models.CASCADE, null=False, blank=False, related_name='order_lines')

    def __str__(self):
        return f'{self.order}: {self.product.name} x{self.quantity}'

Затем давайте начнем с DRF ViewSet без настроенной фильтрации и упорядочения:

# views.py

from rest_framework import viewsets
from django_filters.rest_framework import DjangoFilterBackend, FilterSet, OrderingFilter
from .models import Order

class OrderFilterSet(FilterSet):
    pass


class OrderViewSet(viewsets.ModelViewSet):
    queryset = Order.objects.all()
    serializer_class = OrderSerializer  # the definition of OrderSerializer is irrelevant and not shown here
    filter_backends = (DjangoFilterBackend, OrderingFilter)
    filterset_class = OrderFilterSet
    ordering_fields = ()

    class Meta:
        model = Order

Моя цель в рамках этой проблемы - позволить клиенту упорядочить запрошенный список заказов по этим полям:

  1. ordering=[-]created - заказ по дате создания заказа ( [-] указывает необязательный - для описания заказа)
  2. ordering=[-]user - заказ по ФИО пользователя, разместившего заказ
  3. ordering=[-]total_quantity - упорядочить по общему количеству товаров в заказе

Базовый подход с ordering_fields , настроенным на ViewSet , позволяет упорядочивать только на основе полей модели, стр. 1. К счастью, мы можем использовать более продвинутый метод подкласса filters.OrderingFilter , также описанный в https://django-filter.readthedocs.io/en/master/ref/filters.html#orderingfilter :

# views.py

from rest_framework import viewsets
from django_filters.rest_framework import DjangoFilterBackend, FilterSet, OrderingFilter
from .models import Order


class OrderOrderingFilter(OrderingFilter):

    def __init__(self):
        super().__init__(fields={
            # example: model field
            # Order by order creation date
            'created': 'created',

            # example: expression on related model
            # Order by user full name
            'user': '',  # ???

            # example: aggregate expression
            'total_quantity': ''  # ???
        })


class OrderFilterSet(FilterSet):
    ordering = OrderOrderingFilter()


class OrderViewSet(viewsets.ModelViewSet):
    queryset = Order.objects.all()
    serializer_class = OrderSerializer  # the definition of OrderSerializer is irrelevant and not shown here
    filter_backends = (DjangoFilterBackend,)
    filterset_class = OrderFilterSet

    class Meta:
        model = Order

Однако даже при использовании этого расширенного метода django-filter, похоже, требует имени поля. Единственный способ обойти эту проблему — annotate 'отфильтровать QuerySet:

# ... the rest is omitted to brevity ...
class OrderOrderingFilter(OrderingFilter):

    def __init__(self):
        super().__init__(fields={
            # ... the rest is omitted to brevity ...
            'user_order': 'user',
        })

    def filter(self, qs, value):
        if value:
            qs = self._annotate(qs)
        return super().filter(qs, value)

    def _annotate(self, qs, value):
        if 'user' in value or '-user' in value:
            qs = qs.annotate(user_order=functions.Concat(
                models.F('user__first_name'),
                models.Value(' '),
                models.F('user__last_name'),
            ))
        return qs

Некоторые могут сказать, что использование здесь .annotate — ужасная идея, потому что нельзя надежно смешивать .annotate с .filter , как описано в https://docs.djangoproject.com/en/2.1 . .annotate , так что пока у нас все хорошо. (_Я на самом деле вернусь к агрегации позже_)

Еще одна проблема с последним образцом кода заключается в том, что код повторяется и его лучше извлекать в базовый класс, который затем можно удобно наследовать. На данный момент я написал свой собственный класс OrderingFilter , который является полной альтернативой тому, что предоставляется django-filter. Его полный исходный код приведен ниже. Вы можете заметить, что он многое позаимствовал у django-filter:

import typing

from django.db import models
from django.db.models import expressions
from django.forms.utils import pretty_name
from django.utils.translation import ugettext_lazy as _

from django_filters import filters
from django_filters.constants import EMPTY_VALUES

__all__ = ('OrderingFilter',)


class OrderingFilter(filters.BaseCSVFilter, filters.ChoiceFilter):
    """
    An alternative to django_filters.filter.OrderingFilter that allows to specify any Django ORM "expression" for ordering.

    Usage examples:

      class MyOrderingFilter(ddf.OrderingFilter):
        def __init__(self):
          super().__init__(fields={

            # a model field
            'id': 'id'

            # an expression
            'published_by':
              functions.Concat(
                expressions.F(f'published_user__first_name'),
                expressions.Value(' '),
                expressions.F(f'published_user__last_name')
              ),

            # a complete field descriptor with custom field label
            'reported_by': {
              'label': 'Reporter',
              'desc_label': 'Reporter (descending)',  # optional, would be derived from 'label' anyway
              'expr': functions.Concat(
                expressions.F(f'reported_user__first_name'),
                expressions.Value(' '),
                expressions.F(f'reported_user__last_name')
              ),
            }
          })

    For more information about expressions, please see the official Django documentation at
    https://docs.djangoproject.com/en/2.1/ref/models/expressions/
    """

    _fields: typing.Mapping[str, 'FieldDescriptor']

    def __init__(self, fields: typing.Mapping[str, typing.Any]):
        self._fields = normalize_fields(fields)
        super().__init__(choices=build_choices(self._fields))

    # <strong i="15">@override</strong>
    def filter(self, qs: models.QuerySet, value: typing.Union[typing.List[str], None]):
        return qs if value in EMPTY_VALUES else qs.order_by(*(expr for expr in map(self.get_ordering_expr, value)))

    def get_ordering_expr(self, param) -> expressions.Expression:
        descending = param.startswith('-')
        param = param[1:] if descending else param
        field_descriptor = self._fields.get(param)
        return None if field_descriptor is None else \
            field_descriptor.expr if not descending else field_descriptor.expr.desc()


def normalize_fields(fields: typing.Mapping[str, typing.Any]) -> typing.Mapping[str, 'FieldDescriptor']:
    return dict((
        param_name,
        FieldDescriptor(param_name, {'expr': normalize_expr(field)} if isinstance(field, (str, expressions.Expression)) else field)
    ) for param_name, field in fields.items())


def normalize_expr(expr: typing.Union[str, expressions.Expression]):
    return models.F(expr) if isinstance(expr, str) else expr


descending_fmt = _('%s (descending)')


class FieldDescriptor:
    expr: models.Expression

    def __init__(self, param_name: str, data: typing.Mapping[str, typing.Any]):
        self.expr = normalize_expr(data['expr'])
        self.label = data.get('label', _(pretty_name(param_name)))
        self.desc_label = data.get('desc_label', descending_fmt.format(self.label))


def build_choices(fields: typing.Mapping[str, 'FieldDescriptor']):
    choices = []
    for param_name, field_descriptor in fields.items():
        choices.append((param_name, field_descriptor.label))
        choices.append((f'-{param_name}', field_descriptor.desc_label))
    return choices

Используя этот класс, OrdersOrderingFilter становится красивым и лаконичным:

# ... the rest is omitted to brevity ...
class OrderOrderingFilter(ddf.OrderingFilter):

    def __init__(self):
        super().__init__(fields={
            # ... the rest is omitted to brevity ...
            'user': functions.Concat(
                models.F('user__first_name'),
                models.Value(' '),
                models.F('user__last_name'),
            ),
        })

Это поднимает вопрос, с которым я пришел сюда:

_Что вы думаете о предоставлении возможности указывать порядок с выражением Django ORM в django_filters.rest_framework.OrderingFilter ?_

Но прежде чем ответить, напомню, что у нас решены только п.п. 1 и 2. Для решения п.3 нам понадобится выражение агрегации:

aggregates.Sum(models.F('order_lines__quantity'))

Мы не можем использовать это выражение в .annotate , потому что результаты смешивания его с фильтрацией QuserySet будут непредсказуемыми. Мы также не можем использовать это выражение непосредственно в вызове Query.order(...) , потому что

django.core.exceptions.FieldError: Using an aggregate in order_by() without also including it in annotate() is not allowed: Sum(F(order_lines__quantity))

Так что пункт 3 недоступен, требуя решения, которое пробьет все, начиная с ViewSet. Это больше связано с Django REST Framework, чем с django-фильтрами. Действительно ли нам нужна поддержка выражений в django_filters.rest_framework.OrderingFilter без поддержки выражений агрегирования? Это может только запутать пользователей django-filter, не принося особой пользы.

Я с нетерпением жду вашего мнения. Я знаю, что это много информации для переваривания. Надеюсь, мой тестовый проект поможет: https://github.com/earshinov/django_sample/tree/master/django_sample/ordering_by_expression .

ImprovemenFeature

Самый полезный комментарий

ach - начал отвечать на это, но мой компьютер сломался.

Я думаю, что предлагаемые изменения здесь разумны. Пара model_field - parameter_name имела смысл в то время, поскольку мы обычно получаем открытые параметры/формы/и т. д. из модели, но нет причин, по которым это необходимо. Замена отображения имела бы смысл и позволила бы нам использовать преимущества более сложных выражений.

Кроме того, я не думаю, что процесс устаревания будет безумно сложным, и я был бы рад помочь в этом. По сути, fields и field_labels переходят в params и param_labels . Достаточно просто преобразовать одно в другое, сохранив при этом обратную совместимость и выставив уведомление об устаревании для пользователей, использующих старые аргументы.

Одна вещь, которую следует учитывать, — это автоматическое преобразование сложных выражений для случая убывания ( param против -param ). например, если по возрастанию .asc(nulls_last=True) ., должна ли обратная этому быть .desc(nulls_first=True) , или нули должны оставаться последними независимо от направления сортировки?

Все 14 Комментарий

Привет @earshinov. Спасибо за отчет. Очень интересно. Позвольте мне подумать об этом. Пингуйте мне в марте, если я не ответил к тому времени. 🙂

Еще одна идея: можно пойти еще дальше и позволить фильтру указывать не одно, а несколько выражений или имен полей.

До (вариант 1):

class OrderOrderingFilter(ddf.OrderingFilter):

    def __init__(self):
        super().__init__(fields={
            'user': functions.Concat(
                models.F('user__first_name'),
                models.Value(' '),
                models.F('user__last_name'),
            ),
        })

После (вариант 2):

class OrderOrderingFilter(ddf.OrderingFilter):

    def __init__(self):
        super().__init__(fields={
            'user': ('user__first_name', 'user__last_name'),
        })

В обоих случаях ordering=user упорядочивает данные сначала по имени пользователя, а затем по фамилии пользователя, но вариант 2 проще написать и, возможно, более эффективен, когда речь идет о запросах к БД.

Привет @carltongibson ,

Вы просили меня пропинговать вас в Match. Уже май :)

Возвращаясь к моему предложению об упорядочении по выражениям (одному или нескольким), я действительно думаю, что это недостающая часть головоломки.

Я вижу четыре основные операции с таблицами:
а. сериализация
б. ~нумерация страниц~ (не имеет отношения к этому обсуждению)
в. фильтрация
д. сортировка

Используя DRF и django-filter, можно реализовать все это с поддержкой связанных моделей, кроме сортировки :

а. Для сериализации у нас есть вложенные сериализаторы . Кроме того, если нам нужно вернуть агрегированное поле (подумайте о count ), мы можем .annotate() набор запросов:

from django.db.models import aggregates, functions, F, Value

class ModelViewSet(...):

    def get_queryset(self):
        qs = Model.objects.all()

        if self.action == 'list':
            qs = qs.annotate(
                author_full_name=functions.Trim(functions.Concat(
                    F('author__first_name'),
                    Value(' '),
                    F('author__last_name'),
                )),
                submodel_count=aggregates.Count('submodel'))
            )

        return qs

в. Для фильтрации с помощью выражений Django можно реализовать собственный фильтр (см. пример ниже). Фильтрация по агрегированному полю ( submodel_count ) возможна с использованием обычных скалярных фильтров ( filters.NumberFilter ).

from django_filters import filters
from django_filters.rest_framework import FilterSet

class ModelFilterSet(FilterSet):

    author = UserFilter(field_name='author', label='author', lookup_expr='icontains')


class UserFilter(filters.Filter):
    """A django_filters filter that implements filtering by user's full name.

    def filter(self, qs: QuerySet, value: str) -> QuerySet:
        # first_name <lookup_expr> <value> OR last_name <lookup_expr> <value>
        return qs if not value else qs.filter(
            Q(**{f'{self.field_name}__first_name__{self.lookup_expr}': value}) |
            Q(**{f'{self.field_name}__last_name__{self.lookup_expr}': value})
        )

д. Нет решения для сортировки :-(

Вот полная реализация нашего пользовательского OrderingFilter , который мы использовали до сих пор:

class OrderingFilter(filters.BaseCSVFilter, filters.ChoiceFilter):
    """An alternative to :class:`django_filters.filters.OrderingFilter` that allows to specify any Django ORM expression for ordering.

    Usage example:

    .. code-block:: python

      from django.db import models
      from django.db.models import aggregates, expressions, fields

      import ddl

      class OrderOrderingFilter(ddl.OrderingFilter):
        def __init__(self):
          super().__init__(fields={

            # a model field
            'created': 'created'

            # an expression
            'submitted': expressions.ExpressionWrapper(
                models.Q(submitted_date__isnull=False),
                output_field=fields.BooleanField()
            ),

            # multiple fields or expressions
            'user': ('user__first_name', 'user__last_name'),

            # a complete field descriptor with custom field label
            'products': {
              'label': 'Total number of items in the order',
              # if not specified, `desc_label` would be derived from 'label' anyway
              'desc_label': 'Total number of items in the order (descending)',
              'expr': aggregates.Sum('order_lines__quantity'),
              # it is also possible to filter by multiple fields or expressions here
              #'exprs': (...)
            },
          })

    For more information about expressions, see the official Django documentation at
    https://docs.djangoproject.com/en/dev/ref/models/expressions/
    """

    _fields: typing.Mapping[str, 'FieldDescriptor']

    def __init__(self, fields: typing.Mapping[str, typing.Any]):
        self._fields = normalize_fields(fields)
        super().__init__(choices=build_choices(self._fields))

    # <strong i="7">@override</strong>
    def filter(self, qs: models.QuerySet, value: typing.Union[typing.List[str], None]):
        return qs if value in EMPTY_VALUES else qs.order_by(*(itertools.chain(*(self.__get_ordering_exprs(param) for param in value))))

    def __get_ordering_exprs(self, param) -> typing.Union[None, typing.List[expressions.Expression]]:
        descending = param.startswith('-')
        param = param[1:] if descending else param
        field_descriptor = self._fields.get(param)
        return () if field_descriptor is None else \
            field_descriptor.exprs if not descending else \
            (expr.desc() for expr in field_descriptor.exprs)



def normalize_fields(fields: typing.Mapping[str, typing.Any]) -> typing.Mapping[str, 'FieldDescriptor']:
    return dict((
        param_name,
        FieldDescriptor(param_name, field if isinstance(field, collections.Mapping) else {'exprs': normalize_exprs(field)})
    ) for param_name, field in fields.items())

def normalize_exprs(exprs: typing.Union[
        typing.Union[str, expressions.Expression],
        typing.List[typing.Union[str, expressions.Expression]]
    ]) -> typing.List[expressions.Expression]:
    # `exprs` is either a single expression or a Sequence of expressions
    exprs = exprs if isinstance(exprs, collections.Sequence) and not isinstance(exprs, str) else (exprs,)
    return [normalize_expr(expr) for expr in exprs]

def normalize_expr(expr: typing.Union[str, expressions.Expression]) -> expressions.Expression:
    return models.F(expr) if isinstance(expr, str) else expr


descending_fmt = _('%s (descending)')


class FieldDescriptor:
    exprs: typing.List[models.Expression]

    def __init__(self, param_name: str, data: typing.Mapping[str, typing.Any]):
        exprs = data.get('exprs') or data.get('expr')
        if not exprs:
            raise ValueError("Expected 'exprs' or 'expr'")
        self.exprs = normalize_exprs(exprs)
        self.label = data.get('label', _(pretty_name(param_name)))
        self.desc_label = data.get('desc_label', descending_fmt.format(self.label))


def build_choices(fields: typing.Mapping[str, 'FieldDescriptor']):
    choices = []
    for param_name, field_descriptor in fields.items():
        choices.append((param_name, field_descriptor.label))
        choices.append((f'-{param_name}', field_descriptor.desc_label))
    return choices

_Update_: Включена реализация normalize_fields и других вспомогательных методов.

Хорошо. 😀

Спасибо за пинг.

Тбх, у меня не было времени подумать об этом.

Во всех ваших великих мыслях есть ли у вас предложение относительно небольшого изменения, которое мы могли бы внести? (Может быть, просто легче продвигаться вперед с пиаром).

Да, не сразу понятно, как сделать такие изменения обратно совместимыми, надо подумать. Не ждите PR в ближайшее время (будете в отпуске в местах, где мало интернета).

Хорошо. 🙂

Здесь нет спешки. Лучше продумаем, если вообще.

@carltongibson , хорошо, мы немного поразмышляли, но не смогли найти приемлемый способ объединения старой и новой реализации OrderingFilter .

Если мы собираемся поместить все в один класс, то самая большая проблема заключается в том, что в старой реализации поля dict хранят пары model_field - parameter_name , тогда как в новой реализации наоборот parameter_name - model_field (так и должно быть, т.к. вместо model_field может быть передано выражение, которое может храниться только в значении, но не в ключе в словаре) .

Технически эту проблему можно решить, интерпретируя словарь «по-старому», если он содержит только строки, и «по-новому» в противном случае. В этом случае пользователю придется «переворачивать» словарные статьи, когда возникает потребность в упорядочении по выражению. И будьте осторожны, чтобы «перевернуть» словарные записи обратно, если выражение упорядочения будет удалено... Для меня это звучит как ужасный пользовательский опыт. Это также сделает реализацию запутанной.

Как вы думаете, возможно ли ввести новый OrderingFilter вместе со старым под другим именем, например ExpressionOrderingFilter ?

Это здорово, как раз в тот момент, когда мне это было нужно! Спасибо @earshinov!

Как бы я сделал что-то вроде:

MyModel.objects.all().order_by(F('price').desc(nulls_last=True))

С вашим фильтром заказов?

    o = ExpressionOrderingFilter(

        fields={
                'price': F('price').desc(nulls_last=True)
        }

    )

кажется, отлично работает!
Теперь осталось только выяснить, как комбинировать несколько фильтров, например. «цена» и «запас» в одном.

ach - начал отвечать на это, но мой компьютер сломался.

Я думаю, что предлагаемые изменения здесь разумны. Пара model_field - parameter_name имела смысл в то время, поскольку мы обычно получаем открытые параметры/формы/и т. д. из модели, но нет причин, по которым это необходимо. Замена отображения имела бы смысл и позволила бы нам использовать преимущества более сложных выражений.

Кроме того, я не думаю, что процесс устаревания будет безумно сложным, и я был бы рад помочь в этом. По сути, fields и field_labels переходят в params и param_labels . Достаточно просто преобразовать одно в другое, сохранив при этом обратную совместимость и выставив уведомление об устаревании для пользователей, использующих старые аргументы.

Одна вещь, которую следует учитывать, — это автоматическое преобразование сложных выражений для случая убывания ( param против -param ). например, если по возрастанию .asc(nulls_last=True) ., должна ли обратная этому быть .desc(nulls_first=True) , или нули должны оставаться последними независимо от направления сортировки?

ОК, супер @rpkilby.

Рад видеть, что мы продвигаемся вперед здесь. Моя главная забота заключается в том, чтобы мы правильно документировали то, что делаем. Пользователи уже находят документы df немного _краткими_, скажем так 🙂 — Рады добавить API, но нам нужно убедиться, что он понятен.

Такая вещь не должна быть усилиями одного человека.

@rpkilby , @carltongibson , Если вам нужна моя помощь в интеграции моих изменений в проект, думаю, я могу уделить немного времени. Но мне нужны направления. Где и как мне начать?

Привет @earshinov. Я бы начал с составления документов. Какую историю мы рассказываем? Оттуда код меняется. Пиар, даже если черновик дает нам о чем поговорить.

Кроме того, тест-кейсы, если они у вас есть. Возможно, их потребуется скорректировать, чтобы они соответствовали окончательным изменениям, но было бы очень полезно продумать различные случаи (например, преобразование .asc в .desc , обработка nulls_fist / nulls_last аргументы

Была ли эта страница полезной?
0 / 5 - 0 рейтинги