Django-filter: [OrderingFilter] Pertanyaan / Permintaan fitur: Izinkan untuk menentukan ekspresi ORM Django sewenang-wenang untuk pemesanan

Dibuat pada 8 Jan 2019  ·  14Komentar  ·  Sumber: carltongibson/django-filter

Halo! Pertama-tama, saya ingin berterima kasih atas proyek ini, yang terbukti sangat berguna dalam kombinasi dengan Django REST Framework dalam aplikasi kita.

Saat ini saya berjuang dengan masalah serialisasi, pemfilteran, dan pemesanan berdasarkan bidang model terkait. Dalam masalah ini saya hanya ingin fokus pada pemesanan.

Izinkan saya mengilustrasikan niat saya dengan model berikut:

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

Kemudian, mari kita mulai dengan DRF ViewSet tanpa pemfilteran dan pengurutan yang dikonfigurasi:

# 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

Tujuan saya dalam lingkup masalah ini adalah untuk memungkinkan klien memesan daftar pesanan yang diminta dengan bidang ini:

  1. ordering=[-]created - memesan berdasarkan tanggal pembuatan pesanan ( [-] menunjukkan - opsional untuk memesan desc.)
  2. ordering=[-]user - pesan dengan nama lengkap pengguna yang memesan
  3. ordering=[-]total_quantity - pesan dengan jumlah total produk dalam pesanan

Pendekatan dasar dengan ordering_fields dikonfigurasi pada ViewSet hanya memungkinkan untuk memesan berdasarkan bidang model, hal.1. Untungnya, kita dapat menggunakan metode yang lebih maju dari subkelas filters.OrderingFilter seperti yang dijelaskan di 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

Namun, bahkan ketika menggunakan metode lanjutan ini, filter-django tampaknya memerlukan nama bidang. Satu-satunya cara untuk menghindari masalah ini adalah dengan annotate 'ing QuerySet yang difilter:

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

Beberapa orang dapat mengatakan bahwa menggunakan .annotate di sini adalah ide yang buruk karena seseorang tidak dapat dengan andal mencampur .annotate dengan .filter seperti yang dijelaskan dalam https://docs.djangoproject.com/en/2.1 /topics/db/aggregation/#order -of-annotate-and-filter-clauses. Namun, Anda baik-baik saja selama Anda tidak menggunakan agregasi dalam panggilan .annotate Anda, jadi sejauh ini kami baik-baik saja. (_Saya sebenarnya akan kembali ke agregasi nanti_)

Masalah lain dengan contoh kode terbaru adalah bahwa kode tersebut berulang dan lebih baik diekstraksi ke kelas dasar yang kemudian dapat dengan mudah diwarisi. Untuk saat ini, saya telah menulis kelas OrderingFilter saya sendiri, yang merupakan alternatif lengkap dari yang disediakan oleh Django-filter. Kode sumber lengkapnya ada di bawah ini. Anda mungkin memperhatikan bahwa ia meminjam banyak dari 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

Menggunakan kelas ini, OrdersOrderingFilter menjadi bagus dan ringkas:

# ... 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'),
            ),
        })

Ini memunculkan pertanyaan yang saya datangi ke sini:

_Apa pendapat Anda tentang menyediakan kemampuan untuk menentukan pemesanan dengan ekspresi Django ORM di django_filters.rest_framework.OrderingFilter ?_

Tetapi sebelum Anda menjawab, izinkan saya mengingatkan Anda bahwa kita hanya memiliki pp. 1 dan 2 yang diselesaikan. Untuk menyelesaikan p.3 kita akan membutuhkan ekspresi agregasi:

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

Kami tidak dapat menggunakan ekspresi ini di .annotate , karena hasil pencampurannya dengan pemfilteran QuserySet tidak dapat diprediksi. Kita juga tidak dapat menggunakan ekspresi ini secara langsung dalam panggilan ke Query.order(...) , karena

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

Jadi p.3 berada di luar jangkauan, membutuhkan solusi yang akan menembus semuanya dimulai dengan ViewSet. Ini lebih berkaitan dengan Django REST Framework daripada Django-filters. Apakah kita benar-benar membutuhkan dukungan ekspresi di django_filters.rest_framework.OrderingFilter tanpa dukungan ekspresi agregasi? Ini mungkin hanya membingungkan pengguna Django-filter tanpa membawa banyak manfaat.

Saya menantikan untuk mendengar pendapat Anda. Saya tahu itu banyak informasi untuk dicerna. Semoga, proyek pengujian saya dapat membantu: https://github.com/earshinov/django_sample/tree/master/django_sample/ordering_by_expression

ImprovemenFeature

Komentar yang paling membantu

ach - mulai merespons ini tetapi komputer saya rusak.

Saya pikir perubahan yang diusulkan di sini masuk akal. Pasangan model_field - parameter_name masuk akal pada saat itu karena kami biasanya menurunkan params/forms/etc yang terbuka dari model, tetapi tidak ada alasan mengapa ini diperlukan. Bertukar pemetaan akan masuk akal, dan memungkinkan kita untuk mengambil keuntungan dari ekspresi yang lebih kompleks.

Juga, saya tidak berpikir proses penghentian akan sangat sulit, dan saya akan dengan senang hati membantu melakukannya. Pada dasarnya, fields dan field_labels akan bertransisi ke params dan param_labels . Cukup mudah untuk mengonversi dari satu ke yang lain, sambil mempertahankan kompatibilitas mundur, dan meningkatkan pemberitahuan penghentian untuk pengguna yang menggunakan argumen lama.

Satu hal yang perlu dipertimbangkan adalah konversi otomatis ekspresi kompleks untuk kasus menurun ( param vs -param ). misalnya, jika menaik adalah .asc(nulls_last=True) ., haruskah kebalikan dari ini .desc(nulls_first=True) , atau haruskah null tetap terakhir terlepas dari arah pengurutan?

Semua 14 komentar

HI @earshinov. Terima kasih atas laporannya. Sangat menarik. Biarkan aku memikirkannya. Ping saya di bulan Maret jika saya belum menjawab saat itu. 🙂.

Gagasan lain: dimungkinkan untuk melangkah lebih jauh dan mengizinkan filter untuk menentukan bukan hanya satu, tetapi beberapa ekspresi atau nama bidang.

Sebelum (opsi 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'),
            ),
        })

Setelah (opsi 2):

class OrderOrderingFilter(ddf.OrderingFilter):

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

Dalam kedua kasus ordering=user memesan data dengan nama depan pengguna terlebih dahulu, nama kedua pengguna kedua, tetapi opsi 2 lebih mudah untuk ditulis, dan mungkin lebih efisien dalam hal kueri DB.

Halo @carltongibson ,

Anda meminta saya untuk melakukan ping ke Anda di Match. Sudah Mei :)

Kembali ke proposal saya tentang memesan dengan ekspresi (satu atau beberapa), saya benar-benar berpikir itu adalah bagian teka-teki yang hilang.

Saya melihat empat operasi tabel dasar:
Sebuah. serialisasi
B. ~pagination~ (tidak relevan dengan diskusi ini)
C. penyaringan
D. penyortiran

Menggunakan DRF dan Django-filter dimungkinkan untuk mengimplementasikan semua ini dengan dukungan untuk model terkait, kecuali untuk menyortir :

Sebuah. Untuk serialisasi, kami memiliki serializer bersarang . Juga, jika kita perlu mengembalikan bidang agregat (pikirkan count ), kita dapat .annotate() queryset:

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. Untuk memfilter dengan ekspresi Django, seseorang dapat mengimplementasikan filter kustom (lihat contoh di bawah). Pemfilteran menurut bidang gabungan ( submodel_count ) dimungkinkan menggunakan filter skalar biasa ( 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. Tidak ada solusi untuk menyortir :-(

Berikut adalah implementasi lengkap dari OrderingFilter kustom kami, yang telah kami gunakan sejauh ini:

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_: Termasuk implementasi normalize_fields dan metode pembantu lainnya.

Bagus. 😀.

Terima kasih untuk pingnya.

Tbh Saya belum sempat memikirkan hal ini.

Dalam semua pemikiran besar Anda di sini, apakah Anda memiliki saran untuk perubahan kecil yang mungkin kami lakukan? (Mungkin lebih mudah untuk maju dengan PR).

Ya, tidak segera jelas bagaimana membuat perubahan seperti itu kompatibel ke belakang, saya harus memikirkannya. Jangan berharap PR segera (akan berlibur di tempat-tempat di mana Internet langka).

Tidak apa-apa. 🙂.

Tidak ada terburu-buru di sini. Lebih baik kita pikirkan matang-matang, jika memang ada.

@carltongibson , Oke, kami merenungkan sedikit, tetapi tidak dapat menemukan cara yang dapat diterima untuk menggabungkan implementasi lama dan baru dari OrderingFilter .

Jika kita akan memasukkan semuanya ke dalam satu kelas, masalah terbesarnya adalah pada implementasi lama dict menyimpan pasangan model_field - parameter_name , sedangkan pada implementasi baru kebalikannya parameter_name - model_field (ini perlu, karena alih-alih model_field ekspresi dapat diteruskan, yang hanya dapat disimpan dalam nilai, tetapi tidak dalam kunci dalam kamus) .

Secara teknis, dimungkinkan untuk mengatasi masalah ini dengan menafsirkan kamus "dengan cara lama" jika hanya berisi string, dan "dengan cara baru" sebaliknya. Dalam hal ini pengguna harus "membalik" entri kamus ketika kebutuhan dalam pengurutan berdasarkan ekspresi muncul. Dan berhati-hatilah untuk "membalik" entri kamus kembali, jika ekspresi pengurutan dihapus... Ini terdengar seperti pengalaman pengguna yang buruk bagi saya. Ini juga akan membuat implementasinya berbelit-belit.

Apakah menurut Anda mungkin untuk memperkenalkan OrderingFilter baru bersama dengan yang lama dengan nama yang berbeda, seperti ExpressionOrderingFilter ?

Ini bagus, tepat pada saat saya membutuhkannya! Terima kasih @earshinov!

Bagaimana saya akan melakukan sesuatu seperti:

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

Dengan filter pemesanan Anda?

    o = ExpressionOrderingFilter(

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

    )

tampaknya bekerja dengan baik!
Sekarang satu-satunya yang tersisa adalah mencari tahu bagaimana menggabungkan banyak filter, ak. 'harga' dan 'saham' menjadi satu.

ach - mulai merespons ini tetapi komputer saya rusak.

Saya pikir perubahan yang diusulkan di sini masuk akal. Pasangan model_field - parameter_name masuk akal pada saat itu karena kami biasanya menurunkan params/forms/etc yang terbuka dari model, tetapi tidak ada alasan mengapa ini diperlukan. Bertukar pemetaan akan masuk akal, dan memungkinkan kita untuk mengambil keuntungan dari ekspresi yang lebih kompleks.

Juga, saya tidak berpikir proses penghentian akan sangat sulit, dan saya akan dengan senang hati membantu melakukannya. Pada dasarnya, fields dan field_labels akan bertransisi ke params dan param_labels . Cukup mudah untuk mengonversi dari satu ke yang lain, sambil mempertahankan kompatibilitas mundur, dan meningkatkan pemberitahuan penghentian untuk pengguna yang menggunakan argumen lama.

Satu hal yang perlu dipertimbangkan adalah konversi otomatis ekspresi kompleks untuk kasus menurun ( param vs -param ). misalnya, jika menaik adalah .asc(nulls_last=True) ., haruskah kebalikan dari ini .desc(nulls_first=True) , atau haruskah null tetap terakhir terlepas dari arah pengurutan?

Oke, super @rpkilby.

Senang melihat kami bergerak maju di sini. Perhatian utama saya adalah bahwa kami mendokumentasikan dengan benar apa yang kami lakukan. Pengguna sudah menemukan df docs sedikit _terse_ haruskah kita katakan — Senang menambahkan API tetapi kita perlu memastikannya jelas.

Hal seperti itu tidak harus menjadi upaya satu orang.

@rpkilby , @carltongibson , Jika Anda memerlukan bantuan saya dalam mengintegrasikan perubahan saya ke dalam proyek, saya pikir saya dapat meluangkan waktu. Tapi aku butuh petunjuk. Di mana dan bagaimana saya memulai?

Hai @earshinov. Saya akan mulai dengan menyusun dokumen. Apa cerita yang kita ceritakan? Dari sana kode berubah. Sebuah PR, bahkan jika hanya sebuah konsep memberi kita sesuatu untuk dibicarakan.

Juga, uji kasus jika Anda memilikinya. Mereka mungkin perlu disesuaikan agar sesuai dengan perubahan terakhir, tetapi memikirkan berbagai kasus akan sangat membantu (misalnya, mengonversi .asc menjadi .desc , menangani nulls_fist / nulls_last argumen, dll.).

Apakah halaman ini membantu?
0 / 5 - 0 peringkat