DELETE INSERT sur une contrainte d'unicité avec Doctrine



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();

:bug: 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.

:art: 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 !