diff --git a/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php new file mode 100644 index 0000000000000..2c46924b55aab --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php @@ -0,0 +1,293 @@ + + * + * 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 +{ + private array $defaultOptions = [ + 'object_manager' => null, + 'expr' => null, + 'mapping' => [], + 'exclude' => [], + 'strip_null' => false, + 'id' => null, + 'evict_cache' => false, + 'auto_mapping' => true, + 'attribute_only' => false, + ]; + + public function __construct( + private ManagerRegistry $registry, + private ?ExpressionLanguage $language = null, + array $defaultOptions = [], + ) { + $this->defaultOptions = array_merge($this->defaultOptions, $defaultOptions); + } + + /** + * {@inheritdoc} + */ + public function supports(Request $request, ArgumentMetadata $argument): bool + { + if (!$this->registry->getManagerNames()) { + return false; + } + + $options = $this->getOptions($argument); + if (null === $options['class']) { + return false; + } + + if ($options['attribute_only'] && !$options['has_attribute']) { + return false; + } + + // Doctrine Entity? + if (null === $objectManager = $this->getManager($options['object_manager'], $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 + $object = $this->findOneBy($class, $request, $options); + if (false === $object) { + 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, array $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['object_manager'], $class); + if ($options['evict_cache'] && $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, array $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, array $options): false|object|null + { + if (!$options['mapping']) { + if (!$options['auto_mapping']) { + return false; + } + + $keys = $request->attributes->keys(); + $options['mapping'] = $keys ? array_combine($keys, $keys) : []; + } + + foreach ($options['exclude'] as $exclude) { + unset($options['mapping'][$exclude]); + } + + if (!$options['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 ($options['id'] && null === $request->attributes->get($options['id'])) { + return false; + } + + $criteria = []; + $objectManager = $this->getManager($options['object_manager'], $class); + $metadata = $objectManager->getClassMetadata($class); + + foreach ($options['mapping'] as $attribute => $field) { + if (!$metadata->hasField($field) && (!$metadata->hasAssociation($field) || !$metadata->isSingleValuedAssociation($field))) { + continue; + } + + $criteria[$field] = $request->attributes->get($attribute); + } + + if ($options['strip_null']) { + $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, array $options): ?object + { + if (null === $this->language) { + 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['object_manager'], $class)->getRepository($class); + $variables = array_merge($request->attributes->all(), ['repository' => $repository]); + + try { + return $this->language->evaluate($expression, $variables); + } catch (NoResultException|ConversionException) { + return null; + } + } + + private function getOptions(ArgumentMetadata $argument): array + { + /** @var ?MapEntity $configuration */ + $configuration = $argument->getAttributes(MapEntity::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null; + + $argumentClass = $argument->getType(); + if ($argumentClass && !class_exists($argumentClass)) { + $argumentClass = null; + } + + if (null === $configuration) { + return array_merge($this->defaultOptions, [ + 'class' => $argumentClass, + 'has_attribute' => false, + ]); + } + + return [ + 'class' => $configuration->class ?? $argumentClass, + 'object_manager' => $configuration->objectManager ?? $this->defaultOptions['object_manager'], + 'expr' => $configuration->expr ?? $this->defaultOptions['expr'], + 'mapping' => $configuration->mapping ?? $this->defaultOptions['mapping'], + 'exclude' => $configuration->exclude ?? $this->defaultOptions['exclude'], + 'strip_null' => $configuration->stripNull ?? $this->defaultOptions['strip_null'], + 'id' => $configuration->id ?? $this->defaultOptions['id'], + 'evict_cache' => $configuration->evictCache ?? $this->defaultOptions['evict_cache'], + 'has_attribute' => true, + 'auto_mapping' => $this->defaultOptions['auto_mapping'], + 'attribute_only' => $this->defaultOptions['attribute_only'], + ]; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php b/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php new file mode 100644 index 0000000000000..b8b84848e4f15 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php @@ -0,0 +1,31 @@ + + * + * 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 readonly ?string $class = null, + public readonly ?string $objectManager = null, + public readonly ?string $expr = null, + public readonly array $mapping = [], + public readonly array $exclude = [], + public readonly bool $stripNull = false, + public readonly array|string|null $id = null, + public readonly bool $evictCache = false, + ) { + } +} 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..85df29d34c5a4 --- /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, ['attribute_only' => 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, ['auto_mapping' => false]); + + $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); + } +}