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 approfondir la compréhension de ces mécanismes dans un premier temps, et cette fois, on passe en mode un peu plus actif où il faudra générer du code avec un assistant, mais aussi coder un peu plus en PHP.

Puis on va ajouter des fonctions permettant de modifier des données.

À l’issue de cette séance, on maîtrisera les outils de base pour gérer la persistance 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 ensuite au projet.

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

Cette étape vise à démarrer la séance dans une nouvelle version du projet fil-rouge basée sur celui de la séance précédente

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, où 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. É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.

3.1. TODO Étape 2-a : Observation des Requêtes SQL de chargement

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 (la propriété completed vaut 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 « Couche d’accès aux données avec l’ORM Doctrine », disponible dans Moodle.

4.1. TODO Étape 3-a : 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 Étape 3-b : 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 des tâches, pas uniquement dans ListTodosCommand.

Pour cela, il suffit de modifier la classe TodoRepository, pour surcharger la méthode findAll() qu’elle hérite d’une classe parente.

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.

You can think of a repository as a PHP class whose only job is to help you fetch entities of a certain class.

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 dans src/Repository/TodoRepository.php, pour redéfinir la méthode findAll() héritée :

    /**
     * {@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']
            );
    }
    

    Même si le code source de TodoRepository.php a été généré initialement par les développeurs qui ont codé le squelette dont on est parti, on considère que maintenant que c’est dans src/, c’est notre code.

    On peut donc le modifier allègrement. On ne ferait pas ça sur le code de la bibliothèque de base de Doctrine qui est quelque part dans vendor/, au risque de perdre les modifications à la prochaine mise à jour faite par Composer.

    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.

4.3. Principe de la surcharge / redéfinition

Est-ce que le fait de redéfinir une méthode findAll() dans notre classe TodoRepository est une chose commune ?

Oui : dans un projet Symfony, on développe en objet.

Pour modifier un comportement par défaut, il faut penser objet : on fait ça via une redéfinition d’une méthode. Rien de spécifique à PHP, ou Symfony. On fait ça en Java, et dans tous les langages objets.

Ainsi on va redéfinir un comportement par défaut, en créant une classe dédiée dans notre code, qui redéfinira ce comportement hérité d’une classe parente.

L’ordre de tri par défaut de Doctrine est contrôlé dans le code du repository générique, tant qu’on n’ajoute pas de méthode findAll spécifique. Mais il peut ainsi être redéfini dans le code d’une classe Repository spécifique, qui surcharge cette classe de base générique.

C’est le rôle des classes présentes dans notre modèle de données dans src/Repository/.

En fait, dans certains projets on pourrait se passer de classes repository spécifiques dans notre modèle de données, à condition d’appliquer à différents endroits dans le code les findBy (comme dans la première option ci-dessus).

Mais autant factoriser plutôt que d’écrire du code identique un peu partout. Comme on l’a vu dans la deuxième option, c’est souvent pratique de changer l’ordre de chargement par défaut, une bonne fois pour toute.

On a aussi besoin de ce genre de factorisations pour réaliser des requêtes de jointures particulières.

Donc dans de vrais projets Symfony et Doctrine, on en arrive vite à créer ce genre de classes Repository spécialisées.

Tant et si bien que vous verrez que l’assistant générateur de code make:entity (qu’on va utiliser dans quelques temps) créera ainsi une classe Repository pour nous, à chaque fois qu’on ajoute une entité au modèle des 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 (sans parler de demander à une IA).

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 6.4.x actuellement).
En cas de doute : symfony console about.
Pour lire les documentations pertinentes, attention à ne pas regarder celles trop vieilles (Symfony v5), mais pas non-plus celles un peu trop récentes (Symfony v7)…
Nous avons fait de notre mieux pour indiquer dans les supports les liens vers les « bonnes versions », dans notre contexte : Symfony 6.4.x, Doctrine 2.15, etc.

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

5.1. TODO Étape 4-a : 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 le début de cette documentation.

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

Vous devriez néanmoins repérer du code similaire à celui de notre application fil-rouge, par exemple dans Fetching objects from the Database :

$repository = ...->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.

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

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. Sa documentation donne donc des exemples en PHP, mais pas forcément toujours adaptés au contexte Symfony

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

Vous comprenez donc maintenant (peut-être) mieux le rôle des arguments qu’on a passé à findBy() dans les modifications du chargement des tâches pour les trier :

...->findBy([],
            ['completed' => 'ASC']);

  1. premier argument attendu $criteria : C’est le critère de filtre du WHERE de la requête SQL.

    Ici un tableau vide : [], donc pas de filtre à appliquer;

  2. deuxième argument (optionnel) $orderBy : ['completed' => 'ASC'].

    C’est le critère de tri qu’on recherche.

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… mais c’est souvent mieux que de suivre des posts random sur des forums.

Note: On touvera en annexe des détails sur comment se référer à la documentation ultime : le code des composants qu’on utilise.

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

Comprendre comment on peut modifier le modèle de données dans le code, et tester que ça fonctionne.

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 notre modèle 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 pour tester la classe Tag.

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 (vous savez maintenant où trouver ces logs - voir ci-dessus).

Si tout va bien à ce stade, votre modèle des données semble fonctionnel.

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.

Voyons maintenant comment on fait pour ajouter des commandes permettant de tester le chargement depuis la base de données, comme les développeurs l’avaient fait avant nous pour app:list-todos, etc.

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é tagRepository dans la classe ListTagsCommand, permettant de gérer l’accès à la base de données via le composant Doctrine,
  • et ajouter son initialisation dans le constructeur de la classe

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

        // ...

Cette propriété tagRepository servira à faire des requêtes vers la base de données, comme on l’a vu pour le TodoRepository.

6.4.3. TODO Ajout du code d’affichage de la liste des étiquettes

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

On utilise la méthode findAll() du repository Doctrine des étiquettes TagRepository, qui a été généré par l’assistant, et qui renvoie un tableau d’étiquettes. 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() dans src/Entity/Tag.php. Vous pouvez vous inspirer de la méthode Todo::__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

Nouveautés : Association entre entités et modification des données

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 superficielle dans la séance précédente.

Passons maintenant à des choses nouvelles dans la découverte du modèle de 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.

Attention, nous attaquons les vraies nouveautés de la séance, et on monte en difficulté. Attachez vos ceintures…

7. Étape 6 : Ajout d’une association M-N au modèle de données

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. TODO Étape 6-a : 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 générer les propriétés des classes PHP correspondantes, avec la syntaxe des annotations 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 nouveau code, mais aussi modifier du code existant).

Cette propriété Todo::tags 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.1. 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.2. 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

Vous avez une nouvelle association entre entités dans la base de données, et on ne va pas tarder à jouer avec.

Mais d’abord, il serait temps de savoir coder par nous-même les modifications des données.

On va commencer avec la création d’une entité en mémoire et sa sauvegarde en base de données.

8. Étape 7 : Coder la modification des données et la sauvegarde en BD

Dans cette étape, on va expérimenter avec les fonctionnalités de Doctrine permettant l’ajout de données et leur sauvegarde 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 bien en mémoire le fil de votre progression

8.1. TODO Étape 7-a : Ajout d’une commande créant de nouvelles entités

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.

Procédez aux étapes suivantes :

  1. Vous utilisez encore une fois l’assistant make:command (une alternative pourrait être de copier-coller le code existant et faire les bons renommages).
  2. Comme d’habitude, il nous faut cabler la commande avec le module Doctrine permettant d’intéragir avec la base de données, au moyen du Repository, et ajouter un constructeur adapté, quasi identique à ce qu’on a fait plus tôt sur ListTagsCommand :

    // ...
    
    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();
            }
    
            // ...
    
  3. 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;
                    }
            }
    
    
    
  4. Réglez les derniers détails nécessaires à l’invocation de la commande (gestion des arguments attendus dans configure(), …), en vous inspirant du code présent dans les commandes existantes liés aux tâches.
  5. 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 non-plus dans la bibliothèque Doctrine, comme nous le signale l’erreur.

    C’est normal, il s’agit là d’une amélioration du code qu’on peut faire pour le rendre plus lisible.

    Ajoutons la méthode save() à 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();
            }
    }
    

    La documentation de Doctrine vous indiquera comment utiliser Doctrine pour sauvegarder des données : préparer la sauvegarde des entités modifiées avec persist(), puis lancer les requêtes de mise à jour vers la base de données avec flush().

    On reverra cela en cours en détails. Inutile de voir tout ça pour l’instant. Disons pour résumer qu’on préfère ajouter une méthode save() au repository, qui se charge de faire tout ça pour nous en un seul appel de méthode. Cela rend le code d’execute() plus lisible, désencombré des détails de Doctrine.

  6. Une fois finalisé la méthode AddTagCommand::execute(), vérifiez que ça fonctionne et que 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.

9. Étape 8 : 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.

Nous avons vu comment modifier des données en créant des instances d’une entité et en les sauvegardant dans la base de données.

Et comme nous avions ajouté une association au modèle de données, il est temps maintenant de voir comment créer des associations et les sauvegarder. On fait la synthèse des deux étapes précédentes.

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.

Cette étape requiert de nombreuses sous-étapes, qu’on a décomposé pas-à-pas. Gardez bien en mémoire le fil de votre progression, et tout ira bien.

9.1. TODO Étape 8-a : 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.

9.2. TODO Étape 8-b : Chargement des données existantes

Ajoutons le chargement des données correspondant aux arguments tagName et todoId reçus par la commande.

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, passée via todoId.

    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;
            }
    
            // ...
    
    
  3. 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);
    

9.3. TODO Étape 8-c : Utilisation de dump() pour examiner les données en mémoire

Examinons maintenant ce qui se passe en mémoire dans l’exécution execute(), ce code utilisant Doctrine.

On peut mettre à profit l’outil dump() de Symfony, qui sera très puissant pour vous aider dans la mise au point du 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, dans l’affichage des dump() ci-dessous :

  • 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
  }
}

Normal, on n’a pas encore associé la tâche et l’étiquette (on suppose que c’est la première exécution, et qu’il n’y avait pas d’association entre tâches et étiquettes, ayant été sauvegardées auparavant dans la base de données)

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

9.4. TODO Étape 8-d : 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. La collection « #363 » contient un élément (le même « #1359 » d’id 2 et de name « facile » : la même référence en mémoire que notre objet $tag) :

    ^ 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 « #1369 » 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).

    On voit bien qu’en mémoire, il s’agit de passer des références et de les mémoriser : les objets sont liés entre-eux par l’association.

  5. Vérifiez enfin 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 maintenant mutuellement (« #1369 » contient bien maintenant un élément, #359 : nos deux entités sont liées l’une à l’autre et réciproquement) :

    ^ 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 effectuées en mémoire, mais quand on recharge les données, les collections restent désespérément vides.

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

    Vérifiez dans les logs… vous ne devriez pas voir passer d’INSERT…

9.5. TODO Étape 8-e : Sauvegarde dans la base de données de l’association existante en mémoire

Dernier élément à coder dans execute(), faire en sorte que ces modifications soient 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 structures de données par exemple avec les associations 1-N OneToMany

10. Étape 9 : Chargement de données liées dans une association ManyToMany

L’objectif de cette étape est de comprendre comment programmer la « navigation » dans les associations entre entités de notre modèle de données, en parcourant une association ManyToMany.

Vous avez vu dans les étapes précédentes comment ajouter une association au modèle de données, et que cette association fonctionne bien : les données de l’association peuvent être bien sauvegardées dans la base de données.

Mais on n’a pas vraiment vu comment exploiter cette association, pour parcourir les données liées dans le code de l’application.

C’est la dernière étape de cette séquence. Vous touchez au but.

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

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

Nous allons voir comment charger des données liées, pour les afficher dans le code de l’application

Nous allons modifier l’affichage des tâches, qui existe déjà, pour ajouter aussi l’affichage des étiquettes de chaque tâche.

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

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 se passe le chargement des données liées venant de tables séparées ? « Automagiquement » grâce à Doctrine.

Nous allons exploiter les méthodes d’accès aux propriétés multi-valuées, de type collection, fournies par l’API objet de Doctrine.

Si on accède à la propriété 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 capot sans qu’on ait à s’en préoccuper (jointures, etc.).

Il n’est pas question d’avoir à coder par nous-même des choses affreuses comme : 1) charger en mémoire les tâches d’un côté, 2) puis les étiquettes de l’autre, 3) réconcilier ensuite « manuellement » quelles étiquettes correspondent à quel tâche… on n’est plus à l’ère préhistorique. L’ORM est là pour faire ça.

Mettons cela en pratique : vous allez ajoutez l’affichage des étiquettes de la tâche dans le code de la commande app:show-todo qui était fournie.

Pour réaliser l’affichage des étiquettes d’une tâche, vous pouvez vous inspirer du code ci-dessous. 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) {
        dump($todo);
        $io->text($todo);

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

    // [...]


À vous de jouer…

Normalement, c’est presque trop simple : tout est là.

Si ça fonctionne, vous verrez dans les logs les différentes requêtes SQL de chargement, dans les 3 tables concernées de la base de données.

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

Nous reviendrons dans une prochaine séquence de cours sur les aspects « automagiques » de ce chargement des données liées.

11. 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.

12. Auto-évaluation

À l’issue de cette séquence de travail, 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
  • comprendre le rôle des fonctions de chargement des données des Repositories Doctrine,
  • modifier le comportement par défaut des requêtes de chargement par la surcharge des méthodes find* des Repositories
  • 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
  • utiliser le générateur de code make:entity pour créer de nouvelles entités du modèle de données
  • utiliser le générateur de code make:command pour créer de nouvelles classes gérant des commandes en console pour les développeurs
  • coder avec Doctrine la sauvegarde des données nouvelles, issues des instances construites en mémoire
  • faire fonctionner la sauvegarde dans les collections pour les associations ManyToMany
  • utiliser dump() pour faciliter la mise au point.

DONE Annexes

DONE 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) :

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.

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.

Comme ce concept d’injection de dépendance dépasse le cadre du cours, on s’abstient de rentrer dans les détails. Pour le lecteur curieux, on trouve quelques éléments dans la documentation, dans Fetching Objects from the Database et la section suivante.

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

Cette section explicite, à la vue du code, et des doctrings qu’il contient, la documentation qui nous permet de vérifier, à la source, comment marchent les options de findBy() sur les repositories.

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

Vérifier dans les docstrings du code comment marche findAll() pour savoir comment la redéfinir

La documentation nous dit comment faire… mais pas vraiment pourquoi.

Si l’on veut modifier le comportement dans notre surcharge de méthode, peut-être sera-t-il utile de comprendre le comportement par défaut…

Mais encore faut-il trouver la documentation de l’API (Application Programming Interface) de Doctrine qui devrait préciser cela.

En fait, en l’occurrence, les développeurs de Doctrine ne publient malheureusement pas sur le Web une documentation spécifique de l’API (ou bien elle est bien cachée).

Mais dans un tel cas, on peut 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/, là où Composer les a extraites.

Le code de la classe Doctrine EntityRepository donne plus de détails sur l’API Doctrine (cf. code sur GitHub de orm/src/EntityRepository.php, aussi présent dans vendor/doctrine/orm/src/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(): array
{
        return $this->findBy([]);
}

Bon, OK, mais qu’est-ce que signifie ce passage d’un tableau vide ([]) en argument à findBy() ?

Pour findBy(), un peu plus bas dans le même fichier source, sa doctring précise :

/**
 * Finds entities by a set of criteria.
 *
 * {@inheritDoc}
 *
 * @psalm-return list<T>
 */
public function findBy(array $criteria, array|null $orderBy = null, int|null $limit = null, int|null $offset = null): array
[...]

Hmmm… mais la doc n’est pas là, dans la docstring : il n’y a que {@inheritDoc}

Comment comprendre le rôle des arguments $criteria, $orderBy, $limit et $offset qu’accepte potentiellement findBy() ?

On comprend déjà que notre tableau vide, ça doit être $criteria (qui n’est pas optionnel, car pas déclaré « = null » par défaut si absent).

Eh oui, cette classe implémente une interface… et il faut encore aller trouver la docstring de cette interface Doctrine\Persistence\ObjectRepository (aussi présent dans vendor/doctrine/persistence/src/Persistence/ObjectRepository.php dans votre IDE).

Notez qu’elle est présente présente dans un autre référentiel de GitHub (vive l’objet, les projets libres qui dépendent les uns des autres… quel jeu de piste !).

Ça nous dit finalement :

/**
 * Finds objects by a set of criteria.
 *
 * Optionally sorting and limiting details can be passed. An implementation may throw
 * an UnexpectedValueException if certain values of the sorting or limiting details are
 * not supported.
 *
 * @param array<string, mixed>       $criteria
 * @param array<string, string>|null $orderBy
 * @psalm-param array<string, 'asc'|'desc'|'ASC'|'DESC'>|null $orderBy
 *
 * @return array<int, object> The objects.
 * @psalm-return T[]
 *
 * @throws UnexpectedValueException
 */
public function findBy(
        array $criteria,
        ?array $orderBy = null,
        ?int $limit = null,
        ?int $offset = null
    );

On est arrivé à ce qu’on cherchait à vérifier : findBy() accepte bien le fait de lui passer deux arguments, et on sait leur rôle :

  • array<string, mixed> $criteria : le « critère » (de filtre) : un tableau associatif [ « chaîne » => …] fera l’affaire ?

    C’est cohérent avec la doc de Doctrine qui donne comme exemple

    // All users that are 20 years old
    $users = $em->getRepository('MyProject\Domain\User')->findBy(array('age' => 20));
    

    le tableau associatif [clé => valeur] : c’est « array<string, mixed> ». Ça matche pour array('age' => 20), dans l’exemple.

  • array<string, string>|null $orderBy : le critère de tri : s’il est défini, un tableau associatif [ « chaîne » => « chaîne »] fera l’affaire ?

    C’est cohérent avec la doc de Doctrine qui donne comme exemple :

    The EntityRepository#findBy() method additionally accepts orderings, limit and offset as second to fourth parameters :

    $tenUsers = $em->getRepository('MyProject\Domain\User')
                   ->findBy(array('age' => 20),
                            array('name' => 'ASC'),
                            10,
                            0);
    

    le tableau associatif [clé => valeur chaîne] : c’est « array<string, string> »

    d’ailleurs les valeurs autorisées semblent seulement 'asc'|'desc'|'ASC'|'DESC'… ça matche pour array('name' => 'ASC'), dans l’exemple

Vous comprenez donc maintenant (peut-être) mieux le rôle des arguments qu’on a passé à findBy() dans la redéfinition de TodoRepository::findAll() ci-dessus :

return $this->findBy(
                [],
                ['completed' => 'ASC']
                );
  1. premier argument attendu $criteria : []. C’est le critère de filtre du WHERE de la requête SQL.

    Ici un tableau vide, donc pas de filtre à appliquer. On charge tout (find all !);

  2. deuxième argument optionnel $orderBy : ['completed' => 'ASC']

    C’est le critère de tri (par valeurs de completed). Tadaaa !

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 !

Author: Olivier Berger (TSP)

Date: 2024-09-13 Fri 15:15

Emacs (Org mode)