From dbbc516f585301e0911feea5bf610707eba555bc Mon Sep 17 00:00:00 2001 From: Benjamin Georgeault Date: Sun, 10 Nov 2024 15:01:44 +0100 Subject: [PATCH] [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()); + } +}