Skip to content

Commit 5be70c3

Browse files
committed
Add an Entity Argument Resolver
1 parent fd2cab9 commit 5be70c3

File tree

3 files changed

+883
-0
lines changed

3 files changed

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

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy