Skip to content

Commit 0cff2eb

Browse files
committed
[Console] Add #[Input] attribute to support DTOs in commands
1 parent 18d7668 commit 0cff2eb

File tree

8 files changed

+501
-48
lines changed

8 files changed

+501
-48
lines changed

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

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\Console\Attribute;
1313

14+
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
1415
use Symfony\Component\Console\Completion\CompletionInput;
1516
use Symfony\Component\Console\Completion\Suggestion;
1617
use Symfony\Component\Console\Exception\InvalidArgumentException;
@@ -19,15 +20,17 @@
1920
use Symfony\Component\Console\Input\InputInterface;
2021
use Symfony\Component\String\UnicodeString;
2122

22-
#[\Attribute(\Attribute::TARGET_PARAMETER)]
23+
#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
2324
class Argument
2425
{
2526
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array'];
2627

2728
private string|bool|int|float|array|null $default = null;
2829
private array|\Closure $suggestedValues;
2930
private ?int $mode = null;
30-
private string $function = '';
31+
/**
32+
* @var string|class-string<\BackedEnum>
33+
*/
3134
private string $typeName = '';
3235

3336
/**
@@ -48,52 +51,45 @@ public function __construct(
4851
/**
4952
* @internal
5053
*/
51-
public static function tryFrom(\ReflectionParameter $parameter): ?self
54+
public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member): ?self
5255
{
53-
/** @var self $self */
54-
if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) {
55-
return null;
56-
}
56+
$reflection = new ReflectionMember($member);
5757

58-
if (($function = $parameter->getDeclaringFunction()) instanceof \ReflectionMethod) {
59-
$self->function = $function->class.'::'.$function->name;
60-
} else {
61-
$self->function = $function->name;
58+
if (!$self = $reflection->getAttribute(self::class)) {
59+
return null;
6260
}
6361

64-
$type = $parameter->getType();
65-
$name = $parameter->getName();
62+
$type = $reflection->getType();
63+
$name = $reflection->getName();
6664

6765
if (!$type instanceof \ReflectionNamedType) {
68-
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));
66+
throw new LogicException(\sprintf('The %s "$%s" of "%s" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $reflection->getMemberName(), $name, $reflection->getSourceName()));
6967
}
7068

7169
$self->typeName = $type->getName();
7270
$isBackedEnum = is_subclass_of($self->typeName, \BackedEnum::class);
7371

7472
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)));
73+
throw new LogicException(\sprintf('The type "%s" on %s "$%s" of "%s" is not supported as a command argument. Only "%s" types and backed enums are allowed.', $self->typeName, $reflection->getMemberName(), $name, $reflection->getSourceName(), implode('", "', self::ALLOWED_TYPES)));
7674
}
7775

7876
if (!$self->name) {
7977
$self->name = (new UnicodeString($name))->kebab();
8078
}
8179

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

86-
$self->mode = $parameter->isDefaultValueAvailable() || $parameter->allowsNull() ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
82+
$self->mode = ($reflection->hasDefaultValue() || $reflection->isNullable()) ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
8783
if ('array' === $self->typeName) {
8884
$self->mode |= InputArgument::IS_ARRAY;
8985
}
9086

91-
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]])) {
87+
if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $reflection->getSourceThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) {
9288
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
9389
}
9490

9591
if ($isBackedEnum && !$self->suggestedValues) {
96-
$self->suggestedValues = array_column(($self->typeName)::cases(), 'value');
92+
$self->suggestedValues = array_column($self->typeName::cases(), 'value');
9793
}
9894

9995
return $self;
@@ -117,7 +113,7 @@ public function resolveValue(InputInterface $input): mixed
117113
$value = $input->getArgument($this->name);
118114

119115
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);
116+
return $this->typeName::tryFrom($value) ?? throw InvalidArgumentException::fromEnumValue($this->name, $value, $this->suggestedValues);
121117
}
122118

123119
return $value;
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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\LogicException;
16+
use Symfony\Component\Console\Input\InputInterface;
17+
18+
#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
19+
final class Input
20+
{
21+
/**
22+
* @var array<string, Argument|Option|self>
23+
*/
24+
private array $definition = [];
25+
26+
private \ReflectionClass $class;
27+
28+
public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member): ?self
29+
{
30+
$reflection = new ReflectionMember($member);
31+
32+
if (!$self = $reflection->getAttribute(self::class)) {
33+
return null;
34+
}
35+
36+
$type = $reflection->getType();
37+
38+
if (!$type instanceof \ReflectionNamedType) {
39+
throw new LogicException(\sprintf('The input %s "%s" must have a named type.', $reflection->getMemberName(), $member->name));
40+
}
41+
42+
if (!class_exists($class = $type->getName())) {
43+
throw new LogicException(\sprintf('The input class "%s" does not exist.', $type->getName()));
44+
}
45+
46+
$self->class = new \ReflectionClass($class);
47+
48+
foreach ($self->class->getProperties() as $property) {
49+
if (!$property->isPublic() || $property->isStatic()) {
50+
continue;
51+
}
52+
53+
if ($argument = Argument::tryFrom($property)) {
54+
$self->definition[$property->name] = $argument;
55+
continue;
56+
}
57+
58+
if ($option = Option::tryFrom($property)) {
59+
$self->definition[$property->name] = $option;
60+
continue;
61+
}
62+
63+
if ($input = self::tryFrom($property)) {
64+
$self->definition[$property->name] = $input;
65+
}
66+
}
67+
68+
if (!$self->definition) {
69+
throw new LogicException(\sprintf('The input class "%s" must have at least one argument or option.', $self->class->name));
70+
}
71+
72+
return $self;
73+
}
74+
75+
/**
76+
* @internal
77+
*/
78+
public function resolveValue(InputInterface $input): mixed
79+
{
80+
$instance = $this->class->newInstanceWithoutConstructor();
81+
82+
foreach ($this->definition as $name => $spec) {
83+
$instance->$name = $spec->resolveValue($input);
84+
}
85+
86+
return $instance;
87+
}
88+
89+
/**
90+
* @return iterable<Argument>
91+
*/
92+
public function getArguments(): iterable
93+
{
94+
foreach ($this->definition as $spec) {
95+
if ($spec instanceof Argument) {
96+
yield $spec;
97+
} elseif ($spec instanceof self) {
98+
yield from $spec->getArguments();
99+
}
100+
}
101+
}
102+
103+
/**
104+
* @return iterable<Option>
105+
*/
106+
public function getOptions(): iterable
107+
{
108+
foreach ($this->definition as $spec) {
109+
if ($spec instanceof Option) {
110+
yield $spec;
111+
} elseif ($spec instanceof self) {
112+
yield from $spec->getOptions();
113+
}
114+
}
115+
}
116+
}

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

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\Console\Attribute;
1313

14+
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
1415
use Symfony\Component\Console\Completion\CompletionInput;
1516
use Symfony\Component\Console\Completion\Suggestion;
1617
use Symfony\Component\Console\Exception\InvalidOptionException;
@@ -19,7 +20,7 @@
1920
use Symfony\Component\Console\Input\InputOption;
2021
use Symfony\Component\String\UnicodeString;
2122

22-
#[\Attribute(\Attribute::TARGET_PARAMETER)]
23+
#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
2324
class Option
2425
{
2526
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array'];
@@ -28,9 +29,13 @@ class Option
2829
private string|bool|int|float|array|null $default = null;
2930
private array|\Closure $suggestedValues;
3031
private ?int $mode = null;
32+
/**
33+
* @var string|class-string<\BackedEnum>
34+
*/
3135
private string $typeName = '';
3236
private bool $allowNull = false;
33-
private string $function = '';
37+
private string $memberName = '';
38+
private string $sourceName = '';
3439

3540
/**
3641
* Represents a console command --option definition.
@@ -52,54 +57,52 @@ public function __construct(
5257
/**
5358
* @internal
5459
*/
55-
public static function tryFrom(\ReflectionParameter $parameter): ?self
60+
public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member): ?self
5661
{
57-
/** @var self $self */
58-
if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) {
62+
$reflection = new ReflectionMember($member);
63+
64+
if (!$self = $reflection->getAttribute(self::class)) {
5965
return null;
6066
}
6167

62-
if (($function = $parameter->getDeclaringFunction()) instanceof \ReflectionMethod) {
63-
$self->function = $function->class.'::'.$function->name;
64-
} else {
65-
$self->function = $function->name;
66-
}
68+
$self->memberName = $reflection->getMemberName();
69+
$self->sourceName = $reflection->getSourceName();
6770

68-
$name = $parameter->getName();
69-
$type = $parameter->getType();
71+
$name = $reflection->getName();
72+
$type = $reflection->getType();
7073

71-
if (!$parameter->isDefaultValueAvailable()) {
72-
throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must declare a default value.', $name, $self->function));
74+
if (!$reflection->hasDefaultValue()) {
75+
throw new LogicException(\sprintf('The option %s "$%s" of "%s" must declare a default value.', $self->memberName, $name, $self->sourceName));
7376
}
7477

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

79-
$self->default = $parameter->getDefaultValue() instanceof \BackedEnum ? $parameter->getDefaultValue()->value : $parameter->getDefaultValue();
80-
$self->allowNull = $parameter->allowsNull();
82+
$self->default = $reflection->getDefaultValue();
83+
$self->allowNull = $reflection->isNullable();
8184

8285
if ($type instanceof \ReflectionUnionType) {
8386
return $self->handleUnion($type);
8487
}
8588

8689
if (!$type instanceof \ReflectionNamedType) {
87-
throw new LogicException(\sprintf('The parameter "$%s" of "%s()" must have a named type. Untyped or Intersection types are not supported for command options.', $name, $self->function));
90+
throw new LogicException(\sprintf('The %s "$%s" of "%s" must have a named type. Untyped or Intersection types are not supported for command options.', $self->memberName, $name, $self->sourceName));
8891
}
8992

9093
$self->typeName = $type->getName();
9194
$isBackedEnum = is_subclass_of($self->typeName, \BackedEnum::class);
9295

9396
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)));
97+
throw new LogicException(\sprintf('The type "%s" on %s "$%s" of "%s" is not supported as a command option. Only "%s" types and BackedEnum are allowed.', $self->typeName, $self->memberName, $name, $self->sourceName, implode('", "', self::ALLOWED_TYPES)));
9598
}
9699

97100
if ('bool' === $self->typeName && $self->allowNull && \in_array($self->default, [true, false], true)) {
98-
throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must not be nullable when it has a default boolean value.', $name, $self->function));
101+
throw new LogicException(\sprintf('The option %s "$%s" of "%s" must not be nullable when it has a default boolean value.', $self->memberName, $name, $self->sourceName));
99102
}
100103

101104
if ($self->allowNull && null !== $self->default) {
102-
throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must either be not-nullable or have a default of null.', $name, $self->function));
105+
throw new LogicException(\sprintf('The option %s "$%s" of "%s" must either be not-nullable or have a default of null.', $self->memberName, $name, $self->sourceName));
103106
}
104107

105108
if ('bool' === $self->typeName) {
@@ -113,12 +116,12 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self
113116
$self->mode = InputOption::VALUE_REQUIRED;
114117
}
115118

116-
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]])) {
119+
if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $reflection->getSourceThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) {
117120
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
118121
}
119122

120123
if ($isBackedEnum && !$self->suggestedValues) {
121-
$self->suggestedValues = array_column(($self->typeName)::cases(), 'value');
124+
$self->suggestedValues = array_column($self->typeName::cases(), 'value');
122125
}
123126

124127
return $self;
@@ -147,7 +150,7 @@ public function resolveValue(InputInterface $input): mixed
147150
}
148151

149152
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);
153+
return $this->typeName::tryFrom($value) ?? throw InvalidOptionException::fromEnumValue($this->name, $value, $this->suggestedValues);
151154
}
152155

153156
if ('array' === $this->typeName && $this->allowNull && [] === $value) {
@@ -177,11 +180,11 @@ private function handleUnion(\ReflectionUnionType $type): self
177180
$this->typeName = implode('|', array_filter($types));
178181

179182
if (!\in_array($this->typeName, self::ALLOWED_UNION_TYPES, true)) {
180-
throw new LogicException(\sprintf('The union type for parameter "$%s" of "%s()" is not supported as a command option. Only "%s" types are allowed.', $this->name, $this->function, implode('", "', self::ALLOWED_UNION_TYPES)));
183+
throw new LogicException(\sprintf('The union type for %s "$%s" of "%s" is not supported as a command option. Only "%s" types are allowed.', $this->memberName, $this->name, $this->sourceName, implode('", "', self::ALLOWED_UNION_TYPES)));
181184
}
182185

183186
if (false !== $this->default) {
184-
throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must have a default value of false.', $this->name, $this->function));
187+
throw new LogicException(\sprintf('The option %s "$%s" of "%s" must have a default value of false.', $this->memberName, $this->name, $this->sourceName));
185188
}
186189

187190
$this->mode = InputOption::VALUE_OPTIONAL;

0 commit comments

Comments
 (0)