Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DoctrineBridge] #[MapEntity] does not resolve an entity from its interface, aliased using resolve_target_entities #51765

Open
siketyan opened this issue Sep 27, 2023 · 2 comments · May be fixed by #54545

Comments

@siketyan
Copy link
Contributor

siketyan commented Sep 27, 2023

Symfony version(s) affected

6.3.x

Description

#[MapEntity] attribute does not resolve an entity from its interface names, even it is implemented on the entity and aliased using doctrine.orm.resolve_target_entities configuration.

Error screen says:

Controller "App\Controller::showUser" requires that you provide a value for the "$user" argument. Either the argument is nullable and no null value has been provided, no default value has been provided or there is a non-optional argument after this one.

How to reproduce

config/packages/doctrine.yaml

doctrine:
  orm:
    resolve_target_entities:
      App\Entity\UserInterface: App\Entity\User

src/Entity/UserInterface.php

interface UserInterface
{
    public function getId(): ?int;
}

src/Entity/User.php

#[ORM\Entity]
class User implements UserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    public ?int $id = null;
}

src/Controller/Controller.php

class Controller
{
    #[Route('/users/{id}', methods: ['GET']]
    public function showUser(#[MapEntity] UserInterface $user): Response
    {
        return new JsonResponse($user);
    }
}

Possible Solution

Problem 1: #[MapEntity] does not accept interfaces

MapEntity checks the class existence by class_exists. For interface names, the function will return false even if it exists. We can use class_exists($class) || interface_exists($class) for resolve this.

Problem 2: EntityValueResolver uses ClassMetadataFactory::isTransient and it does not load metadata

To resolve entities aliased in resolve_target_entities, we have to load their metadata before using. ClassMetadataFactory::isTransient does not do so, then it will return true. We should explicitly load metadata to resolve entities.

Additional Context

Specifying the actual entity as #[MapEntity(class: User::class)] do a trick, but we actually separate the interface and the entity in different repository (integrated using Symfony Bundle system).

@trislem
Copy link

trislem commented Oct 20, 2023

+1

For informations, the issue is also reported in the doctrine/persistence repository (doctrine/persistence#63).
I encounter this issue on a Symfony 5.4 project. Since EntityValueResolver was introduced into Symfony 6.2, before that the issue is located into the SensioFrameworkExtraBundle DoctrineParamConverter

@NanoSector
Copy link
Contributor

We've worked around this issue by decorating the EntityValueResolver class and dynamically updating the $class property of the MapEntity attribute.

This consists of two parts; the wrapping ValueResolver, and a container compiler pass to insert the Doctrine configuration into the container:

ValueResolver decorator

Symfony should automatically pick this up and configure the service appropriately if it's placed in your project.

<?php

declare(strict_types=1);

namespace App\Common\Infrastructure\ValueResolver;

use InvalidArgumentException;
use Symfony\Bridge\Doctrine\ArgumentResolver\EntityValueResolver;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
 * @internal Works around https://github.com/symfony/symfony/issues/51765
 */
#[AsDecorator('doctrine.orm.entity_value_resolver')]
#[AutoconfigureTag('controller.argument_value_resolver')]
final readonly class AliasingEntityValueResolver implements ValueResolverInterface
{
    public function __construct(
        #[AutowireDecorated]
        private EntityValueResolver $inner,

        /** @var array<class-string, class-string> */
        #[Autowire(param: 'doctrine.orm.resolve_target_entities')]
        private array $doctrineTargetEntityAliases,
    ) {
    }

    /**
     * @return iterable<object>
     * @throws NotFoundHttpException
     * @throws InvalidArgumentException
     */
    public function resolve(Request $request, ArgumentMetadata $argument): iterable
    {
        if (\is_object($request->attributes->get($argument->getName()))) {
            return [];
        }

        $entityMappings = $argument->getAttributes(MapEntity::class, ArgumentMetadata::IS_INSTANCEOF);

        /** @var MapEntity|false $entityMapping */
        $entityMapping = reset($entityMappings);

        if ($entityMapping === false) {
            return [];
        }

        $targetClass = $entityMapping->class
            ?? $argument->getType()
            ?? throw new InvalidArgumentException('MapEntity parameters should specify either a type or a class argument');

        if (\array_key_exists($targetClass, $this->doctrineTargetEntityAliases)) {
            $entityMapping->class = $this->doctrineTargetEntityAliases[$targetClass];
        }

        return $this->inner->resolve($request, $argument);
    }
}

Compiler pass

This does assume the configuration property is always present.

<?php

declare(strict_types=1);

namespace App\Common\Infrastructure\DependencyInjection;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use RuntimeException;

class DoctrineResolveTargetEntitiesInjectorCompilerPass implements CompilerPassInterface
{
    /**
     * @throws RuntimeException
     */
    public function process(ContainerBuilder $container): void
    {
        $config = $container->getExtensionConfig('doctrine');

        if (
            !isset($config[0]['orm']['resolve_target_entities'])
            || !is_array($mapping = $config[0]['orm']['resolve_target_entities'])
        ) {
            throw new RuntimeException(
                'doctrine.orm.resolve_target_entities is missing from the bundle configuration',
            );
        }

        $container->setParameter('doctrine.orm.resolve_target_entities', $mapping);
    }
}

Register it in your Kernel class:

<?php

declare(strict_types=1);

namespace App;

use App\Common\Infrastructure\DependencyInjection\DoctrineResolveTargetEntitiesInjectorCompilerPass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

final class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    // ...

    protected function build(ContainerBuilder $container): void
    {
        $container->addCompilerPass(new DoctrineResolveTargetEntitiesInjectorCompilerPass());
    }

    // ...
}

NanoSector added a commit to NanoSector/symfony that referenced this issue Apr 10, 2024
This allows for fixing symfony#51765; with a consequential Doctrine bundle update, the resolve_target_entities configuration can be injected similarly to ResolveTargetEntityListener in the Doctrine codebase.

Alternatively the config and ValueResolver can be injected using a compiler pass in the Symfony core code, however the value resolver seems to be configured in the Doctrine bundle already.
NanoSector added a commit to NanoSector/symfony that referenced this issue Apr 10, 2024
This allows for fixing symfony#51765; with a consequential Doctrine bundle update, the resolve_target_entities configuration can be injected similarly to ResolveTargetEntityListener in the Doctrine codebase.

Alternatively the config and ValueResolver can be injected using a compiler pass in the Symfony core code, however the value resolver seems to be configured in the Doctrine bundle already.
NanoSector added a commit to NanoSector/symfony that referenced this issue Apr 10, 2024
This allows for fixing symfony#51765; with a consequential Doctrine bundle update, the resolve_target_entities configuration can be injected similarly to ResolveTargetEntityListener in the Doctrine codebase.

Alternatively the config and ValueResolver can be injected using a compiler pass in the Symfony core code, however the value resolver seems to be configured in the Doctrine bundle already.
NanoSector added a commit to NanoSector/symfony that referenced this issue Apr 11, 2024
This allows for fixing symfony#51765; with a consequential Doctrine bundle update, the resolve_target_entities configuration can be injected similarly to ResolveTargetEntityListener in the Doctrine codebase.

Alternatively the config and ValueResolver can be injected using a compiler pass in the Symfony core code, however the value resolver seems to be configured in the Doctrine bundle already.
NanoSector added a commit to NanoSector/DoctrineBundle that referenced this issue Apr 11, 2024
This is an addendum to PR symfony/symfony#51765 in the Symfony Doctrine Bridge, which adds type alias support to EntityValueResolver.

This code injects the doctrine.orm.resolve_target_entities configuration into the EntityValueResolver class.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants