Sith/docs/howto/querysets.md
2024-07-21 00:56:58 +02:00

9.0 KiB

L'ORM de Django est puissant, très puissant, non par parce qu'il est performant (après tout, ce n'est qu'une interface, le gros du boulot, c'est la db qui le fait), mais parce qu'il permet d'écrire de manière relativement simple un grand panel de requêtes.

De manière générale, puisqu'un ORM est un système consistant à manipuler avec un code orienté-objet une db relationnelle (c'est-à-dire deux paradigmes qui ne fonctionnent absolument pas pareil), on rencontre un des deux problèmes suivants :

  • soit l'ORM n'offre pas assez d'abstraction, auquel cas, quand on veut faire des requêtes plus complexes qu'un select avec un where, on s'emmêle les pinceaux et on se dit que ça aurait été plus simple de le faire directement en SQL.
  • soit l'ORM offre trop d'abstraction, auquel cas, on a tendance à ne pas prêter assez attention aux requêtes envoyées en base de données et on finit par se rendre compte que les temps d'attente explosent parce qu'on envoie trop de requêtes.

Django est dans ce deuxième cas.

C'est pourquoi nous ne parlerons pas ici de son fonctionnement exact ni de toutes les fonctions que l'on peut utiliser (la doc officielle fait déjà ça mieux que nous), mais plutôt des pièges courants et des astuces pour les éviter.

Les N+1 queries

Le problème

Normalement, quand on veut récupérer une liste, on fait une requête et c'est fini. Mais des fois, ça n'est pas si simple. Par exemple, supposons que nous voulons récupérer les 100 utilisateurs les plus riches, avec leurs informations client :

from core.models import User

for user in User.objects.order_by("-customer__amount")[:100]:
    print(user.customer.amount)

Combien de requêtes le bout de code suivant effectue-t-il ? 101. En deux pauvres lignes de code, nous avons demandé à la base de données d'effectuer 101 requêtes. Une requête toute seule n'est déjà une opération anodine, alors je vous laisse imaginer ce que ça donne pour 101.

Si vous ne comprenez pourquoi ce nombre, c'est très simple :

  • Une requête pour sélectionner nos 100 utilisateurs
  • Une requête supplémentaire pour récupérer les informations client de chaque utilisateur, soit 100 requêtes.

En effet, les informations client sont stockées dans une autre table, mais le fait d'établir un lien de clef étrangère permet de manipuler customer comme si c'était un membre à part entière de User.

Il est à noter cependant, que Django n'effectue une requête que pour le premier accès à un membre d'une relation de clef étrangère. Toutes les fois suivantes, l'objet est déjà là, et django le récupère :

from core.models import User

# l'utilisateur le plus riche
user = User.objects.order_by("-customer__amount").first()  # <-- requête db
print(user.customer.amount)  # <-- requête db
print(user.customer.account_id)  # on a déjà récupéré `customer`, donc pas de requête

Ce n'est donc pas gravissime si vous faites cette erreur quand vous manipulez un seul objet. En revanche, quand vous en manipulez plusieurs, il faut régler le problème. Pour ça, il y a plusieurs méthodes, en fonction de votre cas.

La méthode la plus basique consiste à annoter le queryset, avec la méthode select_related(). En faisant ça, Django fera une jointure sur l'autre table et demandera des informations en plus à la db lors de la requête.

De la sorte, lorsque vous appellerez le membre relié, les informations seront déjà là.

from core.models import User

richest = User.objects.order_by("-customer__amount")
for user in richest.select_related("customer")[:100]:
    print(user.customer)

Le code ci-dessus effectue une seule requête. Chaque fois qu'on veut accéder à customer, c'est bon, ça a déjà été récupéré à travers le annotate.

Maintenant, un cas plus compliqué. Supposons que vous ne vouliez pas récupérer des informations reliées par une relation One-to-One, mais par une relation One-to-Many.

Par exemple, un utilisateur a un seul compte client, mais il peut avoir plusieurs cotisations à son actif. Et dans ces cas-là, annotate ne marche plus. En effet, s'il peut exister plusieurs cotisations, comment savoir laquelle on veut ?

Il faut alors utiliser un prefetch_related. C'est un mécanisme un peu différent : au lieu de faire une jointure et d'ajouter les informations voulues dans la même requête, Django va effectuer une deuxième requête pour récupérer les éléments de l'autre table, puis, à partir de ces éléments, peupler la relation de son côté.

C'est un mécanisme qui peut être un peu coûteux en mémoire et qui demande une deuxième requête, mais qui reste quand même largement préférable à faire N requêtes en plus.

from core.models import User

for user in User.objects.prefetch_related("subscriptions")[:100]:
    # c'est bon, la méthode prefetch a récupéré en avance les `subscriptions`
    print(user.subscriptions.all())

!!! danger

La méthode `prefetch_related` ne marche que si vous
utilisez la méthode `all()` pour accéder au membre.
Si vous utilisez une autre méthode (comme `filter` ou `annotate`),
alors Django effectuera une nouvelle requête,
et vous retomberez dans le problème initial.

```python
from core.models import User
from django.db.models import Count

for user in User.objects.prefetch_related("subscriptions")[:100]:
    # Le prefetch_related ne marche plus !
    print(user.subscriptions.annotate(count=Count("*")))
```

Récupérer ce dont vous avez besoin

Des fois (souvent, même), penser explicitement à la jointure est le meilleur choix.

En effet, vous remarquerez que dans tous les exemples précédents, nous n'utilisions qu'une partie des informations (par exemple, nous ne récupérions que la somme d'argent sur les comptes, et éventuellement le numéro de compte).

Nous pouvons utiliser la méthode annotate pour spécifier explicitement les données que l'on veut joindre à notre requête.

Quand nous voulions récupérer les informations utilisateur, nous aurions tout aussi bien pu écrire :

from core.models import User
from django.db.models import F

richest = User.objects.order_by("-customer__amount")
for user in richest.annotate(amount=F("customer__amount"))[:100]:
    print(user.amount)

On aurait même pu réorganiser ça :


from core.models import User
from django.db.models import F

richest = User.objects.annotate(amount=F("customer__amount")).order_by("-amount")
for user in richest[:100]:
    print(user.amount)

Ça peut sembler moins bien qu'un select_related, comme ça. Des fois, c'est en effet moins bien, et des fois c'est mieux. La comparaison est plus évidente avec le prefetch_related.

En effet, quand nous voulions récupérer le nombre de cotisations des utilisateurs, le prefetch_related ne marchait plus. Pourtant, nous voulions récupérer une seule information.

Il aurait donc été suffisant d'écrire :

from core.models import User
from django.db.models import Count

for user in User.objects.annotate(nb_subscriptions=Count("subscriptions"))[:100]:
    # Et là ça marche, en une seule requête.
    print(user.nb_subscriptions)

Faire une jointure, c'est normal en SQL. Et pourtant avec Django on les oublie trop facilement. Posez-vous toujours la question des données que vous pourriez avoir besoin d'annoter, et vous éviterez beaucoup d'ennuis.

Les aggrégations manquées

Il arrive souvent que l'on veuille une information qui porte sur un ensemble d'objets de notre db.

Imaginons par exemple que nous voulons connaitre la somme totale des ventes faites à un comptoir.

Nous avons tous suivi nos cours de programmation, nous écrivons donc instinctivement :

from counter.models import Counter

foyer = Counter.objects.get(name="Foyer")
total_amount = sum(
    sale.amount * sale.unit_price
    for sale in foyer.sellings.all()
)

On pourrait penser qu'il n'y a pas de problème. Après tout, on ne fait qu'une seule requête. Eh bien si, il y a un problème : on fait beaucoup de choses en trop.

Concrètement, on demande à la base de données de renvoyer toutes les informations, ce qui rallonge inutilement la durée de l'échange entre le serveur et la db, puis on perd du temps à convertir ces informations en objets Python (opération qui a un coût également), et enfin on reperd du temps à calculer en Python quelque chose que la db aurait pu calculer à notre plus bien plus vite.

Nous aurions dû aggréger la requête, avec la méthode aggregate :

from counter.models import Counter
from django.db.models import Sum, F

foyer = Counter.objects.get(name="Foyer")
total_amount = (
    foyer.sellings.aggregate(amount=Sum(F("amount") * F("unit_price"), default=0))
)["amount__sum"]

En effectuant cette requête, la base de données nous renverra exactement l'information dont nous avons besoin. Et de notre côté, nous n'aurons pas à faire de traitement en plus.