From 2766572304d59f4158c2a9cdfa85f9b105282d13 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 | 13 ++ doc/lock.rst | 134 ++++++++++++++++++ src/Config/Option/EA.php | 1 + src/Contracts/LockableInterface.php | 18 +++ .../LockVersionValidationListener.php | 101 +++++++++++++ src/Form/Extension/LockVersionExtension.php | 78 ++++++++++ src/Form/Extension/VersionableTrait.php | 25 ++++ translations/EasyAdminBundle.ar.php | 6 + translations/EasyAdminBundle.en.php | 6 + translations/EasyAdminBundle.fr.php | 7 + 10 files changed, 389 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..b40065af8f 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,17 @@ ->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(AdminUrlGenerator::class)) + ->arg(1, service('translator')) + ->arg(2, 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/Config/Option/EA.php b/src/Config/Option/EA.php index 0372fa095d..7cc3fc4031 100644 --- a/src/Config/Option/EA.php +++ b/src/Config/Option/EA.php @@ -32,4 +32,5 @@ final class EA /** @deprecated this parameter is no longer used because menu items are now highlighted automatically */ public const SUBMENU_INDEX = 'submenuIndex'; public const URL_SIGNATURE = 'signature'; + public const LOCK_VERSION = '_lock_version'; } diff --git a/src/Contracts/LockableInterface.php b/src/Contracts/LockableInterface.php new file mode 100644 index 0000000000..43e5af370a --- /dev/null +++ b/src/Contracts/LockableInterface.php @@ -0,0 +1,18 @@ + + */ +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 + */ + 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('', $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..925a1be4fc --- /dev/null +++ b/src/Form/Extension/LockVersionExtension.php @@ -0,0 +1,78 @@ + + */ +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 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..6a744e35a7 --- /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..ea8b31d0d2 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..30e45c5008 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..aba697824d 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', + ], ];