Architecture(s) et
application(s) Web

CSC4101 - Accès aux données avec l'ORM Doctrine

01/09/2024

Plan du cours

  • CM 1-2
    • Introduction du module
    • Langage PHP
    • Accès aux données avec l’ORM Doctrine
    • Concepts généraux du Web, architecture 3 couches
    • Histoire succincte de la Toile

Plan de la séance

Objectifs de cette séquence :

  1. Principe ORM Doctrine
  2. Coder les classes
  3. Programmer l'accès à la base de données
  4. Outils du programmeur

Pourquoi une base de données ?

Exécution typique d’une application Web

  1. L’application se lance (arrivée requête HTTP)
    • Charge des données en mémoire
  2. Elle répond aux requêtes (envoi réponse HTTP)
    • Impact sur données
  3. L’application s’arrête
    • Enregistre les données mises à jour

Mais aussi interaction entre utilisateurs sur données partagées

Programmer :

  • Modèle de données en mémoire (à chaud) :

    programmation objet

  • Modèle de données en base de données (à froid, persistant) :

    base de données relationnelle (SGBD, SQL)

Rôle d’un ORM

ORM Object Relational Mapper

  • Principe ORM
    • Manipuler les données de l’application via des classes / objets
    • Implémentation du Modèle de données applicatif (en mémoire)
    • Persistence dans un SGBD
    • Conversion d’un modèle objet en un modèle relationnel
mapping_single_entity.png
Figure 1 : Conversion objet - relationnel

Doctrine, l’ORM standard en PHP

doctrine-logo.png

https://www.doctrine-project.org/projects/orm.html

  • composant standard applis PHP
  • bien intégré avec Symfony :
    • gestion modèle de données
    • intégration avec formulaires saisie données
    • assistant génération de code dans Symfony

Concevoir les classes du modèle

Conception orientée objet

  • Classes (PHP)
  • Propriétés mono-valuées des classes :

    types de bases + références à des instances d’autres classes

  • Propriétés multi-valués :

    Collections d’objets (ou de références d’objets)

Exemple modèle de données objet Todo

Modéliser des tâches : classe Todo

Propriétés :

  • title : chaîne
  • completed : booléen
  • created, updated : dates
class Todo
{
        private string $title;
        private bool $completed;
        private DateTime $created;
        private DateTime $updated;

Modéliser des projets : classe Project

Propriétés :

  • title : chaîne
  • description : chaîne
class Project
{
        private string $title;
        private string $description;

Association 1-n entre Project et Todo

  • les tâches d’un projet (Project::todos)

    class Project
    {
            private string $title;
            private description $completed;
    
            private array $todos = array();
    
  • le projet d’une tâche (Todo::project)

    class Todo
    {
            private string $title;
            private bool $completed;
            private DateTime $created;
            private DateTime $updated;
    
            private Project $project;
    

Raffiner

Quelle est la « force » de l’association ?

  • association : tâches sans projet possibles ?
  • composition : pas de tâche sans projet ?

Pourra être approfondi dans CSC4102 « Introduction au Génie Logiciel Orienté Objet »

Vous êtes en terrain connu

Vous maîtrisez déjà :

  • Conception du modèle des données en Objet (comme découvert en CSC3101)
  • Programmation objet en PHP (assez proche de Java, en fait, même si pas compilé) :
    • références
    • collections

À l’exécution, sous le capot : génère des requêtes SQL dans SGBD relationnel (appris en CSC3601)… mais pas besoin de les programmer

Utiliser l’ORM

  • Ne pas écrire les requêtes SQL de chargement / modification
  • Programmer en objet avec Doctrine
  • L’ORM (Object Relational Mapper) détecte les données nouvelles ou modifiées et génère le SQL sous le capot

Oublier SQL ?

  • Pas toujours si simple
  • Réviser un peu CSC3601 « Modélisation, bases de données et systèmes d’information » ?

Comprendre pour debugger si tout ne marche pas comme prévu automagiquement.

Exemple : références traduites en clés étrangères

mapping_relations.png
Figure 2 : Traduction des références en clés étrangères

Objectifs de cette séquence

Objectifs de cette séquence :

  1. Principe ORM Doctrine
  2. Coder les classes
  3. Programmer l'accès à la base de données
  4. Outils du programmeur

Codage des entités du modèle de données

Emplacement du code PHP

Classe de l’entité Todo :

  • classe PHP Todo
  • fichier source : src/Entity/Todo.php
  • espace de nommage : module App\Entity\Todo

Classe PHP « naïve »

class Todo
{
    private string $title;
    private bool $completed;

    public function getTitle() {
      // ...
    public function setTitle($title) {
      // ...
// ...

Pas de typage des propriétés, en « PHP basique », mais possible en PHP moderne (et objet).

Classe PHP documentée

Docblocks PHP « standard » (sans ORM) :

class Todo {
        /**
         * @var string task title
         */
        private string $title;

        /**
         * @var bool Is the task completed/finished.
         */
        private bool $completed;

}

Introduction de la persistance avec Doctrine

Attributs PHP (8 +)

Ajout de méta-données pour Doctrine

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Todo
{
        #[ORM\Id, ORM\GeneratedValue, ORM\Column]
        private int $id;

        #[ORM\Column(length: 255, nullable: true)]
        private string $title;

        #[ORM\ManyToOne(targetEntity: Project::class,
                        inversedBy: 'todos')]
        private Project $project;
        //...
}
#[ORM\Entity]
class Project
{
    #[ORM\Id, ORM\GeneratedValue]
    private int $id;

    #[ORM\Column(type: "text", nullable: true)]
    private string description;

    #[ORM\OneToMany(targetEntity: Todo::Class, mappedBy: 'project')]
    private $todos;
    //...
}

Générateur de code make:entity

Asssistant générateur de code : make:entity

Abus fortement recommandé !

Utiliser les classes du modèle de données

Programmer le chargement en mémoire

Le repository d’instances

On gère le chargement des données grâce à un « générateur d’objets » (le Repository), associé à une classe Doctrine.

use Doctrine\ORM\Mapping as ORM;

use App\Repository\TodoRepository;

#[ORM\Entity(repositoryClass: TodoRepository::class)]
class Todo
{
        #[ORM\Id, ORM\GeneratedValue, ORM\Column]
        private int $id;

Chargement d’une collection d’instances (findAll())

Utilisation pour charger toutes les instances de Todo :

use App\Entity\Todo;
//...
        protected function execute()
        {
                // récupère une liste toutes les instances de Todo
                $todos = $this->todoRepository->findAll();

                foreach($todos as $todo)
                {
                        //...
                }

        }

Accès au repository de Doctrine

use App\Entity\Todo;
use App\Repository\TodoRepository;
//...
class ListTodosCommand extends Command  {
    /**
     *  @var TodoRepository data access repository
     */
    private $todoRepository;

    public function __construct(ManagerRegistry $doctrineManager) {
        $this->todoRepository = $doctrineManager
                              ->getRepository(Todo::class);
        parent::__construct();
    }

    protected function execute(): int {
        // fetches all instances of class Todo from the DB
        $todos = $this->todoRepository->findAll();
        // ...

Chargement depuis la base de données (find...())

Chargement d’un seul objet depuis la base de données.

3 variantes :

  • par identifiant
  • par critères de sélection
  • par un valeur d’une propriété (raccourci)

Encore grâce à la même classe repository.

Chargement via l’identifiant : find($id)

$todo = $this->todoRepository->find($id);

génère, via Doctrine :

SELECT * FROM ... WHERE ID=[$id]

Attention : les identifiants sont uniques, mais un détail d’implémentation (valeur générée par le SGBD).

Chargement par sélection sur des propriétés (findOneBy())

Chargement d’une instance de Todo, étant donnés un titre et un état terminé (extrait de ShowTodoCommand.php) :

$todo = $this->todoRepository->findOneBy(
    ['title' => $title,
     'completed' => $completed]);

génère une requête SQL style :

SELECT ... FROM todo
  WHERE title = [$title] 
  AND completed = [$completed] 
  LIMIT 1

Sélection via findBy… suivi du nom de propriété

Méthodes findBy* nommées d’après les propriétés de la classe (magic finders) :

Exemple :

$todos = $this->todoRepository->findByCompleted(false);

donne :

..
  WHERE completed = 0;

Méthodes spécifiques de votre modèle applicatif

Les classes repository sont générées avec des fonctionnalités basiques.

Si besoin, on complète le repository pour ajouter des requêtes spécifiques à notre contexte applicatif :

class TodoRepository extends ServiceEntityRepository
{
        //...
        public function findLatest($page)
        {
                //...
        }

Création de la base de tests

Attention : en dév ou en prod

Outils à utiliser dans env. de développement !

Base existante, ou base générée

Qui gère la base de données ?

  • brancher Symfony sur une base existante
  • ou générer une base de données à partir du modèle de données de notre application Symfony

Dans les deux cas Doctrine fait le job

Dans notre contexte, on est dans le second cas.

Génération d’une BD à partir du code

  • Recherche des attributs Doctrine ORM dans code des classes PHP
  • Génération requêtes SQL de création du schéma (SQL /modeling language)
    • Spécificités SGBD cible (SQLite, PostgreSQL, MariaDB, …)
CREATE TABLE todo (
       id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
       title VARCHAR(255) NOT NULL,
       completed BOOLEAN NOT NULL);

Commandes de re-génération de la base de données

  1. Configurer quelle base de données cible (SQLite, MySQL, PostGreSQL, …) : variable dans le fichier .env
  2. Exécuter :

    symfony console doctrine:database:create
    symfony console doctrine:schema:create
    

La base de données (fichier SQLite) est créée dans le répertoire courant, avec des tables vides.

Structure du schéma

Voir le schéma de données généré dans la base :

$ bin/console doctrine:schema:create --dump-sql

Exemple :

CREATE TABLE project (
  id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
  title VARCHAR(255) NOT NULL,
  description CLOB DEFAULT NULL);

CREATE TABLE todo (
  tid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
  project_id INTEGER DEFAULT NULL,
  title CLOB DEFAULT NULL,
  completed BOOLEAN NOT NULL,
  -- ...
  CONSTRAINT FK_CD826255166D1F9C FOREIGN KEY (project_id) 
    REFERENCES project (id) NOT DEFERRABLE INITIALLY IMMEDIATE);

...

Relations m-n ManyToMany

Exemple : étiquettes (Tag) sur des tâches (Todo)

CREATE TABLE todo (
       id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
...);
CREATE TABLE tag (
       id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
...);

-- Génération d'une table de relation
CREATE TABLE todo_tag (
       todo_id INTEGER NOT NULL,
       tag_id INTEGER NOT NULL,
       PRIMARY KEY(todo_id, tag_id),
       CONSTRAINT FK_D767A0BAEA1EBC33
                  FOREIGN KEY (todo_id)
                  REFERENCES todo (id),
       CONSTRAINT FK_D767A0BABAD26311
                  FOREIGN KEY (tag_id)
                  REFERENCES tag (id) );

Programmer la modifications des données

Objet + ORM

Sauvegarder les ajouts / suppressions / modifications

Deux étapes :

  1. la création, modification ou suppression d’instances, en mémoire
  2. la synchronisation entre le nouvel état obtenu et le contenu de la BD

C’est le rôle de l’entity manager (em) de l’ORM Doctrine d’effectuer cette synchronisation.

Création et sauvegarde d’une nouvelle instance

public function createAction(ManagerRegistry $doctrine)
{
        $project = new Project();
        $project->setTitle('CSC4101');
        $project->setDescription("Architectures et applications Web");

        // entity manager
        $entityManager= $doctrine->getManager();

        // indique à Doctrine que vous aimeriez éventuellement
        // sauvegarder ce Projet (mais pas encore de requête)
        $entityManager->persist($project);

        // exécute effectivement la sauvegarde (ex. la requête INSERT)
        $entityManager->flush();

        // L'identifiant est enfin renseigné (généré par le SGBD)
        return new Response("Sauvegardé le projet d'id ".
                                                $project->getId());
}

setter pour collection …ToMany

class Project
{
        #[ORM\OneToMany(targetEntity: Todo::Class, mappedBy: 'project')]
        private $todos;

        public function addTodo(Todo $todo) {
                if (!$this->todos->contains($todo)) {
                        $this->todos->add($todo);
                        $todo->setProject($this);
                }
                //...
        }

        public function removeTodo(Todo $todo) {
                if ($this->todos->contains($todo)) {
                        $this->todos->removeElement($todo);
                        // set the owning side to null
                        // (unless already changed)
                        if ($todo->getProject() === $this) {
                                $todo->setProject(null);
                        }
                }

Qui sauvegarder à l’ajout, pour les entités liées ?

OneToMany entre Project et Todo

class Project
{
        public function addTodo(Todo $todo): self
        {
                if (!$this->todos->contains($todo)) {
                        $this->todos->add($todo);
                        $todo->setProject($this);
                }
                return $this;
        }

Attention : qui est modifié (pour le persist() ultérieur) ?

Le nouveau Todo !

mapping_relations.png
Figure 3 : Rappel du mapping d’une référence 1-N

Suppression pour les entités liées

Code généré par l’assistant make:entity :

class Project
{
        public function removeTodo(Todo $todo): self
        {
                if ($this->todos->contains($todo)) {
                        $this->todos->removeElement($todo);
                        // set the owning side to null
                        // (unless already changed)
                        if ($todo->getProject() === $this) {
                                $todo->setProject(null);
                        }
                }
                return $this;
        }

Persist ?

Possibilité gestion automatique des associations

Propagation du persist() en cascade

Propagation en cascade :

#[OneToMany(... cascade: ['persist', 'remove'] ...)]
$toto = new Todo();
$project->addTodo($todo)

$entityManager->persist($project);

Sauvegarde de ses todos modifiés

Suppression des instances orphelines

orphanRemoval :

#[OneToMany(... cascade: ['persist'], orphanRemoval: true)]
$todo = ...
$todo->setProject(null);

$entityManager->persist($todo);

Suppression en base

En cas de doute : vérifier les requêtes générées (dans l’outil Doctrine de la barre d’outils Symfony, ou dans les logs)

Gérer des données de tests

Initialiser la base avec données de tests

Coder une classe utilitaire DataFixtures pour Doctrine

Exemple :

  • Chargement dans la base de données :

    private function loadProjects(ObjectManager $manager)
    {
            foreach ($this->getProjectsData() as [$title, $description]) {
                    $project = new Project();
                    $project->setTitle($title);
                    $project->setDescription($description);
                    $manager->persist($project);
            }
            $manager->flush();
    }
    
  • Définition des données dans un générateur :

    private function getProjectsData()
    {
            // project = [title, description];
            yield ['CSC4101', "Architectures et applications Web"];
            yield ['CSC4102', "Introduction au Génie Logiciel Orienté Objet"];
    }
    

Lancer le chargement depuis la ligne de commande

À refaire à chaque recréation de la base de données dans l’environnement de développement :

$ symfony console doctrine:fixtures:load
Careful, database will be purged. Do you want to continue y/N ?y
  > purging database
  > loading App\DataFixtures\ProjectFixtures

</orm>

Take Away

  • ORM : Entités : classes, objets -> schéma relationnel
  • ORM : Chargement des objets en mémoire
  • Gestion des données liées dans les associations
  • Programmer les modifications synchronisées dans la BD
  • Outils de génération de base de données relationnelle
  • Outil de données de tests Fixtures

Postface

Crédits illustrations et vidéos

  • illustrations mapping Doctrine empruntées à la documentation Symfony

Copyright

  • Document propriété de ses auteurs et de Télécom SudParis (sauf exceptions explicitement mentionnées).
  • Réservé à l’utilisation pour la formation initiale à Télécom SudParis.