gwift-book/chapters/models.tex

884 lines
37 KiB
TeX
Executable File

\chapter{Modélisation}
Ce chapitre aborde la modélisation des objets et les options qui y sont liées.
Avec Django, la modélisation est en lien direct avec la conception et le stockage, sous forme d'une base de données relationnelle, et la manière dont ces données s'agencent et communiquent entre elles.
Cette modélisation va ériger les premières pierres de votre édifice.
Comme expliqué par Aurélie Jean \cite{other_side}, "\emph{toute modélisation reste une approximation de la réalité}".
\begin{quote}
\emph{Le modèle n'est qu'une grande hypothèse.
Il se base sur des choix conscients et inconscients, et dans chacun de ces choix se cachent nos propres perceptions qui résultent de qui nous sommes, de nos connaissances, de nos profils scientifiques et de tant d'autres choses.} \cite{other_side}
\end{quote}
Django utilise un paradigme de persistence des données de type \href{https://fr.wikipedia.org/wiki/Mapping_objet-relationnel}{ORM} - c'est-à-dire que chaque type d'objet manipulé peut s'apparenter à une
table SQL, tout en respectant une approche propre à la programmation orientée objet.
Plus spécifiquement, l'ORM de Django suit le patron de conception \href{https://en.wikipedia.org/wiki/Active_record_pattern}{Active Records}, comme le font par exemple \href{https://rubyonrails.org/}{Rails} pour Ruby ou \href{https://docs.microsoft.com/fr-fr/ef/}{EntityFramework} pour .Net.
Le modèle de données de Django est sans doute la seule partie qui soit tellement couplée au framework qu'un changement à ce niveau nécessitera une refonte complète de beaucoup d'autres briques de vos projets; là où un pattern de type \href{https://www.martinfowler.com/eaaCatalog/repository.html}{Repository} permettrait justement de découpler le modèle des données de l'accès à ces mêmes données, un pattern Active Record lie de manière extrêmement forte le modèle à sa persistence.
Architecturalement, c'est sans doute la plus grosse faiblesse de Django, à tel point que \textbf{ne pas utiliser cette brique de fonctionnalités} peut remettre en question le choix du framework. \footnote{Et dans ce cas, il y a des alternatives comme Flask qui permettent une flexibilité et un choix des composants beaucoup plus grand.}
Conceptuellement, c'est pourtant la manière de faire qui permettra d'avoir quelque chose à présenter très rapidement: à partir du moment où vous aurez un modèle de données, vous aurez accès, grâce à cet ORM à:
\begin{enumerate}
\item
Des migrations de données et la possibilité de faire évoluer votre modèle,
\item
Une abstraction entre votre modélisation et la manière dont les données sont représentées \emph{via} un moteur de base de données relationnelles,
\item
Une interface d'administration auto-générée
\item
Un mécanisme de formulaires HTML complet, pratique à utiliser, orienté objet et logique à faire évoluer,
\item
Une définition des notions d'héritage (tout en restant dans une forme d'héritage simple).
\end{enumerate}
Comme tout ceci reste au niveau du code, cela suit également la méthodologie des douze facteurs concernant la minimisation des divergences entre environnements d'exécution: il n'est plus nécessaire d'avoir un DBA qui doive démarrer un script sur un serveur au moment de la mise à jour, de recevoir une release note de 512 pages en PDF reprenant les modifications ou de nécessiter l'intervention de trois équipes différentes lors d'une modification majeure du code.
Déployer une nouvelle instance de l'application pourra être réalisé directement à partir d'une seule et même commande.
\section{Active Records}
Il est important de noter que l'implémentation d'Active Records reste une forme hybride entre une structure de données brutes et une classe: \cite{clean_code}
\begin{itemize}
\item
Une \textbf{classe} va exposer ses données derrière une forme d'abstraction et n'exposer que les fonctions qui opèrent sur ces données,
\item
Une \textbf{structure de données} ne va exposer que ses champs et propriétés, et ne va pas avoir de functions significatives.
\end{itemize}
L'exemple ci-dessous présente trois structure de données, qui exposent chacune leurs propres champs:
\begin{minted}{Python}
class Square:
def __init__(self, top_left, side):
self.top_left = top_left
self.side = side
class Rectangle:
def __init__(self, top_left, height, width):
self.top_left = top_left
self.height = height
self.width = width
class Circle:
def __init__(self, center, radius):
self.center = center
self.radius = radius
\end{minted}
Si nous souhaitons ajouter une fonctionnalité permettant de calculer l'aire pour chacune de ces structures, nous aurons deux possibilités:
\begin{enumerate}
\item
Soit ajouter une classe de \emph{visite} qui ajoute cette fonction de calcul d'aire
\item
Soit modifier notre modèle pour que chaque structure hérite d'une classe de type \texttt{Shape}, qui implémentera elle-même ce calcul d'aire.
\end{enumerate}
Dans le premier cas, nous pouvons procéder de la manière suivante:
\begin{minted}{python}
class Geometry:
PI = 3.141592653589793
def area(self, shape):
if isinstance(shape, Square):
return shape.side * shape.side
if isinstance(shape, Rectangle):
return shape.height * shape.width
if isinstance(shape, Circle):
return PI * shape.radius**2
raise NoSuchShapeException()
\end{minted}
Dans le second cas, l'implémentation pourrait évoluer de la manière suivante:
\begin{minted}{python}
class Shape:
def area(self):
pass
class Square(Shape):
def __init__(self, top_left, side):
self.__top_left = top_left
self.__side = side
def area(self):
return self.__side * self.__side
class Rectangle(Shape):
def __init__(self, top_left, height, width):
self.__top_left = top_left
self.__height = height
self.__width = width
def area(self):
return self.__height * self.__width
class Circle(Shape):
def __init__(self, center, radius):
self.__center = center
self.__radius = radius
def area(self):
PI = 3.141592653589793
return PI * self.__radius**2
\end{minted}
Une structure de données peut être rendue abstraite au travers des notions de programmation orientée objet.
Dans l'exemple géométrique ci-dessus, repris de \cite[pp. 95-97]{clean_code}, l'accessibilité des champs devient restreinte, tandis que la fonction \texttt{area()} bascule comme méthode d'instance plutôt que de l'isoler au niveau d'un visiteur.
Nous ajoutons une abstraction au niveau des formes grâce à un héritage sur la classe \texttt{Shape}; indépendamment de ce que nous manipulerons, nous aurons la possibilité de calculer son aire.
Une structure de données permet de facilement gérer des champs et des propriétés, tandis qu'une classe gère et facilite l'ajout de fonctions et de méthodes.
Le problème d'Active Records est que chaque classe s'apparente à une table SQL et revient donc à gérer des \emph{DTO} ou \emph{Data Transfer Object}, c'est-à-dire des objets de correspondance pure et simple entre
les champs de la base de données et les propriétés de la programmation orientée objet, c'est-à-dire également des classes sans fonctions.
Or, chaque classe a également la possibilité d'exposer des possibilités d'interactions au niveau de la persistence, en \href{https://docs.djangoproject.com/en/stable/ref/models/instances/\#django.db.models.Model.save}{enregistrant ses propres données} ou en en autorisant leur \href{https://docs.djangoproject.com/en/stable/ref/models/instances/\#deleting-objects}{suppression}.
Nous arrivons alors à un modèle hybride, mélangeant des structures de données et des classes d'abstraction, ce qui restera parfaitement viable tant que l'on garde ces principes en tête et que l'on se prépare à une
éventuelle réécriture du code.
Lors de l'analyse d'une classe de modèle, nous pouvons voir que Django exige un héritage de la classe \texttt{django.db.models.Model}.
Nous pouvons regarder les propriétés définies dans cette classe en analysant le fichier
\texttt{lib\textbackslash{}site-packages\textbackslash{}django\textbackslash{}models\textbackslash{}base.py}.
Outre que \texttt{models.Model} hérite de \texttt{ModelBase} au travers de \href{https://pypi.python.org/pypi/six}{six} pour la rétrocompatibilité vers Python 2.7, cet héritage apporte notamment les fonctions \texttt{save()}, \texttt{clean()}, \texttt{delete()}, \ldots
En résumé, toutes les méthodes qui font qu'une instance sait \textbf{comment} interagir avec la base de données.
\section{Types de champs, relations et clés étrangères}
Nous l'avons vu plus tôt, Python est un langage dynamique et fortement typé.
Django, de son côté, ajoute une couche de typage statique exigé par le lien sous-jacent avec les moteurs de base de données relationnelles.
Dans ce domaine, un point d'attention est de toujours disposer d'une clé primaire pour nos enregistrements.
Si aucune clé primaire n'est spécifiée, Django s'occupera d'en ajouter une automatiquement et la nommera (par
convention) \texttt{id}.
Par défaut, et si aucune propriété ne dispose d'un attribut \texttt{primary\_key=True}, Django s'occupera d'ajouter un champ \texttt{id} grâce à son héritage de la classe \texttt{models.Model}.
Elle sera ainsi accessible autant par cette propriété que par la propriété \texttt{pk}.
Chaque champ du modèle est donc typé et lié, soit à un primitif, soit à une autre instance au travers de sa clé d'identification.
Grâce à toutes ces informations, nous sommes en mesure de représenter facilement des relations, par exemple des livres liés à des catégories:
\begin{minted}{python}
class Category(models.Model):
name = models.CharField(max_length=255)
class Book(models.Model):
title = models.CharField(max_length=255)
category = models.ForeignKey(Category, on_delete=models.CASCADE)
\end{minted}
Les autres champs nous permettent d'identifier une catégorie (\texttt{Category}) par un nom (\texttt{name}), tandis qu'un livre (\texttt{Book}) le sera par ses propriétés \texttt{title} et une clé de relation vers une catégorie. Un livre est donc lié à une catégorie, tandis qu'une catégorie est associée à plusieurs livres.
\includegraphics{diagrams/books-foreign-keys-example.drawio.png}
A présent que notre structure dispose de sa modélisation, il nous faut informer le moteur de base de données de créer la structure correspondance, grâce à la création d'une étape de migration:
\begin{verbatim}
$ python manage.py makemigrations
Migrations for 'library':
library/migrations/0001_initial.py
- Create model Category
- Create model Book
\end{verbatim}
Cette étape créera un fichier différentiel, explicitant les modifications à appliquer à la structure de données pour rester en corrélation avec la modélisation de notre application.
Nous pouvons écrire un premier code d'initialisation de la manière suivante:
\begin{minted}{python}
from library.models import Book, Category
movies = Category.objects.create(name="Adaptations au cinéma")
medieval = Category.objects.create(name="Médiéval-Fantastique")
science_fiction = Category.objects.create(name="Sciences-fiction")
computers = Category.objects.create(name="Sciences Informatiques")
books = {
"Harry Potter": movies,
"The Great Gatsby": movies,
"Dune": science_fiction,
"H2G2": science_fiction,
"Ender's Game": science_fiction,
"Le seigneur des anneaux": medieval,
"L'Assassin Royal", medieval,
"Clean code": computers,
"Designing Data-Intensive Applications": computers
}
for book_title, category in books.items:
Book.objects.create(name=book_title, category=category)
\end{minted}
Nous nous rendons rapidement compte qu'un livre peut appartenir à plusieurs catégories:
\begin{itemize}
\item
\emph{Dune} a été adapté au cinéma en 1973 et en 2021, de même que \emph{Le Seigneur des Anneaux}.
Ces deux titres (au moins) peuvent appartenir à deux catégories distinctes.
\item
Pour \emph{The Great Gatsby}, c'est l'inverse: nous l'avons initialement classé comme film, mais le livre existe depuis 1925.
\item
Nous pourrions sans doute également étoffer notre bibliothèque avec une catégorie supplémentaire "Baguettes magiques et trucs phalliques", à laquelle nous pourrons associer la saga \emph{Harry Potter} et ses dérivés.
\end{itemize}
En clair, notre modèle n'est pas adapté, et nous devons le modifier pour qu'une occurrence d'un livre puisse être liée à plusieurs catégories.
Au lieu d'utiliser un champ de type \texttt{ForeignKey}, nous utiliserons à présent un champ de type \texttt{ManyToMany}, c'est-à-dire qu'un livre pourra être lié à plusieurs catégories, et qu'inversément, une même catégorie pourra être liée à plusieurs livres.
\begin{minted}{python}
class Category(models.Model):
name = models.CharField(max_length=255)
class Book(models.Model):
title = models.CharField(max_length=255)
category = models.ManyManyField(Category)
\end{minted}
Notre code d'initialisation reste par contre identique: Django s'occupe parfaitement de gérer la transition.
\section{Shell}
Le \texttt{shell} est un environnement REPL \index{REPL} identique à ce que l'interpréteur Python offre par défaut, connecté à la base de données, qui permet de :
\begin{enumerate}
\item Tester des comportements spécifiques
\item Instancier des enregistrements provenant de la base de données
\item Voire, exceptionnellement, d'analyser un soucis en production.
\end{enumerate}
Il se démarre grâce à la commande \texttt{python manage.py shell}, et donne un accès intuitif \footnote{Pour un développeur\ldots} à l'ensemble des informations disponibles.
\subsection{Accès aux relations}
\begin{minted}{python}
# wish/models.py
class Wishlist(models.Model):
pass
class Item(models.Model):
wishlist = models.ForeignKey(Wishlist)
\end{minted}
Depuis le code, à partir de l'instance de la classe \texttt{Item}, on peut donc accéder à la liste en appelant la propriété \texttt{wishlist} de notre instance. \textbf{A contrario}, depuis une instance de type
\texttt{Wishlist}, on peut accéder à tous les éléments liés grâce à \texttt{\textless{}nom\ de\ la\ propriété\textgreater{}\_set}; ici \texttt{item\_set}.
Lorsque vous déclarez une relation 1-1, 1-N ou N-N entre deux classes, vous pouvez ajouter l'attribut \texttt{related\_name} afin de nommer la relation inverse.
\begin{minted}{python}
# wish/models.py
class Wishlist(models.Model):
pass
class Item(models.Model):
wishlist = models.ForeignKey(Wishlist, related_name='items')
\end{minted}
Si, dans une classe A, plusieurs relations sont liées à une classe B, Django ne saura pas à quoi correspondra la relation inverse.
Pour palier à ce problème, nous fixons une valeur à l'attribut \texttt{related\_name}. Par facilité (et par conventions), prenez l'habitude de toujours ajouter cet attribut: votre modèle gagnera en cohérence et en lisibilité. Si cette relation inverse n'est pas nécessaire, il est possible de l'indiquer (par convention) au travers de l'attribut \texttt{related\_name="+"}.
A partir de maintenant, nous pouvons accéder à nos propriétés de la manière suivante:
\begin{verbatim}
# python manage.py shell
>>> from wish.models import Wishlist, Item
>>> wishlist = Wishlist.create('Liste de test', 'description')
>>> item = Item.create('Element de test', 'description', w)
>>>
>>> item.wishlist
<Wishlist: Wishlist object>
>>>
>>> wishlist.items.all()
[<Item: Item object>]
\end{verbatim}
\subsection{Choix}
Voir \href{https://girlthatlovestocode.com/django-model}{ici}
\begin{minted}{python}
class Runner(models.Model):
# this is new:
class Zone(models.IntegerChoices):
ZONE_1 = 1, 'Less than 3.10'
ZONE_2 = 2, 'Less than 3.25'
ZONE_3 = 3, 'Less than 3.45'
ZONE_4 = 4, 'Less than 4 hours'
ZONE_5 = 5, 'More than 4 hours'
name = models.CharField(max_length=50)
last_name = models.CharField(max_length=50)
email = models.EmailField()
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
start_zone = models.PositiveSmallIntegerField( # this is new
choices=Zone.choices,
default=Zone.ZONE_5,
help_text="What was your best time on the marathon in last 2 years?"
)
\end{minted}
\section{Validateurs}
La validation des champs intervient sur toute donnée entrée via le modèle.
Cette validation dispose de trois niveaux:
\begin{enumerate}
\item Directement au niveau du modèle, au travers de validateurs sur les champs
\item Via une méthode de nettoyage sur un champ, qui permet de prendre d'autres informations contextuelles en considération
\item Via une méthode de nettoyage globale, associée à l'instance.
\end{enumerate}
\subsection{Validation d'un champ}
La première manière de valider le contenu d'un champ est aussi la plus simple.
En prenant un modèle type:
\begin{minted}{python}
from django.db import models
def validate_title(value):
if 't' not in value:
raise ValidationError('Title does not start with T')
class Book(models.Model):
title = models.CharField(max_length=255, validators=[validate_title])
\end{minted}
\subsection{Clean\_<field\_name>}
Ca, c'est le deuxième niveau. Le contexte donne accès à déjà plus d'informations et permet de valider les informations en interogeant (par exemple) la base de données.
\begin{minted}{python}
class Bidule(models.Model):
def clean_title(self):
raise ValidationError('Title does not start with T')
\end{minted}
Il n'est plus nécessaire de définir d'attribut \texttt{validators=[\ldots]}, puisque Django va appliquer un peu d'introspection pour récupérer toutes les méthodes qui commencent par \texttt{clean\_} et pour les faire correspondre au nom du champ à valider (ici, \texttt{title}).
\subsection{Clean}
Ici, c'est global: nous pouvons valider des données ou des champs globalement vis-à-vis d'autres champs déjà remplis.
\begin{minted}{python}
class Bidule(models.Model):
def clean(self):
raise ValidationError('Title does not start with T')
\end{minted}
\section{Constructeurs}
Si vous décidez de définir un constructeur sur votre modèle, ne surchargez pas la méthode \texttt{\_\_init\_\_}: créez plutôt une méthode static de type \texttt{create()}, en y associant les paramètres obligatoires ou souhaités.
Mieux encore: nous pouvons passer par un \texttt{ModelManager} pour limiter le couplage; l'accès à une information stockée en base de données ne se fait dès lors qu'au travers de cette instance et pas directement au travers du modèle.
\begin{minted}{python}
class ItemManager(...):
(de mémoire, je ne sais plus exactement)
\end{minted}
\section{Jointures, compositions et filtres}
Pour appliquer une jointure sur un modèle, nous pouvons passer par les méthodes \texttt{select\_related} et \texttt{prefetch\_related}.
Il faut cependant faire \textbf{très} attention au prefetch related, qui fonctionne en fait comme une grosse requête dans laquelle nous trouvons un \texttt{IN\ (\ldots)}.
Càd que Django va récupérer tous les objets demandés initialement par le queryset, pour ensuite prendre
toutes les clés primaires, pour finalement faire une deuxième requête et récupérer les relations externes.
Au final, si votre premier queryset est relativement grand (nous parlons de 1000 à 2000 éléments, en fonction du moteur de base de données), la seconde requête va planter et vous obtiendrez une exception de type \texttt{django.db.utils.OperationalError:\ too\ many\ SQL\ variables}.
Nous pourrions penser qu'utiliser un itérateur permettrait de combiner les deux, mais ce n'est pas le cas\ldots
Comme l'indique la documentation:
\begin{verbatim}
Note that if you use iterator() to run the query, prefetch_related() calls will be ignored since these two optimizations do not make sense together.
\end{verbatim}
Ajouter un itérateur va en fait forcer le code à parcourir chaque élément de la liste, pour l'évaluer. Il y aura donc (à nouveau) autant de requêtes qu'il y a d'éléments, ce que nous cherchons à éviter.
\begin{minted}{python}
informations = (
<MyObject>.objects.filter(<my_criteria>)
.select_related(<related_field>)
.prefetch_related(<related_field>)
.iterator(chunk_size=1000)
)
\end{minted}
DANGER: Les requêtes sont sensibles à la casse, \textbf{même} si le
moteur de base de données ne l'est pas. C'est notamment le cas pour
Microsoft SQL Server; faire une recherche directement via les outils de
Microsoft ne retournera pas obligatoirement les mêmes résultats que les
managers, qui seront beaucoup plus tatillons sur la qualité des
recherches par rapport aux filtres paramétrés en entrée.
\begin{verbatim}
Pour un `AND`, il suffit de chaîner les conditions. ** trouver un exemple ici ** :-)
\end{verbatim}
\begin{verbatim}
Mais en gros : bidule.objects.filter(condition1, condition2)
\end{verbatim}
\begin{verbatim}
Il existe deux autres options : combiner deux querysets avec l'opérateur `&` ou combiner des Q objects avec ce même opérateur.
\end{verbatim}
Soit encore combiner des filtres:
\begin{minted}{python}
from core.models import Wish
Wish.objects
Wish.objects.filter(name__icontains="test").filter(name__icontains="too")
\end{minted}
\begin{itemize}
\item
Ca, c'est notre manager.
\item
Et là, on chaîne les requêtes pour composer une recherche sur tous les souhaits dont le nom contient (avec une casse insensible) la chaîne "test" et dont le nom contient la chaîne "too".
\end{itemize}
Pour un 'OR', on a deux options :
\begin{enumerate}
\item
Soit passer par deux querysets, typiuqment
\texttt{queryset1\ \textbar{}\ queryset2}
\item
Soit passer par des \texttt{Q\ objects}, que l'on trouve dans le
namespace \texttt{django.db.models}.
\end{enumerate}
\begin{minted}{python}
from django.db.models import Q
condition1 = Q(...)
condition2 = Q(...)
bidule.objects.filter(condition1 | condition2)
\end{minted}
L'opérateur inverse (\emph{NOT})
Idem que ci-dessus : soit on utilise la méthode \texttt{exclude} sur le
queryset, soit l'opérateur \texttt{\textasciitilde{}} sur un Q object;
\subsection{N+1 Queries}
\begin{itemize}
\item
\url{http://stackoverflow.com/questions/12681653/when-to-use-or-not-use-iterator-in-the-django-orm}
\item
\url{https://docs.djangoproject.com/en/1.9/ref/models/querysets/\#django.db.models.query.QuerySet.iterator}
\item
\url{http://blog.etianen.com/blog/2013/06/08/django-querysets/}
\end{itemize}
Deux solutions:
\begin{enumerate}
\item
Prefetch
\item
select\_related
\end{enumerate}
\subsection{Indices}
Après analyse seulement.
\subsection{Agrégation et annotations}
\url{https://docs.djangoproject.com/en/3.1/topics/db/aggregation/}
\section{Métamodèle et introspection}
Comme chaque classe héritant de \texttt{models.Model} possède une propriété \texttt{objects}.
Cette propriété permet d'accéder aux objects persistants dans la base de données, au travers d'un \texttt{ModelManager}.
Les propriétés de la classe Meta les plus utiles sont les suivates:
\begin{itemize}
\item
\texttt{ordering} pour spécifier un ordre de récupération spécifique.
\item
\texttt{verbose\_name} pour indiquer le nom à utiliser au singulier
pour définir votre classe
\item
\texttt{verbose\_name\_plural}, pour le pluriel.
\item
\texttt{contraints} (Voir \href{https://girlthatlovestocode.com/django-model}{ici}-), par exemple
\end{itemize}
\subsection{Ordre par défaut}
\begin{minted}{python}
class Wish(models.Model):
name = models.CharField(max_length=255)
class Meta:
ordering = ('name',)
\end{minted}
Nous définissons un ordre par défaut, directement au niveau du modèle.
Cela ne signifie pas qu'il ne sera pas possible de modifier cet ordre (la méthode \texttt{order\_by} existe et peut être chaînée à n'importe quel \emph{queryset}).
D'où l'intérêt de tester ce type de comportement, dans la mesure où un \texttt{top\ 1} dans votre code pourrait être modifié simplement par cette petite information.
Pour sélectionner un objet au pif: \texttt{return\ Category.objects.order\_by("?").first()}
En plus de cela, il faut bien tenir compte des propriétés \texttt{Meta} de la classe: si elle contient déjà un ordre par défaut, celui-ci sera pris en compte pour l'ensemble des requêtes effectuées sur cette classe.
Cela signifie que le \texttt{top 1} utilisé dans le code pourrait être impacté en cas de modification de cette propriété \texttt{ordering}, ce qui corrobore la nécessité de \emph{tester le code dans son ensemble}.
\subsection{Représentation textuelle}
verbose\_name et verbose\_name\_plural + lien avec
\subsection{Contraintes}
Les contraintes sont de plusieurs types:
\begin{enumerate}
\item Unicité
\item Composée
\end{enumerate}
\begin{minted}{python}
constraints = [ # constraints added
models.CheckConstraint(check=models.Q(year_born__lte=datetime.date
.today().year-18), name='will_be_of_age'),
]
\end{minted}
\section{Querysets et managers}
L'ORM de Django (et donc, chacune des classes qui composent votre modèle) propose par défaut deux objets hyper importants:
\begin{itemize}
\item
Les \texttt{managers}, qui consistent en un point d'entrée pour
accéder aux objets persistants
\item
Les \texttt{querysets}, qui permettent de filtrer des ensembles ou
sous-ensemble d'objets. Les querysets peuvent s'imbriquer, pour
ajouter d'autres filtres à des filtres existants, et fonctionnent
comme un super jeu d'abstraction pour accéder à nos données
(persistentes).
\end{itemize}
Ces deux propriétés vont de paire; par défaut, chaque classe de votre modèle propose un attribut \texttt{objects}, qui correspond à un manager (ou un gestionnaire, si vous préférez).
Ce gestionnaire constitue l'interface par laquelle vous accéderez à la base de données.
Mais pour cela, vous aurez aussi besoin d'appliquer certains requêtes ou filtres.
Et pour cela, vous aurez besoin des \texttt{querysets}, qui consistent en des ensembles de requêtes.
\subsection{Managers}
Les \texttt{managers} constituent une partie de la couche de "services" de notre application.
Ces services peuvent se trouver à trois endroits \cite[Rule \# 2]{django_for_startup_founders}:
\begin{enumerate}
\item Dans les modèles ou au niveau des managers
\item Dans les forms ou les sérializeurs
\item Dans une couche de services séparée du reste.
\end{enumerate}
\subsection{Querysets}
Si on veut connaître la requête SQL sous-jacente à l'exécution du queryset, il suffit d'appeler la fonction str() sur la propriété \texttt{query}:
\begin{verbatim}
queryset = Wishlist.objects.all()
print(queryset.query)
\end{verbatim}
Chaque définition de modèle utilise un \texttt{Manager}, afin d'accéder
à la base de données et traiter nos demandes. Indirectement, une
instance de modèle ne \textbf{connait} \textbf{pas} la base de données:
c'est son gestionnaire qui a cette tâche. Il existe deux exceptions à
cette règle: les méthodes \texttt{save()} et \texttt{update()}.
\begin{itemize}
\item
Instanciation: MyClass()
\item
Récupération: MyClass.objects.get(pk=\ldots\hspace{0pt})
\item
Sauvegarde : MyClass().save()
\item
Création: MyClass.objects.create(\ldots\hspace{0pt})
\item
Liste des enregistrements: MyClass.objects.all()
\end{itemize}
Par défaut, le gestionnaire est accessible au travers de la propriété
\texttt{objects}. Cette propriété a une double utilité:
\begin{enumerate}
\def\labelenumi{\arabic{enumi}.}
\item
Elle est facile à surcharger - il nous suffit de définir une nouvelle
classe héritant de ModelManager, puis de définir, au niveau de la
classe, une nouvelle assignation à la propriété \texttt{objects}
\item
Il est tout aussi facile de définir d'autres propriétés présentant des
filtres bien spécifiques.
\end{enumerate}
\section{Services}
Une des recommandations que l'on peut lire pour django consiste à créer des \textit{fat models} et à conserver des \textit{thin views}, c'est-à-dire à compléter le maximum d'informations directement au niveau du modèle et sortir le maximum de règles métiers des vues.
Le soucis, c'est que l'on arrive rapidement à créer des classes de plusieurs centaines de lignes, qui dialoguent directement avec la base de données, et qui pourraient potentiellement:
\begin{enumerate}
\item
Réaliser des requêtes N+1 vers d'autres objets
\item
Ajouter un couplage supplémentaire directement au niveau des propriétés.
\end{enumerate}
\begin{quote}
The "Fat Models" recommendation is one of the most destructive in my opinion: https://django-best-practices.readthedocs.io/en/latest/appli..., along with Django Rest Framework "Model Serializers". A JSON serializer that talks directly to the database is just madness.
-- \url{https://news.ycombinator.com/item?id=23322880}
\end{quote}
La proposition consiste à créer des services qui viennent se glisser entre les vues et les managers.
De cette manière, nous :
\begin{enumerate}
\item
Conservons le principe de \textit{thin views}
\item
Ajoutons un concept intermédiaire, entre ces vues et les managers, afin de limiter la maintenance des requêtes, mais aussi
\item
Gardons les managers comme couche d'accès à la base de données,
\item
Conservons des modèles cohérents, dans lesquels il serait logique de trouver une propriété spécifique,
\item
Gardons les modèles à la frontière entre la représentation mentale d'un concept et sa presistance.
\end{enumerate}
\includegraphics{images/diagrams/views-services-managers-models.drawio.png}
\section{Refactoring et héritages}
On constate que plusieurs classes possèdent les mêmes propriétés \texttt{created\_at} et \texttt{updated\_at}, initialisées aux mêmes valeurs.
Pour gagner en cohérence, nous allons créer une classe dans laquelle nous définirons ces deux champs, et nous ferons en sorte que les classes \texttt{Wishlist}, \texttt{Item} et \texttt{Part} en
héritent.
Django gère trois sortes d'héritage:
\begin{itemize}
\item
L'héritage par classe abstraite
\item
L'héritage classique
\item
L'héritage par classe proxy.
\end{itemize}
\subsection{Classes abstraites}
L'héritage par classe abstraite consiste à déterminer une classe mère
qui ne sera jamais instanciée. C'est utile pour définir des champs qui
se répèteront dans plusieurs autres classes et surtout pour respecter le
principe de DRY. Comme la classe mère ne sera jamais instanciée, ces
champs seront en fait dupliqués physiquement, et traduits en SQL, dans
chacune des classes filles.
\begin{minted}{python}
# wish/models.py
class AbstractModel(models.Model):
class Meta:
abstract = True
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Wishlist(AbstractModel):
pass
class Item(AbstractModel):
pass
class Part(AbstractModel):
pass
\end{minted}
En traduisant ceci en SQL, on aura en fait trois tables, chacune
reprenant les champs \texttt{created\_at} et \texttt{updated\_at}, ainsi
que son propre identifiant:
\begin{minted}{sql}
--$ python manage.py sql wish
BEGIN;
CREATE TABLE "wish_wishlist" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"created_at" datetime NOT NULL,
"updated_at" datetime NOT NULL
)
;
CREATE TABLE "wish_item" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"created_at" datetime NOT NULL,
"updated_at" datetime NOT NULL
)
;
CREATE TABLE "wish_part" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"created_at" datetime NOT NULL,
"updated_at" datetime NOT NULL
)
;
COMMIT;
\end{minted}
Une bonne pratique consiste à ajouter un "autre" identifiant pour un modèle susceptible d'être interrogé.
Nous pensons ici à un UUID ou à un slug \index{slug}, qui permettrait d'avoir une sorte de clé sémantique associée à un object; ceci évite également d'avoir des identifiants qui soient trop facilement récupérables.
\subsection{Héritage classique}
L'héritage classique est généralement déconseillé, car il peut
introduire très rapidement un problème de performances: en reprenant
l'exemple introduit avec l'héritage par classe abstraite, et en omettant
l'attribut \texttt{abstract\ =\ True}, on se retrouvera en fait avec
quatre tables SQL:
\begin{itemize}
\item
Une table \texttt{AbstractModel}, qui reprend les deux champs
\texttt{created\_at} et \texttt{updated\_at}
\item
Une table \texttt{Wishlist}
\item
Une table \texttt{Item}
\item
Une table \texttt{Part}.
\end{itemize}
A nouveau, en analysant la sortie SQL de cette modélisation, on obtient
ceci:
\begin{minted}{sql}
--$ python manage.py sql wish
BEGIN;
CREATE TABLE "wish_abstractmodel" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"created_at" datetime NOT NULL,
"updated_at" datetime NOT NULL
)
;
CREATE TABLE "wish_wishlist" (
"abstractmodel_ptr_id" integer NOT NULL PRIMARY KEY REFERENCES
"wish_abstractmodel" ("id")
)
;
CREATE TABLE "wish_item" (
"abstractmodel_ptr_id" integer NOT NULL PRIMARY KEY REFERENCES
"wish_abstractmodel" ("id")
)
;
CREATE TABLE "wish_part" (
"abstractmodel_ptr_id" integer NOT NULL PRIMARY KEY REFERENCES
"wish_abstractmodel" ("id")
)
;
COMMIT;
\end{minted}
Le problème est que les identifiants seront définis et incrémentés au
niveau de la table mère. Pour obtenir les informations héritées, nous
seront obligés de faire une jointure. En gros, impossible d'obtenir les
données complètes pour l'une des classes de notre travail de base sans
effectuer un \textbf{join} sur la classe mère.
Dans ce sens, cela va encore\ldots\hspace{0pt} Mais imaginez que vous
définissiez une classe \texttt{Wishlist}, de laquelle héritent les
classes \texttt{ChristmasWishlist} et \texttt{EasterWishlist}: pour
obtenir la liste complètes des listes de souhaits, il vous faudra faire
une jointure \textbf{externe} sur chacune des tables possibles, avant
même d'avoir commencé à remplir vos données. Il est parfois nécessaire
de passer par cette modélisation, mais en étant conscient des risques
inhérents.
\subsection{Classes Proxy}
Lorsqu'on définit une classe de type \textbf{proxy}, on fait en sorte
que cette nouvelle classe ne définisse aucun nouveau champ sur la classe
mère. Cela ne change dès lors rien à la traduction du modèle de données
en SQL, puisque la classe mère sera traduite par une table, et la classe
fille ira récupérer les mêmes informations dans la même table: elle ne
fera qu'ajouter ou modifier un comportement dynamiquement, sans ajouter
d'emplacements de stockage supplémentaires.
Nous pourrions ainsi définir les classes suivantes:
\begin{minted}{python}
# wish/models.py
class Wishlist(models.Model):
name = models.CharField(max_length=255)
description = models.CharField(max_length=2000)
expiration_date = models.DateField()
@staticmethod
def create(self, name, description, expiration_date=None):
wishlist = Wishlist()
wishlist.name = name
wishlist.description = description
wishlist.expiration_date = expiration_date
wishlist.save()
return wishlist
class ChristmasWishlist(Wishlist):
class Meta:
proxy = True
@staticmethod
def create(self, name, description):
christmas = datetime(current_year, 12, 31)
w = Wishlist.create(name, description, christmas)
w.save()
class EasterWishlist(Wishlist):
class Meta:
proxy = True
@staticmethod
def create(self, name, description):
expiration_date = datetime(current_year, 4, 1)
w = Wishlist.create(name, description, expiration_date)
w.save()
\end{minted}
\section{Conclusions}
Le modèle proposé par Django est un composant extrêmement performant, mais fort couplé avec le coeur du framework.
Si tous les composants peuvent être échangés avec quelques manipulations, le cas du modèle sera
plus difficile à interchanger.
A côté de cela, il permet énormément de choses, et vous fera gagner un temps précieux, tant en rapidité d'essais/erreurs, que de preuves de concept.
Dans les exemples ci-dessus, nous avons vu les relations multiples (1-N), représentées par des clés étrangères (\textbf{ForeignKey}) d'une classe A vers une classe B.
Pour représenter d'autres types de relations, il existe également les champs de type \textbf{ManyToManyField}, afin de représenter une relation N-N.
Il existe également un type de champ spécial pour les clés étrangères, qui est le Les champs de type \textbf{OneToOneField}, pour représenter une relation 1-1.