UE5 : Fondamentaux du développement web frontal, niveau 2

Séance 3 : Penser les données par un ORM: les modèles

  1. Installation et configuration de Flask-Sqlalchemy
    1. Pré-requis
    2. Introduction aux ORM
    3. Flask-SQLAlchemy
  2. Du modèle de données aux modèles SQLAlchemy
    1. Une requête simple pour s'assurer de la bonne connectivité avec la base
    2. Les modèles de base de données
      1. Représenter une table en modèle
      2. Poser des contraintes sur un modèle
      3. Quelques méthodes de classe utiles
    3. Une première requête avec l'ORM
  3. Les relations entre modèles
    1. One-to-many
    2. Maany-to-many

Installation et configuration de Flask-Sqlalchemy

Pré-requis

Dans le cadre de ce cours, nous utiliserons une base SQLite plutôt qu'un serveur MySQL ou PostGreSQL. Il est nécessaire d'avoir SQLite3 installé sur son poste:

Introduction aux ORM

Avec Python, l'ORM le plus utilisé est SQLAlchemy. Un ORM (Object Relational Mapping) est un ensemble de librairies qui permet, grâce à un langage de programmation (Python dans notre cas), d'interagir facilement avec les données grâce à des objets: l'ORM est une couche d'abstraction entre le code et les données. L'ORM, nous le verrons, apporte de nombreux avantages au développeur, mais aussi des inconvénients et certaines limites.

L'avantage certain de l'ORM est la réduction (parfois considérable) du nombre de lignes de code SQL à écrire. L'ORM peut aussi rendre la requête plus lisible, puisqu'elle tient généralement en une seule ligne de code. Modifier de moteur SQL est aussi très simple, l'ORM se chargera d'écrire la rtequête SQL comme il convient (par exemple, si la base est OracleSQL, un rownum sera utilisé là où les autres SGBD utilisent limit). Enfin, un autre avantage certain est que l'utilisation d'un ORM permet une varaibilisation plus facile du code: en cas de changeemnt de nom de table ou de champ, il est facile de changer une variable dans le code Python, plutôt que de modifier toutes les requêtes SQL concernées.

En revanche, dès lors que le modèle de données devient complexe, et que les opérations à effectuer sur les données dépassent le simple select ou delete sur une ligne particulière, l'ORM montre ses limites. Par exemple, effectuer de nombreuses jointures avec un ORM est extrêmement complexe à écrire (et long à exécuter finalement sur la base); alors qu'écrire directement la requête et l'intégrer dans le code est plus facile et plus rapide.

Si SQLAlchemy est très répandu avec Python, ce n'est pas le cas de tous les ORM. Attention donc aux ORM choisis, un changement de version peut casser tout notre code, alors que le SQL est stable et qu'une requête écrite en dure pourrait toujours s'exécuter.

Flask-SQLAlchemy

Flask-SQLAlchemy est l'adaptation pour Flask de SQLAlchemy. SQLAlchemy est requis pour permettre à FLask-SQLAlchemy de fonctionner, mais il est importé directement en effectuant la commande suivante dans l'environnement virtuel:

pip install flask_sqlalchemy
Collecting flask_sqlalchemy
  Downloading Flask_SQLAlchemy-3.0.2-py3-none-any.whl (24 kB)
Requirement already satisfied: Flask>=2.2 in c:\users\maxim\documents\formations\tnah\python_flask\coursm2tnah_flask_code\env\lib\site-packages (from flask_sqlalchemy) (2.2.2)
Collecting SQLAlchemy>=1.4.18
  Downloading SQLAlchemy-1.4.44-cp311-cp311-win_amd64.whl (1.6 MB)
     ---------------------------------------- 1.6/1.6 MB 2.3 MB/s eta 0:00:00
Requirement already satisfied: Werkzeug>=2.2.2 in c:\users\maxim\documents\formations\tnah\python_flask\coursm2tnah_flask_code\env\lib\site-packages (from Flask>=2.2->flask_sqlalchemy) (2.2.2)
Requirement already satisfied: Jinja2>=3.0 in c:\users\maxim\documents\formations\tnah\python_flask\coursm2tnah_flask_code\env\lib\site-packages (from Flask>=2.2->flask_sqlalchemy) (3.1.2)
Requirement already satisfied: itsdangerous>=2.0 in c:\users\maxim\documents\formations\tnah\python_flask\coursm2tnah_flask_code\env\lib\site-packages (from Flask>=2.2->flask_sqlalchemy) (2.1.2)
Requirement already satisfied: click>=8.0 in c:\users\maxim\documents\formations\tnah\python_flask\coursm2tnah_flask_code\env\lib\site-packages (from Flask>=2.2->flask_sqlalchemy) (8.1.3)
Collecting greenlet!=0.4.17
  Downloading greenlet-2.0.1-cp311-cp311-win_amd64.whl (191 kB)
     -------------------------------------- 191.2/191.2 kB 5.8 MB/s eta 0:00:00
Requirement already satisfied: colorama in c:\users\maxim\documents\formations\tnah\python_flask\coursm2tnah_flask_code\env\lib\site-packages (from click>=8.0->Flask>=2.2->flask_sqlalchemy) (0.4.6)
Requirement already satisfied: MarkupSafe>=2.0 in c:\users\maxim\documents\formations\tnah\python_flask\coursm2tnah_flask_code\env\lib\site-packages (from Jinja2>=3.0->Flask>=2.2->flask_sqlalchemy) (2.1.1)
Installing collected packages: greenlet, SQLAlchemy, flask_sqlalchemy
Successfully installed SQLAlchemy-1.4.44 flask_sqlalchemy-3.0.2 greenlet-2.0.1

La connection de Flask-SQLAlchemy à l'application Flask est très simple:

#app.py

...
from flask_sqlalchemy import SQLAlchemy
...
# app.config['SQLALCHEMY_DATABASE_URI'] = Config().SQLALCHEMY_DATABASE_URI

db = SQLAlchemy(app)
...
from .routes import generales

Comme nous pouvons le remarquer, il est nécessaire de créer une nouvelle variable de contexte dans la classe Config(), sans oublier de l'ajouter dans le fichier .env. Quelques remarques sur la valeur à donner à cette variable de contexte SQLALCHEMY_DATABASE_URI:

Commentons le code de app.py:

Code concerné: Seance3/flask_sqlalchemy

Du modèle de données aux modèles SQLAlchemy

Le modèle de données physique de notre base de données sur le Factbook est le suivant: h:300

Une requête simple pour s'assurer de la bonne connectivité avec la base

SQLalchemy permet d'effectuer des requêtes SQL sans utiliser les possibilités de l'ORM. Pour cela, on insère la requête dans le code suivant; pour les besoins de l'exercice nous jouerons une requête sur une table puis nous afficherons les résultats avec un print():

#generales.py /pays

print(db.session.execute("SELECT * FROM country LIMIT 2").fetchall())

Quelques commentaires:

Le résultat de ce code doit être le suivant

[("<p>Algeria has known many empires and dynasties starting with the ancient Numidians (3rd century B.C.), Phoenicians, Carthaginians, Romans, Vandals,  ... (
2790 characters truncated) ... arbon revenues to fund the government and finance the large subsidies for the population has fallen under stress because of dec
lining oil prices.</p>", 'ag', 'Algeria', 'sovereign'), ("From the late 14th to the mid 19th century a Kingdom of Kongo stretched across central Africa from p
resent-day northern Angola into the current Congo ... (1141 characters truncated) ...  He pushed through a new constitution in 2010. Joao LOURENCO was elected
 president in August 2017 and became president of the MPLA in September 2018.", 'ao', 'Angola', 'sovereign')]
127.0.0.1 - - [27/Nov/2022 17:18:40] "GET /pays HTTP/1.1" 200 -

Si un résultat similaire est obtenu, alors la base de données est correctement branchée; dans le cas contraire, vérifier le chemin donné en variable SQLALCHEMY_DATABASE_URI.

Code concerné: Seance3/premiere_requete

Les modèles de base de données

Représenter une table en modèle

Plutôt que d'effectuer des requêtes SQL à la main, l'ORM propose de les générer à notre place. Pour cela, il est nécessaire d'indiquer à l'ORM une représentation de nos données. Les données conservées en base sont alors représentées en une collection d'objets (de classes), ce qui est nommé modèles de base de données. La couche ORM va alors effectuer le mapping modèle>table elle-même.

Chaque table est représentée par un modèle, qui est lui-même une classe Python dérivée de SQLAlchemy(app).Model qui porte le même nom que la table (l'ORM est insensible à la casse pour le nom de table; par convention, il est préférable de nommer chaque classe en camelCase). Les modèles sont stockés dans un au plusieurs fichiers Python dans le module models.

# models/factbook.py
from ..app import app, db

class Country(db.Model):
    ...

La déclaration des champs d'une table se fait sous la forme suivante : nom = db.Colmun(type de colonne, paramètres supplémentaires). Il est important d'indiquer le type du champ, la documentation de SQLAlchemy propose les suivants:

# models/factbook.py
from ..app import app, db

class Country(db.Model):
    id = db.Column(db.String(10))
    Introduction = db.Column(db.Text)
    name = db.Column(db.String(500))
    type = db.Column(db.String(100))

Note: dans le jeu d'exemple du Factbook, les données sont sales; ainsi, les champs Integer n'ont pas pu être initilisés en base et se retrouvent en base. Avec un véritable jeu de données, il convient évidemment de typer correctement les colonnes des tables, et par conséquent les objets des modèles.

Note 2: dans le code lié à cette partie du cours, seuls les modèles des tables qui ne sont pas des tables de relations many-to-many sont converties en modèles. Voir la partie suivante pour les jointures.

Code concerné: Seance3/modeles ; ce code n'est pas fonctionnel car les contraintes déclarées en base de données ne sont pas déclarées dans nos modèles

Poser des contraintes sur un modèle

SQLAlchemy permet de déclarer les contraintes présentes dans la base de données. Ainsi, le modèle prend en charge les contraintes en amont de la transaction relationnelle: ce sera alors SQLAlchemy qui remontera une erreur, au lieu de la base de données. Cette partie va aborder les contraintes SQLAlchemy, à l'exception des clés étrangères (abordées dans la partie sur les relations entre modèles (les jointures)).

# models/factbook.py

class Country(db.Model):
    id = db.Column(db.String(10), primary_key=True)
    Introduction = db.Column(db.Text)
    name = db.Column(db.String(500))
    type = db.Column(db.String(100))
# models/factbook.py

class Country(db.Model):
    id = db.Column(db.String(10), primary_key=True)
    Introduction = db.Column(db.Text)
    name = db.Column(db.String(500), unique=True)
    type = db.Column(db.String(100))
# models/factbook.py

class Country(db.Model):
    id = db.Column(db.String(10), primary_key=True)
    Introduction = db.Column(db.Text)
    name = db.Column(db.String(500), unique=True, nullable=False)
    type = db.Column(db.String(100))
Quelques attributs et méthodes de classe utiles

En plus de déclarer les champs de chaque table, il est possible d'apporter plus de précisions dans les modèles concernant la table elle-même:

# routes/generales.py pays

...
from ..models.Country import Country
...
print(Country.__table__)
...

Enfin, certaines méthodes des classes représentant nos tables sont communes dans Flask-SQLAlchemy. C'est le cas de __repr__(self). Elle permet d'indiquer la forme dans laquelle retourner les objets de la classe. Cette méthode est très utile pour le débuggage.

# models/factbook.py

class Country(db.Model):
    __tablename__ = "country"

    id = db.Column(db.String(10), primary_key=True)
    Introduction = db.Column(db.Text)
    name = db.Column(db.String(500), unique=True, nullable=False)
    type = db.Column(db.String(100))

    def __repr__(self):
        return '<Country %r>' % (self.name)

Code concerné: Seance3/contraintes ; ce code n'est pas fonctionnel (pour le faire fonctionner, supprimer toutes les classes sauf Country())

Une première requête avec l'ORM

Maintenant que notre base de données est branchée à l'application, et que les modèles sont créés, nous pouvons afficher les données de la table country dans la page /pays. Pour cela, il faut:

h:300

#routes/generales.py

...
@app.route("/pays")
def pays():
    donnees = []
    for country in Country.query.all():
        donnees.append({
            "nom": country.name,
            "capitale":"inconnu",
            "continent":"inconnu"
        })
    
    return render_template("pages/tous_pays.html", donnees=donnees, sous_titre="Tous les pays")

Commentaires:

NB: pour les besoins de cet exemple, toutes les classes autres que Country() ont été supprimées du modèle

Code concerné: Seance3/requete_orm

Les relations entre modèles

Il existe deux grands types de relations entre tables de la base de données:

Ces deux types de relations sont pris en charge simplement par SQLALchemy.

One-to-many

Partons de la relation entre la table country et la table area et supposons qu'il est possible pour un pays d'avoir plusieurs aires. Alors il nous faut lier ces deux tables dans la base relationnelle (par une clé étrangère posée dans area) et donc les deux classes Country() et Area(). Pour ce faire, SQLAlchemy offre plusieurs fonctions et propriétés qui permettent de le déclarer.

Théoriquement, la syntaxe pour lier deux tables est la suivante :

class A(db.Model):
   cle_primaire = db.Column(db.String(100), primary_key=True)
   un_champ_de_la_table = db.Column(db.String(100))

   propriete_de_relation = db.relationship(
        "classe_liee",
        backref="classes_liees",
        lazy="dynamic"
    )
class classe_liee(db.Model):
   un_champ_de_la_table = db.Column(db.String(100))
   
   a_id = db.Column(db.String(100), db.ForeignKey('a.cle_primaire'))

Commentaires:

Reprenons l'exemple de country et area, les classes deviennent alors:

#models.factbook.py

...
class Country(db.Model):
    __tablename__ = "country"
    id = db.Column(db.String(10), primary_key=True)
    ...

    # propriétés de relation
    areas = db.relationship(
        'Area', 
        backref='areas', 
        lazy=True
    )

    def __repr__(self):
        return '<Country %r>' % (self.name) 

class Area(db.Model):
    __tablename__ = "area"
   ...
    total = db.Column(db.String(100), primary_key=True)
    ...

    # clés étrangères
    id = db.Column(
        db.String(100),  
        db.ForeignKey('country.id')
    )

    def __repr__(self):
        return '<Area %r>' % (self.total) 

Vérifions maintenant que la relation est correctement établie et remonte les objets Area lorsque l'on demande les pays:

# routes/generales.py /pays

for country in Country.query.all():
        ...
        print(country.areas)
return ...

Commentaires:

...
[<Area '510.072 million sq km'>]
...

Code concerné: Seance3/one_to_many

Many-to-many

Dans une relation many-to-many, une table de relation s'intercale entre les deux tables principales de la relation. Cette table de relation est physiquement présente dans la base de données, et doit être déclarée dans les classes du modèle. C'est le cas pour les relations des pays vers les map et les resources, mais aussi pour la relation country_country.

Reprenons la syntaxe d'un point de vue théorique:


table_de_relation_a_vers_b = db.Table(
    "table_de_relation_a_vers_b",
    db.Column('a_id', db.String(100), db.ForeignKey('a.id'), primary_key=True),
    db.Column('b_id', db.String(100), db.ForeignKey('b.id'), primary_key=True)
)


class A(db.Model):
   id = db.Column(db.String(100), primary_key=True)
   un_champ_de_la_table = db.Column(db.String(100))

   bs = db.relationship(
        'B', 
        secondary=table_de_relation_a_vers_b, 
        backref="bs"
    )

class B(db.Model):
   id = db.Column(db.String(100), primary_key=True)
   un_champ_de_la_table = db.Column(db.String(100))

Commentaires:

Reprenons à nouveau un exemple dans le Factbook avec la relation entre country et map:

#models/factbook.py 

country_map = db.Table(
    "country_map",
    db.Column('id', db.String(100), db.ForeignKey('country.id'), primary_key=True),
    db.Column('map_ref', db.String(100), db.ForeignKey('map.name'), primary_key=True)
)

class Country(db.Model):
    __tablename__ = "country"
    id = db.Column(db.String(10), primary_key=True)
    ...
    # propriétés de relation
    ...
    maps = db.relationship(
        'Map', 
        secondary=country_map, 
        backref="maps"
    )
   ...

class Map(db.Model):
    __tablename__ = "map"
    ...
    name = db.Column(db.Text, primary_key=True)
   ...

Pour obtenir tous les noms de la première map liée à chaque pays, il suffit de jouer:

# routes/generales.py /pays

for country in Country.query.all():
        ...
        print(country.maps[0].name)
return ...
...
Asia
Political Map of the World
Asia
Asia
Asia
Physical Map of the World
...

Code concerné: Seance3/many_to_many