diff --git a/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php new file mode 100644 index 0000000000000..98f8150674154 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php @@ -0,0 +1,246 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\ArgumentResolver; + +use Doctrine\DBAL\Types\ConversionException; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\NoResultException; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\ObjectManager; +use Symfony\Bridge\Doctrine\Attribute\MapEntity; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * Yields the entity matching the criteria provided in the route. + * + * @author Fabien Potencier + * @author Jérémy Derussé + */ +final class EntityValueResolver implements ArgumentValueResolverInterface +{ + public function __construct( + private ManagerRegistry $registry, + private ?ExpressionLanguage $expressionLanguage = null, + private MapEntity $defaults = new MapEntity(), + ) { + } + + /** + * {@inheritdoc} + */ + public function supports(Request $request, ArgumentMetadata $argument): bool + { + if (!$this->registry->getManagerNames()) { + return false; + } + + $options = $this->getOptions($argument); + if (!$options->class || $options->disabled) { + return false; + } + + // Doctrine Entity? + if (!$objectManager = $this->getManager($options->objectManager, $options->class)) { + return false; + } + + return !$objectManager->getMetadataFactory()->isTransient($options->class); + } + + /** + * {@inheritdoc} + */ + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + $options = $this->getOptions($argument); + $name = $argument->getName(); + $class = $options->class; + + $errorMessage = null; + if (null !== $options->expr) { + if (null === $object = $this->findViaExpression($class, $request, $options->expr, $options)) { + $errorMessage = sprintf('The expression "%s" returned null', $options->expr); + } + // find by identifier? + } elseif (false === $object = $this->find($class, $request, $options, $name)) { + // find by criteria + if (false === $object = $this->findOneBy($class, $request, $options)) { + if (!$argument->isNullable()) { + throw new \LogicException(sprintf('Unable to guess how to get a Doctrine instance from the request information for parameter "%s".', $name)); + } + + $object = null; + } + } + + if (null === $object && !$argument->isNullable()) { + $message = sprintf('"%s" object not found by the "%s" Argument Resolver.', $class, self::class); + if ($errorMessage) { + $message .= ' '.$errorMessage; + } + + throw new NotFoundHttpException($message); + } + + return [$object]; + } + + private function getManager(?string $name, string $class): ?ObjectManager + { + if (null === $name) { + return $this->registry->getManagerForClass($class); + } + + if (!isset($this->registry->getManagerNames()[$name])) { + return null; + } + + try { + return $this->registry->getManager($name); + } catch (\InvalidArgumentException) { + return null; + } + } + + private function find(string $class, Request $request, MapEntity $options, string $name): false|object|null + { + if ($options->mapping || $options->exclude) { + return false; + } + + $id = $this->getIdentifier($request, $options, $name); + if (false === $id || null === $id) { + return false; + } + + $objectManager = $this->getManager($options->objectManager, $class); + if ($options->evictCache && $objectManager instanceof EntityManagerInterface) { + $cacheProvider = $objectManager->getCache(); + if ($cacheProvider && $cacheProvider->containsEntity($class, $id)) { + $cacheProvider->evictEntity($class, $id); + } + } + + try { + return $objectManager->getRepository($class)->find($id); + } catch (NoResultException|ConversionException) { + return null; + } + } + + private function getIdentifier(Request $request, MapEntity $options, string $name): mixed + { + if (\is_array($options->id)) { + $id = []; + foreach ($options->id as $field) { + // Convert "%s_uuid" to "foobar_uuid" + if (str_contains($field, '%s')) { + $field = sprintf($field, $name); + } + + $id[$field] = $request->attributes->get($field); + } + + return $id; + } + + if (null !== $options->id) { + $name = $options->id; + } + + if ($request->attributes->has($name)) { + return $request->attributes->get($name); + } + + if (!$options->id && $request->attributes->has('id')) { + return $request->attributes->get('id'); + } + + return false; + } + + private function findOneBy(string $class, Request $request, MapEntity $options): false|object|null + { + if (null === $mapping = $options->mapping) { + $keys = $request->attributes->keys(); + $mapping = $keys ? array_combine($keys, $keys) : []; + } + + foreach ($options->exclude as $exclude) { + unset($mapping[$exclude]); + } + + if (!$mapping) { + return false; + } + + // if a specific id has been defined in the options and there is no corresponding attribute + // return false in order to avoid a fallback to the id which might be of another object + if (\is_string($options->id) && null === $request->attributes->get($options->id)) { + return false; + } + + $criteria = []; + $objectManager = $this->getManager($options->objectManager, $class); + $metadata = $objectManager->getClassMetadata($class); + + foreach ($mapping as $attribute => $field) { + if (!$metadata->hasField($field) && (!$metadata->hasAssociation($field) || !$metadata->isSingleValuedAssociation($field))) { + continue; + } + + $criteria[$field] = $request->attributes->get($attribute); + } + + if ($options->stripNull) { + $criteria = array_filter($criteria, static fn ($value) => null !== $value); + } + + if (!$criteria) { + return false; + } + + try { + return $objectManager->getRepository($class)->findOneBy($criteria); + } catch (NoResultException|ConversionException) { + return null; + } + } + + private function findViaExpression(string $class, Request $request, string $expression, MapEntity $options): ?object + { + if (!$this->expressionLanguage) { + throw new \LogicException(sprintf('You cannot use the "%s" if the ExpressionLanguage component is not available. Try running "composer require symfony/expression-language".', __CLASS__)); + } + + $repository = $this->getManager($options->objectManager, $class)->getRepository($class); + $variables = array_merge($request->attributes->all(), ['repository' => $repository]); + + try { + return $this->expressionLanguage->evaluate($expression, $variables); + } catch (NoResultException|ConversionException) { + return null; + } + } + + private function getOptions(ArgumentMetadata $argument): MapEntity + { + /** @var MapEntity $options */ + $options = $argument->getAttributes(MapEntity::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? $this->defaults; + + return $options->withDefaults($this->defaults, $argument->getType()); + } +} diff --git a/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php b/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php new file mode 100644 index 0000000000000..74caf14c9af55 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Attribute; + +/** + * Indicates that a controller argument should receive an Entity. + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class MapEntity +{ + public function __construct( + public ?string $class = null, + public ?string $objectManager = null, + public ?string $expr = null, + public ?array $mapping = null, + public ?array $exclude = null, + public ?bool $stripNull = null, + public array|string|null $id = null, + public ?bool $evictCache = null, + public bool $disabled = false, + ) { + } + + public function withDefaults(self $defaults, ?string $class): static + { + $clone = clone $this; + $clone->class ??= class_exists($class ?? '') ? $class : null; + $clone->objectManager ??= $defaults->objectManager; + $clone->expr ??= $defaults->expr; + $clone->mapping ??= $defaults->mapping; + $clone->exclude ??= $defaults->exclude ?? []; + $clone->stripNull ??= $defaults->stripNull ?? false; + $clone->id ??= $defaults->id; + $clone->evictCache ??= $defaults->evictCache ?? false; + + return $clone; + } +} diff --git a/src/Symfony/Bridge/Doctrine/CHANGELOG.md b/src/Symfony/Bridge/Doctrine/CHANGELOG.md index 750de71c5b4cd..db7ce80db1a16 100644 --- a/src/Symfony/Bridge/Doctrine/CHANGELOG.md +++ b/src/Symfony/Bridge/Doctrine/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 6.2 --- + * Add `#[MapEntity]` with its corresponding `EntityArgumentResolver` * Add `NAME` constant to `UlidType` and `UuidType` 6.0 diff --git a/src/Symfony/Bridge/Doctrine/Tests/ArgumentResolver/EntityValueResolverTest.php b/src/Symfony/Bridge/Doctrine/Tests/ArgumentResolver/EntityValueResolverTest.php new file mode 100644 index 0000000000000..2dc6c0f731371 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/ArgumentResolver/EntityValueResolverTest.php @@ -0,0 +1,615 @@ +getMockBuilder(ManagerRegistry::class)->getMock(); + $manager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $metadataFactory = $this->getMockBuilder(ClassMetadataFactory::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $registry->expects($this->once()) + ->method('getManagerNames') + ->with() + ->willReturn(['default' => 'default']); + + $registry->expects($this->once()) + ->method('getManagerForClass') + ->with('stdClass') + ->willReturn($manager); + $manager->expects($this->once()) + ->method('getMetadataFactory') + ->with() + ->willReturn($metadataFactory); + $metadataFactory->expects($this->once()) + ->method('isTransient') + ->with('stdClass') + ->willReturn(false); + + $request = new Request(); + $argument = $this->createArgument(); + + $this->assertTrue($converter->supports($request, $argument)); + } + + public function testSupportWithoutRegistry() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $registry->expects($this->once()) + ->method('getManagerNames') + ->with() + ->willReturn([]); + + $request = new Request(); + $argument = $this->createArgument(); + + $this->assertFalse($converter->supports($request, $argument)); + } + + public function testSupportWithoutClass() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $registry->expects($this->once()) + ->method('getManagerNames') + ->with() + ->willReturn(['default' => 'default']); + + $request = new Request(); + $argument = new ArgumentMetadata('arg', null, false, false, null); + + $this->assertFalse($converter->supports($request, $argument)); + } + + public function testSupportWithoutAttribute() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry, null, new MapEntity(disabled: true)); + + $registry->expects($this->once()) + ->method('getManagerNames') + ->with() + ->willReturn(['default' => 'default']); + + $request = new Request(); + $argument = $this->createArgument(); + + $this->assertFalse($converter->supports($request, $argument)); + } + + public function testSupportWithoutManager() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $registry->expects($this->once()) + ->method('getManagerNames') + ->with() + ->willReturn(['default' => 'default']); + + $registry->expects($this->once()) + ->method('getManagerForClass') + ->with('stdClass') + ->willReturn(null); + + $request = new Request(); + $argument = $this->createArgument(); + + $this->assertFalse($converter->supports($request, $argument)); + } + + public function testApplyWithNoIdAndData() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $this->expectException(\LogicException::class); + + $request = new Request(); + $argument = $this->createArgument(null, new MapEntity()); + + $converter->resolve($request, $argument); + } + + public function testApplyWithNoIdAndDataOptional() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $request = new Request(); + $argument = $this->createArgument(null, new MapEntity(), 'arg', true); + + $ret = $converter->resolve($request, $argument); + + $this->assertYieldEquals([null], $ret); + } + + public function testApplyWithStripNulls() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $request = new Request(); + $request->attributes->set('arg', null); + $argument = $this->createArgument('stdClass', new MapEntity(mapping: ['arg' => 'arg'], stripNull: true), 'arg', true); + + $classMetadata = $this->getMockBuilder(ClassMetadata::class)->getMock(); + $manager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $manager->expects($this->once()) + ->method('getClassMetadata') + ->with('stdClass') + ->willReturn($classMetadata); + + $manager->expects($this->never()) + ->method('getRepository'); + + $registry->expects($this->once()) + ->method('getManagerForClass') + ->with('stdClass') + ->willReturn($manager); + + $classMetadata->expects($this->once()) + ->method('hasField') + ->with($this->equalTo('arg')) + ->willReturn(true); + + $ret = $converter->resolve($request, $argument); + + $this->assertYieldEquals([null], $ret); + } + + /** + * @dataProvider idsProvider + */ + public function testApplyWithId(string|int $id) + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $request = new Request(); + $request->attributes->set('id', $id); + + $argument = $this->createArgument('stdClass', new MapEntity(id: 'id')); + + $manager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectRepository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + $registry->expects($this->once()) + ->method('getManagerForClass') + ->with('stdClass') + ->willReturn($manager); + + $manager->expects($this->once()) + ->method('getRepository') + ->with('stdClass') + ->willReturn($objectRepository); + + $objectRepository->expects($this->once()) + ->method('find') + ->with($this->equalTo($id)) + ->willReturn($object = new \stdClass()); + + $ret = $converter->resolve($request, $argument); + + $this->assertYieldEquals([$object], $ret); + } + + public function testApplyWithConversionFailedException() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $request = new Request(); + $request->attributes->set('id', 'test'); + + $argument = $this->createArgument('stdClass', new MapEntity(id: 'id')); + + $manager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectRepository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + $registry->expects($this->once()) + ->method('getManagerForClass') + ->with('stdClass') + ->willReturn($manager); + + $manager->expects($this->once()) + ->method('getRepository') + ->with('stdClass') + ->willReturn($objectRepository); + + $objectRepository->expects($this->once()) + ->method('find') + ->with($this->equalTo('test')) + ->will($this->throwException(new ConversionException())); + + $this->expectException(NotFoundHttpException::class); + + $converter->resolve($request, $argument); + } + + public function testUsedProperIdentifier() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $request = new Request(); + $request->attributes->set('id', 1); + $request->attributes->set('entity_id', null); + $request->attributes->set('arg', null); + + $argument = $this->createArgument('stdClass', new MapEntity(id: 'entity_id'), 'arg', true); + + $ret = $converter->resolve($request, $argument); + + $this->assertYieldEquals([null], $ret); + } + + public function idsProvider(): iterable + { + yield [1]; + yield [0]; + yield ['foo']; + } + + public function testApplyGuessOptional() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $request = new Request(); + $request->attributes->set('arg', null); + + $argument = $this->createArgument('stdClass', new MapEntity(), 'arg', true); + + $classMetadata = $this->getMockBuilder(ClassMetadata::class)->getMock(); + $manager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $manager->expects($this->once()) + ->method('getClassMetadata') + ->with('stdClass') + ->willReturn($classMetadata); + + $objectRepository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + $registry->expects($this->once()) + ->method('getManagerForClass') + ->with('stdClass') + ->willReturn($manager); + + $manager->expects($this->never())->method('getRepository'); + + $objectRepository->expects($this->never())->method('find'); + $objectRepository->expects($this->never())->method('findOneBy'); + + $ret = $converter->resolve($request, $argument); + + $this->assertYieldEquals([null], $ret); + } + + public function testApplyWithMappingAndExclude() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $request = new Request(); + $request->attributes->set('foo', 1); + $request->attributes->set('bar', 2); + + $argument = $this->createArgument( + 'stdClass', + new MapEntity(mapping: ['foo' => 'Foo'], exclude: ['bar']) + ); + + $manager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $metadata = $this->getMockBuilder(ClassMetadata::class)->getMock(); + $repository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + + $registry->expects($this->once()) + ->method('getManagerForClass') + ->with('stdClass') + ->willReturn($manager); + + $manager->expects($this->once()) + ->method('getClassMetadata') + ->with('stdClass') + ->willReturn($metadata); + $manager->expects($this->once()) + ->method('getRepository') + ->with('stdClass') + ->willReturn($repository); + + $metadata->expects($this->once()) + ->method('hasField') + ->with($this->equalTo('Foo')) + ->willReturn(true); + + $repository->expects($this->once()) + ->method('findOneBy') + ->with($this->equalTo(['Foo' => 1])) + ->willReturn($object = new \stdClass()); + + $ret = $converter->resolve($request, $argument); + + $this->assertYieldEquals([$object], $ret); + } + + public function testIgnoreMappingWhenAutoMappingDisabled() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry, null, new MapEntity(mapping: [])); + + $request = new Request(); + $request->attributes->set('foo', 1); + + $argument = $this->createArgument( + 'stdClass', + new MapEntity() + ); + + $metadata = $this->getMockBuilder(ClassMetadata::class)->getMock(); + + $registry->expects($this->never()) + ->method('getManagerForClass'); + + $metadata->expects($this->never()) + ->method('hasField'); + + $this->expectException(\LogicException::class); + + $ret = $converter->resolve($request, $argument); + + $this->assertYieldEquals([], $ret); + } + + public function testSupports() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $argument = $this->createArgument('stdClass', new MapEntity()); + $metadataFactory = $this->getMockBuilder(ClassMetadataFactory::class)->getMock(); + $metadataFactory->expects($this->once()) + ->method('isTransient') + ->with($this->equalTo('stdClass')) + ->willReturn(false); + + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectManager->expects($this->once()) + ->method('getMetadataFactory') + ->willReturn($metadataFactory); + + $registry->expects($this->any()) + ->method('getManagerNames') + ->willReturn(['default' => 'default']); + + $registry->expects($this->once()) + ->method('getManagerForClass') + ->with('stdClass') + ->willReturn($objectManager); + + $ret = $converter->supports(new Request(), $argument); + + $this->assertTrue($ret, 'Should be supported'); + } + + public function testSupportsWithConfiguredObjectManager() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $argument = $this->createArgument('stdClass', new MapEntity(objectManager: 'foo')); + $metadataFactory = $this->getMockBuilder(ClassMetadataFactory::class)->getMock(); + $metadataFactory->expects($this->once()) + ->method('isTransient') + ->with($this->equalTo('stdClass')) + ->willReturn(false); + + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectManager->expects($this->once()) + ->method('getMetadataFactory') + ->willReturn($metadataFactory); + + $registry->expects($this->exactly(2)) + ->method('getManagerNames') + ->willReturn(['foo' => 'foo']); + + $registry->expects($this->once()) + ->method('getManager') + ->with('foo') + ->willReturn($objectManager); + + $ret = $converter->supports(new Request(), $argument); + + $this->assertTrue($ret, 'Should be supported'); + } + + public function testSupportsWithDifferentConfiguration() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $argument = $this->createArgument('DateTime'); + + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectManager->expects($this->never()) + ->method('getMetadataFactory'); + + $registry->expects($this->any()) + ->method('getManagerNames') + ->willReturn(['default' => 'default']); + + $registry->expects($this->never()) + ->method('getManager'); + + $ret = $converter->supports(new Request(), $argument); + + $this->assertFalse($ret, 'Should not be supported'); + } + + public function testExceptionWithExpressionIfNoLanguageAvailable() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $converter = new EntityValueResolver($registry); + + $this->expectException(\LogicException::class); + + $request = new Request(); + $argument = $this->createArgument( + 'stdClass', + new MapEntity(expr: 'repository.find(id)'), + 'arg1' + ); + + $converter->resolve($request, $argument); + } + + public function testExpressionFailureReturns404() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $language = $this->getMockBuilder(ExpressionLanguage::class)->getMock(); + $converter = new EntityValueResolver($registry, $language); + + $this->expectException(NotFoundHttpException::class); + + $request = new Request(); + $argument = $this->createArgument( + 'stdClass', + new MapEntity(expr: 'repository.someMethod()'), + 'arg1' + ); + + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectRepository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + + $objectManager->expects($this->once()) + ->method('getRepository') + ->willReturn($objectRepository); + + // find should not be attempted on this repository as a fallback + $objectRepository->expects($this->never()) + ->method('find'); + + $registry->expects($this->once()) + ->method('getManagerForClass') + ->willReturn($objectManager); + + $language->expects($this->once()) + ->method('evaluate') + ->willReturn(null); + + $converter->resolve($request, $argument); + } + + public function testExpressionMapsToArgument() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $language = $this->getMockBuilder(ExpressionLanguage::class)->getMock(); + $converter = new EntityValueResolver($registry, $language); + + $request = new Request(); + $request->attributes->set('id', 5); + $argument = $this->createArgument( + 'stdClass', + new MapEntity(expr: 'repository.findOneByCustomMethod(id)'), + 'arg1' + ); + + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectRepository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + + $objectManager->expects($this->once()) + ->method('getRepository') + ->willReturn($objectRepository); + + // find should not be attempted on this repository as a fallback + $objectRepository->expects($this->never()) + ->method('find'); + + $registry->expects($this->once()) + ->method('getManagerForClass') + ->willReturn($objectManager); + + $language->expects($this->once()) + ->method('evaluate') + ->with('repository.findOneByCustomMethod(id)', [ + 'repository' => $objectRepository, + 'id' => 5, + ]) + ->willReturn($object = new \stdClass()); + + $ret = $converter->resolve($request, $argument); + $this->assertYieldEquals([$object], $ret); + } + + public function testExpressionSyntaxErrorThrowsException() + { + $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $language = $this->getMockBuilder(ExpressionLanguage::class)->getMock(); + $converter = new EntityValueResolver($registry, $language); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('syntax error message around position 10'); + + $request = new Request(); + $argument = $this->createArgument( + 'stdClass', + new MapEntity(expr: 'repository.findOneByCustomMethod(id)'), + 'arg1' + ); + + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectRepository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + + $objectManager->expects($this->once()) + ->method('getRepository') + ->willReturn($objectRepository); + + // find should not be attempted on this repository as a fallback + $objectRepository->expects($this->never()) + ->method('find'); + + $registry->expects($this->once()) + ->method('getManagerForClass') + ->willReturn($objectManager); + + $language->expects($this->once()) + ->method('evaluate') + ->will($this->throwException(new SyntaxError('syntax error message', 10))); + + $ret = $converter->resolve($request, $argument); + + $this->assertYieldEquals([null], $ret); + } + + private function createArgument(string $class = null, MapEntity $entity = null, string $name = 'arg', bool $isNullable = false): ArgumentMetadata + { + return new ArgumentMetadata($name, $class ?? \stdClass::class, false, false, null, $isNullable, $entity ? [$entity] : []); + } + + private function assertYieldEquals(array $expected, iterable $generator) + { + $args = []; + foreach ($generator as $arg) { + $args[] = $arg; + } + + $this->assertEquals($expected, $args); + } +}