Skip to content

Commit

Permalink
[feature] allow injecting args/options into __invoke() (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
kbond authored Jun 30, 2022
1 parent 09b1f70 commit a1e132f
Show file tree
Hide file tree
Showing 7 changed files with 131 additions and 43 deletions.
32 changes: 24 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ final class CreateUserCommand extends InvokableServiceCommand
{
use ConfigureWithDocblocks, RunsCommands, RunsProcesses;

public function __invoke(IO $io, UserRepository $repo): void
public function __invoke(IO $io, UserRepository $repo, string $email, string $password, array $roles): void
{
$repo->createUser($io->argument('email'), $io->argument('password'), $io->option('role'));
$repo->createUser($email, $password, $roles);

$this->runCommand('another:command');
$this->runProcess('/some/script');
Expand Down Expand Up @@ -71,27 +71,43 @@ On its own, it isn't very special, but it can be auto-injected into [`Invokable`

### `Invokable`

Use this trait to remove the need for extending `Command::execute()` and just inject what your need (ie [`IO`](#io))
into your command's `__invoke()` method.
Use this trait to remove the need for extending `Command::execute()` and just inject what your need
into your command's `__invoke()` method. The following are parameters that can be auto-injected:

- [`Zenstruck\Console\IO`](#io)
- `Symfony\Component\Console\Style\StyleInterface`
- `Symfony\Component\Console\Input\InputInterface`
- `Symfony\Component\Console\Input\OutputInterface`
- *arguments* (parameter name much match argument name)
- *options* (parameter name much match option name)

```php
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Zenstruck\Console\Invokable;
use Zenstruck\Console\IO;

class MyCommand extends \Symfony\Component\Console\Command\Command
{
use Invokable;
use Invokable; // enables this feature

public function __invoke(IO $io)
// $username/$roles are the argument/option defined below
public function __invoke(IO $io, string $username, array $roles)
{
$role = $io->option('role');

$io->success('created.');

// even if you don't inject IO, it's available as a method:
$this->io(); // IO
}

public function configure(): void
{
$this
->addArgument('username', InputArgument::REQUIRED)
->addOption('roles', mode: InputOption::VALUE_IS_ARRAY)
;
}
}
```

Expand Down
54 changes: 34 additions & 20 deletions src/Invokable.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,28 +47,42 @@ protected function initialize(InputInterface $input, OutputInterface $output): v

protected function execute(InputInterface $input, OutputInterface $output): int
{
self::invokeParameters();

$parameters = \array_merge(
\array_map(
static function(callable $factory, ?string $type) use ($input, $output) {
$factory = Parameter::factory(fn() => $factory($input, $output));

return $type ? Parameter::typed($type, $factory, Argument::EXACT) : Parameter::untyped($factory);
},
$this->argumentFactories,
\array_keys($this->argumentFactories)
),
[
Parameter::untyped($this->io()),
Parameter::typed(InputInterface::class, $input, Argument::EXACT),
Parameter::typed(OutputInterface::class, $output, Argument::EXACT),
Parameter::typed(IO::class, $this->io(), Argument::COVARIANCE),
Parameter::typed(IO::class, Parameter::factory(fn($class) => new $class($input, $output))),
]
$parameters = \array_map(
function(\ReflectionParameter $parameter) use ($input, $output) {
$type = $parameter->getType();

if (null !== $type && !$type instanceof \ReflectionNamedType) {
throw new \LogicException("Union/Intersection types not yet supported for \"{$parameter}\".");
}

if ($type instanceof \ReflectionNamedType && isset($this->argumentFactories[$type->getName()])) {
return Parameter::typed(
$type->getName(),
Parameter::factory(fn() => $this->argumentFactories[$type->getName()]($input, $output)),
Argument::EXACT
);
}

if ((!$type || $type->isBuiltin()) && $input->hasArgument($parameter->name)) {
return $input->getArgument($parameter->name);
}

if ((!$type || $type->isBuiltin()) && $input->hasOption($parameter->name)) {
return $input->getOption($parameter->name);
}

return Parameter::union(
Parameter::untyped($this->io()),
Parameter::typed(InputInterface::class, $input, Argument::EXACT),
Parameter::typed(OutputInterface::class, $output, Argument::EXACT),
Parameter::typed(IO::class, $this->io(), Argument::COVARIANCE),
Parameter::typed(IO::class, Parameter::factory(fn($class) => new $class($input, $output)))
);
},
self::invokeParameters(),
);

$return = Callback::createFor($this)->invokeAll(Parameter::union(...$parameters));
$return = Callback::createFor($this)->invoke(...$parameters);

if (null === $return) {
$return = 0; // assume 0
Expand Down
6 changes: 1 addition & 5 deletions src/InvokableServiceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,12 @@ static function(\ReflectionParameter $parameter): ?string {
return null;
}

if (!$type instanceof \ReflectionNamedType) {
if (!$type instanceof \ReflectionNamedType || $type->isBuiltin()) {
return null;
}

$name = $type->getName();

if ($type->isBuiltin()) {
throw new \LogicException(\sprintf('"%s::__invoke()" cannot accept built-in parameter: $%s (%s).', static::class, $parameter->getName(), $name));
}

if (\is_a($name, InputInterface::class, true)) {
return null;
}
Expand Down
33 changes: 31 additions & 2 deletions tests/Fixture/Command/ServiceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\StyleInterface;
Expand All @@ -16,8 +17,21 @@
*/
final class ServiceCommand extends InvokableServiceCommand
{
public function __invoke(IO $io, InputInterface $input, OutputInterface $output, StyleInterface $style, $none, LoggerInterface $logger, ?RouterInterface $router = null, ?Table $optional = null): void
{
public function __invoke(
IO $io,
InputInterface $input,
OutputInterface $output,
StyleInterface $style,
$none,
$arg1,
string $arg2,
$opt1,
bool $opt2,
string $env,
LoggerInterface $logger,
?RouterInterface $router = null,
?Table $optional = null
): void {
$io->comment(\sprintf('IO: %s', \get_debug_type($io)));
$io->comment(\sprintf('InputInterface: %s', \get_debug_type($input)));
$io->comment(\sprintf('OutputInterface: %s', \get_debug_type($output)));
Expand All @@ -27,6 +41,11 @@ public function __invoke(IO $io, InputInterface $input, OutputInterface $output,
$io->comment(\sprintf('RouterInterface: %s', \get_debug_type($router)));
$io->comment(\sprintf('Table: %s', \get_debug_type($optional)));
$io->comment(\sprintf('Parameter environment: %s', $this->parameter('kernel.environment')));
$io->comment(\sprintf('arg1: %s', \var_export($arg1, true)));
$io->comment(\sprintf('arg2: %s', \var_export($arg2, true)));
$io->comment(\sprintf('opt1: %s', \var_export($opt1, true)));
$io->comment(\sprintf('opt2: %s', \var_export($opt2, true)));
$io->comment(\sprintf('env: %s', \var_export($env, true)));

$io->success('done!');
}
Expand All @@ -35,4 +54,14 @@ public static function getDefaultName(): string
{
return 'service-command';
}

protected function configure(): void
{
$this
->addArgument('arg1', InputArgument::REQUIRED)
->addArgument('arg2', InputArgument::REQUIRED)
->addOption('opt1')
->addOption('opt2')
;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Zenstruck\Console\EventListener\CommandSummarySubscriber;
use Zenstruck\Console\Test\InteractsWithConsole;
use Zenstruck\Console\Tests\Fixture\Command\ServiceCommand;
use Zenstruck\Console\Tests\Fixture\EventListener\CustomCommandSummarySubscriber;

/**
Expand All @@ -24,7 +23,7 @@ public function can_add_summary_subscriber(): void

self::$kernel->getContainer()->get('event_dispatcher')->addSubscriber(new CommandSummarySubscriber());

$this->executeConsoleCommand(ServiceCommand::class)
$this->executeConsoleCommand('service-command foo bar')
->assertSuccessful()
->assertOutputContains('IO: ')
->assertOutputContains('Duration: ')
Expand All @@ -41,7 +40,7 @@ public function can_disable_summary_with_custom_subscriber(): void

self::$kernel->getContainer()->get('event_dispatcher')->addSubscriber(new CustomCommandSummarySubscriber());

$this->executeConsoleCommand(ServiceCommand::class)
$this->executeConsoleCommand('service-command foo bar')
->assertSuccessful()
->assertOutputContains('IO: ')
->assertOutputNotContains('Duration: ')
Expand Down
8 changes: 6 additions & 2 deletions tests/Integration/InvokableServiceCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
use Zenstruck\Console\Test\InteractsWithConsole;
use Zenstruck\Console\Test\TestInput;
use Zenstruck\Console\Test\TestOutput;
use Zenstruck\Console\Tests\Fixture\Command\ServiceCommand;
use Zenstruck\Console\Tests\Fixture\Command\ServiceSubscriberTraitCommand;

/**
Expand All @@ -24,7 +23,7 @@ final class InvokableServiceCommandTest extends KernelTestCase
*/
public function can_auto_inject_services_into_invoke(): void
{
$this->executeConsoleCommand(ServiceCommand::class)
$this->executeConsoleCommand('service-command foo bar --opt2')
->assertSuccessful()
->assertOutputContains(\sprintf('IO: %s', IO::class))
->assertOutputContains(\sprintf('InputInterface: %s', TestInput::class))
Expand All @@ -35,6 +34,11 @@ public function can_auto_inject_services_into_invoke(): void
->assertOutputContains(\sprintf('RouterInterface: %s', Router::class))
->assertOutputContains('Table: null')
->assertOutputContains('Parameter environment: test')
->assertOutputContains("arg1: 'foo'")
->assertOutputContains("arg2: 'bar'")
->assertOutputContains("env: 'test'")
->assertOutputContains('opt1: false')
->assertOutputContains('opt2: true')
;
}

Expand Down
36 changes: 33 additions & 3 deletions tests/Unit/InvokableTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,56 @@ public function invoke_auto_injects_proper_objects(): void
{
TestCommand::for(
new class() extends InvokableCommand {
public function __invoke(IO $io, InputInterface $input, OutputInterface $output, StyleInterface $style, $none, ?string $optional = null)
{
public function __invoke(
IO $io,
InputInterface $input,
OutputInterface $output,
StyleInterface $style,
$none,
$arg1,
string $arg2,
$opt1,
bool $opt2,
?string $optional = null
) {
$io->comment(\sprintf('IO: %s', \get_class($io)));
$io->comment(\sprintf('$this->io(): %s', \get_class($this->io())));
$io->comment(\sprintf('InputInterface: %s', \get_class($input)));
$io->comment(\sprintf('OutputInterface: %s', \get_class($output)));
$io->comment(\sprintf('StyleInterface: %s', \get_class($style)));
$io->comment(\sprintf('none: %s', \get_class($none)));
$io->comment(\sprintf('arg1: %s', \var_export($arg1, true)));
$io->comment(\sprintf('arg2: %s', \var_export($arg2, true)));
$io->comment(\sprintf('opt1: %s', \var_export($opt1, true)));
$io->comment(\sprintf('opt2: %s', \var_export($opt2, true)));

$io->success('done!');
}

protected function configure(): void
{
parent::configure();

$this
->addArgument('arg1')
->addArgument('arg2')
->addOption('opt1')
->addOption('opt2')
;
}
})
->execute()
->execute('foo bar --opt2')
->assertSuccessful()
->assertOutputContains(\sprintf('IO: %s', IO::class))
->assertOutputContains(\sprintf('$this->io(): %s', IO::class))
->assertOutputContains(\sprintf('InputInterface: %s', TestInput::class))
->assertOutputContains(\sprintf('OutputInterface: %s', TestOutput::class))
->assertOutputContains(\sprintf('StyleInterface: %s', IO::class))
->assertOutputContains(\sprintf('none: %s', IO::class))
->assertOutputContains("arg1: 'foo'")
->assertOutputContains("arg2: 'bar'")
->assertOutputContains('opt1: false')
->assertOutputContains('opt2: true')
;
}

Expand Down

0 comments on commit a1e132f

Please sign in to comment.