Skip to content

Commit a9f09ae

Browse files
jderussenicolas-grekas
authored andcommitted
Add an Entity Argument Resolver
1 parent 3edca67 commit a9f09ae

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 <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\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 <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+
'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 <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 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)
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