Skip to content

Commit 9fa044e

Browse files
committed
Add an Entity Argument Resolver
1 parent 3fb7af0 commit 9fa044e

File tree

3 files changed

+941
-0
lines changed

3 files changed

+941
-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 <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 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) {
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) {
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) {
260+
return null;
261+
}
262+
}
263+
264+
private function getOptions(ArgumentMetadata $argument): array
265+
{
266+
/** @var ?Entity $configuration */
267+
$configuration = $argument->getAttributes(Entity::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null;
268+
269+
$argumentClass = $argument->getType();
270+
if ($argumentClass && !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 <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