• 12 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 16/05/2024

Créez un schéma en base de données

 

Découvrez Doctrine

Nous avons un site avec une page d’accueil. Amélie est ravie, Sébastien est fier de vous, mais il vous attend au tournant ! Vous avez fait le plus simple, maintenant il va falloir aller plus loin et dynamiser notre application. La demande suivante d’Amélie demande de la préparation : vous allez mettre en place le listing des livres.

Bien entendu, qui dit affichage de liste dit données à afficher. Il va donc falloir commencer par se pencher sur le stockage de ces données et leur représentation dans notre code. Nous allons donc devoir mettre en place une base de données.

Avant même de déterminer en détail comment nous allons représenter nos données, il convient de savoir dans quel type de base de données nous allons les stocker. Et pour ça, nous allons devoir déterminer notre modèle conceptuel de données.

Examinons de nouveau le besoin. La demande d’Amélie est assez claire :

  • Le site doit permettre l’affichage d’une liste de livres, ainsi que l’affichage des détails d’un livre particulier.

  • Pour chaque livre, on doit pouvoir retrouver son titre, le nom de l’auteur ou des auteurs, une image de couverture sous forme d’URL, le numéro ISBN, la date de sortie, l’éditeur, le nombre de pages, un synopsis, son statut (emprunté ou non), et les commentaires.

  • Chaque commentaire doit comporter le nom et l’adresse e-mail de la personne qui l’a posté, la date de création, la date de publication, un statut (elle veut pouvoir modérer les commentaires), et bien entendu le commentaire lui-même.

  • Chaque nom d’éditeur doit être unique, et une recherche par éditeur sera implémentée dans un second temps.

  • Elle n'exclut pas de vous demander plus tard d’ajouter des pages dédiées à chaque auteur afin de lister quelques informations de base et tous ses livres disponibles à la médiathèque. Vous pouvez d’ores et déjà prévoir pour les auteurs de quoi stocker leur nom, date de naissance et éventuellement de décès, et leur nationalité.

Mis sous forme de schéma UML simplifié, cela nous donne à peu près ceci :

Schéma UML de la demande d'Amélie
Schéma UML de la demande d'Amélie

Il saute aux yeux assez rapidement que ces classes sont liées entre elles. Cela veut donc dire que vous allez devoir utiliser un SGBDR.

Nous n’allons pas faire un comparatif ici de tous les systèmes existants, mais Sébastien a un avis pour vous : pour faciliter les recherches dans du texte, PostgreSQL sera notre meilleur allié. Il va donc nous falloir une base de données PostgreSQL, et de quoi nous y connecter avec Symfony.

Symfony embarque quasi systématiquement un outil permettant de gérer la connexion et les requêtes dans les systèmes de base de données, y compris celle qui nous intéresse : Doctrine.

Connectez-vous à une base de données avec Doctrine

Doctrine est un projet large qui propose plusieurs librairies, pour gérer aussi bien les SGBDR en SQL que les stockages NoSQL. Nous allons nous concentrer sur les systèmes SQL, qui sont plus couramment utilisés.

De plus, Doctrine propose de nombreuses librairies pour vous aider dans tous les aspects de votre travail sur les bases de données avec PHP. Nous n’utiliserons pas tout sur ce projet, mais nous allons tout de même utiliser deux des briques principales de Doctrine : DBAL et ORM.

Ça y est, je ne comprends de nouveau plus, ça veut dire quoi, ça ? 

Bonne question ! DBAL signifie “DataBase Abstraction Layer”, c’est la couche logicielle qui nous sert à accéder de façon brute à la base de données. Si vous connaissez déjà l’extension de PHP PDO, voyez Doctrine DBAL comme un joli emballage pour faciliter l’accès à PDO.

ORM, quant à lui, signifie “Object Relational Mapper”. Cette brique opère à un niveau plus élevé que DBAL, et va nous permettre de faire le lien entre nos classes et objets PHP, et la base de données.

Positionnement de Doctrine entre les classes PHP et le SGBDR
Positionnement de Doctrine entre les classes PHP et le SGBDR

Concrètement, vous n’utiliserez jamais DBAL directement, mais il sera derrière toutes les requêtes que vous ferez sur votre base de données.

  1. Commençons par l’utiliser pour nous connecter à la base de données. Pour cela, nous allons utiliser ce qu’on appelle un DSN, pour Database Source Name.

  2. Ouvrez le fichier  .env  situé à la racine de votre projet. Il contient ce qu’on appelle des variables d’environnement. Ces variables, comme leur nom l’indique, dépendent de l’environnement dans lequel vous vous trouvez. C'est-à-dire que leur contenu sera différent selon que vous vous trouviez sur votre machine, en mode développement, ou sur le serveur de production.

D’accord, mais pourquoi est-ce qu’on aurait besoin de deux valeurs différentes selon l’environnement ?

Pour vous répondre, prenons l’exemple de la variable d’environnement qui nous intéresse. Vous devriez trouver ce bloc de code :

###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=15&charset=utf8"
###< doctrine/doctrine-bundle ###

Vous avez ici la variable d’environnement  DATABASE_URL  qui contient les paramètres de connexion à votre base de données. Nous n’allons pas nous connecter à la base de données de production pendant nos développements, ce serait trop risqué (imaginez que vous effaciez complètement la base par erreur). De même, nous n’utiliserons pas notre base de données de développement une fois le projet en production. La valeur de cette variable doit donc changer en fonction de l’environnement.

Oui mais là ce que je vois, c’est quelque chose qui ressemble beaucoup à une URL. C’est ça un DSN ?

Tout à fait ! Voici en fait comment se décompose la structure d’un DSN :

$dsn = "<plateforme>://<utilisateur>:<mot_de_passe>@<hote>:<port>/<nom_de_la_base>";

Comme vous pouvez le voir dans votre fichier   .env  , on vous propose plusieurs exemples pour le contenu de votre variable  DATABASE_URL  . Plusieurs sont précédés par le signe  #  : ils sont commentés (et ne sont donc pas pris en compte par le code de Doctrine) et un seul est actif. Nous allons donc devoir fournir une version qui nous convient. 

Avant cela, nous allons prendre une précaution : vous allez créer un fichier  .env.local  à la racine de votre projet. C’est dans celui-ci que vous mettrez la bonne version de la variable  DATABASE_URL  . En effet, le fichier  .env  est versionné par Git. Du coup, si vous utilisez GitHub par exemple, ce fichier sera mis en ligne tel quel et tout le monde verra votre mot de passe de base de données !

Revenons-en à notre fichier  .env.local  . Vous allez donc écrire dedans la ligne suivante :

DATABASE_URL="postgresql://<utilisateur>:<mot_de_passe>@127.0.0.1:5432/biblios?serverVersion=<version>&charset=utf8"

N’oubliez pas de remplacer les variables dans le DSN :

  • <utilisateur>  par le nom de l’utilisateur de la base de données ;

  • <mot_de_passe>  par son mot de passe ;

  • <version>  par le numéro de version de PostreSQL installé sur votre poste (si vous avez un doute, vous pouvez l’obtenir en utilisant la commande  postgres -V  dans votre terminal).

Et voilà, votre application est prête à se connecter !

Mais comment je sais si j’ai mis la bonne configuration ?

Vous pouvez simplement utiliser une commande Symfony pour ça. Ouvrez votre terminal et entrez la commande suivante :

symfony console doctrine:database:create –if-not-exists

Si tout se passe bien, vous devriez avoir un retour qui vous dit soit que la base de données existe déjà, soit qu’elle a été créée. Sinon, c’est effectivement que la configuration n’était pas la bonne. Vérifiez les paramètres dans le DSN et réessayez.

Vérification des paramètres dans le DSN
Vérification des paramètres dans le DSN

Découvrez les entités

Nous avons maintenant une base de données, et nous pouvons nous y connecter. Il va être temps de créer la structure de la base de données qui correspond à notre MCD. Pour rappel, voici à quoi il ressemble :

Rappel de la demande d'Amélie
Rappel de la demande d'Amélie

Nous n’avons pas encore créé de modèle physique de données (MPD) correspondant à ce MCP. Et nous n’en aurons pas besoin.

Une minute, on va construire une base de données sans savoir à quoi elle va ressembler ?

Ça peut paraître étrange, mais oui ! Sur de gros projets, vous voudrez certainement créer votre MPD malgré tout pour régler tous les détails vous-même, mais ici ce n’est pas la peine : nous allons demander à Doctrine de le faire pour nous.

De manière très concrète, nous allons créer ce qu’on appelle des entités. Ces entités sont en fait un mapping, que Doctrine va utiliser pour faire le lien entre nos classes PHP et la base de données. Ce mapping servira donc de base à Doctrine pour créer nos tables.

Positionnement des entités Doctrine pour faire le lien entre les classes PHP et le SGBDR
Positionnement des entités Doctrine pour faire le lien entre les classes PHP et la base de données

En clair, nous allons écrire nos classes PHP telles que décrites sur le MCD, y ajouter quelques informations, et Doctrine fera le reste. Magique, non ?

D’accord, ça a l’air super. Mais on fait comment, concrètement ?

Concrètement, nous allons maintenant voir comment créer une entité !

Créez une entité

Nous savons désormais tout ce qu’il faut pour créer nos propres entités. Une dernière chose nous manque : l’outil parfait. Vous vous rappelez de votre meilleur ami, le MakerBundle ? Eh bien oui, il embarque aussi une commande pour créer des entités !

  • La commande  make:entity  permet de créer rapidement des entités en étant sur du mapping généré.

  • Les propriétés générées le sont avec leurs accesseurs et mutateurs (méthodes  getXxx  et  setXxx  ).

  • Une propriété  id  est systématiquement générée sans avoir besoin de la demander à la commande, car un  id  est nécessaire dans chaque entité pour utiliser Doctrine.

  • Cet  id  n'a pas de méthode  setId  correspondante, car nous devons laisser Doctrine le gérer pour nous.

Retournez dans votre terminal et essayez d’ajouter une nouvelle propriété (si vous avez fermé le terminal, relancez la commande  make:entity  et donnez-lui à nouveau le nom d’entité  Book  , il vous proposera alors de compléter votre entité). Le Maker vous pose toutes les questions dont il a besoin pour connaître les détails des propriétés (et donc des futures colonnes de votre table) de votre classe.

Ajoutez les propriétés définies dans notre MCD. Sauf mention contraire, elles ne sont pas  nullable  .

Mais même quand je dis à Maker que ce n’est pas nullable, la propriété créée dans ma classe est marquée nullable et par défaut égale à false !

C’est normal. Depuis PHP 7.4, lorsque vous ajoutez un type à une propriété, celle-ci doit absolument être initialisée avec une valeur. Donc soit avoir une valeur par défaut, soit recevoir une valeur par le constructeur de la classe. Doctrine les crée donc comme valant  null  par défaut pour vous laisser mettre ce que vous voulez dedans. La colonne qui sera créée en base, elle, ne sera pas nullable.

Certaines autres propriétés risquent aussi de vous poser problème. Par exemple, ignorez pour l’instant les propriétés  authors  ,  comments  et  editor  que nous verrons ensuite.

Le  status  , marqué comme  enum  dans notre MCD, présente un problème que nous pouvons résoudre facilement. Ce statut sera représenté en PHP par une énumération, mais Maker ne vous propose pas de type correspondant. Commencez par créer la colonne malgré tout, comme s’il s’agissait d’une  string  .

Nous allons ensuite créer l’énumération PHP. Créez un nouveau dossier  Enum  dans  src  , et créez dedans le fichier  BookStatus.php  avec le contenu suivant :

<?php

namespace App\Enum;

enum BookStatus: string
{
    case Available = 'available';
    case Borrowed = 'borrowed';
    case Unavailable = 'unavailable';
    
    public function getLabel(): string
    {
        return match ($this) {
            self::Available => 'Disponible',
            self::Borrowed => 'Emprunté',
            self::Unavailable => 'Indisponible',
        };
    }
}

Nous avons ici créé une classe d’énumération, dont les instances ne pourront avoir comme valeur que  BookStatus::Available  ,  BookStatus::Borrowed   ou  BookStatus::Unavailable  . 

Ensuite, modifiez simplement le type de votre propriété  status  , le typage de retour de la méthode  getStatus  , et le type de l’argument  $status  de la méthode  setStatus()  dans  Book.php  :

<?php

class Book
{
    // ...
    #[ORM\Column(length: 255)]
    private ?BookStatus $status = null;
    // …
    
    public function getStatus(): ?BookStatus
    {
        // …
    }
    
    public function setStatus(BookStatus $status): static
    {
        // …
    }

Et… c’est tout. Encore une fois, Doctrine se chargera du reste.

Quand vous avez fini d’ajouter des propriétés avec Maker, appuyez une dernière fois sur la touche Entrée de votre clavier pour sortir de la commande. Vous avez une nouvelle classe, et l’entité qui va avec !

Et comment on fait une relation avec tout ça ? Parce que là je ne sais pas comment ajouter la propriété  editor  dans la classe Book.

Très bonne question. Prenons un exemple et ajoutons une nouvelle entité Editor pour représenter les auteurs de nos livres. Utilisez la commande  make:entity  pour ce faire. Référez-vous au MCD pour la liste complète des propriétés.

Maintenant, lancez à nouveau la commande  make:entity Book  . Ajoutez une propriété  editor  , et comme type, choisissez  relation  . Maker vous demande alors le nom de la classe à laquelle cette propriété doit correspondre, entrez donc  Editor  . Un tableau apparaît pour vous détailler les choix disponibles : 

Tableau pour détailler les choix de l'entité
Tableau pour détailler les choix de l'entité

La formulation est ici assez claire. On vous propose plusieurs options pour qualifier cette relation. Certaines choses méritent cependant qu’on s’y arrête.

En premier lieu, essayons de voir comment tout cela va être représenté en base de données. Chaque relation doit être matérialisée d’une façon spécifique :

  • ManyToOne : Ici, un objet A peut être lié à un objet B uniquement, mais chaque objet B peut être lié à plusieurs objets A. Exemple : un Book peut avoir un seul Editor, mais chaque Editor peut contenir plusieurs Books. Dans la base de données, avoir une colonne dans la table des éditeurs qui contiendrait tous les identifiants des livres liés serait impossible à maintenir. Nous aurons donc au contraire une colonne dans la table des livres qui référencera l'éditeur qui a publié ce livre. Et si plusieurs livres font référence au même éditeur, pas de problème. Le côté de cette relation sur lequel est définie la relation (la table des produits) est appelé “propriétaire”. Le côté propriétaire sera toujours le côté “Many” d’une relation de ce type. Ce terme sera important par la suite.

  • OneToMany : Il s’agit de la même relation que précédemment, mais observée depuis le côté non propriétaire. Cela signifie que notre relation est “bidirectionnelle” : en effet, on cherche à définir une relation du côté “One” (côté Editor, dans notre exemple), mais sa représentation en base de données sera du côté “Many”. Nous sommes donc obligés de la définir des deux côtés à la fois.

  • ManyToMany : Ici, un objet A peut référencer plusieurs objets B, mais un objet B peut aussi référencer plusieurs objets A. Par exemple, un même objet Author pourrait référencer plusieurs Books, et un Book pourrait faire référence à plusieurs objets Author suivant le nombre d’auteurs qui ont pris part à l’écriture. Ici, nous ne pouvons toujours pas avoir en base une colonne dans Author qui contiendrait tous les identifiants de Books, ni de colonne dans Book pour tous les Authors. Doctrine va donc créer pour nous une table de jointure. Cette table s’appelera  book_author  (ou  author_book  , peu importe) et ne contiendra que deux colonnes :  author_id  et  book_id  . Du coup, nous pouvons avoir plusieurs fois le même identifiant d’auteur, et plusieurs fois le même identifiant de livre. Seule la combinaison des deux doit être unique.

  • OneToOne : C’est la relation la plus simple, un objet A ne peut être lié qu’à un objet B, et un objet B ne peut être lié qu’à un objet A. Vous ne pouvez avoir qu’une seule carte d’identité, et votre carte d’identité ne peut appartenir qu’à vous, par exemple. Une simple colonne avec une clé étrangère dans l’une ou l’autre des tables (ou les deux) suffira amplement.

Dans notre cas, nous allons créer un  ManyToOne  puisque chaque livre a un seul éditeur et un éditeur peut éditer plusieurs livres. Entrez donc le bon type de relation dans votre terminal. Maker vous demande si cette propriété peut être nulle, répondez non. Il vous demande ensuite si vous souhaitez ajouter une propriété dans Editor pour accéder aux objets Book qui lui sont liés. Répondez oui, et il vous demande ensuite le nom que doit prendre cette propriété, en vous proposant  books  par défaut, ce qui est un choix tout à fait correct.

À ce moment-là, Maker vous pose une question un peu énigmatique.

Question du Maker sur l'activation du orphanRemoval
Question du Maker sur l'activation du orphanRemoval

On vous demande si vous souhaitez activer  orphanRemoval  sur cette relation, en précisant qu’un objet Book est orphelin quand il est retiré de son Editor.

Et c’est là l’enjeu de cette question : voulez-vous automatiquement retirer de la base de données un livre que vous avez dissocié de son éditeur, c’est-à-dire “voulez-vous autoriser un livre à ne pas avoir d’éditeur ?”. Si la réponse est non, activez l’orphanRemoval. C’est effectivement notre cas.

Nous avons terminé de définir cette relation, regardons ce que cela donne dans le code :

<?php
// src/Entity/Editor.php

#[ORM\Entity(repositoryClass: EditorRepository::class)]
class Editor
{
    // …
    #[ORM\OneToMany(mappedBy: editor, targetEntity: Book::class, orphanRemoval: true)]
    private Collection $books;
    // …
}

// src/Entity/Book.php

#[ORM\Entity(repositoryClass: BookRepository::class)]
class Book
{
    // …
    #[ORM\ManyToOne(inversedBy: 'books')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Editor $editor = null;
    // …
}

Et voilà ! Vous auriez très bien pu coder tout ça à la main, et vous pourrez même modifier les attributs à la main en cas de besoin. Mais c’est tout de même beaucoup plus facile comme ça, non ?

Représentez vos classes PHP en base de données

Vous avez maintenant deux entités. Mais si vous regardez en base de données, vous vous apercevrez vite que vous n’avez toujours aucune table. Il va falloir indiquer à Doctrine qu’il doit créer le schéma correspondant à vos entités dans la base de données.

Nous allons faire ceci au moyen de fichiers appelés Migrations. Ces fichiers sont créés par une librairie logicielle de Doctrine appelée… DoctrineMigrations. Ces migrations proposent beaucoup de fonctionnalités très intéressantes. 

Lorsque vous utilisez les migrations, tous vos scripts SQL qui apportent des modifications structurelles à votre base de données sont encapsulés dans des classes spécifiques, rangées dans le dossier  migrations  de votre projet. Cela a plusieurs avantages :

  • ces fichiers sont versionnés par Git, ce qui vous offre une grande traçabilité des modifications apportées à votre base de données ;

  • ces classes comportent une méthode  up  pour appliquer un changement en base de données, et une méthode  down  pour appliquer le changement inverse (et donc défaire ce que faisait la méthode  up  ) ;

  • ces fichiers peuvent être rejoués à l’infini, en  up  comme en  down  ;

  • Doctrine peut générer tout seul le script SQL qu’il doit mettre dans ces fichiers, en comparant l’état de vos fichiers PHP d’entités avec l’état de votre base de données.

C’est ce dernier point que nous allons mettre en œuvre tout de suite. Ouvrez votre terminal, et entrez la commande suivante :

symfony console make:migration

Si vos entités sont bien construites, vous devriez avoir ce résultat :

Résultat après construction des entités
Résultat après construction des entités

Vous pouvez noter que le nom du fichier qui a été créé par la commande comporte un timestamp correspondant à la date et à l’heure auxquelles vous avez lancé la commande. Ouvrez ce fichier.

<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240124171626 extends AbstractMigration
{
    public function getDescription(): string
    {
        return '';
    }
    
    public function up(Schema $schema): void
    {
        // this up() migration is auto-generated, please modify it to your needs
        $this->addSql('CREATE SEQUENCE book_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
        $this->addSql('CREATE SEQUENCE editor_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
        $this->addSql('CREATE TABLE book (id INT NOT NULL, editor_id INT NOT NULL, title VARCHAR(255) NOT NULL, isbn VARCHAR(255) NOT NULL, edited_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, plot TEXT NOT NULL, page_number INT NOT NULL, status VARCHAR(255) NOT NULL, PRIMARY KEY(id))');
        // …
    }
    
    public function down(Schema $schema): void
    {
        // this down() migration is auto-generated, please modify it to your needs
        $this->addSql('CREATE SCHEMA public');
        $this->addSql('DROP SEQUENCE book_id_seq CASCADE');
        // …
    }
}

Ne vous inquiétez pas, nous n’allons pas étudier tout ça en détail ici. Vous pouvez cependant noter que Doctrine a bien fait son travail et a généré une structure complète en fonction de vos entités. La plupart du temps, il a même deviné tout seul le type de chaque colonne en fonction du type de la propriété PHP sur laquelle il devait se baser.

Mais pourquoi ça parle d’une table  messenger_messages  ? J’ai pas créé d’entité de ce nom-là, moi !

Pas de panique. C’est une table créée automatiquement par Symfony pour son composant Messenger. Nous ne nous en servirons pas, mais elle ne vous gênera pas non plus, et si vous retirez ses instructions de création, Doctrine continuera de nous embêter avec. Laissez donc les lignes qui lui correspondent.

Il ne nous reste plus qu’à appliquer cette migration. Il existe une commande magique de Doctrine, qui va exécuter toutes les migrations que vous pourriez avoir en attente. Retournez donc dans votre terminal, et entrez :

symfony console doctrine:migrations:migrate

On vous demande une confirmation, et après avoir répondu oui, vous devriez avoir ce résultat :

Résultat après confirmation de la migration
Résultat après confirmation de la migration

Et voilà ! Vous pouvez vérifier dans votre base de données, le schéma a bien été créé.

À vous de jouer

Contexte

Il vous reste encore deux classes et entités à créer pour compléter le MCD :  Author  et  Comment  .

Consignes

Reportez-vous au MCD pour la liste complète des propriétés et construisez vos entités. Faites attention à la qualification des relations.

Vous noterez que la classe  Comment  comporte elle aussi un statut sous forme d’énumération. Celle-ci s’appellera  CommentStatus  et comportera les trois cas suivants :  Pending  ,  Published  et  Moderated  . N’hésitez pas à vous inspirer du  BookStatus  pour la classe, et pour implémenter une méthode  getLabel()  analogue à celle de  BookStatus  (elles nous serviront beaucoup par la suite).

Une fois ceci fait, vous générerez une migration que vous appliquerez.

En résumé

  • Doctrine est l’outil qui nous sert à gérer les bases de données relationnelles avec Symfony.

  • La configuration de doctrine se fait par un DSN (Database Source Name) dans le fichier  .env

  • Une entité est un mapping que Doctrine utilise pour faire le lien entre nos classes PHP et la base de données.

  • Nos classes et nos entités peuvent avoir des relations complexes.

  • MakerBundle permet de créer des entités de manière interactive, rapide et sûre.

  • Les Migrations sont le moyen privilégié d’appliquer des modifications structurelles à notre base de données.

Il va maintenant être temps de voir comment récolter des données à mettre dans cette base, ce que vous allons faire au chapitre suivant !

Exemple de certificat de réussite
Exemple de certificat de réussite