Django-filter: [OrderingFilter] Pregunta/Solicitud de función: Permitir especificar una expresión ORM de Django arbitraria para ordenar

Creado en 8 ene. 2019  ·  14Comentarios  ·  Fuente: carltongibson/django-filter

¡Hola! En primer lugar, me gustaría agradecerle por este proyecto, que demostró ser extremadamente útil en combinación con Django REST Framework en nuestra aplicación.

Actualmente estoy luchando con el problema de serializar, filtrar y ordenar por campos de modelos relacionados. En este número me gustaría centrarme únicamente en los pedidos.

Permítanme ilustrar mis intenciones con el siguiente 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}'

Luego, comencemos con un DRF ViewSet sin ningún filtrado ni orden configurado:

# 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

Mi objetivo en el alcance de este problema es permitir que el cliente ordene la lista de pedidos solicitada por estos campos:

  1. ordering=[-]created - pedido por fecha de creación del pedido ( [-] indica un - opcional para la descripción del pedido)
  2. ordering=[-]user - pedido por el nombre completo del usuario que realizó el pedido
  3. ordering=[-]total_quantity - orden por la cantidad total de productos en el pedido

El enfoque básico con ordering_fields configurado en ViewSet solo permite ordenar según los campos del modelo, p.1. Afortunadamente, podemos usar un método más avanzado para subclasificar filters.OrderingFilter como se describe en 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

Sin embargo, incluso cuando se usa este método avanzado, django-filter parece requerir un nombre de campo. La única forma de eludir este problema es annotate 'ing el 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

Algunos pueden decir que usar .annotate aquí es una idea terrible porque no se puede mezclar de manera confiable .annotate con .filter como se describe en https://docs.djangoproject.com/en/2.1 /topics/db/aggregation/#order -of-anotar-y-filter-clauses. Sin embargo, está bien siempre y cuando no use la agregación en sus llamadas .annotate , por lo que estamos bien hasta ahora. (_En realidad volveré a la agregación más tarde_)

Otro problema con el ejemplo de código más reciente es que el código es repetitivo y es mejor extraerlo en una clase base que luego podría heredarse convenientemente. Por ahora, he escrito mi propia clase OrderingFilter , que es una alternativa completa a la proporcionada por django-filter. Su código fuente completo se encuentra a continuación. Puede notar que toma mucho de 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 clase, OrdersOrderingFilter se vuelve agradable y 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'),
            ),
        })

Esto trae a colación la pregunta con la que vine aquí:

_¿Qué opinas de brindar la posibilidad de especificar el orden con una expresión ORM de Django en django_filters.rest_framework.OrderingFilter ?_

Pero antes de responder, déjame recordarte que solo hemos resuelto las páginas 1 y 2. Para resolver p.3 vamos a necesitar una expresión de agregación:

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

No podemos usar esta expresión en .annotate , porque los resultados de mezclarla con el filtrado QuserySet serán impredecibles. Tampoco podemos usar esta expresión directamente en una llamada a 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))

Por lo tanto, p.3 está fuera de alcance, lo que requiere una solución que atraviese todo, comenzando con ViewSet. Tiene más que ver con Django REST Framework que con django-filters. ¿Realmente necesitamos compatibilidad con expresiones en django_filters.rest_framework.OrderingFilter sin compatibilidad con expresiones de agregación? Esto solo podría confundir a los usuarios de django-filter sin traer mucho beneficio.

Estoy deseando escuchar tu opinión. Sé que es mucha información para digerir. Con suerte, mi proyecto de prueba podría ayudar: https://github.com/earshinov/django_sample/tree/master/django_sample/ordering_by_expression

ImprovemenFeature

Comentario más útil

ach: comenzó a responder a esto, pero mi computadora dejó de funcionar.

Creo que los cambios propuestos aquí son sensatos. El par model_field - parameter_name tenía sentido en ese momento, ya que generalmente derivamos los parámetros/formularios/etc. expuestos del modelo, pero no hay ninguna razón por la que esto sea necesario. Intercambiar el mapeo tendría sentido y nos permitiría aprovechar expresiones más complejas.

Además, no creo que el proceso de desaprobación sea increíblemente difícil, y estaría feliz de ayudar a participar. Básicamente, fields y field_labels harían la transición a params y param_labels . Es bastante fácil convertir de uno a otro, al tiempo que conserva la compatibilidad con versiones anteriores y genera un aviso de desaprobación para los usuarios que usan los argumentos antiguos.

Una cosa a considerar es la conversión automática de expresiones complejas para el caso descendente ( param vs -param ). por ejemplo, si el ascendente es .asc(nulls_last=True) ., ¿debe ser el inverso de esto .desc(nulls_first=True) , o los valores nulos deben permanecer en último lugar independientemente de la dirección de clasificación?

Todos 14 comentarios

Hola @earshinov. Gracias por el informe. Muy interesante. Déjame pensarlo. Hazme un ping en marzo si no he respondido para entonces. 🙂

Otra idea: es posible ir un paso más allá y permitir que un filtro especifique no una, sino varias expresiones o nombres de campo.

Antes (opción 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'),
            ),
        })

Después (opción 2):

class OrderOrderingFilter(ddf.OrderingFilter):

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

En ambos casos ordering=user ordena los datos primero por el nombre del usuario, luego por el segundo nombre del usuario, pero la opción 2 es más fácil de escribir y quizás más eficiente cuando se trata de consultas de bases de datos.

Hola @carltongibson ,

Me pediste que te hiciera ping en Match. Ya es mayo :)

Volviendo a mi propuesta de ordenar por expresiones (una o varias), realmente creo que es una pieza que falta en el rompecabezas.

Veo cuatro operaciones básicas de tabla:
un. publicación por entregas
B. ~paginación~ (irrelevante para esta discusión)
C. filtración
D. clasificación

Usando DRF y django-filter, es posible implementar todo esto con soporte para modelos relacionados, excepto para ordenar :

un. Para la serialización, tenemos serializadores anidados . Además, si necesitamos devolver un campo agregado (piense en count ), podemos .annotate() el conjunto de consulta:

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 con expresiones de Django, se puede implementar un filtro personalizado (vea un ejemplo a continuación). Es posible filtrar por un campo agregado ( submodel_count ) usando los filtros escalares ordinarios ( 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. No hay solución para ordenar :-(

Aquí está la implementación completa de nuestro OrderingFilter personalizado, que hemos estado usando hasta ahora:

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

_Actualización_: Incluida la implementación de normalize_fields y otros métodos auxiliares.

Bueno. 😀

Gracias por el ping.

Tbh no he tenido un momento para pensar en esto.

En todo su gran pensamiento aquí, ¿tiene alguna sugerencia para un pequeño cambio que podríamos hacer? (Tal vez es más fácil seguir adelante con un PR).

Sí, no es inmediatamente obvio cómo hacer que dichos cambios sean compatibles con versiones anteriores, tendré que pensarlo. No esperes un PR pronto (estará de vacaciones en lugares donde Internet escasea).

Esta bien. 🙂

Aquí no hay prisa. Será mejor que lo pensemos, si es que lo hacemos.

@carltongibson , bien, contemplamos un poco, pero no pudimos encontrar una forma aceptable de combinar la implementación antigua y la nueva de OrderingFilter .

Si vamos a poner todo en una sola clase, el mayor problema es que en la implementación anterior, los campos dict almacenan pares model_field - parameter_name , mientras que en la nueva implementación es al contrario parameter_name - model_field (debe serlo, porque en lugar de model_field se puede pasar una expresión, que solo se puede almacenar en un valor, pero no en una clave en un diccionario) .

Técnicamente, es posible superar este problema interpretando el diccionario "a la antigua" si solo contiene cadenas, y "a la nueva" en caso contrario. En este caso, el usuario tendrá que "voltear" las entradas del diccionario cuando surja la necesidad de ordenarlas por expresión. Y tenga cuidado de "voltear" las entradas del diccionario, en caso de que se elimine la expresión de pedido... Esto me parece una experiencia de usuario terrible. También hará que la implementación sea complicada.

¿Cree que es posible introducir el nuevo OrderingFilter junto con el anterior con un nombre diferente, como ExpressionOrderingFilter ?

¡Esto es genial, exactamente en el momento en que lo necesitaba! ¡Gracias @earshinov!

¿Cómo haría algo como:

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

¿Con su filtro de pedidos?

    o = ExpressionOrderingFilter(

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

    )

parece funcionar muy bien!
Ahora lo único que queda es descubrir cómo combinar múltiples filtros, ak. 'precio' y 'acción' en uno.

ach: comenzó a responder a esto, pero mi computadora dejó de funcionar.

Creo que los cambios propuestos aquí son sensatos. El par model_field - parameter_name tenía sentido en ese momento, ya que generalmente derivamos los parámetros/formularios/etc. expuestos del modelo, pero no hay ninguna razón por la que esto sea necesario. Intercambiar el mapeo tendría sentido y nos permitiría aprovechar expresiones más complejas.

Además, no creo que el proceso de desaprobación sea increíblemente difícil, y estaría feliz de ayudar a participar. Básicamente, fields y field_labels harían la transición a params y param_labels . Es bastante fácil convertir de uno a otro, al tiempo que conserva la compatibilidad con versiones anteriores y genera un aviso de desaprobación para los usuarios que usan los argumentos antiguos.

Una cosa a considerar es la conversión automática de expresiones complejas para el caso descendente ( param vs -param ). por ejemplo, si el ascendente es .asc(nulls_last=True) ., ¿debe ser el inverso de esto .desc(nulls_first=True) , o los valores nulos deben permanecer en último lugar independientemente de la dirección de clasificación?

Bien, super @rpkilby.

Feliz de vernos avanzar aquí. Mi principal preocupación es que documentemos adecuadamente lo que estamos haciendo. Los usuarios ya encuentran los documentos df un poco _concisos_ digamos 🙂 — Feliz de agregar la API, pero debemos asegurarnos de que esté claro.

Tal cosa no tiene que ser un esfuerzo de una sola persona.

@rpkilby , @carltongibson , si necesita mi ayuda para integrar mis cambios en el proyecto, creo que puedo dedicarle algo de tiempo. Pero necesito direcciones. ¿Dónde y cómo empiezo?

Hola @earshinov. Empezaría por redactar los documentos. ¿Cuál es la historia que contamos? A partir de ahí el código cambia. Un PR, aunque solo sea un borrador, da de qué hablar.

Además, pruebe casos si los tiene. Es posible que deban ajustarse para que coincidan con los cambios finales, pero sería muy útil tener los distintos casos pensados ​​(por ejemplo, convertir .asc a .desc , manejar nulls_fist / nulls_last argumentos, etc.).

¿Fue útil esta página
0 / 5 - 0 calificaciones