Skip to content

Commit 0dca39b

Browse files
Merge branch '7.4' into 8.0
* 7.4: (28 commits) [Messenger] Allow Pheanstalk v8 [TypeInfo] Fix resolving constructor type with templates fix compatibility with RelayCluster 0.12 fix type alias with template resolving fix compatibility with RelayCluster 0.11 and 0.12 [DependencyInjection] Register a custom autoloader to generate `*Config` classes when they don't exist yet [Security] Add security:oidc-token:generate command [PropertyInfo][TypeInfo] Fix resolving constructor type with templates [WebProfilerBundle] ”finish” errored requests Add support for union types on AsEventListener [Console] Update CHANGELOG to reflect attribute name changes for interactive invokable commands bump ext-redis to 6.2 and ext-relay to 0.12 minimum [TypeInfo] Fix type alias with template resolving [Console] Add support for interactive invokable commands with `#[Interact]` and `#[Ask]` attributes bump ext-relay to 0.12+ fix merge [Config] Generate the array-shape of the current node instead of the whole root node in Config classes [HttpFoundation] Deprecate Request::get() in favor of using properties ->attributes, query or request directly fix Relay Cluster 0.12 compatibility [TypeInfo] ArrayShape can resolve key type as callable instead of string ...
2 parents a2991be + caff466 commit 0dca39b

File tree

13 files changed

+565
-22
lines changed

13 files changed

+565
-22
lines changed

Attribute/Argument.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class Argument
3232
* @var string|class-string<\BackedEnum>
3333
*/
3434
private string $typeName = '';
35+
private ?InteractiveAttributeInterface $interactiveAttribute = null;
3536

3637
/**
3738
* Represents a console command <argument> definition.
@@ -79,7 +80,8 @@ public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member)
7980

8081
$self->default = $reflection->hasDefaultValue() ? $reflection->getDefaultValue() : null;
8182

82-
$self->mode = ($reflection->hasDefaultValue() || $reflection->isNullable()) ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
83+
$isOptional = $reflection->hasDefaultValue() || $reflection->isNullable();
84+
$self->mode = $isOptional ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
8385
if ('array' === $self->typeName) {
8486
$self->mode |= InputArgument::IS_ARRAY;
8587
}
@@ -92,6 +94,12 @@ public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member)
9294
$self->suggestedValues = array_column($self->typeName::cases(), 'value');
9395
}
9496

97+
$self->interactiveAttribute = Ask::tryFrom($member, $self->name);
98+
99+
if ($self->interactiveAttribute && $isOptional) {
100+
throw new LogicException(\sprintf('The %s "$%s" argument of "%s" cannot be both interactive and optional.', $reflection->getMemberName(), $self->name, $reflection->getSourceName()));
101+
}
102+
95103
return $self;
96104
}
97105

@@ -118,4 +126,12 @@ public function resolveValue(InputInterface $input): mixed
118126

119127
return $value;
120128
}
129+
130+
/**
131+
* @internal
132+
*/
133+
public function getInteractiveAttribute(): ?InteractiveAttributeInterface
134+
{
135+
return $this->interactiveAttribute;
136+
}
121137
}

Attribute/Ask.php

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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\Component\Console\Attribute;
13+
14+
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
15+
use Symfony\Component\Console\Exception\InvalidArgumentException;
16+
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Question\Question;
18+
use Symfony\Component\Console\Style\SymfonyStyle;
19+
20+
#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
21+
class Ask implements InteractiveAttributeInterface
22+
{
23+
public ?\Closure $validator;
24+
private \Closure $closure;
25+
26+
/**
27+
* @param string $question The question to ask the user
28+
* @param string|bool|int|float|null $default The default answer to return if the user enters nothing
29+
* @param bool $hidden Whether the user response must be hidden or not
30+
* @param bool $multiline Whether the user response should accept newline characters
31+
* @param bool $trimmable Whether the user response must be trimmed or not
32+
* @param int|null $timeout The maximum time the user has to answer the question in seconds
33+
* @param callable|null $validator The validator for the question
34+
* @param int|null $maxAttempts The maximum number of attempts allowed to answer the question.
35+
* Null means an unlimited number of attempts
36+
*/
37+
public function __construct(
38+
public string $question,
39+
public string|bool|int|float|null $default = null,
40+
public bool $hidden = false,
41+
public bool $multiline = false,
42+
public bool $trimmable = true,
43+
public ?int $timeout = null,
44+
?callable $validator = null,
45+
public ?int $maxAttempts = null,
46+
) {
47+
$this->validator = $validator ? $validator(...) : null;
48+
}
49+
50+
/**
51+
* @internal
52+
*/
53+
public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member, string $name): ?self
54+
{
55+
$reflection = new ReflectionMember($member);
56+
57+
if (!$self = $reflection->getAttribute(self::class)) {
58+
return null;
59+
}
60+
61+
$self->closure = function (SymfonyStyle $io, InputInterface $input) use ($self, $reflection, $name) {
62+
if (($reflection->isProperty() && isset($this->{$reflection->getName()})) || ($reflection->isParameter() && null !== $input->getArgument($name))) {
63+
return;
64+
}
65+
66+
$question = new Question($self->question, $self->default);
67+
$question->setHidden($self->hidden);
68+
$question->setMultiline($self->multiline);
69+
$question->setTrimmable($self->trimmable);
70+
$question->setTimeout($self->timeout);
71+
72+
if (!$self->validator && $reflection->isProperty()) {
73+
$self->validator = function (mixed $value) use ($reflection): mixed {
74+
return $this->{$reflection->getName()} = $value;
75+
};
76+
}
77+
78+
$question->setValidator($self->validator);
79+
$question->setMaxAttempts($self->maxAttempts);
80+
81+
if ($reflection->isBackedEnumType()) {
82+
/** @var class-string<\BackedEnum> $backedType */
83+
$backedType = $reflection->getType()->getName();
84+
$question->setNormalizer(fn (string|int $value) => $backedType::tryFrom($value) ?? throw InvalidArgumentException::fromEnumValue($reflection->getName(), $value, array_map(fn (\BackedEnum $enum): string|int => $enum->value, $backedType::cases())));
85+
}
86+
87+
$value = $io->askQuestion($question);
88+
89+
if (null === $value && !$reflection->isNullable()) {
90+
return;
91+
}
92+
93+
if ($reflection->isProperty()) {
94+
$this->{$reflection->getName()} = $value;
95+
} else {
96+
$input->setArgument($name, $value);
97+
}
98+
};
99+
100+
return $self;
101+
}
102+
103+
/**
104+
* @internal
105+
*/
106+
public function getFunction(object $instance): \ReflectionFunction
107+
{
108+
return new \ReflectionFunction($this->closure->bindTo($instance, $instance::class));
109+
}
110+
}

Attribute/Interact.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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\Component\Console\Attribute;
13+
14+
use Symfony\Component\Console\Exception\LogicException;
15+
16+
#[\Attribute(\Attribute::TARGET_METHOD)]
17+
class Interact implements InteractiveAttributeInterface
18+
{
19+
private \ReflectionMethod $method;
20+
21+
/**
22+
* @internal
23+
*/
24+
public static function tryFrom(\ReflectionMethod $method): ?self
25+
{
26+
/** @var self|null $self */
27+
if (!$self = ($method->getAttributes(self::class)[0] ?? null)?->newInstance()) {
28+
return null;
29+
}
30+
31+
if (!$method->isPublic() || $method->isStatic()) {
32+
throw new LogicException(\sprintf('The interactive method "%s::%s()" must be public and non-static.', $method->getDeclaringClass()->getName(), $method->getName()));
33+
}
34+
35+
if ('__invoke' === $method->getName()) {
36+
throw new LogicException(\sprintf('The "%s::__invoke()" method cannot be used as an interactive method.', $method->getDeclaringClass()->getName()));
37+
}
38+
39+
$self->method = $method;
40+
41+
return $self;
42+
}
43+
44+
/**
45+
* @internal
46+
*/
47+
public function getFunction(object $instance): \ReflectionFunction
48+
{
49+
return new \ReflectionFunction($this->method->getClosure($instance));
50+
}
51+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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\Component\Console\Attribute;
13+
14+
/**
15+
* @internal
16+
*/
17+
interface InteractiveAttributeInterface
18+
{
19+
public function getFunction(object $instance): \ReflectionFunction;
20+
}

Attribute/MapInput.php

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
1515
use Symfony\Component\Console\Exception\LogicException;
1616
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Interaction\Interaction;
1718

1819
/**
1920
* Maps a command input into an object (DTO).
@@ -28,6 +29,14 @@ final class MapInput
2829

2930
private \ReflectionClass $class;
3031

32+
/**
33+
* @var list<Interact>
34+
*/
35+
private array $interactiveAttributes = [];
36+
37+
/**
38+
* @internal
39+
*/
3140
public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member): ?self
3241
{
3342
$reflection = new ReflectionMember($member);
@@ -49,46 +58,72 @@ public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member)
4958
$self->class = new \ReflectionClass($class);
5059

5160
foreach ($self->class->getProperties() as $property) {
52-
if (!$property->isPublic() || $property->isStatic()) {
53-
continue;
54-
}
55-
5661
if ($argument = Argument::tryFrom($property)) {
5762
$self->definition[$property->name] = $argument;
58-
continue;
59-
}
60-
61-
if ($option = Option::tryFrom($property)) {
63+
} elseif ($option = Option::tryFrom($property)) {
6264
$self->definition[$property->name] = $option;
63-
continue;
65+
} elseif ($input = self::tryFrom($property)) {
66+
$self->definition[$property->name] = $input;
6467
}
6568

66-
if ($input = self::tryFrom($property)) {
67-
$self->definition[$property->name] = $input;
69+
if (isset($self->definition[$property->name]) && (!$property->isPublic() || $property->isStatic())) {
70+
throw new LogicException(\sprintf('The input property "%s::$%s" must be public and non-static.', $self->class->name, $property->name));
6871
}
6972
}
7073

7174
if (!$self->definition) {
7275
throw new LogicException(\sprintf('The input class "%s" must have at least one argument or option.', $self->class->name));
7376
}
7477

78+
foreach ($self->class->getMethods() as $method) {
79+
if ($attribute = Interact::tryFrom($method)) {
80+
$self->interactiveAttributes[] = $attribute;
81+
}
82+
}
83+
7584
return $self;
7685
}
7786

7887
/**
7988
* @internal
8089
*/
81-
public function resolveValue(InputInterface $input): mixed
90+
public function resolveValue(InputInterface $input): object
8291
{
8392
$instance = $this->class->newInstanceWithoutConstructor();
8493

8594
foreach ($this->definition as $name => $spec) {
95+
// ignore required arguments that are not set yet (may happen in interactive mode)
96+
if ($spec instanceof Argument && null === $input->getArgument($spec->name) && $spec->toInputArgument()->isRequired()) {
97+
continue;
98+
}
99+
86100
$instance->$name = $spec->resolveValue($input);
87101
}
88102

89103
return $instance;
90104
}
91105

106+
/**
107+
* @internal
108+
*/
109+
public function setValue(InputInterface $input, object $object): void
110+
{
111+
foreach ($this->definition as $name => $spec) {
112+
$property = $this->class->getProperty($name);
113+
114+
if (!$property->isInitialized($object) || null === $value = $property->getValue($object)) {
115+
continue;
116+
}
117+
118+
match (true) {
119+
$spec instanceof Argument => $input->setArgument($spec->name, $value),
120+
$spec instanceof Option => $input->setOption($spec->name, $value),
121+
$spec instanceof self => $spec->setValue($input, $value),
122+
default => throw new LogicException('Unexpected specification type.'),
123+
};
124+
}
125+
}
126+
92127
/**
93128
* @return iterable<Argument>
94129
*/
@@ -116,4 +151,38 @@ public function getOptions(): iterable
116151
}
117152
}
118153
}
154+
155+
/**
156+
* @internal
157+
*
158+
* @return iterable<Interaction>
159+
*/
160+
public function getPropertyInteractions(): iterable
161+
{
162+
foreach ($this->definition as $spec) {
163+
if ($spec instanceof self) {
164+
yield from $spec->getPropertyInteractions();
165+
} elseif ($spec instanceof Argument && $attribute = $spec->getInteractiveAttribute()) {
166+
yield new Interaction($this, $attribute);
167+
}
168+
}
169+
}
170+
171+
/**
172+
* @internal
173+
*
174+
* @return iterable<Interaction>
175+
*/
176+
public function getMethodInteractions(): iterable
177+
{
178+
foreach ($this->definition as $spec) {
179+
if ($spec instanceof self) {
180+
yield from $spec->getMethodInteractions();
181+
}
182+
}
183+
184+
foreach ($this->interactiveAttributes as $attribute) {
185+
yield new Interaction($this, $attribute);
186+
}
187+
}
119188
}

Attribute/Reflection/ReflectionMember.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,19 @@ public function getMemberName(): string
9696
{
9797
return $this->member instanceof \ReflectionParameter ? 'parameter' : 'property';
9898
}
99+
100+
public function isBackedEnumType(): bool
101+
{
102+
return $this->member->getType() instanceof \ReflectionNamedType && is_subclass_of($this->member->getType()->getName(), \BackedEnum::class);
103+
}
104+
105+
public function isParameter(): bool
106+
{
107+
return $this->member instanceof \ReflectionParameter;
108+
}
109+
110+
public function isProperty(): bool
111+
{
112+
return $this->member instanceof \ReflectionProperty;
113+
}
99114
}

0 commit comments

Comments
 (0)