Runroom / runroom-packages

Runroom packages for Symfony development
MIT License
5 stars 5 forks source link

sortable-behavior-bundle doesn't support multiple entity managers out of the box #152

Closed landure closed 1 month ago

landure commented 9 months ago

When using sortable-behavior-bundle package with Sonata Admin as described by 8. Sortable behavior in admin listing in a project with several entity managers, the following error appear:

An exception has been thrown during the rendering of a template ("The class 'App\ModuleB\Entity\ValueName' was not found in the chain configured namespaces App\ModuleA\Entity").

This error is raised by vendor/runroom-packages/sortable-behavior-bundle/src/Resources/views/sort.html.twig line 2 : {% set current_position = currentObjectPosition(object) %}

The app use a modular monolith architecture with one database per module. The issue lies with the ORMPositionHandler and GedmoPositionHandler services. They use EntityManagerInterface, that is auto-wired with the first of default entity manager.

It should auto-wire the Doctrine ManagerRegistry to get the correct EntityManager for the class using the getManagerForClass method.

Or the documentation should provide an example on how to create a custom Position Handler service for each entity manager.

Thank you for your work.

landure commented 9 months ago

This is an example implementation I used to fix the issue in my project.

<?php

declare(strict_types=1);

/*
 * This file is part of the Runroom package.
 *
 * (c) Runroom <runroom@runroom.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Runroom\SortableBehaviorBundle\Service;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Gedmo\Sortable\SortableListener;

/**
 * Position handler for Gedmo Sortable behavior.
 */
final class GedmoPositionHandler extends AbstractPositionHandler
{
    /**
     * @var array<string, int>
     */
    private array $cacheLastPosition = [];

    private ?EntityManagerInterface $entityManager = null;

    /**
     * Constructor, for dependency injection.
     */
    public function __construct(
        private readonly ManagerRegistry $registry,
        private readonly SortableListener $listener
    ) {
    }

    /**
     * Get the entity previous position.
     *
     * @param object $entity
     *
     * @return int
     */
    public function getLastPosition(object $entity): int
    {
        $meta = $this->getEntityManager($entity::class)->getClassMetadata($entity::class);
        /**
         * @var array{ useObjectClass: string, position: string, groups?: class-string[] }
         */
        $config = $this->listener->getConfiguration($this->getEntityManager(), $meta->getName());

        /** @var array<string,mixed> $groups */
        $groups = [];
        if (isset($config['groups'])) {
            foreach ($config['groups'] as $groupName) {
                /** @psalm-suppress MixedAssignment */
                $groups[$groupName] = $meta->getReflectionProperty($groupName)->getValue($entity);
            }
        }

        $hash = $this->getHash($config, $groups);

        if (!isset($this->cacheLastPosition[$hash])) {
            $this->cacheLastPosition[$hash] = $this->queryLastPosition($config, $groups);
        }

        return $this->cacheLastPosition[$hash];
    }

    /**
     * Get the position field name for the given entity.
     */
    public function getPositionFieldByEntity($entity): string
    {
        if (\is_object($entity)) {
            $entity = $entity::class;
        }

        $meta = $this->getEntityManager($entity)->getClassMetadata($entity);

        /**
         * @var array{position: string}
         */
        $config = $this->listener->getConfiguration($this->getEntityManager($entity), $meta->getName());

        return $config['position'];
    }

    /**
     * @param array<string,string|class-string[]> $config
     * @param array<string, mixed>                $groups
     * @psalm-param array{
     *     useObjectClass: string,
     *     position: string,
     *     groups?: class-string[]
     * } $config
     */
    private function getHash(array $config, array $groups): string
    {
        $data = $config['useObjectClass'];
        /** @psalm-suppress MixedAssignment */
        foreach ($groups as $groupName => $value) {
            if ($value instanceof \DateTime) {
                $value = $value->format('c');
            }
            if (\is_object($value)) {
                $value = spl_object_hash($value);
            }
            if (is_scalar($value)) {
                $data .= $groupName.$value;
                continue;
            }
            // If value can't be converted to string, ignore it for hash.
            $data .= $groupName;
        }

        return md5($data);
    }

    /**
     * @param array<string,string|class-string[]> $config
     * @param array<string, mixed>                $groups
     * @psalm-param array{
     *     useObjectClass: string,
     *     position: string,
     *     groups?: class-string[]
     * } $config
     */
    private function queryLastPosition(array $config, array $groups): int
    {
        $queryBuilder = $this->getEntityManager()->createQueryBuilder();
        $queryBuilder->select(sprintf('MAX(n.%s)', $config['position']))
            ->from($config['useObjectClass'], 'n');

        $index = 0;

        /** @psalm-suppress MixedAssignment */
        foreach ($groups as $groupName => $value) {
            ++$index;
            if (null === $value) {
                $queryBuilder->andWhere(sprintf('n.%s IS NULL', $groupName));

                continue;
            }

            $queryBuilder->andWhere(sprintf('n.%s = :group_%s', $groupName, $index));
            $queryBuilder->setParameter(sprintf('group_%s', $index), $value);
        }

        $query = $queryBuilder->getQuery();
        $query->disableResultCache();

        $lastPosition = $query->getSingleScalarResult();
        \assert(is_numeric($lastPosition));

        return (int) $lastPosition;
    }

    /**
     * @param class-string|null $class
     *
     * @throws \RuntimeException if the entity manager is not found
     */
    private function getEntityManager(string $class = null): EntityManagerInterface
    {
        if (null === $class) {
            if (null === $this->entityManager) {
                throw new \RuntimeException('Entity manager not set');
            }

            return $this->entityManager;
        }

        $entityManager = $this->registry->getManagerForClass($class);

        if (!$entityManager instanceof EntityManagerInterface) {
            throw new \RuntimeException(sprintf('Unable to find the entity manager for class "%s".', $class));
        }

        // Cache the entity manager for later use
        $this->entityManager = $entityManager;

        return $this->entityManager;
    }
}
jordisala1991 commented 8 months ago

Your proposal is interesting. Isn't the Manager Registry aleready doing some sort of cache for getManagerForClass? Why do you need to add an extra cache layer?

landure commented 8 months ago

I cache the entity manager because queryLastPosition method doesn't know of the entity class. It should probably be passed to the method from getLastPosition method, but I not sufficiently expert on this subject to predict side effects.