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
- 2. Étape 1 : Mise en place de la séance
- 3. Étape 2 : Observation des requêtes sur l’application fil-rouge « ToDo »
- 4. Étape 3 : Modification de l’ordre de tri par défaut des tâches
- 5. Étape 4 : Consultation de la documentation Symfony au sujet de Doctrine
- 6. Étape 5 : Ajout d’une nouvelle entité au modèle de données : étiquettes (
Tag
) - Vers un modèle de données réaliste : associations entre entités et modification des données
- 7. Étape 6 : Ajout d’une association M-N au modèle de données
- 8. Étape 7 : Coder la modification des données et la sauvegarde en BD
- 9. Étape 8 : Coder des ajouts sur les associations ManyToMany
- 9.1. TODO Étape 8-a : Génération du squelette d’une nouvelle commande
app:add-tag
- 9.2. TODO Étape 8-b : Chargement des données existantes
- 9.3. TODO Étape 8-c : Utilisation de
dump()
pour examiner les données en mémoire - 9.4. TODO Étape 8-d : Ajout en mémoire d’un lien entre deux entités
- 9.5. TODO Étape 8-e : Sauvegarde dans la base de données de l’association existante en mémoire
- 9.1. TODO Étape 8-a : Génération du squelette d’une nouvelle commande
- 10. Étape 9 : Chargement de données liées dans une association ManyToMany
- 11. Conclusion
- 12. Auto-évaluation
- DONE Annexes
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.
Effectuez les opérations suivantes pour dupliquer le dossier :
cd "$HOME/CSC4101" cp -r tp-01 tp-02 cd tp-02 cd todo-app
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.
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/
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.
Exécutez une des commandes de l’application :
symfony console app:list-todos
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.
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 unfindAll()
:[...] 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 successivement deux façons de faire cela.
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
(dans src/Command/ListTodosCommand.php
).
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
.
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(); $criteria = []; $orderBy = ['completed' => 'ASC']; $todos = $this->todoRepository->findBy($criteria, $orderBy); //... }
On examinera la syntaxe de l’appel à
findBy()
un peu plus tard.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.
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.
Notez qu’ici, on ne réalise pas le tri en mémoire dans le code PHP de notre application. On fait réliser le tri par le SGBD sous-jacent, ce qui nous assure les meilleurs performances possibles. SQL est là pour ça !
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
.
Modifiez le code de la classe
TodoRepository
pour ajouter la méthodefindAll()
.Vous pouvez par exemple coder ceci dans
src/Repository/TodoRepository.php
, pour redéfinir la méthodefindAll()
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 danssrc/
, 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.- Supprimez le changement effectué précédamment dans
ListTodosCommand::execute()
pour remettre$todos = $this->todoRepository->findAll();
- Vérifiez le comportement de
app:list-todos
, qui est bien impacté comme on le souhaite. - Vérifiez que la requête est inchangée en ce qui concerne l’
ORDER-BY
. 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 (l’ordre de tri), il faut penser objet : on fait ça via la redéfinition d’une méthode. Rien de spécifique à PHP, ou Symfony. On fait aussi ç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 de base.
Examinons, sans trop rentrer dans les détails, pourquoi le cadriciel Symfony nous permet de faire cela, dans la philosophie objet.
4.3.1. Identifier où est défini le comportement par défaut de findAll()
Dans Eclipse, on peut visualiser la hiérarchie des classes (Type
Hierarchy). Ainsi, on peut sélectionner TodoRepository
et utiliser le
menu Quick Type Hierarchy qui affiche cette arborescence :
Le cadriciel Symfony est complexe et met en oeuvre des patrons de conception objet que nous n’allons pas développer ici, mais on peut constater qu’il y a différentes interfaces qui entrent en jeu. Cela vous donne un aperçu de la puissance de l’outil, mais aussi de sa complexité.
Essayons de voir si parmi ces classes dont hérite TodoRepository
, il y
en a qui proposent une implémentation de
findAll()
. Si on navigue sur Doctrine\ORM\EntityRepository
, dans cette
hiérarchie, on trouve
en effet une méthode findAll()
. Bonne pioche !
C’est pourquoi nous vous avions suggéré la doctring de
la méthode findAll() qui comportait cette annotation : @see
\Doctrine\ORM\EntityRepository::findAll()
, dans le code fourni
ci-dessus pour TodoRepository
.
4.3.2. Examen de l’implémentation par défaut de findAll()
Dans l’IDE Eclipse, lorsque vous positionnez la souris au-dessus d’un
élément, la documentation est affichée dans un popup. Par exemple,
pour EntityRepository::findAll()
, dans notre doctring :
Vous pouvez également positionner le curseur dessus, et appuyer directement sur F2 :
Ainsi, si vous positionnez le curseur sur la déclaration de la méthode findAll()
dans
TodoRepository
, le popup vous indique « Overrides: findAll() in
EntityRepository ».
Si vous cliquez sur ce lien, puis F2 pour charger le code de
EntityRepository::findAll()
, l’IDE ouvre directement le code source fourni par Symfony (dans vendor/doctrine/orm/src/EntityRepository.php
), pour
EntityRepository::findAll()
.
Vous pouvez constater qu’il implémente ceci :
/** * Finds all entities in the repository. * * @phpstan-return list<T> The entities. */ public function findAll(): array { return $this->findBy([]); } /** * Finds entities by a set of criteria. * * {@inheritDoc} * * @phpstan-return list<T> */ public function findBy(array $criteria, array|null $orderBy = null, int|null $limit = null, int|null $offset = null): array { // ...
On voit donc ici, que tout récupérer (find all), c’est comme filtrer (find by) avec un critère vide (le premier argument criteria
de findBy()
valant []
).
L’appel à findBy() ne comporte pas d’autre argument. L’argument
orderBy
prend donc la valeur par défaut (nulll
).
L’ordre de tri par défaut de Doctrine est donc contrôlé dans ce
morceau de code du repository
générique (EntityRepository::findAll()
), tant qu’on n’ajoute pas de
méthode findAll()
spécifique.
C’est pourquoi on en arrive à le redéfinir, dans
TodoRepository::findAll() via : findBy([], ['completed' =>
'ASC'])
. Ainsi, on
se contente d’ajouter un deuxième argument oderBy qui fournit un
critère ascendant pour l’attribut completed
.
Ce jeu de piste dans le code nous permet de mieux comprendre la façon
dont on peut s’y prendre pour coder, dans le code d’une classe
Repository spécifique, fille de EntityRepository
, la surcharge d’une
méthode comme findAll(), de cette classe de base.
4.3.3. Conclusion sur la redéfinition en objet
Dans des projets basiques, 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 mêmes critères pour 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 toutes.
On a aussi besoin de ce genre de factorisations pour réaliser des requêtes de jointures particulières, quand le modèle de données se complexifie.
C’est pour cela qu’on ajoute des classes présentes dans
src/Repository/
, comme notre classe TodoRepository
, à chaque fois
qu’on enrichit notre modèle de données (deuxième option).
Comme dans les projets Symfony et Doctrine, on en arrive vite à
créer beaucoup de classes Repository spécialisées, 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.
Allons maintenant examiner ce que dit la documentation au sujet de ces méthodes find*() de Doctrine.
5. Étape 4 : Consultation de la documentation Symfony au sujet de Doctrine
Si l’on n’a pas l’envie (ou les compétences) d’explorer le code du cadriciel et de faire un jeu de piste dans l’arbrescence des classes objet, comment savoir comment modifier l’ordre de chargement des tâches dans notre code ? En se référant à la documentation de Symfony et de son composant 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 retrouvez donc maintenant ce que la documentation nous indique du rôle des arguments qu’on a passé à =findBy(), dans les modifications du code ci-dessus.
Si on veut charger toutes les tâches et les trier au moment du chargement, on fait donc :
...->findBy([], ['completed' => 'ASC']);
premier argument attendu
$criteria
: C’est le critère de filtre duWHERE
de la requête SQL.Ici un tableau vide :
[]
, donc pas de filtre à appliquer;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.
Et au pire, comme on l’a vu, quand la doc fait défaut, on pourra toujours se référer au code et aux annotations dans les doctrings, qui sont comprises par notre IDE.
5.2. Conclusion sur la méthodologie pour le développement avec un cadriciel objet
Nous avons appliqué le principe de surcharge des méthodes de base fournies par le cadriciel Symfony, pour implémenter un comportement spécifique à notre projet (changer l’ordre de tri de celui par défaut). C’est une façon de faire très commune (et recommandée) dans le monde objet.
Pour savoir comment s’y prendre au mieux, nous avons pu nous appuyer sur plusieurs outils :
- se référer autant que possible à la documentation, même si elle peut être parfois inadaptée, et disparate
- les indices données par l’IDE quand on consulte le code, ce qui peut même nous amener à explorer le code source des composants du cadriciel objet
Ce n’est pas toujours trivial, mais c’est ainsi qu’on s’y prend avec un cadriciel objet.
On peut aussi avoir une approche beaucoup moins rigoureuse, en demandant à des perroquets stochastiques de faire le job, mais assumeriez-vous alors votre responsabilité professionnelle sur du code issu de réponses générées de façon plus ou moins magique ?
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.
Il est temps de passer à des modifications substentielles de notre application, en ajoutant une nouvelle fonctionnalité. Ça va coder !
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.
Nous rappelons le détail de l’enchaînement des commandes et des modifications de code associées, mais si vous avez déjà travaillé sur la création de nouvelles entités et commandes (sur l’application « mini-allociné », par exemple).
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
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 étiquettessrc/Repository/TagRepository.php
: gère le chargement des étiquettes depuis la base de donnée
Attention à lire attentivement les questions (ainsi que les messages en réponse), et à éviter le copier/coller un peu rapide.
Si jamais vous vous trompez, vous pourrez supprimer les deux fichier générés, et recommencer.
Rafraîchissez votre projet dans l’IDE pour voir apparaître ces deux classes (Eclipse n’est pas forcément bien mis au courant du fait que vous avez lancé le générateur de code : sélectionnez le répertoire « src » dans l’explorateur du projet et menu Refresh / F5)
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.
Vous aviez bien noté ces commandes « symfony console doctrine:… » quelque part pour vous en souvenir, n’est-ce pas ?
Non ? Bon, et bien, il n’est pas trop tard pour ce faire !
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 :
Copiez-collez le code ci-dessous de façon à ajouter les méthodes
loadTags()
etgetTagsData()
à la classeAppFixtures
(dans le fichier sourcesrc/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']; }
Il faut ensuite vous assurer que toutes les déclarations sont correctes pour que l’ajout de ce code soit correct.
Ici, l’IDE Eclipse vous donne des indices, en principe, s’il souligne des choses en rouge.
Dans Eclipse, en marge de l’appel à
new Tag()
(qui est souligné en rouge), l’IDE indique le problème présent dans un popup : « Tag cannot be resolved to a type ».En cliquant sur l’icône avec la croix rouge dans la marge, différentes solutions sont proposées :
En général, la première est la bonne (ici : "Use ’Tag’ (App/Entity)"), qui corrige la déclaration manquante (ajout de
use App\Entity\Tag;
au début du fichier source).Vous pouvez donc ajouter l’appel à cette nouvelle méthode
loadTags()
dans la méthodeload()
existante :public function load(ObjectManager $manager): void { $this->loadTodos($manager); $this->loadTags($manager); }
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 classeListTagsCommand
, 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
.
Modifiez la méthode
execute()
deListTagsCommand
: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; }
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 cela en ajoutant une méthode
Tag::__toString()
danssrc/Entity/Tag.php
. Vous pouvez vous inspirer de la méthodeTodo::__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
Vers un modèle de données réaliste : associations entre entités et modification des données
Jusqu’ici, vous avez revisité ce qui avait été découvert de façon superficielle dans la séance précédente. Nous avons regardé en détail certains aspects du code, avec les outils associés, pour le générer et le modifier là où c’est judicieux.
Le modèle de données était jusqu’ici très pauvre : une entité objet (Todo) stockée dans une table du SGBD, une autre (Tag), stockée dans une autre table.
Passons maintenant à des concepts plus avancés dans la découverte des fonctionnalités du modèle de données avec Symfony et Doctrine.
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 (on fait référence à la terminologie définie en cours de bases de données).
Nous allons voir comment gérer de telles associations dans notre modèle objet avec Doctrine.
Ensuite on approfondira comment on peut modifier les données dans notre 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 reproduites ci-dessous, 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
, contenant les é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
Chargement des données de tests :
symfony console doctrine:fixtures:load -n
Quand on vous dit de vous souvenir de ces commandes, qui reviennent periodiquement…
Bravo. 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 à notre application de nouvelles commandes utilisables en console, 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 :
- Vous utilisez encore une fois l’assistant
make:command
(une alternative pourrait être de copier-coller le code existant pour les tâches, puis faire les bons renommages). Comme précédamment, 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(); } // ...
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 dansNewTodoCommand::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 (gestion
des arguments attendus dans
configure()
etc.), en vous inspirant du code présent dans les commandes existantes liés aux tâches. 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éthodeTagRepository::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 dansTodoRepository::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 avecflush()
.On reverra cela en cours en détails. Inutile d’en dire beaucoup plus 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.- Une fois finalisé la méthode
NewTagCommand::execute()
, vérifiez que ça fonctionne et que vous voyez passer les requêtes SQLINSERT
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 :
- Utilisez
make:command
à nouveau pour générer le squelette de code danssrc/Command/AddTagCommand.php
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()
:
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); // ...
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; } // ...
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éthodefindOneBy()
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 unePersistentCollection
, 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.
Modifiez
execute()
en ajoutant ce code ://... // 2. add tag to todo $todo->addTag($tag); dump($todo); dump($tag);
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 laTodo
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 dename
« 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 } }
- 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). 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.
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 :-)
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
.
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; }
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 :
- chargement de la tâche par son numéro « 1 » (appel à
find()
, qui charge ici notre tâche d’id
1) - 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) - 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 (premierINNER JOIN
avec la table de jointure créée dans la base pour gérer l’association M-N :todo_tag
) - de façon réciproque, chargement des tâches de l’étiquette
(deuxième
INNER JOIN
sur la table d’association) - enfin, il est temps de sauvegarder les données (
persist()
etflush()
générés par l’appel àsave()
) :- 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)
- 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’associationtodo_tag
.
- chargement de la tâche par son numéro « 1 » (appel à
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 Doctrine est là pour faire ça.
Mettons cela en pratique : vous allez ajouter 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 :
doctrine.DEBUG: Executing statement: CREATE TABLE tag (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL) {"sql":"CREATE TABLE tag (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL)"} [] doctrine.DEBUG: Executing statement: CREATE UNIQUE INDEX UNIQ_389B7835E237E06 ON tag (name) {"sql":"CREATE UNIQUE INDEX UNIQ_389B7835E237E06 ON tag (name)"} []
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.