Skip to content

Commit 17195f4

Browse files
committed
[Console] Add argument resolvers
1 parent e840fae commit 17195f4

File tree

8 files changed

+517
-0
lines changed

8 files changed

+517
-0
lines changed
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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\Console\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\Console\ArgumentResolver\ValueResolver\ValueResolverInterface;
21+
use Symfony\Component\Console\Attribute\MapInput;
22+
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
23+
use Symfony\Component\Console\Input\InputInterface;
24+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
25+
use Symfony\Component\HttpKernel\Exception\NearMissValueResolverException;
26+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
27+
28+
/**
29+
* Yields the entity matching the criteria provided in the Command definition.
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+
public function __construct(
38+
private ManagerRegistry $registry,
39+
private ?ExpressionLanguage $expressionLanguage = null,
40+
private MapEntity $defaults = new MapEntity(),
41+
/** @var array<class-string, class-string> */
42+
private readonly array $typeAliases = [],
43+
) {
44+
}
45+
46+
public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable
47+
{
48+
if (\is_object($input->getArgument($argumentName))) {
49+
return [];
50+
}
51+
52+
$options = $member->getAttribute(MapEntity::class);
53+
$options = ($options[0] ?? $this->defaults)->withDefaults($this->defaults, $member->getType()->getName());
54+
55+
if (!$options->class || $options->disabled) {
56+
return [];
57+
}
58+
59+
$options->class = $this->typeAliases[$options->class] ?? $options->class;
60+
61+
if (!$manager = $this->getManager($options->objectManager, $options->class)) {
62+
return [];
63+
}
64+
65+
$message = '';
66+
if (null !== $options->expr) {
67+
if (null === $object = $this->findViaExpression($manager, $input, $options)) {
68+
$message = \sprintf(' The expression "%s" returned null.', $options->expr);
69+
}
70+
// find by identifier?
71+
} elseif (false === $object = $this->find($argumentName, $manager, $input, $options, $member)) {
72+
// find by criteria
73+
if (!$criteria = $this->getCriteria($argumentName, $input, $options, $manager, $member)) {
74+
if (!class_exists(NearMissValueResolverException::class)) {
75+
return [];
76+
}
77+
78+
throw new NearMissValueResolverException(\sprintf('Cannot find mapping for "%s": declare one using either the #[MapEntity] attribute or mapped route parameters.', $options->class));
79+
}
80+
try {
81+
$object = $manager->getRepository($options->class)->findOneBy($criteria);
82+
} catch (NoResultException|ConversionException) {
83+
$object = null;
84+
}
85+
}
86+
87+
if (null === $object && !$argument->isNullable()) {
88+
throw new NotFoundHttpException($options->message ?? (\sprintf('"%s" object not found by "%s".', $options->class, self::class).$message));
89+
}
90+
91+
return [$object];
92+
}
93+
94+
private function getManager(?string $name, string $class): ?ObjectManager
95+
{
96+
if (null === $name) {
97+
return $this->registry->getManagerForClass($class);
98+
}
99+
100+
try {
101+
$manager = $this->registry->getManager($name);
102+
} catch (\InvalidArgumentException) {
103+
return null;
104+
}
105+
106+
return $manager->getMetadataFactory()->isTransient($class) ? null : $manager;
107+
}
108+
109+
private function find(string $argumentName, ObjectManager $manager, InputInterface $input, MapEntity $options, ReflectionMember $member): false|object|null
110+
{
111+
if ($options->mapping || $options->exclude) {
112+
return false;
113+
}
114+
115+
$id = $this->getIdentifier($argumentName, $input, $options, $member);
116+
if (false === $id || null === $id) {
117+
return $id;
118+
}
119+
if (\is_array($id) && \in_array(null, $id, true)) {
120+
return null;
121+
}
122+
123+
if ($options->evictCache && $manager instanceof EntityManagerInterface) {
124+
$cacheProvider = $manager->getCache();
125+
if ($cacheProvider && $cacheProvider->containsEntity($options->class, $id)) {
126+
$cacheProvider->evictEntity($options->class, $id);
127+
}
128+
}
129+
130+
try {
131+
return $manager->getRepository($options->class)->find($id);
132+
} catch (NoResultException|ConversionException) {
133+
return null;
134+
}
135+
}
136+
137+
private function getIdentifier(string $argumentName, InputInterface $input, MapEntity $options, ReflectionMember $member): mixed
138+
{
139+
if (\is_array($options->id)) {
140+
$id = [];
141+
foreach ($options->id as $field) {
142+
// Convert "%s_uuid" to "foobar_uuid"
143+
if (str_contains($field, '%s')) {
144+
$field = \sprintf($field, $argumentName);
145+
}
146+
147+
$id[$field] = $input->getArgument($field);
148+
}
149+
150+
return $id;
151+
}
152+
153+
if ($options->id) {
154+
return $request->attributes->get($options->id) ?? ($options->stripNull ? false : null);
155+
}
156+
157+
158+
// if ($input->hasArgument($argumentName)) {
159+
// if (\is_array($id = $input->getArgument($argumentName))) {
160+
// return false;
161+
// }
162+
//
163+
// foreach ($request->attributes->get('_route_mapping') ?? [] as $parameter => $attribute) {
164+
// if ($argumentName === $attribute) {
165+
// $options->mapping = [$name => $parameter];
166+
//
167+
// return false;
168+
// }
169+
// }
170+
//
171+
// return $id ?? ($options->stripNull ? false : null);
172+
// }
173+
174+
if ($input->hasArgument('id')) {
175+
return $input->getArgument('id') ?? ($options->stripNull ? false : null);
176+
}
177+
178+
return false;
179+
}
180+
181+
private function getCriteria(string $argumntName, InputInterface $input, MapEntity $options, ObjectManager $manager, ReflectionMember $member): array
182+
{
183+
if (!($mapping = $options->mapping) && \is_array($criteria = $input->getArgument($argumntName))) {
184+
foreach ($options->exclude as $exclude) {
185+
unset($criteria[$exclude]);
186+
}
187+
188+
if ($options->stripNull) {
189+
$criteria = array_filter($criteria, static fn ($value) => null !== $value);
190+
}
191+
192+
return $criteria;
193+
}
194+
195+
if ($mapping && array_is_list($mapping)) {
196+
$mapping = array_combine($mapping, $mapping);
197+
}
198+
199+
foreach ($options->exclude as $exclude) {
200+
unset($mapping[$exclude]);
201+
}
202+
203+
if (!$mapping) {
204+
return [];
205+
}
206+
207+
$criteria = [];
208+
$metadata = null === $options->mapping ? $manager->getClassMetadata($options->class) : false;
209+
210+
foreach ($mapping as $attribute => $field) {
211+
if ($metadata && !$metadata->hasField($field) && (!$metadata->hasAssociation($field) || !$metadata->isSingleValuedAssociation($field))) {
212+
continue;
213+
}
214+
215+
$criteria[$field] = $input->getArgument($attribute);
216+
}
217+
218+
if ($options->stripNull) {
219+
$criteria = array_filter($criteria, static fn ($value) => null !== $value);
220+
}
221+
222+
return $criteria;
223+
}
224+
225+
private function findViaExpression(ObjectManager $manager, InputInterface $input, MapEntity $options): object|iterable|null
226+
{
227+
if (!$this->expressionLanguage) {
228+
throw new \LogicException(\sprintf('You cannot use the "%s" if the ExpressionLanguage component is not available. Try running "composer require symfony/expression-language".', __CLASS__));
229+
}
230+
231+
$repository = $manager->getRepository($options->class);
232+
$variables = array_merge($input->getArguments(), [], [
233+
'repository' => $repository,
234+
'input' => $input,
235+
]);
236+
237+
try {
238+
return $this->expressionLanguage->evaluate($options->expr, $variables);
239+
} catch (NoResultException|ConversionException) {
240+
return null;
241+
}
242+
}
243+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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\Component\Console\ArgumentResolver;
13+
14+
use Psr\Container\ContainerInterface;
15+
use Symfony\Component\Console\ArgumentResolver\ValueResolver\ValueResolverInterface;
16+
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
17+
use Symfony\Component\Console\Input\InputInterface;
18+
use Symfony\Component\HttpKernel\Attribute\ValueResolver;
19+
use Symfony\Component\Console\Exception\NearMissValueResolverException;
20+
use Symfony\Component\Console\Exception\ResolverNotFoundException;
21+
use Symfony\Contracts\Service\ServiceProviderInterface;
22+
23+
/**
24+
* Responsible for resolving the arguments passed to an action.
25+
*
26+
* @author Iltar van der Berg <[email protected]>
27+
* @author Robin Chalas <[email protected]>
28+
*/
29+
final class ArgumentResolver implements ArgumentResolverInterface
30+
{
31+
/**
32+
* @var ReflectionMember[]
33+
*/
34+
private array $argumentReflectors = [];
35+
36+
/**
37+
* @param iterable<mixed, ValueResolverInterface> $argumentValueResolvers
38+
*/
39+
public function __construct(
40+
private iterable $argumentValueResolvers = [],
41+
private ?ContainerInterface $namedResolvers = null,
42+
) {
43+
}
44+
45+
public function getArguments(InputInterface $input, callable $command, ?\ReflectionFunctionAbstract $reflector = null): array
46+
{
47+
if (!$this->argumentReflectors) {
48+
$arguments = [];
49+
$reflector ??= new \ReflectionFunction($command(...));
50+
//$controllerName = $this->getPrettyName($reflector);
51+
52+
foreach ($reflector->getParameters() as $param) {
53+
$attributes = [];
54+
foreach ($param->getAttributes() as $reflectionAttribute) {
55+
if (class_exists($reflectionAttribute->getName())) {
56+
$attributes[] = $reflectionAttribute->newInstance();
57+
}
58+
}
59+
60+
$this->argumentReflectors[$param->getName()] = new ReflectionMember($param);
61+
}
62+
}
63+
64+
$arguments = [];
65+
66+
foreach ($this->argumentReflectors as $argumentName => $member) {
67+
$argumentValueResolvers = $this->argumentValueResolvers;
68+
$disabledResolvers = [];
69+
70+
if ($this->namedResolvers && $attributes = $member->getAttributes(ValueResolver::class)) {
71+
$resolverName = null;
72+
foreach ($attributes as $attribute) {
73+
if ($attribute->disabled) {
74+
$disabledResolvers[$attribute->resolver] = true;
75+
} elseif ($resolverName) {
76+
throw new \LogicException(\sprintf('You can only pin one resolver per argument, but argument "$%s" of "%s()" has more.', $member->getName(), $member->getControllerName()));
77+
} else {
78+
$resolverName = $attribute->resolver;
79+
}
80+
}
81+
82+
if ($resolverName) {
83+
if (!$this->namedResolvers->has($resolverName)) {
84+
throw new ResolverNotFoundException($resolverName, $this->namedResolvers instanceof ServiceProviderInterface ? array_keys($this->namedResolvers->getProvidedServices()) : []);
85+
}
86+
87+
$argumentValueResolvers = [
88+
$this->namedResolvers->get($resolverName),
89+
// new DefaultValueResolver() ?
90+
];
91+
}
92+
}
93+
94+
$valueResolverExceptions = [];
95+
foreach ($argumentValueResolvers as $name => $resolver) {
96+
if (isset($disabledResolvers[\is_int($name) ? $resolver::class : $name])) {
97+
continue;
98+
}
99+
100+
try {
101+
$count = 0;
102+
foreach ($resolver->resolve($argumentName, $input, $member) as $argument) {
103+
++$count;
104+
$arguments[] = $argument;
105+
}
106+
} catch (NearMissValueResolverException $e) {
107+
$valueResolverExceptions[] = $e;
108+
}
109+
110+
if (1 < $count && !$member->isVariadic()) {
111+
throw new \InvalidArgumentException(\sprintf('"%s::resolve()" must yield at most one value for non-variadic arguments.', get_debug_type($resolver)));
112+
}
113+
114+
if ($count) {
115+
// continue to the next controller argument
116+
continue 2;
117+
}
118+
}
119+
120+
$reasons = array_map(static fn (NearMissValueResolverException $e) => $e->getMessage(), $valueResolverExceptions);
121+
if (!$reasons) {
122+
$reasons[] = 'Either the argument is nullable and no null value has been provided, no default value has been provided or there is a non-optional argument after this one.';
123+
}
124+
125+
$reasonCounter = 1;
126+
if (\count($reasons) > 1) {
127+
foreach ($reasons as $i => $reason) {
128+
$reasons[$i] = $reasonCounter.') '.$reason;
129+
++$reasonCounter;
130+
}
131+
}
132+
133+
throw new \RuntimeException(\sprintf('Controller "%s" requires the "$%s" argument that could not be resolved. '.($reasonCounter > 1 ? 'Possible reasons: ' : '').'%s', $member->getSourceName(), $member->getName(), implode(' ', $reasons)));
134+
}
135+
136+
return $arguments;
137+
}
138+
}

0 commit comments

Comments
 (0)