From 4524083aedd71c17ebc3d3a8873ab11ca556b15e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Deruss=C3=A9?= Date: Sun, 31 Oct 2021 11:50:07 +0100 Subject: [PATCH 1/2] Add an Entity Argument Resolver --- .../ArgumentResolver/EntityValueResolver.php | 293 +++++++++ .../Bridge/Doctrine/Attribute/MapEntity.php | 31 + .../EntityValueResolverTest.php | 615 ++++++++++++++++++ 3 files changed, 939 insertions(+) create mode 100644 src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php create mode 100644 src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php create mode 100644 src/Symfony/Bridge/Doctrine/Tests/ArgumentResolver/EntityValueResolverTest.php 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); + } +} From 5a3df5eb384268d5ffec928f560547205d0d94ca Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 12 Jul 2022 15:18:49 +0300 Subject: [PATCH 2/2] Improve EntityValueResolver (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jérémy Derussé --- .../ArgumentResolver/EntityValueResolver.php | 123 ++++++------------ .../Bridge/Doctrine/Attribute/MapEntity.php | 32 +++-- src/Symfony/Bridge/Doctrine/CHANGELOG.md | 1 + .../EntityValueResolverTest.php | 4 +- 4 files changed, 65 insertions(+), 95 deletions(-) diff --git a/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php index 2c46924b55aab..98f8150674154 100644 --- a/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php +++ b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php @@ -31,24 +31,11 @@ */ 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 = [], + private ?ExpressionLanguage $expressionLanguage = null, + private MapEntity $defaults = new MapEntity(), ) { - $this->defaultOptions = array_merge($this->defaultOptions, $defaultOptions); } /** @@ -61,20 +48,16 @@ public function supports(Request $request, ArgumentMetadata $argument): bool } $options = $this->getOptions($argument); - if (null === $options['class']) { - return false; - } - - if ($options['attribute_only'] && !$options['has_attribute']) { + if (!$options->class || $options->disabled) { return false; } // Doctrine Entity? - if (null === $objectManager = $this->getManager($options['object_manager'], $options['class'])) { + if (!$objectManager = $this->getManager($options->objectManager, $options->class)) { return false; } - return !$objectManager->getMetadataFactory()->isTransient($options['class']); + return !$objectManager->getMetadataFactory()->isTransient($options->class); } /** @@ -83,20 +66,18 @@ public function supports(Request $request, ArgumentMetadata $argument): bool public function resolve(Request $request, ArgumentMetadata $argument): iterable { $options = $this->getOptions($argument); - $name = $argument->getName(); - $class = $options['class']; + $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']); + 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 (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)); } @@ -134,9 +115,9 @@ private function getManager(?string $name, string $class): ?ObjectManager } } - private function find(string $class, Request $request, array $options, string $name): false|object|null + private function find(string $class, Request $request, MapEntity $options, string $name): false|object|null { - if ($options['mapping'] || $options['exclude']) { + if ($options->mapping || $options->exclude) { return false; } @@ -145,8 +126,8 @@ private function find(string $class, Request $request, array $options, string $n return false; } - $objectManager = $this->getManager($options['object_manager'], $class); - if ($options['evict_cache'] && $objectManager instanceof EntityManagerInterface) { + $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); @@ -160,11 +141,11 @@ private function find(string $class, Request $request, array $options, string $n } } - private function getIdentifier(Request $request, array $options, string $name): mixed + private function getIdentifier(Request $request, MapEntity $options, string $name): mixed { - if (\is_array($options['id'])) { + if (\is_array($options->id)) { $id = []; - foreach ($options['id'] as $field) { + foreach ($options->id as $field) { // Convert "%s_uuid" to "foobar_uuid" if (str_contains($field, '%s')) { $field = sprintf($field, $name); @@ -176,51 +157,47 @@ private function getIdentifier(Request $request, array $options, string $name): return $id; } - if (null !== $options['id']) { - $name = $options['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')) { + 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 + private function findOneBy(string $class, Request $request, MapEntity $options): false|object|null { - if (!$options['mapping']) { - if (!$options['auto_mapping']) { - return false; - } - + if (null === $mapping = $options->mapping) { $keys = $request->attributes->keys(); - $options['mapping'] = $keys ? array_combine($keys, $keys) : []; + $mapping = $keys ? array_combine($keys, $keys) : []; } - foreach ($options['exclude'] as $exclude) { - unset($options['mapping'][$exclude]); + foreach ($options->exclude as $exclude) { + unset($mapping[$exclude]); } - if (!$options['mapping']) { + 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 ($options['id'] && null === $request->attributes->get($options['id'])) { + if (\is_string($options->id) && null === $request->attributes->get($options->id)) { return false; } $criteria = []; - $objectManager = $this->getManager($options['object_manager'], $class); + $objectManager = $this->getManager($options->objectManager, $class); $metadata = $objectManager->getClassMetadata($class); - foreach ($options['mapping'] as $attribute => $field) { + foreach ($mapping as $attribute => $field) { if (!$metadata->hasField($field) && (!$metadata->hasAssociation($field) || !$metadata->isSingleValuedAssociation($field))) { continue; } @@ -228,7 +205,7 @@ private function findOneBy(string $class, Request $request, array $options): fal $criteria[$field] = $request->attributes->get($attribute); } - if ($options['strip_null']) { + if ($options->stripNull) { $criteria = array_filter($criteria, static fn ($value) => null !== $value); } @@ -243,51 +220,27 @@ private function findOneBy(string $class, Request $request, array $options): fal } } - private function findViaExpression(string $class, Request $request, string $expression, array $options): ?object + private function findViaExpression(string $class, Request $request, string $expression, MapEntity $options): ?object { - if (null === $this->language) { + 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['object_manager'], $class)->getRepository($class); + $repository = $this->getManager($options->objectManager, $class)->getRepository($class); $variables = array_merge($request->attributes->all(), ['repository' => $repository]); try { - return $this->language->evaluate($expression, $variables); + return $this->expressionLanguage->evaluate($expression, $variables); } catch (NoResultException|ConversionException) { return null; } } - private function getOptions(ArgumentMetadata $argument): array + private function getOptions(ArgumentMetadata $argument): MapEntity { - /** @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, - ]); - } + /** @var MapEntity $options */ + $options = $argument->getAttributes(MapEntity::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? $this->defaults; - 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'], - ]; + 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 index b8b84848e4f15..74caf14c9af55 100644 --- a/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php +++ b/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php @@ -18,14 +18,30 @@ 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, + 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 index 85df29d34c5a4..2dc6c0f731371 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/ArgumentResolver/EntityValueResolverTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/ArgumentResolver/EntityValueResolverTest.php @@ -85,7 +85,7 @@ public function testSupportWithoutClass() public function testSupportWithoutAttribute() { $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); - $converter = new EntityValueResolver($registry, null, ['attribute_only' => true]); + $converter = new EntityValueResolver($registry, null, new MapEntity(disabled: true)); $registry->expects($this->once()) ->method('getManagerNames') @@ -353,7 +353,7 @@ public function testApplyWithMappingAndExclude() public function testIgnoreMappingWhenAutoMappingDisabled() { $registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); - $converter = new EntityValueResolver($registry, null, ['auto_mapping' => false]); + $converter = new EntityValueResolver($registry, null, new MapEntity(mapping: [])); $request = new Request(); $request->attributes->set('foo', 1);