MySQL est une base de données relationnelle (tout comme MariaDB, PostgreSQL, Oracle, etc.) et il n’est pas rare d’avoir des relations diverses lorsque nous modélisons une application.
Par exemple, un sondage est constitué d’un ensemble de questions, une question peut avoir plusieurs réponses possibles, un utilisateur peut avoir une adresse, etc.
Toutes ces relations peuvent être matérialisées en utilisant des clés étrangères en SQL.
Avec Doctrine, selon la nature de la relation, nous avons des moyens très simples, efficaces et élégants de la gérer. Nous allons donc étoffer notre modèle de données en implémentant des relations avec Doctrine.
- L'annotation OneToOne
- Interaction avec une entité ayant une relation
- La relation OneToOne bidirectionnelle
- L'annotation JoinColumn
L'annotation OneToOne
Reprenons notre exemple et considérons qu’un utilisateur peut avoir une seule adresse et que cette adresse ne peut être liée qu’à un seul utilisateur.
Dans la base de données, le schéma ressemblerait à :
Avec Doctrine, nous pouvons obtenir ce résultat avec l’annotation OneToOne
.
Supposons que notre adresse est comme suit (vous pouvez aussi vous entrainer en créant vous-même une entité adresse) :
<?php
# src/Entity/Address.php
namespace Tuto\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="addresses")
*/
class Address
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
protected $id;
/**
* @ORM\Column(type="string")
*/
protected $street;
/**
* @ORM\Column(type="string")
*/
protected $city;
/**
* @ORM\Column(type="string")
*/
protected $country;
public function __toString()
{
$format = "Address (id: %s, street: %s, city: %s, country: %s)";
return sprintf($format, $this->id, $this->street, $this->city, $this->country);
}
// ... tous les getters et setters
}
Nous avons jusque-là rien de nouveau. Pour relier cette adresse à un utilisateur, nous devons modifier l’entité utilisateur. Pour des soucis de clarté tous les autres attributs seront masqués.
<?php
# src/Entity/User.php
namespace Tuto\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="users")
*/
class User
{
// ...
/**
* @ORM\OneToOne(targetEntity=Address::class)
*/
protected $address;
// Ajout de l'adresse à la méthode __toString
public function __toString()
{
$format = "User (id: %s, firstname: %s, lastname: %s, role: %s, address: %s)\n";
return sprintf($format, $this->id, $this->firstname, $this->lastname, $this->role, $this->address);
}
}
Avec l’annotation OneToOne
, nous disons à Doctrine que notre utilisateur peut être lié à une adresse (grâce à l’attribut targetEntity
).
Dans targetEntity
, il faut spécifier un espace de nom complet. Avec PHP 7, l’utilisation de la constante class
nous facilite la tâche.
En lançant une mise à jour de la base de données, Doctrine génère la nouvelle table pour les adresses et crée la clé étrangère dans la table des utilisateurs.
-- vendor/bin/doctrine orm:schema-tool:update --dump-sql --force
CREATE TABLE addresses (id INT AUTO_INCREMENT NOT NULL, street VARCHAR(255) NOT NULL, city VARCHAR(255) NOT NULL,
country VARCHAR(255) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB;
ALTER TABLE users ADD address_id INT DEFAULT NULL;
ALTER TABLE users ADD CONSTRAINT FK_1483A5E9F5B7AF75 FOREIGN KEY (address_id) REFERENCES addresses (id);
CREATE UNIQUE INDEX UNIQ_1483A5E9F5B7AF75 ON users (address_id);
Interaction avec une entité ayant une relation
Essayons maintenant d’affecter une adresse à un de nos utilisateurs :
<?php
# set-user-address.php
$entityManager = require_once join(DIRECTORY_SEPARATOR, [__DIR__, 'bootstrap.php']);
use Tuto\Entity\User;
use Tuto\Entity\Address;
$userRepo = $entityManager->getRepository(User::class);
$user = $userRepo->find(1);
$address = new Address();
$address->setStreet("Champ de Mars, 5 Avenue Anatole");
$address->setCity("Paris");
$address->setCountry("France");
$user->setAddress($address);
$entityManager->flush();
En exécutant le code, une belle erreur s’affiche :
Fatal error: Uncaught Doctrine\ORM\ORMInvalidArgumentException:
A new entity was found through the relationship 'Tuto\Entity\User#address' that was not configured to cascade
persist operations for entity: Address (id: , street: Champ de Mars, 5 Avenue Anatole, city: Paris, country: France).
To solve this issue: Either explicitly call EntityManager#persist() on this unknown entity or
configure cascade persist this association in the mapping for example @ManyToOne(..,cascade={"persist"}).
Vous l’aurez deviné, l’entité adresse n’est pas encore gérée par Doctrine. Notre ORM ne sait donc pas quoi faire avec. Pour résoudre ce problème nous avons deux solutions que nous allons aborder ci-dessous.
Gestion manuelle des relations
Comme le message d’erreur le suggère, nous pouvons corriger le problème en utilisant directement la méthode persist
sur l’entité adresse avant de flusher. Elle sera ainsi gérée par Doctrine. Le code devient alors :
<?php
# set-user-address.php
$entityManager = require_once join(DIRECTORY_SEPARATOR, [__DIR__, 'bootstrap.php']);
use Tuto\Entity\User;
use Tuto\Entity\Address;
$userRepo = $entityManager->getRepository(User::class);
$user = $userRepo->find(1);
$address = new Address();
$address->setStreet("Champ de Mars, 5 Avenue Anatole");
$address->setCity("Paris");
$address->setCountry("France");
$user->setAddress($address);
$entityManager->persist($address);
$entityManager->flush();
En ré-exécutant le code, une adresse est créée et affectée à notre utilisateur.
Délégation à Doctrine
Nous pouvons aussi utiliser le système de cascade de Doctrine pour gérer la relation entre les entités utilisateur et adresse. Avec ce système, nous pouvons définir comment Doctrine gère l’entité adresse si l’entité utilisateur est modifiée. Ainsi, pendant la création et la suppression de l’utilisateur, nous pouvons demander à Doctrine de répercuter ces changements sur son adresse.
Le paramètre à utiliser est l’attribut cascade
de l’annotation OneToOne
. Voici un tableau récapitulatif de quelques valeurs possibles et de leurs effets.
Cascade | Effet |
---|---|
persist | Si l’entité utilisateur est sauvegardée, faire de même avec l’entité adresse associée |
remove | Si l’entité utilisateur est supprimée, faire de même avec l’entité adresse associée |
Il faut porter une attention particulière lors de l’utilisation de la cascade d’opérations. Les performances de votre application peuvent en pâtir si cela est mal configurée.
Pour notre cas, puisqu’une adresse ne sera associée qu’à un et un seul utilisateur et vice versa, nous pouvons nous permettre de la créer ou de la supprimer suivant l’état de l’entité utilisateur.
L’entité utilisateur devient :
<?php
# src/Entity/User.php
namespace Tuto\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="users")
*/
class User
{
// ...
/**
* @ORM\OneToOne(targetEntity=Address::class, cascade={"persist", "remove"})
*/
protected $address;
// ...
}
N’hésitez pas à valider les annotations en utilisant les commandes que Doctrine met à notre disposition.
vendor/bin/doctrine orm:validate-schema
[Mapping] OK - The mapping files are correct.
[Database] OK - The database schema is in sync with the mapping files.
Avec l’attribut cascade
, les opérations de cascade sont effectuées par Doctrine au niveau applicatif. Les événements SQL de type ON DELETE
et ON UPDATE
sur les clés étrangères ne sont pas utilisés.
La relation OneToOne bidirectionnelle
Avec notre modèle actuel, lorsque nous avons une entité utilisateur, nous pouvons trouver l’adresse qui lui est associée. Par contre, lorsque nous avons une entité adresse, nous ne sommes pas en mesure de récupérer l’utilisateur associé. La relation que nous avons configurée est dite unidirectionnelle car seul un des membres de la relation peut faire référence à l’autre.
Dans certains cas, il est possible d’avoir une relation dite bidirectionnelle où chacun des deux membres peut faire référence à l’autre.
Pour ce faire, nous devons mettre à jour les annotations des entités.
Nous allons d’abord modifier l’adresse pour rajouter l’information relative à l’utilisateur.
<?php
# src/Entity/Address.php
namespace Tuto\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="addresses")
*/
class Address
{
// ...
/**
* @ORM\OneToOne(targetEntity=User::class, mappedBy="address")
*/
protected $user;
// ...
}
L’attribut mappedBy
est obligatoire et elle permet de dire à Doctrine que cette relation est bidirectionnelle et que l’attribut utilisé dans l’autre côté de la relation est address
.
Dans la même logique, au niveau de l’utilisateur, nous devons mettre en place un attribut inverseBy
pour signifier à Doctrine que nous utilisons une relation bidirectionnelle. La valeur de ce paramètre désigne le nom de l’attribut dans l’autre côté de la relation.
<?php
# src/Entity/User.php
namespace Tuto\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="users")
*/
class User
{
// ...
/**
* @ORM\OneToOne(targetEntity=Address::class, cascade={"persist", "remove"}, inversedBy="user")
*/
protected $address;
// ...
}
Ces modifications n’affectent pas la configuration de la base de données mais permettent au niveau applicatif d’accéder plus facilement à certaines informations.
Pour notre exemple actuel, avoir une relation bidirectionnelle n’est pas pertinente car l’adresse ne sera jamais utilisée sans l’utilisateur. Nous pouvons donc nous en passer.
L'annotation JoinColumn
Si nous consultons la base de données, nous pouvons voir que la colonne qui porte la clé étrangère s’appelle address_id
. Doctrine a choisi automatique ce nom en se basant sur l’attribut address
de notre entité utilisateur.
De plus, avec la configuration actuelle, il est possible d’avoir un utilisateur sans adresse (colonne à NULL
). Nous pouvons personnaliser ces informations en utilisant l’annotation JoinColumn
.
Cette annotation nous permet entre autres :
- de modifier le nom de la colonne portant la clé étrangère avec son attribut
name
; - ou de rajouter une contrainte de nullité avec l’attribut
nullable
.
Avec comme configuration :
<?php
# src/Entity/User.php
namespace Tuto\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="users")
*/
class User
{
// ...
/**
* @ORM\OneToOne(targetEntity=Address::class, cascade={"persist", "remove"}, inversedBy="user")
* @ORM\JoinColumn(name="address", nullable=false)
*/
protected $address;
// ...
}
… nous aurions eu comme requête SQL :
ALTER TABLE users ADD address INT NOT NULL;
au lieu de :
ALTER TABLE users ADD address_id INT DEFAULT NULL;
Doctrine utilise beaucoup de valeurs par défaut mais nous laisse le choix de les personnaliser à travers les différentes annotations à notre disposition.
Maintenant que nous savons comment Doctrine gère les relations dans un modèle de données, nous allons découvrir d’autres types de relations comme le 1..n et le m..n.
Même si l’ORM nous facilite le travail, il faut quand même s’y connaitre un minimum en modélisation de base de données pour profiter une maximum de ses fonctionnalités.