gwift-book/source/part-3-data-model/models.adoc

20 KiB
Raw Blame History

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 de la base de données relationnelle, et la manière dont ces données sagencent et communiquent entre elles.

Django utilise un paradigme de type ORM - cest-à-dire que chaque type dobjet manipulé peut sapparenter à une table SQL, tout en ajoutant une couche propre à la programmation orientée object. Plus spécifiquement, lORM de Django suit le patron de conception Active Records, comme le font par exemple Rails pour Ruby ou EntityFramework pour .Net.

Le modèle de données de Django est sans doute la (seule ?) partie qui soit tellement couplée au framework quun changement à ce niveau nécessitera une refonte complète de beaucoup dautres briques de vos applications; là où un pattern de type Repository permettrait justement de découpler le modèle des données de laccès à ces mêmes données, un pattern Active Record lie de manière extrêmement forte le modèle à sa persistence.

Architecturalement, cest sans doute la plus grosse faiblesse de Django. Conceptuellement, cest pourtant la manière de faire qui permettra davoir 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 à Django:

  1. Aux migrations de données,

  2. A un découplage complet entre le moteur de données relationnel et le modèle de données,

  3. A une interface dadministration auto-générée

  4. A un mécanisme de formulaires HTML qui soit complet, pratique à utiliser, orienté objet et facile à faire évoluer,

  5. De définir des notions dhéritage (tout en restant dans une forme dhéritage simple).

Comme tout ceci reste au niveau du code, cela suit également la méthodologie des douze facteurs, concernant la minimisation des divergences entre environnements dexécution: comme tout se trouve au niveau du code, il nest plus nécessaire davoir 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 lintervention de trois équipes différentes lors dune modification majeure du code. Déployer une nouvelle instance de lapplication pourra être réalisé directement à partir dune seule et même commande.

A contrario, ces avantages sont balancés au travers dun couplage extrêmement fort entre la modélisation et le reste du framework - à tel point que ne pas utiliser cette brique de fonctionnalités peut remettre en question le choix du framework. En plus de ceci, limplémentation dActive Records reste une forme hybride entre une structure de données brutes et une classe: là où une classe va exposer ses données derrière une forme dabstraction et nexposer que les fonctions qui opèrent sur ces données, une structure de données ne va exposer que ses champs et propriétés, et ne va pas avoir de functions significatives.

Lexemple ci-dessous (en Java) présente trois structure de données, qui exposent chacune ses propres champs:

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

Si nous souhaitons ajouter une fonctionnalité permettant de calculer laire pour chacune de ces structures, nous aurons deux possibilités:

  1. Soit ajouter une classe de visite qui ajoute cette fonction de calcul daire

  2. Soit modifier notre modèle pour que chaque structure hérite dune classe de type Shape, qui implémentera elle-même ce calcul daire.

Dans le premier cas, nous pouvons procéder de la manière suivante:

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()

Dans le second cas, limplémentation pourrait évoluer de la manière suivante:

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

On le voit: une structure brute peut être rendue abstraite au travers des notions de programmation orientée objet. Dans lexemple géométrique ci-dessus, repris de cite:[clean_code, 95-97], laccessibilité des champs devient restreinte, tandis que la fonction area() bascule comme méthode dinstance plutôt quisolée au niveau dun visiteur. Nous ajoutons une abstraction au niveau des formes grâce à un héritage sur la classe 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 quune classe gère et facilite lajout de fonctions et de méthodes.

Le problème dActive Records est que chaque classe sapparente à une table SQL et revient donc à gérer des DTO ou Data Transfer Object, cest-à-dire des objets de correspondance pure et simple les champs de la base de données et les propriétés de la programmation orientée objet, cest-à-dire également des classes sans fonctions. Or, chaque classe a également la possibilité dexposer des possibilités dinteractions au niveau de la persistence, en enregistrant ses propres données ou en en autorisant la suppression. Nous arrivons alors à un modèle hybride, mélangeant des structures de données et des classes dabstraction, ce qui restera parfaitement viable tant que lon garde ces principes en tête et que lon se prépare à une éventuelle réécriture ultérieure.

Lors de lanalyse dune classe de modèle, nous pouvons voir que Django exige un héritage de la classe django.db.models.Model. Nous pouvons regarder les propriétés définies dans cette classe en analysant le fichier lib\site-packages\django\models\base.py. Outre que models.Model hérite de ModelBase au travers de six pour la rétrocompatibilité vers Python 2.7, cet héritage apporte notamment les fonctions save(), clean(), delete(), …​ En résumé, toutes les méthodes qui font quune instance est sait comment interagir avec la base de données.

Types de champs, clés étrangères et relations

Nous lavons 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 avec le moteur de base de données relationnelle sous-jacent. Dans le domaine des bases de données relationnelles, un point dattention est de toujours disposer dune clé primaire pour nos enregistrements. Si aucune clé primaire nest spécifiée, Django soccupera den ajouter une automatiquement et la nommera (par convention) id. Elle sera ainsi accessible autant par cette propriété que par la propriété pk.

Chaque champ du modèle est donc typé et lié, soit à une primitive, soit à une autre instance au travers de sa clé didentification.

Grâce à toutes ces informations, nous sommes en mesure de représenter facilement des livres liés à des catégories:

class Category(models.Model):
    name = models.CharField(max_length=255)

class Book(models.Model):
    author = models.CharField(max_length=255)
    title = models.CharField(max_length=255)
    category = models.ForeignKey(Category, on_delete=models.CASCADE)
  1. ForeignKey

  2. ManyToManyField

  3. OneToOneField

Dans les examples ci-dessus, nous avons vu les relations multiples (1-N), représentées par des clés étrangères (ForeignKey) dune classe A vers une classe B. Pour représenter dautres types de relations, il existe également les champs de type ManyToManyField, afin de représenter une relation N-N. Les champs de type OneToOneField, pour représenter une relation 1-1.

Dans notre modèle ci-dessus, nous navons jusquà présent eu besoin que des relations 1-N:

  1. La première entre les listes de souhaits et les souhaits;

  2. La seconde entre les souhaits et les parts.

# wish/models.py

class Wishlist(models.Model):
    pass


class Item(models.Model):
    wishlist = models.ForeignKey(Wishlist)

Depuis le code, à partir de linstance de la classe Item, on peut donc accéder à la liste en appelant la propriété wishlist de notre instance. A contrario, depuis une instance de type Wishlist, on peut accéder à tous les éléments liés grâce à <nom de la propriété>_set; ici item_set.

Lorsque vous déclarez une relation 1-1, 1-N ou N-N entre deux classes, vous pouvez ajouter lattribut related_name afin de nommer la relation inverse.

# wish/models.py

class Wishlist(models.Model):
    pass


class Item(models.Model):
    wishlist = models.ForeignKey(Wishlist, related_name='items')
Note
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 à lattribut related_name. Par facilité (et pas conventions), prenez lhabitude de toujours ajouter cet attribut. Votre modèle gagnera en cohérence et en lisibilité. Si cette relation inverse nest pas nécessaire, il est possible de lindiquer (par convention) au travers de lattribut related_name="+".

A partir de maintenant, nous pouvons accéder à nos propriétés de la manière suivante:

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

Metamodèle et introspection

Dautre part, chaque classe héritant de models.Model possède une propriété objects. Comme on la vu dans la section Jouons un peu avec la console, cette propriété permet daccéder aux objects persistants dans la base de données, au travers dun ModelManager.

En plus de cela, il faut bien tenir compte des propriétés Meta de la classe: si elle contient déjà un ordre par défaut, celui-ci sera pris en compte pour lensemble des requêtes effectuées sur cette classe.

class Wish(models.Model):
    name = models.CharField(max_length=255)

    class Meta:
        ordering = ('name',) (1)
  1. On définit un ordre par défaut, directement au niveau du modèle. Cela ne signifie pas quil ne sera pas possible de modifier cet ordre (la méthode order_by existe et peut être chaînée à nimporte quel queryset). Doù lintérêt de tester ce type de comportement, dans la mesure où un top 1 dans votre code pourrait être modifié simplement par cette petite information.

Pour sélectionner un objet au pif : return Category.objects.order_by("?").first()

Les propriétés de la classe Meta les plus utiles sont les suivates:

  • ordering pour spécifier un ordre de récupération spécifique.

  • verbose_name pour indiquer le nom à utiliser au singulier pour définir votre classe

  • verbose_name_plural, pour le pluriel.

  • contraints (Voir ici-), par exemple

    constraints = [ # constraints added
        models.CheckConstraint(check=models.Q(year_born__lte=datetime.date.today().year-18), name='will_be_of_age'),
    ]

Choix

Voir ici

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(choices=Zone.choices, default=Zone.ZONE_5, help_text="What was your best time on the marathon in last 2 years?") # this is new

Validateurs

Protocoles de langage (dunder methods)

The Python data model specifies a lot of specially named methods that can be overridden in your custom classes to provide them with additional syntax capabilities. You can recognize these methods by their specific naming conventions that wrap the method name with double underscores. Because of this, they are sometimes referred to as dunder methods. It is simply shorthand for double underscores.The most common and obvious example of such dunder methods is init(), which is used for class instance initialization:

class CustomUserClass:
    def __init__(self, initiatization_argument):
        ...

En fait, lintérêt concerne surtout la représentation de nos modèles, puisque chaque classe du modèle est représentée par la définition dun objet Python. Nous pouvons donc utiliser ces mêmes dunder methods (double-underscores methods) pour étoffer les protocoles du langage.

These methods, either alone or when defined in a specific combination, constitute the so-called language protocols. If we say that an object implements a specific language protocol, it means that it is compatible with a specific part of the Python language syntax. The following is a table of the most common protocols within the Python language.Protocol nameMethodsDescriptionCallable protocolcall()Allows objects to be called with parentheses:instance()Descriptor protocolsset(), get(), and del()Allows us to manipulate the attribute access pattern of classes (see the Descriptors section)Container protocolcontains()Allows us to test whether or not an object contains some value using the in keyword:value in instance Python in Comparison with Other LanguagesIterable protocoliter()Allows objects to be iterated using the forkeyword:for value in instance: …Sequence protocolgetitem(),len()Allows objects to be indexed with square bracket syntax and queried for length using a built-in function:item = instance[index]length = len(instance)Each operator available in Python has its own protocol and operator overloading happens by implementing the dunder methods of that protocol. Python provides over 50 overloadable operators that can be divided into five main groups:• Arithmetic operators • In-place assignment operators• Comparison operators• Identity operators• Bitwise operatorsThats a lot of protocols so we wont discuss all of them here. We will instead take a look at a practical example that will allow you to better understand how to implement operator overloading on your own

A full list of available dunder methods can be found in the Data model section of the official Python documentation available at https://docs.python.org/3/reference/datamodel.html. All operators are also exposed as ordinary functions in the operators module. The documentation of that module gives a good overview of Python operators. It can be found at https://docs.python.org/3.9/library/operator.html

The add() method is responsible for overloading the + (plus sign) operator and here it allows us to add two matrices together. Only matrices of the same dimensions can be added together. This is a fairly simple operation that involves adding all matrix elements one by one to form a new matrix.

The sub() method is responsible for overloading the (minus sign) operator that will be responsible for matrix subtraction. To subtract two matrices, we use a similar technique as in the operator:

def __sub__(self, other):
    if (len(self.rows) != len(other.rows) or len(self.rows[0]) != len(other.rows[0])):
        raise ValueError("Matrix dimensions don't match")
    return Matrix([[a - b for a, b in zip(a_row, b_row)] for a_row, b_row in zip(self.rows, other.rows)    ])

And the following is the last method we add to our class:

def __mul__(self, other):
    if not isinstance(other, Matrix):
        raise TypeError(f"Don't know how to multiply {type(other)} with Matrix")

    if len(self.rows[0]) != len(other.rows):
        raise ValueError("Matrix dimensions don't match")

    rows = [[0 for _ in other.rows[0]]  for _ in self.rows]

    for i in range(len (self.rows)):
        for j in range(len (other.rows[0])):
            for k in range(len (other.rows)):
                rows[i][j] += self.rows[i][k] * other.rows[k][j]

    return Matrix(rows)

The last overloaded operator is the most complex one. This is the * operator, which is implemented through the mul() method. In linear algebra, matrices dont have the same multiplication operation as real numbers. Two matrices can be multiplied if the first matrix has a number of columns equal to the number of rows of the second matrix. The result of that operation is a new matrix where each element is a dot product of the corresponding row of the first matrix and the corresponding column of the second matrix. Here weve built our own implementation of the matrix to present the idea of operators overloading. Although Python lacks a built-in type for matrices, you dont need to build them from scratch. The NumPy package is one of the best Python mathematical packages and among others provides native support for matrix algebra. You can easily obtain the NumPy package from PyPI

(Voir Expert Python Programming, 4th Edition, page 142-144)

Constructeurs

Si vous décidez de définir un constructeur sur votre modèle, ne surchargez pas la méthode init: créez plutôt une méthode static de type create(), en y associant les paramètres obligatoires ou souhaités:

class Wishlist(models.Model):

    @staticmethod
    def create(name, description):
        w = Wishlist()
        w.name = name
        w.description = description
        w.save()
        return w

class Item(models.Model):

    @staticmethod
    def create(name, description, wishlist):
        i = Item()
        i.name = name
        i.description = description
        i.wishlist = wishlist
        i.save()
        return i

Mieux encore: on pourrait passer par un ModelManager pour limiter le couplage; laccès à une information stockée en base de données ne se ferait dès lors quau travers de cette instance et pas directement au travers du modèle. De cette manière, on limite le couplage des classes et on centralise laccès.

class ItemManager(...):
    (de mémoire, je ne sais plus exactement :-))

Conclusion

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é dessais/erreurs, que de preuves de concept.

Une possibilité peut également être de cantonner Django à un framework