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
) - Nouveautés : Association 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 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
.
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.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.
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.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 dansTodoRepository.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 !
- 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, 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']);
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.
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 étiquettessrc/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 :
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']; }
- 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)
- Assurez-vous de bien ajouter l’appel à cette nouvelle méthode
loadTags()
dans la méthodeload()
existante. 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 celà 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
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 :
- Vous utilisez encore une fois l’assistant
make:command
(une alternative pourrait être de copier-coller le code existant et faire les bons renommages). Comme d’habitude, il nous faut cabler la commande avec le module Doctrine permettant d’intéragir avec la base de données, 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()
, …), 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 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.- Une fois finalisé la méthode
AddTagCommand::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 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 pourarray('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'] );
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. On charge tout (find all !);
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 !