From 0cff2ebc28d97cfe137e60387c0caef895863f6d Mon Sep 17 00:00:00 2001 From: Yonel Ceruto Date: Thu, 21 Aug 2025 01:19:55 +0200 Subject: [PATCH] [Console] Add #[Input] attribute to support DTOs in commands --- .../Component/Console/Attribute/Argument.php | 40 +++--- .../Component/Console/Attribute/Input.php | 116 +++++++++++++++ .../Component/Console/Attribute/Option.php | 53 +++---- .../Attribute/Reflection/ReflectionMember.php | 99 +++++++++++++ src/Symfony/Component/Console/CHANGELOG.md | 1 + .../Console/Command/InvokableCommand.php | 29 +++- .../InvokableWithInputTestCommand.php | 77 ++++++++++ .../Tests/Tester/CommandTesterTest.php | 134 ++++++++++++++++++ 8 files changed, 501 insertions(+), 48 deletions(-) create mode 100644 src/Symfony/Component/Console/Attribute/Input.php create mode 100644 src/Symfony/Component/Console/Attribute/Reflection/ReflectionMember.php create mode 100644 src/Symfony/Component/Console/Tests/Fixtures/InvokableWithInputTestCommand.php diff --git a/src/Symfony/Component/Console/Attribute/Argument.php b/src/Symfony/Component/Console/Attribute/Argument.php index 203dcc2af980f..b1b40bd3e622e 100644 --- a/src/Symfony/Component/Console/Attribute/Argument.php +++ b/src/Symfony/Component/Console/Attribute/Argument.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Console\Attribute; +use Symfony\Component\Console\Attribute\Reflection\ReflectionMember; use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\Suggestion; use Symfony\Component\Console\Exception\InvalidArgumentException; @@ -19,7 +20,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\String\UnicodeString; -#[\Attribute(\Attribute::TARGET_PARAMETER)] +#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)] class Argument { private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array']; @@ -27,7 +28,9 @@ class Argument private string|bool|int|float|array|null $default = null; private array|\Closure $suggestedValues; private ?int $mode = null; - private string $function = ''; + /** + * @var string|class-string<\BackedEnum> + */ private string $typeName = ''; /** @@ -48,52 +51,45 @@ public function __construct( /** * @internal */ - public static function tryFrom(\ReflectionParameter $parameter): ?self + public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member): ?self { - /** @var self $self */ - if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) { - return null; - } + $reflection = new ReflectionMember($member); - if (($function = $parameter->getDeclaringFunction()) instanceof \ReflectionMethod) { - $self->function = $function->class.'::'.$function->name; - } else { - $self->function = $function->name; + if (!$self = $reflection->getAttribute(self::class)) { + return null; } - $type = $parameter->getType(); - $name = $parameter->getName(); + $type = $reflection->getType(); + $name = $reflection->getName(); if (!$type instanceof \ReflectionNamedType) { - 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)); + 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())); } $self->typeName = $type->getName(); $isBackedEnum = is_subclass_of($self->typeName, \BackedEnum::class); if (!\in_array($self->typeName, self::ALLOWED_TYPES, true) && !$isBackedEnum) { - 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))); + 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))); } if (!$self->name) { $self->name = (new UnicodeString($name))->kebab(); } - if ($parameter->isDefaultValueAvailable()) { - $self->default = $parameter->getDefaultValue() instanceof \BackedEnum ? $parameter->getDefaultValue()->value : $parameter->getDefaultValue(); - } + $self->default = $reflection->hasDefaultValue() ? $reflection->getDefaultValue() : null; - $self->mode = $parameter->isDefaultValueAvailable() || $parameter->allowsNull() ? InputArgument::OPTIONAL : InputArgument::REQUIRED; + $self->mode = ($reflection->hasDefaultValue() || $reflection->isNullable()) ? InputArgument::OPTIONAL : InputArgument::REQUIRED; if ('array' === $self->typeName) { $self->mode |= InputArgument::IS_ARRAY; } - 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]])) { + 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]])) { $self->suggestedValues = [$instance, $self->suggestedValues[1]]; } if ($isBackedEnum && !$self->suggestedValues) { - $self->suggestedValues = array_column(($self->typeName)::cases(), 'value'); + $self->suggestedValues = array_column($self->typeName::cases(), 'value'); } return $self; @@ -117,7 +113,7 @@ public function resolveValue(InputInterface $input): mixed $value = $input->getArgument($this->name); if (is_subclass_of($this->typeName, \BackedEnum::class) && (\is_string($value) || \is_int($value))) { - return ($this->typeName)::tryFrom($value) ?? throw InvalidArgumentException::fromEnumValue($this->name, $value, $this->suggestedValues); + return $this->typeName::tryFrom($value) ?? throw InvalidArgumentException::fromEnumValue($this->name, $value, $this->suggestedValues); } return $value; diff --git a/src/Symfony/Component/Console/Attribute/Input.php b/src/Symfony/Component/Console/Attribute/Input.php new file mode 100644 index 0000000000000..65bbf4aef965f --- /dev/null +++ b/src/Symfony/Component/Console/Attribute/Input.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Attribute; + +use Symfony\Component\Console\Attribute\Reflection\ReflectionMember; +use Symfony\Component\Console\Exception\LogicException; +use Symfony\Component\Console\Input\InputInterface; + +#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)] +final class Input +{ + /** + * @var array + */ + private array $definition = []; + + private \ReflectionClass $class; + + public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member): ?self + { + $reflection = new ReflectionMember($member); + + if (!$self = $reflection->getAttribute(self::class)) { + return null; + } + + $type = $reflection->getType(); + + if (!$type instanceof \ReflectionNamedType) { + throw new LogicException(\sprintf('The input %s "%s" must have a named type.', $reflection->getMemberName(), $member->name)); + } + + if (!class_exists($class = $type->getName())) { + throw new LogicException(\sprintf('The input class "%s" does not exist.', $type->getName())); + } + + $self->class = new \ReflectionClass($class); + + foreach ($self->class->getProperties() as $property) { + if (!$property->isPublic() || $property->isStatic()) { + continue; + } + + if ($argument = Argument::tryFrom($property)) { + $self->definition[$property->name] = $argument; + continue; + } + + if ($option = Option::tryFrom($property)) { + $self->definition[$property->name] = $option; + continue; + } + + if ($input = self::tryFrom($property)) { + $self->definition[$property->name] = $input; + } + } + + if (!$self->definition) { + throw new LogicException(\sprintf('The input class "%s" must have at least one argument or option.', $self->class->name)); + } + + return $self; + } + + /** + * @internal + */ + public function resolveValue(InputInterface $input): mixed + { + $instance = $this->class->newInstanceWithoutConstructor(); + + foreach ($this->definition as $name => $spec) { + $instance->$name = $spec->resolveValue($input); + } + + return $instance; + } + + /** + * @return iterable + */ + public function getArguments(): iterable + { + foreach ($this->definition as $spec) { + if ($spec instanceof Argument) { + yield $spec; + } elseif ($spec instanceof self) { + yield from $spec->getArguments(); + } + } + } + + /** + * @return iterable