Django-filter: [OrderingFilter] Pergunta / Solicitação de recurso: permite especificar uma expressão arbitrária do Django ORM para ordenação

Criado em 8 jan. 2019  ·  14Comentários  ·  Fonte: carltongibson/django-filter

Olá! Em primeiro lugar, gostaria de agradecer por este projeto, que provou ser extremamente útil em combinação com o Django REST Framework em nossa aplicação.

Atualmente estou lutando com um problema de serialização, filtragem e ordenação por campos de modelos relacionados. Nesta edição, gostaria de me concentrar apenas no pedido.

Deixe-me ilustrar minhas intenções com o seguinte modelo:

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

Então, vamos começar com um DRF ViewSet sem nenhuma filtragem e ordenação configurada:

# 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

Meu objetivo no escopo desta edição é permitir que o cliente solicite a lista de pedidos solicitada por estes campos:

  1. ordering=[-]created - pedido por data de criação do pedido ( [-] indica um opcional - para a descrição do pedido)
  2. ordering=[-]user - pedido pelo nome completo do usuário que fez o pedido
  3. ordering=[-]total_quantity - ordem pela quantidade total de produtos no pedido

A abordagem básica com ordering_fields configurada no ViewSet permite apenas ordenar com base nos campos do modelo, p.1. Felizmente, podemos usar um método mais avançado de subclassificar o filters.OrderingFilter , conforme descrito em 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

No entanto, mesmo usando esse método avançado, o django-filter parece exigir um nome de campo. A única maneira de contornar esse problema é annotate 'ing o QuerySet filtrado:

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

Alguns podem dizer que usar .annotate aqui é uma péssima ideia porque não se pode misturar de forma confiável .annotate com .filter conforme descrito em https://docs.djangoproject.com/en/2.1 /topics/db/aggregation/#order -of-annotate-and-filter-clauses. No entanto, você está bem desde que não use agregação em suas chamadas .annotate , então estamos bem até agora. (_Retornarei à agregação mais tarde_)

Outro problema com a amostra de código mais recente é que o código é repetitivo e é melhor ser extraído em uma classe base que pode ser herdada convenientemente. Por enquanto, escrevi minha própria classe OrderingFilter , que é uma alternativa completa à fornecida pelo django-filter. Seu código-fonte completo está abaixo. Você pode notar que ele empresta muito do 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

Usando esta classe, OrdersOrderingFilter se torna agradável e conciso:

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

Isso traz a pergunta que eu vim aqui com:

_O que você acha de fornecer a capacidade de especificar a ordenação com uma expressão Django ORM em django_filters.rest_framework.OrderingFilter ?_

Mas antes de responder, deixe-me lembrá-lo de que temos apenas as pp. 1 e 2 resolvidas. Para resolver p.3 vamos precisar de uma expressão de agregação:

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

Não podemos usar essa expressão em .annotate , porque os resultados de misturá-la com a filtragem QuserySet serão imprevisíveis. Também não podemos usar essa expressão diretamente em uma chamada para Query.order(...) , porque

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

Então p.3 está fora de alcance, exigindo uma solução que irá perfurar tudo começando com o ViewSet. Tem mais a ver com Django REST Framework do que django-filters. Na verdade, precisamos de suporte a expressões em django_filters.rest_framework.OrderingFilter sem suporte a expressões de agregação? Isso pode apenas confundir os usuários do django-filter sem trazer muitos benefícios.

Estou ansioso para ouvir sua opinião. Eu sei que é muita informação para digerir. Espero que meu projeto de teste possa ajudar: https://github.com/earshinov/django_sample/tree/master/django_sample/ordering_by_expression

ImprovemenFeature

Comentários muito úteis

ach - começou a responder a isso, mas meu computador foi kaput.

Acho que as mudanças propostas aqui são sensatas. O par model_field - parameter_name fazia sentido na época, pois normalmente derivamos os parâmetros/forms/etc expostos do modelo, mas não há razão para isso ser necessário. Trocar o mapeamento faria sentido e nos permitiria tirar proveito de expressões mais complexas.

Além disso, não acho que o processo de descontinuação seja insanamente difícil e ficaria feliz em ajudá-lo. Basicamente, fields e field_labels fariam a transição para params e param_labels . É bastante fácil converter de um para o outro, mantendo a compatibilidade com versões anteriores e gerando um aviso de descontinuação para usuários que usam os argumentos antigos.

Uma coisa a considerar é a conversão automática de expressões complexas para o caso descendente ( param vs -param ). por exemplo, se o ascendente for .asc(nulls_last=True) ., o inverso disso deve ser .desc(nulls_first=True) , ou os nulos devem permanecer por último, independentemente da direção da classificação?

Todos 14 comentários

Olá @earshinov. Obrigado pelo relatório. Muito interessante. Deixe-me pensar sobre isso. Faça um ping em março se eu não tiver respondido até lá. 🙂

Outra ideia: é possível dar um passo adiante e permitir que um filtro especifique não uma, mas várias expressões ou nomes de campos.

Antes (opção 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'),
            ),
        })

Depois (opção 2):

class OrderOrderingFilter(ddf.OrderingFilter):

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

Em ambos os casos ordering=user ordena os dados pelo primeiro nome do usuário, segundo o segundo nome do usuário, mas a opção 2 é mais fácil de escrever e talvez mais eficiente quando se trata de consultas de banco de dados.

Olá @carltongibson ,

Você me pediu para fazer um ping no Match. Já é maio :)

Voltando à minha proposta de ordenação por expressões (uma ou várias), realmente acho que falta uma parte do quebra-cabeça.

Vejo quatro operações básicas de tabela:
uma. serialização
b. ~paginação~ (irrelevante para esta discussão)
c. filtragem
d. Ordenação

Usando DRF e django-filter é possível implementar tudo isso com suporte para modelos relacionados, exceto para ordenação :

uma. Para serialização, temos serializadores aninhados . Além disso, se precisarmos retornar um campo agregado (pense em count ), podemos .annotate() o conjunto de consultas:

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. Para filtrar com expressões Django, pode-se implementar um filtro personalizado (veja um exemplo abaixo). A filtragem por um campo agregado ( submodel_count ) é possível usando os filtros escalares comuns ( 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. Não há solução para classificação :-(

Aqui está a implementação completa do nosso OrderingFilter personalizado, que temos usado até agora:

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_: Incluída a implementação de normalize_fields e outros métodos auxiliares.

Boa. 😀

Obrigado pelo ping.

Tbh eu não tive um momento para pensar sobre isso.

Em todo o seu grande pensamento aqui, você tem uma sugestão para uma pequena mudança que podemos fazer? (Talvez seja mais fácil avançar com um PR).

Sim, não é imediatamente óbvio como tornar essas alterações compatíveis com versões anteriores, terei que pensar. Não espere um PR em breve (estará de férias em locais onde a Internet é escassa).

Isso é bom. 🙂

Não há pressa aqui. É melhor pensarmos bem, se for o caso.

@carltongibson , Ok, contemplamos um pouco, mas não conseguimos encontrar uma maneira aceitável de combinar a implementação antiga e nova de OrderingFilter .

Se vamos colocar tudo em uma classe, o maior problema é que na implementação antiga os campos dict armazenam pares model_field - parameter_name , enquanto na nova implementação é o oposto parameter_name - model_field (precisa ser, pois ao invés de model_field pode ser passada uma expressão, que só pode ser armazenada em um valor, mas não em uma chave de um dicionário) .

Tecnicamente, é possível superar esse problema interpretando o dicionário "à maneira antiga" se ele contiver apenas strings, e "à nova maneira" caso contrário. Neste caso o usuário terá que "inverter" as entradas do dicionário quando surgir a necessidade de ordenar por expressão. E tome cuidado para "virar" as entradas do dicionário de volta, caso a expressão de ordenação seja removida... Isso me parece uma experiência de usuário terrível. Isso também tornará a implementação complicada.

Você acha que é possível introduzir o novo OrderingFilter junto com o antigo com um nome diferente, como ExpressionOrderingFilter ?

Isso é ótimo, exatamente no momento que eu precisava! Obrigado @earshinov!

Como eu faria algo como:

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

Com o seu filtro de pedidos?

    o = ExpressionOrderingFilter(

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

    )

parece funcionar muito bem!
Agora a única coisa que resta é descobrir como combinar vários filtros, ak. 'preço' e 'estoque' em um.

ach - começou a responder a isso, mas meu computador foi kaput.

Acho que as mudanças propostas aqui são sensatas. O par model_field - parameter_name fazia sentido na época, pois normalmente derivamos os parâmetros/forms/etc expostos do modelo, mas não há razão para isso ser necessário. Trocar o mapeamento faria sentido e nos permitiria tirar proveito de expressões mais complexas.

Além disso, não acho que o processo de descontinuação seja insanamente difícil e ficaria feliz em ajudá-lo. Basicamente, fields e field_labels fariam a transição para params e param_labels . É bastante fácil converter de um para o outro, mantendo a compatibilidade com versões anteriores e gerando um aviso de descontinuação para usuários que usam os argumentos antigos.

Uma coisa a considerar é a conversão automática de expressões complexas para o caso descendente ( param vs -param ). por exemplo, se o ascendente for .asc(nulls_last=True) ., o inverso disso deve ser .desc(nulls_first=True) , ou os nulos devem permanecer por último, independentemente da direção da classificação?

OK, super @rpkilby.

Feliz por nos ver avançando aqui. Minha principal preocupação é documentarmos adequadamente o que estamos fazendo. Os usuários já acham os documentos df um pouco _concisos_ digamos 🙂 — Feliz em adicionar a API, mas precisamos ter certeza de que está claro.

Tal coisa não tem que ser um esforço de uma pessoa.

@rpkilby , @carltongibson , Se você precisar de minha ajuda para integrar minhas alterações no projeto, acho que posso dedicar algum tempo. Mas eu preciso de direções. Onde e como eu começo?

Olá @earshinov. Eu começaria redigindo os documentos. Qual é a história que contamos? A partir daí o código muda. Um PR, mesmo que apenas um rascunho, nos dê algo para falar.

Além disso, casos de teste, se você os tiver. Eles podem precisar ser ajustados para corresponder às alterações finais, mas ter os vários casos pensados ​​seria muito útil (por exemplo, converter .asc em .desc , manipular nulls_fist / nulls_last argumentos, etc.).

Esta página foi útil?
0 / 5 - 0 avaliações