Skip to content

Commit 7d87396

Browse files
WedgeSamaGromNaN
authored andcommitted
[make:decorator] Add new maker to create decorator
Add a make:decorator command that generates a decorator class for an existing service. The maker asks for the service to decorate and the class name of the decorator, then generates a class that either implements the decorated service interfaces or extends its class. Supporting pieces: - DecoratorHelper resolves a service id from a full id or a short class name, and exposes suggestions on error. - MakeDecoratorPass fills the helper with the service ids, the service id to class map and the short class name map at compile time. - DecoratorInfo and the ClassMethod / MethodArgument models describe the methods to forward to the decorated service, including variadic parameters. - UseStatementGenerator handles the extra type imports needed by the generated methods. - Validator gains a check for existing class names. Closes #1401
1 parent 11dce35 commit 7d87396

43 files changed

Lines changed: 2184 additions & 9 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

config/help/MakeDecorator.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
The <info>%command.name%</info> command generates a new service decorator class.
2+
3+
<info>php %command.full_name%</info>
4+
<info>php %command.full_name% My\Decorated\Service\Class</info>
5+
<info>php %command.full_name% My\Decorated\Service\Class MyServiceDecorator</info>
6+
<info>php %command.full_name% My\Decorated\Service\Class Service\MyServiceDecorator</info>
7+
<info>php %command.full_name% my_decorated.service.id MyServiceDecorator</info>
8+
<info>php %command.full_name% my_decorated.service.id MyServiceDecorator</info>
9+
10+
If one argument is missing, the command will ask for it interactively.

config/makers.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Symfony\Bundle\MakerBundle\Maker\MakeCommand;
1616
use Symfony\Bundle\MakerBundle\Maker\MakeController;
1717
use Symfony\Bundle\MakerBundle\Maker\MakeCrud;
18+
use Symfony\Bundle\MakerBundle\Maker\MakeDecorator;
1819
use Symfony\Bundle\MakerBundle\Maker\MakeDockerDatabase;
1920
use Symfony\Bundle\MakerBundle\Maker\MakeEntity;
2021
use Symfony\Bundle\MakerBundle\Maker\MakeFixtures;
@@ -69,6 +70,10 @@
6970
])
7071
->tag('maker.command');
7172

73+
$services->set('maker.maker.make_decorator', MakeDecorator::class)
74+
->args([service('maker.decorator_helper')])
75+
->tag('maker.command');
76+
7277
$services->set('maker.maker.make_docker_database', MakeDockerDatabase::class)
7378
->args([service('maker.file_manager')])
7479
->tag('maker.command');

config/services.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
1313

1414
use Symfony\Bundle\MakerBundle\Command\MakerCommand;
15+
use Symfony\Bundle\MakerBundle\DependencyInjection\DecoratorHelper;
1516
use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
1617
use Symfony\Bundle\MakerBundle\Doctrine\EntityClassGenerator;
1718
use Symfony\Bundle\MakerBundle\Event\ConsoleErrorSubscriber;
@@ -61,6 +62,13 @@
6162
service('doctrine')->ignoreOnInvalid(),
6263
]);
6364

65+
$services->set('maker.decorator_helper', DecoratorHelper::class)
66+
->args([
67+
null, // Service ids
68+
null, // Map of service id to class
69+
null, // Map of short class name to service ids
70+
]);
71+
6472
$services->set('maker.auto_command.abstract', MakerCommand::class)
6573
->abstract()
6674
->args([
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony MakerBundle 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\Bundle\MakerBundle\DependencyInjection\CompilerPass;
13+
14+
use Symfony\Bundle\MakerBundle\Str;
15+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
16+
use Symfony\Component\DependencyInjection\ContainerBuilder;
17+
18+
/**
19+
* @author Benjamin Georgeault <git@wedgesama.fr>
20+
*/
21+
class MakeDecoratorPass implements CompilerPassInterface
22+
{
23+
public function process(ContainerBuilder $container): void
24+
{
25+
if (!$container->hasDefinition('maker.decorator_helper')) {
26+
return;
27+
}
28+
29+
$shortNameMap = [];
30+
$serviceClasses = [];
31+
foreach ($container->getServiceIds() as $id) {
32+
if (str_starts_with($id, '.')) {
33+
continue;
34+
}
35+
36+
if (interface_exists($id) || class_exists($id)) {
37+
$shortClass = Str::getShortClassName($id);
38+
$shortNameMap[$shortClass] ??= [];
39+
$shortNameMap[$shortClass][] = $id;
40+
}
41+
42+
if (!$container->hasDefinition($id)) {
43+
continue;
44+
}
45+
46+
if (
47+
(null === $class = $container->getDefinition($id)->getClass())
48+
|| $class === $id
49+
) {
50+
continue;
51+
}
52+
53+
$shortClass = Str::getShortClassName($class);
54+
$shortNameMap[$shortClass] ??= [];
55+
$shortNameMap[$shortClass][] = $id;
56+
$serviceClasses[$id] = $class;
57+
}
58+
59+
foreach ($shortNameMap as $shortClass => $ids) {
60+
$shortNameMap[$shortClass] = array_unique($ids);
61+
}
62+
63+
$ids = $container->getServiceIds();
64+
$container->getDefinition('maker.decorator_helper')
65+
->replaceArgument(0, $ids)
66+
->replaceArgument(1, $serviceClasses)
67+
->replaceArgument(2, $shortNameMap)
68+
;
69+
}
70+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony MakerBundle 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\Bundle\MakerBundle\DependencyInjection;
13+
14+
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
15+
16+
/**
17+
* @author Benjamin Georgeault <git@wedgesama.fr>
18+
*
19+
* @internal
20+
*/
21+
final class DecoratorHelper
22+
{
23+
/**
24+
* @param array<string> $ids
25+
* @param array<string, string> $serviceClasses
26+
* @param array<string, string[]> $shortNameMap
27+
*/
28+
public function __construct(
29+
private readonly array $ids,
30+
private readonly array $serviceClasses,
31+
private readonly array $shortNameMap,
32+
) {
33+
}
34+
35+
public function suggestIds(): array
36+
{
37+
return [
38+
...array_keys($this->shortNameMap),
39+
...$this->ids,
40+
];
41+
}
42+
43+
public function getRealId(string $id): ?string
44+
{
45+
if (\in_array($id, $this->ids, true)) {
46+
return $id;
47+
}
48+
49+
if (\array_key_exists($id, $this->shortNameMap) && 1 === \count($this->shortNameMap[$id])) {
50+
return $this->shortNameMap[$id][0];
51+
}
52+
53+
return null;
54+
}
55+
56+
public function guessRealIds(string $id): array
57+
{
58+
$guessTypos = [];
59+
foreach ($this->shortNameMap as $shortName => $ids) {
60+
if (levenshtein($id, $shortName) < 3) {
61+
$guessTypos = [
62+
...$guessTypos,
63+
...$ids,
64+
];
65+
}
66+
}
67+
68+
foreach ($this->ids as $suggestId) {
69+
if (levenshtein($id, $suggestId) < 3) {
70+
$guessTypos[] = $suggestId;
71+
}
72+
}
73+
74+
return $guessTypos;
75+
}
76+
77+
/**
78+
* @return class-string
79+
*/
80+
public function getClass(string $id): string
81+
{
82+
if (class_exists($id) || interface_exists($id)) {
83+
return $id;
84+
}
85+
86+
if (\array_key_exists($id, $this->serviceClasses)) {
87+
return $this->serviceClasses[$id];
88+
}
89+
90+
throw new RuntimeCommandException(\sprintf('Cannot getClass for id "%s".', $id));
91+
}
92+
}

src/Maker/MakeDecorator.php

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony MakerBundle 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\Bundle\MakerBundle\Maker;
13+
14+
use Symfony\Bundle\MakerBundle\ConsoleStyle;
15+
use Symfony\Bundle\MakerBundle\DependencyBuilder;
16+
use Symfony\Bundle\MakerBundle\DependencyInjection\DecoratorHelper;
17+
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
18+
use Symfony\Bundle\MakerBundle\Generator;
19+
use Symfony\Bundle\MakerBundle\InputConfiguration;
20+
use Symfony\Bundle\MakerBundle\Str;
21+
use Symfony\Bundle\MakerBundle\Util\DecoratorInfo;
22+
use Symfony\Bundle\MakerBundle\Validator;
23+
use Symfony\Component\Console\Command\Command;
24+
use Symfony\Component\Console\Input\InputArgument;
25+
use Symfony\Component\Console\Input\InputInterface;
26+
use Symfony\Component\Console\Input\InputOption;
27+
use Symfony\Component\Console\Question\ConfirmationQuestion;
28+
use Symfony\Component\Console\Question\Question;
29+
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
30+
31+
/**
32+
* @author Benjamin Georgeault <git@wedgesama.fr>
33+
*/
34+
final class MakeDecorator extends AbstractMaker
35+
{
36+
public function __construct(
37+
private readonly DecoratorHelper $helper,
38+
) {
39+
}
40+
41+
public static function getCommandName(): string
42+
{
43+
return 'make:decorator';
44+
}
45+
46+
public static function getCommandDescription(): string
47+
{
48+
return 'Create a decorator of a service';
49+
}
50+
51+
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
52+
{
53+
$command
54+
->addArgument('id', InputArgument::OPTIONAL, 'The ID of the service to decorate.')
55+
->addArgument('decorator-class', InputArgument::OPTIONAL, \sprintf('The class name of the service to create (e.g. <fg=yellow>%sDecorator</>)', Str::asClassName(Str::getRandomTerm())))
56+
->addOption('priority', null, InputOption::VALUE_REQUIRED, 'The priority of this decoration when multiple decorators are declared for the same service.')
57+
->addOption('on-invalid', null, InputOption::VALUE_REQUIRED, 'The behavior to adopt when the decoration is invalid.')
58+
->setHelp($this->getHelpFileContents('MakeDecorator.txt'))
59+
;
60+
61+
$inputConfig->setArgumentAsNonInteractive('id');
62+
$inputConfig->setArgumentAsNonInteractive('decorator-class');
63+
}
64+
65+
public function configureDependencies(DependencyBuilder $dependencies): void
66+
{
67+
$dependencies->addClassDependency(
68+
AsDecorator::class,
69+
'dependency-injection',
70+
);
71+
}
72+
73+
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
74+
{
75+
// Ask for service id.
76+
if (null === $input->getArgument('id')) {
77+
$argument = $command->getDefinition()->getArgument('id');
78+
79+
($question = new Question($argument->getDescription()))
80+
->setAutocompleterValues($suggestIds = $this->helper->suggestIds())
81+
->setValidator(fn ($answer) => Validator::serviceExists($answer, $suggestIds))
82+
->setMaxAttempts(3);
83+
84+
$input->setArgument('id', $io->askQuestion($question));
85+
}
86+
87+
$id = $input->getArgument('id');
88+
if (null === $realId = $this->helper->getRealId($id)) {
89+
$guessCount = \count($guessRealIds = $this->helper->guessRealIds($id));
90+
91+
if (0 === $guessCount) {
92+
throw new RuntimeCommandException(\sprintf('Cannot find nor guess service for given id "%s".', $id));
93+
} elseif (1 === $guessCount) {
94+
$question = new ConfirmationQuestion(\sprintf('<fg=green>Did you mean</> <fg=yellow>"%s"</> <fg=green>?</>', $guessRealIds[0]), true);
95+
96+
if (!$io->askQuestion($question)) {
97+
throw new RuntimeCommandException(\sprintf('Cannot find nor guess service for given id "%s".', $id));
98+
}
99+
100+
$input->setArgument('id', $id = $guessRealIds[0]);
101+
} else {
102+
$input->setArgument(
103+
'id',
104+
$id = $io->choice(\sprintf('Multiple services found for "%s", choose which one you want to decorate:', $id), $guessRealIds),
105+
);
106+
}
107+
} else {
108+
$input->setArgument('id', $id = $realId);
109+
}
110+
111+
// Ask for decorator classname.
112+
if (null === $input->getArgument('decorator-class')) {
113+
$argument = $command->getDefinition()->getArgument('decorator-class');
114+
115+
$basename = Str::getShortClassName(match (true) {
116+
interface_exists($id) => Str::removeSuffix($id, 'Interface'),
117+
class_exists($id) => $id,
118+
default => Str::asClassName($id),
119+
});
120+
121+
$defaultClass = Str::asClassName(\sprintf('%s Decorator', $basename));
122+
123+
($question = new Question($argument->getDescription(), $defaultClass))
124+
->setValidator(fn ($answer) => Validator::validateClassName(Validator::classDoesNotExist($answer)))
125+
->setMaxAttempts(3);
126+
127+
$input->setArgument('decorator-class', $io->askQuestion($question));
128+
}
129+
}
130+
131+
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
132+
{
133+
$id = $input->getArgument('id');
134+
135+
$classNameDetails = $generator->createClassNameDetails(
136+
Validator::validateClassName(Validator::classDoesNotExist($input->getArgument('decorator-class'))),
137+
'',
138+
);
139+
140+
$priority = $input->getOption('priority');
141+
$onInvalid = $input->getOption('on-invalid');
142+
143+
$decoratedInfo = new DecoratorInfo(
144+
$classNameDetails->getFullName(),
145+
$id,
146+
$this->helper->getClass($id),
147+
empty($priority) ? null : $priority,
148+
null === $onInvalid || 1 === $onInvalid ? null : $onInvalid,
149+
);
150+
151+
$classData = $decoratedInfo->getClassData();
152+
153+
$generator->generateClassFromClassData(
154+
$classData,
155+
'decorator/Decorator.tpl.php',
156+
[
157+
'decorated_info' => $decoratedInfo,
158+
],
159+
);
160+
161+
$generator->writeChanges();
162+
163+
$this->writeSuccessMessage($io);
164+
}
165+
}

src/MakerBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Bundle\MakerBundle;
1313

1414
use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\MakeCommandRegistrationPass;
15+
use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\MakeDecoratorPass;
1516
use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\RemoveMissingParametersPass;
1617
use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\SetDoctrineAnnotatedPrefixesPass;
1718
use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
@@ -77,6 +78,7 @@ public function build(ContainerBuilder $container): void
7778
{
7879
// add a priority so we run before the core command pass
7980
$container->addCompilerPass(new MakeCommandRegistrationPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 10);
81+
$container->addCompilerPass(new MakeDecoratorPass());
8082
$container->addCompilerPass(new RemoveMissingParametersPass());
8183
$container->addCompilerPass(new SetDoctrineAnnotatedPrefixesPass());
8284
}

0 commit comments

Comments
 (0)