Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 92935e5

Browse files
committed
feature #62917 [Console] Add ArgumentResolver (chalasr)
This PR was merged into the 8.1 branch. Discussion ---------- [Console] Add ArgumentResolver | Q | A | ------------- | --- | Branch? | 8.1 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | Fix #62829, #59794 | License | MIT Happy new year, Symfony lovers! This PR adds argument resolver support to Console commands similar to HttpKernel's controller argument resolution, allowing custom resolution of command input arguments and options through ValueResolverInterface implementations. It includes the following core resolvers: - BuiltinTypeValueResolver for PHP built-in types - BackedEnumValueResolver - DateTimeValueResolver for date/time objects - UidValueResolver for Uuid/Ulid objects - VariadicValueResolver for variadic parameters - MapInputValueResolver for DTO hydration - EntityValueResolver in Doctrine Bridge that shares most of its logic with the HTTP counterpart through a trait - ServiceValueResolver for service injection - DefaultValueResolver for fallback values All those are wired through compiler passes hosted by Console. Resolvers can be targeted to specific arguments using the `#[AsTargetedValueResolver]` attribute, following the same pattern as HttpKernel. | Feature | HttpKernel | Console | |-------------------------|-------------------------|----------------| | Built-in types | Yes | Yes | | BackedEnum | Yes | Yes | | DateTime | Yes | Yes | | Uid | Yes | Yes | | Variadic parameters | Yes | Yes | | Entity (Doctrine) | Yes | Yes | | DTO | Yes (MapRequestPayload) | Yes (MapInput) | | Targeted resolvers | Yes | Yes | | Default value fallback | Yes | Yes | | Service injection | Yes | Yes | | Request/Input injection | Yes | Yes | | Output/Style injection | No (irrelevant) | Yes | | CurrentUser resolution | Yes | No (irrelevant) | | Custom Resolvers | Yes | Yes | This brings feature parity between HTTP Controllers and CLI commands, the latter being given more and more well deserved attention in the AI era we're living. All resolvers except the `ServiceValueResolver` and `MapInputValueResolver` require the to-be-resolved parameter to either have an `#[Argument]` or an `#[Option]` attribute to indicate whether it maps to an input argument or an input option. Also `#[MapInput]` attribute now supports `\BackedEnum` and `\DateTime*` resolution for DTO properties (the actual resolution logic is delegated to the corresponding value resolvers). In #59794 I failed to come up with an abstraction that makes sense to share foundations and plumbing of this feature between HttpKernel and Console, i.e. one that does not involve too much tradeoffs on the public API (supporting both Input and Request is hard) and/or tons of deprecations. As such, this makes the Console implements his own system. This implies that ~35% of the attached patch is adapted from HttpKernel, which is on purpose and I think is fine: better abstract later if we have some serious enough use case. Let's make this happen! Commits ------- 7008e30 [Console] Add argument resolvers
2 parents 56c40dd + 7008e30 commit 92935e5

61 files changed

Lines changed: 4903 additions & 247 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Console;
13+
14+
use Doctrine\Persistence\ManagerRegistry;
15+
use Doctrine\Persistence\ObjectManager;
16+
use Symfony\Bridge\Doctrine\ArgumentResolver\EntityValueResolverTrait;
17+
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
18+
use Symfony\Component\Console\ArgumentResolver\Exception\NearMissValueResolverException;
19+
use Symfony\Component\Console\ArgumentResolver\ValueResolver\ValueResolverInterface;
20+
use Symfony\Component\Console\Attribute\Argument;
21+
use Symfony\Component\Console\Attribute\Option;
22+
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
23+
use Symfony\Component\Console\Exception\RuntimeException;
24+
use Symfony\Component\Console\Input\InputInterface;
25+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
26+
use Symfony\Component\String\UnicodeString;
27+
28+
/**
29+
* Resolves a Command parameter holding the #[MapEntity] attribute to an Entity.
30+
*
31+
* @author Fabien Potencier <[email protected]>
32+
* @author Jérémy Derussé <[email protected]>
33+
* @author Robin Chalas <[email protected]>
34+
*/
35+
final class EntityValueResolver implements ValueResolverInterface
36+
{
37+
use EntityValueResolverTrait;
38+
39+
public function __construct(
40+
private ManagerRegistry $registry,
41+
private ?ExpressionLanguage $expressionLanguage = null,
42+
private MapEntity $defaults = new MapEntity(),
43+
/** @var array<class-string, class-string> */
44+
private readonly array $typeAliases = [],
45+
) {
46+
}
47+
48+
public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable
49+
{
50+
if (!Argument::tryFrom($member->getMember()) && !Option::tryFrom($member->getMember())) {
51+
return [];
52+
}
53+
54+
$type = $member->getType();
55+
if (!$type instanceof \ReflectionNamedType || $type->isBuiltin()) {
56+
return [];
57+
}
58+
59+
$inputName = $member->getInputName();
60+
61+
if ($input->hasArgument($inputName) && \is_object($input->getArgument($inputName))) {
62+
return [];
63+
}
64+
65+
// #[MapEntity] is optional
66+
$attribute = $member->getAttribute(MapEntity::class) ?? $this->defaults;
67+
68+
$options = $attribute->withDefaults($this->defaults, $type->getName());
69+
70+
if (!$options->class) {
71+
return [];
72+
}
73+
74+
$options->class = $this->typeAliases[$options->class] ?? $options->class;
75+
76+
if (!$manager = $this->getManager($this->registry, $options->objectManager, $options->class)) {
77+
return [];
78+
}
79+
80+
$message = '';
81+
if (null !== $options->expr) {
82+
$variables = array_merge($input->getArguments(), ['input' => $input]);
83+
if (null === $object = $this->findViaExpression($this->expressionLanguage, $manager, $options, $variables)) {
84+
$message = \sprintf(' The expression "%s" returned null.', $options->expr);
85+
}
86+
} elseif (false === $object = $this->findById($manager, $options, $this->getIdentifier($inputName, $input, $options, $member))) {
87+
if (!$criteria = $this->getCriteria($inputName, $input, $options, $manager, $member)) {
88+
throw new NearMissValueResolverException(\sprintf('Cannot find mapping for "%s": use the #[MapEntity] attribute to configure entity resolution.', $options->class));
89+
}
90+
$object = $this->findOneByCriteria($manager, $options, $criteria);
91+
}
92+
93+
if (null === $object && !$member->isNullable()) {
94+
throw new RuntimeException($options->message ?? \sprintf('"%s" object not found by "%s".%s', $options->class, self::class, $message));
95+
}
96+
97+
return [$object];
98+
}
99+
100+
private function getIdentifier(string $argumentName, InputInterface $input, MapEntity $options, ReflectionMember $member): mixed
101+
{
102+
if (\is_array($options->id)) {
103+
$id = [];
104+
foreach ($options->id as $field) {
105+
if (str_contains($field, '%s')) {
106+
$field = \sprintf($field, $argumentName);
107+
}
108+
109+
$fieldName = (new UnicodeString($field))->kebab()->toString();
110+
111+
if (!$input->hasArgument($fieldName)) {
112+
return $options->stripNull ? false : null;
113+
}
114+
115+
$id[$field] = $input->getArgument($fieldName);
116+
}
117+
118+
return $id;
119+
}
120+
121+
if ($options->id) {
122+
$idName = (new UnicodeString($options->id))->kebab()->toString();
123+
124+
return $input->hasArgument($idName)
125+
? $input->getArgument($idName)
126+
: ($options->stripNull ? false : null);
127+
}
128+
129+
if ($input->hasArgument($argumentName)) {
130+
$value = $input->getArgument($argumentName);
131+
if (\is_array($value)) {
132+
return false;
133+
}
134+
135+
return $value ?? ($options->stripNull ? false : null);
136+
}
137+
138+
if ($input->hasArgument('id')) {
139+
return $input->getArgument('id') ?? ($options->stripNull ? false : null);
140+
}
141+
142+
return false;
143+
}
144+
145+
private function getCriteria(string $argumentName, InputInterface $input, MapEntity $options, ObjectManager $manager, ReflectionMember $member): array
146+
{
147+
$mapping = $options->mapping;
148+
149+
if (!$mapping && $input->hasArgument($argumentName) && \is_array($criteria = $input->getArgument($argumentName))) {
150+
foreach ($options->exclude ?? [] as $exclude) {
151+
unset($criteria[$exclude]);
152+
}
153+
154+
if ($options->stripNull) {
155+
$criteria = array_filter($criteria, static fn ($value) => null !== $value);
156+
}
157+
158+
return $criteria;
159+
}
160+
161+
if (!$mapping) {
162+
return [];
163+
}
164+
165+
if (array_is_list($mapping)) {
166+
/** @var list<string> $list */
167+
$list = $mapping;
168+
$mapping = array_combine($list, $list);
169+
}
170+
171+
$values = [];
172+
foreach (array_keys($mapping) as $attribute) {
173+
$attributeName = (new UnicodeString($attribute))->kebab()->toString();
174+
if ($input->hasArgument($attributeName)) {
175+
$values[$attribute] = $input->getArgument($attributeName);
176+
}
177+
}
178+
179+
return $this->buildCriteriaFromMapping($manager, $options, $mapping, $values);
180+
}
181+
}

src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php

Lines changed: 8 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,6 @@
1111

1212
namespace Symfony\Bridge\Doctrine\ArgumentResolver;
1313

14-
use Doctrine\DBAL\Types\ConversionException;
15-
use Doctrine\ORM\EntityManagerInterface;
16-
use Doctrine\ORM\NoResultException;
1714
use Doctrine\Persistence\ManagerRegistry;
1815
use Doctrine\Persistence\ObjectManager;
1916
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
@@ -32,6 +29,8 @@
3229
*/
3330
final class EntityValueResolver implements ValueResolverInterface
3431
{
32+
use EntityValueResolverTrait;
33+
3534
public function __construct(
3635
private ManagerRegistry $registry,
3736
private ?ExpressionLanguage $expressionLanguage = null,
@@ -56,17 +55,18 @@ public function resolve(Request $request, ArgumentMetadata $argument): array
5655

5756
$options->class = $this->typeAliases[$options->class] ?? $options->class;
5857

59-
if (!$manager = $this->getManager($options->objectManager, $options->class)) {
58+
if (!$manager = $this->getManager($this->registry, $options->objectManager, $options->class)) {
6059
return [];
6160
}
6261

6362
$message = '';
6463
if (null !== $options->expr) {
65-
if (null === $object = $this->findViaExpression($manager, $request, $options)) {
64+
$variables = array_merge($request->attributes->all(), ['request' => $request]);
65+
if (null === $object = $this->findViaExpression($this->expressionLanguage, $manager, $options, $variables)) {
6666
$message = \sprintf(' The expression "%s" returned null.', $options->expr);
6767
}
6868
// find by identifier?
69-
} elseif (false === $object = $this->find($manager, $request, $options, $argument)) {
69+
} elseif (false === $object = $this->findById($manager, $options, $this->getIdentifier($request, $options, $argument))) {
7070
// find by criteria
7171
if (!$criteria = $this->getCriteria($request, $options, $manager, $argument)) {
7272
if (!class_exists(NearMissValueResolverException::class)) {
@@ -75,11 +75,7 @@ public function resolve(Request $request, ArgumentMetadata $argument): array
7575

7676
throw new NearMissValueResolverException(\sprintf('Cannot find mapping for "%s": declare one using either the #[MapEntity] attribute or mapped route parameters.', $options->class));
7777
}
78-
try {
79-
$object = $manager->getRepository($options->class)->findOneBy($criteria);
80-
} catch (NoResultException|ConversionException) {
81-
$object = null;
82-
}
78+
$object = $this->findOneByCriteria($manager, $options, $criteria);
8379
}
8480

8581
if (null === $object && !$argument->isNullable()) {
@@ -89,49 +85,6 @@ public function resolve(Request $request, ArgumentMetadata $argument): array
8985
return [$object];
9086
}
9187

92-
private function getManager(?string $name, string $class): ?ObjectManager
93-
{
94-
if (null === $name) {
95-
return $this->registry->getManagerForClass($class);
96-
}
97-
98-
try {
99-
$manager = $this->registry->getManager($name);
100-
} catch (\InvalidArgumentException) {
101-
return null;
102-
}
103-
104-
return $manager->getMetadataFactory()->isTransient($class) ? null : $manager;
105-
}
106-
107-
private function find(ObjectManager $manager, Request $request, MapEntity $options, ArgumentMetadata $argument): false|object|null
108-
{
109-
if ($options->mapping || $options->exclude) {
110-
return false;
111-
}
112-
113-
$id = $this->getIdentifier($request, $options, $argument);
114-
if (false === $id || null === $id) {
115-
return $id;
116-
}
117-
if (\is_array($id) && \in_array(null, $id, true)) {
118-
return null;
119-
}
120-
121-
if ($options->evictCache && $manager instanceof EntityManagerInterface) {
122-
$cacheProvider = $manager->getCache();
123-
if ($cacheProvider && $cacheProvider->containsEntity($options->class, $id)) {
124-
$cacheProvider->evictEntity($options->class, $id);
125-
}
126-
}
127-
128-
try {
129-
return $manager->getRepository($options->class)->find($id);
130-
} catch (NoResultException|ConversionException) {
131-
return null;
132-
}
133-
}
134-
13588
private function getIdentifier(Request $request, MapEntity $options, ArgumentMetadata $argument): mixed
13689
{
13790
if (\is_array($options->id)) {
@@ -191,52 +144,10 @@ private function getCriteria(Request $request, MapEntity $options, ObjectManager
191144
return $criteria;
192145
}
193146

194-
if ($mapping && array_is_list($mapping)) {
195-
$mapping = array_combine($mapping, $mapping);
196-
}
197-
198-
foreach ($options->exclude as $exclude) {
199-
unset($mapping[$exclude]);
200-
}
201-
202147
if (!$mapping) {
203148
return [];
204149
}
205150

206-
$criteria = [];
207-
$metadata = null === $options->mapping ? $manager->getClassMetadata($options->class) : false;
208-
209-
foreach ($mapping as $attribute => $field) {
210-
if ($metadata && !$metadata->hasField($field) && (!$metadata->hasAssociation($field) || !$metadata->isSingleValuedAssociation($field))) {
211-
continue;
212-
}
213-
214-
$criteria[$field] = $request->attributes->get($attribute);
215-
}
216-
217-
if ($options->stripNull) {
218-
$criteria = array_filter($criteria, static fn ($value) => null !== $value);
219-
}
220-
221-
return $criteria;
222-
}
223-
224-
private function findViaExpression(ObjectManager $manager, Request $request, MapEntity $options): object|iterable|null
225-
{
226-
if (!$this->expressionLanguage) {
227-
throw new \LogicException(\sprintf('You cannot use the "%s" if the ExpressionLanguage component is not available. Try running "composer require symfony/expression-language".', __CLASS__));
228-
}
229-
230-
$repository = $manager->getRepository($options->class);
231-
$variables = array_merge($request->attributes->all(), [
232-
'repository' => $repository,
233-
'request' => $request,
234-
]);
235-
236-
try {
237-
return $this->expressionLanguage->evaluate($options->expr, $variables);
238-
} catch (NoResultException|ConversionException) {
239-
return null;
240-
}
151+
return $this->buildCriteriaFromMapping($manager, $options, $mapping, $request->attributes->all());
241152
}
242153
}

0 commit comments

Comments
 (0)