L'ORM Django : quelques astuces à connaître pour des accès BDD parfaits !

Travailler avec un ORM est généralement synonyme de gain de temps et de meilleure maintenabilité des applications web… si on le fait bien (sinon, c'est surtout l'occasion de plomber méchamment les performances) : Django ne déroge pas à cette règle !

ORM : un outil puissant… et dangereux

Les grands débutants ont généralement un de ces deux avis sur les ORM :

  • Ça sert à rien car je sais écrire mes requêtes SQL moi-même, et en plus ça va ralentir mon application.
  • C'est génial, je vais pouvoir travailler avec une BDD sans connaître SQL et en écrivant moins de code.

Ces deux affirmations sont complètement fausses, bien que comportant quelques relants de vérité (en picard, on dirait « il o intindu ène vake brère din eine échurie » : les autochtones apprécieront, pour les autres… ignorez simplement cette parenthèse).

La bonne nouvelle, c'est que les ORM, bien utilisés, permettent effectivement d'écrire moins de code. Que dis-je : beaucoup moins de code, ce qui implique beaucoup moins de bugs (c'est mécanique) et une maintenance simplifiée (c'est plus facile d'intervenir sur un programme qui comporte 10 lignes que sur un autre qui en comporte 100 dont 90 de SQL avec 10 dans le contrôleur et 90 dans les modèles…).

La mauvaise nouvelle, c'est que ce gain de temps, de concision et de maintenabilité a un prix : il faut savoir ce que l'on fait (comprenez : connaître le SQL… oui, quand même !). Si vous utilisez « bêtement » un ORM, vous risquez de vous retrouver avec un SGBD qui plie sous le poids des dizaines de requêtes exécutées pour réaliser un travail pour lequel une seule requête suffirait.

Dans la suite de cette page, nous considérerons que vous connaissez SQL. Pas forcément que vous soyez des DBA (database administrator) aguerris ou des brutes de l'optimisation BDD, mais si vous pensez que SELECT * FROM ou LEFT OUTER JOIN sont des insultes en chtimi, commencez par relire vos cours de licence, lire un tutoriel SQL ou encore vous procurer un bon bouquin sur les BDD et SQL.

Quelques commandes de base à connaître sur l'ORM Django

Voici quelques petites choses très utiles que vous voudrez faire à un moment ou à un autre avec l'ORM de Django, comme avec tous les ORM.

Pré-requis : le manager d'objets

Tout modèle Django est automatiquement équipé d'un manager d'objets, implémenté via la classe Model de laquelle héritent tous nos modèles. Ce manager est en fait un attribut du modèle (un composant au sens UML) fournissant des méthodes utiles pour retrouver des objets, créer, supprimer, filtrer, etc. Nous reparlerons des managers tout au long de cette page.

Créer et persister un objet

Étant un modèle déclaré dans le fichier models.py, vous pouvez créer un objet (instance de ce modèle) puis (mais ce n'est pas obligatoire ni systématique) enregistrer cet objet en base de données :

my_team = Team(name='Front office team') # Crée une instance du modèle Team
my_team.save() # Persiste l'objet my_team

Remarquez que ces méthodes « unitaires » n'utilisent pas le manager de notre modèle. Pour aller plus vite, le manager fournit une méthode shortcut qui s'appelle create : cette méthode permet de créer puis persister un objet, en une seule ligne :

my_team = Team.objects.create(name='Front office team')

Bien entendu, pour pouvoir persister des objets en BDD, vous devez avoir paramétré une BDD dans le fichier settings.py de votre projet.

Lors de l'enregistrement en base, chaque objet se voit attribuer un identifiant. Cet identifiant (clé primaire) est enregistré dans la colonne id de la table permettant le stockage des objets du modèle. Remarquez que ce champ id n'est pas déclaré dans nos modèles : Django le crée automatiquement pour nous !

Récupérer un objet

Pour récupérer un objet, Django fournit une commande get, via le manager d'objets du modèle :

team = Team.objects.get(name='Front office team')

Une fois récupéré, vous pouvez utiliser un objet comme bon vous semble : modifier un de ses attributs, l'enregistrer à nouveau, etc.

Modifier un objet

Nous commençons par récupérer l'objet, puis nous le modifions et l'enregistrons :

the_team = Team.objects.get(name='Front office team')
the_team.name = 'The Django Team'
the_team.save()

C'est fait : notre objet a été modifié, et la base de données a été mise à jour !

Récupérer plusieurs objets à la fois

Bien entendu, nous avons souvent besoin de requêter un ensemble d'objets répondants à certaines conditions. Pour cela, le manager fournit deux méthodes utiles :

  • all() : récupère toutes les instances persistées du modèles (~ SELECT * FROM table)
  • filter() : récupère certaines instances persistées, sur la base d'un filter (~ SELECT... WHERE...)
all_sprints = Sprint.objects.all() # Récupère tous les sprints

my_team = Team.objects.get(name='Front office team')
team_1_sprints = Sprint.objects.filter(team=my_team) # Récupère les sprints de l'équipe my_team

team_1_last_sprints = Sprint.objects.filter(team=my_team).order_by(begin_date)[:3] # Récupère les 3 derniers sprints de l'équipe my_team

Un filtre peut aussi cumuler plusieurs conditions :

my_user = User.objects.get(is_staff=False, is_active=True) # intersection ("et" logique)
my_other_user = User.objects.get(Q(username__startswith='Jean') | Q(date_joined__year=2014)) # disjonction ("ou" logique)

Django : un ORM paresseux ?

Une chose importante que vous devez comprendre et bien assimiler, c'est que construire des requêtes optimisées est, pour un système automatique (comme pour le commun des mortel d'ailleurs), une tâche compliquée. Les ORM s'en sortent généralement très bien sur des requêtes simples, mais ont souvent plus de mal à se débrouiller seuls sur des cas complexes : il convient alors de les aider un peu…

Niveau 0 : savoir ce qui se passe !

La chose la plus déroutante avec les ORM, c'est de ne pas savoir comment ce composant fait sa petite cuisine pour répondre aux requêtes. L'ORM génère des requêtes, les exécute, et créer/peuple des objets avec les données retrouvées, mais on ne sait pas quelles sont les requêtes générées.

La première chose à faire est de monitorer cette affaire, pour savoir précisément quelles requêtes sont réalisées, combien, quand, comment, etc.

Pour cela, il existe une manière toute simple en Django (qui a d’ailleurs été portée dans pas mal d'autres frameworks) : la Debug Toolbar. Django Debug Toolbar est une application Django que l'on utilise en dev pour connaître tout un tas de choses sur les traitements réalisés par le framework à l'exécution, notamment les requêtes SQL, mais aussi les templates utilisés, les appels au cache, le temps de calcul d'une page, etc. : c'est un véritable outil de debugging. Commencez par installer l'application Django Debug Toolbar de Rob Hudson

Télécharger Django Debug Toolbar

Le piège du n requêtes

En analysant les requêtes générées et exécutées par l'ORM sur chacune de vos pages, vous allez vous rendre compte que dans certaines situations, beaucoup trop de requêtes sont exécutées, de l'ordre de n requêtes, avec n le nombre d'objets retournées. Grosso modo, vous exécutez un filtre censé retourner 150 objets, et l'ORM exécute… 150 requêtes SQL ! Simplement dévastateur en matière de performance.

La raison de cela est que les QuerySets de Django sont paresseuses (on dit « laizy QuerySets ») : elles ne « tapent » la base de données que lorsque c'est nécessaire. Ce choix tient à des raisons de performance d'ensemble : pourquoi faire des requêtes BDD quand ce n'est pas nécessaire ? Du coup, pourquoi requêter la 123e ligne si on n'utilise que les 122 premières ? Vous avez compris le principe : par défaut, on ne requête que ce dont on a besoin, sauf que parfois, on fait à l'avance que l'on va avoir besoin de tout, et au lieu de faire 150 requêtes, on préfère n'en faire qu'une seule comportant les jointures adéquates.

Prenons un exemple : notre page de visualisation d'un backlog. Après installation de la Django Debug Toolbar, nous voyons ceci :

Écran backlog avec Django Toolbar

La barre de débug nous signale que pour générer cette page, 4 requêtes SQL ont été générées et exécutées par l'ORM. Voyons quelles sont ces requêtes :

Écran backlog avec Debug Toolbar : requêtes SQL

Voyons ces requêtes en détail :

  • Les deux premières requêtes ne sont « pas de notre fait » : il s'agit de requêtes utilitaires générées par l'application de gestion de l'authentification de Django. Vous comprendrez aisément ce que font ces requêtes : elles récupèrent les données concernant l'utilisateur connecté et ses informations de session.
  • La 3e requête a bien été provoqué par notre code, en l’occurrence par le code que nous avons écrit dans notre contrôleur (view) backlog. Pour avoir un peu plus d'informations là dessus, dépliez en cliquant sur le petit signe + à gauche de a requête. Vous devriez voir ceci :
    Informations sur la requête SQL
    Il s'agit donc de notre requête destinée à récupérer les informations sur le backlog demandé. Cette requête est on ne peut plus simple… Remarquez simplement (cela aura une importance non négligeable par la suite, qu'aucune jointure vers la table des équipes (Team) n'a été réalisée : si on en a besoin, il faudra faire une autre requête…)
  • La 4e requête concerne les user-stories liées au backlog demandé. Si vous cliquez sur le petit +, vous verrez que ceci correspond à notre ligne de code du contrôleur (view) : stories = UserStory.objects.filter(product_backlog=backlog).

OK, grâce à cette toolbar fort pratique, nous connaissons exactement ce qui se passe derrière la scène en matière de requêtes SQL.

Réalisons maintenant une modification dans notre vue (template), afin d'afficher, sur la page backlog, le nom de l'équipe qui travaille sur ce backlog. Voici le code de notre template, avant et après :

backlog.html (avant)
{% extends "base.html" %}

{% block title %}Backlog {{ backlog.name }}{% endblock %}

{% block body %}
    <h1>{{ backlog.name }}</h1>
    <ul>
        {% for story in stories %}
            <li>{{ story.name }}</li>
        {% endfor %}
    </ul>
{% endblock %}
backlog.html (après)
{% extends "base.html" %}

{% block title %}Backlog {{ backlog.name }}{% endblock %}

{% block body %}
    <h1>{{ backlog.name }}</h1>
    <h2>Équipe : {{ backlog.team.name }}</h2>
    <ul>
        {% for story in stories %}
            <li>{{ story.name }}</li>
        {% endfor %}
    </ul>
{% endblock %}

Comme vous le voyez, une seule ligne a été ajoutée : <h2>Équipe : {{ backlog.team.name }}</h2>. Cette ligne fait donc référence à l'attribut name de l'objet team lié au backlog à afficher par une clé étrangère (team = models.ForeignKey(Team)). Ceci n'a a priori, aucune incidence sur les requêtes SQL réalisées… et pourtant.

Examinons le nombre de requêtes réalisées et leur contenu après cette modification :

Nouvelle query ORM

Pas de bol, cette petite ligne sans prétention nous cause l'exécution d'une requête supplémentaire, « pour rien » : on voit que cette ligne, précisément, nécessite un SELECT sur la table des équipes.

Pour éviter ceci, une simple jointure suffirait ! Nous allons donc spécifier à Django le fait que quand il requête un ou des backlogs, il doit également rapatrier l'équipe liée.

select_related

Nous allons utiliser pour cela la clause select_related proposée par l'ORM de Django.

Dans le contrôleur (view) backlog, nous allons remplacer la ligne :

backlog = ProductBacklog.objects.get(pk=backlog_id)

Par la suivante :

backlog = ProductBacklog.objects.get(pk=backlog_id).select_related('team')

À présent, l'écran utilisateur n'a pas changé, mais vous pouvez constater que la requête superflue a été évitée : seule 4 requêtes sont exécutées !

ORM select_related Django
Qu'est-ce qu'un ORM ?
  • Un composant qui permet de gagner du temps en rendant transparent l'aspect persistance des données depuis un modèle objet
  • Un Objet Roulant Motorisé
  • Un outil permettant de ne quasiment plus écrire de SQL dans un projet
  • Un système permettant de limiter le recours aux modèles
Comment connaître les requêtes générées par Django lors du traitement d'un contrôleur ?
  • En utilisant un middleware maison.
  • En utilisant un outil comme la Django Debug Toolbar.
  • En utilisant un debugger.
  • Ce n'est pas possible de connaître les requêtes générées car c'est l'ORM qui gère cette partie.

Quelle est la différence entre les deux exemples suivants :

jb = Aliment(name="jambon")
jb.save()
jb = Aliment.objects.create(name="jambon")
  • Il n'y en a pas.
  • Le premier ne persiste pas jb en base de données.
  • Le second ne persiste pas jb en base de données.
  • Les deux permettent de créer une instance du modèle Aliment, mais le premier provoquera une erreur si un aliment ayant pour nom « jambon » existe déjà.

Soit un modèle Animal, ayant un attribut name. Que se passe-t-il quand on exécute le code suivant :

trichobatrachus_robustus = Animal.objects.create(name="Grenouille poilue")
une_grenouille_a_poils = Animal.objects.create(name="Grenouille poilue")
grenouille = Animal.objects.get(name="Grenouille poilue")
  • On récupère les deux objets ayant pour nom « Grenouille poilue ».
  • Django lève une exception.
  • On récupère le premier objet ayant pour nom « Grenouille poilue ».
  • Rien ne se passe

Souvenez-vous que get() retourne une instance de modèle, et qu'il ne peut en retourner qu'une. Si plusieurs objets correspondent au filtre, alors il lève une exception MultipleObjectsReturned.

Soit un modèle Guitarist ayant un attribut name et un attribut nick_name. Que sera affiché à l'écran si on exécute ces commandes dans une console Django :

mon_guitariste_prefere = Guitarist.objects.create(name="Jimi Hendrix", nick_name="Voodoo Child")
mon_guitariste_prefere.name = "B.B. King"
mon_guitariste_prefere = Guitarist.objects.get(nick_name="Voodoo Child")
print(mon_guitariste_prefere.name)
  • B.B. King
  • Jimi Hendrix
  • Louis Bertignac
  • Voodoo Child

Soit un modèle Guitarist ayant un attribut name et un attribut nick_name. Que sera affiché à l'écran si on exécute ces commandes dans une console Django :

mon_guitariste_prefere = Guitarist.objects.create(name="Jimi Hendrix", nick_name="Voodoo Child")
mon_guitariste_prefere.name = "B.B. King"
mon_guitariste_prefere.save()
mon_guitariste_prefere = Guitarist.objects.get(nick_name="Voodoo Child")
print(mon_guitariste_prefere.name)
  • B.B. King
  • Jimi Hendrix
  • Louis Bertignac
  • Voodoo Child
En chargeant une page d'une de vos applications, vous vous rendez-compte grâce à Django Debug Toolbar que 3 requêtes ont été exécutées alors que vous n'avez écrit qu'une ligne dans votre contrôleur. Que s'est-il passé ?
  • Vous avez probablement fait quelque chose de mal et devez régler ça au plus vite !
  • C'est tout à fait normal, Django réalise un certain nombre de requêtes pour les besoins internes du framework.
  • Vous avez probablement omis de forcer des jointures avec select_related().
  • C'est une très bonne nouvelle : plus l'application réalise de requêtes, plus elle est performante.
En chargeant une page d'une de vos applications, vous vous rendez-compte grâce à Django Debug Toolbar que 549 requêtes ont été exécutées alors que vous n'avez écrit qu'une ligne dans votre contrôleur. Que s'est-il passé ?
  • Vous avez probablement fait quelque chose de mal et devez régler ça au plus vite !
  • C'est tout à fait normal, Django réalise un certain nombre de requêtes pour les besoins internes du framework.
  • Vous avez probablement omis de forcer des jointures avec select_related().
  • C'est une très bonne nouvelle : plus l'application réalise de requêtes, plus elle est performante.

Soit la requête suivante écrite dans un contrôleur (view) :

user_last_comments = Comment.objects.filter(Q(user=request.user)|Q(user_ip=user_ip))

Que permet-elle de faire ?

  • Récupérer les commentaires créés par l'utilisateur qui visite la page ainsi que les commentaires créés par une personne ayant l'IP user_ip.
  • Récupérer l'ensemble des commentaires enregistrés en base de données.
  • Récupérer les commentaires de l'utilisateur en cours mais pas ceux créés par un utilisateur ayant l'IP user_ip.
  • Cette requête a une syntaxe erronée et provoquera une erreur d’interprétation.
  • Cette requête a une syntaxe erronée et Django lèvera une exception.

La requête suivante est-elle valide :

mes_personnes = Person.objects.raw("SELECT first_name, last_name, birth_date FROM myapp_person")
  • Oui
  • Non