TP n°2 - Manipuler les données avec Doctrine
Programmer en PHP des modifications dans la base de données

Table des matières

1. Objectifs de cette séquence

Cette séquence de travail a pour objectif de compléter la découverte des fonctionnalités du module Doctrine pour la gestion du modèle de données en PHP.

La séance de travail précédente a permis de se familiariser avec l’environnement de travail du développeur Symfony, et avec le composant d’accès aux données Doctrine.

On a étudié pour l’instant le fonctionnement de l’application « fil-rouge » ToDo sur la partie la consultation de la base de données.

On va maintenant lui ajouter des fonctions permettant de modifier des données, dans une première partie de la séance.

À l’issue de cette séance, on maîtrisera les outils de base pour gérer la persistence en base de données en PHP objet avec Doctrine. Cela permettra de développer les fondements d’une application Web Symfony typique, pour pouvoir appliquer tout cela au projet.

2. Étape 1 : Mise en place de la séance

2.1. TODO Préparation du répertoire de travail

Vous allez dupliquer l’arborescence du projet PHP obtenu à la fin de la séance précédente, pour travailler sur une nouvelle version.

Vous pourrez ainsi faire évoluer le code, et revenir en arrière si besoin, en comparant l’état à la fin de la séance prédente, avec l’état courant.

  1. Effectuez les opérations suivantes pour dupliquer le dossier :

    cd "$HOME/CSC4101"
    cp -r tp-01 tp-02
    cd tp-02
    cd todo-app
    
  2. Ensuite, dans ce nouveau projet, on va réinitialiser les rouages du cadriciel Symfony avec Composer :

    cd "$HOME/CSC4101/tp-02/todo-app"
    rm -fr composer.lock symfony.lock var/cache/ vendor/ .project
    symfony composer install
    

    Confirmez la génération de fichiers pour Docker (on ne s’en servira pas tout de suite, mais pourquoi ne pas les avoir… le rôle de Docker ne sera pas évoqué en cours, mais vous pouvez en parler à vos encadrants de TP).

Cette étape est nécessaire car Symfony s’appuie sur une mécanique sophistiquée de mise en cache du code de l’application (dans var/cache/), pour assurer des performances maximales. Malheureusement, si on duplique un projet, une partie de ce cache a tendance à devenir incohérente (présence de chemins d’accès « en dur », etc.).

Il est donc préférable de réinitialiser le projet radicalement : à un état le plus « propre » possible. On fait ensuite réinstaller par Composer les bibliothèques du cadriciel Symfony (installées dans vendor/), et reconfigurer certains fichiers de configuration associés.

La documentation Symfony préconise d’utiliser symfony console cache:clear pour ce genre d’opérations, mais nous adoptons ici une approche plus radicale, qui évite certains problèmes étranges dans des environnements de travail exotiques, ou cache:clear ne semble pas suffisant.

Ces manipulations pourront se révéler nécessaires dès que vous aurez à réorganiser votre environnement de travail, migrer le code d’une machine à l’autre, ou d’un compte à l’autre.

Gardez une trace de ces commandes quelque part pour pouvoir vous y référer.

2.2. TODO Chargement du nouveau projet dans l’IDE

Vous allez travailler sur un nouveau projet dans le workspace dans l’IDE Eclipse, importé depuis ce nouveau répertoire de travail.

  1. Pour les utilisateurs de l’IDE Eclipse, supprimez les anciennes infos du projet Eclipse :

    cd "$HOME/CSC4101/tp-02/todo-app"
    rm -fr .project .settings/
    
  2. Importez dans votre IDE cette nouvelle version du projet Symfony Todo sur laquelle vous allez maintenant travailler.

    Dans Eclipse vous pouvez importer ce nouveau projet dans le même workspace que le précédent, mais avec un nom distinctif (par exemple « todo-tp-2 »).

    Si besoin, vous pourrez comparer le code des deux projets « todo-tp-2 » et « todo-tp-1 ». Mais pour l’instant, et pour éviter de vous mélanger, nous vous conseillons de fermer le projet de la séance précédente (menu Project / Close …).

Certains préféreront peut-être l’approche gestion de versions de l’application dans Git, plutôt que la duplication dans différents répertoires. Cette approche est totalement valide, mais pour ne pas alourdir le contenu de la séance avec la manipulation de Git, nous avons préféré une approche plus basique (certains diront archaïque).

3. TODO Étape 2 : Observation des requêtes sur l’application fil-rouge « ToDo »

Cette étape vise à découvrir le mécanisme de traces permettant de mieux maîtriser la mise au point d’une application Symfony, au niveau de la couche d’accès aux données, pour Doctrine et SQL.

Sous le capot, Doctrine utilise des requêtes SQL SELECT pour le chargement des données.

Symfony, comme tous les frameworks, possède des fonctionnalités de debug (déverminage), avec entre autre la possibilité d’avoir des messages plus verbeux, des traces, etc. (en mode développement, au moins; cf. Troubleshooting Problems).

Ce genre d’éléments de diagnostic nous permet d’essayer de traverser les couches d’exécution des différents composants du cadriciel, pour comprendre ce qui se passe, et sans avoir à modifier le code.

On va regarder ici quelles requêtes SQL sont effectivement générées par le code PHP de notre application.

  1. Exécutez une des commandes de l’application :

    symfony console app:list-todos
    
  2. Ouvrez un autre terminal dans le même répertoire, et exécutez la commande suivante :

    cd "$HOME/CSC4101/tp-02/todo-app"
    tail -f var/log/dev.log
    

    La commande tail -f permet de consulter la fin d’un fichier et de surveiller les ajouts éventuels.

    Pour l’instant, elle affiche les dernières lignes présentes dans le fichier, générées lors de l’appel de la commande qu’on vient de faire.

  3. Regardez les traces apparaissant dans le fichier var/log/dev.log au fur et à mesure de l’exécution de commandes.

    Nous nous intéressons aux messages doctrine.DEBUG (vous pouvez a priori ignorer le reste des messages *.info).

    Vous verrez apparaître les requêtes SELECT générées, par exemple ici pour un findAll() :

    [...] doctrine.DEBUG: Executing query:
      SELECT t0.id AS id_1, t0.title AS title_2, t0.completed AS completed_3, t0.created AS created_4, t0.updated AS updated_5
        FROM todo t0
        {"sql":"SELECT t0.id AS id_1, t0.title AS title_2, t0.completed AS completed_3, t0.created AS created_4, t0.updated AS updated_5 FROM todo t0"} []
    

    Ici, la requête est très simple (une version extensive du SELECT *), mais en cas de problèmes, c’est bien pratique de comprendre d’où vient le problème : le code, ou les données.

En cas de bugs, ou de doute, il sera souvent nécessaire (en plus de lire les messages d’erreur de Symfony), d’aller consulter ces traces (logs) dans var/log/dev.log.

Pas uniquement pour Doctrine, mais plus tard pour ce qui concerne HTTP, etc.

Au départ, certains messages peuvent sembler obscurs, mais apprendre à les décoder peut sauver des vies (presque, mais au moins de l’argent, pour commencer !).

Gardez en mémoire cette façon de faire, elle va vous servir souvent.

On verra plus tard, quand on sera en mode Web, qu’on aura à notre disposition le même mécanisme de traces de ces requêtes, dans la barre d’outils de mise au point de Symfony qui sera disponible directement dans l’interface Web, sans avoir à lancer un terminal en parallèle.

4. Étape 3 : Modification de l’ordre de tri par défaut des tâches

On va maintenant essayer de modifier l’ordre de tri par défaut des tâches, lors du chargement des données, pour avoir les tâches actives en premier.

Quand vous exécutez app:list-todos, les tâches apparaissent dans l’ordre où elles ont été ajoutées dans la base de données, c’est à dire dans l’ordre croissant des identifiants.

À tire d’exercice, nous souhaitons modifier cet ordre pour afficher les tâches actives (completed est false) avant les tâches terminées (completed à true).

On va voir deux façons de faire ci-dessous.

Pour mémoire, nous avons présenté certaines fonctionnalités de Doctrine dans les transparents du cours CM 1-2 (Partie 5 : Couche d’accès aux données avec l’ORM Doctrine, également disponible dans Moodle).

4.1. TODO Modification dans le code de la commande app:list-todos

Première option : modifier le code effectuant la requête, à l’intérieur de la méthode execute() de notre commande ListTodosCommand.

La requête est actuellement faite avec l’instruction de chargement des tâches $todos = $this->todoRepository->findAll(); qui se traduit par un SELECT *, comme on vient de le voir.

Par défaut, la requête est faite sans critère de tri des résultats (donc par défaut, triés dans l’ordre où ils ont été stockés dans la table SQLite).

On peut modifier cet appel pour utiliser plutôt la méthode findBy() en lui passant en second argument un critère de tri sur la valeur de la propriété completed.

  1. Modifiez le code de ListTodosCommand::execute() dans votre IDE :

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        //...
    
        // fetches all instances of class Todo from the DB
        //$todos = $this->todoRepository->findAll();
        $todos = $this->todoRepository->findBy([],
                                               ['completed' => 'ASC']);
    
        //...
    }
    

    On examinera la syntaxe de l’appel à findBy() un peu plus tard.

  2. Exécutez la commande app:list-todos

    Les tâches doivent apparaître dans l’ordre souhaité, non-plus triées par ordre d’identifiant, mais les tâches terminées à la fin.

  3. Consultez les logs pour vérifier la requête SQL exécutée.

    Vous devez voir effectivement une spécification d’ORDER BY SQL, du type :

    [...] doctrine.DEBUG: Executing query:
          SELECT t0.id AS id_1, t0.title AS title_2, t0.completed AS completed_3, t0.created AS created_4, t0.updated AS updated_5
          FROM todo t0
          ORDER BY t0.completed ASC
          {"sql":"SELECT t0.id AS id_1, t0.title AS title_2, t0.completed AS completed_3, t0.created AS created_4, t0.updated AS updated_5 FROM todo t0 ORDER BY t0.completed ASC"} []
    

    Pour le reste, la requête est identique à la précédente.

4.2. TODO Modification de l’ordre de chargement par défaut pour l’ensemble de l’application

Deuxième option : modifier l’ensemble des requêtes de chargement, pas uniquement dans ListTodosCommand.

Pour cela, il suffit de modifier la classe TodoRepository, présente dans src/Repository/TodoRepository.php, pour surcharger la méthode findAll() qu’elle hérite d’une classe parente.

Ainsi, on modifie globalement le comportement du chargement par défaut, avec un impact sur tout le code qui utilise la méthode findAll() du repository de la classe Todo.

  1. Modifiez le code de la classe TodoRepository pour ajouter la méthode findAll().

    Vous pouvez par exemple coder ceci, pour surcharger la méthode par défaut findAll() fournie par la classe de base :

    /**
     * {@inheritDoc}
     * @return Todo[]
     * @see \Doctrine\ORM\EntityRepository::findAll()
     */
    public function findAll(): array
    {
            //  findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
            return $this->findBy(
                    [],
                    ['completed' => 'ASC']
            );
    }
    

    Notez que l’IDE nous indiquait qu’une méthode findAll() était déjà présente, bien qu’en fait, il n’y en ait aucun code présent dans TodoRepository.php. C’est dû au fait que qu’une docstring mentionne l’existence de cette méthode, au-dessus de la déclaration de la classe TodoRepository

    /**
     * @extends ServiceEntityRepository<Todo>
     *
     * [...]
     * @method Todo[]    findAll()
     */
    class TodoRepository extends ServiceEntityRepository
    {
    

    Comme la méthode existe bien en effet dans une classe parente du repository, cela permet à l’IDE nous propose de l’utiliser quand on utilise la complétion, par exemple. Pratique !

  2. Supprimez le changement effectué précédamment dans ListTodosCommand::execute() pour remettre $todos = $this->todoRepository->findAll();
  3. Vérifiez le comportement de app:list-todos, qui est bien impacté comme on le souhaite.
  4. Vérifiez que la requête est inchangée en ce qui concerne l’ORDER-BY.
  5. Lancez le serveur Web de l’environnement de tests locaux de Symfony avec symfony server:start et connectez-vous à l’interface Web de consultation des tâches sur la page /todo/list

    Les tâches sont également chargées dans le nouvel ordre souhaité.

    Les traces du serveur Web vous donnent aussi les informations de debug sur les requêtes SQL :

    DEBUG  | DOCTRI Executing query:
       SELECT t0.id AS id_1, t0.title AS title_2, t0.completed AS completed_3, t0.created AS created_4, t0.updated AS updated_5
       FROM todo t0
       ORDER BY t0.completed ASC
       sql="SELECT t0.id AS id_1, t0.title AS title_2, t0.completed AS completed_3, t0.created AS created_4, t0.updated AS updated_5 FROM todo t0 ORDER BY t0.completed ASC"
    

    Le comportement a bien changé de la même façon dans l’interface Web, et c’est bien logique puisque le code qui charge les données pour l’affichage dans la page Web, présent dans src/Controller/TodoController.php utilise :

    $todos = $entityManager->getRepository(Todo::class)->findAll();
    

Bravo, vous savez modifier l’ordre de chargement par défaut, maintenant que vous avez compris le rôle du repository des entité du modèle de données.

Auriez-vous sû comment faire, sans indications ?

Comment savoir comment on peut écrire ce mécanisme et cette surcharge de méthodes, sans passer des semaines à lire la doc ?

Ou bien, de façon plus réaliste, comprendre quelle est la meilleure façon de faire, à la lecture des dizaines de messages sur des forums répondant plus ou moins à cette question…) ?

Allons maintenant examiner la documentation.

Et non, la bonne solution n’est pas de demander à ChatGPT !

5. Étape 4 : Consultation de la documentation Symfony au sujet de Doctrine

Savoir trouver et lire la documentation de référence est une compétence importante pour les ingénieurs.

Ça a l’air trivial (et un truc de boomer) d’apprendre à lire la doc… pourtant le temps passé à lire la documentation est souvent profitable par rapport au temps passé à faire le tri dans les réponses de StackExchange.

Notez que Symfony, Doctrine, et toutes les bibliothèques du framework de développement qu’on utilise évoluent en permanence. Il est donc important de savoir sur quelle version du framework on travaille (nous, c’est la version stable, la 6.3.x actuellement).
En cas de doute : symfony console about.

Pour lire les documentations pertinentes, attention à ne pas regarder celles trop vieilles (Symfony v3), mais pas trop récentes (Symfony v7)…
Nous avons fait de notre mieux pour indiquer des liens vers les « bonnes versions », dans notre contexte : Symfony 6.3.x, Doctrine 2.13, etc.

Méfiance en cas de recherches dans un moteur de recherche, dans les forums, etc.

5.1. TODO Identification de la documentation de référence de Symfony

Ouvrez la documentation relative à la gestion des données avec Doctrine dans Symfony : Databases and the Doctrine ORM

Vous pouvez parcourir rapidement cette documentation.

Les explications sont utiles, mais font référence au contexte d’une application Web, or nous n’avons pas encore réellement codé pour le contexte Web. Le code des classes Controller ne vous est donc pas encore familier.

Mais vous devriez néanmoins repérer du code similaire à celui de notre application « Todo », par exemple dans Fetching objects from the Database :

$repository = $entityManager->getRepository(Product::class);

// look for a single Product by its primary key (usually "id")
$product = $repository->find($id);

// look for a single Product by name
$product = $repository->findOneBy(['name' => 'Keyboard']);
// or find by name and price
$product = $repository->findOneBy([
        'name' => 'Keyboard',
        'price' => 1999,
]);

// look for multiple Product objects matching the name, ordered by price
$products = $repository->findBy(
        ['name' => 'Keyboard'],
        ['price' => 'ASC']
);

// look for *all* Product objects
$products = $repository->findAll();

Espérons que cela vous paraît compréhensible.

Doctrine met en œuvre un patron de conception assez courant dans les couches d’abstraction sur l’accès aux données, le Repository, qu’on retrouve dans beaucoup d’autres cadriciels et langages.

Si on veut creuser un plus loin, il faut lire la doc du projet Doctrine lui-même.

Pour connaître toutes les variantes possibles pour faire des requêtes, cette documentation donne plus de détails sur le fonctionnement des repository d’entités : Documentation Doctrine ORM / Working with Objects / By Simple Conditions

Doctrine est un projet libre à part entière pour fournir des utilitaires d’accès aux données en PHP, qui évolue indépendamment de Symfony. Malheureusement, sa documentation donne donc des exemples en PHP, mais pas forcément toujours adaptés au contexte Symfony

Que faire de toute cette documentation ? Le but est de savoir où elle est, et de s’y référer, autant que de besoin, et surtout sur la version qui nous concerne. C’est parfois un jeu de piste, et parfois la documentation manque…

Gardez un marque-page sur les deux liens ci-dessus pour pouvoir vous y référer plus tard (pendant votre projet par exemple). Inutile de tout lire en détail maintenant.

Examinons alors comment se référer à la documentation ultime : le code des composants qu’on utilise.

5.2. TODO Se référer au source des bibliothèques si besoin (par ex. l’API de Doctrine)

Revenons à la modification qu’on vous a proposée, du code de src/Repository/TodoRepository.php visant à modifier le tri effectué au chargement des données, en y ajoutant une surcharge de la méthode findAll().

5.2.1. Principe de la surcharge

Avec Symfony, on développe en objet, et le principe des classes Repository qui sont codées dans notre modèle de données particulier (dans src/Repository/) est de surcharger le comportement par défaut de Doctrine.

Pour modifier le comportement, il faut d’abord penser objet : surcharger le comportement par défaut en spécialisant le comportement de notre repository, par rapport au repository générique de la classe de base. C’est le principe de la surcharge des méthodes en objet. Rien de spécifique à PHP, ou Symfony.

En fait, on pourrait se passer de classes repository spécifiques dans notre modèle de données, mais comme on l’a vu c’est souvent pratique de changer l’ordre de chargement par défaut, ou de réaliser des requêtes de jointures particulières, donc on en arrive vite à créer ce genre de classes.

Vous verrez que l’assistant générateur de code make:entity (qu’on va utiliser dans quelques temps) créera ainsi la classe Repository pour nous à chaque fois.

5.2.2. Comment écrire le code de surcharge correctement

Mais encore faut-il trouver la documentation de l’API (Application Programming Interface) de Doctrine qui codifie ce comportement par défaut, si l’on veut le modifier dans notre surcharge de méthode…

En fait, en l’occurrence, ici, les développeurs de Doctrine ne publient malheureusement pas (plus) sur le Web une documentation spécifique de l’API.

Dans ce cas, on doit donc se référer au code. Et ça tombe bien, car ce code contient des commentaires (docstrings) que l’IDE peut exploiter pour nous guider dans l’utilisation des API des bibliothèques comme Doctrine.

En plus, PHP étant un langage interprété, les bibliothèques du cadriciel Symfony, comme Doctrine, sont présentes sous forme de code source dans vendor/ où Composer les a téléchargées.

Le code de la classe Doctrine EntityRepository donne plus de détails sur l’API Doctrine (cf. code sur GitHub de lib/Doctrine/ORM/EntityRepository.php, aussi présent dans vendor/doctrine/orm/lib/Doctrine/ORM/EntityRepository.php dans votre IDE) :

Tout d’abord, par défaut, findAll() est juste un appel à findBy() avec les options par défaut :

public function findAll()
{
        return $this->findBy([]);
}

Ensuite, pour findBy(), sa doctring précise :

/**
 * Finds entities by a set of criteria.
 *
 * @param int|null $limit
 * @param int|null $offset
 * @psalm-param array<string, mixed> $criteria
 * @psalm-param array<string, string>|null $orderBy
 *
 * @return object[] The objects.
 * @psalm-return list<T>
 */
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null)
[...]

Vous comprenez donc mieux le rôle des arguments qu’on passe à findBy() dans TodoRepository::findAll() :

  1. tableau vide [] : le premier tableau (array) attendu, pour l’argument $criteria (le critère de restriction générant un WHERE dans la requête SQL SELECT).

    Ici, on passe un tableau vide, donc il n’y a pas de restriction à appliquer;

  2. ['completed' => 'ASC'] : le deuxième tableau (optionnel) $orderBy, qui nous intéresse justement, pour les critères de tri.

Savoir lire le code des projets qu’on utilise est une compétence importante des ingénieurs, notamment aujourd’hui où la plupart des projets utilisent des composants libres plus ou moins bien documentés.

Use the source, Luke !

6. Étape 5 : Ajout d’une nouvelle entité au modèle de données : étiquettes (Tag)

Si vous avez déjà travaillé sur la création de nouvelles entités et commandes (sur l’application « mini-allociné », par exemple), vous retrouverez ici les mêmes opérations.

Nous rappelons le détail de l’enchaînement des commandes et des modifications de code associées, mais vous devriez pouvoir les enchaîner rapidement puisque vous avez déjà effectué ce genre de manipulations.

6.1. TODO Étape 5-a : Ajout d’une nouvelle entité : « Tag »

Utiliser l’assistant générateur de code pour ajouter une nouvelle entité au modèle de données

Nous allons ajouter une nouvelle entité Tag pour gérer dans la base de données des étiquettes qui pourront, plus tard, être liées aux tâches.

Commençons par l’ajout de l’entité Tag très simple, ayant une propriété name (une chaîne de caractères).

Si vous êtes comme la plupart des programmeurs, vous aimez modérément copier-coller du code qui pourrait être généré automatiquement.

Ça tombe bien, Symfony propose différent assistants qu’on ne va pas se priver d’utiliser pour générer la base du code de nos applications, plutôt que d’écrire nous-même du code buggé.

Lancez l’assistant make:entity, dans le terminal, depuis l’intérieur du projet Symfony. Il va nous servir à générer le code de classes PHP pour gérer notre entité « Tag ».

Répondez aux questions de l’assistant pour obtenir une interaction similaire à la trace présentée ci-dessous :

symfony console make:entity
Class name of the entity to create or update (e.g. GrumpyElephant):
> Tag

created: src/Entity/Tag.php
created: src/Repository/TagRepository.php

Entity generated! Now let's add some fields!
You can always add more fields later manually or by re-running this command.

New property name (press <return> to stop adding fields):
> name

Field type (enter ? to see all types) [string]:
> 

Field length [255]:
> 

Can this field be null in the database (nullable) (yes/no) [no]:
> 

updated: src/Entity/Tag.php

Add another property? Enter the property name (or press <return> to stop adding fields):
> 



 Success! 


Next: When you're ready, create a migration with symfony console make:migration


L’assistant a créé pour nous deux classes PHP qui utilisent Doctrine :

  • src/Entity/Tag.php : gère les instances en mémoire des étiquettes
  • src/Repository/TagRepository.php : gère le chargement des étiquettes depuis la base de donnée

Rafraîchissez votre projet dans l’IDE si nécessaire pour voir apparaître ces deux classes.

Attention à lire attentivement les questions (ainsi que les messages en réponse), et à éviter le copier/coller un peu rapide.

6.2. TODO Étape 5-b : Re-création de la base de données

Procédez maintenant à la recréation du fichier de stockage de la base de données SQLite :

symfony console doctrine:database:drop --force
symfony console doctrine:database:create

Créez maintenant le schéma de la base de données :

symfony console doctrine:schema:create

Les tables et index correspondant aux classes de notre modèle de données sont créées. Les tables restent vides en attendant qu’on y ajoute des données.

6.3. TODO Étape 5-c : Ajout du chargement de données de test des étiquettes

On a déjà croisé le module DataFixtures de Symfony qui permet d’utiliser un outil de chargement de données de test, qui permettent aux développeurs de tester le code de leur modèle de données, en chargeant des données dans la base.

On va l’utiliser pour peupler la base de données avec des données d’étiquettes.

Effectuez les opérations suivantes :

  1. Copiez-collez le code ci-dessous de façon à l’ajouter au contenu du fichier source src/DataFixtures/AppFixtures.php :

    private function loadTags(ObjectManager $manager)
    {
            foreach ($this->getTagsData() as [$name]) {
                    $todo = new Tag();
                    $todo->setName($name);
                    $manager->persist($todo);
            }
            $manager->flush();
    }
    
    private function getTagsData()
    {
            // tag = [name];
            yield ['important'];
            yield ['facile'];
            yield ['urgent'];
            yield ['seum'];
    }
    
  2. Assurez-vous que toutes les déclarations sont correctes pour que l’ajout de ce code soit correct (l’IDE Eclipse vous donne des indices, en principe, s’il souligne des choses en rouge)
  3. Assurez-vous de bien ajouter l’appel à cette nouvelle méthode loadTags() dans la méthode load() existante.
  4. Lancez finalement le chargement des données de test :

    symfony console doctrine:fixtures:load -n
    

Attention, vous ne devez pas voir de message d’erreur, si tout a été bien cablé dans le code ci-dessus.

Pour être sûr que les étiquettes sont bien présentes dans les données de tests, vous pouvez regarder les requêtes INSERT qui devraient apparaître dans les logs.

6.4. Étape 5-d : Ajout d’une nouvelle commande app:list-tags

Ajoutons une interface en ligne de commande à notre application PHP, pour disposer d’une commande accessible pour le développeur via l’interface ligne de commande offerte par bin/console.

6.4.1. TODO Génération du squelette de la classe ListTagsCommand

Cette fois encore, utilisons un assistant générateur de code pour nous faciliter le travail :

symfony console make:command
Choose a command name (e.g. app:victorious-popsicle):
> app:list-tags

created: src/Command/ListTagsCommand.php


 Success! 


Next: open your new command class and customize it!
Find the documentation at https://symfony.com/doc/current/console.html

Consultez le résultat généré dans src/Command/ListTagsCommand.php (pensez à rafraîchir le contenu du répertoire dans l’IDE, pour voir apparaître ce nouveau fichier source)

Vérifiez que la commande est bien disponible via :

symfony console list app

et :

symfony console app:list-tags --help

Et enfin qu’elle répond quand on l’invoque :

Description:
  Add a short description for your command

Usage:
  app:list-tags [options] [--] [<arg1>]

Arguments:
  arg1                  Argument description

Options:
      --option1         Option description
  -h, --help            Display help for the given command. When no command is given display help for the list command
  -q, --quiet           Do not output any message
  -V, --version         Display this application version
      --ansi|--no-ansi  Force (or disable --no-ansi) ANSI output
  -n, --no-interaction  Do not ask any interactive question
  -e, --env=ENV         The Environment name. [default: "dev"]
      --no-debug        Switch off debug mode.
  -v|vv|vvv, --verbose  Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug

6.4.2. TODO Branchement de notre commande à Doctrine

Pour que notre commande puisse afficher la liste des étiquettes on va devoir réaliser quelques branchements utiles.

Vous pouvez suivre les indications données ici (et copier/coller) ou vous inspirer du code déjà fourni, qu’on a étudié pour ListTodosCommand.

Modifiez le code de src/Command/ListTagsCommand.php, pour ajouter une propriété tagsRepository dans la classe ListTagsCommand, permettant de gérer l’accès à la base de données via le composant Doctrine, et son initialisation.

Copiez-collez les éléments ci-dessous pour ajouter la propriété et le constructeur dans ListTagsCommand (attention à ne pas dupliquer la déclaration de la classe) :

// ...

use App\Entity\Tag;
use App\Repository\TagRepository;
use Doctrine\Persistence\ManagerRegistry;

// ...

#[AsCommand(
    name: 'app:list-tags',
    description: 'Displays all tags',
)]
class ListTagsCommand extends Command
{
        /**
         *  @var TagRepository data access repository
         */
        private $tagRepository;

        /**
         * Plugs the database to the command
         * 
         * @param ManagerRegistry $doctrineManager
         */
        public function __construct(ManagerRegistry $doctrineManager)
        {
                $this->tagRepository = $doctrineManager->getRepository(Tag::class);

                parent::__construct();
        }

        // ...

6.4.3. TODO Ajout du code d’affichage de la liste des tâches

Ajoutons maintenant le code permettant de charger toutes les tâches présentes dans la base de données.

On utilise la méthode findAll() du repository Doctrine des tâches TagRepository, qui a été généré par l’asssistant, et qui renvoie un tableau de tâches. On peut manipuler ce tableau comme un tableau PHP ordinaire, par exemple avec foreach.

  1. Modifiez la méthode execute() de ListTagsCommand :

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
             $io = new SymfonyStyle($input, $output);
    
             // récupère une liste toutes les instances de la classe Tag
             $tags = $this->tagRepository->findAll();
             //dump($tags);
             if(!empty($tags)) {
                     $io->title('list of tags:');
    
                     $io->listing($tags);
             } else {
                     $io->error('no tags found!');
                     return Command::FAILURE;
             }
             return Command::SUCCESS;
    }
    
    
  2. Testez avec :

    symfony console app:list-tags
    

    Le programme échoue avec une erreur (attentue) du type :

    
    list of tags:
    =============
    
    
    In SymfonyStyle.php line 107:
    
      Object of class App\Entity\Tag could not be converted to string  
    
    

    Ce genre de chose devrait vous paraître cohérent avec ce qui a été vu l’année passée en programmation orientée objet.

    La méthode d’affichage sur la sortie standard essaie d’afficher une chaîne de caractères sur la console… mais une instance de la classe Tag peut-elle être convertie en chaîne de caractères ? Pas de base, en PHP.

    Réglez celà en ajoutant une méthode Tag::__toString()

Si vous n’obtenez pas cette erreur-là, mais plutôt un message disant qu’il n’y a pas de tâches dans la base, c’est probablement que vous avez manqué une étape sur le chargement des données de test des étiquettes ci-dessus. Re-vérifiez que le chargement fonctionne bien.

Réessayez le lancement de notre commande app:list-tags qui devrait afficher cette-fois quelque chose proche de :

list of tags:
=============

 * 1 important
 * 2 facile
 * 3 urgent
 * 4 seum
 

Bravo, vous avez ajouté une nouvelle entité et une commande, en allant relativement vite, grâce aux générateurs de code

Jusqu’ici, vous avez revisité, de façon plus active, en générant le code, et en le modifiant, ce qui avait été découvert de façon plus superficielle dans la séance précédente.

Passons maintenant à des nouveautés : la gestion des associations et les modifications des données.

On monte un degré en difficulté.

7. Étape 6 : Nouveauté : association entre entités et modification des données

Dans une application réaliste, on a de multiples entités au sein du modèle de données, et qui sont reliées par des associations.

Nous allons voir comment gérer de telles associations dans Doctrine.

Ensuite on approfondira comment on peut modifier les données dans le code PHP.

7.1. Étape 6-a : Ajout association Todo-Tag

Le but de cette étape est de mettre en œuvre une association M-N (ou ManyToMany) dans le modèle de données, entre nos tâches et nos étiquettes.

Notre modèle de données, qui contenait initialement une entité tâche (Todo), vient d’être enrichi avec les étiquettes (Tag). On va maintenant relier ces deux entités indépendantes, par une association qu’on exploitera dans notre application.

On va relier tâches et étiquettes par une association M-N. c’est à dire qu’une tâche pourra recevoir différentes étiquettes parmi celles présentes dans la base de données, et une même étiquette pourra être ajoutée à plusieurs tâches.

7.1.1. TODO Ajout de propriétés avec le générateur de code make:entity

Ici aussi, on peut utiliser le générateur de code make:entity pour modifier le code PHP de nos entités existantes, et y générer les attributs PHP correspondants à Doctrine de façon beaucoup plus simple qu’en éditant le code manuellement.

Dans les réponses au dialogue de l’assistant, on va ajouter une nouvelle propriété tags à notre entité Todo existante (make:entity sait générer du code, ou modifier du code existant).

Cette propriété sera de type relation avec une autre entité existante, Tag, et avec une multiplicité ManyToMany. De façon réciproque, Tag recevra une nouvelle propriété todos (notez qu’on nomme nos propriétés avec un pluriel, pour matérialiser le fait qu’il y a plusieurs entités cible de l’association).

symfony console make:entity
 Class name of the entity to create or update (e.g. BravePopsicle):
 > Todo

 Your entity already exists! So let's add some new fields!

 New property name (press <return> to stop adding fields):
 > tags

 Field type (enter ? to see all types) [string]:
 > ?

Main Types
  ,* string
  ,* text
  ,* boolean
  ,* integer or smallint or bigint
  ,* float

Relationships/Associations
  ,* relation or a wizard 🧙 will help you build the relation
  ,* ManyToOne
  ,* OneToMany
  ,* ManyToMany
  ,* OneToOne

Array/Object Types
  ,* array or simple_array
  ,* json
  ,* object
  ,* binary
  ,* blob

Date/Time Types
  ,* datetime or datetime_immutable
  ,* datetimetz or datetimetz_immutable
  ,* date or date_immutable
  ,* time or time_immutable
  ,* dateinterval

Other Types
  ,* ascii_string
  ,* decimal
  ,* guid
  ,* uuid
  ,* ulid


 Field type (enter ? to see all types) [string]:
 > relation

 What class should this entity be related to?:
 > Tag

What type of relationship is this?
 ------------ ---------------------------------------------------------------- 
  Type         Description                                                     
 ------------ ---------------------------------------------------------------- 
  ManyToOne    Each Todo relates to (has) one Tag.                             
               Each Tag can relate to (can have) many Todo objects.            

  OneToMany    Each Todo can relate to (can have) many Tag objects.            
               Each Tag relates to (has) one Todo.                             

  ManyToMany   Each Todo can relate to (can have) many Tag objects.            
               Each Tag can also relate to (can also have) many Todo objects.  

  OneToOne     Each Todo relates to (has) exactly one Tag.                     
               Each Tag also relates to (has) exactly one Todo.                
 ------------ ---------------------------------------------------------------- 

 Relation type? [ManyToOne, OneToMany, ManyToMany, OneToOne]:
 > ManyToMany

 Do you want to add a new property to Tag so that you can access/update Todo objects from it
  - e.g. $tag->getTodos()? (yes/no) [yes]:
 > 

 A new property will also be added to the Tag class so that you can access the related Todo objects from it.

 New field name inside Tag [todos]:
 > 

 updated: src/Entity/Todo.php
 updated: src/Entity/Tag.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > 



  Success! 


 Next: When you're ready, create a migration with symfony console make:migration

Rechargez le projet dans l’IDE pour voir apparaître le code modifié dans les classes PHP Todo et Tag (oui, même si nous avons demandé à modifier Todo, Tag a été impacté aussi : « Do you want to add a new property to Tag so that you can access/update Todo objects from it - e.g. $tag->getTodos()? (yes/no) [yes] »).

Vous noterez peut-être que le dernier message nous indique qu’on peut procéder à une migration des données.

Cela correspond à la possibilité d’appliquer un traitement aux données présentes dans la base pour prendre en compte le changement de schéma. Or cela concerne essentiellement la production, et pour l’instant, notre application n’a pas été déployée, donc le plus simple est ici de casser la base et la recréer.

On reviendra sur ce mécanisme des migrations plus tard dans le cours.

7.1.2. TODO Observation du code PHP généré

La classe Todo comporte maintenant un attribut multi-valué tags qui agit comme une Collection des instances de Tag des étiquettes présentes sur cette tâche.

Elle est spécifiée par l’attribut ORM\ManyToMany qui définit en particulier l’entité cible de l’association M-N (targetEntity: Tag::class).

Elle se manipule en PHP avec une interface Collection semblable aux tableaux PHP classiques (Array).

Plutôt que des getters et setters des propriétés mono-valuées, tags dispose donc des méthodes générées getTags(): Collection, addTag(Tag $tag) et removeTag(Tag $tag).

Exemple de boucle énumérant les étiquettes d’une tâche :

$tags = $todo->getTags();

if (count($tags))
{
        foreach($tags as $tag)
        {
                print $tag;
        }
}

Il se peut que vous constatiez des erreurs ou warnings, ou des comportements étranges dans Eclipse, qui peuvent s’expliquer dans la mesure où les générateurs de code travaillent « dans son dos ». Dans ce genre de cas, n’hésitez pas à sélectionner le menu « Project > Clean », qui aide parfois à résoudre ce genre de comportements erratiques.

7.1.3. TODO Mise à jour de la base de données

Cette fois encore, on a modifié le schéma de la base de données. Simplifions-nous la vie, et recréons la base de données SQLite, de zéro:

Création du fichier de stockage de la base de données SQLite :

symfony console doctrine:database:drop --force
symfony console doctrine:database:create

Création du schéma de la base de données :

symfony console doctrine:schema:create

7.2. Étape 6-b : Ajout de fonctions de modification des données

Dans cette étape, on va expérimenter avec les fonctionnalités de Doctrine permettant l’ajout de données dans la base.

Vous allez ajouter de nouvelles commandes, utilisables en console, dans notre application, pour réaliser cette fois des modifications.

Vous utiliserez à nouveau le générateur de code make:command pour aller plus vite, et ne pas commettre trop d’erreurs.

On aborde là la partie la plus difficile de la séance : gardez le fil de votre progression

7.2.1. TODO Étape 6-b.1 : Commande de création de nouvelle étiquette (app:new-tag)

Sur le modèle des ajouts de commandes effectués précédemment, ajoutez une commande app:new-tag qui prend en argument le nom de l’étiquette, et va l’ajouter à la base de données.

Vous utilisez encore une fois l’assistant make:command (une alternative pourrait être de copier-coller le code existant et faire les bons renommages).

Comme d’habitude, il nous faut cabler la commande avec le module Doctrine permettant d’intéragir avec la base de données, et ajouter un constructeur adapté, du style :

// ...

use Doctrine\Persistence\ManagerRegistry;

#[AsCommand(
        name: 'app:new-tag',
        description: 'Creates a new tag',
)]
class NewTagCommand extends Command
{
        /**
         *  @var TagRepository data access repository
         */
        private $tagRepository;

        /**
         * Plugs the database to the command
         *
         * @param ManagerRegistry $doctrineManager
         */
        public function __construct(ManagerRegistry $doctrineManager)
        {
                $this->tagRepository = $doctrineManager->getRepository(Tag::class);

                parent::__construct();
        }

        // ...

Il ne reste plus qu’à coder le cœur de la commande dans la méthode execute() qui permettra d’ajouter des données dans la base, via le repository, comme c’est fait dans NewTodoCommand::execute() :

//...
use App\Entity\Tag;
use App\Repository\TagRepository;

//...

        protected function execute(InputInterface $input, OutputInterface $output): int
        {
                $io = new SymfonyStyle($input, $output);

                $tag = new Tag();
                $tag->setName($input->getArgument('name'));

                $this->tagRepository->save($tag, true);

                if($tag->getId()) {        
                        $io->success('Created: '. $tag);
                        return Command::SUCCESS;
                }
                else {
                        $io->error('could not create tag!');
                        return Command::FAILURE;
                }
        }


Réglez les derniers détails nécessaires à l’invocation de la commande, en vous inspirant du code présent dans les commandes fournies (gestion des arguments attendus dans configure(), …).

In fine, quand vous testez, vous allez bloquer sur une erreur du type :

In EntityRepository.php line 283:
                                                                                                 
  Undefined method "save". The method name must start with either findBy, findOneBy or countBy!  
                                                                                                 

En effet, dans le code du repository des étiquettes, tel que généré par make:entity, il nous manque la méthode TagRepository::save(), et elle n’existe pas dans la bibliothèque Doctrine, non-plus, comme nous le signale l’erreur.

Ajoutons-la à TagRepository, sur le modèle de celle fournie dans TodoRepository::save() :

public function save(Tag $entity, bool $flush = false): void
{
        $this->getEntityManager()->persist($entity);

        if ($flush) {
                $this->getEntityManager()->flush();
        }
}

Une fois finalisé la méthode AddTagCommand::execute(), vous voyez passer les requêtes SQL INSERT générées par Doctrine dans le fichier de logs.

En cas de problèmes (commande non reconnue), n’hésitez pas à exécuter la commande suivante, pour mettre au propre l’environnement d’exécution alors que le code a évolué :

symfony console cache:clear

Et rappelez-vous que l’IDE ne sait pas que des fichiers nouveaux sont générés par nos assistants générateurs de code Symfony, donc pensez à recharger le projet, si vous ne trouvez pas certains fichiers source.

Bravo, vous savez maintenant comment on code la sauvegarde de données dans la base avec Doctrine.

Même si on l’avait déjà abordée dans les Fixtures pour les données de tests, là on est dans le code de l’application qui s’exécute, pas dans un utilitaire destiné aux développeurs.

Vous remarquerez peut-être qu’on a la possibilité d’ajouter plusieurs fois de suite des étiquettes de même nom… ce n’est pas idéal, dans une vraie application, mais ne devrait pas être problématique pour notre application fil-rouge, pour les TP.

On explicite comment gérer une contrainte d’unicité dans les annexes, ci-après, pour le lecteur curieux. Inutile de s’y référer pour l’instant.

7.2.2. Étape 6-b.2 : Coder des ajouts sur les associations ManyToMany

Dans cette étape, on va maintenant examiner en détail comment gérer avec Doctrine l’association M-N entre deux entités.

Vous allez ajouter une commande app:add-tag permettant d’ajouter une étiquette existante à une tâche existante, en mémoire pendant l’exécution, puis sauvegardée dans la base de données.

7.2.2.1. TODO Génération du squelette d’une nouvelle commande app:add-tag

Sur le modèle des portions de code précédentes, ajoutez une commande app:add-tag prenant en entrée deux arguments :

  1. Utilisez make:command à nouveau pour générer le squelette de code dans src/Command/AddTagCommand.php
  2. Adaptez ce squelette de base de la commande, pour qu’elle recoive deux arguments, et pour cabler l’utilisation de Doctrine :

    <?php
    
    namespace App\Command;
    
    use Symfony\Component\Console\Attribute\AsCommand;
    use Symfony\Component\Console\Command\Command;
    use Symfony\Component\Console\Input\InputArgument;
    use Symfony\Component\Console\Input\InputInterface;
    use Symfony\Component\Console\Input\InputOption;
    use Symfony\Component\Console\Output\OutputInterface;
    use Symfony\Component\Console\Style\SymfonyStyle;
    use App\Entity\Tag;
    use App\Entity\Todo;
    use Doctrine\Persistence\ManagerRegistry;
    
    #[AsCommand(
            name: 'app:add-tag',
            description: 'Add a tag to a todo (both must exist already)',
    )]
    class AddTagCommand extends Command
    {
            /**
             *  @var ManagerRegistry data access repository
             */
            private $doctrineManager;
    
            /**
             * Plugs the database to the command
             *
             * @param ManagerRegistry $doctrineManager
             */
            public function __construct(ManagerRegistry $doctrineManager)
            {
                    $this->doctrineManager = $doctrineManager;
    
                    parent::__construct();
            }
    
            protected function configure(): void
            {
                    $this
                            ->addArgument('tagName', InputArgument::REQUIRED, 'Tag name')
                            ->addArgument('todoId', InputArgument::REQUIRED, 'Id of the todo')
                            ;
            }
    
            protected function execute(InputInterface $input, OutputInterface $output): int
            {
                    $io = new SymfonyStyle($input, $output);
                    $tagName = $input->getArgument('tagName');
                    $todoId = $input->getArgument('todoId');
    
                    dump($tagName);
                    dump($todoId);
    
                    // Do the work here :
                    // TODO 1. load tag and todo
                    // TODO 2. add tag to todo
                    // TODO 3. save changes to the database
    
                    return Command::SUCCESS;
            }
    }
    
    

    Contrairement aux codes des commandes précédentes, ici, on a gardé dans une propriété de la classe ($doctrineManager) la référence au gestionnaire doctrine, pour pouvoir accéder plus tard aux deux repositories respectifs des tâches et des étiquettes.

7.2.2.2. TODO Chargement des données existantes

Ajoutons le chargement des données correspondant aux arguments reçus.

Modifiez le code de execute() :

  1. Dans un premier temps, on récupère les deux références aux repositories Doctrine de nos deux entités, grâce à la propriété $doctrineManager qu’on a gardé en mémoire dans le constructeur :

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
    
            //...
    
            // Do the work here :
            // 1. load tag and todo
            $todoRepository = $this->doctrineManager->getRepository(Todo::class);
            $tagRepository = $this->doctrineManager->getRepository(Tag::class);
    
            // ...
    
    
  2. On peut donc maintenant ajouter le chargement de la tâche choisie :
    1. On charge la tâche (qui doit exister en base de données pour que la commande réussisse), avec la méthode find() du repository, qui charge une instance à partir de son identifiant (unique) :

      protected function execute(InputInterface $input, OutputInterface $output): int
      {
      
              //...
              $todo = $todoRepository->find($todoId);
              if(! $todo){
                      $io->error('could not find todo!');
                      return Command::FAILURE;
              }
      
              // ...
      
      
    2. Pour le chargement de l’étiquette (qui doit elle-aussi exister préalablement), on n’a pas passé en argument de la commande un identifiant (numéro), mais une chaîne de caractères correspondant à une propriété de notre entité (name).

      On ne peut donc pas utiliser directement find(), mais on peut utiliser la méthode findOneBy() pour effectuer une requête de sélection par critère (SELECT ... WHERE ...).

      Elle effectue une requête de chargement d’une instance (findOne…) via une sélection, selon des couples clé-valeur passés en argument (un tableau associatif ['NOM_PROPRIETE1' => VALEUR_PROPRIETE1, ...], qui se traduit par les clauses WHERE correspondantes) :

      $tag = $tagRepository->findOneBy(['name' => $tagName]);
      

      Mais notez qu’on peut aussi utiliser une version alternative, en utilisant une méthode « magique » qui contient le nom de la propriété recherchée (écriture plus limpide sans tableau en argument) :

      $tag = $tagRepository->findOneByName($tagName);
      
7.2.2.3. TODO Utilisation de dump()

Examinons maintenant ce qui se passe en mémoire dans l’exécution de notre code basé sur Doctrine.

On peut utiliser ici l’outil dump() de Symfony, qui sera très puissant pour vous aider dans la mise au point de votre code.

Maintenant qu’on a les données en mémoire, complétez =execute() pour afficher notre tâche et notre étiquette :

dump($todo);
dump($tag);

Vous remarquez que notre tâche « apprendre les bases de PHP » (/instance #359 de App\Entity\Todo/) a une propriété tags qui est une PersistentCollection, qui ne semble rien contenir pour l’instant. De façon réciproque, notre étiquette « facile » a une collection de tâches todos pas beaucoup plus conséquente :

^ App\Entity\Todo^ {#359
  -id: 1
  -title: "apprendre les bases de PHP"
  -completed: true
  [...]
  -tags: Doctrine\ORM\PersistentCollection^ {#363
    #collection: Doctrine\Common\Collections\ArrayCollection^ {#442
      -elements: []
    }
    #initialized: false
    -snapshot: []
    -owner: App\Entity\Todo^ {#359}
    -association: array:20 [ …20]
    -em: ContainerOeMwtvh\EntityManagerGhostEbeb667^ {#267 …12}
    -backRefFieldName: "todos"
    -typeClass: Doctrine\ORM\Mapping\ClassMetadata {#357 …}
    -isDirty: false
  }
}
^ App\Entity\Tag^ {#1359
  -id: 2
  -name: "facile"
  -todos: Doctrine\ORM\PersistentCollection^ {#1368
    #collection: Doctrine\Common\Collections\ArrayCollection^ {#1369
      -elements: []
    }
    #initialized: false
    -snapshot: []
    -owner: App\Entity\Tag^ {#1359}
    -association: array:16 [ …16]
    -em: ContainerOeMwtvh\EntityManagerGhostEbeb667^ {#267 …12}
    -backRefFieldName: "tags"
    -typeClass: Doctrine\ORM\Mapping\ClassMetadata {#403 …}
    -isDirty: false
  }
}

dump() est un outil précieux pour consulter le contenu interne de nos objets, débugger, etc. Oublié print() !

7.2.2.4. TODO Ajout en mémoire d’un lien entre deux entités

Il est temps d’ajouter l’étiquette à la tâche, en mémoire.

Plus précisément, on va ajouter l’étiquette qu’on a chargée depuis la base de donnée, dans la collection des étiquettes de la tâche.

Rien de plus compliqué qu’utiliser la méthode Todo::addTag(), qui est là pour cela.

Notez que Todo::addTag() a été ajoutée à notre code par make:entity, lors de l’ajout de l’association entre tâches et étiquettes.

  1. Modifiez execute() en ajoutant ce code :

    //...
    
    // 2. add tag to todo
    $todo->addTag($tag);
    
    dump($todo);
    dump($tag);
    
  2. Testez l’appel de la commande, et vérifiez le contenu avant/après de nos instances en mémoire dans l’affichage fait par dump().

    Cette fois, l’affichage des collections tags dans la Todo nous laisse bien apparaître le lien entre nos deux entités :

    ^ App\Entity\Todo^ {#359
      -id: 1
      -title: "apprendre les bases de PHP"
      -completed: true
      [...]
      -tags: Doctrine\ORM\PersistentCollection^ {#363
        #collection: Doctrine\Common\Collections\ArrayCollection^ {#442
          -elements: array:1 [
            0 => App\Entity\Tag^ {#1359
              -id: 2
              -name: "facile"
              [...]
            }
          ]
        }
        #initialized: true
        -snapshot: []
        -owner: App\Entity\Todo^ {#359}
        -association: array:20 [ …20]
        -em: ContainerOeMwtvh\EntityManagerGhostEbeb667^ {#267 …12}
        -backRefFieldName: "todos"
        -typeClass: Doctrine\ORM\Mapping\ClassMetadata {#357 …}
        -isDirty: true
      }
    }
    ^ App\Entity\Tag^ {#1359
      -id: 2
      -name: "facile"
      -todos: Doctrine\ORM\PersistentCollection^ {#1368
        #collection: Doctrine\Common\Collections\ArrayCollection^ {#1369
        -elements: []
        }
        #initialized: true
        -snapshot: []
        -owner: App\Entity\Tag^ {#1359}
        -association: array:16 [ …16]
        -em: ContainerOeMwtvh\EntityManagerGhostEbeb667^ {#267 …12}
        -backRefFieldName: "tags"
        -typeClass: Doctrine\ORM\Mapping\ClassMetadata {#403 …}
        -isDirty: false
      }
    }
    
  3. Vous voyez également qu’en mémoire, si notre tâche a son étiquette, par contre, l’étiquette semble ne pas connaître la tâche (dans la collection de sa propriété Tag::todos, qui ne contient pas d’éléments).
  4. Modifions cela dans Todo::addTag(), en ajoutant $tag->addTodo($this);, pour obtenir :

    public function addTag(Tag $tag): static
    {
                    if (!$this->tags->contains($tag)) {
                                    $this->tags->add($tag);
                                    // make sure the reverse reference is set too
                                    $tag->addTodo($this);
                    }
    
                    return $this;
    }
    

    La tâche ajoute l’étiquette dans sa collection d’étiquette tags, mais elle informe ensuite l’étiquette en question qu’elle a une nouvelle tâche associée (en lui passant sa propre référence $this).

  5. Vérifiez dans l’affichage de dump() à l’exécution après cette dernière modification.

    Le modèle en mémoire est cohérent : nos deux entités se référencent mutuellement :

    ^ App\Entity\Todo^ {#359
      -id: 1
      -title: "apprendre les bases de PHP"
      -completed: true
      [...]
      -tags: Doctrine\ORM\PersistentCollection^ {#363
        #collection: Doctrine\Common\Collections\ArrayCollection^ {#442
          -elements: array:1 [
            0 => App\Entity\Tag^ {#1359
              -id: 2
              -name: "facile"
              [...]
            }
          ]
        }
        #initialized: true
        -snapshot: []
        -owner: App\Entity\Todo^ {#359}
        -association: array:20 [ …20]
        -em: ContainerOeMwtvh\EntityManagerGhostEbeb667^ {#267 …12}
        -backRefFieldName: "todos"
        -typeClass: Doctrine\ORM\Mapping\ClassMetadata {#357 …}
        -isDirty: true
      }
    }
    ^ App\Entity\Tag^ {#1359
      -id: 2
      -name: "facile"
      -todos: Doctrine\ORM\PersistentCollection^ {#1368
        #collection: Doctrine\Common\Collections\ArrayCollection^ {#1369
          -elements: array:1 [
            0 => App\Entity\Todo^ {#359
              -id: 1
              -title: "apprendre les bases de PHP"
              -completed: true
              [...]
              }
            }
          ]
        }
        #initialized: true
        -snapshot: []
        -owner: App\Entity\Tag^ {#1359}
        -association: array:16 [ …16]
        -em: ContainerOeMwtvh\EntityManagerGhostEbeb667^ {#267 …12}
        -backRefFieldName: "tags"
        -typeClass: Doctrine\ORM\Mapping\ClassMetadata {#403 …}
        -isDirty: true
      }
    }
    

    En plus d’afficher de jolies couleurs, bien qu’il y ait un cycle dans nos données, dump() fait du bon boulot et ne part pas dans une boucle infinie ! Nice :-)

  6. Lancez plusieurs fois de suite la même commande app:add-tag. Vous constatez que les modifications sont bien effectuez en mémoire, mais quand on recharge les données, les collections restent désespérément vides.

    Il manque finalement la persistence en base de données.

7.2.2.5. TODO Sauvegarde de l’association en base de données

Dernier élément à coder dans execute(), faire en sorte que ces modifications soit sauvegardées en base de données.

Si vous avez observé attentivement les dumps avant/après ci-dessus, vous remarquerez qu’avant l’ajout en mémoire, les entités comportent une propriété interne à Doctrine isDirty à false. Cette propriété passe à true une fois appelé addTag() (sur les deux entités).

C’est le mécanisme qui indique à Doctrine que les données en mémoire ont changé par rapport à la base de données.

On va devoir sauvegarder explicitement, dans notre code, en ajoutant l’appel à save() pour l’une des entités, par exemple $todo.

  1. Modifiez enfin execute() pour ajouter la sauvegarde :

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
                    //...
    
                    // 3. save changes to the database
                    $todoRepository->save($todo, true);
    
                    return Command::SUCCESS;
    }
    
  2. Testez une nouvelle fois l’invocation de la même commande symfony console app:add-tag facile 1, en regardant les logs, et vérifiez que cela fonctionne bien : la requête SQL INSERT est bien transmise à la base SQLite.

    Les messages de traces de débug de Doctrine (doctrine.DEBUG) ressemblent à :

    Executing statement: SELECT t0.id AS id_1, t0.title AS title_2, t0.completed AS completed_3, t0.created AS created_4, t0.updated AS updated_5
                         FROM todo t0
                         WHERE t0.id = ?
                         (parameters: array{"1":"1"}, types: array{"1":1})
    Executing statement: SELECT t0.id AS id_1, t0.name AS name_2
                         FROM tag t0
                         WHERE t0.name = ? LIMIT 1
                         (parameters: array{"1":"facile"}, types: array{"1":2})
    Executing statement: SELECT t0.id AS id_1, t0.name AS name_2
                         FROM tag t0
                         INNER JOIN todo_tag
                               ON t0.id = todo_tag.tag_id
                               WHERE todo_tag.todo_id = ?
                         (parameters: array{"1":1}, types: array{"1":1})
    Executing statement: SELECT t0.id AS id_1, t0.title AS title_2, t0.completed AS completed_3, t0.created AS created_4, t0.updated AS updated_5
                         FROM todo t0
                         INNER JOIN todo_tag
                               ON t0.id = todo_tag.todo_id
                               WHERE todo_tag.tag_id = ? (parameters: array{"1":2}, types: array{"1":1})
    Beginning transaction 
    Executing statement: INSERT INTO todo_tag (todo_id, tag_id)
                         VALUES (?, ?) (parameters: array{"1":1,"2":2}, types: array{"1":1,"2":1})
    Committing transaction
    
    

    Explications des différentes instructions :

    1. chargement de la tâche par son numéro « 1 » (appel à find(), qui charge ici notre tâche d’id 1)
    2. chargement de l’étiquette par son nom « facile » (appel à findOneByName(), qui charge notre étiquette d’id 2, comme on l’avait vu dans les dumps)
    3. ensuite vient l’impact du addTag(), qui implique le chargement des étiquettes existantes de notre tâche (if (!$this->tags->contains($tag))), qui génère une jointure pour charger les étiquettes de la tâche (premier INNER JOIN avec la table de jointure créée dans la base pour gérer l’association M-N : todo_tag)
    4. de façon réciproque, chargement des tâches de l’étiquette (deuxième INNER JOIN sur la table d’association)
    5. enfin, il est temps de sauvegarder les données (persist() et flush() générés par l’appel à save()) :
      1. Doctrine crée une transaction pour assurer que les contraintes d’intégrité sont bien respectées (une application Symfony est typiquement multi-utilisateur, et il ne faut pas que les modifications concurrentes occasionnent des bugs)
      2. si tout va bien et qu’aucune autre exécution du code ne modifie la même table d’association, on peut alors créer le couple des identifiant de nos deux entités, via un INSERT dans la table d’association todo_tag.

Voilà, ça fonctionne !

Bravo, vous savez maintenant comment on code la sauvegarde de données sur des entités associées dans le modèle de données par une association ManyToMany.

On verra ultérieurement d’autres strcutures de données par exemple avec les associations 1-N OneToMany

8. Étape 7 : Chargement de données liées dans une association ManyToMany

L’objectif de cette étape est de naviguer dans les associations entre entités de notre modèle de données, en parcourant une association ManyToMany.

Si vous avez terminé l’étape précédente, tout va bien : vous avez réalisé une première application PHP utilisant Symfony, et qui est capable de s’interfacer avec une base de données relationnelles, pour charger des données.

Allons plus loin pour progresser un peu dans notre connaissance du modèle de données objet de PHP, qu’on met en œuvre avec Doctrine, dans les applications Symfony.

Cette fois, nous fournissons un peu moins d’aide pour diminuer le copier/coller et vous laisser retrouver les opérations par vous-même.

8.1. TODO Étape 7-a : Ajout de l’affichage des étiquettes d’une tâche

Nous allons parcourir les données de notre application en utilisant l’approche objet, pour charger des données liées, afin d’afficher les étiquettes de chaque tâche.

En base de données, les tâches et les étiquettes sont stockés dans des tables séparées (avec une table d’association pour matérialiser l’association M-N).

Comment allons-nous gérer cela en PHP ? En objet !

Plutôt que de charger en mémoire les tâches d’un côté, puis les étiquettes de l’autre, et de réconcilier ensuite « manuellement » quelles étiquettes correspondent à quel tâche, nous allons exploiter les méthodes de parcours d’attributs de type collection dans l’API Objet fournie par Doctrine.

Si on accède à l’attribut Todo::tags via $todo->getTags() (déjà introduit brièvement plus haut), on programme en objet tout naturellement… et Doctrine fera les chargements correspondants sous-le capôt sans qu’on ait à s’en préoccuper (jointures, etc.).

Mettons cela en pratique : on va ajouter à la commande app:show-todo fournie, l’affichage des étiquettes de la tâche

Vous pouvez vous inspirer du code ci-dessous pour l’affichage des étiquettes d’une tâche. Observez la façon dont est écrit le foreach qui permet d’accéder aux données liées dans la collection des étiquettes de cette tâche :

protected function execute(InputInterface $input, OutputInterface $output)
{

// [...]
if ($todo) {
                $io->text($todo);

                $tags = $todo->getTags();
                if(count($tags)) {
                        $io->text('tags:');
                        foreach($tags as $tag) {
                                $io->text('- '. $tag);
                        }
                }
        }

// [...]


Bravo, vous savez maintenant comment accéder aux données, en programmant avec une approche objetc en PHP, grâce Doctrine, y compris pour des données liées par des associations M-N.

9. Conclusion

Nous avons vu en détails comment gérer le modèle de données en PHP avec Doctrine, pour coder le chargement ou la modification des données, à partir d’un code exécuté en ligne de commande.

Nous avons examiné également le fonctionnement d’une association ManyToMany entre entités de notre modèle de données.

Au passage vous avez utilisé de nombreux outils du cadriciel, dont les traces (logs) et dump(), qui vont vous resservir souvent, dans la suite des séances.

10. Auto-évaluation

À l’issue de cette séquence de travail autonome, vous savez :

  • expliquer le rôle de l’ORM Doctrine, pour utiliser l’approche Objet à partir d’un modèle de données programmé dans des classes PHP
  • identifier la documentation de Doctrine pour pouvoir vous y référer
  • identifier les fonctions de chargement des données via les méthodes des repositories dans le code PHP
  • manipuler des données de tests pour initialiser la base de données avec les data fixtures
  • observer les requêtes SQL dans les traces pour aider à la mise au point
  • recréer la base de données et son schéma
  • coder avec Doctrine la sauvegarde des données nouvelles, issues des instances construites en mémoire
  • comprendre le mécanisme de persistence dans les collections pour les associations ManyToMany
  • utiliser dump() pour faciliter la mise au point.

11. Annexe

11.1. Exemple de vérification de contrainte d’unicité

Dans la gestion des étiquettes, elles ont une seule propriété name dont on pourrait souhaiter que sa valeur soit unique (en effet, dans l’implémentation courante, rien n’empêche de stocker dans la base deux étiquettes de même nom).

On peut implémenter cette contrainte d’unicité à plusieurs niveaux :

  • a minima dans la base de données (c’est le rôle du SGBD de garantir de façon ultime ce genre de contraines d’intégrité de notre modèle de données)
  • de façon plus intéressante, via notre code applicatif.

    En effet, même si la base de données fait le job, on peut préférer détecter la violation de contrainte au plus tôt, en mémoire, dès la saisie des données entrantes dans l’application, sans attendre d’essayer de les sauver dans la base de données, et d’obtenir à ce moment là une erreur.

    Un mécanisme avancé de Symfony permet de faire cela : le Validator.

Examinons les deux options (qu’on peut combiner) :

11.1.1. Unicité vérifiée dans le SGBD via Doctrine

Exemple de code déclarant comme unique la propriété, du point de vue du SGBD, dans l’entité Tag, avec le paramètre unique de l’attribut ORM\Entity de Doctrine :

#[ORM\Entity(repositoryClass: TagRepository::class)]
class Tag
{
    #[ORM\Column(length: 255, unique: true)]
    private ?string $name = null;

Cette contrainte d’unicité fonctionne également avec SQLite : parfait pour les tests en phase de développement.

11.1.2. Unicité vérifiée dans un validateur Symfony

Exemple de code vérifiant la contrainte d’unicité avec le validateur, qui utilise cette-fois la contraine déclarée avec l’attribut UniqueEntity des validateurs Symfony :

use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

#[ORM\Entity(repositoryClass: TagRepository::class)]
#[UniqueEntity('name')]
class Tag
{
    #[ORM\Column(length: 255, unique: true)]
    private ?string $name = null;

Cette fois, c’est au programmeur de demander explicitement la validation des données. Par exemple, dans une commande en console :

use Symfony\Component\Validator\Validator\ValidatorInterface;
class NewTagCommand extends Command
{
    /**
     *  @var TagRepository data access repository
     */
    private $tagRepository;

    private ValidatorInterface $validator;

    public function __construct(ManagerRegistry $doctrineManager, ValidatorInterface $validator)
    {
        $this->tagRepository = $doctrineManager->getRepository(Tag::class);

        $this->validator = $validator;

        // ...
    }


    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // ...
        $tag = new Tag();
        $tag->setName($input->getArgument('name'));

        $errors = $this->validator->validate($tag);

        if(count($errors)) {
            $io->error((string)$errors);
            return Command::FAILURE;
        }

Tout comme on utilise l’injection de dépendances pour transmettre le gestionnaire Doctrine au constructeur de la commande, on peut aussi le lier au module de validation de Symfony.

Auteur: Olivier Berger (TSP)

Created: 2023-10-02 Mon 14:48

Validate