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. TODO É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
) - 7. Étape 6 : Nouveauté : association entre entités et modification des données
- 8. Étape 7 : Chargement de données liées dans une association ManyToMany
- 9. Conclusion
- 10. Auto-évaluation
- 11. Annexe
1. Objectifs de cette séquence
Cette séquence de travail a pour objectif de compléter la découverte des fonctionnalités du module Doctrine pour la gestion du modèle de données en PHP.
La séance de travail précédente a permis de se familiariser avec l’environnement de travail du développeur Symfony, et avec le composant d’accès aux données Doctrine.
On a étudié pour l’instant le fonctionnement de l’application « fil-rouge » ToDo sur la partie la consultation de la base de données.
On va maintenant lui ajouter des fonctions permettant de modifier des données, dans une première partie de la séance.
À l’issue de cette séance, on maîtrisera les outils de base pour gérer la persistence en base de données en PHP objet avec Doctrine. Cela permettra de développer les fondements d’une application Web Symfony typique, pour pouvoir appliquer tout cela au projet.
2. Étape 1 : Mise en place de la séance
2.1. TODO Préparation du répertoire de travail
Vous allez dupliquer l’arborescence du projet PHP obtenu à la fin de la séance précédente, pour travailler sur une nouvelle version.
Vous pourrez ainsi faire évoluer le code, et revenir en arrière si besoin, en comparant l’état à la fin de la séance prédente, avec l’état courant.
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, ou cache:clear
ne semble pas
suffisant.
Ces manipulations pourront se révéler nécessaires dès que vous aurez à
réorganiser votre environnement de travail, migrer le code d’une
machine à l’autre, ou d’un compte à l’autre.
Gardez une trace de ces commandes quelque part pour pouvoir vous y référer.
2.2. TODO Chargement du nouveau projet dans l’IDE
Vous allez travailler sur un nouveau projet dans le workspace dans l’IDE Eclipse, importé depuis ce nouveau répertoire de travail.
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. TODO Étape 2 : Observation des requêtes sur l’application fil-rouge « ToDo »
Cette étape vise à découvrir le mécanisme de traces permettant de mieux maîtriser la mise au point d’une application Symfony, au niveau de la couche d’accès aux données, pour Doctrine et SQL.
Sous le capot, Doctrine utilise des requêtes SQL SELECT
pour le
chargement des données.
Symfony, comme tous les frameworks, possède des fonctionnalités de debug (déverminage), avec entre autre la possibilité d’avoir des messages plus verbeux, des traces, etc. (en mode développement, au moins; cf. Troubleshooting Problems).
Ce genre d’éléments de diagnostic nous permet d’essayer de traverser les couches d’exécution des différents composants du cadriciel, pour comprendre ce qui se passe, et sans avoir à modifier le code.
On va regarder ici quelles requêtes SQL sont effectivement générées par le code PHP de notre application.
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
(completed
est false
) avant les tâches terminées (completed
à true
).
On va voir deux façons de faire ci-dessous.
Pour mémoire, nous avons présenté certaines fonctionnalités de Doctrine dans les transparents du cours CM 1-2 (Partie 5 : Couche d’accès aux données avec l’ORM Doctrine, également disponible dans Moodle).
4.1. TODO Modification dans le code de la commande app:list-todos
Première option : modifier le code effectuant la requête, à
l’intérieur de la méthode execute()
de notre commande
ListTodosCommand
.
La requête est actuellement faite avec l’instruction de chargement des
tâches $todos = $this->todoRepository->findAll();
qui se traduit par un SELECT *
, comme on vient de le voir.
Par défaut, la requête est faite sans critère de tri des résultats (donc par défaut, triés dans l’ordre où ils ont été stockés dans la table SQLite).
On peut modifier cet appel pour utiliser plutôt la méthode findBy()
en lui passant en second argument un critère de tri sur la valeur de la propriété completed
.
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 Modification de l’ordre de chargement par défaut pour l’ensemble de l’application
Deuxième option : modifier l’ensemble des requêtes de chargement, pas uniquement dans ListTodosCommand
.
Pour cela, il suffit de modifier la classe TodoRepository
, présente
dans src/Repository/TodoRepository.php
, pour surcharger la
méthode findAll()
qu’elle hérite d’une classe parente.
Ainsi, on modifie globalement le comportement du chargement par
défaut, avec un impact sur tout le code qui utilise la méthode
findAll()
du repository de la classe Todo
.
Modifiez le code de la classe
TodoRepository
pour ajouter la méthodefindAll()
.Vous pouvez par exemple coder ceci, pour surcharger la méthode par défaut
findAll()
fournie par la classe de base :/** * {@inheritDoc} * @return Todo[] * @see \Doctrine\ORM\EntityRepository::findAll() */ public function findAll(): array { // findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) return $this->findBy( [], ['completed' => 'ASC'] ); }
Notez que l’IDE nous indiquait qu’une méthode
findAll()
était déjà présente, bien qu’en fait, il n’y en ait aucun code présent 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.
Auriez-vous sû comment faire, sans indications ?
Comment savoir comment on peut écrire ce mécanisme et cette surcharge de méthodes, sans passer des semaines à lire la doc ?
Ou bien, de façon plus réaliste, comprendre quelle est la meilleure façon de faire, à la lecture des dizaines de messages sur des forums répondant plus ou moins à cette question…) ?
Allons maintenant examiner la documentation.
Et non, la bonne solution n’est pas de demander à ChatGPT !
5. Étape 4 : Consultation de la documentation Symfony au sujet de Doctrine
Savoir trouver et lire la documentation de référence est une
compétence importante pour les ingénieurs.
Ça a l’air trivial (et un truc de boomer) d’apprendre à lire la doc… pourtant le temps passé à lire la documentation est souvent profitable par rapport au temps passé à faire le tri dans les réponses de StackExchange.
Notez que Symfony, Doctrine, et toutes les
bibliothèques du framework de développement qu’on utilise évoluent
en permanence. Il est donc important de savoir sur quelle version du
framework on
travaille (nous, c’est la version stable, la 6.3.x actuellement).
En cas de doute : symfony console about
.
Pour lire les documentations pertinentes, attention à ne pas regarder
celles trop vieilles (Symfony v3), mais pas trop récentes (Symfony
v7)…
Nous avons fait de notre mieux pour indiquer des liens vers les « bonnes versions », dans notre contexte :
Symfony 6.3.x, Doctrine 2.13, etc.
Méfiance en cas de recherches dans un moteur de recherche, dans les forums, etc.
5.1. TODO Identification de la documentation de référence de Symfony
Ouvrez la documentation relative à la gestion des données avec Doctrine dans Symfony : Databases and the Doctrine ORM
Vous pouvez parcourir rapidement cette documentation.
Les explications sont utiles, mais font référence au contexte d’une application Web, or nous n’avons pas encore réellement codé pour le contexte Web. Le code des classes Controller ne vous est donc pas encore familier.
Mais vous devriez néanmoins repérer du code similaire à celui de notre application « Todo », par exemple dans Fetching objects from the Database :
$repository = $entityManager->getRepository(Product::class); // look for a single Product by its primary key (usually "id") $product = $repository->find($id); // look for a single Product by name $product = $repository->findOneBy(['name' => 'Keyboard']); // or find by name and price $product = $repository->findOneBy([ 'name' => 'Keyboard', 'price' => 1999, ]); // look for multiple Product objects matching the name, ordered by price $products = $repository->findBy( ['name' => 'Keyboard'], ['price' => 'ASC'] ); // look for *all* Product objects $products = $repository->findAll();
Espérons que cela vous paraît compréhensible.
Doctrine met en œuvre un patron de conception assez courant dans les
couches d’abstraction sur l’accès aux données, le Repository
, qu’on
retrouve dans beaucoup d’autres cadriciels et langages.
Si on veut creuser un plus loin, il faut lire la doc du projet Doctrine lui-même.
Pour connaître toutes les variantes possibles pour faire des requêtes, cette documentation donne plus de détails sur le fonctionnement des repository d’entités : Documentation Doctrine ORM / Working with Objects / By Simple Conditions
Doctrine est un projet libre à part entière pour fournir des utilitaires d’accès aux données en PHP, qui évolue indépendamment de Symfony. Malheureusement, sa documentation donne donc des exemples en PHP, mais pas forcément toujours adaptés au contexte Symfony
Que faire de toute cette documentation ? Le but est de savoir où elle est, et de s’y référer, autant que de besoin, et surtout sur la version qui nous concerne. C’est parfois un jeu de piste, et parfois la documentation manque…
Gardez un marque-page sur les deux liens ci-dessus pour pouvoir vous y référer plus tard (pendant votre projet par exemple). Inutile de tout lire en détail maintenant.
Examinons alors comment se référer à la documentation ultime : le code des composants qu’on utilise.
5.2. TODO Se référer au source des bibliothèques si besoin (par ex. l’API de Doctrine)
Revenons à la modification qu’on vous a proposée, du code de
src/Repository/TodoRepository.php
visant à
modifier le tri effectué au chargement des données, en y ajoutant
une surcharge de la méthode findAll()
.
5.2.1. Principe de la surcharge
Avec Symfony, on développe en objet, et le principe des classes
Repository qui sont codées dans notre modèle de données particulier (dans
src/Repository/)
est de surcharger le comportement par défaut de Doctrine.
Pour modifier le comportement, il faut d’abord penser objet : surcharger le comportement par défaut en spécialisant le comportement de notre repository, par rapport au repository générique de la classe de base. C’est le principe de la surcharge des méthodes en objet. Rien de spécifique à PHP, ou Symfony.
En fait, on pourrait se passer de classes repository spécifiques dans notre modèle de données, mais comme on l’a vu c’est souvent pratique de changer l’ordre de chargement par défaut, ou de réaliser des requêtes de jointures particulières, donc on en arrive vite à créer ce genre de classes.
Vous verrez que l’assistant générateur de code
make:entity
(qu’on va utiliser dans quelques temps) créera ainsi la classe Repository pour nous à chaque fois.
5.2.2. Comment écrire le code de surcharge correctement
Mais encore faut-il trouver la documentation de l’API (Application Programming Interface) de Doctrine qui codifie ce comportement par défaut, si l’on veut le modifier dans notre surcharge de méthode…
En fait, en l’occurrence, ici, les développeurs de Doctrine ne publient malheureusement pas (plus) sur le Web une documentation spécifique de l’API.
Dans ce cas, on doit donc se référer au code. Et ça tombe bien, car ce code contient des commentaires (docstrings) que l’IDE peut exploiter pour nous guider dans l’utilisation des API des bibliothèques comme Doctrine.
En plus, PHP étant un langage interprété, les bibliothèques du
cadriciel Symfony, comme Doctrine, sont présentes sous forme de code
source dans vendor/
où Composer les a téléchargées.
Le code de la classe Doctrine EntityRepository
donne plus de détails
sur l’API Doctrine
(cf. code sur GitHub de lib/Doctrine/ORM/EntityRepository.php
,
aussi présent dans
vendor/doctrine/orm/lib/Doctrine/ORM/EntityRepository.php
dans votre
IDE) :
Tout d’abord, par défaut, findAll()
est juste un appel à findBy()
avec les options par défaut :
public function findAll() { return $this->findBy([]); }
Ensuite, pour findBy()
, sa doctring précise :
/** * Finds entities by a set of criteria. * * @param int|null $limit * @param int|null $offset * @psalm-param array<string, mixed> $criteria * @psalm-param array<string, string>|null $orderBy * * @return object[] The objects. * @psalm-return list<T> */ public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null) [...]
Vous comprenez donc mieux le rôle des arguments qu’on passe à findBy()
dans TodoRepository::findAll()
:
tableau vide
[]
: le premier tableau (array) attendu, pour l’argument$criteria
(le critère de restriction générant unWHERE
dans la requête SQLSELECT
).Ici, on passe un tableau vide, donc il n’y a pas de restriction à appliquer;
['completed' => 'ASC']
: le deuxième tableau (optionnel)$orderBy
, qui nous intéresse justement, pour les critères de tri.
Savoir lire le code des projets qu’on utilise est une compétence importante des ingénieurs, notamment aujourd’hui où la plupart des projets utilisent des composants libres plus ou moins bien documentés.
Use the source, Luke !
6. Étape 5 : Ajout d’une nouvelle entité au modèle de données : étiquettes (Tag
)
Si vous avez déjà travaillé sur la création de nouvelles entités et commandes (sur l’application « mini-allociné », par exemple), vous retrouverez ici les mêmes opérations.
Nous rappelons le détail de l’enchaînement des commandes et des modifications de code associées, mais vous devriez pouvoir les enchaîner rapidement puisque vous avez déjà effectué ce genre de manipulations.
6.1. TODO Étape 5-a : Ajout d’une nouvelle entité : « Tag »
Utiliser l’assistant générateur de code pour ajouter une nouvelle entité au modèle de données
Nous allons ajouter une nouvelle entité Tag
pour gérer dans la base de données des étiquettes qui pourront, plus tard, être liées aux tâches.
Commençons par l’ajout de l’entité Tag
très simple, ayant une propriété name
(une chaîne de caractères).
Si vous êtes comme la plupart des programmeurs, vous aimez modérément
copier-coller du code qui pourrait être généré automatiquement.
Ça tombe bien, Symfony propose différent assistants qu’on ne va pas se
priver d’utiliser pour générer la base du code de nos applications,
plutôt que d’écrire nous-même du code buggé.
Lancez l’assistant make:entity
, dans le terminal, depuis l’intérieur
du projet Symfony. Il va nous servir à générer le code de classes PHP
pour gérer notre entité « Tag ».
Répondez aux questions de l’assistant pour obtenir une interaction similaire à la trace présentée ci-dessous :
symfony console make:entity
Class name of the entity to create or update (e.g. GrumpyElephant): > Tag created: src/Entity/Tag.php created: src/Repository/TagRepository.php Entity generated! Now let's add some fields! You can always add more fields later manually or by re-running this command. New property name (press <return> to stop adding fields): > name Field type (enter ? to see all types) [string]: > Field length [255]: > Can this field be null in the database (nullable) (yes/no) [no]: > updated: src/Entity/Tag.php Add another property? Enter the property name (or press <return> to stop adding fields): > Success! Next: When you're ready, create a migration with symfony console make:migration
L’assistant a créé pour nous deux classes PHP qui utilisent Doctrine :
src/Entity/Tag.php
: gère les instances en mémoire des é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.
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.
6.4. Étape 5-d : Ajout d’une nouvelle commande app:list-tags
Ajoutons une interface en ligne de commande à notre application PHP,
pour disposer d’une commande accessible pour le développeur via
l’interface ligne de commande offerte par bin/console
.
6.4.1. TODO Génération du squelette de la classe ListTagsCommand
Cette fois encore, utilisons un assistant générateur de code pour nous faciliter le travail :
symfony console make:command
Choose a command name (e.g. app:victorious-popsicle): > app:list-tags created: src/Command/ListTagsCommand.php Success! Next: open your new command class and customize it! Find the documentation at https://symfony.com/doc/current/console.html
Consultez le résultat généré dans src/Command/ListTagsCommand.php
(pensez à rafraîchir le contenu du répertoire dans l’IDE, pour voir apparaître ce nouveau fichier source)
Vérifiez que la commande est bien disponible via :
symfony console list app
et :
symfony console app:list-tags --help
Et enfin qu’elle répond quand on l’invoque :
Description: Add a short description for your command Usage: app:list-tags [options] [--] [<arg1>] Arguments: arg1 Argument description Options: --option1 Option description -h, --help Display help for the given command. When no command is given display help for the list command -q, --quiet Do not output any message -V, --version Display this application version --ansi|--no-ansi Force (or disable --no-ansi) ANSI output -n, --no-interaction Do not ask any interactive question -e, --env=ENV The Environment name. [default: "dev"] --no-debug Switch off debug mode. -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
6.4.2. TODO Branchement de notre commande à Doctrine
Pour que notre commande puisse afficher la liste des étiquettes on va devoir réaliser quelques branchements utiles.
Vous pouvez suivre les indications données ici (et copier/coller) ou vous inspirer du code déjà fourni, qu’on a étudié pour ListTodosCommand
.
Modifiez le code de src/Command/ListTagsCommand.php
, pour ajouter
une propriété tagsRepository
dans la classe ListTagsCommand
, permettant
de gérer l’accès à la base de données via le composant Doctrine, et
son initialisation.
Copiez-collez les éléments ci-dessous pour ajouter la propriété et le constructeur dans ListTagsCommand
(attention à ne pas
dupliquer la déclaration de la classe) :
// ... use App\Entity\Tag; use App\Repository\TagRepository; use Doctrine\Persistence\ManagerRegistry; // ... #[AsCommand( name: 'app:list-tags', description: 'Displays all tags', )] class ListTagsCommand extends Command { /** * @var TagRepository data access repository */ private $tagRepository; /** * Plugs the database to the command * * @param ManagerRegistry $doctrineManager */ public function __construct(ManagerRegistry $doctrineManager) { $this->tagRepository = $doctrineManager->getRepository(Tag::class); parent::__construct(); } // ...
6.4.3. TODO Ajout du code d’affichage de la liste des tâches
Ajoutons maintenant le code permettant de charger toutes les tâches présentes dans la base de données.
On utilise la méthode findAll()
du repository
Doctrine des tâches TagRepository
, qui a été généré par
l’asssistant, et qui renvoie un
tableau de tâches. On peut manipuler ce tableau comme un tableau PHP
ordinaire, par exemple avec foreach
.
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()
Si vous n’obtenez pas cette erreur-là, mais plutôt un message disant qu’il n’y a pas de tâches dans la base, c’est probablement que vous avez manqué une étape sur le chargement des données de test des étiquettes ci-dessus. Re-vérifiez que le chargement fonctionne bien.
Réessayez le lancement de notre commande app:list-tags
qui devrait afficher cette-fois quelque chose proche de :
list of tags: ============= * 1 important * 2 facile * 3 urgent * 4 seum
Bravo, vous avez ajouté une nouvelle entité et une commande, en allant relativement vite, grâce aux générateurs de code
Jusqu’ici, vous avez revisité, de façon plus active, en générant le code, et en le modifiant, ce qui avait été découvert de façon plus superficielle dans la séance précédente.
Passons maintenant à des nouveautés : la gestion des associations et les modifications des données.
On monte un degré en difficulté.
7. Étape 6 : Nouveauté : association entre entités et modification des données
Dans une application réaliste, on a de multiples entités au sein du modèle de données, et qui sont reliées par des associations.
Nous allons voir comment gérer de telles associations dans Doctrine.
Ensuite on approfondira comment on peut modifier les données dans le code PHP.
7.1. Étape 6-a : Ajout association Todo-Tag
Le but de cette étape est de mettre en œuvre une association M-N (ou ManyToMany) dans le modèle de données, entre nos tâches et nos étiquettes.
Notre modèle de données, qui contenait initialement une entité tâche
(Todo
), vient d’être enrichi avec les étiquettes (Tag
). On va
maintenant relier ces deux entités indépendantes, par une association
qu’on exploitera dans notre application.
On va relier tâches et étiquettes par une association M-N. c’est à dire qu’une tâche pourra recevoir différentes étiquettes parmi celles présentes dans la base de données, et une même étiquette pourra être ajoutée à plusieurs tâches.
7.1.1. TODO Ajout de propriétés avec le générateur de code make:entity
Ici aussi, on peut utiliser le générateur de code make:entity
pour
modifier le code PHP de nos entités existantes, et y générer les
attributs PHP correspondants à Doctrine de façon beaucoup plus simple
qu’en éditant le code manuellement.
Dans les réponses au dialogue de l’assistant, on va ajouter une
nouvelle propriété tags
à notre entité Todo
existante (make:entity
sait générer du code, ou modifier du code existant).
Cette propriété sera de
type relation avec une autre entité existante, Tag
, et avec une
multiplicité ManyToMany
. De façon réciproque, Tag
recevra une nouvelle
propriété todos
(notez qu’on nomme nos propriétés avec un pluriel,
pour matérialiser le fait qu’il y a plusieurs entités cible de l’association).
symfony console make:entity
Class name of the entity to create or update (e.g. BravePopsicle): > Todo Your entity already exists! So let's add some new fields! New property name (press <return> to stop adding fields): > tags Field type (enter ? to see all types) [string]: > ? Main Types ,* string ,* text ,* boolean ,* integer or smallint or bigint ,* float Relationships/Associations ,* relation or a wizard 🧙 will help you build the relation ,* ManyToOne ,* OneToMany ,* ManyToMany ,* OneToOne Array/Object Types ,* array or simple_array ,* json ,* object ,* binary ,* blob Date/Time Types ,* datetime or datetime_immutable ,* datetimetz or datetimetz_immutable ,* date or date_immutable ,* time or time_immutable ,* dateinterval Other Types ,* ascii_string ,* decimal ,* guid ,* uuid ,* ulid Field type (enter ? to see all types) [string]: > relation What class should this entity be related to?: > Tag What type of relationship is this? ------------ ---------------------------------------------------------------- Type Description ------------ ---------------------------------------------------------------- ManyToOne Each Todo relates to (has) one Tag. Each Tag can relate to (can have) many Todo objects. OneToMany Each Todo can relate to (can have) many Tag objects. Each Tag relates to (has) one Todo. ManyToMany Each Todo can relate to (can have) many Tag objects. Each Tag can also relate to (can also have) many Todo objects. OneToOne Each Todo relates to (has) exactly one Tag. Each Tag also relates to (has) exactly one Todo. ------------ ---------------------------------------------------------------- Relation type? [ManyToOne, OneToMany, ManyToMany, OneToOne]: > ManyToMany Do you want to add a new property to Tag so that you can access/update Todo objects from it - e.g. $tag->getTodos()? (yes/no) [yes]: > A new property will also be added to the Tag class so that you can access the related Todo objects from it. New field name inside Tag [todos]: > updated: src/Entity/Todo.php updated: src/Entity/Tag.php Add another property? Enter the property name (or press <return> to stop adding fields): > Success! Next: When you're ready, create a migration with symfony console make:migration
Rechargez le projet dans l’IDE pour voir apparaître le code modifié dans les classes
PHP Todo
et Tag
(oui, même si nous avons demandé à modifier Todo
, Tag
a été impacté aussi : « Do you want to add a new property to Tag so that you can access/update Todo objects from it - e.g. $tag->getTodos()? (yes/no) [yes] »).
Vous noterez peut-être que le dernier message nous indique qu’on peut procéder à une migration des données.
Cela correspond à la possibilité d’appliquer un traitement aux données présentes dans la base pour prendre en compte le changement de schéma. Or cela concerne essentiellement la production, et pour l’instant, notre application n’a pas été déployée, donc le plus simple est ici de casser la base et la recréer.
On reviendra sur ce mécanisme des migrations plus tard dans le cours.
7.1.2. TODO Observation du code PHP généré
La classe Todo
comporte maintenant un attribut multi-valué tags
qui
agit comme une Collection des instances de Tag
des étiquettes présentes sur cette tâche.
Elle est spécifiée par l’attribut
ORM\ManyToMany
qui définit en particulier l’entité cible de
l’association M-N (targetEntity: Tag::class
).
Elle se manipule en PHP avec une interface Collection
semblable aux
tableaux PHP classiques (Array
).
Plutôt que des getters et setters des propriétés mono-valuées, tags
dispose donc
des méthodes générées getTags(): Collection
,
addTag(Tag $tag)
et removeTag(Tag $tag)
.
Exemple de boucle énumérant les étiquettes d’une tâche :
$tags = $todo->getTags(); if (count($tags)) { foreach($tags as $tag) { print $tag; } }
Il se peut que vous constatiez des erreurs ou warnings, ou des comportements étranges dans Eclipse, qui peuvent s’expliquer dans la mesure où les générateurs de code travaillent « dans son dos ». Dans ce genre de cas, n’hésitez pas à sélectionner le menu « Project > Clean », qui aide parfois à résoudre ce genre de comportements erratiques.
7.1.3. TODO Mise à jour de la base de données
Cette fois encore, on a modifié le schéma de la base de données. Simplifions-nous la vie, et recréons la base de données SQLite, de zéro:
Création du fichier de stockage de la base de données SQLite :
symfony console doctrine:database:drop --force symfony console doctrine:database:create
Création du schéma de la base de données :
symfony console doctrine:schema:create
7.2. Étape 6-b : Ajout de fonctions de modification des données
Dans cette étape, on va expérimenter avec les fonctionnalités de Doctrine permettant l’ajout de données dans la base.
Vous allez ajouter de nouvelles commandes, utilisables en console, dans notre application, pour réaliser cette fois des modifications.
Vous utiliserez à nouveau le générateur de code make:command
pour
aller plus vite, et ne pas commettre trop d’erreurs.
On aborde là la partie la plus difficile de la séance : gardez le fil de votre progression
7.2.1. TODO Étape 6-b.1 : Commande de création de nouvelle étiquette (app:new-tag
)
Sur le modèle des ajouts de commandes effectués précédemment, ajoutez une commande app:new-tag
qui prend en
argument le nom de l’étiquette, et va l’ajouter à la base de données.
Vous utilisez encore une fois l’assistant make:command
(une
alternative pourrait être de copier-coller le code existant et faire
les bons renommages).
Comme d’habitude, il nous faut cabler la commande avec le module Doctrine permettant d’intéragir avec la base de données, et ajouter un constructeur adapté, du style :
// ... use Doctrine\Persistence\ManagerRegistry; #[AsCommand( name: 'app:new-tag', description: 'Creates a new tag', )] class NewTagCommand extends Command { /** * @var TagRepository data access repository */ private $tagRepository; /** * Plugs the database to the command * * @param ManagerRegistry $doctrineManager */ public function __construct(ManagerRegistry $doctrineManager) { $this->tagRepository = $doctrineManager->getRepository(Tag::class); parent::__construct(); } // ...
Il ne reste plus qu’à coder le cœur de la commande dans la méthode
execute()
qui permettra d’ajouter des
données dans la base, via le repository, comme c’est fait dans NewTodoCommand::execute()
:
//... use App\Entity\Tag; use App\Repository\TagRepository; //... protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $tag = new Tag(); $tag->setName($input->getArgument('name')); $this->tagRepository->save($tag, true); if($tag->getId()) { $io->success('Created: '. $tag); return Command::SUCCESS; } else { $io->error('could not create tag!'); return Command::FAILURE; } }
Réglez les derniers détails nécessaires à l’invocation de la commande,
en vous inspirant du code présent dans les commandes fournies (gestion
des arguments attendus dans configure()
, …).
In fine, quand vous testez, vous allez bloquer sur une erreur du type :
In EntityRepository.php line 283: Undefined method "save". The method name must start with either findBy, findOneBy or countBy!
En effet, dans le code du repository des étiquettes, tel que généré par make:entity
, il
nous manque la méthode TagRepository::save()
, et elle n’existe pas
dans la bibliothèque Doctrine, non-plus, comme nous le signale
l’erreur.
Ajoutons-la à TagRepository
, sur le modèle de celle fournie dans TodoRepository::save()
:
public function save(Tag $entity, bool $flush = false): void { $this->getEntityManager()->persist($entity); if ($flush) { $this->getEntityManager()->flush(); } }
Une fois finalisé la méthode AddTagCommand::execute()
, vous voyez
passer les requêtes SQL INSERT
générées par Doctrine dans le fichier de
logs.
En cas de problèmes (commande non reconnue), n’hésitez pas à exécuter la commande suivante, pour mettre au propre l’environnement d’exécution alors que le code a évolué :
symfony console cache:clear
Et rappelez-vous que l’IDE ne sait pas que des fichiers nouveaux sont générés par nos assistants générateurs de code Symfony, donc pensez à recharger le projet, si vous ne trouvez pas certains fichiers source.
Bravo, vous savez maintenant comment on code la sauvegarde de données dans la base avec Doctrine.
Même si on l’avait déjà abordée dans les Fixtures pour les données de tests, là on est dans le code de l’application qui s’exécute, pas dans un utilitaire destiné aux développeurs.
Vous remarquerez peut-être qu’on a la possibilité d’ajouter plusieurs fois de suite des étiquettes de même nom… ce n’est pas idéal, dans une vraie application, mais ne devrait pas être problématique pour notre application fil-rouge, pour les TP.
On explicite comment gérer une contrainte d’unicité dans les annexes, ci-après, pour le lecteur curieux. Inutile de s’y référer pour l’instant.
7.2.2. Étape 6-b.2 : Coder des ajouts sur les associations ManyToMany
Dans cette étape, on va maintenant examiner en détail comment gérer avec Doctrine l’association M-N entre deux entités.
Vous allez ajouter une commande app:add-tag
permettant d’ajouter une
étiquette existante à une tâche existante, en mémoire pendant
l’exécution, puis sauvegardée dans la base de données.
7.2.2.1. TODO Génération du squelette d’une nouvelle commande app:add-tag
Sur le modèle des portions de code précédentes, ajoutez une commande
app:add-tag
prenant en entrée deux arguments :
- 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.
7.2.2.2. TODO Chargement des données existantes
Ajoutons le chargement des données correspondant aux arguments reçus.
Modifiez le code de execute()
:
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 :
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);
7.2.2.3. TODO Utilisation de dump()
Examinons maintenant ce qui se passe en mémoire dans l’exécution de notre code basé sur Doctrine.
On peut utiliser ici l’outil dump()
de Symfony, qui sera très puissant
pour vous aider dans la mise au point de votre code.
Maintenant qu’on a les données en mémoire, complétez =execute() pour afficher notre tâche et notre étiquette :
dump($todo); dump($tag);
Vous remarquez que notre tâche « apprendre les bases de PHP »
(/instance #359 de App\Entity\Todo/
) a une propriété tags
qui est une
PersistentCollection
, qui ne semble rien contenir pour
l’instant. De façon réciproque, notre étiquette « facile » a une
collection de tâches todos
pas beaucoup plus conséquente :
^ App\Entity\Todo^ {#359 -id: 1 -title: "apprendre les bases de PHP" -completed: true [...] -tags: Doctrine\ORM\PersistentCollection^ {#363 #collection: Doctrine\Common\Collections\ArrayCollection^ {#442 -elements: [] } #initialized: false -snapshot: [] -owner: App\Entity\Todo^ {#359} -association: array:20 [ …20] -em: ContainerOeMwtvh\EntityManagerGhostEbeb667^ {#267 …12} -backRefFieldName: "todos" -typeClass: Doctrine\ORM\Mapping\ClassMetadata {#357 …} -isDirty: false } } ^ App\Entity\Tag^ {#1359 -id: 2 -name: "facile" -todos: Doctrine\ORM\PersistentCollection^ {#1368 #collection: Doctrine\Common\Collections\ArrayCollection^ {#1369 -elements: [] } #initialized: false -snapshot: [] -owner: App\Entity\Tag^ {#1359} -association: array:16 [ …16] -em: ContainerOeMwtvh\EntityManagerGhostEbeb667^ {#267 …12} -backRefFieldName: "tags" -typeClass: Doctrine\ORM\Mapping\ClassMetadata {#403 …} -isDirty: false } }
dump()
est un outil précieux pour consulter le contenu interne de
nos objets, débugger, etc. Oublié print()
!
7.2.2.4. TODO Ajout en mémoire d’un lien entre deux entités
Il est temps d’ajouter l’étiquette à la tâche, en mémoire.
Plus précisément, on va ajouter l’étiquette qu’on a chargée depuis la base de donnée, dans la collection des étiquettes de la tâche.
Rien de plus compliqué qu’utiliser la méthode Todo::addTag()
, qui est
là pour cela.
Notez que Todo::addTag()
a été ajoutée à notre code par make:entity
,
lors de l’ajout de l’association entre tâches et étiquettes.
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 :^ 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 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
).Vérifiez dans l’affichage de dump() à l’exécution après cette dernière modification.
Le modèle en mémoire est cohérent : nos deux entités se référencent mutuellement :
^ App\Entity\Todo^ {#359 -id: 1 -title: "apprendre les bases de PHP" -completed: true [...] -tags: Doctrine\ORM\PersistentCollection^ {#363 #collection: Doctrine\Common\Collections\ArrayCollection^ {#442 -elements: array:1 [ 0 => App\Entity\Tag^ {#1359 -id: 2 -name: "facile" [...] } ] } #initialized: true -snapshot: [] -owner: App\Entity\Todo^ {#359} -association: array:20 [ …20] -em: ContainerOeMwtvh\EntityManagerGhostEbeb667^ {#267 …12} -backRefFieldName: "todos" -typeClass: Doctrine\ORM\Mapping\ClassMetadata {#357 …} -isDirty: true } } ^ App\Entity\Tag^ {#1359 -id: 2 -name: "facile" -todos: Doctrine\ORM\PersistentCollection^ {#1368 #collection: Doctrine\Common\Collections\ArrayCollection^ {#1369 -elements: array:1 [ 0 => App\Entity\Todo^ {#359 -id: 1 -title: "apprendre les bases de PHP" -completed: true [...] } } ] } #initialized: true -snapshot: [] -owner: App\Entity\Tag^ {#1359} -association: array:16 [ …16] -em: ContainerOeMwtvh\EntityManagerGhostEbeb667^ {#267 …12} -backRefFieldName: "tags" -typeClass: Doctrine\ORM\Mapping\ClassMetadata {#403 …} -isDirty: true } }
En plus d’afficher de jolies couleurs, bien qu’il y ait un cycle dans nos données, dump() fait du bon boulot et ne part pas dans une boucle infinie ! Nice :-)
Lancez plusieurs fois de suite la même commande
app:add-tag
. Vous constatez que les modifications sont bien effectuez en mémoire, mais quand on recharge les données, les collections restent désespérément vides.Il manque finalement la persistence en base de données.
7.2.2.5. TODO Sauvegarde de l’association en base de données
Dernier élément à coder dans execute()
, faire en sorte que ces modifications soit
sauvegardées en base de données.
Si vous avez observé attentivement les dumps avant/après ci-dessus,
vous remarquerez qu’avant l’ajout en mémoire, les entités
comportent une propriété interne à Doctrine isDirty
à false
. Cette
propriété passe à true
une fois appelé addTag()
(sur les deux
entités).
C’est le mécanisme qui indique à Doctrine que les données en mémoire ont changé par rapport à la base de données.
On va devoir sauvegarder explicitement, dans notre code, en
ajoutant l’appel à save()
pour l’une des entités, par exemple
$todo
.
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 strcutures de données par exemple avec les associations 1-N OneToMany
8. Étape 7 : Chargement de données liées dans une association ManyToMany
L’objectif de cette étape est de naviguer dans les associations entre entités de notre modèle de données, en parcourant une association ManyToMany.
Si vous avez terminé l’étape précédente, tout va bien : vous avez réalisé une première application PHP utilisant Symfony, et qui est capable de s’interfacer avec une base de données relationnelles, pour charger des données.
Allons plus loin pour progresser un peu dans notre connaissance du modèle de données objet de PHP, qu’on met en œuvre avec Doctrine, dans les applications Symfony.
Cette fois, nous fournissons un peu moins d’aide pour diminuer le copier/coller et vous laisser retrouver les opérations par vous-même.
8.1. TODO Étape 7-a : Ajout de l’affichage des étiquettes d’une tâche
Nous allons parcourir les données de notre application en utilisant l’approche objet, pour charger des données liées, afin d’afficher les étiquettes de chaque tâche.
En base de données, les tâches et les étiquettes sont stockés dans des tables séparées (avec une table d’association pour matérialiser l’association M-N).
Comment allons-nous gérer cela en PHP ? En objet !
Plutôt que de charger en mémoire les tâches d’un côté, puis les étiquettes de l’autre, et de réconcilier ensuite « manuellement » quelles étiquettes correspondent à quel tâche, nous allons exploiter les méthodes de parcours d’attributs de type collection dans l’API Objet fournie par Doctrine.
Si on accède à l’attribut Todo::tags
via
$todo->getTags()
(déjà introduit brièvement plus haut), on
programme en objet tout naturellement… et Doctrine fera les
chargements correspondants sous-le capôt sans qu’on ait à s’en
préoccuper (jointures, etc.).
Mettons cela en pratique : on va ajouter à la commande app:show-todo
fournie, l’affichage des étiquettes de la tâche
Vous pouvez vous inspirer du code ci-dessous pour l’affichage des
étiquettes d’une tâche. Observez la façon dont est écrit le foreach
qui permet d’accéder aux données liées dans la collection des
étiquettes de cette tâche :
protected function execute(InputInterface $input, OutputInterface $output) { // [...] if ($todo) { $io->text($todo); $tags = $todo->getTags(); if(count($tags)) { $io->text('tags:'); foreach($tags as $tag) { $io->text('- '. $tag); } } } // [...]
Bravo, vous savez maintenant comment accéder aux données, en programmant avec une approche objetc en PHP, grâce Doctrine, y compris pour des données liées par des associations M-N.
9. Conclusion
Nous avons vu en détails comment gérer le modèle de données en PHP avec Doctrine, pour coder le chargement ou la modification des données, à partir d’un code exécuté en ligne de commande.
Nous avons examiné également le fonctionnement d’une association ManyToMany entre entités de notre modèle de données.
Au passage vous avez utilisé de nombreux outils du cadriciel, dont les
traces (logs) et dump()
, qui vont vous resservir souvent, dans la
suite des séances.
10. Auto-évaluation
À l’issue de cette séquence de travail autonome, vous savez :
- expliquer le rôle de l’ORM Doctrine, pour utiliser l’approche Objet à partir d’un modèle de données programmé dans des classes PHP
- identifier la documentation de Doctrine pour pouvoir vous y référer
- identifier les fonctions de chargement des données via les méthodes des repositories dans le code PHP
- manipuler des données de tests pour initialiser la base de données avec les data fixtures
- observer les requêtes SQL dans les traces pour aider à la mise au point
- recréer la base de données et son schéma
- coder avec Doctrine la sauvegarde des données nouvelles, issues des instances construites en mémoire
- comprendre le mécanisme de persistence dans les collections pour les associations ManyToMany
- utiliser
dump()
pour faciliter la mise au point.
11. Annexe
11.1. Exemple de vérification de contrainte d’unicité
Dans la gestion des étiquettes, elles ont une seule propriété name
dont
on pourrait souhaiter que sa valeur soit unique (en effet, dans
l’implémentation courante, rien n’empêche de stocker dans la base deux
étiquettes de même nom).
On peut implémenter cette contrainte d’unicité à plusieurs niveaux :
- a minima dans la base de données (c’est le rôle du SGBD de garantir de façon ultime ce genre de contraines d’intégrité de notre modèle de données)
de façon plus intéressante, via notre code applicatif.
En effet, même si la base de données fait le job, on peut préférer détecter la violation de contrainte au plus tôt, en mémoire, dès la saisie des données entrantes dans l’application, sans attendre d’essayer de les sauver dans la base de données, et d’obtenir à ce moment là une erreur.
Un mécanisme avancé de Symfony permet de faire cela : le Validator.
Examinons les deux options (qu’on peut combiner) :
11.1.1. Unicité vérifiée dans le SGBD via Doctrine
Exemple de code déclarant comme unique la propriété, du point de vue
du SGBD, dans l’entité Tag
, avec le paramètre unique
de l’attribut
ORM\Entity
de Doctrine :
#[ORM\Entity(repositoryClass: TagRepository::class)] class Tag { #[ORM\Column(length: 255, unique: true)] private ?string $name = null;
Cette contrainte d’unicité fonctionne également avec SQLite : parfait pour les tests en phase de développement.
11.1.2. Unicité vérifiée dans un validateur Symfony
Exemple de code vérifiant la contrainte d’unicité avec le validateur,
qui utilise cette-fois la contraine déclarée avec l’attribut
UniqueEntity
des validateurs Symfony :
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; #[ORM\Entity(repositoryClass: TagRepository::class)] #[UniqueEntity('name')] class Tag { #[ORM\Column(length: 255, unique: true)] private ?string $name = null;
Cette fois, c’est au programmeur de demander explicitement la validation des données. Par exemple, dans une commande en console :
use Symfony\Component\Validator\Validator\ValidatorInterface; class NewTagCommand extends Command { /** * @var TagRepository data access repository */ private $tagRepository; private ValidatorInterface $validator; public function __construct(ManagerRegistry $doctrineManager, ValidatorInterface $validator) { $this->tagRepository = $doctrineManager->getRepository(Tag::class); $this->validator = $validator; // ... } protected function execute(InputInterface $input, OutputInterface $output): int { // ... $tag = new Tag(); $tag->setName($input->getArgument('name')); $errors = $this->validator->validate($tag); if(count($errors)) { $io->error((string)$errors); return Command::FAILURE; }
Tout comme on utilise l’injection de dépendances pour transmettre le gestionnaire Doctrine au constructeur de la commande, on peut aussi le lier au module de validation de Symfony.