Skip to content

Commit 45c19b0

Browse files
committed
Add an Entity Argument Resolver
1 parent 8d13f4b commit 45c19b0

File tree

3 files changed

+992
-0
lines changed

3 files changed

+992
-0
lines changed
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
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\Entity;
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+
private array $defaultOptions;
35+
36+
public function __construct(
37+
private ManagerRegistry $registry,
38+
private ?ExpressionLanguage $language = null,
39+
array $defaultOptions = []
40+
) {
41+
$this->defaultOptions = array_merge([
42+
'entity_manager' => null,
43+
'expr' => null,
44+
'mapping' => [],
45+
'exclude' => [],
46+
'strip_null' => false,
47+
'id' => null,
48+
'evict_cache' => false,
49+
'auto_mapping' => true,
50+
'attribute_only' => false,
51+
], $defaultOptions);
52+
}
53+
54+
/**
55+
* {@inheritdoc}
56+
*/
57+
public function supports(Request $request, ArgumentMetadata $argument): bool
58+
{
59+
if (0 === \count($this->registry->getManagerNames())) {
60+
return false;
61+
}
62+
63+
$options = $this->getOptions($argument);
64+
if (null === $options['class']) {
65+
return false;
66+
}
67+
68+
if ($options['attribute_only'] && !$options['has_attribute']) {
69+
return false;
70+
}
71+
72+
// Doctrine Entity?
73+
$em = $this->getManager($options['entity_manager'], $options['class']);
74+
if (null === $em) {
75+
return false;
76+
}
77+
78+
return !$em->getMetadataFactory()->isTransient($options['class']);
79+
}
80+
81+
/**
82+
* {@inheritdoc}
83+
*/
84+
public function resolve(Request $request, ArgumentMetadata $argument): iterable
85+
{
86+
$options = $this->getOptions($argument);
87+
88+
$name = $argument->getName();
89+
$class = $options['class'];
90+
91+
$errorMessage = null;
92+
if (null !== $options['expr']) {
93+
$object = $this->findViaExpression($class, $request, $options['expr'], $options);
94+
95+
if (null === $object) {
96+
$errorMessage = sprintf('The expression "%s" returned null', $options['expr']);
97+
}
98+
// find by identifier?
99+
} else {
100+
$object = $this->find($class, $request, $options, $name);
101+
if (false === $object) {
102+
// find by criteria
103+
$object = $this->findOneBy($class, $request, $options);
104+
if (false === $object) {
105+
if (!$argument->isNullable()) {
106+
throw new \LogicException(sprintf('Unable to guess how to get a Doctrine instance from the request information for parameter "%s".', $name));
107+
}
108+
109+
$object = null;
110+
}
111+
}
112+
}
113+
114+
if (null === $object && !$argument->isNullable()) {
115+
$message = sprintf('"%s" object not found by the "%s" Argument Resolver.', $class, self::class);
116+
if ($errorMessage) {
117+
$message .= ' '.$errorMessage;
118+
}
119+
120+
throw new NotFoundHttpException($message);
121+
}
122+
123+
return [$object];
124+
}
125+
126+
private function getManager(?string $name, string $class): ?ObjectManager
127+
{
128+
if (null === $name) {
129+
return $this->registry->getManagerForClass($class);
130+
}
131+
132+
return $this->registry->getManager($name);
133+
}
134+
135+
private function find(string $class, Request $request, array $options, string $name): false|object|null
136+
{
137+
if ($options['mapping'] || $options['exclude']) {
138+
return false;
139+
}
140+
141+
$id = $this->getIdentifier($request, $options, $name);
142+
if (false === $id || null === $id) {
143+
return false;
144+
}
145+
146+
$em = $this->getManager($options['entity_manager'], $class);
147+
if ($options['evict_cache'] && $em instanceof EntityManagerInterface) {
148+
$cacheProvider = $em->getCache();
149+
if ($cacheProvider && $cacheProvider->containsEntity($class, $id)) {
150+
$cacheProvider->evictEntity($class, $id);
151+
}
152+
}
153+
154+
try {
155+
return $em->getRepository($class)->find($id);
156+
} catch (NoResultException|ConversionException $e) {
157+
return null;
158+
}
159+
}
160+
161+
private function getIdentifier(Request $request, array $options, string $name): mixed
162+
{
163+
if (null !== $options['id']) {
164+
if (\is_array($options['id'])) {
165+
$id = [];
166+
foreach ($options['id'] as $field) {
167+
// Convert "%s_uuid" to "foobar_uuid"
168+
if (str_contains($field, '%s')) {
169+
$field = sprintf($field, $name);
170+
}
171+
172+
$id[$field] = $request->attributes->get($field);
173+
}
174+
175+
return $id;
176+
}
177+
178+
$name = $options['id'];
179+
}
180+
181+
if ($request->attributes->has($name)) {
182+
return $request->attributes->get($name);
183+
}
184+
185+
if ($request->attributes->has('id') && !$options['id']) {
186+
return $request->attributes->get('id');
187+
}
188+
189+
return false;
190+
}
191+
192+
private function findOneBy(string $class, Request $request, array $options): false|object|null
193+
{
194+
if (!$options['mapping']) {
195+
if (!$options['auto_mapping']) {
196+
return false;
197+
}
198+
199+
$keys = $request->attributes->keys();
200+
$options['mapping'] = $keys ? array_combine($keys, $keys) : [];
201+
}
202+
203+
foreach ($options['exclude'] as $exclude) {
204+
unset($options['mapping'][$exclude]);
205+
}
206+
207+
if (!$options['mapping']) {
208+
return false;
209+
}
210+
211+
// if a specific id has been defined in the options and there is no corresponding attribute
212+
// return false in order to avoid a fallback to the id which might be of another object
213+
if ($options['id'] && null === $request->attributes->get($options['id'])) {
214+
return false;
215+
}
216+
217+
$criteria = [];
218+
$em = $this->getManager($options['entity_manager'], $class);
219+
$metadata = $em->getClassMetadata($class);
220+
221+
foreach ($options['mapping'] as $attribute => $field) {
222+
if (!$metadata->hasField($field) && (!$metadata->hasAssociation(
223+
$field
224+
) || !$metadata->isSingleValuedAssociation($field))) {
225+
continue;
226+
}
227+
228+
$criteria[$field] = $request->attributes->get($attribute);
229+
}
230+
231+
if ($options['strip_null']) {
232+
$criteria = array_filter($criteria, static function ($value) {
233+
return null !== $value;
234+
});
235+
}
236+
237+
if (!$criteria) {
238+
return false;
239+
}
240+
241+
try {
242+
return $em->getRepository($class)->findOneBy($criteria);
243+
} catch (NoResultException|ConversionException $e) {
244+
return null;
245+
}
246+
}
247+
248+
private function findViaExpression(string $class, Request $request, string $expression, array $options): ?object
249+
{
250+
if (null === $this->language) {
251+
throw new \LogicException(sprintf('You cannot use the "%s" if the ExpressionLanguage component is not available. Try running "composer require symfony/expression-language".', __CLASS__));
252+
}
253+
254+
$repository = $this->getManager($options['entity_manager'], $class)->getRepository($class);
255+
$variables = array_merge($request->attributes->all(), ['repository' => $repository]);
256+
257+
try {
258+
return $this->language->evaluate($expression, $variables);
259+
} catch (NoResultException|ConversionException $e) {
260+
return null;
261+
}
262+
}
263+
264+
private function getOptions(ArgumentMetadata $argument): array
265+
{
266+
/** @var ?Entity $configuration */
267+
$configuration = method_exists($argument, 'getAttributes') ? $argument->getAttributes(Entity::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null : null;
268+
269+
$argumentClass = $argument->getType();
270+
if (!class_exists($argumentClass)) {
271+
$argumentClass = null;
272+
}
273+
274+
if (null === $configuration) {
275+
return array_merge($this->defaultOptions, [
276+
'class' => $argumentClass,
277+
'has_attribute' => false,
278+
]);
279+
}
280+
281+
return [
282+
'class' => $configuration->class ?? $argumentClass,
283+
'entity_manager' => $configuration->entityManager ?? $this->defaultOptions['entity_manager'],
284+
'expr' => $configuration->expr ?? $this->defaultOptions['expr'],
285+
'mapping' => $configuration->mapping ?? $this->defaultOptions['mapping'],
286+
'exclude' => $configuration->exclude ?? $this->defaultOptions['exclude'],
287+
'strip_null' => $configuration->stripNull ?? $this->defaultOptions['strip_null'],
288+
'id' => $configuration->id ?? $this->defaultOptions['id'],
289+
'evict_cache' => $configuration->evictCache ?? $this->defaultOptions['evict_cache'],
290+
'has_attribute' => true,
291+
'auto_mapping' => $this->defaultOptions['auto_mapping'],
292+
'attribute_only' => $this->defaultOptions['attribute_only'],
293+
];
294+
}
295+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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 Entity
19+
{
20+
public function __construct(
21+
public ?string $class = null,
22+
public ?string $entityManager = null,
23+
public ?string $expr = null,
24+
public array $mapping = [],
25+
public array $exclude = [],
26+
public bool $stripNull = false,
27+
public array|string|null $id = null,
28+
public bool $evictCache = false,
29+
) {
30+
}
31+
}

0 commit comments

Comments
 (0)