1

The question relates to the technology stack I use:

  • Symfony 4.2.3
  • Doctrine ORM 2.6.3
  • Sonata Admin 3.45.2
  • sqlite3 3.22 (although the RDBMS shouldn't play a role)

Let's say we have two entities: Category and Product where the relation category to product is 1:n and product to category is n:1. This would look like:

Category.php

class Category
{
    // ...
    /**
     * @ORM\OneToMany(
     *     targetEntity="App\Entity\Product",
     *     mappedBy="category",
     *     cascade={"persist"}
     * )
     * @Assert\Valid()
     */
    private $products;
    // ...
}

Product.php

class Product
{
    // ...
    /**
     * @ORM\ManyToOne(
     *     targetEntity="App\Entity\Category", 
     *     inversedBy="products"
     * )
     * @ORM\JoinColumn(nullable=false)
     * @Assert\NotBlank()
     */
    private $category;
    // ...
}

Product must be assigned to a Category. Category can have 0 or more Products. If Category contains any Products it must NOT be deleted. Category can be deleted only if no Products are assigned to it.

When I try to delete a Category which has Products in the Sonata Admin, the deletion is prevented, as expected, and an Exception is thrown:

PDOException

SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed

Now, that is expected, but not very nice for the end user. I'd like to provide a message and inform the user that the Category can not be deleted because it still holds Products.

In Sonata Admin I use a workaround, writing CategoryAdminController and implementing the preDelete hook:

public function preDelete(Request $request, $object)
{
    if ($object->getProducts()->isEmpty()) {
        return null;
    }

    $count = $object->getProducts()->count();
    $objectName = $this->admin->toString($object);
    $this->addFlash(
        'sonata_flash_error',
        sprintf(
            'The category "%s" can not be deleted because it contains %s product(s).',
            $objectName,
            $count
        )
    );

    return $this->redirectTo($object);
}

However this doesn't feel right, because I have to reimplement it outside the admin.

What is the best practice to handle this? Can I implement some kind of validation in the entity? Or maybe Doctrine event listeners are the right thing?

cezar
  • 11,616
  • 6
  • 48
  • 84
  • I haven't used Doctrine in a while, to much of a headache but, I don't see why you can't do a `try{ }catch(PDOException $e){ }` block and then kick the error message out from there. – ArtisticPhoenix Mar 03 '19 at 10:57
  • @ArtisticPhoenix It doesn't help me to use `try - catch` block. I'm looking for a generic solution. Otherwise I have to use `try - catch` at every place I need to delete an object. – cezar Mar 03 '19 at 11:03
  • That `at every place I need to delete an object` is not exactly true, as you can make a `delete` method in your `model` or `entity` and use that as a funnel for the delete command, if you follow. I forget how to delete entities in doctrine, but I am sure you can make a method and do everything you need to in there. – ArtisticPhoenix Mar 03 '19 at 11:04

2 Answers2

1

I believe what you are trying to do is described here:

Symfony + Doctrine - Define an error message when integrity constraint error

I won't copy-paste the whole message, but the concept is to create onKernelResponse listener and listen for PDOException. How to do this there are lot of articles, I believe you can easily find online, I've selected one of the first that I've found.

Within that Listener you can determine what exception it is and use flashbag either a default symfony one:

https://symfony.com/doc/current/components/http_foundation/sessions.html

$session->getFlashBag()->add('notice', 'Profile updated');

Or you could use Sonata Core Flashbag:

https://sonata-project.org/bundles/core/master/doc/reference/flash_messages.html

To use this feature in your PHP classes/controllers:

$flashManager = $this->get('sonata.core.flashmessage.manager');

$messages = $flashManager->get('success'); To use this feature in your templates, include the following template (with an optional domain parameter):

{% include '@SonataCore/FlashMessage/render.html.twig' %}

Note If necessary, you can also specify a translation domain to override configuration here:

{% include '@SonataCore/FlashMessage/render.html.twig' with { domain: 'MyCustomBundle' } %}

You can also take a look at this article https://tocacar.com/symfony2-how-to-modify-sonataadminbundles-error-message-on-entity-deletion-ca77cac343fa and override CRUDController::deleteAction so you can handle such errors.

And here you can find some code, that is related to your issue a bit, on Sonata Admin GitHub page https://github.com/sonata-project/SonataAdminBundle/issues/4485 it catches PDOException, so also check what version you are using, maybe what you need is an update.

Community
  • 1
  • 1
  • one reason I hate Doctrine, Why can't one just wrap the thing in a try catch block? – ArtisticPhoenix Mar 03 '19 at 10:55
  • 1
    Well you can :D you just need to know what to catch, actually Sonata converts PDOException into ModelManagerException. Check that github issue I referred. Sonata is a hell of a thing it adds lots of unwanted layers you are mostly unaware of. – Vytenis Ščiukas Mar 03 '19 at 11:05
  • I don't use any of that stuff, I used Doctrine for one project and that was enough. Not that I can't, I have been doing PHP for 9 years or so. But its like wearing handcuffs for me. I've probably built over 50 websites on various platforms, and I manage a site that does 150, million searches each day... – ArtisticPhoenix Mar 03 '19 at 11:07
  • @ArtisticPhoenix well you need to get used to ORMs. This is very helpful tool in many cases. And everytime you want strict control over something you face consequences of higher learning curve. – Vytenis Ščiukas Mar 03 '19 at 11:09
  • No, ORM's are mostly Junk IMO. The quires are not optimized, they are bloated, have poor support for large datasets etc... I deal in the millions of rows. I can do everything I need to with PDO, including injecting table values into a class. And UnBuffered queries, which is the only way to pull millions of records from a DB. Not to mention transactions... for ACID etc. – ArtisticPhoenix Mar 03 '19 at 11:10
  • 2
    @ArtisticPhoenix seems like you dont understand the purpose of ORM. And have not seen any queries generated by Doctrine ORM. YES with improper usage of ORM you can end up with poor performance, but with proper architecture, cache, etc.. you will have properly controlled environment to work with. We are facing an age where you just cant rely on one technology, you must use search layers like Elastic, ODM for Document based databases and ORMs for SQL Databases, because every tool goes with its ups and downs and you cant be stuck with only one tool. – Vytenis Ščiukas Mar 03 '19 at 11:16
  • PS. I don't use just one technollagy. I use `sphinx shearchd` similar to elastic. MongoDB, MySQL, RabbitMQ, Cassandra etc... For someone that doesn't know how to write there own queries and manage their own relationships and application stack, sure it's fine. I read the wikki, https://stackoverflow.com/questions/448684/why-should-you-use-an-orm and agree with `Chuck` which happens to be the top voted answer. – ArtisticPhoenix Mar 03 '19 at 11:24
  • Thank you @vytsci. I managed to solve the problem with the help of the links you've provided. – cezar Mar 07 '19 at 09:18
1

I managed to solve the problem by adding a custom listener. It catches the ModelManagerException when deleting a restricted object. It works for all registered admins. Here is the class:

<?php

namespace App\EventListener;

use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Sonata\AdminBundle\Exception\ModelManagerException;

class ModelManagerExceptionResponseListener
{
    private $session;
    private $router;
    private $em;

    public function __construct(SessionInterface $session, UrlGeneratorInterface $router, EntityManagerInterface $em)
    {
        $this->session = $session;
        $this->router = $router;
        $this->em = $em;
    }

    public function onKernelException(GetResponseForExceptionEvent $event)
    {
        // get the exception
        $exception =  $event->getException();
        // we proceed only if it is ModelManagerException
        if (!$exception instanceof ModelManagerException) {
            return;
        }

        // get the route and id
        // if it wasn't a delete route we don't want to proceed
        $request = $event->getRequest();
        $route = $request->get('_route');
        $id = $request->get('id');
        if (substr($route, -6) !== 'delete') {
            return;
        }
        $route = str_replace('delete', 'edit', $route);

        // get the message
        // we proceed only if it is the desired message
        $message = $exception->getMessage();
        $failure = 'Failed to delete object: ';
        if (strpos($message, $failure) < 0) {
            return;
        }

        // get the object that can't be deleted
        $entity = str_replace($failure, '', $message);
        $repository = $this->em->getRepository($entity);
        $object = $repository->findOneById($id);

        $this->session->getFlashBag()
            ->add(
                'sonata_flash_error',
                sprintf('The item "%s" can not be deleted because other items depend on it.', $object)
            )
        ;

        // redirect to the edit form of the object
        $url = $this->router->generate($route, ['id' => $id]);
        $response = new RedirectResponse($url);
        $event->setResponse($response);
    }
}

And we register the service:

app.event_listener.pdoexception_listener:
    class: App\EventListener\ModelManagerExceptionResponseListener
    arguments:
        - '@session'
        - '@router'
        - '@doctrine.orm.entity_manager'
    tags:
        - { name: kernel.event_listener, event: kernel.exception }
    public: true # this maybe isn't needed

Probably deleting of any object outside the admin will not be allowed in my particular case. Therefore this solution satisfies the requirements. I hope that this example can help others. You'll have to adapt some parts according to your needs.

cezar
  • 11,616
  • 6
  • 48
  • 84
  • That's very nice solution. Thanks. One can simplify it by checking `$exception->getPrevious() instanceof ForeignKeyConstraintViolationException` – virtualize Jun 10 '22 at 12:19