Sith/docs/howto/querysets.md

281 lines
9.0 KiB
Markdown
Raw Normal View History

2024-07-16 16:39:54 +00:00
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
2024-07-16 21:35:24 +00:00
(la [doc officielle](https://docs.djangoproject.com/fr/stable/topics/db/queries/) fait déjà ça mieux que nous),
2024-07-16 16:39:54 +00:00
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 :
```python
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 :
```python
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.
### `select_related`
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à.
```python
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`.
### `prefetch_related`
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.
```python
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 :
```python
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 :
```python
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 :
```python
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 :
```python
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` :
```python
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.