Si vous êtes arrivé ici, c’est peut-être parce que vous avez ce message :
SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value violates unique constraint "unique_picture_position_idx"
DETAIL: Key (article_id, "position")=(41ffd17c-4a15-4074-8db0-f7f8604d3458, 0) already exists. (Doctrine\DBAL\Exception\UniqueConstraintViolationException)1. Contexte
Imaginez un Article de blog avec une relation OneToMany vers Picture tout ce qu’il y a de plus classique.
# Article.yml
Article:
type: entity
[...]
oneToMany:
pictures:
targetEntity: Picture
mappedBy: article# Picture.yml
Picture:
type: entity
[...]
manyToOne:
article:
reversedBy: pictures
joinColumn:
referencedColumnName: id
nullable: falseAfin de pouvoir établir un ordre entre ces images nous ajoutons une position à Picture.
Puisque nous sommes malins on ajoute une contrainte d’unicité entre cette position et article_id
# Picture.yml
fields:
position:
type: integer
options:
unsigned: true
uniqueConstraints:
unique_picture_position_idx:
columns: [ article_id, position ]2. Exemple
Dans cette configuration, il est possible de reproduire l’erreur.
La condition sinequanone est de procéder à un DELETE et un INSERT dans la même transaction.
Prenons l’exemple suivant :
<?php
$em->remove($picture); // $picture->position = 0
$article->getPictures()->removeElement($picture);
$picture = new Picture();
$picture->setPosition(0);
$picture->setArticle($article);
$article->getPictures()->add($picture);
$em->persist($picture);
$em->flush();Bingo. Alors d’où vient le problème concrètement ?
3. Origine
Tout se passe dans l’UnitOfWork (Doctrine\ORM\UnitOfWork) de l’ORM.
Si on regarde d’un peu plus près la méthode commit() on peut constater que les INSERT sont fait avant les DELETE :
<?php
// Begin transaction
try {
if ($this->entityInsertions) {
foreach ($commitOrder as $class) {
$this->executeInserts($class);
}
}
// ...
// Entity deletions come last and need to be in reverse commit order
if ($this->entityDeletions) {
for ($count = count($commitOrder), $i = $count - 1; $i >= 0 && $this->entityDeletions; --$i) {
$this->executeDeletions($commitOrder[$i]);
}
}
// Commit transaction
} catch (Exception $e) {
// Rollback transaction
}4. Solutions
Une première possibilité serait bien sûr d’exécuter les deux actions dans deux transactions différentes.
<?php
$em->remove($picture); // $picture->position = 0
$article->getPictures()->removeElement($picture);
$em->flush();
// ...
$picture = new Picture();
$picture->setPosition(0);
$picture->setArticle($article);
$article->getPictures()->add($picture);
$em->persist($picture);
$em->flush();
L’inconvénient avec cette méthode c’est si, pour une raison quelconque, l’ajout de l’image ne se passe pas bien, nous l’avons supprimé sans avoir pu la remplacer. On a donc un problème d’incohérence des données.
Il existe une autre possibilité, c’est de supprimer la contrainte d’unicité.
Pourquoi ? Il est préférable de faire confiance à son domaine plutôt qu’à son schéma de base de données, n’en déplaise à votre esprit de DBA.
Grâce aux options de persistance en cascade et de suppression d’entité orpheline de Doctrine nous pouvons complètement abstraire l’utilisation de l’EntityManager.
De plus, une bonne habitude est d’encapsuler l’accès à vos collections dans des méthodes métier de votre agrégat :
# Article.yml
Article:
type: entity
[...]
oneToMany:
pictures:
targetEntity: Picture
mappedBy: article
cascade: ["persist"] # Auto persist
orphanRemoval: true # Auto remove<?php
$article->removePicture($picture);
$article->addPicture(new Picture(), 0);
$em->flush();<?php
// Article.php
public function removePicture(Picture $picture)
{
$this->pictures->removeElement($picture);
}
public function addPicture(Picture $picture, $position)
{
foreach($this->pictures as $_picture)
{
if ($_picture->getPosition() === $position) {
throw new PositionAlreadyUsed();
}
}
$picture->setPosition($position);
$this->pictures->add($picture);
}Qu’en pensez-vous ? Quelle solution avez-vous choisie ? Une autre idée ? N’hésitez pas à m’en faire part sur Twitter !