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: false
Afin 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 !