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 = $class_data->getNamespace() ?>;
+
+= $class_data->getUseStatements(); ?>
+
+#[AsDecorator(= $decorated_info->getDecoratedIdDeclaration(); ?>)]
+= $class_data->getClassDeclaration(); ?>
+
+{
+ public function __construct(
+ #[AutowireDecorated]
+ private readonly = $decorated_info->getShortNameInnerType(); ?> $inner,
+ ) {
+ }
+getPublicMethods() as $method): ?>
+
+ = $method->getDeclaration() ?>
+
+ {
+ isReturnVoid()): ?>return = ($method->isStatic()) ? 'parent::' : '$this->inner->' ; ?>getName() ?>(= $method->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());
+ }
+}