From dbbc516f585301e0911feea5bf610707eba555bc Mon Sep 17 00:00:00 2001 From: Benjamin Georgeault Date: Sun, 10 Nov 2024 15:01:44 +0100 Subject: [PATCH 1/3] [make:decorator] Add new maker to create decorator https://github.com/symfony/maker-bundle/issues/1401 --- config/help/MakeDecorator.txt | 10 + config/makers.xml | 6 + .../CompilerPass/MakeDecoratorPass.php | 34 +++ src/Maker/MakeDecorator.php | 141 +++++++++ src/MakerBundle.php | 2 + src/Util/ClassSource/Model/ClassData.php | 44 ++- src/Util/ClassSource/Model/ClassMethod.php | 61 ++++ src/Util/ClassSource/Model/MethodArgument.php | 42 +++ src/Util/DecoratorInfo.php | 223 ++++++++++++++ src/Util/UseStatementGenerator.php | 97 ++++++- src/Validator.php | 33 +++ templates/decorator/Decorator.tpl.php | 24 ++ .../Fixtures/OtherServiceInterface.php | 16 + .../DependencyInjection/Fixtures/ServiceA.php | 25 ++ .../DependencyInjection/Fixtures/ServiceB.php | 30 ++ .../DependencyInjection/Fixtures/ServiceC.php | 20 ++ .../DependencyInjection/Fixtures/ServiceD.php | 20 ++ .../DependencyInjection/Fixtures/ServiceE.php | 16 + .../DependencyInjection/Fixtures/ServiceF.php | 25 ++ .../Fixtures/ServiceInterface.php | 21 ++ .../DependencyInjection/Fixtures/ServiceZ.php | 16 + .../Fixtures/Sub/ServiceA.php | 27 ++ .../Fixtures/Sub/ServiceB.php | 18 ++ .../Fixtures/Sub/ServiceD.php | 21 ++ tests/Maker/MakeDecoratorTest.php | 91 ++++++ tests/Util/ClassSource/ClassDataTest.php | 22 ++ tests/Util/ClassSource/ClassMethodTest.php | 114 ++++++++ tests/Util/ClassSource/MethodArgumentTest.php | 59 ++++ tests/Util/DecoratorInfoTest.php | 273 ++++++++++++++++++ tests/Util/UseStatementGeneratorTest.php | 28 ++ tests/ValidatorTest.php | 49 ++++ .../basic_setup/src/Service/BarInterface.php | 8 + .../basic_setup/src/Service/FooInterface.php | 8 + .../basic_setup/src/Service/FooService.php | 11 + .../src/Service/ForExtendService.php | 19 ++ .../src/Service/MultipleImpService.php | 15 + .../tests/it_generates_basic_implements.php | 25 ++ .../tests/it_generates_force_extends.php | 21 ++ .../it_generates_multiple_implements.php | 27 ++ 39 files changed, 1733 insertions(+), 9 deletions(-) create mode 100644 config/help/MakeDecorator.txt create mode 100644 src/DependencyInjection/CompilerPass/MakeDecoratorPass.php create mode 100644 src/Maker/MakeDecorator.php create mode 100644 src/Util/ClassSource/Model/ClassMethod.php create mode 100644 src/Util/ClassSource/Model/MethodArgument.php create mode 100644 src/Util/DecoratorInfo.php create mode 100644 templates/decorator/Decorator.tpl.php create mode 100644 tests/DependencyInjection/Fixtures/OtherServiceInterface.php create mode 100644 tests/DependencyInjection/Fixtures/ServiceA.php create mode 100644 tests/DependencyInjection/Fixtures/ServiceB.php create mode 100644 tests/DependencyInjection/Fixtures/ServiceC.php create mode 100644 tests/DependencyInjection/Fixtures/ServiceD.php create mode 100644 tests/DependencyInjection/Fixtures/ServiceE.php create mode 100644 tests/DependencyInjection/Fixtures/ServiceF.php create mode 100644 tests/DependencyInjection/Fixtures/ServiceInterface.php create mode 100644 tests/DependencyInjection/Fixtures/ServiceZ.php create mode 100644 tests/DependencyInjection/Fixtures/Sub/ServiceA.php create mode 100644 tests/DependencyInjection/Fixtures/Sub/ServiceB.php create mode 100644 tests/DependencyInjection/Fixtures/Sub/ServiceD.php create mode 100644 tests/Maker/MakeDecoratorTest.php create mode 100644 tests/Util/ClassSource/ClassMethodTest.php create mode 100644 tests/Util/ClassSource/MethodArgumentTest.php create mode 100644 tests/Util/DecoratorInfoTest.php create mode 100644 tests/fixtures/make-decorator/basic_setup/src/Service/BarInterface.php create mode 100644 tests/fixtures/make-decorator/basic_setup/src/Service/FooInterface.php create mode 100644 tests/fixtures/make-decorator/basic_setup/src/Service/FooService.php create mode 100644 tests/fixtures/make-decorator/basic_setup/src/Service/ForExtendService.php create mode 100644 tests/fixtures/make-decorator/basic_setup/src/Service/MultipleImpService.php create mode 100644 tests/fixtures/make-decorator/tests/it_generates_basic_implements.php create mode 100644 tests/fixtures/make-decorator/tests/it_generates_force_extends.php create mode 100644 tests/fixtures/make-decorator/tests/it_generates_multiple_implements.php diff --git a/config/help/MakeDecorator.txt b/config/help/MakeDecorator.txt new file mode 100644 index 000000000..a89dc535c --- /dev/null +++ b/config/help/MakeDecorator.txt @@ -0,0 +1,10 @@ +The %command.name% command generates a new service decorator class. + +php %command.full_name% +php %command.full_name% My\Decorated\Service\Class +php %command.full_name% My\Decorated\Service\Class MyServiceDecorator +php %command.full_name% My\Decorated\Service\Class Service\MyServiceDecorator +php %command.full_name% my_decorated.service.id MyServiceDecorator +php %command.full_name% my_decorated.service.id MyServiceDecorator + +If one argument is missing, the command will ask for it interactively. diff --git a/config/makers.xml b/config/makers.xml index ad7d45483..9c7c2005a 100644 --- a/config/makers.xml +++ b/config/makers.xml @@ -35,6 +35,12 @@ + + + + + + diff --git a/src/DependencyInjection/CompilerPass/MakeDecoratorPass.php b/src/DependencyInjection/CompilerPass/MakeDecoratorPass.php new file mode 100644 index 000000000..8de450098 --- /dev/null +++ b/src/DependencyInjection/CompilerPass/MakeDecoratorPass.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * @author Benjamin Georgeault + */ +class MakeDecoratorPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition('maker.maker.make_decorator')) { + return; + } + + $container->getDefinition('maker.maker.make_decorator') + ->replaceArgument(0, ServiceLocatorTagPass::register($container, $ids = $container->getServiceIds())) + ->replaceArgument(1, $ids) + ; + } +} diff --git a/src/Maker/MakeDecorator.php b/src/Maker/MakeDecorator.php new file mode 100644 index 000000000..59e354fea --- /dev/null +++ b/src/Maker/MakeDecorator.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Maker; + +use Psr\Container\ContainerInterface; +use Symfony\Bundle\MakerBundle\ConsoleStyle; +use Symfony\Bundle\MakerBundle\DependencyBuilder; +use Symfony\Bundle\MakerBundle\Generator; +use Symfony\Bundle\MakerBundle\InputConfiguration; +use Symfony\Bundle\MakerBundle\Str; +use Symfony\Bundle\MakerBundle\Util\DecoratorInfo; +use Symfony\Bundle\MakerBundle\Validator; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Question\Question; +use Symfony\Component\DependencyInjection\Attribute\AsDecorator; + +/** + * @author Benjamin Georgeault + */ +final class MakeDecorator extends AbstractMaker +{ + /** + * @param array $ids + */ + public function __construct( + private readonly ContainerInterface $container, + private readonly array $ids, + ) { + } + + public static function getCommandName(): string + { + return 'make:decorator'; + } + + public static function getCommandDescription(): string + { + return 'Create CRUD for Doctrine entity class'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + $command + ->addArgument('id', InputArgument::OPTIONAL, 'The ID of the service to decorate.') + ->addArgument('decorator-class', InputArgument::OPTIONAL, \sprintf('The class name of the service to create (e.g. %sDecorator)', Str::asClassName(Str::getRandomTerm()))) + ->setHelp($this->getHelpFileContents('MakeDecorator.txt')) + ; + } + + public function configureDependencies(DependencyBuilder $dependencies): void + { + $dependencies->addClassDependency( + AsDecorator::class, + 'dependency-injection', + ); + } + + public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void + { + // Ask for service id. + if (null === $input->getArgument('id')) { + $argument = $command->getDefinition()->getArgument('id'); + + ($question = new Question($argument->getDescription())) + ->setAutocompleterValues($this->ids) + ->setValidator(fn ($answer) => Validator::serviceExists($answer, $this->ids)) + ->setMaxAttempts(3); + + $input->setArgument('id', $io->askQuestion($question)); + } + + $id = $input->getArgument('id'); + + // Ask for decorator classname. + if (null === $input->getArgument('decorator-class')) { + $argument = $command->getDefinition()->getArgument('decorator-class'); + + $basename = Str::getShortClassName(match (true) { + interface_exists($id) => Str::removeSuffix($id, 'Interface'), + class_exists($id) => $id, + default => Str::asClassName($id), + }); + + $defaultClass = Str::asClassName(\sprintf('%s Decorator', $basename)); + + ($question = new Question($argument->getDescription(), $defaultClass)) + ->setValidator(fn ($answer) => Validator::validateClassName(Validator::classDoesNotExist($answer))) + ->setMaxAttempts(3); + + $input->setArgument('decorator-class', $io->askQuestion($question)); + } + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + $id = $input->getArgument('id'); + + $classNameDetails = $generator->createClassNameDetails( + Validator::validateClassName(Validator::classDoesNotExist($input->getArgument('decorator-class'))), + '', + ); + + $decoratedInfo = $this->createDecoratorInfo($id, $classNameDetails->getFullName()); + $classData = $decoratedInfo->getClassData(); + + $generator->generateClassFromClassData( + $classData, + 'decorator/Decorator.tpl.php', + [ + 'decorated_info' => $decoratedInfo, + ], + ); + + $generator->writeChanges(); + + $this->writeSuccessMessage($io); + } + + private function createDecoratorInfo(string $id, string $decoratorClass): DecoratorInfo + { + return new DecoratorInfo( + $decoratorClass, + match (true) { + class_exists($id), interface_exists($id) => $id, + default => $this->container->get($id)::class, + }, + $id, + ); + } +} diff --git a/src/MakerBundle.php b/src/MakerBundle.php index 42516aec8..c8bd89358 100644 --- a/src/MakerBundle.php +++ b/src/MakerBundle.php @@ -12,6 +12,7 @@ namespace Symfony\Bundle\MakerBundle; use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\MakeCommandRegistrationPass; +use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\MakeDecoratorPass; use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\RemoveMissingParametersPass; use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\SetDoctrineAnnotatedPrefixesPass; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; @@ -69,6 +70,7 @@ public function build(ContainerBuilder $container): void { // add a priority so we run before the core command pass $container->addCompilerPass(new MakeCommandRegistrationPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 10); + $container->addCompilerPass(new MakeDecoratorPass()); $container->addCompilerPass(new RemoveMissingParametersPass()); $container->addCompilerPass(new SetDoctrineAnnotatedPrefixesPass()); } diff --git a/src/Util/ClassSource/Model/ClassData.php b/src/Util/ClassSource/Model/ClassData.php index 4ce0cd605..68e1a61be 100644 --- a/src/Util/ClassSource/Model/ClassData.php +++ b/src/Util/ClassSource/Model/ClassData.php @@ -30,13 +30,14 @@ private function __construct( private bool $isFinal = true, private string $rootNamespace = 'App', private ?string $classSuffix = null, + public readonly ?array $implements = null, ) { if (str_starts_with(haystack: $this->namespace, needle: $this->rootNamespace)) { $this->namespace = substr_replace(string: $this->namespace, replace: '', offset: 0, length: \strlen($this->rootNamespace) + 1); } } - public static function create(string $class, ?string $suffix = null, ?string $extendsClass = null, bool $isEntity = false, array $useStatements = []): self + public static function create(string $class, ?string $suffix = null, ?string $extendsClass = null, bool $isEntity = false, array $useStatements = [], ?array $implements = null): self { $className = Str::getShortClassName($class); @@ -44,19 +45,29 @@ public static function create(string $class, ?string $suffix = null, ?string $ex $className = Str::asClassName(\sprintf('%s%s', $className, $suffix)); } - $useStatements = new UseStatementGenerator($useStatements); + $className = Str::asClassName($className); + + $useStatements = new UseStatementGenerator($useStatements, [$className]); if ($extendsClass) { - $useStatements->addUseStatement($extendsClass); + $useStatements->addUseStatement($extendsClass, 'Base'); + } + + if ($implements) { + array_walk($implements, function (string &$interface) use ($useStatements) { + $useStatements->addUseStatement($interface, 'Base'); + $interface = $useStatements->getShortName($interface); + }); } return new self( - className: Str::asClassName($className), + className: $className, namespace: Str::getNamespace($class), - extends: null === $extendsClass ? null : Str::getShortClassName($extendsClass), + extends: null === $extendsClass ? null : $useStatements->getShortName($extendsClass), isEntity: $isEntity, useStatementGenerator: $useStatements, classSuffix: $suffix, + implements: $implements, ); } @@ -130,10 +141,17 @@ public function getClassDeclaration(): string $extendsDeclaration = \sprintf(' extends %s', $this->extends); } - return \sprintf('%sclass %s%s', + $implementsDeclaration = ''; + + if (null !== $this->implements) { + $implementsDeclaration = \sprintf(' implements %s', implode(', ', $this->implements)); + } + + return \sprintf('%sclass %s%s%s', $this->isFinal ? 'final ' : '', $this->className, $extendsDeclaration, + $implementsDeclaration, ); } @@ -144,9 +162,9 @@ public function setIsFinal(bool $isFinal): self return $this; } - public function addUseStatement(array|string $useStatement): self + public function addUseStatement(array|string $useStatement, ?string $aliasPrefixIfExist = null): self { - $this->useStatementGenerator->addUseStatement($useStatement); + $this->useStatementGenerator->addUseStatement($useStatement, $aliasPrefixIfExist); return $this; } @@ -155,4 +173,14 @@ public function getUseStatements(): string { return (string) $this->useStatementGenerator; } + + public function getUseStatementShortName(string $className): string + { + return $this->useStatementGenerator->getShortName($className); + } + + public function hasUseStatement(string $className): bool + { + return $this->useStatementGenerator->hasUseStatement($className); + } } diff --git a/src/Util/ClassSource/Model/ClassMethod.php b/src/Util/ClassSource/Model/ClassMethod.php new file mode 100644 index 000000000..3edd862d4 --- /dev/null +++ b/src/Util/ClassSource/Model/ClassMethod.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Util\ClassSource\Model; + +/** + * @author Benjamin Georgeault + * + * @internal + */ +final class ClassMethod +{ + /** + * @param MethodArgument[] $arguments + */ + public function __construct( + private readonly string $name, + private readonly array $arguments = [], + private readonly ?string $returnType = null, + private readonly bool $isStatic = false, + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function isReturnVoid(): bool + { + return 'void' === $this->returnType; + } + + public function isStatic(): bool + { + return $this->isStatic; + } + + public function getDeclaration(): string + { + return \sprintf('public %sfunction %s(%s)%s', + $this->isStatic ? 'static ' : '', + $this->name, + implode(', ', array_map(fn (MethodArgument $arg) => $arg->getDeclaration(), $this->arguments)), + $this->returnType ? ': '.$this->returnType : '', + ); + } + + public function getArgumentsUse(): string + { + return implode(', ', array_map(fn (MethodArgument $arg) => $arg->getVariable(), $this->arguments)); + } +} diff --git a/src/Util/ClassSource/Model/MethodArgument.php b/src/Util/ClassSource/Model/MethodArgument.php new file mode 100644 index 000000000..3237a7806 --- /dev/null +++ b/src/Util/ClassSource/Model/MethodArgument.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Util\ClassSource\Model; + +/** + * @author Benjamin Georgeault + * + * @internal + */ +final class MethodArgument +{ + public function __construct( + private readonly string $name, + private readonly ?string $type = null, + private readonly ?string $default = null, + ) { + } + + public function getDeclaration(): string + { + return ($this->type ?? ''). + ($this->type ? ' ' : ''). + $this->getVariable(). + ($this->default ? ' = ' : ''). + ($this->default ?? '') + ; + } + + public function getVariable(): string + { + return '$'.$this->name; + } +} diff --git a/src/Util/DecoratorInfo.php b/src/Util/DecoratorInfo.php new file mode 100644 index 000000000..f84e61a02 --- /dev/null +++ b/src/Util/DecoratorInfo.php @@ -0,0 +1,223 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Util; + +use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; +use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassData; +use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassMethod; +use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\MethodArgument; +use Symfony\Component\DependencyInjection\Attribute\AsDecorator; +use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; + +/** + * @internal + */ +final class DecoratorInfo +{ + private readonly ClassData $classData; + + private readonly array $methods; + + private readonly array $decoratedClassOrInterfaces; + + private readonly string $decoratedIdDeclaration; + + /** + * @param class-string $decoratorClassName + * @param class-string $decoratedClassOrInterface + */ + public function __construct( + private readonly string $decoratorClassName, + string $decoratedId, + string $decoratedClassOrInterface, + ) { + $decoratedTypeRef = new \ReflectionClass($decoratedClassOrInterface); + + // Try implements + $interfaces = match (true) { + interface_exists($decoratedClassOrInterface) => [$decoratedClassOrInterface], + self::isClassEquivalentToItsInterfaces($decoratedTypeRef) => array_values(class_implements($decoratedClassOrInterface)), + default => null, + }; + + // Try extends if cannot implements. + $extends = (null === $interfaces) ? match (true) { + self::isClassEquivalentToItsParentClass($decoratedTypeRef) => get_parent_class($decoratedClassOrInterface), + !$decoratedTypeRef->isFinal() => $decoratedClassOrInterface, + default => throw new RuntimeCommandException(\sprintf('Cannot decorate "%s", its class does not have any interface, parent class and its final.', $decoratedClassOrInterface)), + } : null; + + $this->classData = ClassData::create( + class: $this->decoratorClassName, + extendsClass: $extends, + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ], + implements: $interfaces, + ); + + // Use interfaces or extends as decorated type + $this->decoratedClassOrInterfaces = $interfaces ?? [$extends]; + + // Handle decorated service's id. + if (class_exists($decoratedId) || interface_exists($decoratedId)) { + if (!$this->classData->hasUseStatement($decoratedId)) { + $this->classData->addUseStatement($decoratedId, 'Service'); + } + + $this->decoratedIdDeclaration = \sprintf('%s::class', $this->classData->getUseStatementShortName($decoratedId)); + } else { + $this->decoratedIdDeclaration = \sprintf('\'%s\'', $decoratedId); + } + + // Trigger methods parsing to register methods arguments type in use statement. + $this->methods = $this->doGetPublicMethods(); + } + + /** + * @return array + */ + public function getPublicMethods(): array + { + return $this->methods; + } + + public function getClassData(): ClassData + { + return $this->classData; + } + + public function getDecoratedIdDeclaration(): string + { + return $this->decoratedIdDeclaration; + } + + public function getShortNameInnerType(): string + { + return implode('&', array_map($this->classData->getUseStatementShortName(...), $this->decoratedClassOrInterfaces)); + } + + /** + * @return array + */ + private function doGetPublicMethods(): array + { + $methods = []; + foreach ($this->decoratedClassOrInterfaces as $classOrInterface) { + $ref = new \ReflectionClass($classOrInterface); + + foreach ($ref->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->isFinal() || \array_key_exists($method->getName(), $methods)) { + continue; + } + + $methods[$method->getName()] = new ClassMethod( + $method->getName(), + [...$this->doParseArguments($method)], + $this->parseType($method->getReturnType()), + $method->isStatic(), + ); + } + } + + return $methods; + } + + /** + * @return iterable + */ + private function doParseArguments(\ReflectionMethod $method): iterable + { + foreach ($method->getParameters() as $parameter) { + $default = null; + if ($parameter->isOptional()) { + if ($parameter->isDefaultValueConstant()) { + $default = $parameter->getDefaultValueConstantName(); + } elseif ($parameter->isDefaultValueAvailable()) { + $defaultValue = $parameter->getDefaultValue(); + + if (\is_string($defaultValue)) { + $default = '\''.str_replace('\'', '\\\'', $defaultValue).'\''; + } elseif (\is_scalar($defaultValue)) { + $default = $defaultValue; + } elseif (\is_array($defaultValue)) { + $default = '[]'; + } elseif (null === $defaultValue) { + $default = 'null'; + } + } + + if (!empty($default)) { + $default = ' = '.$default; + } + } + + yield new MethodArgument( + $parameter->getName(), + $this->parseType($parameter->getType()), + $default, + ); + } + } + + private function parseType(?\ReflectionType $type): ?string + { + if (null === $type) { + return null; + } + + if ($type instanceof \ReflectionNamedType) { + if (class_exists($type->getName()) || interface_exists($type->getName())) { + $this->classData->addUseStatement($type->getName(), 'Arg'); + + return $this->classData->getUseStatementShortName($type->getName()); + } + + return $type->getName(); + } + + if ($type instanceof \ReflectionUnionType) { + return implode('|', array_map($this->parseType(...), $type->getTypes())); + } + + if ($type instanceof \ReflectionIntersectionType) { + return implode('&', array_map($this->parseType(...), $type->getTypes())); + } + + throw new RuntimeCommandException('Should never be reach.'); + } + + private static function isClassEquivalentToItsInterfaces(\ReflectionClass $classRef): bool + { + if (empty($interfaceRefs = $classRef->getInterfaces())) { + return false; + } + + $methodCount = array_sum(array_map( + fn (\ReflectionClass $ref) => \count($ref->getMethods(\ReflectionMethod::IS_PUBLIC)), + $interfaceRefs, + )); + + return $methodCount === \count($classRef->getMethods(\ReflectionMethod::IS_PUBLIC)); + } + + private static function isClassEquivalentToItsParentClass(\ReflectionClass $classRef): bool + { + if (false === $parentClassRef = $classRef->getParentClass()) { + return false; + } + + return \count($classRef->getMethods(\ReflectionMethod::IS_PUBLIC)) + === \count($parentClassRef->getMethods(\ReflectionMethod::IS_PUBLIC)); + } +} diff --git a/src/Util/UseStatementGenerator.php b/src/Util/UseStatementGenerator.php index 17cf47d2d..f87b737ac 100644 --- a/src/Util/UseStatementGenerator.php +++ b/src/Util/UseStatementGenerator.php @@ -11,6 +11,9 @@ namespace Symfony\Bundle\MakerBundle\Util; +use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; +use Symfony\Bundle\MakerBundle\Str; + /** * Converts fully qualified class names into sorted use statements for templates. * @@ -27,9 +30,11 @@ final class UseStatementGenerator implements \Stringable * to mix non-aliases classes with aliases. * * @param string[]|array $classesToBeImported + * @param string[] $concideredShortScoped */ public function __construct( private array $classesToBeImported, + private readonly array $concideredShortScoped = [], ) { } @@ -74,8 +79,20 @@ public function __toString(): string /** * @param string|string[]|array $className */ - public function addUseStatement(array|string $className): void + public function addUseStatement(array|string $className, ?string $aliasPrefixIfExist = null): void { + if (null !== $aliasPrefixIfExist) { + if (\is_array($className)) { + throw new RuntimeCommandException('$aliasIfScoped must be null if $className is an array.'); + } + + if ($this->isShortNameScoped($className)) { + $this->classesToBeImported[] = [$className => $aliasPrefixIfExist.Str::getShortClassName($className)]; + + return; + } + } + if (\is_array($className)) { $this->classesToBeImported = array_merge($this->classesToBeImported, $className); @@ -89,4 +106,82 @@ public function addUseStatement(array|string $className): void $this->classesToBeImported[] = $className; } + + public function getShortName(string $className): string + { + foreach ($this->classesToBeImported as $class) { + $alias = null; + if (\is_array($class)) { + $alias = current($class); + $class = key($class); + } + + if (null === $alias) { + if ($class === $className) { + return Str::getShortClassName($class); + } + + if (str_starts_with($className, $class)) { + return Str::getShortClassName($class).substr($className, \strlen($class)); + } + + continue; + } + + if ($class === $className) { + return $alias; + } + + if (str_starts_with($className, $class)) { + return $alias.substr($className, \strlen($class)); + } + } + + throw new RuntimeCommandException(\sprintf('The class "%s" is not found in use statement.', $className)); + } + + public function hasUseStatement(string $className): bool + { + foreach ($this->classesToBeImported as $class) { + if (\is_array($class)) { + $class = key($class); + } + + if ($class === $className) { + return true; + } + } + + return false; + } + + private function isShortNameScoped(string $className): bool + { + $shortClassName = Str::getShortClassName($className); + + if (\in_array($shortClassName, $this->concideredShortScoped)) { + return true; + } + + foreach ($this->classesToBeImported as $class) { + if (\is_array($class)) { + $tmp = $class; + $class = key($class); + $shortClass = current($tmp); + } else { + $shortClass = Str::getShortClassName($class); + } + + // If class already exist, considered as not scoped. + if ($class === $className) { + return false; + } + + if ($shortClassName === $shortClass) { + return true; + } + } + + return false; + } } diff --git a/src/Validator.php b/src/Validator.php index 4682624f5..da6204ef0 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -255,4 +255,37 @@ public static function classIsBackedEnum($backedEnum): string return $backedEnum; } + + public static function serviceExists(string $id, array $ids = []): string + { + self::notBlank($id); + + if (!\in_array($id, $ids)) { + throw new RuntimeCommandException(\sprintf('Service "%s" doesn\'t exist; please enter an existing one.', $id)); + } + + return $id; + } + + /** + * @param class-string $interface + */ + public static function allowedInterface(string $interface, array $interfaces): string + { + self::notBlank($interface); + + if (empty($interfaces)) { + throw new RuntimeCommandException('Please give interfaces to check.'); + } + + if (!interface_exists($interface)) { + throw new RuntimeCommandException(\sprintf('The interface "%s" doesn\'t exist.', $interface)); + } + + if (!\in_array($interface, $interfaces)) { + throw new RuntimeCommandException(\sprintf('The interface "%s" is not allowed.', $interface)); + } + + return $interface; + } } diff --git a/templates/decorator/Decorator.tpl.php b/templates/decorator/Decorator.tpl.php new file mode 100644 index 000000000..459b705f8 --- /dev/null +++ b/templates/decorator/Decorator.tpl.php @@ -0,0 +1,24 @@ + + +namespace getNamespace() ?>; + +getUseStatements(); ?> + +#[AsDecorator(getDecoratedIdDeclaration(); ?>)] +getClassDeclaration(); ?> + +{ + public function __construct( + #[AutowireDecorated] + private readonly getShortNameInnerType(); ?> $inner, + ) { + } +getPublicMethods() as $method): ?> + + getDeclaration() ?> + + { + isReturnVoid()): ?>return isStatic()) ? 'parent::' : '$this->inner->' ; ?>getName() ?>(getArgumentsUse() ?>); + } + +} diff --git a/tests/DependencyInjection/Fixtures/OtherServiceInterface.php b/tests/DependencyInjection/Fixtures/OtherServiceInterface.php new file mode 100644 index 000000000..85cb0752a --- /dev/null +++ b/tests/DependencyInjection/Fixtures/OtherServiceInterface.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures; + +interface OtherServiceInterface +{ +} diff --git a/tests/DependencyInjection/Fixtures/ServiceA.php b/tests/DependencyInjection/Fixtures/ServiceA.php new file mode 100644 index 000000000..1496f82c2 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/ServiceA.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures; + +class ServiceA implements ServiceInterface +{ + public function getName(): string + { + return 'service_a'; + } + + public function getDefault(string $mode = self::MODE_FOO): ?string + { + return $this->getName(); + } +} diff --git a/tests/DependencyInjection/Fixtures/ServiceB.php b/tests/DependencyInjection/Fixtures/ServiceB.php new file mode 100644 index 000000000..a6cf4b39b --- /dev/null +++ b/tests/DependencyInjection/Fixtures/ServiceB.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures; + +class ServiceB extends ServiceA +{ + public function getName(): string + { + return 'service_b'; + } + + public function getFoo(): bool + { + return true; + } + + public static function getStaticValue(string $default = ''): ServiceInterface|string|null + { + return 'service_b'; + } +} diff --git a/tests/DependencyInjection/Fixtures/ServiceC.php b/tests/DependencyInjection/Fixtures/ServiceC.php new file mode 100644 index 000000000..e59818c0c --- /dev/null +++ b/tests/DependencyInjection/Fixtures/ServiceC.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures; + +class ServiceC +{ + public function getFoo(): string + { + return 'foo'; + } +} diff --git a/tests/DependencyInjection/Fixtures/ServiceD.php b/tests/DependencyInjection/Fixtures/ServiceD.php new file mode 100644 index 000000000..1e8c83222 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/ServiceD.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures; + +class ServiceD extends ServiceA +{ + public function getDefault(string $mode = self::MODE_FOO): ?string + { + return parent::getDefault($mode); + } +} diff --git a/tests/DependencyInjection/Fixtures/ServiceE.php b/tests/DependencyInjection/Fixtures/ServiceE.php new file mode 100644 index 000000000..38a7c1f9a --- /dev/null +++ b/tests/DependencyInjection/Fixtures/ServiceE.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures; + +final class ServiceE extends ServiceA +{ +} diff --git a/tests/DependencyInjection/Fixtures/ServiceF.php b/tests/DependencyInjection/Fixtures/ServiceF.php new file mode 100644 index 000000000..63212dd60 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/ServiceF.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures; + +final class ServiceF implements ServiceInterface, OtherServiceInterface +{ + public function getName(): string + { + return 'service_f'; + } + + public function getDefault(string $mode = self::MODE_FOO): ?string + { + return 'service_f'; + } +} diff --git a/tests/DependencyInjection/Fixtures/ServiceInterface.php b/tests/DependencyInjection/Fixtures/ServiceInterface.php new file mode 100644 index 000000000..3479745df --- /dev/null +++ b/tests/DependencyInjection/Fixtures/ServiceInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures; + +interface ServiceInterface +{ + public const MODE_FOO = 'foo'; + + public function getName(): string; + + public function getDefault(string $mode = self::MODE_FOO): ?string; +} diff --git a/tests/DependencyInjection/Fixtures/ServiceZ.php b/tests/DependencyInjection/Fixtures/ServiceZ.php new file mode 100644 index 000000000..227f7473d --- /dev/null +++ b/tests/DependencyInjection/Fixtures/ServiceZ.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures; + +final class ServiceZ +{ +} diff --git a/tests/DependencyInjection/Fixtures/Sub/ServiceA.php b/tests/DependencyInjection/Fixtures/Sub/ServiceA.php new file mode 100644 index 000000000..b906e5f3c --- /dev/null +++ b/tests/DependencyInjection/Fixtures/Sub/ServiceA.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\Sub; + +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceInterface; + +class ServiceA implements ServiceInterface +{ + public function getName(): string + { + return 'FOO'; + } + + public function getDefault(string $mode = self::MODE_FOO): ?string + { + return null; + } +} diff --git a/tests/DependencyInjection/Fixtures/Sub/ServiceB.php b/tests/DependencyInjection/Fixtures/Sub/ServiceB.php new file mode 100644 index 000000000..a0dbf9112 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/Sub/ServiceB.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\Sub; + +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceB as BaseService; + +class ServiceB extends BaseService +{ +} diff --git a/tests/DependencyInjection/Fixtures/Sub/ServiceD.php b/tests/DependencyInjection/Fixtures/Sub/ServiceD.php new file mode 100644 index 000000000..594b96133 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/Sub/ServiceD.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\Sub; + +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceD as BaseService; + +class ServiceD extends BaseService +{ + public function blabla(): void + { + } +} diff --git a/tests/Maker/MakeDecoratorTest.php b/tests/Maker/MakeDecoratorTest.php new file mode 100644 index 000000000..767a06392 --- /dev/null +++ b/tests/Maker/MakeDecoratorTest.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\Maker; + +use Symfony\Bundle\MakerBundle\Maker\MakeDecorator; +use Symfony\Bundle\MakerBundle\Test\MakerTestCase; +use Symfony\Bundle\MakerBundle\Test\MakerTestDetails; +use Symfony\Bundle\MakerBundle\Test\MakerTestRunner; + +class MakeDecoratorTest extends MakerTestCase +{ + protected function getMakerClass(): string + { + return MakeDecorator::class; + } + + protected function createMakerTest(): MakerTestDetails + { + return parent::createMakerTest() + ->preRun(function (MakerTestRunner $runner) { + $runner->copy( + 'make-decorator/basic_setup', + '' + ); + + $runner->modifyYamlFile('config/services.yaml', function (array $config) { + $config['services']['App\\Service\\'] = [ + 'resource' => '../src/Service', + 'public' => true, + ]; + + return $config; + }); + }); + } + + public function getTestDetails(): \Generator + { + yield 'it_generates_basic_implements' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker([ + 'App\\Service\\FooService', + 'GeneratedServiceDecorator', + ]); + + $this->runFormTest($runner, 'it_generates_basic_implements.php'); + }), + ]; + + yield 'it_generates_multiple_implements' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker([ + 'App\\Service\\MultipleImpService', + 'GeneratedServiceDecorator', + ]); + + $this->runFormTest($runner, 'it_generates_multiple_implements.php'); + }), + ]; + + yield 'it_generates_force_extends' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker([ + 'App\\Service\\ForExtendService', + 'GeneratedServiceDecorator', + ]); + + $this->runFormTest($runner, 'it_generates_force_extends.php'); + }), + ]; + } + + private function runFormTest(MakerTestRunner $runner, string $filename): void + { + $runner->copy( + 'make-decorator/tests/'.$filename, + 'tests/GeneratedDecoratorTest.php' + ); + + $runner->runTests(); + } +} diff --git a/tests/Util/ClassSource/ClassDataTest.php b/tests/Util/ClassSource/ClassDataTest.php index 27d345028..7755cf0e2 100644 --- a/tests/Util/ClassSource/ClassDataTest.php +++ b/tests/Util/ClassSource/ClassDataTest.php @@ -12,7 +12,9 @@ namespace Symfony\Bundle\MakerBundle\Tests\Util\ClassSource; use PHPUnit\Framework\TestCase; +use Symfony\Bundle\MakerBundle\InputAwareMakerInterface; use Symfony\Bundle\MakerBundle\MakerBundle; +use Symfony\Bundle\MakerBundle\MakerInterface; use Symfony\Bundle\MakerBundle\Test\MakerTestKernel; use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassData; @@ -143,4 +145,24 @@ public function fullClassNameProvider(): \Generator yield ['Controller\MyController', 'Custom', false, true, 'Custom\Controller\My']; yield ['Controller\MyController', 'Custom', true, true, 'Controller\My']; } + + /** @dataProvider withImplementsProvider */ + public function testWithImplements(string $class, array $implements, string $expectedClassDeclaration, string $expectedUseStatements) + { + $meta = ClassData::create(class: $class, implements: $implements); + self::assertSame($expectedClassDeclaration, $meta->getClassDeclaration()); + self::assertSame($expectedUseStatements, $meta->getUseStatements()); + } + + public function withImplementsProvider(): \Generator + { + yield [MakerBundle::class, [MakerInterface::class], 'final class MakerBundle implements MakerInterface', "use Symfony\Bundle\MakerBundle\MakerInterface;\n"]; + yield [MakerBundle::class, [MakerInterface::class, InputAwareMakerInterface::class], 'final class MakerBundle implements MakerInterface, InputAwareMakerInterface', "use Symfony\Bundle\MakerBundle\InputAwareMakerInterface;\nuse Symfony\Bundle\MakerBundle\MakerInterface;\n"]; + } + + public function testWithExtendsAndImplements() + { + $meta = ClassData::create(class: MakerBundle::class, extendsClass: MakerTestKernel::class, implements: [MakerInterface::class]); + self::assertSame('final class MakerBundle extends MakerTestKernel implements MakerInterface', $meta->getClassDeclaration()); + } } diff --git a/tests/Util/ClassSource/ClassMethodTest.php b/tests/Util/ClassSource/ClassMethodTest.php new file mode 100644 index 000000000..6dbb8fe46 --- /dev/null +++ b/tests/Util/ClassSource/ClassMethodTest.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\Util\ClassSource; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassMethod; +use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\MethodArgument; + +class ClassMethodTest extends TestCase +{ + public function testGetName() + { + self::assertSame('foobar', (new ClassMethod('foobar'))->getName()); + } + + /** @dataProvider returnVoidProvider */ + public function testReturnVoid(?string $returnType, bool $isVoid) + { + self::assertSame($isVoid, (new ClassMethod('foobar', [], $returnType))->isReturnVoid()); + } + + public function returnVoidProvider(): \Generator + { + yield ['void', true]; + yield ['string', false]; + yield [null, false]; + } + + public function testIsStatic() + { + self::assertTrue((new ClassMethod('foobar', [], null, true))->isStatic()); + self::assertFalse((new ClassMethod('foobar', [], null, false))->isStatic()); + self::assertFalse((new ClassMethod('foobar'))->isStatic()); + } + + /** @dataProvider declarationsProvider */ + public function testGetDeclaration(array $args, ?string $returnType, bool $isStatic, string $expectedDeclaration): void + { + $classMethod = new ClassMethod('foobar', $args, $returnType, $isStatic); + + self::assertSame($expectedDeclaration, $classMethod->getDeclaration()); + } + + public function declarationsProvider(): \Generator + { + yield [ + [], + null, + false, + 'public function foobar()', + ]; + + yield [ + [ + new MethodArgument('toto', 'array'), + new MethodArgument('titi', 'AClass'), + new MethodArgument('foo', 'string', '\'THE_DEFAULT_VALUE\''), + new MethodArgument('bar', 'int', 'self::NUM'), + ], + 'void', + false, + 'public function foobar(array $toto, AClass $titi, string $foo = \'THE_DEFAULT_VALUE\', int $bar = self::NUM): void', + ]; + + yield [ + [], + 'string', + true, + 'public static function foobar(): string', + ]; + } + + /** @dataProvider argumentsUsesProvider */ + public function testGetArgumentsUse(array $args, string $expectedDeclaration): void + { + $classMethod = new ClassMethod('foobar', $args); + + self::assertSame($expectedDeclaration, $classMethod->getArgumentsUse()); + } + + public function argumentsUsesProvider(): \Generator + { + yield [ + [], + '', + ]; + + yield [ + [ + new MethodArgument('toto', 'array'), + new MethodArgument('titi', 'AClass'), + new MethodArgument('foo', 'string', '\'THE_DEFAULT_VALUE\''), + new MethodArgument('bar', 'int', 'self::NUM'), + ], + '$toto, $titi, $foo, $bar', + ]; + + yield [ + [ + new MethodArgument('toto', 'array'), + ], + '$toto', + ]; + } +} diff --git a/tests/Util/ClassSource/MethodArgumentTest.php b/tests/Util/ClassSource/MethodArgumentTest.php new file mode 100644 index 000000000..6625e72b8 --- /dev/null +++ b/tests/Util/ClassSource/MethodArgumentTest.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\Util\ClassSource; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\MethodArgument; + +/** + * Class MethodArgumentTest. + * + * @author Benjamin Georgeault + */ +class MethodArgumentTest extends TestCase +{ + /** @dataProvider declarationsProvider */ + public function testGetDeclaration(?string $type, ?string $default, string $expected) + { + $methodArgument = new MethodArgument('foo', $type, $default); + + $this->assertSame($expected, $methodArgument->getDeclaration()); + } + + public function declarationsProvider(): \Generator + { + yield [ + null, + null, + '$foo', + ]; + + yield [ + 'string', + '\'foobar\'', + 'string $foo = \'foobar\'', + ]; + + yield [ + 'string', + null, + 'string $foo', + ]; + } + + public function testGetVariable() + { + $methodArgument = new MethodArgument('foo'); + + $this->assertSame('$foo', $methodArgument->getVariable()); + } +} diff --git a/tests/Util/DecoratorInfoTest.php b/tests/Util/DecoratorInfoTest.php new file mode 100644 index 000000000..da7e3c765 --- /dev/null +++ b/tests/Util/DecoratorInfoTest.php @@ -0,0 +1,273 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\Util; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\OtherServiceInterface; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceA; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceB; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceC; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceD; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceE; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceF; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceInterface; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceZ; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\Sub\ServiceA as SubServiceA; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\Sub\ServiceB as SubServiceB; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\Sub\ServiceD as SubServiceD; +use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassData; +use Symfony\Bundle\MakerBundle\Util\DecoratorInfo; +use Symfony\Component\DependencyInjection\Attribute\AsDecorator; +use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; + +class DecoratorInfoTest extends TestCase +{ + public function testInvalid() + { + $this->expectException(RuntimeCommandException::class); + $this->expectExceptionMessage('Cannot decorate "Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceZ", its class does not have any interface, parent class and its final.'); + new DecoratorInfo('FooBar', 'foo.bar', ServiceZ::class); + } + + /** @dataProvider publicMethodsProvider */ + public function testGetPublicMethods(string $decoratedClassOrInterface, array $expected) + { + $decoratorInfo = new DecoratorInfo('FooBar', 'foo.bar', $decoratedClassOrInterface); + + $this->assertSame($expected, array_keys($decoratorInfo->getPublicMethods())); + } + + public function publicMethodsProvider(): \Generator + { + yield [ServiceInterface::class, ['getName', 'getDefault']]; + yield [ServiceA::class, ['getName', 'getDefault']]; + yield [ServiceB::class, ['getName', 'getFoo', 'getStaticValue', 'getDefault']]; + yield [ServiceC::class, ['getFoo']]; + yield [ServiceD::class, ['getName', 'getDefault']]; + yield [ServiceE::class, ['getName', 'getDefault']]; + yield [ServiceF::class, ['getName', 'getDefault']]; + } + + /** @dataProvider classDataProvider */ + public function testGetClassData(string $decoratedId, string $decoratedClassOrInterface, ClassData $expected) + { + $decoratorInfo = new DecoratorInfo('FooBar', $decoratedId, $decoratedClassOrInterface); + + $this->assertEquals($expected, $decoratorInfo->getClassData()); + } + + public function classDataProvider(): \Generator + { + yield [ + 'foo.bar', + ServiceInterface::class, + ClassData::create( + class: 'FooBar', + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ], + implements: [ServiceInterface::class], + ), + ]; + + yield [ + 'foo.bar', + ServiceA::class, + ClassData::create( + class: 'FooBar', + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ], + implements: [ServiceInterface::class], + ), + ]; + + yield [ + 'foo.bar', + ServiceB::class, + ClassData::create( + class: 'FooBar', + extendsClass: ServiceB::class, + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ServiceB::class, + ServiceInterface::class, + ], + ), + ]; + + yield [ + 'foo.bar', + ServiceC::class, + ClassData::create( + class: 'FooBar', + extendsClass: ServiceC::class, + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ], + ), + ]; + + yield [ + 'foo.bar', + ServiceD::class, + ClassData::create( + class: 'FooBar', + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ], + implements: [ServiceInterface::class], + ), + ]; + + yield [ + 'foo.bar', + ServiceE::class, + ClassData::create( + class: 'FooBar', + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ], + implements: [ServiceInterface::class], + ), + ]; + + yield [ + 'foo.bar', + ServiceF::class, + ClassData::create( + class: 'FooBar', + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ], + implements: [ + ServiceInterface::class, + OtherServiceInterface::class, + ], + ), + ]; + + yield [ + ServiceInterface::class, + ServiceF::class, + ClassData::create( + class: 'FooBar', + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ], + implements: [ + ServiceInterface::class, + OtherServiceInterface::class, + ], + ), + ]; + } + + /** @dataProvider decoratedIdDeclarationProvider */ + public function testGetDecoratedIdDeclaration(string $serviceId, string $decoratedClassOrInterface, string $expected, bool $idAsClassOrInterface) + { + $decoratorInfo = new DecoratorInfo('FooBar', $serviceId, $decoratedClassOrInterface); + + $this->assertSame($expected, $decoratorInfo->getDecoratedIdDeclaration()); + + if ($idAsClassOrInterface) { + $this->assertTrue($decoratorInfo->getClassData()->hasUseStatement($serviceId)); + } + } + + public function decoratedIdDeclarationProvider(): \Generator + { + yield ['foo.bar', ServiceInterface::class, '\'foo.bar\'', false]; + yield [ServiceInterface::class, ServiceInterface::class, 'ServiceInterface::class', true]; + } + + /** @dataProvider shortNameInnerTypeProvider */ + public function testGetShortNameInnerType(string $decoratedClassOrInterface, string $expected, array $inUseStatements) + { + $decoratorInfo = new DecoratorInfo('FooBar', 'foo.bar', $decoratedClassOrInterface); + + $this->assertSame($expected, $decoratorInfo->getShortNameInnerType()); + + foreach ($inUseStatements as $inUseStatement) { + $this->assertTrue($decoratorInfo->getClassData()->hasUseStatement($inUseStatement)); + } + } + + public function shortNameInnerTypeProvider(): \Generator + { + yield [ServiceInterface::class, 'ServiceInterface', [ServiceInterface::class]]; + yield [ServiceA::class, 'ServiceInterface', [ServiceInterface::class]]; + yield [ServiceB::class, 'ServiceB', [ServiceB::class]]; + yield [ServiceC::class, 'ServiceC', [ServiceC::class]]; + yield [ServiceD::class, 'ServiceInterface', [ServiceInterface::class]]; + yield [ServiceE::class, 'ServiceInterface', [ServiceInterface::class]]; + yield [ServiceF::class, 'ServiceInterface&OtherServiceInterface', [ServiceInterface::class, OtherServiceInterface::class]]; + } + + /** @dataProvider aliasOnClassNameProvider */ + public function testAliasOnClassName(string $decoratorClassName, string $decoratedId, string $decoratedClassOrInterface, array $inUseStatements) + { + $decoratorInfo = new DecoratorInfo($decoratorClassName, $decoratedId, $decoratedClassOrInterface); + + foreach ($inUseStatements as $class => $alias) { + $this->assertSame($alias, $decoratorInfo->getClassData()->getUseStatementShortName($class)); + } + } + + public function aliasOnClassNameProvider(): \Generator + { + yield [ + 'TheService\\ServiceA', + SubServiceA::class, + SubServiceA::class, + [ + SubServiceA::class => 'ServiceServiceA', + ], + ]; + + yield [ + 'TheService\\ServiceB', + ServiceB::class, + ServiceB::class, + [ + ServiceB::class => 'BaseServiceB', + ], + ]; + + yield [ + 'TheService\\ServiceB', + SubServiceB::class, + SubServiceB::class, + [ + ServiceB::class => 'BaseServiceB', + ], + ]; + + yield [ + 'TheService\\ServiceD', + SubServiceD::class, + SubServiceD::class, + [ + SubServiceD::class => 'BaseServiceD', + ], + ]; + } +} diff --git a/tests/Util/UseStatementGeneratorTest.php b/tests/Util/UseStatementGeneratorTest.php index 392280d57..8a094e145 100644 --- a/tests/Util/UseStatementGeneratorTest.php +++ b/tests/Util/UseStatementGeneratorTest.php @@ -106,4 +106,32 @@ public function testUseStatementsWithDuplicates(): void EOT; self::assertSame($expected, (string) $unsorted); } + + public function testUseStatementShortName() + { + $statement = new UseStatementGenerator([ + \Symfony\UX\Turbo\Attribute\Broadcast::class, + \ApiPlatform\Core\Annotation\ApiResource::class, + [\Doctrine\ORM\Mapping::class => 'ORM'], + ]); + + self::assertSame('Broadcast', $statement->getShortName(\Symfony\UX\Turbo\Attribute\Broadcast::class)); + self::assertSame('ApiResource', $statement->getShortName(\ApiPlatform\Core\Annotation\ApiResource::class)); + self::assertSame('ORM', $statement->getShortName(\Doctrine\ORM\Mapping::class)); + self::assertSame('ORM\\Entity', $statement->getShortName(\Doctrine\ORM\Mapping\Entity::class)); + } + + public function testHasUseStatement() + { + $statement = new UseStatementGenerator([ + \Symfony\UX\Turbo\Attribute\Broadcast::class, + \ApiPlatform\Core\Annotation\ApiResource::class, + [\Doctrine\ORM\Mapping::class => 'ORM'], + ]); + + self::assertTrue($statement->hasUseStatement(\ApiPlatform\Core\Annotation\ApiResource::class)); + self::assertTrue($statement->hasUseStatement(\Symfony\UX\Turbo\Attribute\Broadcast::class)); + self::assertTrue($statement->hasUseStatement(\Doctrine\ORM\Mapping::class)); + self::assertFalse($statement->hasUseStatement(\Doctrine\ORM\Cache::class)); + } } diff --git a/tests/ValidatorTest.php b/tests/ValidatorTest.php index 97748925b..7e135aa60 100644 --- a/tests/ValidatorTest.php +++ b/tests/ValidatorTest.php @@ -116,4 +116,53 @@ public function testEntityDoesNotExist() $this->expectExceptionMessage(\sprintf('Entity "%s" doesn\'t exist; please enter an existing one or create a new one.', $className)); Validator::entityExists($className, ['Full\Entity\DummyEntity']); } + + public function testServiceExists() + { + $id = 'my_existing.service_id'; + $ids = ['my_existing.service_id']; + + $this->assertSame($id, Validator::serviceExists($id, $ids)); + } + + public function testServiceDoesNotExists() + { + $id = 'my_non_existing.service_id'; + $ids = ['my_existing.service_id']; + + $this->expectException(RuntimeCommandException::class); + $this->expectExceptionMessage('Service "my_non_existing.service_id" doesn\'t exist; please enter an existing one.'); + Validator::serviceExists($id, $ids); + } + + public function testEmptyAllowedInterface() + { + $this->expectException(RuntimeCommandException::class); + $this->expectExceptionMessage('Please give interfaces to check.'); + Validator::allowedInterface('Throwable', []); + } + + public function testNonExistingAllowedInterface() + { + $this->expectException(RuntimeCommandException::class); + $this->expectExceptionMessage('The interface "FooBar\RandomInterface" doesn\'t exist.'); + Validator::allowedInterface('FooBar\RandomInterface', ['Throwable']); + } + + public function testAllowedInterface() + { + $interface = 'Throwable'; + + $this->assertSame($interface, Validator::allowedInterface($interface, [$interface])); + } + + public function testNotAllowedInterface() + { + $interface = 'Throwable'; + $interfaces = ['Iterator']; + + $this->expectException(RuntimeCommandException::class); + $this->expectExceptionMessage('The interface "Throwable" is not allowed.'); + $this->assertSame($interface, Validator::allowedInterface($interface, $interfaces)); + } } diff --git a/tests/fixtures/make-decorator/basic_setup/src/Service/BarInterface.php b/tests/fixtures/make-decorator/basic_setup/src/Service/BarInterface.php new file mode 100644 index 000000000..c57fffdcb --- /dev/null +++ b/tests/fixtures/make-decorator/basic_setup/src/Service/BarInterface.php @@ -0,0 +1,8 @@ +get(FooService::class); + + $this->assertInstanceOf(GeneratedServiceDecorator::class, $service); + $this->assertInstanceOf(FooInterface::class, $service); + $this->assertNotInstanceOf(FooService::class, $service); + + $this->assertSame('THE_FOO_VALUE', $service->getTheValue()); + } +} diff --git a/tests/fixtures/make-decorator/tests/it_generates_force_extends.php b/tests/fixtures/make-decorator/tests/it_generates_force_extends.php new file mode 100644 index 000000000..fca0d8732 --- /dev/null +++ b/tests/fixtures/make-decorator/tests/it_generates_force_extends.php @@ -0,0 +1,21 @@ +get(ForExtendService::class); + + $this->assertInstanceOf(GeneratedServiceDecorator::class, $service); + $this->assertInstanceOf(ForExtendService::class, $service); + } +} diff --git a/tests/fixtures/make-decorator/tests/it_generates_multiple_implements.php b/tests/fixtures/make-decorator/tests/it_generates_multiple_implements.php new file mode 100644 index 000000000..b80c41383 --- /dev/null +++ b/tests/fixtures/make-decorator/tests/it_generates_multiple_implements.php @@ -0,0 +1,27 @@ +get(MultipleImpService::class); + + $this->assertInstanceOf(GeneratedServiceDecorator::class, $service); + $this->assertInstanceOf(FooInterface::class, $service); + $this->assertInstanceOf(BarInterface::class, $service); + $this->assertNotInstanceOf(MultipleImpService::class, $service); + + $this->assertSame('THE_FOO_VALUE', $service->getTheValue()); + } +} From 2fb5feb9104295865d2047e96f9ba7a334dbf429 Mon Sep 17 00:00:00 2001 From: Benjamin Georgeault Date: Wed, 13 Nov 2024 17:34:26 +0100 Subject: [PATCH 2/3] Fix argument default value. --- src/Util/DecoratorInfo.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Util/DecoratorInfo.php b/src/Util/DecoratorInfo.php index f84e61a02..ec1959500 100644 --- a/src/Util/DecoratorInfo.php +++ b/src/Util/DecoratorInfo.php @@ -156,10 +156,6 @@ private function doParseArguments(\ReflectionMethod $method): iterable $default = 'null'; } } - - if (!empty($default)) { - $default = ' = '.$default; - } } yield new MethodArgument( From 478542fdec790d4c5d0eee0f22101b3f3eab67c0 Mon Sep 17 00:00:00 2001 From: Benjamin Georgeault Date: Fri, 15 Nov 2024 11:16:41 +0100 Subject: [PATCH 3/3] Add DecoratorHelper + Suggest service's id on error/shortname + priority/onInvalid Also fix: - ignore `__construct` - fix static method on decorate by implements - Fix description --- config/makers.xml | 3 +- config/services.xml | 6 + .../CompilerPass/MakeDecoratorPass.php | 42 ++++- src/DependencyInjection/DecoratorHelper.php | 92 +++++++++++ src/Maker/MakeDecorator.php | 68 +++++--- src/Util/ClassSource/Model/ClassData.php | 5 + src/Util/DecoratorInfo.php | 77 +++++++-- templates/decorator/Decorator.tpl.php | 7 +- .../DecoratorHelperTest.php | 147 ++++++++++++++++++ tests/Util/DecoratorInfoTest.php | 23 ++- 10 files changed, 419 insertions(+), 51 deletions(-) create mode 100644 src/DependencyInjection/DecoratorHelper.php create mode 100644 tests/DependencyInjection/DecoratorHelperTest.php diff --git a/config/makers.xml b/config/makers.xml index 9c7c2005a..8167e2569 100644 --- a/config/makers.xml +++ b/config/makers.xml @@ -36,8 +36,7 @@ - - + diff --git a/config/services.xml b/config/services.xml index 8f0b2a209..ab8d86d66 100644 --- a/config/services.xml +++ b/config/services.xml @@ -40,6 +40,12 @@ + + + + + + %env(default::string:MAKER_PHP_CS_FIXER_BINARY_PATH)% diff --git a/src/DependencyInjection/CompilerPass/MakeDecoratorPass.php b/src/DependencyInjection/CompilerPass/MakeDecoratorPass.php index 8de450098..51815fcd2 100644 --- a/src/DependencyInjection/CompilerPass/MakeDecoratorPass.php +++ b/src/DependencyInjection/CompilerPass/MakeDecoratorPass.php @@ -11,8 +11,8 @@ namespace Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass; +use Symfony\Bundle\MakerBundle\Str; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; -use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; use Symfony\Component\DependencyInjection\ContainerBuilder; /** @@ -22,13 +22,45 @@ class MakeDecoratorPass implements CompilerPassInterface { public function process(ContainerBuilder $container): void { - if (!$container->hasDefinition('maker.maker.make_decorator')) { + if (!$container->hasDefinition('maker.decorator_helper')) { return; } - $container->getDefinition('maker.maker.make_decorator') - ->replaceArgument(0, ServiceLocatorTagPass::register($container, $ids = $container->getServiceIds())) - ->replaceArgument(1, $ids) + $shortNameMap = []; + $serviceClasses = []; + foreach ($container->getServiceIds() as $id) { + if (str_starts_with($id, '.')) { + continue; + } + + if (interface_exists($id) || class_exists($id)) { + $shortClass = Str::getShortClassName($id); + $shortNameMap[$shortClass][] = $id; + } + + if (!$container->hasDefinition($id)) { + continue; + } + + if ( + (null === $class = $container->getDefinition($id)->getClass()) + || $class === $id + ) { + continue; + } + + $shortClass = Str::getShortClassName($class); + $shortNameMap[$shortClass][] = $id; + $serviceClasses[$id] = $class; + } + + $shortNameMap = array_map(array_unique(...), $shortNameMap); + + $ids = $container->getServiceIds(); + $container->getDefinition('maker.decorator_helper') + ->replaceArgument(0, $ids) + ->replaceArgument(1, $serviceClasses) + ->replaceArgument(2, $shortNameMap) ; } } diff --git a/src/DependencyInjection/DecoratorHelper.php b/src/DependencyInjection/DecoratorHelper.php new file mode 100644 index 000000000..08bd08f7d --- /dev/null +++ b/src/DependencyInjection/DecoratorHelper.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\DependencyInjection; + +use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; + +/** + * @author Benjamin Georgeault + * + * @internal + */ +final class DecoratorHelper +{ + /** + * @param array $ids + * @param array $serviceClasses + * @param array $shortNameMap + */ + public function __construct( + private readonly array $ids, + private readonly array $serviceClasses, + private readonly array $shortNameMap, + ) { + } + + public function suggestIds(): array + { + return [ + ...array_keys($this->shortNameMap), + ...$this->ids, + ]; + } + + public function getRealId(string $id): ?string + { + if (\in_array($id, $this->ids)) { + return $id; + } + + if (\array_key_exists($id, $this->shortNameMap) && 1 === \count($this->shortNameMap[$id])) { + return $this->shortNameMap[$id][0]; + } + + return null; + } + + public function guessRealIds(string $id): array + { + $guessTypos = []; + foreach ($this->shortNameMap as $shortName => $ids) { + if (levenshtein($id, $shortName) < 3) { + $guessTypos = [ + ...$guessTypos, + ...$ids, + ]; + } + } + + foreach ($this->ids as $suggestId) { + if (levenshtein($id, $suggestId) < 3) { + $guessTypos[] = $suggestId; + } + } + + return $guessTypos; + } + + /** + * @return class-string + */ + public function getClass(string $id): string + { + if (class_exists($id) || interface_exists($id)) { + return $id; + } + + if (\array_key_exists($id, $this->serviceClasses)) { + return $this->serviceClasses[$id]; + } + + throw new RuntimeCommandException(\sprintf('Cannot getClass for id "%s".', $id)); + } +} diff --git a/src/Maker/MakeDecorator.php b/src/Maker/MakeDecorator.php index 59e354fea..573b37cc5 100644 --- a/src/Maker/MakeDecorator.php +++ b/src/Maker/MakeDecorator.php @@ -11,9 +11,10 @@ namespace Symfony\Bundle\MakerBundle\Maker; -use Psr\Container\ContainerInterface; use Symfony\Bundle\MakerBundle\ConsoleStyle; use Symfony\Bundle\MakerBundle\DependencyBuilder; +use Symfony\Bundle\MakerBundle\DependencyInjection\DecoratorHelper; +use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; use Symfony\Bundle\MakerBundle\Generator; use Symfony\Bundle\MakerBundle\InputConfiguration; use Symfony\Bundle\MakerBundle\Str; @@ -22,6 +23,8 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Question\Question; use Symfony\Component\DependencyInjection\Attribute\AsDecorator; @@ -30,12 +33,8 @@ */ final class MakeDecorator extends AbstractMaker { - /** - * @param array $ids - */ public function __construct( - private readonly ContainerInterface $container, - private readonly array $ids, + private readonly DecoratorHelper $helper, ) { } @@ -46,7 +45,7 @@ public static function getCommandName(): string public static function getCommandDescription(): string { - return 'Create CRUD for Doctrine entity class'; + return 'Create a decorator of a service'; } public function configureCommand(Command $command, InputConfiguration $inputConfig): void @@ -54,8 +53,13 @@ public function configureCommand(Command $command, InputConfiguration $inputConf $command ->addArgument('id', InputArgument::OPTIONAL, 'The ID of the service to decorate.') ->addArgument('decorator-class', InputArgument::OPTIONAL, \sprintf('The class name of the service to create (e.g. %sDecorator)', Str::asClassName(Str::getRandomTerm()))) + ->addOption('priority', null, InputOption::VALUE_REQUIRED, 'The priority of this decoration when multiple decorators are declared for the same service.') + ->addOption('on-invalid', null, InputOption::VALUE_REQUIRED, 'The behavior to adopt when the decoration is invalid.') ->setHelp($this->getHelpFileContents('MakeDecorator.txt')) ; + + $inputConfig->setArgumentAsNonInteractive('id'); + $inputConfig->setArgumentAsNonInteractive('decorator-class'); } public function configureDependencies(DependencyBuilder $dependencies): void @@ -73,14 +77,36 @@ public function interact(InputInterface $input, ConsoleStyle $io, Command $comma $argument = $command->getDefinition()->getArgument('id'); ($question = new Question($argument->getDescription())) - ->setAutocompleterValues($this->ids) - ->setValidator(fn ($answer) => Validator::serviceExists($answer, $this->ids)) + ->setAutocompleterValues($suggestIds = $this->helper->suggestIds()) + ->setValidator(fn ($answer) => Validator::serviceExists($answer, $suggestIds)) ->setMaxAttempts(3); $input->setArgument('id', $io->askQuestion($question)); } $id = $input->getArgument('id'); + if (null === $realId = $this->helper->getRealId($id)) { + $guessCount = \count($guessRealIds = $this->helper->guessRealIds($id)); + + if (0 === $guessCount) { + throw new RuntimeCommandException(\sprintf('Cannot find nor guess service for given id "%s".', $id)); + } elseif (1 === $guessCount) { + $question = new ConfirmationQuestion(\sprintf('Did you mean "%s" ?', $guessRealIds[0]), true); + + if (!$io->askQuestion($question)) { + throw new RuntimeCommandException(\sprintf('Cannot find nor guess service for given id "%s".', $id)); + } + + $input->setArgument('id', $id = $guessRealIds[0]); + } else { + $input->setArgument( + 'id', + $id = $io->choice(\sprintf('Multiple services found for "%s", choice which one you want to decorate?', $id), $guessRealIds), + ); + } + } else { + $input->setArgument('id', $id = $realId); + } // Ask for decorator classname. if (null === $input->getArgument('decorator-class')) { @@ -111,7 +137,17 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen '', ); - $decoratedInfo = $this->createDecoratorInfo($id, $classNameDetails->getFullName()); + $priority = $input->getOption('priority'); + $onInvalid = $input->getOption('on-invalid'); + + $decoratedInfo = new DecoratorInfo( + $classNameDetails->getFullName(), + $id, + $this->helper->getClass($id), + empty($priority) ? null : $priority, + null === $onInvalid || 1 === $onInvalid ? null : $onInvalid, + ); + $classData = $decoratedInfo->getClassData(); $generator->generateClassFromClassData( @@ -126,16 +162,4 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $this->writeSuccessMessage($io); } - - private function createDecoratorInfo(string $id, string $decoratorClass): DecoratorInfo - { - return new DecoratorInfo( - $decoratorClass, - match (true) { - class_exists($id), interface_exists($id) => $id, - default => $this->container->get($id)::class, - }, - $id, - ); - } } diff --git a/src/Util/ClassSource/Model/ClassData.php b/src/Util/ClassSource/Model/ClassData.php index 68e1a61be..e5dbbfc09 100644 --- a/src/Util/ClassSource/Model/ClassData.php +++ b/src/Util/ClassSource/Model/ClassData.php @@ -183,4 +183,9 @@ public function hasUseStatement(string $className): bool { return $this->useStatementGenerator->hasUseStatement($className); } + + public function hasExtends(): bool + { + return null !== $this->extends; + } } diff --git a/src/Util/DecoratorInfo.php b/src/Util/DecoratorInfo.php index ec1959500..4083f3231 100644 --- a/src/Util/DecoratorInfo.php +++ b/src/Util/DecoratorInfo.php @@ -17,6 +17,7 @@ use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\MethodArgument; use Symfony\Component\DependencyInjection\Attribute\AsDecorator; use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; +use Symfony\Component\DependencyInjection\ContainerInterface; /** * @internal @@ -31,6 +32,8 @@ final class DecoratorInfo private readonly string $decoratedIdDeclaration; + private readonly ?string $onInvalid; + /** * @param class-string $decoratorClassName * @param class-string $decoratedClassOrInterface @@ -39,6 +42,8 @@ public function __construct( private readonly string $decoratorClassName, string $decoratedId, string $decoratedClassOrInterface, + private readonly ?int $priority = null, + ?int $onInvalid = null, ) { $decoratedTypeRef = new \ReflectionClass($decoratedClassOrInterface); @@ -80,6 +85,28 @@ class: $this->decoratorClassName, $this->decoratedIdDeclaration = \sprintf('\'%s\'', $decoratedId); } + if (null === $onInvalid) { + $this->onInvalid = null; + } else { + $this->classData->addUseStatement(ContainerInterface::class); + + $ok = false; + $allowedValues = []; + $ref = new \ReflectionClass(ContainerInterface::class); + foreach ($ref->getConstants(\ReflectionClassConstant::IS_PUBLIC) as $name => $value) { + if ($onInvalid === $value) { + $this->onInvalid = \sprintf('ContainerInterface::%s', $name); + $ok = true; + break; + } + $allowedValues[] = $value; + } + + if (!$ok) { + throw new RuntimeCommandException(\sprintf('Invalid "onInvalid" value "%d", it must be one of %s.', $onInvalid, implode(', ', $allowedValues))); + } + } + // Trigger methods parsing to register methods arguments type in use statement. $this->methods = $this->doGetPublicMethods(); } @@ -97,14 +124,19 @@ public function getClassData(): ClassData return $this->classData; } - public function getDecoratedIdDeclaration(): string + public function getShortNameInnerType(): string { - return $this->decoratedIdDeclaration; + return implode('&', array_map($this->classData->getUseStatementShortName(...), $this->decoratedClassOrInterfaces)); } - public function getShortNameInnerType(): string + public function getDecorateAttributeDeclaration(): string { - return implode('&', array_map($this->classData->getUseStatementShortName(...), $this->decoratedClassOrInterfaces)); + return \sprintf( + '#[AsDecorator(decorates: %s%s%s)]', + $this->decoratedIdDeclaration, + null !== $this->priority ? \sprintf(', priority: %d', $this->priority) : '', + null !== $this->onInvalid ? \sprintf(', onInvalid: %s', $this->onInvalid) : '', + ); } /** @@ -117,7 +149,7 @@ private function doGetPublicMethods(): array $ref = new \ReflectionClass($classOrInterface); foreach ($ref->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { - if ($method->isFinal() || \array_key_exists($method->getName(), $methods)) { + if ($method->isFinal() || \array_key_exists($method->getName(), $methods) || '__construct' === $method->getName()) { continue; } @@ -173,7 +205,7 @@ private function parseType(?\ReflectionType $type): ?string } if ($type instanceof \ReflectionNamedType) { - if (class_exists($type->getName()) || interface_exists($type->getName())) { + if (!$type->isBuiltin()) { $this->classData->addUseStatement($type->getName(), 'Arg'); return $this->classData->getUseStatementShortName($type->getName()); @@ -199,12 +231,22 @@ private static function isClassEquivalentToItsInterfaces(\ReflectionClass $class return false; } - $methodCount = array_sum(array_map( - fn (\ReflectionClass $ref) => \count($ref->getMethods(\ReflectionMethod::IS_PUBLIC)), - $interfaceRefs, - )); + $interfaceMethods = []; + foreach ($interfaceRefs as $ref) { + $methodRefs = $ref->getMethods(\ReflectionMethod::IS_PUBLIC); + foreach ($methodRefs as $methodRef) { + $interfaceMethods[] = $methodRef->getName(); + } + } + + $interfaceMethods = array_unique($interfaceMethods); - return $methodCount === \count($classRef->getMethods(\ReflectionMethod::IS_PUBLIC)); + $classMethodsCount = \count($classRef->getMethods(\ReflectionMethod::IS_PUBLIC)); + if ($classRef->hasMethod('__construct')) { + --$classMethodsCount; + } + + return \count($interfaceMethods) === $classMethodsCount; } private static function isClassEquivalentToItsParentClass(\ReflectionClass $classRef): bool @@ -213,7 +255,16 @@ private static function isClassEquivalentToItsParentClass(\ReflectionClass $clas return false; } - return \count($classRef->getMethods(\ReflectionMethod::IS_PUBLIC)) - === \count($parentClassRef->getMethods(\ReflectionMethod::IS_PUBLIC)); + $classMethodsCount = \count($classRef->getMethods(\ReflectionMethod::IS_PUBLIC)); + if ($classRef->hasMethod('__construct')) { + --$classMethodsCount; + } + + $parentMethodsCount = \count($parentClassRef->getMethods(\ReflectionMethod::IS_PUBLIC)); + if ($parentClassRef->hasMethod('__construct')) { + --$parentMethodsCount; + } + + return $classMethodsCount === $parentMethodsCount; } } diff --git a/templates/decorator/Decorator.tpl.php b/templates/decorator/Decorator.tpl.php index 459b705f8..d7043de76 100644 --- a/templates/decorator/Decorator.tpl.php +++ b/templates/decorator/Decorator.tpl.php @@ -4,7 +4,8 @@ getUseStatements(); ?> -#[AsDecorator(getDecoratedIdDeclaration(); ?>)] +getDecorateAttributeDeclaration(); ?> + getClassDeclaration(); ?> { @@ -18,7 +19,9 @@ public function __construct( getDeclaration() ?> { - isReturnVoid()): ?>return isStatic()) ? 'parent::' : '$this->inner->' ; ?>getName() ?>(getArgumentsUse() ?>); + isStatic() && !$class_data->hasExtends()): ?>// @TODO Implements this static method + + isStatic() && !$class_data->hasExtends()): ?>// isReturnVoid()): ?>return isStatic()) ? 'parent::' : '$this->inner->' ; ?>getName() ?>(getArgumentsUse() ?>); } } diff --git a/tests/DependencyInjection/DecoratorHelperTest.php b/tests/DependencyInjection/DecoratorHelperTest.php new file mode 100644 index 000000000..6e0878e8d --- /dev/null +++ b/tests/DependencyInjection/DecoratorHelperTest.php @@ -0,0 +1,147 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\DependencyInjection; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\MakerBundle\DependencyInjection\DecoratorHelper; +use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceA; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceD; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceE; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceF; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceInterface; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\Sub\ServiceA as SubServiceA; + +class DecoratorHelperTest extends TestCase +{ + public function testSuggestIds() + { + $this->assertSame([ + 'ServiceA', + 'ServiceD', + 'ServiceE', + 'ServiceF', + 'ServiceInterface', + 'bar.service_d', + 'foo.service_e', + ServiceInterface::class, + ServiceF::class, + ServiceA::class, + SubServiceA::class, + ], $this->getHelper()->suggestIds()); + } + + /** @dataProvider realIdsProvider */ + public function testGetRealIds(string $id, ?string $expected) + { + $this->assertSame($expected, $this->getHelper()->getRealId($id)); + } + + public function realIdsProvider(): \Generator + { + yield ['bar.service_d', 'bar.service_d']; + yield ['foo.service_e', 'foo.service_e']; + yield [ServiceInterface::class, ServiceInterface::class]; + yield [ServiceF::class, ServiceF::class]; + yield [ServiceA::class, ServiceA::class]; + yield [SubServiceA::class, SubServiceA::class]; + yield ['ServiceA', null]; + yield ['ServiceD', 'bar.service_d']; + yield ['ServiceE', 'foo.service_e']; + yield ['ServiceF', ServiceF::class]; + yield ['ServiceInterface', ServiceInterface::class]; + yield ['ServiceeeInterface', null]; + yield ['NotExisting', null]; + } + + /** @dataProvider guessRealIdsProvider */ + public function testGuessRealIds(string $id, array $expected) + { + $this->assertSame($expected, $this->getHelper()->guessRealIds($id)); + } + + public function guessRealIdsProvider(): \Generator + { + yield [ + 'ServiceA', + [ + ServiceA::class, + SubServiceA::class, + 'bar.service_d', + 'foo.service_e', + ServiceF::class, + ], + ]; + + yield ['ServiceeeInterface', [ServiceInterface::class]]; + yield ['baar.servicce_d', ['bar.service_d']]; + yield ['baaaaaar.servicce_d', []]; + yield ['NotExisting', []]; + } + + /** @dataProvider classProvider */ + public function testGetClass(string $id, string $expected) + { + $this->assertSame($expected, $this->getHelper()->getClass($id)); + } + + public function classProvider(): \Generator + { + yield ['bar.service_d', ServiceD::class]; + yield ['foo.service_e', ServiceE::class]; + yield [ServiceF::class, ServiceF::class]; + yield [ServiceA::class, ServiceA::class]; + yield [SubServiceA::class, SubServiceA::class]; + } + + public function testInvalidGetClass() + { + $this->expectException(RuntimeCommandException::class); + $this->expectExceptionMessage('Cannot getClass for id "NotExisting".'); + $this->getHelper()->getClass('NotExisting'); + } + + private function getHelper(): DecoratorHelper + { + return new DecoratorHelper( + [ + 'bar.service_d', + 'foo.service_e', + ServiceInterface::class, + ServiceF::class, + ServiceA::class, + SubServiceA::class, + ], [ + 'bar.service_d' => ServiceD::class, + 'foo.service_e' => ServiceE::class, + ServiceInterface::class => ServiceA::class, + ], [ + 'ServiceA' => [ + ServiceA::class, + SubServiceA::class, + ], + 'ServiceD' => [ + 'bar.service_d', + ], + 'ServiceE' => [ + 'foo.service_e', + ], + 'ServiceF' => [ + ServiceF::class, + ], + 'ServiceInterface' => [ + ServiceInterface::class, + ], + ], + ); + } +} diff --git a/tests/Util/DecoratorInfoTest.php b/tests/Util/DecoratorInfoTest.php index da7e3c765..6031a21fd 100644 --- a/tests/Util/DecoratorInfoTest.php +++ b/tests/Util/DecoratorInfoTest.php @@ -181,22 +181,31 @@ class: 'FooBar', ]; } - /** @dataProvider decoratedIdDeclarationProvider */ - public function testGetDecoratedIdDeclaration(string $serviceId, string $decoratedClassOrInterface, string $expected, bool $idAsClassOrInterface) + /** @dataProvider decorateAttributeDeclarationProvider */ + public function testGetDecorateAttributeDeclaration(string $serviceId, string $decoratedClassOrInterface, ?int $priority, ?int $onInvalid, string $expected, bool $idAsClassOrInterface) { - $decoratorInfo = new DecoratorInfo('FooBar', $serviceId, $decoratedClassOrInterface); + $decoratorInfo = new DecoratorInfo('FooBar', $serviceId, $decoratedClassOrInterface, $priority, $onInvalid); - $this->assertSame($expected, $decoratorInfo->getDecoratedIdDeclaration()); + $this->assertSame($expected, $decoratorInfo->getDecorateAttributeDeclaration()); if ($idAsClassOrInterface) { $this->assertTrue($decoratorInfo->getClassData()->hasUseStatement($serviceId)); } } - public function decoratedIdDeclarationProvider(): \Generator + public function decorateAttributeDeclarationProvider(): \Generator { - yield ['foo.bar', ServiceInterface::class, '\'foo.bar\'', false]; - yield [ServiceInterface::class, ServiceInterface::class, 'ServiceInterface::class', true]; + yield ['foo.bar', ServiceInterface::class, null, null, '#[AsDecorator(decorates: \'foo.bar\')]', false]; + yield [ServiceInterface::class, ServiceInterface::class, null, null, '#[AsDecorator(decorates: ServiceInterface::class)]', true]; + yield ['foo.bar', ServiceInterface::class, 50, null, '#[AsDecorator(decorates: \'foo.bar\', priority: 50)]', false]; + yield ['foo.bar', ServiceInterface::class, null, 0, '#[AsDecorator(decorates: \'foo.bar\', onInvalid: ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE)]', false]; + yield ['foo.bar', ServiceInterface::class, 50, 0, '#[AsDecorator(decorates: \'foo.bar\', priority: 50, onInvalid: ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE)]', false]; + } + + public function testInvalidOnInvalid() + { + $this->expectException(RuntimeCommandException::class); + new DecoratorInfo('FooBar', 'foo.bar', ServiceInterface::class, null, -1); } /** @dataProvider shortNameInnerTypeProvider */