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.php b/config/makers.php index 91a0a68cd..cd01cacb2 100644 --- a/config/makers.php +++ b/config/makers.php @@ -15,6 +15,7 @@ use Symfony\Bundle\MakerBundle\Maker\MakeCommand; use Symfony\Bundle\MakerBundle\Maker\MakeController; use Symfony\Bundle\MakerBundle\Maker\MakeCrud; +use Symfony\Bundle\MakerBundle\Maker\MakeDecorator; use Symfony\Bundle\MakerBundle\Maker\MakeDockerDatabase; use Symfony\Bundle\MakerBundle\Maker\MakeEntity; use Symfony\Bundle\MakerBundle\Maker\MakeFixtures; @@ -69,6 +70,10 @@ ]) ->tag('maker.command'); + $services->set('maker.maker.make_decorator', MakeDecorator::class) + ->args([service('maker.decorator_helper')]) + ->tag('maker.command'); + $services->set('maker.maker.make_docker_database', MakeDockerDatabase::class) ->args([service('maker.file_manager')]) ->tag('maker.command'); diff --git a/config/services.php b/config/services.php index 9edf0665e..bd7860cbd 100644 --- a/config/services.php +++ b/config/services.php @@ -12,6 +12,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\Bundle\MakerBundle\Command\MakerCommand; +use Symfony\Bundle\MakerBundle\DependencyInjection\DecoratorHelper; use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper; use Symfony\Bundle\MakerBundle\Doctrine\EntityClassGenerator; use Symfony\Bundle\MakerBundle\Event\ConsoleErrorSubscriber; @@ -61,6 +62,13 @@ service('doctrine')->ignoreOnInvalid(), ]); + $services->set('maker.decorator_helper', DecoratorHelper::class) + ->args([ + null, // Service ids + null, // Map of service id to class + null, // Map of short class name to service ids + ]); + $services->set('maker.auto_command.abstract', MakerCommand::class) ->abstract() ->args([ diff --git a/src/DependencyInjection/CompilerPass/MakeDecoratorPass.php b/src/DependencyInjection/CompilerPass/MakeDecoratorPass.php new file mode 100644 index 000000000..9e95ce0ff --- /dev/null +++ b/src/DependencyInjection/CompilerPass/MakeDecoratorPass.php @@ -0,0 +1,70 @@ + + * + * 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\Bundle\MakerBundle\Str; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * @author Benjamin Georgeault + */ +class MakeDecoratorPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition('maker.decorator_helper')) { + return; + } + + $shortNameMap = []; + $serviceClasses = []; + foreach ($container->getServiceIds() as $id) { + if (str_starts_with($id, '.')) { + continue; + } + + if (str_contains($id, '\\')) { + $shortClass = Str::getShortClassName($id); + $shortNameMap[$shortClass] ??= []; + $shortNameMap[$shortClass][] = $id; + } + + if (!$container->hasDefinition($id)) { + continue; + } + + if ( + (null === $class = $container->getDefinition($id)->getClass()) + || $class === $id + ) { + continue; + } + + $shortClass = Str::getShortClassName($class); + $shortNameMap[$shortClass] ??= []; + $shortNameMap[$shortClass][] = $id; + $serviceClasses[$id] = $class; + } + + foreach ($shortNameMap as $shortClass => $ids) { + $shortNameMap[$shortClass] = array_unique($ids); + } + + $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..52ada85a4 --- /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, true)) { + 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 new file mode 100644 index 000000000..e48c56fa4 --- /dev/null +++ b/src/Maker/MakeDecorator.php @@ -0,0 +1,165 @@ + + * + * 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 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; +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\Input\InputOption; +use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Question\Question; +use Symfony\Component\DependencyInjection\Attribute\AsDecorator; + +/** + * @author Benjamin Georgeault + */ +final class MakeDecorator extends AbstractMaker +{ + public function __construct( + private readonly DecoratorHelper $helper, + ) { + } + + public static function getCommandName(): string + { + return 'make:decorator'; + } + + public static function getCommandDescription(): string + { + return 'Create a decorator of a service'; + } + + 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()))) + ->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 + { + $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($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", choose which one you want to decorate:', $id), $guessRealIds), + ); + } + } else { + $input->setArgument('id', $id = $realId); + } + + // 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'))), + '', + ); + + $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( + $classData, + 'decorator/Decorator.tpl.php', + [ + 'decorated_info' => $decoratedInfo, + ], + ); + + $generator->writeChanges(); + + $this->writeSuccessMessage($io); + } +} diff --git a/src/MakerBundle.php b/src/MakerBundle.php index 2b2aed165..9a3dbceac 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; @@ -77,6 +78,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..e5dbbfc09 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,19 @@ 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); + } + + public function hasExtends(): bool + { + return null !== $this->extends; + } } diff --git a/src/Util/ClassSource/Model/ClassMethod.php b/src/Util/ClassSource/Model/ClassMethod.php new file mode 100644 index 000000000..5f024a334 --- /dev/null +++ b/src/Util/ClassSource/Model/ClassMethod.php @@ -0,0 +1,64 @@ + + * + * 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->isVariadic() ? '...' : '').$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..b63d1b598 --- /dev/null +++ b/src/Util/ClassSource/Model/MethodArgument.php @@ -0,0 +1,48 @@ + + * + * 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, + private readonly bool $isVariadic = false, + ) { + } + + public function getDeclaration(): string + { + return ($this->type ?? ''). + ($this->type ? ' ' : ''). + ($this->isVariadic ? '...' : ''). + $this->getVariable(). + ($this->isVariadic || !$this->default ? '' : ' = '.$this->default) + ; + } + + public function isVariadic(): bool + { + return $this->isVariadic; + } + + 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..c5aec060a --- /dev/null +++ b/src/Util/DecoratorInfo.php @@ -0,0 +1,268 @@ + + * + * 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; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * @internal + */ +final class DecoratorInfo +{ + private readonly ClassData $classData; + + private readonly array $methods; + + private readonly array $decoratedClassOrInterfaces; + + private readonly string $decoratedIdDeclaration; + + private readonly ?string $onInvalid; + + /** + * @param class-string $decoratorClassName + * @param class-string $decoratedClassOrInterface + */ + public function __construct( + private readonly string $decoratorClassName, + string $decoratedId, + string $decoratedClassOrInterface, + private readonly ?int $priority = null, + ?int $onInvalid = null, + ) { + $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); + } + + if (null === $onInvalid) { + $this->onInvalid = null; + } else { + $this->classData->addUseStatement(ContainerInterface::class); + + $onInvalidConstants = [ + 'EXCEPTION_ON_INVALID_REFERENCE' => ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, + 'NULL_ON_INVALID_REFERENCE' => ContainerInterface::NULL_ON_INVALID_REFERENCE, + 'IGNORE_ON_INVALID_REFERENCE' => ContainerInterface::IGNORE_ON_INVALID_REFERENCE, + 'RUNTIME_EXCEPTION_ON_INVALID_REFERENCE' => ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE, + ]; + + if (false === $name = array_search($onInvalid, $onInvalidConstants, true)) { + throw new RuntimeCommandException(\sprintf('Invalid "onInvalid" value "%d", it must be one of "%s".', $onInvalid, implode(', ', $onInvalidConstants))); + } + + $this->onInvalid = \sprintf('ContainerInterface::%s', $name); + } + + // 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 getShortNameInnerType(): string + { + return implode('&', array_map($this->classData->getUseStatementShortName(...), $this->decoratedClassOrInterfaces)); + } + + public function getDecorateAttributeDeclaration(): string + { + 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) : '', + ); + } + + /** + * @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) || '__construct' === $method->getName()) { + 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->isVariadic() && $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'; + } + } + } + + yield new MethodArgument( + $parameter->getName(), + $this->parseType($parameter->getType()), + $default, + $parameter->isVariadic(), + ); + } + } + + private function parseType(?\ReflectionType $type): ?string + { + if (null === $type) { + return null; + } + + if ($type instanceof \ReflectionNamedType) { + if (!$type->isBuiltin()) { + $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 reached.'); + } + + private static function isClassEquivalentToItsInterfaces(\ReflectionClass $classRef): bool + { + if (empty($interfaceRefs = $classRef->getInterfaces())) { + return false; + } + + $interfaceMethods = []; + foreach ($interfaceRefs as $ref) { + $methodRefs = $ref->getMethods(\ReflectionMethod::IS_PUBLIC); + foreach ($methodRefs as $methodRef) { + $interfaceMethods[] = $methodRef->getName(); + } + } + + $interfaceMethods = array_unique($interfaceMethods); + + $classMethodsCount = \count($classRef->getMethods(\ReflectionMethod::IS_PUBLIC)); + if ($classRef->hasMethod('__construct')) { + --$classMethodsCount; + } + + return \count($interfaceMethods) === $classMethodsCount; + } + + private static function isClassEquivalentToItsParentClass(\ReflectionClass $classRef): bool + { + if (false === $parentClassRef = $classRef->getParentClass()) { + return false; + } + + $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/src/Util/UseStatementGenerator.php b/src/Util/UseStatementGenerator.php index 41773a7c0..2e695b7cb 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..d7043de76 --- /dev/null +++ b/templates/decorator/Decorator.tpl.php @@ -0,0 +1,27 @@ + + +namespace getNamespace() ?>; + +getUseStatements(); ?> + +getDecorateAttributeDeclaration(); ?> + +getClassDeclaration(); ?> + +{ + public function __construct( + #[AutowireDecorated] + private readonly getShortNameInnerType(); ?> $inner, + ) { + } +getPublicMethods() as $method): ?> + + getDeclaration() ?> + + { + 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/Command/MakerCommandTest.php b/tests/Command/MakerCommandTest.php index 1b5e130eb..8519800a3 100644 --- a/tests/Command/MakerCommandTest.php +++ b/tests/Command/MakerCommandTest.php @@ -22,7 +22,7 @@ class MakerCommandTest extends TestCase { - public function testExceptionOnMissingDependencies(): void + public function testExceptionOnMissingDependencies() { $this->expectException(RuntimeCommandException::class); // @phpstan-ignore function.alreadyNarrowedType @@ -47,7 +47,7 @@ public function testExceptionOnMissingDependencies(): void $tester->execute([]); } - public function testExceptionOnUnknownRootNamespace(): void + public function testExceptionOnUnknownRootNamespace() { $maker = $this->createMock(MakerInterface::class); diff --git a/tests/DependencyInjection/DecoratorHelperTest.php b/tests/DependencyInjection/DecoratorHelperTest.php new file mode 100644 index 000000000..8b3620e47 --- /dev/null +++ b/tests/DependencyInjection/DecoratorHelperTest.php @@ -0,0 +1,152 @@ + + * + * 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\FinalServiceExtendingParent; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceImplementingInterface; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceImplementingTwoInterfaces; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceInterface; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceOverridingParentMethod; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\Sub\ServiceImplementingInterface as SubServiceImplementingInterface; + +class DecoratorHelperTest extends TestCase +{ + public function testSuggestIds() + { + $this->assertSame([ + 'ServiceImplementingInterface', + 'ServiceOverridingParentMethod', + 'FinalServiceExtendingParent', + 'ServiceImplementingTwoInterfaces', + 'ServiceInterface', + 'bar.service_d', + 'foo.service_e', + ServiceInterface::class, + ServiceImplementingTwoInterfaces::class, + ServiceImplementingInterface::class, + SubServiceImplementingInterface::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 [ServiceImplementingTwoInterfaces::class, ServiceImplementingTwoInterfaces::class]; + yield [ServiceImplementingInterface::class, ServiceImplementingInterface::class]; + yield [SubServiceImplementingInterface::class, SubServiceImplementingInterface::class]; + yield ['ServiceImplementingInterface', null]; + yield ['ServiceOverridingParentMethod', 'bar.service_d']; + yield ['FinalServiceExtendingParent', 'foo.service_e']; + yield ['ServiceImplementingTwoInterfaces', ServiceImplementingTwoInterfaces::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 [ + 'ServiceImplementingInterface', + [ + ServiceImplementingInterface::class, + SubServiceImplementingInterface::class, + ], + ]; + + yield [ + 'ServiceImplementingInterfacee', + [ + ServiceImplementingInterface::class, + SubServiceImplementingInterface::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', ServiceOverridingParentMethod::class]; + yield ['foo.service_e', FinalServiceExtendingParent::class]; + yield [ServiceImplementingTwoInterfaces::class, ServiceImplementingTwoInterfaces::class]; + yield [ServiceImplementingInterface::class, ServiceImplementingInterface::class]; + yield [SubServiceImplementingInterface::class, SubServiceImplementingInterface::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, + ServiceImplementingTwoInterfaces::class, + ServiceImplementingInterface::class, + SubServiceImplementingInterface::class, + ], [ + 'bar.service_d' => ServiceOverridingParentMethod::class, + 'foo.service_e' => FinalServiceExtendingParent::class, + ServiceInterface::class => ServiceImplementingInterface::class, + ], [ + 'ServiceImplementingInterface' => [ + ServiceImplementingInterface::class, + SubServiceImplementingInterface::class, + ], + 'ServiceOverridingParentMethod' => [ + 'bar.service_d', + ], + 'FinalServiceExtendingParent' => [ + 'foo.service_e', + ], + 'ServiceImplementingTwoInterfaces' => [ + ServiceImplementingTwoInterfaces::class, + ], + 'ServiceInterface' => [ + ServiceInterface::class, + ], + ], + ); + } +} diff --git a/tests/DependencyInjection/Fixtures/FinalServiceExtendingParent.php b/tests/DependencyInjection/Fixtures/FinalServiceExtendingParent.php new file mode 100644 index 000000000..8c7c59348 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/FinalServiceExtendingParent.php @@ -0,0 +1,17 @@ + + * + * 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 inheriting its interface from a parent: the decorator implements that interface. +final class FinalServiceExtendingParent extends ServiceImplementingInterface +{ +} diff --git a/tests/DependencyInjection/Fixtures/FinalServiceWithoutParent.php b/tests/DependencyInjection/Fixtures/FinalServiceWithoutParent.php new file mode 100644 index 000000000..c14dd901a --- /dev/null +++ b/tests/DependencyInjection/Fixtures/FinalServiceWithoutParent.php @@ -0,0 +1,17 @@ + + * + * 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 with no interface and no parent: cannot be decorated. +final class FinalServiceWithoutParent +{ +} diff --git a/tests/DependencyInjection/Fixtures/OtherServiceInterface.php b/tests/DependencyInjection/Fixtures/OtherServiceInterface.php new file mode 100644 index 000000000..ad4e7b950 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/OtherServiceInterface.php @@ -0,0 +1,17 @@ + + * + * 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; + +// Empty second interface, used to test a service implementing several interfaces. +interface OtherServiceInterface +{ +} diff --git a/tests/DependencyInjection/Fixtures/ServiceImplementingInterface.php b/tests/DependencyInjection/Fixtures/ServiceImplementingInterface.php new file mode 100644 index 000000000..a3ebb517e --- /dev/null +++ b/tests/DependencyInjection/Fixtures/ServiceImplementingInterface.php @@ -0,0 +1,26 @@ + + * + * 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; + +// Plain implementation of a single interface: the decorator implements the interface. +class ServiceImplementingInterface 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/ServiceImplementingTwoInterfaces.php b/tests/DependencyInjection/Fixtures/ServiceImplementingTwoInterfaces.php new file mode 100644 index 000000000..1aa63700a --- /dev/null +++ b/tests/DependencyInjection/Fixtures/ServiceImplementingTwoInterfaces.php @@ -0,0 +1,26 @@ + + * + * 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; + +// Implements two interfaces: the decorator implements both and types the inner as an intersection. +final class ServiceImplementingTwoInterfaces 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..cd85ff0ad --- /dev/null +++ b/tests/DependencyInjection/Fixtures/ServiceInterface.php @@ -0,0 +1,22 @@ + + * + * 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; + +// Base interface with a constant used as a default argument value. +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/ServiceOverridingParentMethod.php b/tests/DependencyInjection/Fixtures/ServiceOverridingParentMethod.php new file mode 100644 index 000000000..f906d8201 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/ServiceOverridingParentMethod.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; + +// Overrides a parent method without adding any: the decorator implements the inherited interface. +class ServiceOverridingParentMethod extends ServiceImplementingInterface +{ + public function getDefault(string $mode = self::MODE_FOO): ?string + { + return parent::getDefault($mode); + } +} diff --git a/tests/DependencyInjection/Fixtures/ServiceWithExtraMethods.php b/tests/DependencyInjection/Fixtures/ServiceWithExtraMethods.php new file mode 100644 index 000000000..e0ed81dd6 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/ServiceWithExtraMethods.php @@ -0,0 +1,31 @@ + + * + * 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; + +// Adds public and static methods not declared in the interface: the decorator extends the class. +class ServiceWithExtraMethods extends ServiceImplementingInterface +{ + 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/ServiceWithVariadicMethod.php b/tests/DependencyInjection/Fixtures/ServiceWithVariadicMethod.php new file mode 100644 index 000000000..fa366adfa --- /dev/null +++ b/tests/DependencyInjection/Fixtures/ServiceWithVariadicMethod.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; + +// Method with a variadic argument, to check the generated signature and forwarding call. +class ServiceWithVariadicMethod +{ + public function logMessages(string $prefix, string ...$messages): void + { + } +} diff --git a/tests/DependencyInjection/Fixtures/ServiceWithoutInterface.php b/tests/DependencyInjection/Fixtures/ServiceWithoutInterface.php new file mode 100644 index 000000000..cefa15509 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/ServiceWithoutInterface.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; + +// Class without interface nor parent: the decorator extends the class. +class ServiceWithoutInterface +{ + public function getFoo(): string + { + return 'foo'; + } +} diff --git a/tests/DependencyInjection/Fixtures/Sub/ServiceImplementingInterface.php b/tests/DependencyInjection/Fixtures/Sub/ServiceImplementingInterface.php new file mode 100644 index 000000000..a6b91f776 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/Sub/ServiceImplementingInterface.php @@ -0,0 +1,28 @@ + + * + * 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; + +// Same short name as the parent namespace class, to test use statement aliasing. +class ServiceImplementingInterface 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/ServiceOverridingParentMethod.php b/tests/DependencyInjection/Fixtures/Sub/ServiceOverridingParentMethod.php new file mode 100644 index 000000000..06a864c17 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/Sub/ServiceOverridingParentMethod.php @@ -0,0 +1,22 @@ + + * + * 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\ServiceOverridingParentMethod as BaseService; + +// Adds a method to an aliased parent of the same short name, to test alias generation. +class ServiceOverridingParentMethod extends BaseService +{ + public function blabla(): void + { + } +} diff --git a/tests/DependencyInjection/Fixtures/Sub/ServiceWithExtraMethods.php b/tests/DependencyInjection/Fixtures/Sub/ServiceWithExtraMethods.php new file mode 100644 index 000000000..878b0e52f --- /dev/null +++ b/tests/DependencyInjection/Fixtures/Sub/ServiceWithExtraMethods.php @@ -0,0 +1,19 @@ + + * + * 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\ServiceWithExtraMethods as BaseService; + +// Extends an aliased class of the same short name, to test alias generation. +class ServiceWithExtraMethods extends BaseService +{ +} diff --git a/tests/Maker/MakeDecoratorTest.php b/tests/Maker/MakeDecoratorTest.php new file mode 100644 index 000000000..745f65ba4 --- /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; + } + + private static function buildDecoratorTest(): MakerTestDetails + { + return self::buildMakerTest() + ->preRun(static 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 static function getTestDetails(): \Generator + { + yield 'it_generates_basic_implements' => [self::buildDecoratorTest() + ->run(static function (MakerTestRunner $runner) { + $runner->runMaker([ + 'App\\Service\\FooService', + 'GeneratedServiceDecorator', + ]); + + self::runFormTest($runner, 'it_generates_basic_implements.php'); + }), + ]; + + yield 'it_generates_multiple_implements' => [self::buildDecoratorTest() + ->run(static function (MakerTestRunner $runner) { + $runner->runMaker([ + 'App\\Service\\MultipleImpService', + 'GeneratedServiceDecorator', + ]); + + self::runFormTest($runner, 'it_generates_multiple_implements.php'); + }), + ]; + + yield 'it_generates_force_extends' => [self::buildDecoratorTest() + ->run(static function (MakerTestRunner $runner) { + $runner->runMaker([ + 'App\\Service\\ForExtendService', + 'GeneratedServiceDecorator', + ]); + + self::runFormTest($runner, 'it_generates_force_extends.php'); + }), + ]; + } + + private static 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 a7ccbf3c8..5c02fa4c8 100644 --- a/tests/Util/ClassSource/ClassDataTest.php +++ b/tests/Util/ClassSource/ClassDataTest.php @@ -13,7 +13,9 @@ use PHPUnit\Framework\Attributes\DataProvider; 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; @@ -147,4 +149,24 @@ public static 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..d1d99065a --- /dev/null +++ b/tests/Util/ClassSource/ClassMethodTest.php @@ -0,0 +1,132 @@ + + * + * 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) + { + $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', + ]; + + yield [ + [ + new MethodArgument('toto', 'array'), + new MethodArgument('args', 'string', isVariadic: true), + ], + null, + false, + 'public function foobar(array $toto, string ...$args)', + ]; + } + + /** @dataProvider argumentsUsesProvider */ + public function testGetArgumentsUse(array $args, string $expectedDeclaration) + { + $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', + ]; + + yield [ + [ + new MethodArgument('toto', 'array'), + new MethodArgument('args', 'string', isVariadic: true), + ], + '$toto, ...$args', + ]; + } +} diff --git a/tests/Util/ClassSource/MethodArgumentTest.php b/tests/Util/ClassSource/MethodArgumentTest.php new file mode 100644 index 000000000..583020304 --- /dev/null +++ b/tests/Util/ClassSource/MethodArgumentTest.php @@ -0,0 +1,82 @@ + + * + * 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, bool $isVariadic, string $expected) + { + $methodArgument = new MethodArgument('foo', $type, $default, $isVariadic); + + $this->assertSame($expected, $methodArgument->getDeclaration()); + } + + public function declarationsProvider(): \Generator + { + yield [ + null, + null, + false, + '$foo', + ]; + + yield [ + 'string', + '\'foobar\'', + false, + 'string $foo = \'foobar\'', + ]; + + yield [ + 'string', + null, + false, + 'string $foo', + ]; + + yield [ + 'string', + null, + true, + 'string ...$foo', + ]; + + yield [ + null, + null, + true, + '...$foo', + ]; + } + + public function testIsVariadic() + { + $this->assertFalse((new MethodArgument('foo'))->isVariadic()); + $this->assertTrue((new MethodArgument('foo', isVariadic: true))->isVariadic()); + } + + public function testGetVariable() + { + $methodArgument = new MethodArgument('foo'); + + $this->assertSame('$foo', $methodArgument->getVariable()); + } +} diff --git a/tests/Util/ClassSourceManipulatorTest.php b/tests/Util/ClassSourceManipulatorTest.php index be9a3d5d0..a163c07e5 100644 --- a/tests/Util/ClassSourceManipulatorTest.php +++ b/tests/Util/ClassSourceManipulatorTest.php @@ -875,7 +875,7 @@ public function testAddConstructorInClassContainsConstructor() * @requires PHP >= 8.4 */ #[\PHPUnit\Framework\Attributes\RequiresPhp('>= 8.4')] - public function testParsingPhp84PropertyHooks(): void + public function testParsingPhp84PropertyHooks() { $source = file_get_contents(__DIR__.'/fixtures/source/User_property_hooks.php'); @@ -890,7 +890,7 @@ public function testParsingPhp84PropertyHooks(): void * @requires PHP >= 8.4 */ #[\PHPUnit\Framework\Attributes\RequiresPhp('>= 8.4')] - public function testAddPropertyToClassWithPropertyHooks(): void + public function testAddPropertyToClassWithPropertyHooks() { $source = file_get_contents(__DIR__.'/fixtures/source/User_property_hooks.php'); diff --git a/tests/Util/DecoratorInfoTest.php b/tests/Util/DecoratorInfoTest.php new file mode 100644 index 000000000..8667f5753 --- /dev/null +++ b/tests/Util/DecoratorInfoTest.php @@ -0,0 +1,296 @@ + + * + * 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\FinalServiceExtendingParent; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\FinalServiceWithoutParent; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\OtherServiceInterface; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceImplementingInterface; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceImplementingTwoInterfaces; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceInterface; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceOverridingParentMethod; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceWithExtraMethods; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceWithoutInterface; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\ServiceWithVariadicMethod; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\Sub\ServiceImplementingInterface as SubServiceImplementingInterface; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\Sub\ServiceOverridingParentMethod as SubServiceOverridingParentMethod; +use Symfony\Bundle\MakerBundle\Tests\DependencyInjection\Fixtures\Sub\ServiceWithExtraMethods as SubServiceWithExtraMethods; +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\FinalServiceWithoutParent", its class does not have any interface, parent class and its final.'); + new DecoratorInfo('FooBar', 'foo.bar', FinalServiceWithoutParent::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 [ServiceImplementingInterface::class, ['getName', 'getDefault']]; + yield [ServiceWithExtraMethods::class, ['getName', 'getFoo', 'getStaticValue', 'getDefault']]; + yield [ServiceWithoutInterface::class, ['getFoo']]; + yield [ServiceOverridingParentMethod::class, ['getName', 'getDefault']]; + yield [FinalServiceExtendingParent::class, ['getName', 'getDefault']]; + yield [ServiceImplementingTwoInterfaces::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', + ServiceImplementingInterface::class, + ClassData::create( + class: 'FooBar', + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ], + implements: [ServiceInterface::class], + ), + ]; + + yield [ + 'foo.bar', + ServiceWithExtraMethods::class, + ClassData::create( + class: 'FooBar', + extendsClass: ServiceWithExtraMethods::class, + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ServiceWithExtraMethods::class, + ServiceInterface::class, + ], + ), + ]; + + yield [ + 'foo.bar', + ServiceWithoutInterface::class, + ClassData::create( + class: 'FooBar', + extendsClass: ServiceWithoutInterface::class, + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ], + ), + ]; + + yield [ + 'foo.bar', + ServiceOverridingParentMethod::class, + ClassData::create( + class: 'FooBar', + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ], + implements: [ServiceInterface::class], + ), + ]; + + yield [ + 'foo.bar', + FinalServiceExtendingParent::class, + ClassData::create( + class: 'FooBar', + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ], + implements: [ServiceInterface::class], + ), + ]; + + yield [ + 'foo.bar', + ServiceImplementingTwoInterfaces::class, + ClassData::create( + class: 'FooBar', + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ], + implements: [ + ServiceInterface::class, + OtherServiceInterface::class, + ], + ), + ]; + + yield [ + ServiceInterface::class, + ServiceImplementingTwoInterfaces::class, + ClassData::create( + class: 'FooBar', + useStatements: [ + AsDecorator::class, + AutowireDecorated::class, + ], + implements: [ + ServiceInterface::class, + OtherServiceInterface::class, + ], + ), + ]; + } + + /** @dataProvider decorateAttributeDeclarationProvider */ + public function testGetDecorateAttributeDeclaration(string $serviceId, string $decoratedClassOrInterface, ?int $priority, ?int $onInvalid, string $expected, bool $idAsClassOrInterface) + { + $decoratorInfo = new DecoratorInfo('FooBar', $serviceId, $decoratedClassOrInterface, $priority, $onInvalid); + + $this->assertSame($expected, $decoratorInfo->getDecorateAttributeDeclaration()); + + if ($idAsClassOrInterface) { + $this->assertTrue($decoratorInfo->getClassData()->hasUseStatement($serviceId)); + } + } + + public function decorateAttributeDeclarationProvider(): \Generator + { + 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 */ + 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 [ServiceImplementingInterface::class, 'ServiceInterface', [ServiceInterface::class]]; + yield [ServiceWithExtraMethods::class, 'ServiceWithExtraMethods', [ServiceWithExtraMethods::class]]; + yield [ServiceWithoutInterface::class, 'ServiceWithoutInterface', [ServiceWithoutInterface::class]]; + yield [ServiceOverridingParentMethod::class, 'ServiceInterface', [ServiceInterface::class]]; + yield [FinalServiceExtendingParent::class, 'ServiceInterface', [ServiceInterface::class]]; + yield [ServiceImplementingTwoInterfaces::class, 'ServiceInterface&OtherServiceInterface', [ServiceInterface::class, OtherServiceInterface::class]]; + } + + public function testGetPublicMethodsWithVariadicArgument() + { + $decoratorInfo = new DecoratorInfo('FooBar', 'foo.bar', ServiceWithVariadicMethod::class); + + $method = $decoratorInfo->getPublicMethods()['logMessages']; + + $this->assertSame( + 'public function logMessages(string $prefix, string ...$messages): void', + $method->getDeclaration(), + ); + $this->assertSame('$prefix, ...$messages', $method->getArgumentsUse()); + } + + /** @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\\ServiceImplementingInterface', + SubServiceImplementingInterface::class, + SubServiceImplementingInterface::class, + [ + SubServiceImplementingInterface::class => 'ServiceServiceImplementingInterface', + ], + ]; + + yield [ + 'TheService\\ServiceWithExtraMethods', + ServiceWithExtraMethods::class, + ServiceWithExtraMethods::class, + [ + ServiceWithExtraMethods::class => 'BaseServiceWithExtraMethods', + ], + ]; + + yield [ + 'TheService\\ServiceWithExtraMethods', + SubServiceWithExtraMethods::class, + SubServiceWithExtraMethods::class, + [ + ServiceWithExtraMethods::class => 'BaseServiceWithExtraMethods', + ], + ]; + + yield [ + 'TheService\\ServiceOverridingParentMethod', + SubServiceOverridingParentMethod::class, + SubServiceOverridingParentMethod::class, + [ + SubServiceOverridingParentMethod::class => 'BaseServiceOverridingParentMethod', + ], + ]; + } +} diff --git a/tests/Util/UseStatementGeneratorTest.php b/tests/Util/UseStatementGeneratorTest.php index bb1d11faf..5cbfff532 100644 --- a/tests/Util/UseStatementGeneratorTest.php +++ b/tests/Util/UseStatementGeneratorTest.php @@ -106,4 +106,32 @@ public function testUseStatementsWithDuplicates() 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/Util/YamlSourceManipulatorTest.php b/tests/Util/YamlSourceManipulatorTest.php index 1c50cecad..45ddb6c8e 100644 --- a/tests/Util/YamlSourceManipulatorTest.php +++ b/tests/Util/YamlSourceManipulatorTest.php @@ -27,7 +27,7 @@ class YamlSourceManipulatorTest extends TestCase */ #[DataProvider('getYamlDataTestsUnixSlashes')] #[DataProvider('getYamlDataTestsWindowsSlashes')] - public function testSetData(string $startingSource, array $newData, string $expectedSource): void + public function testSetData(string $startingSource, array $newData, string $expectedSource) { $manipulator = new YamlSourceManipulator($startingSource); 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()); + } +}