Django-filter: [OrderingFilter] Question / Demande de fonctionnalité : Autoriser la spécification d'une expression ORM Django arbitraire pour la commande

Créé le 8 janv. 2019  ·  14Commentaires  ·  Source: carltongibson/django-filter

Bonjour! Tout d'abord, je voudrais vous remercier pour ce projet, qui s'est avéré extrêmement utile en combinaison avec Django REST Framework dans notre application.

Actuellement, je suis aux prises avec un problème de sérialisation, de filtrage et de classement par champs de modèles associés. Dans ce numéro, je voudrais me concentrer uniquement sur la commande.

Permettez-moi d'illustrer mes intentions avec le modèle suivant :

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

Ensuite, commençons avec un ViewSet DRF sans aucun filtrage ni tri configuré :

# 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

Mon objectif dans le cadre de ce problème est de permettre au client de commander la liste de commandes demandée par ces champs :

  1. ordering=[-]created - trier par date de création de la commande ( [-] indique un - facultatif pour la description de la commande)
  2. ordering=[-]user - commande par le nom complet de l'utilisateur qui a passé la commande
  3. ordering=[-]total_quantity - commander par la quantité totale de produits dans la commande

L'approche de base avec ordering_fields configuré sur le ViewSet permet uniquement de commander en fonction des champs du modèle, p.1. Heureusement, nous pouvons utiliser une méthode plus avancée de sous-classement des filters.OrderingFilter comme bien décrit dans 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

Cependant, même en utilisant cette méthode avancée, django-filter semble nécessiter un nom de champ. La seule façon de contourner ce problème est de annotate 'ing le QuerySet filtré :

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

Certains peuvent dire que l'utilisation .annotate ici est une idée terrible car on ne peut pas mélanger de manière fiable .annotate avec .filter comme décrit dans https://docs.djangoproject.com/en/2.1 /topics/db/aggregation/#order -of-annotate-and-filter-clauses. Cependant, tout va bien tant que vous n'utilisez pas l'agrégation dans vos appels .annotate , donc tout va bien jusqu'à présent. (_Je reviendrai en fait à l'agrégation plus tard_)

Un autre problème avec le dernier exemple de code est que le code est répétitif et qu'il vaut mieux l'extraire dans une classe de base qui pourrait ensuite être facilement héritée. Pour l'instant, j'ai écrit ma propre classe OrderingFilter , qui est une alternative complète à celle fournie par django-filter. Son code source complet est ci-dessous. Vous remarquerez peut-être qu'il emprunte beaucoup à 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

En utilisant cette classe, OrdersOrderingFilter devient agréable et concis :

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

Cela soulève la question avec laquelle je suis venu ici:

_Que pensez-vous de fournir la possibilité de spécifier l'ordre avec une expression ORM Django dans django_filters.rest_framework.OrderingFilter ?_

Mais avant que vous ne répondiez, permettez-moi de vous rappeler que nous n'avons résolu que les pages 1 et 2. Pour résoudre p.3 nous allons avoir besoin d'une expression d'agrégation :

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

Nous ne pouvons pas utiliser cette expression dans .annotate , car les résultats de son mélange avec le filtrage QuserySet seront imprévisibles. Nous ne pouvons pas non plus utiliser cette expression directement dans un appel à Query.order(...) , car

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

La p.3 est donc hors de portée, nécessitant une solution qui percera tout à commencer par le ViewSet. Cela a plus à voir avec Django REST Framework qu'avec django-filters. Avons-nous réellement besoin d'un support d'expression dans django_filters.rest_framework.OrderingFilter sans support d'expression d'agrégation ? Cela ne peut que confondre les utilisateurs de django-filter sans apporter beaucoup d'avantages.

J'ai hâte d'entendre votre avis. Je sais que c'est beaucoup d'informations à digérer. J'espère que mon projet de test pourra vous aider : https://github.com/earshinov/django_sample/tree/master/django_sample/ordering_by_expression

ImprovemenFeature

Commentaire le plus utile

ach - a commencé à répondre à cela, mais mon ordinateur est devenu kaput.

Je pense que les modifications proposées ici sont sensées. La paire model_field - parameter_name avait du sens à l'époque puisque nous dérivons généralement les paramètres/formulaires/etc exposés du modèle, mais il n'y a aucune raison pour que cela soit nécessaire. Échanger le mappage aurait du sens et nous permettrait de tirer parti d'expressions plus complexes.

De plus, je ne pense pas que le processus de dépréciation serait incroyablement difficile, et je serais heureux de vous aider à y participer. Fondamentalement, fields et field_labels passeraient à params et param_labels . Il est assez facile de passer de l'un à l'autre, tout en conservant la rétrocompatibilité et en levant un avis de dépréciation pour les utilisateurs utilisant les anciens arguments.

Une chose à considérer est la conversion automatique des expressions complexes pour le cas décroissant ( param vs -param ). par exemple, si l'ascendant est .asc(nulls_last=True) ., l'inverse de ceci devrait-il être .desc(nulls_first=True) , ou les valeurs nulles devraient-elles rester en dernier quel que soit le sens du tri ?

Tous les 14 commentaires

Salut @earshinov. Merci pour le rapport. Très intéressant. Laissez-moi y réfléchir. Ping moi en mars si je n'ai pas répondu d'ici là. 🙂

Autre idée : il est possible d'aller plus loin et de permettre à un filtre de spécifier non pas une, mais plusieurs expressions ou noms de champs.

Avant (option 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'),
            ),
        })

Après (option 2) :

class OrderOrderingFilter(ddf.OrderingFilter):

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

Dans les deux cas, ordering=user ordonne les données par le prénom de l'utilisateur en premier, le deuxième nom de l'utilisateur ensuite, mais l'option 2 est plus facile à écrire et peut-être plus efficace lorsqu'il s'agit de requêtes DB.

Bonjour @carltongibson ,

Vous m'avez demandé de vous envoyer un ping dans Match. C'est déjà mai :)

Revenant à ma proposition sur le classement par expressions (une ou plusieurs), je pense vraiment que c'est une pièce manquante du puzzle.

Je vois quatre opérations de table de base :
une. sérialisation
b. ~pagination~ (sans rapport avec cette discussion)
c. filtration
ré. tri

En utilisant DRF et django-filter, il est possible d'implémenter tout cela avec la prise en charge des modèles associés, à l'exception du tri :

une. Pour la sérialisation, nous avons des sérialiseurs imbriqués . De plus, si nous devons renvoyer un champ agrégé (pensez à count ), nous pouvons .annotate() le 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. Pour filtrer avec des expressions Django, on peut implémenter un filtre personnalisé (voir un exemple ci-dessous). Le filtrage par un champ agrégé ( submodel_count ) est possible en utilisant les filtres scalaires ordinaires ( 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})
        )

ré. Il n'y a pas de solution pour le tri :-(

Voici l'implémentation complète de notre OrderingFilter personnalisé, que nous avons utilisé jusqu'à présent :

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

_Mise à jour_ : inclut l'implémentation de normalize_fields et d'autres méthodes d'assistance.

Bien. 😀

Merci pour le ping.

Tbh je n'ai pas eu un moment pour y penser.

Dans toute votre grande réflexion ici, avez-vous une suggestion pour un petit changement que nous pourrions apporter ? (Peut-être qu'il est simplement plus facile d'aller de l'avant avec un PR).

Ouais, il n'est pas immédiatement évident de faire de tels changements rétrocompatibles, je vais devoir y réfléchir. Ne vous attendez pas à un PR bientôt (sera en vacances dans des endroits où Internet est rare).

C'est très bien. 🙂

Il n'y a pas d'urgence ici. Mieux vaut que nous y réfléchissions, voire pas du tout.

@carltongibson , d'accord, nous avons réfléchi un peu, mais nous n'avons pas trouvé de moyen acceptable de combiner l'ancienne et la nouvelle implémentation de OrderingFilter .

Si nous allons tout mettre dans une classe, le plus gros problème est que dans l'ancienne implémentation, les champs dict stockent les paires model_field - parameter_name , alors que dans la nouvelle implémentation c'est l'inverse parameter_name - model_field (il le faut, car au lieu de model_field une expression peut être passée, qui ne peut être stockée que dans une valeur, mais pas dans une clé dans un dictionnaire) .

Techniquement, il est possible de pallier ce problème en interprétant le dictionnaire "à l'ancienne" s'il ne contient que des chaînes, et "à la nouvelle" dans le cas contraire. Dans ce cas, l'utilisateur devra "inverser" les entrées du dictionnaire lorsqu'un besoin de trier par expression se fait sentir. Et faites attention à "retourner" les entrées du dictionnaire, si l'expression de commande devait être supprimée... Cela me semble être une expérience utilisateur terrible. Cela rendra également la mise en œuvre compliquée.

Pensez-vous qu'il est possible d'introduire le nouveau OrderingFilter avec l'ancien sous un nom différent, comme ExpressionOrderingFilter ?

C'est super, exactement au moment où j'en avais besoin ! Merci @earshinov !

Comment ferais-je quelque chose comme:

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

Avec votre filtre de commande ?

    o = ExpressionOrderingFilter(

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

    )

semble fonctionner très bien!
Maintenant, il ne reste plus qu'à trouver comment combiner plusieurs filtres, nyark. 'prix' et 'stock' en un seul.

ach - a commencé à répondre à cela, mais mon ordinateur est devenu kaput.

Je pense que les modifications proposées ici sont sensées. La paire model_field - parameter_name avait du sens à l'époque puisque nous dérivons généralement les paramètres/formulaires/etc exposés du modèle, mais il n'y a aucune raison pour que cela soit nécessaire. Échanger le mappage aurait du sens et nous permettrait de tirer parti d'expressions plus complexes.

De plus, je ne pense pas que le processus de dépréciation serait incroyablement difficile, et je serais heureux de vous aider à y participer. Fondamentalement, fields et field_labels passeraient à params et param_labels . Il est assez facile de passer de l'un à l'autre, tout en conservant la rétrocompatibilité et en levant un avis de dépréciation pour les utilisateurs utilisant les anciens arguments.

Une chose à considérer est la conversion automatique des expressions complexes pour le cas décroissant ( param vs -param ). par exemple, si l'ascendant est .asc(nulls_last=True) ., l'inverse de ceci devrait-il être .desc(nulls_first=True) , ou les valeurs nulles devraient-elles rester en dernier quel que soit le sens du tri ?

OK, super @rpkilby.

Heureux de nous voir avancer ici. Ma principale préoccupation est que nous documentions correctement ce que nous faisons. Les utilisateurs trouvent déjà les docs df un peu _laconique_ dirons-nous 🙂 — Heureux d'ajouter l'API mais nous devons nous assurer que c'est clair.

Une telle chose ne doit pas être un effort d'une seule personne.

@rpkilby , @carltongibson , Si vous avez besoin de mon aide pour intégrer mes changements dans le projet, je pense que je peux y consacrer du temps. Mais j'ai besoin d'indications. Où et comment commencer ?

Salut @earshinov. Je commencerais par rédiger les docs. Quelle est l'histoire que nous racontons? A partir de là, le code change. Un PR, même s'il ne s'agit que d'un brouillon, nous donne de quoi parler.

Aussi, cas de test si vous en avez. Il peut être nécessaire de les ajuster pour correspondre aux modifications finales, mais il serait très utile de réfléchir aux différents cas (par exemple, convertir .asc en .desc , gérer nulls_fist / nulls_last arguments, etc.).

Cette page vous a été utile?
0 / 5 - 0 notes

Questions connexes

nhuzaa picture nhuzaa  ·  3Commentaires

chromakey picture chromakey  ·  5Commentaires

sassanh picture sassanh  ·  4Commentaires

Sonictherocketman picture Sonictherocketman  ·  3Commentaires

GuillaumeCisco picture GuillaumeCisco  ·  3Commentaires