Django-filter: [OrderingFilter] 问题/功能请求:允许指定任意 Django ORM 表达式进行排序

创建于 2019-01-08  ·  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 - 按订单中产品的总数量排序

ViewSet $#$ 上配置ordering_fields的基本方法仅允许基于模型字段进行排序,p.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 'ing 过滤的 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_filters.rest_framework.OrderingFilter中提供使用 Django ORM 表达式指定排序的能力?_

但在你回答之前,让我提醒你,我们只解决了第 1 和第 2 页。 为了解决 p.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))

所以 p.3 是遥不可及的,需要一个从 ViewSet 开始贯穿所有内容的解决方案。 它与 Django REST 框架的关系比 django-filters 更多。 在没有聚合表达式支持的情况下,我们真的需要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对在当时是有意义的,因为我们通常从模型中派生公开的参数/表单/等,但没有理由这样做是必要的。 交换映射是有意义的,并允许我们利用更复杂的表达式。

此外,我不认为弃用过程会非常困难,我很乐意帮助参与其中。 基本上, fieldsfield_labels将转换为paramsparam_labels 。 从一个转换到另一个很容易,同时保持向后兼容性,并为使用旧参数的用户发出弃用通知。

要考虑的一件事是复杂表达式的自动转换降序情况( param vs -param )。 例如,如果升序是.asc(nulls_last=True) .,它的倒数应该是.desc(nulls_first=True) ,还是应该不管排序方向如何都保持在最后?

所有14条评论

嗨@earshinov。 感谢您的报告。 很有意思。 让我考虑一下。 如果那时我还没有回复,请在 3 月联系我。 🙂

另一个想法:可以更进一步,允许过滤器指定的不是一个,而是多个表达式或字段名称。

之前(选项 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 中 ping 你。 已经是五月了:)

回到我关于按表达式(一个或多个)排序的建议,我真的认为这是这个难题的缺失部分。

我看到四个基本的表操作:
一种。 序列化
湾。 ~pagination~(与本次讨论无关)
C。 过滤
d。 排序

使用 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

C。 对于使用 Django 表达式进行过滤,可以实现自定义过滤器(参见下面的示例)。 使用普通的标量过滤器 ( filters.NumberFilter ) 可以按聚合字段 ( submodel_count ) 进行过滤。

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})
        )

d。 排序没有解决方案:-(

这是我们迄今为止一直在使用的自定义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和其他辅助方法的实现。

好的。 😀

谢谢你的平。

Tbh 我没有时间考虑这个。

在您的所有伟大想法中,您对我们可能做出的微小改变有什么建议吗? (也许通过 PR 更容易推进)。

是的,如何使这些更改向后兼容并不是很明显,我将不得不考虑一下。 不要指望很快会有 PR(将在互联网稀缺的地方度假)。

没关系。 🙂

这里不急。 最好我们会考虑清楚,如果有的话。

@carltongibson ,好的,我们考虑了一点,但找不到一种可以接受的方法来组合OrderingFilter的新旧实现。

如果我们要将所有内容都放在一个类中,最大的问题是在旧实现中,字段字典存储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)
        }

    )

似乎工作得很好!
现在唯一剩下的就是弄清楚如何组合多个过滤器,ak。 “价格”和“库存”合二为一。

ach - 开始对此做出回应,但我的电脑坏了。

我认为这里提出的改变是明智的。 model_field - parameter_name对在当时是有意义的,因为我们通常从模型中派生公开的参数/表单/等,但没有理由这样做是必要的。 交换映射是有意义的,并允许我们利用更复杂的表达式。

此外,我不认为弃用过程会非常困难,我很乐意帮助参与其中。 基本上, fieldsfield_labels将转换为paramsparam_labels 。 从一个转换到另一个很容易,同时保持向后兼容性,并为使用旧参数的用户发出弃用通知。

要考虑的一件事是复杂表达式的自动转换降序情况( param vs -param )。 例如,如果升序是.asc(nulls_last=True) .,它的倒数应该是.desc(nulls_first=True) ,还是应该不管排序方向如何都保持在最后?

好的,超级@rpkilby。

很高兴看到我们在这里前进。 我主要关心的是我们是否正确记录了我们正在做的事情。 用户已经发现 df 文档有点_简洁_ 我们应该说 🙂 — 很高兴添加 API,但我们需要确保它清晰。

这样的事情不一定是一个人的努力。

@rpkilby@carltongibson ,如果您需要我的帮助将我的更改集成到项目中,我想我可以花一些时间。 但我需要方向。 我从哪里开始以及如何开始?

嗨@earshinov。 我会从起草文档开始。 我们讲的故事是什么? 从那里代码更改。 公关,即使只是草稿,也能给我们一些话题。

此外,如果你有它们,请测试用例。 它们可能需要进行调整以匹配最终更改,但考虑到各种情况将非常有帮助(例如,将.asc转换为.desc ,处理nulls_fist / nulls_last参数等)。

此页面是否有帮助?
0 / 5 - 0 等级