Skip to content

Commit 5675aa8

Browse files
committed
feature symfony#43854 [DoctrineBridge] Add an Entity Argument Resolver (jderusse, nicolas-grekas)
This PR was merged into the 6.2 branch. Discussion ---------- [DoctrineBridge] Add an Entity Argument Resolver | Q | A | ------------- | --- | Branch? | 6.1 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | part of symfony#40333 | License | MIT | Doc PR | todo This PR provides an Argument Resolver for Doctrine entities. This would replace the SensioFramework's DoctrineParamConverter (in fact most of the code is copy/pasted from here) and helps users to disable the paramConverter and fix the related issue. usage: ```yaml sensio_framework_extra: request: converters: false services: Symfony\Bridge\Doctrine\ArgumentResolver\EntityValueResolver: ~ ``` ```php #[Route('/blog/{slug}')] public function show(Post $post) { } ``` or with custom options ```php #[Route('/blog/{id}')] public function show( #[MapEntity(entityManager: 'foo', expr: 'repository.findNotDeletedById(id)')] Post $post ) { } ``` Commits ------- 5a3df5e Improve EntityValueResolver (#3) 4524083 Add an Entity Argument Resolver
2 parents 397abb6 + 5a3df5e commit 5675aa8

File tree

4 files changed

+909
-0
lines changed

4 files changed

+909
-0
lines changed
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bridge\Doctrine\ArgumentResolver;
13+
14+
use Doctrine\DBAL\Types\ConversionException;
15+
use Doctrine\ORM\EntityManagerInterface;
16+
use Doctrine\ORM\NoResultException;
17+
use Doctrine\Persistence\ManagerRegistry;
18+
use Doctrine\Persistence\ObjectManager;
19+
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
20+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
21+
use Symfony\Component\HttpFoundation\Request;
22+
use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
23+
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
24+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
25+
26+
/**
27+
* Yields the entity matching the criteria provided in the route.
28+
*
29+
* @author Fabien Potencier <[email protected]>
30+
* @author Jérémy Derussé <[email protected]>
31+
*/
32+
final class EntityValueResolver implements ArgumentValueResolverInterface
33+
{
34+
public function __construct(
35+
private ManagerRegistry $registry,
36+
private ?ExpressionLanguage $expressionLanguage = null,
37+
private MapEntity $defaults = new MapEntity(),
38+
) {
39+
}
40+
41+
/**
42+
* {@inheritdoc}
43+
*/
44+
public function supports(Request $request, ArgumentMetadata $argument): bool
45+
{
46+
if (!$this->registry->getManagerNames()) {
47+
return false;
48+
}
49+
50+
$options = $this->getOptions($argument);
51+
if (!$options->class || $options->disabled) {
52+
return false;
53+
}
54+
55+
// Doctrine Entity?
56+
if (!$objectManager = $this->getManager($options->objectManager, $options->class)) {
57+
return false;
58+
}
59+
60+
return !$objectManager->getMetadataFactory()->isTransient($options->class);
61+
}
62+
63+
/**
64+
* {@inheritdoc}
65+
*/
66+
public function resolve(Request $request, ArgumentMetadata $argument): iterable
67+
{
68+
$options = $this->getOptions($argument);
69+
$name = $argument->getName();
70+
$class = $options->class;
71+
72+
$errorMessage = null;
73+
if (null !== $options->expr) {
74+
if (null === $object = $this->findViaExpression($class, $request, $options->expr, $options)) {
75+
$errorMessage = sprintf('The expression "%s" returned null', $options->expr);
76+
}
77+
// find by identifier?
78+
} elseif (false === $object = $this->find($class, $request, $options, $name)) {
79+
// find by criteria
80+
if (false === $object = $this->findOneBy($class, $request, $options)) {
81+
if (!$argument->isNullable()) {
82+
throw new \LogicException(sprintf('Unable to guess how to get a Doctrine instance from the request information for parameter "%s".', $name));
83+
}
84+
85+
$object = null;
86+
}
87+
}
88+
89+
if (null === $object && !$argument->isNullable()) {
90+
$message = sprintf('"%s" object not found by the "%s" Argument Resolver.', $class, self::class);
91+
if ($errorMessage) {
92+
$message .= ' '.$errorMessage;
93+
}
94+
95+
throw new NotFoundHttpException($message);
96+
}
97+
98+
return [$object];
99+
}
100+
101+
private function getManager(?string $name, string $class): ?ObjectManager
102+
{
103+
if (null === $name) {
104+
return $this->registry->getManagerForClass($class);
105+
}
106+
107+
if (!isset($this->registry->getManagerNames()[$name])) {
108+
return null;
109+
}
110+
111+
try {
112+
return $this->registry->getManager($name);
113+
} catch (\InvalidArgumentException) {
114+
return null;
115+
}
116+
}
117+
118+
private function find(string $class, Request $request, MapEntity $options, string $name): false|object|null
119+
{
120+
if ($options->mapping || $options->exclude) {
121+
return false;
122+
}
123+
124+
$id = $this->getIdentifier($request, $options, $name);
125+
if (false === $id || null === $id) {
126+
return false;
127+
}
128+
129+
$objectManager = $this->getManager($options->objectManager, $class);
130+
if ($options->evictCache && $objectManager instanceof EntityManagerInterface) {
131+
$cacheProvider = $objectManager->getCache();
132+
if ($cacheProvider && $cacheProvider->containsEntity($class, $id)) {
133+
$cacheProvider->evictEntity($class, $id);
134+
}
135+
}
136+
137+
try {
138+
return $objectManager->getRepository($class)->find($id);
139+
} catch (NoResultException|ConversionException) {
140+
return null;
141+
}
142+
}
143+
144+
private function getIdentifier(Request $request, MapEntity $options, string $name): mixed
145+
{
146+
if (\is_array($options->id)) {
147+
$id = [];
148+
foreach ($options->id as $field) {
149+
// Convert "%s_uuid" to "foobar_uuid"
150+
if (str_contains($field, '%s')) {
151+
$field = sprintf($field, $name);
152+
}
153+
154+
$id[$field] = $request->attributes->get($field);
155+
}
156+
157+
return $id;
158+
}
159+
160+
if (null !== $options->id) {
161+
$name = $options->id;
162+
}
163+
164+
if ($request->attributes->has($name)) {
165+
return $request->attributes->get($name);
166+
}
167+
168+
if (!$options->id && $request->attributes->has('id')) {
169+
return $request->attributes->get('id');
170+
}
171+
172+
return false;
173+
}
174+
175+
private function findOneBy(string $class, Request $request, MapEntity $options): false|object|null
176+
{
177+
if (null === $mapping = $options->mapping) {
178+
$keys = $request->attributes->keys();
179+
$mapping = $keys ? array_combine($keys, $keys) : [];
180+
}
181+
182+
foreach ($options->exclude as $exclude) {
183+
unset($mapping[$exclude]);
184+
}
185+
186+
if (!$mapping) {
187+
return false;
188+
}
189+
190+
// if a specific id has been defined in the options and there is no corresponding attribute
191+
// return false in order to avoid a fallback to the id which might be of another object
192+
if (\is_string($options->id) && null === $request->attributes->get($options->id)) {
193+
return false;
194+
}
195+
196+
$criteria = [];
197+
$objectManager = $this->getManager($options->objectManager, $class);
198+
$metadata = $objectManager->getClassMetadata($class);
199+
200+
foreach ($mapping as $attribute => $field) {
201+
if (!$metadata->hasField($field) && (!$metadata->hasAssociation($field) || !$metadata->isSingleValuedAssociation($field))) {
202+
continue;
203+
}
204+
205+
$criteria[$field] = $request->attributes->get($attribute);
206+
}
207+
208+
if ($options->stripNull) {
209+
$criteria = array_filter($criteria, static fn ($value) => null !== $value);
210+
}
211+
212+
if (!$criteria) {
213+
return false;
214+
}
215+
216+
try {
217+
return $objectManager->getRepository($class)->findOneBy($criteria);
218+
} catch (NoResultException|ConversionException) {
219+
return null;
220+
}
221+
}
222+
223+
private function findViaExpression(string $class, Request $request, string $expression, MapEntity $options): ?object
224+
{
225+
if (!$this->expressionLanguage) {
226+
throw new \LogicException(sprintf('You cannot use the "%s" if the ExpressionLanguage component is not available. Try running "composer require symfony/expression-language".', __CLASS__));
227+
}
228+
229+
$repository = $this->getManager($options->objectManager, $class)->getRepository($class);
230+
$variables = array_merge($request->attributes->all(), ['repository' => $repository]);
231+
232+
try {
233+
return $this->expressionLanguage->evaluate($expression, $variables);
234+
} catch (NoResultException|ConversionException) {
235+
return null;
236+
}
237+
}
238+
239+
private function getOptions(ArgumentMetadata $argument): MapEntity
240+
{
241+
/** @var MapEntity $options */
242+
$options = $argument->getAttributes(MapEntity::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? $this->defaults;
243+
244+
return $options->withDefaults($this->defaults, $argument->getType());
245+
}
246+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bridge\Doctrine\Attribute;
13+
14+
/**
15+
* Indicates that a controller argument should receive an Entity.
16+
*/
17+
#[\Attribute(\Attribute::TARGET_PARAMETER)]
18+
class MapEntity
19+
{
20+
public function __construct(
21+
public ?string $class = null,
22+
public ?string $objectManager = null,
23+
public ?string $expr = null,
24+
public ?array $mapping = null,
25+
public ?array $exclude = null,
26+
public ?bool $stripNull = null,
27+
public array|string|null $id = null,
28+
public ?bool $evictCache = null,
29+
public bool $disabled = false,
30+
) {
31+
}
32+
33+
public function withDefaults(self $defaults, ?string $class): static
34+
{
35+
$clone = clone $this;
36+
$clone->class ??= class_exists($class ?? '') ? $class : null;
37+
$clone->objectManager ??= $defaults->objectManager;
38+
$clone->expr ??= $defaults->expr;
39+
$clone->mapping ??= $defaults->mapping;
40+
$clone->exclude ??= $defaults->exclude ?? [];
41+
$clone->stripNull ??= $defaults->stripNull ?? false;
42+
$clone->id ??= $defaults->id;
43+
$clone->evictCache ??= $defaults->evictCache ?? false;
44+
45+
return $clone;
46+
}
47+
}

src/Symfony/Bridge/Doctrine/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
6.2
55
---
66

7+
* Add `#[MapEntity]` with its corresponding `EntityArgumentResolver`
78
* Add `NAME` constant to `UlidType` and `UuidType`
89

910
6.0

0 commit comments

Comments
 (0)