Skip to content

Navigation Menu

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 4524083

Browse filesBrowse files
jderussefabpot
authored andcommitted
Add an Entity Argument Resolver
1 parent 397abb6 commit 4524083
Copy full SHA for 4524083

File tree

3 files changed

+939
-0
lines changed
Filter options

3 files changed

+939
-0
lines changed
+293Lines 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+
}
+31Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.