Django-filter: [OrderingFilter]質問/機能リクエスト:順序付けに任意のDjangoORM式を指定できるようにする

作成日 2019年01月08日  ·  14コメント  ·  ソース: carltongibson/django-filter

こんにちは! まず、このプロジェクトに感謝​​します。このプロジェクトは、アプリケーションでDjango RESTFrameworkと組み合わせて非常に役立つことが証明されました。

現在、関連モデルのフィールドごとのシリアル化、フィルタリング、および順序付けの問題に苦労しています。 この号では、注文のみに焦点を当てたいと思います。

次のモデルで私の意図を説明しましょう。

# 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}'

次に、フィルタリングと順序付けを構成せずにDRFViewSetから始めましょう。

# 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 -注文された製品の総数で注文する

ViewSetordering_fieldsを設定する基本的なアプローチでは、モデルフィールドに基づく順序付けのみが可能です(p.1)。 幸い、 https ://django-filter.readthedocs.io/en/master/ref/filters.html#orderingfilterで説明されているように、 filters.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にはフィールド名が必要なようです。 この問題を回避する唯一の方法は、フィルタリングされたQuerySetをannotate 'することです。

# ... 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

https://docs.djangoproject.com/en/2.1で説明されているように、 .annotate.filterを確実に混合できないため、ここで.annotateを使用するのはひどい考えだと言う人もいます。 / topics / db / aggregation /#order -of-annotate-and-filter-clauses。 ただし、 .annotateの呼び出しで集計を使用しない限り、問題はありません。これまでのところ問題ありません。 (_後で実際に集計に戻ります_)

最新のコードサンプルのもう1つの問題は、コードが反復的であり、便利に継承できる基本クラスに抽出する方がよいことです。 今のところ、私は自分の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-filtersよりもDjangoRESTFrameworkと関係があります。 集計式のサポートなしで、実際に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のペアは、通常、公開されたparams / forms / etcをモデルから導出するため、当時は理にかなっていますが、これが必要な理由はありません。 マッピングを交換することは理にかなっており、より複雑な式を利用できるようになります。

また、非推奨のプロセスがめちゃくちゃ難しいとは思わないので、喜んでそれを投入するのを手伝いたいと思います。 基本的に、 fieldsfield_labelsparamsparam_labelsに移行します。 下位互換性を維持し、古い引数を使用しているユーザーに非推奨の通知を表示しながら、一方から他方に変換するのは簡単です。

考慮すべきことの1つは、降順の場合の複雑な式の自動変換です( param-param )。 たとえば、昇順が.asc(nulls_last=True)の場合、これの逆数は.desc(nulls_first=True)にする必要がありますか、それとも並べ替えの方向に関係なくnullを最後に残す必要がありますか?

全てのコメント14件

こんにちは@earshinov。 報告ありがとうございます。 とても興味深い。 考えさせてください。 それまでに返信がない場合は、3月にpingを送信してください。 🙂

別のアイデア:さらに一歩進んで、フィルターが1つではなく、複数の式またはフィールド名を指定できるようにすることができます。

前(オプション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番目の名前でデータを並べ替えますが、オプション2は記述が簡単で、DBクエリに関してはおそらくより効率的です。

こんにちは@carltongibson

あなたは私にマッチであなたにpingするように頼んだ。 もう5月です:)

式(1つまたは複数)による順序付けに関する私の提案に戻ると、それはパズルの欠けている部分だと本当に思います。

4つの基本的なテーブル操作が表示されます。
a。 シリアル化
b。 〜pagination〜(この議論とは無関係)
c。 フィルタリング
d。 並べ替え

DRFとdjango-filterを使用すると、並べ替えを除いて、関連するモデルをサポートするこれらすべてを実装できます。

a。 シリアル化のために、ネストされたシリアライザーがあります。 また、集約されたフィールドを返す必要がある場合( 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式でフィルタリングするために、カスタムフィルターを実装できます(以下の例を参照)。 集約フィールド( 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})
        )

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およびその他のヘルパーメソッドの実装が含まれています。

良い。 😀

pingをありがとう。

Tbh私はこれについて考える時間がありませんでした。

ここでのあなたの素晴らしい考えのすべてにおいて、私たちが行うかもしれない小さな変更についての提案がありますか? (たぶん、PRで進めるほうが簡単かもしれません)。

ええ、そのような変更を下位互換性のあるものにする方法はすぐにはわかりません。考えてみる必要があります。 すぐにPRを期待しないでください(インターネットが不足している場所では休暇になります)。

それはいいです。 🙂

ここに急いでいることはありません。 仮にあったとしても、よく考えてみます。

@carltongibson 、わかりました。少し考えましたが、 OrderingFilterの新旧の実装を組み合わせるための許容できる方法を見つけることができませんでした。

すべてを1つのクラスにまとめる場合、最大の問題は、古い実装ではフィールドdictがmodel_field - parameter_nameペアを格納するのに対し、新しい実装では反対のparameter_nameであるということです。 model_fieldmodel_fieldの代わりに式を渡すことができるため、値にのみ格納できますが、辞書のキーには格納できません) 。

技術的には、文字列のみが含まれている場合は「古い方法で」辞書を解釈し、そうでない場合は「新しい方法で」辞書を解釈することで、この問題を克服することができます。 この場合、式による順序付けが必要になったときに、ユーザーは辞書エントリを「反転」する必要があります。 また、順序式が削除された場合は、辞書のエントリを「裏返す」ように注意してください...これは私にはひどいユーザーエクスペリエンスのように聞こえます。 また、実装が複雑になります。

ExpressionOrderingFilterのように、新しいOrderingFilterを古いものと一緒に別の名前で導入することは可能だと思いますか?

これは素晴らしいです、まさに私がそれを必要とした瞬間に! ありがとう@earshinov!

どのように私は次のようなことをしますか?

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

注文フィルターを使用しますか?

    o = ExpressionOrderingFilter(

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

    )

うまくいくようです!
残っているのは、複数のフィルターを組み合わせる方法を理解することだけです。 「価格」と「在庫」を1つに。

ach-これに応答し始めましたが、私のコンピューターは機能しなくなりました。

ここで提案された変更は賢明だと思います。 model_field - parameter_nameのペアは、通常、公開されたparams / forms / etcをモデルから導出するため、当時は理にかなっていますが、これが必要な理由はありません。 マッピングを交換することは理にかなっており、より複雑な式を利用できるようになります。

また、非推奨のプロセスがめちゃくちゃ難しいとは思わないので、喜んでそれを投入するのを手伝いたいと思います。 基本的に、 fieldsfield_labelsparamsparam_labelsに移行します。 下位互換性を維持し、古い引数を使用しているユーザーに非推奨の通知を表示しながら、一方から他方に変換するのは簡単です。

考慮すべきことの1つは、降順の場合の複雑な式の自動変換です( param-param )。 たとえば、昇順が.asc(nulls_last=True)の場合、これの逆数は.desc(nulls_first=True)にする必要がありますか、それとも並べ替えの方向に関係なくnullを最後に残す必要がありますか?

OK、スーパー@rpkilby。

私たちがここで前進するのを見てうれしいです。 私の主な関心事は、私たちが行っていることを適切に文書化することです。 ユーザーはすでにdfドキュメントを少し_簡潔に_見つけています🙂— APIを追加できてうれしいですが、それが明確であることを確認する必要があります。

そのようなことは一人の努力である必要はありません。

@ rpkilby@ carltongibson 、私の変更をプロジェクトに統合するのに助けが必要な場合は、しばらく時間がかかると思います。 しかし、私は方向性が必要です。 どこから、どのように始めればよいですか?

こんにちは@earshinov。 ドキュメントのドラフトから始めます。 私たちが話す話は何ですか? そこからコードが変更されます。 ドラフトだけで何か話したいことがあるとしても、PR。

また、テストケースがある場合はテストケース。 最終的な変更に合わせて調整する必要があるかもしれませんが、さまざまなケースを検討しておくと非常に役立ちます(たとえば、 .asc.descに変換し、 nulls_fist / nulls_lastを処理する

このページは役に立ちましたか?
0 / 5 - 0 評価