Skip to content

Navigation Menu

Sign in
Appearance settings

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 5675aa8

Browse filesBrowse files
committed
feature #43854 [DoctrineBridge] Add an Entity Argument Resolver (jderusse, nicolas-grekas)
This PR was merged into the 6.2 branch. Discussion ---------- [DoctrineBridge] Add an Entity Argument Resolver | Q | A | ------------- | --- | Branch? | 6.1 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | part of #40333 | License | MIT | Doc PR | todo This PR provides an Argument Resolver for Doctrine entities. This would replace the SensioFramework's DoctrineParamConverter (in fact most of the code is copy/pasted from here) and helps users to disable the paramConverter and fix the related issue. usage: ```yaml sensio_framework_extra: request: converters: false services: Symfony\Bridge\Doctrine\ArgumentResolver\EntityValueResolver: ~ ``` ```php #[Route('/blog/{slug}')] public function show(Post $post) { } ``` or with custom options ```php #[Route('/blog/{id}')] public function show( #[MapEntity(entityManager: 'foo', expr: 'repository.findNotDeletedById(id)')] Post $post ) { } ``` Commits ------- 5a3df5e Improve EntityValueResolver (#3) 4524083 Add an Entity Argument Resolver
2 parents 397abb6 + 5a3df5e commit 5675aa8
Copy full SHA for 5675aa8

File tree

4 files changed

+909
-0
lines changed
Filter options

4 files changed

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

‎src/Symfony/Bridge/Doctrine/CHANGELOG.md

Copy file name to clipboardExpand all lines: src/Symfony/Bridge/Doctrine/CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
6.2
55
---
66

7+
* Add `#[MapEntity]` with its corresponding `EntityArgumentResolver`
78
* Add `NAME` constant to `UlidType` and `UuidType`
89

910
6.0

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.