Skip to content

Commit

Permalink
feat: add object locking to prevent concurrent edit conflicts
Browse files Browse the repository at this point in the history
  • Loading branch information
ahmed-bhs committed Dec 16, 2024
1 parent 81e468d commit 6edcedc
Show file tree
Hide file tree
Showing 9 changed files with 394 additions and 0 deletions.
14 changes: 14 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand Down
134 changes: 134 additions & 0 deletions doc/lock.rst
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.

17 changes: 17 additions & 0 deletions src/Contracts/LockableInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?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;
}
104 changes: 104 additions & 0 deletions src/Form/EventListener/LockVersionValidationListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php declare(strict_types=1);

namespace EasyCorp\Bundle\EasyAdminBundle\Form\EventListener;

use App\Controller\Admin\DashboardController;
use Doctrine\Persistence\ManagerRegistry;
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 Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Contracts\Translation\TranslatorInterface;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface;

/**
* Validates lock version to prevent concurrent entity modifications.
*
* @author Ahmed EBEN HASSINE <[email protected]>
*/
class LockVersionValidationListener implements EventSubscriberInterface
{
private ManagerRegistry $doctrine;

Check failure on line 26 in src/Form/EventListener/LockVersionValidationListener.php

View workflow job for this annotation

GitHub Actions / phpstan

Property EasyCorp\Bundle\EasyAdminBundle\Form\EventListener\LockVersionValidationListener::$doctrine is never read, only written.
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<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;

Check failure on line 72 in src/Form/EventListener/LockVersionValidationListener.php

View workflow job for this annotation

GitHub Actions / phpstan

Access to undefined constant EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA::LOCK_VERSION.
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)

Check failure on line 91 in src/Form/EventListener/LockVersionValidationListener.php

View workflow job for this annotation

GitHub Actions / phpstan

Class App\Controller\Admin\DashboardController not found.
->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));
}
}
}
81 changes: 81 additions & 0 deletions src/Form/Extension/LockVersionExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?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\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
{
/**
* 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())) {

Check failure on line 53 in src/Form/Extension/LockVersionExtension.php

View workflow job for this annotation

GitHub Actions / phpstan

Only booleans are allowed in &&, EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext|null given on the left side.
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')) {

Check failure on line 61 in src/Form/Extension/LockVersionExtension.php

View workflow job for this annotation

GitHub Actions / phpstan

Only booleans are allowed in &&, mixed given on the right side.
$form->add(EA::LOCK_VERSION, HiddenType::class, [

Check failure on line 62 in src/Form/Extension/LockVersionExtension.php

View workflow job for this annotation

GitHub Actions / phpstan

Access to undefined constant EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA::LOCK_VERSION.
'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];
}
}
25 changes: 25 additions & 0 deletions src/Form/Extension/VersionableTrait.php
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;
}
}
Loading

0 comments on commit 6edcedc

Please sign in to comment.