From 6edcedcc13699fa1192cb8b75a2258870ef32d92 Mon Sep 17 00:00:00 2001 From: Ahmed EBEN HASSINE Date: Mon, 16 Dec 2024 18:44:06 +0100 Subject: [PATCH] feat: add object locking to prevent concurrent edit conflicts --- config/services.php | 14 ++ doc/lock.rst | 134 ++++++++++++++++++ src/Contracts/LockableInterface.php | 17 +++ .../LockVersionValidationListener.php | 104 ++++++++++++++ src/Form/Extension/LockVersionExtension.php | 81 +++++++++++ src/Form/Extension/VersionableTrait.php | 25 ++++ translations/EasyAdminBundle.ar.php | 6 + translations/EasyAdminBundle.en.php | 6 + translations/EasyAdminBundle.fr.php | 7 + 9 files changed, 394 insertions(+) create mode 100644 doc/lock.rst create mode 100644 src/Contracts/LockableInterface.php create mode 100644 src/Form/EventListener/LockVersionValidationListener.php create mode 100644 src/Form/Extension/LockVersionExtension.php create mode 100644 src/Form/Extension/VersionableTrait.php diff --git a/config/services.php b/config/services.php index 816d9d7e76..aa01f286e9 100644 --- a/config/services.php +++ b/config/services.php @@ -60,8 +60,10 @@ use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\NullConfigurator as NullFilterConfigurator; use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\NumericConfigurator as NumericFilterConfigurator; use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\TextConfigurator as TextFilterConfigurator; +use EasyCorp\Bundle\EasyAdminBundle\Form\EventListener\LockVersionValidationListener; use EasyCorp\Bundle\EasyAdminBundle\Form\Extension\CollectionTypeExtension; use EasyCorp\Bundle\EasyAdminBundle\Form\Extension\EaCrudFormTypeExtension; +use EasyCorp\Bundle\EasyAdminBundle\Form\Extension\LockVersionExtension; use EasyCorp\Bundle\EasyAdminBundle\Form\Type\CrudFormType; use EasyCorp\Bundle\EasyAdminBundle\Form\Type\FileUploadType; use EasyCorp\Bundle\EasyAdminBundle\Form\Type\FiltersFormType; @@ -122,6 +124,18 @@ ->arg(0, service(AdminContextProvider::class)) ->tag('data_collector', ['id' => 'easyadmin', 'template' => '@EasyAdmin/inspector/data_collector.html.twig']) + ->set(LockVersionExtension::class) + ->arg(0, service(LockVersionValidationListener::class)) + ->arg(1, service(AdminContextProvider::class)) + ->tag('form.type_extension') + + ->set(LockVersionValidationListener::class) + ->arg(0, service('doctrine')) + ->arg(1, service(AdminUrlGenerator::class)) + ->arg(2, service('translator')) + ->arg(3, service(AdminContextProvider::class)) + ->tag('kernel.event_subscriber') + ->set(ExceptionListener::class) ->arg(0, '%kernel.debug%') ->arg(1, service(AdminContextProvider::class)) diff --git a/doc/lock.rst b/doc/lock.rst new file mode 100644 index 0000000000..e94278955f --- /dev/null +++ b/doc/lock.rst @@ -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 +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 +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. + diff --git a/src/Contracts/LockableInterface.php b/src/Contracts/LockableInterface.php new file mode 100644 index 0000000000..d798a6a7f9 --- /dev/null +++ b/src/Contracts/LockableInterface.php @@ -0,0 +1,17 @@ + + */ +class LockVersionValidationListener implements EventSubscriberInterface +{ + private ManagerRegistry $doctrine; + private AdminUrlGeneratorInterface $adminUrlGenerator; + private TranslatorInterface $translator; + private AdminContextProvider $adminContextProvider; + + public function __construct( + ManagerRegistry $doctrine, + AdminUrlGeneratorInterface $adminUrlGenerator, + TranslatorInterface $translator, + AdminContextProvider$adminContextProvider + ) { + $this->doctrine = $doctrine; + $this->adminUrlGenerator = $adminUrlGenerator; + $this->translator = $translator; + $this->adminContextProvider =$adminContextProvider; + } + + /** + * Returns the events this listener subscribes to. + * + * @return array + */ + 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 ($submittedLockVersion === null) { + return; + } + + $eaContext = $this->adminContextProvider->getContext(); + if (!$eaContext instanceof AdminContext) { + return; + } + + $currentLockVersion = $instance->getLockVersion(); + if ($currentLockVersion === null) { + 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(DashboardController::class) + ->setAction(Crud::PAGE_EDIT) + ->generateUrl(); + + $message = $this->translator->trans('flash_lock_error.message', [ + '%link_start%' => sprintf('', $targetUrl), + '%link_end%' => '', + '%reload_text%' => $this->translator->trans('flash_lock_error.reload_page', [], 'EasyAdminBundle'), + ], 'EasyAdminBundle'); + + $form->addError(new FormError($message)); + } + } +} diff --git a/src/Form/Extension/LockVersionExtension.php b/src/Form/Extension/LockVersionExtension.php new file mode 100644 index 0000000000..08ebbfc794 --- /dev/null +++ b/src/Form/Extension/LockVersionExtension.php @@ -0,0 +1,81 @@ + + */ +class LockVersionExtension extends AbstractTypeExtension +{ + /** + * Request stack to access current request context. + */ + private LockVersionValidationListener $validationListener; + private AdminContextProvider $adminContextProvider; + + /** + * Constructor to initialize dependencies. + * + * @param LockVersionValidationListener $validationListener + * @param LockVersionValidationListener $validationListener Listener for lock version validation + */ + 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 (!($eaContext && 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() && $entity && method_exists($entity, 'getLockVersion')) { + $form->add(EA::LOCK_VERSION, HiddenType::class, [ + 'mapped' => false, + 'data' => $entity->getLockVersion(), + ]); + } + }); + + $builder->addEventSubscriber($this->validationListener); + } + + /** + * Specifies the extended form types. + * + * @return iterable List of extended form types + */ + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } +} diff --git a/src/Form/Extension/VersionableTrait.php b/src/Form/Extension/VersionableTrait.php new file mode 100644 index 0000000000..e6725dd42c --- /dev/null +++ b/src/Form/Extension/VersionableTrait.php @@ -0,0 +1,25 @@ + + */ +trait VersionableTrait +{ + #[ORM\Column(type: Types::INTEGER)] + #[ORM\Version] + private ?int $lockVersion = null; + + public function getLockVersion() + : ?int + { + return $this->lockVersion; + } +} diff --git a/translations/EasyAdminBundle.ar.php b/translations/EasyAdminBundle.ar.php index 4a4d3e25e9..834e4c5cfa 100644 --- a/translations/EasyAdminBundle.ar.php +++ b/translations/EasyAdminBundle.ar.php @@ -141,4 +141,10 @@ 'no-more-results' => 'لا يوجد نتائج أٌخرى', 'loading-more-results' => 'جاري تحميل نتائج إضافية…', ], + 'flash_lock_error' => [ + 'message' => "لقد تم تعديل هذا السجل من قبل مستخدم آخر منذ بدئك بالتحرير. + لا يمكن حفظ تغييراتك لمنع فقدان البيانات. + %link_start%انقر هنا للـ %reload_text%%link_end% والحصول على أحدث نسخة.", + 'reload_page' => 'إعادة تحميل الصفحة', + ], ]; diff --git a/translations/EasyAdminBundle.en.php b/translations/EasyAdminBundle.en.php index d825f8ee21..b8833c4174 100644 --- a/translations/EasyAdminBundle.en.php +++ b/translations/EasyAdminBundle.en.php @@ -141,4 +141,10 @@ 'no-more-results' => 'No more results', 'loading-more-results' => 'Loading more results…', ], + 'flash_lock_error' => [ + 'message' => "This record has been modified by another user since you started editing. + Your changes cannot be saved to prevent data loss. + %link_start%Click here to %reload_text%%link_end% and get the latest version.", + 'reload_page' => 'reload the page', + ], ]; diff --git a/translations/EasyAdminBundle.fr.php b/translations/EasyAdminBundle.fr.php index 767f1c2680..45fe70c13e 100644 --- a/translations/EasyAdminBundle.fr.php +++ b/translations/EasyAdminBundle.fr.php @@ -141,4 +141,11 @@ 'no-more-results' => 'Aucun autre résultat trouvé', 'loading-more-results' => 'Chargement de résultats supplémentaires…', ], + 'flash_lock_error' => [ + 'message' => "Cet enregistrement a été modifié par un autre utilisateur depuis que vous avez commencé à le modifier. + Vos modifications ne peuvent pas être enregistrées pour prévenir la perte de données. + %link_start%Cliquez ici pour %reload_text%%link_end% et obtenir la dernière version.", + + 'reload_page' => 'recharger la page', + ], ];