Skip to content

Commit 4524083

Browse files
jderussefabpot
authored andcommitted
Add an Entity Argument Resolver
1 parent 397abb6 commit 4524083

File tree

3 files changed

+939
-0
lines changed

3 files changed

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

0 commit comments

Comments
 (0)