-
-
Notifications
You must be signed in to change notification settings - Fork 1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add object locking to prevent concurrent edit conflicts
- Loading branch information
Showing
11 changed files
with
390 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
Object Locking in EasyAdmin | ||
=========================== | ||
|
||
Object locking prevents conflicts when multiple users edit the same item simultaneously, ensuring data integrity and avoiding overwrites. This feature is essential for environments where multiple administrators manage the same data, such as a back-office application using **EasyAdmin**. | ||
|
||
How It Works | ||
------------ | ||
|
||
When two users try to edit the same entity at the same time, the system uses the `lockVersion` field to detect that one of the users has already made changes. If the second user tries to submit their changes without reloading, EasyAdmin will notify them with a flash message, instructing them to reload the page to see the most recent version of the object. | ||
|
||
Use Case Example | ||
---------------- | ||
|
||
Imagine two users, Alice and Bob, both working on the same product record in the back-office system: | ||
|
||
1. **Alice** starts editing a product, say "Product A". | ||
2. **Bob** also starts editing "Product A" at the same time, unaware that Alice is editing it. | ||
3. **Alice** saves her changes, which updates the `lockVersion` in the database. | ||
4. **Bob** tries to submit his changes, but the system detects that the `lockVersion` has changed and warns him that someone else has already modified the product. | ||
|
||
This ensures that Bob cannot overwrite Alice’s changes without seeing the most recent version of the data. | ||
|
||
Implementation Example | ||
---------------------- | ||
|
||
1. **Add the `VersionableTrait` to the Entity** | ||
|
||
You need to add the `VersionableTrait` to any entity you want to support locking. This trait automatically manages the `lockVersion` field. | ||
|
||
Example: | ||
|
||
```php | ||
<?php | ||
namespace App\Entity; | ||
use Doctrine\ORM\Mapping as ORM; | ||
use Doctrine\DBAL\Types\Types; | ||
use EasyCorp\Bundle\EasyAdminBundle\Form\Extension\VersionableTrait; | ||
#[ORM\Entity] | ||
class Product | ||
{ | ||
use VersionableTrait; // Enables version locking | ||
#[ORM\Id] | ||
#[ORM\GeneratedValue] | ||
#[ORM\Column(type: Types::INTEGER)] | ||
private ?int $id = null; | ||
#[ORM\Column(type: Types::STRING)] | ||
private string $name; | ||
public function getId(): ?int | ||
{ | ||
return $this->id; | ||
} | ||
public function getName(): string | ||
{ | ||
return $this->name; | ||
} | ||
public function setName(string $name): self | ||
{ | ||
$this->name = $name; | ||
return $this; | ||
} | ||
} | ||
``` | ||
|
||
2. **Implement `LockableInterface`** | ||
|
||
Next, implement the `LockableInterface` from EasyAdmin to signal that the entity supports locking. | ||
|
||
```php | ||
<?php | ||
namespace App\Entity; | ||
use Doctrine\ORM\Mapping as ORM; | ||
use EasyCorp\Bundle\EasyAdminBundle\Contracts\LockableInterface; | ||
use EasyCorp\Bundle\EasyAdminBundle\Form\Extension\VersionableTrait; | ||
#[ORM\Entity] | ||
class Product implements LockableInterface | ||
{ | ||
use VersionableTrait; | ||
#[ORM\Id] | ||
#[ORM\GeneratedValue] | ||
#[ORM\Column(type: Types::INTEGER)] | ||
private ?int $id = null; | ||
#[ORM\Column(type: Types::STRING)] | ||
private string $name; | ||
public function getId(): ?int | ||
{ | ||
return $this->id; | ||
} | ||
public function getName(): string | ||
{ | ||
return $this->name; | ||
} | ||
public function setName(string $name): self | ||
{ | ||
$this->name = $name; | ||
return $this; | ||
} | ||
} | ||
``` | ||
|
||
3. **Handling the Conflict in EasyAdmin** | ||
|
||
When a conflict occurs (i.e., if two users are editing the same entity), EasyAdmin will automatically display a message to the second user. The message will inform the user that the entity was modified by someone else and prompt them to reload the page. | ||
|
||
``` | ||
This record has been modified by another user since you started editing. | ||
Your changes cannot be saved to prevent data loss. | ||
Click here to reload this page and get the latest version. | ||
``` | ||
|
||
### Summary of Benefits | ||
---------------------- | ||
|
||
- **Data Integrity**: Ensures that edits from multiple users do not conflict or overwrite each other. | ||
- **User Awareness**: Users are notified in real-time when another user has made changes to the same object. | ||
- **Easy Integration**: By implementing the `LockableInterface` and using the `VersionableTrait`, you can seamlessly add object locking without disrupting existing workflows. | ||
|
||
This feature ensures smoother collaboration, reduces the risk of data errors, and provides a better experience for administrators working with shared data in EasyAdmin. | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace EasyCorp\Bundle\EasyAdminBundle\Contracts; | ||
|
||
/** | ||
* Class LockableInterface. | ||
*/ | ||
interface LockableInterface | ||
{ | ||
/** | ||
* Returns the version or timestamp used to manage locking. | ||
* | ||
* @return ?int A version number or timestamp | ||
*/ | ||
public function getLockVersion(): ?int; | ||
} |
101 changes: 101 additions & 0 deletions
101
src/Form/EventListener/LockVersionValidationListener.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace EasyCorp\Bundle\EasyAdminBundle\Form\EventListener; | ||
|
||
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; | ||
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA; | ||
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext; | ||
use EasyCorp\Bundle\EasyAdminBundle\Contracts\LockableInterface; | ||
use EasyCorp\Bundle\EasyAdminBundle\Provider\AdminContextProvider; | ||
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface; | ||
use Symfony\Component\EventDispatcher\EventSubscriberInterface; | ||
use Symfony\Component\Form\FormError; | ||
use Symfony\Component\Form\FormEvent; | ||
use Symfony\Component\Form\FormEvents; | ||
use Symfony\Contracts\Translation\TranslatorInterface; | ||
|
||
/** | ||
* Validates lock version to prevent concurrent entity modifications. | ||
* | ||
* @author Ahmed EBEN HASSINE <[email protected]> | ||
*/ | ||
class LockVersionValidationListener implements EventSubscriberInterface | ||
{ | ||
private AdminUrlGeneratorInterface $adminUrlGenerator; | ||
private TranslatorInterface $translator; | ||
private AdminContextProvider $adminContextProvider; | ||
|
||
public function __construct( | ||
AdminUrlGeneratorInterface $adminUrlGenerator, | ||
TranslatorInterface $translator, | ||
AdminContextProvider $adminContextProvider, | ||
) { | ||
$this->adminUrlGenerator = $adminUrlGenerator; | ||
$this->translator = $translator; | ||
$this->adminContextProvider = $adminContextProvider; | ||
} | ||
|
||
/** | ||
* Returns the events this listener subscribes to. | ||
* | ||
* @return array<string, string> | ||
*/ | ||
public static function getSubscribedEvents(): array | ||
{ | ||
return [ | ||
FormEvents::PRE_SUBMIT => 'onPostSubmit', | ||
]; | ||
} | ||
|
||
/** | ||
* Validates the lock version before form submission. | ||
* | ||
* @throws \RuntimeException If lock version cannot be determined | ||
*/ | ||
public function onPostSubmit(FormEvent $event): void | ||
{ | ||
$data = $event->getData(); | ||
$form = $event->getForm(); | ||
$instance = $form->getData(); | ||
|
||
// Only proceed for root forms with lock-capable entities | ||
if (!($form->isRoot() && $instance instanceof LockableInterface)) { | ||
return; | ||
} | ||
|
||
// Extract submitted lock version | ||
$submittedLockVersion = $data[EA::LOCK_VERSION] ?? null; | ||
if (null === $submittedLockVersion) { | ||
return; | ||
} | ||
|
||
$eaContext = $this->adminContextProvider->getContext(); | ||
if (!$eaContext instanceof AdminContext) { | ||
return; | ||
} | ||
|
||
$currentLockVersion = $instance->getLockVersion(); | ||
if (null === $currentLockVersion) { | ||
throw new \RuntimeException('Lock version not found in the database.'); | ||
} | ||
|
||
// Check for version mismatch and add error if needed | ||
if ((int) $submittedLockVersion !== $currentLockVersion) { | ||
$targetUrl = $this->adminUrlGenerator | ||
->setController($eaContext->getCrud()->getControllerFqcn()) | ||
->setDashboard($eaContext->getDashboardControllerFqcn()) | ||
->setAction(Crud::PAGE_EDIT) | ||
->generateUrl(); | ||
|
||
$message = $this->translator->trans('flash_lock_error.message', [ | ||
'%link_start%' => sprintf('<a href="%s" class="info-link">', $targetUrl), | ||
'%link_end%' => '</a>', | ||
'%reload_text%' => $this->translator->trans('flash_lock_error.reload_page', [], 'EasyAdminBundle'), | ||
], 'EasyAdminBundle'); | ||
|
||
$form->addError(new FormError($message)); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace EasyCorp\Bundle\EasyAdminBundle\Form\Extension; | ||
|
||
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; | ||
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA; | ||
use EasyCorp\Bundle\EasyAdminBundle\Contracts\LockableInterface; | ||
use EasyCorp\Bundle\EasyAdminBundle\Form\EventListener\LockVersionValidationListener; | ||
use EasyCorp\Bundle\EasyAdminBundle\Provider\AdminContextProvider; | ||
use Symfony\Component\Form\AbstractTypeExtension; | ||
use Symfony\Component\Form\Extension\Core\Type\FormType; | ||
use Symfony\Component\Form\Extension\Core\Type\HiddenType; | ||
use Symfony\Component\Form\FormBuilderInterface; | ||
use Symfony\Component\Form\FormEvent; | ||
use Symfony\Component\Form\FormEvents; | ||
|
||
/** | ||
* Form extension to handle entity lock versioning. | ||
* | ||
* @author Ahmed EBEN HASSINE <[email protected]> | ||
*/ | ||
class LockVersionExtension extends AbstractTypeExtension | ||
{ | ||
private LockVersionValidationListener $validationListener; | ||
private AdminContextProvider $adminContextProvider; | ||
|
||
public function __construct(LockVersionValidationListener $validationListener, AdminContextProvider $adminContextProvider) | ||
{ | ||
$this->validationListener = $validationListener; | ||
$this->adminContextProvider = $adminContextProvider; | ||
} | ||
|
||
/** | ||
* Builds form by adding lock version field for root entities. | ||
* | ||
* @param FormBuilderInterface $builder Form builder instance | ||
* @param array $options Form build options | ||
*/ | ||
public function buildForm(FormBuilderInterface $builder, array $options): void | ||
{ | ||
$eaContext = $this->adminContextProvider->getContext(); | ||
|
||
if (null === $eaContext) { | ||
return; | ||
} | ||
|
||
if (Crud::PAGE_EDIT !== $eaContext->getCrud()->getCurrentAction()) { | ||
return; | ||
} | ||
|
||
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) { | ||
$entity = $event->getData(); | ||
$form = $event->getForm(); | ||
|
||
// Add lock version field for root entities with getLockVersion method | ||
if ($form->isRoot() && null !== $entity && $entity instanceof LockableInterface) { | ||
$form->add(EA::LOCK_VERSION, HiddenType::class, [ | ||
'mapped' => false, | ||
'data' => $entity->getLockVersion(), | ||
]); | ||
} | ||
}); | ||
|
||
$builder->addEventSubscriber($this->validationListener); | ||
} | ||
|
||
/** | ||
* Specifies the extended form types. | ||
* | ||
* @return iterable<string> List of extended form types | ||
*/ | ||
public static function getExtendedTypes(): iterable | ||
{ | ||
return [FormType::class]; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace EasyCorp\Bundle\EasyAdminBundle\Form\Extension; | ||
|
||
use Doctrine\DBAL\Types\Types; | ||
use Doctrine\ORM\Mapping as ORM; | ||
|
||
/** | ||
* Class VersionableTrait. | ||
* | ||
* @author Ahmed EBEN HASSINE <[email protected]> | ||
*/ | ||
trait VersionableTrait | ||
{ | ||
#[ORM\Column(type: Types::INTEGER)] | ||
#[ORM\Version] | ||
private ?int $lockVersion = null; | ||
|
||
public function getLockVersion(): ?int | ||
{ | ||
return $this->lockVersion; | ||
} | ||
} |
Oops, something went wrong.