Skip to content

Commit 5675aa8

Browse 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

File tree

4 files changed

+909
-0
lines changed

4 files changed

+909
-0
lines changed
Lines 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+
}
Lines 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

Lines 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)
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