From 9975de266bf866f92a789e0d5f083c6a6c3ae028 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 7 Mar 2023 17:04:33 +0100 Subject: [PATCH] [DependencyInjection] Make it possible to cast callables into single-method interfaces --- .../Argument/LazyClosure.php | 43 ++++++++-- .../Attribute/AutowireCallable.php | 5 +- .../DependencyInjection/CHANGELOG.md | 1 + .../Compiler/AutowirePass.php | 6 +- .../DependencyInjection/ContainerBuilder.php | 9 +-- .../DependencyInjection/Dumper/PhpDumper.php | 81 +++++++++---------- .../Configurator/FromCallableConfigurator.php | 47 +++++++++++ .../Configurator/ServiceConfigurator.php | 1 + .../Configurator/Traits/FromCallableTrait.php | 64 +++++++++++++++ .../Loader/XmlFileLoader.php | 46 +++++++++++ .../Loader/YamlFileLoader.php | 16 ++++ .../schema/dic/services/services-1.0.xsd | 1 + .../Tests/ContainerBuilderTest.php | 16 ++++ .../Tests/Dumper/PhpDumperTest.php | 52 ++++++++++++ .../config/from_callable.expected.yml | 12 +++ .../Tests/Fixtures/config/from_callable.php | 14 ++++ .../Fixtures/includes/autowiring_classes.php | 5 ++ .../Tests/Fixtures/includes/classes.php | 5 +- .../php/callable_adapter_consumer.php | 57 +++++++++++++ .../Tests/Fixtures/php/closure_proxy.php | 62 ++++++++++++++ .../Tests/Fixtures/xml/from_callable.xml | 8 ++ .../Tests/Fixtures/yaml/from_callable.yml | 4 + .../Tests/Loader/PhpFileLoaderTest.php | 3 + .../Tests/Loader/XmlFileLoaderTest.php | 10 +++ .../Tests/Loader/YamlFileLoaderTest.php | 10 +++ 25 files changed, 518 insertions(+), 60 deletions(-) create mode 100644 src/Symfony/Component/DependencyInjection/Loader/Configurator/FromCallableConfigurator.php create mode 100644 src/Symfony/Component/DependencyInjection/Loader/Configurator/Traits/FromCallableTrait.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/from_callable.expected.yml create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/from_callable.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/callable_adapter_consumer.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/closure_proxy.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/from_callable.xml create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/from_callable.yml diff --git a/src/Symfony/Component/DependencyInjection/Argument/LazyClosure.php b/src/Symfony/Component/DependencyInjection/Argument/LazyClosure.php index 7b001352ac8bd..6324f021afadf 100644 --- a/src/Symfony/Component/DependencyInjection/Argument/LazyClosure.php +++ b/src/Symfony/Component/DependencyInjection/Argument/LazyClosure.php @@ -11,8 +11,11 @@ namespace Symfony\Component\DependencyInjection\Argument; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Exception\RuntimeException; +use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\VarExporter\ProxyHelper; /** @@ -44,17 +47,43 @@ public function __get(mixed $name): mixed return $this->service; } - public static function getCode(string $initializer, ?\ReflectionClass $r, string $method, ?string $id): string + public static function getCode(string $initializer, array $callable, Definition $definition, ContainerBuilder $container, ?string $id): string { - if (!$r || !$r->hasMethod($method)) { + $method = $callable[1]; + $asClosure = 'Closure' === ($definition->getClass() ?: 'Closure'); + + if ($asClosure) { + $class = ($callable[0] instanceof Reference ? $container->findDefinition($callable[0]) : $callable[0])->getClass(); + } else { + $class = $definition->getClass(); + } + + $r = $container->getReflectionClass($class); + + if (!$asClosure) { + if (!$r || !$r->isInterface()) { + throw new RuntimeException(sprintf('Cannot create adapter for service "%s" because "%s" is not an interface.', $id, $class)); + } + if (1 !== \count($method = $r->getMethods())) { + throw new RuntimeException(sprintf('Cannot create adapter for service "%s" because interface "%s" doesn\'t have exactly one method.', $id, $class)); + } + $method = $method[0]->name; + } elseif (!$r || !$r->hasMethod($method)) { throw new RuntimeException(sprintf('Cannot create lazy closure for service "%s" because its corresponding callable is invalid.', $id)); } - $signature = ProxyHelper::exportSignature($r->getMethod($method)); - $signature = preg_replace('/: static$/', ': \\'.$r->name, $signature); + $code = ProxyHelper::exportSignature($r->getMethod($method)); + + if ($asClosure) { + $code = ' { '.preg_replace('/: static$/', ': \\'.$r->name, $code); + } else { + $code = ' implements \\'.$r->name.' { '.$code; + } + + $code = 'new class('.$initializer.') extends \\'.self::class + .$code.' { return $this->service->'.$callable[1].'(...\func_get_args()); } ' + .'}'; - return '(new class('.$initializer.') extends \\'.self::class.' { ' - .$signature.' { return $this->service->'.$method.'(...\func_get_args()); } ' - .'})->'.$method.'(...)'; + return $asClosure ? '('.$code.')->'.$method.'(...)' : $code; } } diff --git a/src/Symfony/Component/DependencyInjection/Attribute/AutowireCallable.php b/src/Symfony/Component/DependencyInjection/Attribute/AutowireCallable.php index 08fdc6e6904aa..c4a7632fa45d7 100644 --- a/src/Symfony/Component/DependencyInjection/Attribute/AutowireCallable.php +++ b/src/Symfony/Component/DependencyInjection/Attribute/AutowireCallable.php @@ -20,11 +20,14 @@ #[\Attribute(\Attribute::TARGET_PARAMETER)] class AutowireCallable extends Autowire { + /** + * @param bool|class-string $lazy Whether to use lazy-loading for this argument + */ public function __construct( string|array $callable = null, string $service = null, string $method = null, - bool $lazy = false, + bool|string $lazy = false, ) { if (!(null !== $callable xor null !== $service)) { throw new LogicException('#[AutowireCallable] attribute must declare exactly one of $callable or $service.'); diff --git a/src/Symfony/Component/DependencyInjection/CHANGELOG.md b/src/Symfony/Component/DependencyInjection/CHANGELOG.md index d61830ef742c7..b92ec95897b85 100644 --- a/src/Symfony/Component/DependencyInjection/CHANGELOG.md +++ b/src/Symfony/Component/DependencyInjection/CHANGELOG.md @@ -18,6 +18,7 @@ CHANGELOG * Add support for generating lazy closures * Add support for autowiring services as closures using `#[AutowireCallable]` or `#[AutowireServiceClosure]` * Add support for `#[Autowire(lazy: true|class-string)]` + * Make it possible to cast callables into single-method interfaces * Deprecate `#[MapDecorated]`, use `#[AutowireDecorated]` instead * Deprecate the `@required` annotation, use the `Symfony\Contracts\Service\Attribute\Required` attribute instead diff --git a/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php b/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php index dd5900bbefddf..a68d19ea30049 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php @@ -291,10 +291,10 @@ private function autowireMethod(\ReflectionFunctionAbstract $reflectionMethod, a $value = $this->processValue(new TypedReference($type ?: '?', $type ?: 'mixed', $invalidBehavior, $name, [$attribute, ...$target])); if ($attribute instanceof AutowireCallable) { - $value = (new Definition('Closure')) + $value = (new Definition($type = \is_string($attribute->lazy) ? $attribute->lazy : ($type ?: 'Closure'))) ->setFactory(['Closure', 'fromCallable']) - ->setArguments([$value + [1 => '__invoke']]) - ->setLazy($attribute->lazy); + ->setArguments([\is_array($value) ? $value + [1 => '__invoke'] : $value]) + ->setLazy($attribute->lazy || 'Closure' !== $type && 'callable' !== (string) $parameter->getType()); } elseif ($lazy = $attribute->lazy) { $definition = (new Definition($type)) ->setFactory('current') diff --git a/src/Symfony/Component/DependencyInjection/ContainerBuilder.php b/src/Symfony/Component/DependencyInjection/ContainerBuilder.php index 57c0234725f7c..ca696673601ff 100644 --- a/src/Symfony/Component/DependencyInjection/ContainerBuilder.php +++ b/src/Symfony/Component/DependencyInjection/ContainerBuilder.php @@ -1051,9 +1051,9 @@ private function createService(Definition $definition, array &$inlineServices, b } $parameterBag = $this->getParameterBag(); - $class = ($parameterBag->resolveValue($definition->getClass()) ?: (['Closure', 'fromCallable'] === $definition->getFactory() ? 'Closure' : null)); + $class = $parameterBag->resolveValue($definition->getClass()) ?: (['Closure', 'fromCallable'] === $definition->getFactory() ? 'Closure' : null); - if ('Closure' === $class && $definition->isLazy() && ['Closure', 'fromCallable'] === $definition->getFactory()) { + if (['Closure', 'fromCallable'] === $definition->getFactory() && ('Closure' !== $class || $definition->isLazy())) { $callable = $parameterBag->unescapeValue($parameterBag->resolveValue($definition->getArgument(0))); if ($callable instanceof Reference || $callable instanceof Definition) { @@ -1065,19 +1065,18 @@ private function createService(Definition $definition, array &$inlineServices, b || $callable[0] instanceof Definition && !isset($inlineServices[spl_object_hash($callable[0])]) )) { $containerRef = $this->containerRef ??= \WeakReference::create($this); - $class = ($callable[0] instanceof Reference ? $this->findDefinition($callable[0]) : $callable[0])->getClass(); $initializer = static function () use ($containerRef, $callable, &$inlineServices) { return $containerRef->get()->doResolveServices($callable[0], $inlineServices); }; - $proxy = eval('return '.LazyClosure::getCode('$initializer', $this->getReflectionClass($class), $callable[1], $id).';'); + $proxy = eval('return '.LazyClosure::getCode('$initializer', $callable, $definition, $this, $id).';'); $this->shareService($definition, $proxy, $id, $inlineServices); return $proxy; } } - if (true === $tryProxy && $definition->isLazy() && 'Closure' !== $class + if (true === $tryProxy && $definition->isLazy() && ['Closure', 'fromCallable'] !== $definition->getFactory() && !$tryProxy = !($proxy = $this->proxyInstantiator ??= new LazyServiceInstantiator()) || $proxy instanceof RealServiceInstantiator ) { $containerRef = $this->containerRef ??= \WeakReference::create($this); diff --git a/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php b/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php index 7f8054675a749..c8f1ebd2b1635 100644 --- a/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php +++ b/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php @@ -1162,7 +1162,8 @@ private function addNewInstance(Definition $definition, string $return = '', str if ('current' === $callable && [0] === array_keys($definition->getArguments()) && \is_array($value) && [0] === array_keys($value)) { return $return.$this->dumpValue($value[0]).$tail; } - if (['Closure', 'fromCallable'] === $callable && [0] === array_keys($definition->getArguments())) { + + if (['Closure', 'fromCallable'] === $callable) { $callable = $definition->getArgument(0); if ($callable instanceof ServiceClosureArgument) { return $return.$this->dumpValue($callable).$tail; @@ -1175,58 +1176,56 @@ private function addNewInstance(Definition $definition, string $return = '', str } } - if (\is_array($callable)) { - if (!preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $callable[1])) { - throw new RuntimeException(sprintf('Cannot dump definition because of invalid factory method (%s).', $callable[1] ?: 'n/a')); - } - - if (['...'] === $arguments && $definition->isLazy() && 'Closure' === ($definition->getClass() ?? 'Closure') && ( - $callable[0] instanceof Reference - || ($callable[0] instanceof Definition && !$this->definitionVariables->contains($callable[0])) - )) { - $class = ($callable[0] instanceof Reference ? $this->container->findDefinition($callable[0]) : $callable[0])->getClass(); + if (\is_string($callable) && str_starts_with($callable, '@=')) { + return $return.sprintf('(($args = %s) ? (%s) : null)', + $this->dumpValue(new ServiceLocatorArgument($definition->getArguments())), + $this->getExpressionLanguage()->compile(substr($callable, 2), ['container' => 'container', 'args' => 'args']) + ).$tail; + } - if (str_contains($initializer = $this->dumpValue($callable[0]), '$container')) { - $this->addContainerRef = true; - $initializer = sprintf('function () use ($containerRef) { $container = $containerRef; return %s; }', $initializer); - } else { - $initializer = 'fn () => '.$initializer; - } + if (!\is_array($callable)) { + return $return.sprintf('%s(%s)', $this->dumpLiteralClass($this->dumpValue($callable)), $arguments ? implode(', ', $arguments) : '').$tail; + } - return $return.LazyClosure::getCode($initializer, $this->container->getReflectionClass($class), $callable[1], $id).$tail; - } + if (!preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $callable[1])) { + throw new RuntimeException(sprintf('Cannot dump definition because of invalid factory method (%s).', $callable[1] ?: 'n/a')); + } - if ($callable[0] instanceof Reference - || ($callable[0] instanceof Definition && $this->definitionVariables->contains($callable[0])) - ) { - return $return.sprintf('%s->%s(%s)', $this->dumpValue($callable[0]), $callable[1], $arguments ? implode(', ', $arguments) : '').$tail; + if (['...'] === $arguments && ($definition->isLazy() || 'Closure' !== ($definition->getClass() ?? 'Closure')) && ( + $callable[0] instanceof Reference + || ($callable[0] instanceof Definition && !$this->definitionVariables->contains($callable[0])) + )) { + if (str_contains($initializer = $this->dumpValue($callable[0]), '$container')) { + $this->addContainerRef = true; + $initializer = sprintf('function () use ($containerRef) { $container = $containerRef->get(); return %s; }', $initializer); + } else { + $initializer = 'fn () => '.$initializer; } - $class = $this->dumpValue($callable[0]); - // If the class is a string we can optimize away - if (str_starts_with($class, "'") && !str_contains($class, '$')) { - if ("''" === $class) { - throw new RuntimeException(sprintf('Cannot dump definition: "%s" service is defined to be created by a factory but is missing the service reference, did you forget to define the factory service id or class?', $id ? 'The "'.$id.'"' : 'inline')); - } + return $return.LazyClosure::getCode($initializer, $callable, $definition, $this->container, $id).$tail; + } - return $return.sprintf('%s::%s(%s)', $this->dumpLiteralClass($class), $callable[1], $arguments ? implode(', ', $arguments) : '').$tail; - } + if ($callable[0] instanceof Reference + || ($callable[0] instanceof Definition && $this->definitionVariables->contains($callable[0])) + ) { + return $return.sprintf('%s->%s(%s)', $this->dumpValue($callable[0]), $callable[1], $arguments ? implode(', ', $arguments) : '').$tail; + } - if (str_starts_with($class, 'new ')) { - return $return.sprintf('(%s)->%s(%s)', $class, $callable[1], $arguments ? implode(', ', $arguments) : '').$tail; + $class = $this->dumpValue($callable[0]); + // If the class is a string we can optimize away + if (str_starts_with($class, "'") && !str_contains($class, '$')) { + if ("''" === $class) { + throw new RuntimeException(sprintf('Cannot dump definition: "%s" service is defined to be created by a factory but is missing the service reference, did you forget to define the factory service id or class?', $id ? 'The "'.$id.'"' : 'inline')); } - return $return.sprintf("[%s, '%s'](%s)", $class, $callable[1], $arguments ? implode(', ', $arguments) : '').$tail; + return $return.sprintf('%s::%s(%s)', $this->dumpLiteralClass($class), $callable[1], $arguments ? implode(', ', $arguments) : '').$tail; } - if (\is_string($callable) && str_starts_with($callable, '@=')) { - return $return.sprintf('(($args = %s) ? (%s) : null)', - $this->dumpValue(new ServiceLocatorArgument($definition->getArguments())), - $this->getExpressionLanguage()->compile(substr($callable, 2), ['container' => 'container', 'args' => 'args']) - ).$tail; + if (str_starts_with($class, 'new ')) { + return $return.sprintf('(%s)->%s(%s)', $class, $callable[1], $arguments ? implode(', ', $arguments) : '').$tail; } - return $return.sprintf('%s(%s)', $this->dumpLiteralClass($this->dumpValue($callable)), $arguments ? implode(', ', $arguments) : '').$tail; + return $return.sprintf("[%s, '%s'](%s)", $class, $callable[1], $arguments ? implode(', ', $arguments) : '').$tail; } if (null === $class = $definition->getClass()) { @@ -2344,7 +2343,7 @@ private function isProxyCandidate(Definition $definition, ?bool &$asGhostObject, { $asGhostObject = false; - if ('Closure' === ($definition->getClass() ?: (['Closure', 'fromCallable'] === $definition->getFactory() ? 'Closure' : null))) { + if (['Closure', 'fromCallable'] === $definition->getFactory()) { return null; } diff --git a/src/Symfony/Component/DependencyInjection/Loader/Configurator/FromCallableConfigurator.php b/src/Symfony/Component/DependencyInjection/Loader/Configurator/FromCallableConfigurator.php new file mode 100644 index 0000000000000..7fe0d3da14c88 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Loader/Configurator/FromCallableConfigurator.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\DependencyInjection\Definition; + +/** + * @author Nicolas Grekas + */ +class FromCallableConfigurator extends AbstractServiceConfigurator +{ + use Traits\AbstractTrait; + use Traits\AutoconfigureTrait; + use Traits\AutowireTrait; + use Traits\BindTrait; + use Traits\DecorateTrait; + use Traits\DeprecateTrait; + use Traits\LazyTrait; + use Traits\PublicTrait; + use Traits\ShareTrait; + use Traits\TagTrait; + + public const FACTORY = 'services'; + + private ServiceConfigurator $serviceConfigurator; + + public function __construct(ServiceConfigurator $serviceConfigurator, Definition $definition) + { + $this->serviceConfigurator = $serviceConfigurator; + + parent::__construct($serviceConfigurator->parent, $definition, $serviceConfigurator->id); + } + + public function __destruct() + { + $this->serviceConfigurator->__destruct(); + } +} diff --git a/src/Symfony/Component/DependencyInjection/Loader/Configurator/ServiceConfigurator.php b/src/Symfony/Component/DependencyInjection/Loader/Configurator/ServiceConfigurator.php index 49aff7ea947e7..2312f3b6e6e97 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/Configurator/ServiceConfigurator.php +++ b/src/Symfony/Component/DependencyInjection/Loader/Configurator/ServiceConfigurator.php @@ -31,6 +31,7 @@ class ServiceConfigurator extends AbstractServiceConfigurator use Traits\DeprecateTrait; use Traits\FactoryTrait; use Traits\FileTrait; + use Traits\FromCallableTrait; use Traits\LazyTrait; use Traits\ParentTrait; use Traits\PropertyTrait; diff --git a/src/Symfony/Component/DependencyInjection/Loader/Configurator/Traits/FromCallableTrait.php b/src/Symfony/Component/DependencyInjection/Loader/Configurator/Traits/FromCallableTrait.php new file mode 100644 index 0000000000000..e3508ab89561d --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Loader/Configurator/Traits/FromCallableTrait.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\Component\DependencyInjection\Loader\Configurator\Traits; + +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Loader\Configurator\FromCallableConfigurator; +use Symfony\Component\DependencyInjection\Loader\Configurator\ReferenceConfigurator; +use Symfony\Component\ExpressionLanguage\Expression; + +trait FromCallableTrait +{ + final public function fromCallable(string|array|ReferenceConfigurator|Expression $callable): FromCallableConfigurator + { + if ($this->definition instanceof ChildDefinition) { + throw new InvalidArgumentException('The configuration key "parent" is unsupported when using "fromCallable()".'); + } + + foreach ([ + 'synthetic' => 'isSynthetic', + 'factory' => 'getFactory', + 'file' => 'getFile', + 'arguments' => 'getArguments', + 'properties' => 'getProperties', + 'configurator' => 'getConfigurator', + 'calls' => 'getMethodCalls', + ] as $key => $method) { + if ($this->definition->$method()) { + throw new InvalidArgumentException(sprintf('The configuration key "%s" is unsupported when using "fromCallable()".', $key)); + } + } + + $this->definition->setFactory(['Closure', 'fromCallable']); + + if (\is_string($callable) && 1 === substr_count($callable, ':')) { + $parts = explode(':', $callable); + + throw new InvalidArgumentException(sprintf('Invalid callable "%s": the "service:method" notation is not available when using PHP-based DI configuration. Use "[service(\'%s\'), \'%s\']" instead.', $callable, $parts[0], $parts[1])); + } + + if ($callable instanceof Expression) { + $callable = '@='.$callable; + } + + $this->definition->setArguments([static::processValue($callable, true)]); + + if ('Closure' !== ($this->definition->getClass() ?? 'Closure')) { + $this->definition->setLazy(true); + } else { + $this->definition->setClass('Closure'); + } + + return new FromCallableConfigurator($this, $this->definition); + } +} diff --git a/src/Symfony/Component/DependencyInjection/Loader/XmlFileLoader.php b/src/Symfony/Component/DependencyInjection/Loader/XmlFileLoader.php index 6350b3af307d2..6ddecf5d54ab6 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/XmlFileLoader.php +++ b/src/Symfony/Component/DependencyInjection/Loader/XmlFileLoader.php @@ -389,6 +389,52 @@ private function parseDefinition(\DOMElement $service, string $file, Definition $definition->setDecoratedService($decorates, $renameId, $priority, $invalidBehavior); } + if ($callable = $this->getChildren($service, 'from-callable')) { + if ($definition instanceof ChildDefinition) { + throw new InvalidArgumentException(sprintf('Attribute "parent" is unsupported when using "" on service "%s".', (string) $service->getAttribute('id'))); + } + + foreach ([ + 'Attribute "synthetic"' => 'isSynthetic', + 'Attribute "file"' => 'getFile', + 'Tag ""' => 'getFactory', + 'Tag ""' => 'getArguments', + 'Tag ""' => 'getProperties', + 'Tag ""' => 'getConfigurator', + 'Tag ""' => 'getMethodCalls', + ] as $key => $method) { + if ($definition->$method()) { + throw new InvalidArgumentException($key.sprintf(' is unsupported when using "" on service "%s".', (string) $service->getAttribute('id'))); + } + } + + $definition->setFactory(['Closure', 'fromCallable']); + + if ('Closure' !== ($definition->getClass() ?? 'Closure')) { + $definition->setLazy(true); + } else { + $definition->setClass('Closure'); + } + + $callable = $callable[0]; + if ($function = $callable->getAttribute('function')) { + $definition->setArguments([$function]); + } elseif ($expression = $callable->getAttribute('expression')) { + if (!class_exists(Expression::class)) { + throw new \LogicException('The "expression" attribute cannot be used without the ExpressionLanguage component. Try running "composer require symfony/expression-language".'); + } + $definition->setArguments(['@='.$expression]); + } else { + if ($childService = $callable->getAttribute('service')) { + $class = new Reference($childService, ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE); + } else { + $class = $callable->hasAttribute('class') ? $callable->getAttribute('class') : null; + } + + $definition->setArguments([[$class, $callable->getAttribute('method') ?: '__invoke']]); + } + } + return $definition; } diff --git a/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php b/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php index 650bbe3556239..901086413bbb4 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php +++ b/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php @@ -396,6 +396,22 @@ private function parseDefinition(string $id, array|string|null $service, string $definition = isset($service[0]) && $service[0] instanceof Definition ? array_shift($service) : null; $return = null === $definition ? $return : true; + if (isset($service['from_callable'])) { + foreach (['alias', 'parent', 'synthetic', 'factory', 'file', 'arguments', 'properties', 'configurator', 'calls'] as $key) { + if (isset($service['factory'])) { + throw new InvalidArgumentException(sprintf('The configuration key "%s" is unsupported for the service "%s" when using "from_callable" in "%s".', $key, $id, $file)); + } + } + + if ('Closure' !== $service['class'] ??= 'Closure') { + $service['lazy'] = true; + } + + $service['factory'] = ['Closure', 'fromCallable']; + $service['arguments'] = [$service['from_callable']]; + unset($service['from_callable']); + } + $this->checkDefinition($id, $service, $file); if (isset($service['alias'])) { diff --git a/src/Symfony/Component/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd b/src/Symfony/Component/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd index c5263185bd0dc..399f93dfbba6d 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd +++ b/src/Symfony/Component/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd @@ -147,6 +147,7 @@ + diff --git a/src/Symfony/Component/DependencyInjection/Tests/ContainerBuilderTest.php b/src/Symfony/Component/DependencyInjection/Tests/ContainerBuilderTest.php index 1491e8843687e..d760415b4d9c9 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/ContainerBuilderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/ContainerBuilderTest.php @@ -49,6 +49,7 @@ use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\DependencyInjection\Tests\Compiler\Foo; use Symfony\Component\DependencyInjection\Tests\Compiler\FooAnnotation; +use Symfony\Component\DependencyInjection\Tests\Compiler\SingleMethodInterface; use Symfony\Component\DependencyInjection\Tests\Compiler\Wither; use Symfony\Component\DependencyInjection\Tests\Fixtures\CaseSensitiveClass; use Symfony\Component\DependencyInjection\Tests\Fixtures\CustomDefinition; @@ -507,6 +508,21 @@ public function testCreateLazyProxy() $this->assertInstanceOf(\Bar\FooClass::class, $foo1); } + public function testClosureProxy() + { + $container = new ContainerBuilder(); + $container->register('closure_proxy', SingleMethodInterface::class) + ->setPublic('true') + ->setFactory(['Closure', 'fromCallable']) + ->setArguments([[new Reference('foo'), 'cloneFoo']]) + ->setLazy(true); + $container->register('foo', Foo::class); + $container->compile(); + + $this->assertInstanceOf(SingleMethodInterface::class, $container->get('closure_proxy')); + $this->assertInstanceOf(Foo::class, $container->get('closure_proxy')->theMethod()); + } + public function testCreateServiceClass() { $builder = new ContainerBuilder(); diff --git a/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php b/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php index 966a333b8cc8d..1e188c8f78f03 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php @@ -48,6 +48,7 @@ use Symfony\Component\DependencyInjection\Tests\Compiler\Foo; use Symfony\Component\DependencyInjection\Tests\Compiler\FooAnnotation; use Symfony\Component\DependencyInjection\Tests\Compiler\IInterface; +use Symfony\Component\DependencyInjection\Tests\Compiler\SingleMethodInterface; use Symfony\Component\DependencyInjection\Tests\Compiler\Wither; use Symfony\Component\DependencyInjection\Tests\Compiler\WitherAnnotation; use Symfony\Component\DependencyInjection\Tests\Fixtures\CustomDefinition; @@ -1673,6 +1674,28 @@ public function testExpressionInFactory() $this->assertSame(247, $container->get('foo')->bar); } + public function testClosureProxy() + { + $container = new ContainerBuilder(); + $container->register('closure_proxy', SingleMethodInterface::class) + ->setPublic('true') + ->setFactory(['Closure', 'fromCallable']) + ->setArguments([[new Reference('foo'), 'cloneFoo']]) + ->setLazy(true); + $container->register('foo', Foo::class); + $container->compile(); + $dumper = new PhpDumper($container); + + $this->assertStringEqualsFile(self::$fixturesPath.'/php/closure_proxy.php', $dumper->dump(['class' => 'Symfony_DI_PhpDumper_Test_Closure_Proxy'])); + + require self::$fixturesPath.'/php/closure_proxy.php'; + + $container = new \Symfony_DI_PhpDumper_Test_Closure_Proxy(); + + $this->assertInstanceOf(SingleMethodInterface::class, $container->get('closure_proxy')); + $this->assertInstanceOf(Foo::class, $container->get('closure_proxy')->theMethod()); + } + public function testClosure() { $container = new ContainerBuilder(); @@ -1795,6 +1818,26 @@ public function testLazyAutowireAttributeWithIntersection() $this->assertStringEqualsFile(self::$fixturesPath.'/php/lazy_autowire_attribute_with_intersection.php', $dumper->dump()); } + + public function testCallableAdapterConsumer() + { + $container = new ContainerBuilder(); + $container->register('foo', Foo::class); + $container->register('bar', CallableAdapterConsumer::class) + ->setPublic('true') + ->setAutowired(true); + $container->compile(); + $dumper = new PhpDumper($container); + + $this->assertStringEqualsFile(self::$fixturesPath.'/php/callable_adapter_consumer.php', $dumper->dump(['class' => 'Symfony_DI_PhpDumper_Test_Callable_Adapter_Consumer'])); + + require self::$fixturesPath.'/php/callable_adapter_consumer.php'; + + $container = new \Symfony_DI_PhpDumper_Test_Callable_Adapter_Consumer(); + + $this->assertInstanceOf(SingleMethodInterface::class, $container->get('bar')->foo); + $this->assertInstanceOf(Foo::class, $container->get('bar')->foo->theMethod()); + } } class Rot13EnvVarProcessor implements EnvVarProcessorInterface @@ -1842,3 +1885,12 @@ public function __construct( ) { } } + +class CallableAdapterConsumer +{ + public function __construct( + #[AutowireCallable(service: 'foo', method: 'cloneFoo')] + public SingleMethodInterface $foo, + ) { + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/from_callable.expected.yml b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/from_callable.expected.yml new file mode 100644 index 0000000000000..d4dbbbadd48bf --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/from_callable.expected.yml @@ -0,0 +1,12 @@ + +services: + service_container: + class: Symfony\Component\DependencyInjection\ContainerInterface + public: true + synthetic: true + from_callable: + class: stdClass + public: true + lazy: true + arguments: [[!service { class: stdClass }, do]] + factory: [Closure, fromCallable] diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/from_callable.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/from_callable.php new file mode 100644 index 0000000000000..b734987147daf --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/from_callable.php @@ -0,0 +1,14 @@ +services() + ->set('from_callable', 'stdClass') + ->fromCallable([service('bar'), 'do']) + ->public() + ->set('bar', 'stdClass'); + } +}; diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/autowiring_classes.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/autowiring_classes.php index 1911282a5c77e..d5f62b9070d31 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/autowiring_classes.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/autowiring_classes.php @@ -536,3 +536,8 @@ public function __construct( ) { } } + +interface SingleMethodInterface +{ + public function theMethod(); +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/classes.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/classes.php index e765bf34b6b3b..846c8fe64797b 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/classes.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/classes.php @@ -1,8 +1,7 @@ ref = \WeakReference::create($this); + $this->services = $this->privates = []; + $this->methodMap = [ + 'bar' => 'getBarService', + ]; + + $this->aliases = []; + } + + public function compile(): void + { + throw new LogicException('You cannot compile a dumped container that was already compiled.'); + } + + public function isCompiled(): bool + { + return true; + } + + public function getRemovedIds(): array + { + return [ + 'foo' => true, + ]; + } + + /** + * Gets the public 'bar' shared autowired service. + * + * @return \Symfony\Component\DependencyInjection\Tests\Dumper\CallableAdapterConsumer + */ + protected static function getBarService($container) + { + return $container->services['bar'] = new \Symfony\Component\DependencyInjection\Tests\Dumper\CallableAdapterConsumer(new class(fn () => new \Symfony\Component\DependencyInjection\Tests\Compiler\Foo()) extends \Symfony\Component\DependencyInjection\Argument\LazyClosure implements \Symfony\Component\DependencyInjection\Tests\Compiler\SingleMethodInterface { public function theMethod() { return $this->service->cloneFoo(...\func_get_args()); } }); + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/closure_proxy.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/closure_proxy.php new file mode 100644 index 0000000000000..94ca615c4ddd4 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/closure_proxy.php @@ -0,0 +1,62 @@ +ref = \WeakReference::create($this); + $this->services = $this->privates = []; + $this->methodMap = [ + 'closure_proxy' => 'getClosureProxyService', + ]; + + $this->aliases = []; + } + + public function compile(): void + { + throw new LogicException('You cannot compile a dumped container that was already compiled.'); + } + + public function isCompiled(): bool + { + return true; + } + + public function getRemovedIds(): array + { + return [ + 'foo' => true, + ]; + } + + protected function createProxy($class, \Closure $factory) + { + return $factory(); + } + + /** + * Gets the public 'closure_proxy' shared service. + * + * @return \Symfony\Component\DependencyInjection\Tests\Compiler\SingleMethodInterface + */ + protected static function getClosureProxyService($container, $lazyLoad = true) + { + return $container->services['closure_proxy'] = new class(fn () => new \Symfony\Component\DependencyInjection\Tests\Compiler\Foo()) extends \Symfony\Component\DependencyInjection\Argument\LazyClosure implements \Symfony\Component\DependencyInjection\Tests\Compiler\SingleMethodInterface { public function theMethod() { return $this->service->cloneFoo(...\func_get_args()); } }; + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/from_callable.xml b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/from_callable.xml new file mode 100644 index 0000000000000..418819d8bdb89 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/from_callable.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/from_callable.yml b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/from_callable.yml new file mode 100644 index 0000000000000..2833ade5f3ccb --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/from_callable.yml @@ -0,0 +1,4 @@ +services: + from_callable: + class: stdClass + from_callable: ['@bar', 'do'] diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/PhpFileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/PhpFileLoaderTest.php index 87683f2f10bf1..f5652a3fd5ba7 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/PhpFileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/PhpFileLoaderTest.php @@ -106,6 +106,7 @@ public static function provideConfig() yield ['config_builder']; yield ['expression_factory']; yield ['closure']; + yield ['from_callable']; yield ['env_param']; } @@ -199,6 +200,8 @@ public function testNestedBundleConfigNotAllowed() public function testWhenEnv() { + $this->expectNotToPerformAssertions(); + $fixtures = realpath(__DIR__.'/../Fixtures'); $container = new ContainerBuilder(); $loader = new PhpFileLoader($container, new FileLocator(), 'dev', new ConfigBuilderGenerator(sys_get_temp_dir())); diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php index 84acd50972016..1a3e7f0493ddf 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php @@ -1193,4 +1193,14 @@ public function testClosure() $definition = $container->getDefinition('closure_property')->getProperties()['foo']; $this->assertEquals((new Definition('Closure'))->setFactory(['Closure', 'fromCallable'])->addArgument(new Reference('bar')), $definition); } + + public function testFromCallable() + { + $container = new ContainerBuilder(); + $loader = new XmlFileLoader($container, new FileLocator(self::$fixturesPath.'/xml')); + $loader->load('from_callable.xml'); + + $definition = $container->getDefinition('from_callable'); + $this->assertEquals((new Definition('stdClass'))->setFactory(['Closure', 'fromCallable'])->addArgument([new Reference('bar'), 'do'])->setLazy(true), $definition); + } } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php index 3f5277875194a..d36ad6ae2caa3 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php @@ -1149,4 +1149,14 @@ public function testClosure() $definition = $container->getDefinition('closure_property')->getProperties()['foo']; $this->assertEquals((new Definition('Closure'))->setFactory(['Closure', 'fromCallable'])->addArgument(new Reference('bar')), $definition); } + + public function testFromCallable() + { + $container = new ContainerBuilder(); + $loader = new YamlFileLoader($container, new FileLocator(self::$fixturesPath.'/yaml')); + $loader->load('from_callable.yml'); + + $definition = $container->getDefinition('from_callable'); + $this->assertEquals((new Definition('stdClass'))->setFactory(['Closure', 'fromCallable'])->addArgument([new Reference('bar'), 'do'])->setLazy(true), $definition); + } }