From 1a76cc84d68960681ab71085ccd0246c2ce9d966 Mon Sep 17 00:00:00 2001 From: Nikita Konstantinov Date: Fri, 19 Sep 2014 01:14:23 +0400 Subject: [PATCH] Add PlainArrayLoader as proof-of-concept --- composer.json | 3 +- .../DependencyInjection/ContainerBuilder.php | 9 +- .../DependencyInjection/Definition.php | 6 +- .../ClosureDumper/ClosureDumperInterface.php | 28 ++ .../ClosureDumper/SuperClosureDumper.php | 36 ++ .../DependencyInjection/Dumper/PhpDumper.php | 26 ++ .../Exception/DumpingClosureException.php | 17 + .../Loader/PlainArrayLoader.php | 339 ++++++++++++++++++ .../Tests/Dumper/PhpDumperTest.php | 51 +++ .../Tests/Fixtures/php/services12.php | 62 ++++ 10 files changed, 572 insertions(+), 5 deletions(-) create mode 100644 src/Symfony/Component/DependencyInjection/Dumper/ClosureDumper/ClosureDumperInterface.php create mode 100644 src/Symfony/Component/DependencyInjection/Dumper/ClosureDumper/SuperClosureDumper.php create mode 100644 src/Symfony/Component/DependencyInjection/Exception/DumpingClosureException.php create mode 100644 src/Symfony/Component/DependencyInjection/Loader/PlainArrayLoader.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services12.php diff --git a/composer.json b/composer.json index fe40170481c7b..a0149e66356c8 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ "symfony/icu": "~1.0", "doctrine/common": "~2.2", "twig/twig": "~1.12", - "psr/log": "~1.0" + "psr/log": "~1.0", + "jeremeamia/SuperClosure": "~1.0" }, "replace": { "symfony/browser-kit": "self.version", diff --git a/src/Symfony/Component/DependencyInjection/ContainerBuilder.php b/src/Symfony/Component/DependencyInjection/ContainerBuilder.php index c06b622da7d80..e8b4449962a50 100644 --- a/src/Symfony/Component/DependencyInjection/ContainerBuilder.php +++ b/src/Symfony/Component/DependencyInjection/ContainerBuilder.php @@ -939,7 +939,14 @@ public function createService(Definition $definition, $id, $tryProxy = true) $arguments = $this->resolveServices($parameterBag->unescapeValue($parameterBag->resolveValue($definition->getArguments()))); - if (null !== $definition->getFactoryMethod()) { + if ($definition->getFactoryMethod() instanceof \Closure) { + if (null !== $definition->getFactoryMethod() || null !== $definition->getFactoryClass()) { + throw new RuntimeException(sprintf('Definition of service "%s" is inconsistent (mixing of closure and factory service/class)', $id)); + } + + $closure = $definition->getFactoryMethod(); + $service = $closure($this); + } elseif (null !== $definition->getFactoryMethod()) { if (null !== $definition->getFactoryClass()) { $factory = $parameterBag->resolveValue($definition->getFactoryClass()); } elseif (null !== $definition->getFactoryService()) { diff --git a/src/Symfony/Component/DependencyInjection/Definition.php b/src/Symfony/Component/DependencyInjection/Definition.php index f83c069c63906..dce32a46bd8ec 100644 --- a/src/Symfony/Component/DependencyInjection/Definition.php +++ b/src/Symfony/Component/DependencyInjection/Definition.php @@ -88,7 +88,7 @@ public function getFactoryClass() /** * Sets the factory method able to create an instance of this class. * - * @param string $factoryMethod The factory method name + * @param string|\Closure $factoryMethod The factory method name or closure * * @return Definition The current instance * @@ -109,7 +109,7 @@ public function setFactoryMethod($factoryMethod) * * @return Definition The current instance * - * @throws InvalidArgumentException In case the decorated service id and the new decorated service id are equals. + * @throws \InvalidArgumentException In case the decorated service id and the new decorated service id are equals. */ public function setDecoratedService($id, $renamedId = null) { @@ -139,7 +139,7 @@ public function getDecoratedService() /** * Gets the factory method. * - * @return string|null The factory method name + * @return string|\Closure|null The factory method name * * @api */ diff --git a/src/Symfony/Component/DependencyInjection/Dumper/ClosureDumper/ClosureDumperInterface.php b/src/Symfony/Component/DependencyInjection/Dumper/ClosureDumper/ClosureDumperInterface.php new file mode 100644 index 0000000000000..a5a8e853fa73c --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Dumper/ClosureDumper/ClosureDumperInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Dumper\ClosureDumper; + +/** + * Dumper is the abstract class for all built-in dumpers. + * + * @api + */ +interface ClosureDumperInterface +{ + /** + * @param \Closure $closure + * @return string + * + * @throws \Symfony\Component\DependencyInjection\Exception\DumpingClosureException If closure couldn't be dumped + */ + public function dump(\Closure $closure); +} diff --git a/src/Symfony/Component/DependencyInjection/Dumper/ClosureDumper/SuperClosureDumper.php b/src/Symfony/Component/DependencyInjection/Dumper/ClosureDumper/SuperClosureDumper.php new file mode 100644 index 0000000000000..8892362af9aae --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Dumper/ClosureDumper/SuperClosureDumper.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Dumper\ClosureDumper; + +use Jeremeamia\SuperClosure\ClosureParser; +use Symfony\Component\DependencyInjection\Exception\DumpingClosureException; + +final class SuperClosureDumper implements ClosureDumperInterface +{ + /** + * {@inheritdoc} + */ + public function dump(\Closure $closure) + { + $reflection = new \ReflectionFunction($closure); + $closureParser = new ClosureParser($reflection); + + try { + $closureCode = $closureParser->getCode(); + } catch (\InvalidArgumentException $e) { + throw new DumpingClosureException($closure); + } + + // Remove ";" from the end of code + return substr($closureCode, 0, -1); + } +} diff --git a/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php b/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php index 2cce294054207..3a278087bbbe7 100644 --- a/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php +++ b/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php @@ -11,6 +11,7 @@ namespace Symfony\Component\DependencyInjection\Dumper; +use Symfony\Component\DependencyInjection\Dumper\ClosureDumper\ClosureDumperInterface; use Symfony\Component\DependencyInjection\Variable; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -60,6 +61,11 @@ class PhpDumper extends Dumper */ private $proxyDumper; + /** + * @var \Symfony\Component\DependencyInjection\Dumper\ClosureDumper\ClosureDumperInterface + */ + private $closureDumper; + /** * {@inheritdoc} * @@ -82,6 +88,16 @@ public function setProxyDumper(ProxyDumper $proxyDumper) $this->proxyDumper = $proxyDumper; } + /** + * Sets the dumper of closures + * + * @param ClosureDumperInterface $closureDumper + */ + public function setClosureDumper(ClosureDumperInterface $closureDumper) + { + $this->closureDumper = $closureDumper; + } + /** * Dumps the service container as a PHP class. * @@ -701,6 +717,16 @@ private function addNewInstance($id, Definition $definition, $return, $instantia } if (null !== $definition->getFactoryMethod()) { + if ($definition->getFactoryMethod() instanceof \Closure) { + if ($this->closureDumper === null) { + throw new RuntimeException('DIC PhpDumper requires ClosureParser in order to dump closures'); + } + + $closureCode = $this->closureDumper->dump($definition->getFactoryMethod()); + + return sprintf(" $return{$instantiation}call_user_func(%s, %s);\n", $closureCode, $arguments ? implode(', ', $arguments) : '$this'); + } + if (null !== $definition->getFactoryClass()) { $class = $this->dumpValue($definition->getFactoryClass()); diff --git a/src/Symfony/Component/DependencyInjection/Exception/DumpingClosureException.php b/src/Symfony/Component/DependencyInjection/Exception/DumpingClosureException.php new file mode 100644 index 0000000000000..51b071ff5c7d9 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Exception/DumpingClosureException.php @@ -0,0 +1,17 @@ +getFileName(), + $reflection->getStartLine() + )); + } +} diff --git a/src/Symfony/Component/DependencyInjection/Loader/PlainArrayLoader.php b/src/Symfony/Component/DependencyInjection/Loader/PlainArrayLoader.php new file mode 100644 index 0000000000000..66487a1f8bf3d --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Loader/PlainArrayLoader.php @@ -0,0 +1,339 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader; + +use Symfony\Component\Config\Resource\FileResource; +use Symfony\Component\DependencyInjection\Alias; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\DefinitionDecorator; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\Config\FileLocatorInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +/** + * @author Fabien Potencier + */ +final class PlainArrayLoader extends FileLoader +{ + private $ext; + + public function __construct(ContainerBuilder $container, FileLocatorInterface $locator, $ext = '.config.php') + { + parent::__construct($container, $locator); + + if (!is_string($ext) || empty($ext)) { + throw new \InvalidArgumentException('Extension of PlainArrayLoader is invalid'); + } + + $this->ext = $ext; + } + + public function load($file, $type = null) + { + $path = $this->locator->locate($file); + + $content = $this->loadFile($path); + + $this->container->addResource(new FileResource($path)); + + // empty file + if (null === $content) { + return; + } + + // imports + $this->parseImports($content, $path); + + // parameters + if (isset($content['parameters'])) { + foreach ($content['parameters'] as $key => $value) { + $this->container->setParameter($key, $this->resolveServices($value)); + } + } + + // extensions + $this->loadFromExtensions($content); + + // services + $this->parseDefinitions($content, $file); + } + + public function supports($resource, $type = null) + { + return is_string($resource) && substr($resource, -strlen($this->ext)) === $this->ext; + } + + /** + * Parses all imports + * + * @param array $content + * @param string $file + */ + private function parseImports($content, $file) + { + if (!isset($content['imports'])) { + return; + } + + foreach ($content['imports'] as $import) { + $this->setCurrentDir(dirname($file)); + $this->import($import['resource'], null, isset($import['ignore_errors']) ? (bool) $import['ignore_errors'] : false, $file); + } + } + + /** + * Parses definitions + * + * @param array $content + * @param string $file + */ + private function parseDefinitions($content, $file) + { + if (!isset($content['services'])) { + return; + } + + foreach ($content['services'] as $id => $service) { + $this->parseDefinition($id, $service, $file); + } + } + + /** + * Parses a definition. + * + * @param string $id + * @param array $service + * @param string $file + * + * @throws InvalidArgumentException When tags are invalid + */ + private function parseDefinition($id, $service, $file) + { + if (is_string($service) && 0 === strpos($service, '@')) { + $this->container->setAlias($id, substr($service, 1)); + + return; + } elseif (isset($service['alias'])) { + $public = !array_key_exists('public', $service) || (bool) $service['public']; + $this->container->setAlias($id, new Alias($service['alias'], $public)); + + return; + } + + if (isset($service['parent'])) { + $definition = new DefinitionDecorator($service['parent']); + } else { + $definition = new Definition(); + } + + if (isset($service['class'])) { + $definition->setClass($service['class']); + } + + if (isset($service['scope'])) { + $definition->setScope($service['scope']); + } + + if (isset($service['synthetic'])) { + $definition->setSynthetic($service['synthetic']); + } + + if (isset($service['synchronized'])) { + $definition->setSynchronized($service['synchronized']); + } + + if (isset($service['lazy'])) { + $definition->setLazy($service['lazy']); + } + + if (isset($service['public'])) { + $definition->setPublic($service['public']); + } + + if (isset($service['abstract'])) { + $definition->setAbstract($service['abstract']); + } + + if (isset($service['factory_class'])) { + $definition->setFactoryClass($service['factory_class']); + } + + if (isset($service['factory_method'])) { + $definition->setFactoryMethod($service['factory_method']); + } + + if (isset($service['factory_service'])) { + $definition->setFactoryService($service['factory_service']); + } + + if (isset($service['file'])) { + $definition->setFile($service['file']); + } + + if (isset($service['arguments'])) { + $definition->setArguments($this->resolveServices($service['arguments'])); + } + + if (isset($service['properties'])) { + $definition->setProperties($this->resolveServices($service['properties'])); + } + + if (isset($service['configurator'])) { + if (is_string($service['configurator'])) { + $definition->setConfigurator($service['configurator']); + } else { + $definition->setConfigurator(array($this->resolveServices($service['configurator'][0]), $service['configurator'][1])); + } + } + + if (isset($service['calls'])) { + foreach ($service['calls'] as $call) { + $args = isset($call[1]) ? $this->resolveServices($call[1]) : array(); + $definition->addMethodCall($call[0], $args); + } + } + + if (isset($service['tags'])) { + if (!is_array($service['tags'])) { + throw new InvalidArgumentException(sprintf('Parameter "tags" must be an array for service "%s" in %s.', $id, $file)); + } + + foreach ($service['tags'] as $tag) { + if (!isset($tag['name'])) { + throw new InvalidArgumentException(sprintf('A "tags" entry is missing a "name" key for service "%s" in %s.', $id, $file)); + } + + $name = $tag['name']; + unset($tag['name']); + + foreach ($tag as $attribute => $value) { + if (!is_scalar($value) && null !== $value) { + throw new InvalidArgumentException(sprintf('A "tags" attribute must be of a scalar-type for service "%s", tag "%s", attribute "%s" in %s.', $id, $name, $attribute, $file)); + } + } + + $definition->addTag($name, $tag); + } + } + + if (isset($service['decorates'])) { + $renameId = isset($service['decoration_inner_name']) ? $service['decoration_inner_name'] : null; + $definition->setDecoratedService($service['decorates'], $renameId); + } + + $this->container->setDefinition($id, $definition); + } + + private function loadFile($file) + { + if (!stream_is_local($file)) { + throw new InvalidArgumentException(sprintf('This is not a local file "%s".', $file)); + } + + if (!file_exists($file)) { + throw new InvalidArgumentException(sprintf('The service file "%s" is not valid.', $file)); + } + + $content = include $file; + + if (!is_array($content)) { + throw new InvalidArgumentException(sprintf('The service file "%s" must return array.', $file)); + } + + return $this->validate($content, $file); + } + + /** + * Resolves services. + * + * @param string $value + * + * @throws InvalidArgumentException + * @return Reference + */ + private function resolveServices($value) + { + if (is_array($value)) { + $value = array_map(array($this, 'resolveServices'), $value); + } elseif (is_string($value) && 0 === strpos($value, '@=')) { + throw new InvalidArgumentException('Expression language is not allowed in array configs, use closures'); + } elseif (is_string($value) && 0 === strpos($value, '@')) { + if (0 === strpos($value, '@@')) { + $value = substr($value, 1); + $invalidBehavior = null; + } elseif (0 === strpos($value, '@?')) { + $value = substr($value, 2); + $invalidBehavior = ContainerInterface::IGNORE_ON_INVALID_REFERENCE; + } else { + $value = substr($value, 1); + $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE; + } + + if ('=' === substr($value, -1)) { + $value = substr($value, 0, -1); + $strict = false; + } else { + $strict = true; + } + + if (null !== $invalidBehavior) { + $value = new Reference($value, $invalidBehavior, $strict); + } + } + + return $value; + } + + private function validate(array $content, $file) + { + foreach (array_keys($content) as $namespace) { + if (in_array($namespace, array('imports', 'parameters', 'services'))) { + continue; + } + + if (!$this->container->hasExtension($namespace)) { + $extensionNamespaces = array_filter(array_map(function ($ext) { return $ext->getAlias(); }, $this->container->getExtensions())); + throw new InvalidArgumentException(sprintf( + 'There is no extension able to load the configuration for "%s" (in %s). Looked for namespace "%s", found %s', + $namespace, + $file, + $namespace, + $extensionNamespaces ? sprintf('"%s"', implode('", "', $extensionNamespaces)) : 'none' + )); + } + } + + return $content; + } + + /** + * Loads from Extensions + * + * @param array $content + */ + private function loadFromExtensions($content) + { + foreach ($content as $namespace => $values) { + if (in_array($namespace, array('imports', 'parameters', 'services'))) { + continue; + } + + if (!is_array($values)) { + $values = array(); + } + + $this->container->loadFromExtension($namespace, $values); + } + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php b/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php index 8d5ea701a50da..509b626be2e02 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\DependencyInjection\Tests\Dumper; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Dumper\PhpDumper; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; use Symfony\Component\DependencyInjection\Reference; @@ -196,4 +197,54 @@ public function testCircularReference() $dumper = new PhpDumper($container); $dumper->dump(); } + + public function testClosureAsFactoryMethod() + { + $container = new ContainerBuilder(); + + $container->register('foo', 'stdClass')->setFactoryMethod( + function (ContainerInterface $container) { + return new \stdClass(); + } + ); + + $container->register('bar', 'stdClass')->setFactoryMethod( + function (\stdClass $foo) { + $bar = clone $foo; + $bar->bar = 42; + + return $bar; + } + )->addArgument(new Reference('foo')); + + $closureDumperMock = $this->getMockForAbstractClass('Symfony\Component\DependencyInjection\Dumper\ClosureDumper\ClosureDumperInterface'); + + $closureDumperMock + ->expects($this->at(0)) + ->method('dump') + ->will($this->returnValue( + <<<'CODE' +function (\stdClass $foo) { + $bar = clone $foo; + $bar->bar = 42; + return $bar; + } +CODE + )); + + $closureDumperMock + ->expects($this->at(1)) + ->method('dump') + ->will($this->returnValue( + <<<'CODE' +function (\Symfony\Component\DependencyInjection\ContainerInterface $container) { + return new \stdClass(); + } +CODE + )); + + $dumper = new PhpDumper($container); + $dumper->setClosureDumper($closureDumperMock); + $this->assertStringEqualsFile(self::$fixturesPath.'/php/services12.php', $dumper->dump()); + } } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services12.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services12.php new file mode 100644 index 0000000000000..1a8aeb1fa985d --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services12.php @@ -0,0 +1,62 @@ +methodMap = array( + 'bar' => 'getBarService', + 'foo' => 'getFooService', + ); + } + + /** + * Gets the 'bar' service. + * + * This service is shared. + * This method always returns the same instance of the service. + * + * @return \stdClass A stdClass instance. + */ + protected function getBarService() + { + return $this->services['bar'] = call_user_func(function (\stdClass $foo) { + $bar = clone $foo; + $bar->bar = 42; + return $bar; + }, $this->get('foo')); + } + + /** + * Gets the 'foo' service. + * + * This service is shared. + * This method always returns the same instance of the service. + * + * @return \stdClass A stdClass instance. + */ + protected function getFooService() + { + return $this->services['foo'] = call_user_func(function (\Symfony\Component\DependencyInjection\ContainerInterface $container) { + return new \stdClass(); + }, $this); + } +}