Skip to content

Commit 10566de

Browse files
chalasrsantysisi
authored andcommitted
feature #60586 [Console] Support BackedEnum in invokable commands (GromNaN)
This PR was merged into the 7.4 branch. Discussion ---------- [Console] Support `BackedEnum` in invokable commands | Q | A | ------------- | --- | Branch? | 7.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | Fix #60433 | License | MIT - Convert automatically from the string input into the backed enum value using `BackedEnum::from($value)` - Display a nice error message when the input value is not compatible with the typed enum - Use `BackedEnum::cases()` to provide autocompletion. Also part of the error message. ### Example Given this 2 backed enums ```php enum StringEnum: string { case Image = 'image'; case Video = 'video'; } enum IntEnum: int { case First = 1; case Second = 2; } ``` We declare this command: ```php #[AsCommand('enum')] class EnumCommand { public function __invoke( OutputInterface $output, #[Argument] StringEnum $string, #[Option] ?IntEnum $int = null, ): int { $output->writeln($string->value); $output->writeln($int?->value ?? 'No value'); return Command::SUCCESS; } } ``` Usage: <img width="393" alt="image" src="https://github.com/user-attachments/assets/5eb4cfe0-fbbb-4a48-966b-ac2bfe582bbd" /> Error with invalid **argument** value: <img width="906" alt="image" src="https://github.com/user-attachments/assets/ddc42d98-3f5e-41ee-9bd1-036ba9353a71" /> Error with invalid **option** value: <img width="758" alt="image" src="https://github.com/user-attachments/assets/c67c5dee-248e-4c0d-aaf7-fb8a52ea12c8" /> Commits ------- cad8869 [Console] Support enum in invokable commands
2 parents 4331e59 + cad8869 commit 10566de

File tree

6 files changed

+151
-9
lines changed

6 files changed

+151
-9
lines changed

src/Symfony/Component/Console/Attribute/Argument.php

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Symfony\Component\Console\Completion\CompletionInput;
1515
use Symfony\Component\Console\Completion\Suggestion;
16+
use Symfony\Component\Console\Exception\InvalidArgumentException;
1617
use Symfony\Component\Console\Exception\LogicException;
1718
use Symfony\Component\Console\Input\InputArgument;
1819
use Symfony\Component\Console\Input\InputInterface;
@@ -27,6 +28,7 @@ class Argument
2728
private array|\Closure $suggestedValues;
2829
private ?int $mode = null;
2930
private string $function = '';
31+
private string $typeName = '';
3032

3133
/**
3234
* Represents a console command <argument> definition.
@@ -66,27 +68,34 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self
6668
throw new LogicException(\sprintf('The parameter "$%s" of "%s()" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $name, $self->function));
6769
}
6870

69-
$parameterTypeName = $type->getName();
71+
$self->typeName = $type->getName();
72+
$isBackedEnum = is_subclass_of($self->typeName, \BackedEnum::class);
7073

71-
if (!\in_array($parameterTypeName, self::ALLOWED_TYPES, true)) {
72-
throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command argument. Only "%s" types are allowed.', $parameterTypeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES)));
74+
if (!\in_array($self->typeName, self::ALLOWED_TYPES, true) && !$isBackedEnum) {
75+
throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command argument. Only "%s" types and backed enums are allowed.', $self->typeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES)));
7376
}
7477

7578
if (!$self->name) {
7679
$self->name = (new UnicodeString($name))->kebab();
7780
}
7881

79-
$self->default = $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null;
82+
if ($parameter->isDefaultValueAvailable()) {
83+
$self->default = $parameter->getDefaultValue() instanceof \BackedEnum ? $parameter->getDefaultValue()->value : $parameter->getDefaultValue();
84+
}
8085

8186
$self->mode = $parameter->isDefaultValueAvailable() || $parameter->allowsNull() ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
82-
if ('array' === $parameterTypeName) {
87+
if ('array' === $self->typeName) {
8388
$self->mode |= InputArgument::IS_ARRAY;
8489
}
8590

8691
if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $parameter->getDeclaringFunction()->getClosureThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) {
8792
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
8893
}
8994

95+
if ($isBackedEnum && !$self->suggestedValues) {
96+
$self->suggestedValues = array_column(($self->typeName)::cases(), 'value');
97+
}
98+
9099
return $self;
91100
}
92101

@@ -105,6 +114,12 @@ public function toInputArgument(): InputArgument
105114
*/
106115
public function resolveValue(InputInterface $input): mixed
107116
{
108-
return $input->getArgument($this->name);
117+
$value = $input->getArgument($this->name);
118+
119+
if (is_subclass_of($this->typeName, \BackedEnum::class) && (is_string($value) || is_int($value))) {
120+
return ($this->typeName)::tryFrom($value) ?? throw InvalidArgumentException::fromEnumValue($this->name, $value, $this->suggestedValues);
121+
}
122+
123+
return $value;
109124
}
110125
}

src/Symfony/Component/Console/Attribute/Option.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Symfony\Component\Console\Completion\CompletionInput;
1515
use Symfony\Component\Console\Completion\Suggestion;
16+
use Symfony\Component\Console\Exception\InvalidOptionException;
1617
use Symfony\Component\Console\Exception\LogicException;
1718
use Symfony\Component\Console\Input\InputInterface;
1819
use Symfony\Component\Console\Input\InputOption;
@@ -75,7 +76,7 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self
7576
$self->name = (new UnicodeString($name))->kebab();
7677
}
7778

78-
$self->default = $parameter->getDefaultValue();
79+
$self->default = $parameter->getDefaultValue() instanceof \BackedEnum ? $parameter->getDefaultValue()->value : $parameter->getDefaultValue();
7980
$self->allowNull = $parameter->allowsNull();
8081

8182
if ($type instanceof \ReflectionUnionType) {
@@ -87,9 +88,10 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self
8788
}
8889

8990
$self->typeName = $type->getName();
91+
$isBackedEnum = is_subclass_of($self->typeName, \BackedEnum::class);
9092

91-
if (!\in_array($self->typeName, self::ALLOWED_TYPES, true)) {
92-
throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command option. Only "%s" types are allowed.', $self->typeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES)));
93+
if (!\in_array($self->typeName, self::ALLOWED_TYPES, true) && !$isBackedEnum) {
94+
throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command option. Only "%s" types and BackedEnum are allowed.', $self->typeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES)));
9395
}
9496

9597
if ('bool' === $self->typeName && $self->allowNull && \in_array($self->default, [true, false], true)) {
@@ -115,6 +117,10 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self
115117
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
116118
}
117119

120+
if ($isBackedEnum && !$self->suggestedValues) {
121+
$self->suggestedValues = array_column(($self->typeName)::cases(), 'value');
122+
}
123+
118124
return $self;
119125
}
120126

@@ -140,6 +146,10 @@ public function resolveValue(InputInterface $input): mixed
140146
return true;
141147
}
142148

149+
if (is_subclass_of($this->typeName, \BackedEnum::class) && (is_string($value) || is_int($value))) {
150+
return ($this->typeName)::tryFrom($value) ?? throw InvalidOptionException::fromEnumValue($this->name, $value, $this->suggestedValues);
151+
}
152+
143153
if ('array' === $this->typeName && $this->allowNull && [] === $value) {
144154
return null;
145155
}

src/Symfony/Component/Console/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ CHANGELOG
77
* Allow setting aliases and the hidden flag via the command name passed to the constructor
88
* Introduce `Symfony\Component\Console\Application::addCommand()` to simplify using invokable commands when the component is used standalone
99
* Deprecate `Symfony\Component\Console\Application::add()` in favor of `Symfony\Component\Console\Application::addCommand()`
10+
* Add `BackedEnum` support with `#[Argument]` and `#[Option]` inputs in invokable commands
1011

1112
7.3
1213
---

src/Symfony/Component/Console/Exception/InvalidArgumentException.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,17 @@
1616
*/
1717
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
1818
{
19+
/**
20+
* @internal
21+
*/
22+
public static function fromEnumValue(string $name, string $value, array|\Closure $suggestedValues): self
23+
{
24+
$error = \sprintf('The value "%s" is not valid for the "%s" argument.', $value, $name);
25+
26+
if (\is_array($suggestedValues)) {
27+
$error .= \sprintf(' Supported values are "%s".', implode('", "', $suggestedValues));
28+
}
29+
30+
return new self($error);
31+
}
1932
}

src/Symfony/Component/Console/Exception/InvalidOptionException.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,17 @@
1818
*/
1919
class InvalidOptionException extends \InvalidArgumentException implements ExceptionInterface
2020
{
21+
/**
22+
* @internal
23+
*/
24+
public static function fromEnumValue(string $name, string $value, array|\Closure $suggestedValues): self
25+
{
26+
$error = \sprintf('The value "%s" is not valid for the "%s" option.', $value, $name);
27+
28+
if (\is_array($suggestedValues)) {
29+
$error .= \sprintf(' Supported values are "%s".', implode('", "', $suggestedValues));
30+
}
31+
32+
return new self($error);
33+
}
2134
}

src/Symfony/Component/Console/Tests/Command/InvokableCommandTest.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@
1111

1212
namespace Symfony\Component\Console\Tests\Command;
1313

14+
use PHPUnit\Framework\Assert;
1415
use PHPUnit\Framework\TestCase;
1516
use Symfony\Component\Console\Attribute\Argument;
1617
use Symfony\Component\Console\Attribute\Option;
1718
use Symfony\Component\Console\Command\Command;
1819
use Symfony\Component\Console\Completion\CompletionInput;
1920
use Symfony\Component\Console\Completion\CompletionSuggestions;
2021
use Symfony\Component\Console\Completion\Suggestion;
22+
use Symfony\Component\Console\Exception\InvalidArgumentException;
2123
use Symfony\Component\Console\Exception\InvalidOptionException;
2224
use Symfony\Component\Console\Exception\LogicException;
2325
use Symfony\Component\Console\Input\ArrayInput;
@@ -132,6 +134,88 @@ public function testCommandInputOptionDefinition()
132134
self::assertFalse($optInputOption->getDefault());
133135
}
134136

137+
public function testEnumArgument()
138+
{
139+
$command = new Command('foo');
140+
$command->setCode(function (
141+
#[Argument] StringEnum $enum,
142+
#[Argument] StringEnum $enumWithDefault = StringEnum::Image,
143+
#[Argument] ?StringEnum $nullableEnum = null,
144+
): int {
145+
Assert::assertSame(StringEnum::Image, $enum);
146+
Assert::assertSame(StringEnum::Image, $enumWithDefault);
147+
Assert::assertNull($nullableEnum);
148+
149+
return 0;
150+
});
151+
152+
$enumInputArgument = $command->getDefinition()->getArgument('enum');
153+
self::assertTrue($enumInputArgument->isRequired());
154+
self::assertNull($enumInputArgument->getDefault());
155+
self::assertTrue($enumInputArgument->hasCompletion());
156+
157+
$enumWithDefaultInputArgument = $command->getDefinition()->getArgument('enum-with-default');
158+
self::assertFalse($enumWithDefaultInputArgument->isRequired());
159+
self::assertSame('image', $enumWithDefaultInputArgument->getDefault());
160+
self::assertTrue($enumWithDefaultInputArgument->hasCompletion());
161+
162+
$nullableEnumInputArgument = $command->getDefinition()->getArgument('nullable-enum');
163+
self::assertFalse($nullableEnumInputArgument->isRequired());
164+
self::assertNull($nullableEnumInputArgument->getDefault());
165+
self::assertTrue($nullableEnumInputArgument->hasCompletion());
166+
167+
$enumInputArgument->complete(CompletionInput::fromTokens([], 0), $suggestions = new CompletionSuggestions());
168+
self::assertEquals([new Suggestion('image'), new Suggestion('video')], $suggestions->getValueSuggestions());
169+
170+
$command->run(new ArrayInput(['enum' => 'image']), new NullOutput());
171+
172+
self::expectException(InvalidArgumentException::class);
173+
self::expectExceptionMessage('The value "incorrect" is not valid for the "enum" argument. Supported values are "image", "video".');
174+
175+
$command->run(new ArrayInput(['enum' => 'incorrect']), new NullOutput());
176+
}
177+
178+
public function testEnumOption()
179+
{
180+
$command = new Command('foo');
181+
$command->setCode(function (
182+
#[Option] StringEnum $enum = StringEnum::Video,
183+
#[Option] StringEnum $enumWithDefault = StringEnum::Image,
184+
#[Option] ?StringEnum $nullableEnum = null,
185+
): int {
186+
Assert::assertSame(StringEnum::Image, $enum);
187+
Assert::assertSame(StringEnum::Image, $enumWithDefault);
188+
Assert::assertNull($nullableEnum);
189+
190+
return 0;
191+
});
192+
193+
$enumInputOption = $command->getDefinition()->getOption('enum');
194+
self::assertTrue($enumInputOption->isValueRequired());
195+
self::assertSame('video', $enumInputOption->getDefault());
196+
self::assertTrue($enumInputOption->hasCompletion());
197+
198+
$enumWithDefaultInputOption = $command->getDefinition()->getOption('enum-with-default');
199+
self::assertTrue($enumWithDefaultInputOption->isValueRequired());
200+
self::assertSame('image', $enumWithDefaultInputOption->getDefault());
201+
self::assertTrue($enumWithDefaultInputOption->hasCompletion());
202+
203+
$nullableEnumInputOption = $command->getDefinition()->getOption('nullable-enum');
204+
self::assertTrue($nullableEnumInputOption->isValueRequired());
205+
self::assertNull($nullableEnumInputOption->getDefault());
206+
self::assertTrue($nullableEnumInputOption->hasCompletion());
207+
208+
$enumInputOption->complete(CompletionInput::fromTokens([], 0), $suggestions = new CompletionSuggestions());
209+
self::assertEquals([new Suggestion('image'), new Suggestion('video')], $suggestions->getValueSuggestions());
210+
211+
$command->run(new ArrayInput(['--enum' => 'image']), new NullOutput());
212+
213+
self::expectException(InvalidOptionException::class);
214+
self::expectExceptionMessage('The value "incorrect" is not valid for the "enum" option. Supported values are "image", "video".');
215+
216+
$command->run(new ArrayInput(['--enum' => 'incorrect']), new NullOutput());
217+
}
218+
135219
public function testInvalidArgumentType()
136220
{
137221
$command = new Command('foo');
@@ -377,3 +461,9 @@ public function getSuggestedRoles(CompletionInput $input): array
377461
return ['ROLE_ADMIN', 'ROLE_USER'];
378462
}
379463
}
464+
465+
enum StringEnum: string
466+
{
467+
case Image = 'image';
468+
case Video = 'video';
469+
}

0 commit comments

Comments
 (0)