The problem
Doctrine has a well defined internal order for the persist operations it executes during a flush.
It’s called “commit order” and it’s the following:
- all entity inserts (topological order)
- all entity updates (topological order)
- all collection deletions
- all collection updates
- all entity deletions (reverse toplogical order)
This can cause problems on certain types of changesets if the entities have unique keys.
As an example, imagine you need to model a warehouse where each product has a location and only a single product can be stored in each location.
Your Product
entity will have a location
unique field.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
/** * @ORM\Table(uniqueConstraints={ * @ORM\UniqueConstraint(name="location_idx", columns={"location"}) * })) * @ORM\Entity() */ class Product { /** * @var int * * @ORM\Column(name="id", type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @var int * * @ORM\Column(name="location", type="integer") */ private $location; } |
Given the following initial state:
- Product A in location 1
- Product B in location 2
- Product C in location 3
If your application has a form that lets users change multiple products in a single request then the following scenarios are possible:
- Deleting product A and creating a product D with location 1
- Swapping locations of products B and C
Both operations will cause a UniqueConstraintViolationException
because of Doctrine commit order as explained in detail in the issue DDC-604. In the scenario, product D INSERT
is executed before product A DELETE
, which causes a temporary violation of the unique key for location 1. In the second scenario, the first of the two UPDATE
s causes the locations of products B and C to be the same before the second UPDATE
has the chance to restore the uniqueness. MySQL checks the constraints during each statement and it can’t be configured to perform the check on transaction boundaries as some other RDBMSs do, so both scenarios will cause a UniqueConstraintViolationException
.
The problem has a workaround for the first scenario: you can do the deletions first, flush, do the insertions, and flush once more, while wrapping everything in a single transaction. Unfortunately, this solution doesn’t solve the problem in the second scenario, and it’s difficult to apply in many real world applications, for instance when you need to change multiple entities in a single Symfony form just like the example above.
Solution
MySQL allows unique keys to have nullable fields, and having a null
value in a unique key on multiple columns excludes the row from the constraint check.
A UNIQUE index permits multiple NULL values for columns that can contain NULL.
For example, if you have a table with a unique key defined on fields A and B, with field B being nullable, then the row A="FOO", B=null
can be inserted multiple times, but the row A="FOO", B="BAR"
can be present only once, and the same goes for A="FOO", B=""
.
This means that we can use an additional nullable field as an enabling flag for the unique key in each row: we only need to add an enabledUniqueKey
nullable field to our product table and include it in the location unique key, and its possible values will be either 1 (default) or null
.
Normally all the rows will have 1 in this field (meaning that the key is enabled on all the rows), but during the flush operation, before the statements execution, it will be set to null
in all the affected rows. By doing this, the key will be disabled for all the rows that can potentially generate a temporary violation of the constraint. Eventually, when all the statements have been executed, we will need to reset the field to 1. This way, the unique constraint will never be violated, no matter the statement execution order. Of course, this process needs to be wrapped in a transaction so that it will not be possible to be left with rows with a disabled key.
Warning: This method addresses only the temporary key violations caused by the Doctrine commit order: it doesn’t prevent any real violations of the unique key to generate a UniqueConstraintViolationException
. These violations still need to be avoided by using a validator.
Implementation
Let’s add the new field:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
/** * @ORM\Table(uniqueConstraints={ * @ORM\UniqueConstraint(name="location_idx", columns={"location", "enabledUniqueKey"}) * })) * @ORM\Entity() * @ORM\EntityListeners({"AppBundle\Entity\Listener\LocationUniqueConstraintListener"}) */ class Product { const ENABLED_CONSTRAINT_DB_VALUE = 1; const ENABLED_CONSTRAINT_FIELD_NAME = 'enabledUniqueKey'; /** * @var int * * @ORM\Column(name="id", type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @var int * * @ORM\Column(name="location", type="integer") */ private $location; /** * @var int|null * @ORM\Column(type="boolean", nullable=true, options={"default": "1"}) */ private $enabledUniqueKey = self::ENABLED_CONSTRAINT_DB_VALUE; public function disableUniqueKey(): void { $this->enabledUniqueKey = null; } public function enableUniqueKey(): void { $this->enabledUniqueKey = self::ENABLED_CONSTRAINT_DB_VALUE; } } |
The process of disabling and re-enabling the key can be implemented in a listener: the keys can be disabled in the prePersist event for the INSERT
s and in the preUpdate for the UPDATE
s, but they need to be re-enabled after all the INSERT
s and UPDATE
s have been executed and after disabling the key for the rows that will be deleted. This can be done using the UnitOfWork::scheduleExtraUpdate()
method. These extra updates are executed just after the updates already computed for the flush.
This is a timeline of the process:
Step | Statement type | Operation | Notes |
prePersist event | INSERT |
Disable row key | |
insert row | INSERT |
Insert row | |
postPersist event | INSERT |
Enable key in object instance in memory and schedule extra update to enable the key in the DB | |
first postPersist or postUpdate | DELETE |
Disable key directly in DB for each row that will be deleted | |
preUpdate event | UPDATE |
Disable row key | |
update row | UPDATE |
Update row | |
postUpdate event | UPDATE |
Enable key in object instance in memory and schedule extra update to enable the key in the DB | |
extra update | INSERT and UPDATE |
Enable key in the DB on all the inserted and all updated rows | The keys can be re-enabled because at this point the possible temporary collisions between inserted and updated rows have been already resolved. Possible collisions with rows that will be deleted aren’t a problem because the keys for those rows remain disabled until their deletion. |
deletion | DELETE |
Delete rows |
Please note that disabling the key is done on the entity which is then persisted by Doctrine, but enabling it is not automatically reflected on the database. For this reason, we need to manually do it on the object and schedule an extra update to keep the database in sync.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
class LocationUniqueConstraintListener { private $deletionsKeyNeedsDisabling = false; private $productRepository; public function __construct(ProductRepository $productRepository) { $this->productRepository = $productRepository; } public function preFlush(Product $product, PreFlushEventArgs $event): void { $this->deletionsKeyNeedsDisabling = true; } public function prePersist(Product $product, LifecycleEventArgs $event): void { $product>disableUniqueKey(); } public function preUpdate(Product $product, PreUpdateEventArgs $event): void { $product>disableUniqueKey(); } public function postUpdate(Product $product, LifecycleEventArgs $event): void { $this->enableKey($product, $event->getEntityManager()->getUnitOfWork(), $event->getEntityManager()); } public function postPersist(Product $product, LifecycleEventArgs $event): void { $this->enableKey($product, $event->getEntityManager()->getUnitOfWork(), $event->getEntityManager()); } private function enableKey(Product $product, UnitOfWork $uow, EntityManager $entityManager): void { $product>enableUniqueKey(); $uow->scheduleExtraUpdate($product, [Product::ENABLED_CONSTRAINT_FIELD_NAME => [null, Product::ENABLED_CONSTRAINT_DB_VALUE]]); $this->disableKeyOnEntitiesToBeRemoved($uow, $entityManager); } private function disableKeyOnEntitiesToBeRemoved(UnitOfWork $uow, EntityManager $entityManager): void { if (!$this->deletionsKeyNeedsDisabling) { return; } $this->deletionsKeyNeedsDisabling = false; $deletingEntityIds = []; foreach ($uow->getScheduledEntityDeletions() as $entityToBeDeleted) { if ($entityToBeDeleted instanceof Product) { $deletingEntityIds[] = $entityToBeDeleted->getId(); } } if (empty($deletingEntityIds)) { return; } $query = sprintf( 'UPDATE %s p SET p.%s = NULL WHERE p.id IN (:ids)', Product::class, Product::ENABLED_CONSTRAINT_FIELD_NAME ); $entityManager->createQuery($query) ->setParameter('ids', $deletingEntityIds) ->execute(); } } |
This solution is a team effort and was developed jointly by Angelo Milazzo and Alessandro Friscia. It has been in use on our systems for several weeks, and no problems arose. We hope you too can benefit from it.