Skip to content

Commit 0564cd8

Browse files
[DI] Add "psr4" service attribute for PSR4-based discovery and registration
1 parent 5a38804 commit 0564cd8

File tree

15 files changed

+273
-17
lines changed

15 files changed

+273
-17
lines changed

src/Symfony/Component/Config/Loader/FileLoader.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ abstract class FileLoader extends Loader
3232
*/
3333
protected $locator;
3434

35-
private $currentDir;
35+
protected $currentDir;
3636

3737
/**
3838
* Constructor.

src/Symfony/Component/DependencyInjection/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
3.3.0
55
-----
66

7+
* [EXPERIMENTAL] added prototype services for PSR4-based discovery and registration
78
* added `ContainerBuilder::getReflectionClass()` for retrieving and tracking reflection class info
89
* deprecated `ContainerBuilder::getClassResource()`, use `ContainerBuilder::getReflectionClass()` or `ContainerBuilder::addObjectResource()` instead
910
* added `ContainerBuilder::fileExists()` for checking and tracking file or directory existence

src/Symfony/Component/DependencyInjection/Loader/FileLoader.php

+109
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@
1212
namespace Symfony\Component\DependencyInjection\Loader;
1313

1414
use Symfony\Component\DependencyInjection\ContainerBuilder;
15+
use Symfony\Component\DependencyInjection\Definition;
16+
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
17+
use Symfony\Component\DependencyInjection\Exception\LogicException;
1518
use Symfony\Component\Config\Loader\FileLoader as BaseFileLoader;
1619
use Symfony\Component\Config\FileLocatorInterface;
20+
use Symfony\Component\Finder\Finder;
21+
use Symfony\Component\Finder\Glob;
1722

1823
/**
1924
* FileLoader is the abstract class used by all built-in loaders that are file based.
@@ -34,4 +39,108 @@ public function __construct(ContainerBuilder $container, FileLocatorInterface $l
3439

3540
parent::__construct($locator);
3641
}
42+
43+
/**
44+
* Registers a set of classes as services using PSR-4 for discovery.
45+
*
46+
* @param Definition $prototype A definition to use as template
47+
* @param string $namespace The namespace prefix of classes in the scanned directory
48+
* @param string $resources The directory to look for classes, glob-patterns allowed
49+
*
50+
* @experimental in version 3.3
51+
*/
52+
public function registerClasses(Definition $prototype, $namespace, $resources)
53+
{
54+
if ('\\' !== substr($namespace, -1)) {
55+
throw new InvalidArgumentException(sprintf('Namespace prefix must end with a "\\": %s.', $namespace));
56+
}
57+
if (!preg_match('/^(?:[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+\\\\)++$/', $namespace)) {
58+
throw new InvalidArgumentException(sprintf('Namespace is not a valid PSR-4 prefix: %s.', $namespace));
59+
}
60+
61+
$classes = $this->findClasses($namespace, $resources);
62+
// prepare for deep cloning
63+
$prototype = serialize($prototype);
64+
65+
foreach ($classes as $class) {
66+
$this->container->setDefinition($class, unserialize($prototype));
67+
}
68+
}
69+
70+
private function findClasses($namespace, $resources)
71+
{
72+
$classes = array();
73+
$extRegexp = defined('HHVM_VERSION') ? '/\\.(?:php|hh)$/' : '/\\.php$/';
74+
75+
foreach ($this->glob($resources, true, $prefixLen) as $path => $info) {
76+
if (!preg_match($extRegexp, $path, $m) || !$info->isFile() || !$info->isReadable()) {
77+
continue;
78+
}
79+
$class = $namespace.ltrim(str_replace('/', '\\', substr($path, $prefixLen, -strlen($m[0]))), '\\');
80+
81+
if (!preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\\\\[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+$/', $class)) {
82+
continue;
83+
}
84+
if (!$r = $this->container->getReflectionClass($class, true)) {
85+
continue;
86+
}
87+
if (!$r->isInterface() && !$r->isTrait()) {
88+
$classes[] = $class;
89+
}
90+
}
91+
92+
return $classes;
93+
}
94+
95+
private function glob($resources, $recursive, &$prefixLen = null)
96+
{
97+
if (strlen($resources) === $i = strcspn($resources, '*?{[')) {
98+
$resourcesPrefix = $resources;
99+
$resources = '';
100+
} elseif (0 === $i) {
101+
$resourcesPrefix = '.';
102+
$resources = '/'.$resources;
103+
} else {
104+
$resourcesPrefix = dirname(substr($resources, 0, 1 + $i));
105+
$resources = substr($resources, strlen($resourcesPrefix));
106+
}
107+
108+
$resourcesPrefix = $this->locator->locate($resourcesPrefix, $this->currentDir, true);
109+
$resourcesPrefix = realpath($resourcesPrefix) ?: $resourcesPrefix;
110+
$prefixLen = strlen($resourcesPrefix);
111+
112+
// track directories only for new & removed files
113+
$this->container->fileExists($resourcesPrefix, '/^$/');
114+
115+
if (false === strpos($resources, '/**/') && (defined('GLOB_BRACE') || false === strpos($resources, '{'))) {
116+
foreach (glob($resourcesPrefix.$resources, defined('GLOB_BRACE') ? GLOB_BRACE : 0) as $path) {
117+
if ($recursive && is_dir($path)) {
118+
$flags = \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS;
119+
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, $flags)) as $path => $info) {
120+
yield $path => $info;
121+
}
122+
} else {
123+
yield $path => new \SplFileInfo($path);
124+
}
125+
}
126+
127+
return;
128+
}
129+
130+
if (!class_exists(Finder::class)) {
131+
throw new LogicException(sprintf('Extended glob pattern "%s" cannot be used as the Finder component is not installed.', $resources));
132+
}
133+
134+
$finder = new Finder();
135+
$regex = Glob::toRegex($resources);
136+
if ($recursive) {
137+
$regex = substr_replace($regex, '(/|$)', -2, 1);
138+
}
139+
140+
foreach ($finder->followLinks()->in($resourcesPrefix) as $path => $info) {
141+
if (preg_match($regex, substr($path, $prefixLen))) {
142+
yield $path => $info;
143+
}
144+
}
145+
}
37146
}

src/Symfony/Component/DependencyInjection/Loader/XmlFileLoader.php

+7-2
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,19 @@ private function parseDefinitions(\DOMDocument $xml, $file)
121121
$xpath = new \DOMXPath($xml);
122122
$xpath->registerNamespace('container', self::NS);
123123

124-
if (false === $services = $xpath->query('//container:services/container:service')) {
124+
if (false === $services = $xpath->query('//container:services/container:service|//container:services/container:prototype')) {
125125
return;
126126
}
127+
$this->setCurrentDir(dirname($file));
127128

128129
$defaults = $this->getServiceDefaults($xml, $file);
129130
foreach ($services as $service) {
130131
if (null !== $definition = $this->parseDefinition($service, $file, $defaults)) {
131-
$this->container->setDefinition((string) $service->getAttribute('id'), $definition);
132+
if ('prototype' === $service->tagName) {
133+
$this->registerClasses($definition, (string) $service->getAttribute('namespace'), (string) $service->getAttribute('resources'));
134+
} else {
135+
$this->container->setDefinition((string) $service->getAttribute('id'), $definition);
136+
}
132137
}
133138
}
134139
}

src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php

+50-11
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
*/
3737
class YamlFileLoader extends FileLoader
3838
{
39-
private static $keywords = array(
39+
private static $serviceKeywords = array(
4040
'alias' => 'alias',
4141
'parent' => 'parent',
4242
'class' => 'class',
@@ -62,6 +62,32 @@ class YamlFileLoader extends FileLoader
6262
'autowiring_types' => 'autowiring_types',
6363
);
6464

65+
private static $prototypeKeywords = array(
66+
'resources' => 'resources',
67+
'parent' => 'parent',
68+
'shared' => 'shared',
69+
'lazy' => 'lazy',
70+
'public' => 'public',
71+
'abstract' => 'abstract',
72+
'deprecated' => 'deprecated',
73+
'factory' => 'factory',
74+
'arguments' => 'arguments',
75+
'properties' => 'properties',
76+
'getters' => 'getters',
77+
'configurator' => 'configurator',
78+
'calls' => 'calls',
79+
'tags' => 'tags',
80+
'inherit_tags' => 'inherit_tags',
81+
'autowire' => 'autowire',
82+
);
83+
84+
private static $defaultsKeywords = array(
85+
'public' => 'public',
86+
'tags' => 'tags',
87+
'inherit_tags' => 'inherit_tags',
88+
'autowire' => 'autowire',
89+
);
90+
6591
private $yamlParser;
6692

6793
/**
@@ -98,6 +124,7 @@ public function load($resource, $type = null)
98124
$this->loadFromExtensions($content);
99125

100126
// services
127+
$this->setCurrentDir(dirname($path));
101128
$this->parseDefinitions($content, $resource);
102129
}
103130

@@ -188,12 +215,11 @@ private function parseDefaults(array &$content, $file)
188215
return array();
189216
}
190217

191-
$defaultKeys = array('public', 'tags', 'inherit_tags', 'autowire');
192218
unset($content['services']['_defaults']);
193219

194220
foreach ($defaults as $key => $default) {
195-
if (!in_array($key, $defaultKeys)) {
196-
throw new InvalidArgumentException(sprintf('The configuration key "%s" cannot be used to define a default value in "%s". Allowed keys are "%s".', $key, $file, implode('", "', $defaultKeys)));
221+
if (!isset(self::$defaultsKeywords[$key])) {
222+
throw new InvalidArgumentException(sprintf('The configuration key "%s" cannot be used to define a default value in "%s". Allowed keys are "%s".', $key, $file, implode('", "', self::$defaultsKeywords)));
197223
}
198224
}
199225
if (!isset($defaults['tags'])) {
@@ -443,7 +469,14 @@ private function parseDefinition($id, $service, $file, array $defaults)
443469
}
444470
}
445471

446-
$this->container->setDefinition($id, $definition);
472+
if (array_key_exists('resources', $service)) {
473+
if (!is_string($service['resources'])) {
474+
throw new InvalidArgumentException(sprintf('A "resources" attribute must be of type string for service "%s" in %s. Check your YAML syntax.', $id, $file));
475+
}
476+
$this->registerClasses($definition, $id, $service['resources']);
477+
} else {
478+
$this->container->setDefinition($id, $definition);
479+
}
447480
}
448481

449482
/**
@@ -660,13 +693,19 @@ private function loadFromExtensions(array $content)
660693
*/
661694
private static function checkDefinition($id, array $definition, $file)
662695
{
696+
if ($throw = isset($definition['resources'])) {
697+
$keywords = static::$prototypeKeywords;
698+
} else {
699+
$keywords = static::$serviceKeywords;
700+
}
701+
663702
foreach ($definition as $key => $value) {
664-
if (!isset(static::$keywords[$key])) {
665-
@trigger_error(sprintf('The configuration key "%s" is unsupported for service definition "%s" in "%s". Allowed configuration keys are "%s". The YamlFileLoader object will raise an exception instead in Symfony 4.0 when detecting an unsupported service configuration key.', $key, $id, $file, implode('", "', static::$keywords)), E_USER_DEPRECATED);
666-
// @deprecated Uncomment the following statement in Symfony 4.0
667-
// and also update the corresponding unit test to make it expect
668-
// an InvalidArgumentException exception.
669-
//throw new InvalidArgumentException(sprintf('The configuration key "%s" is unsupported for service definition "%s" in "%s". Allowed configuration keys are "%s".', $key, $id, $file, implode('", "', static::$keywords)));
703+
if (!isset($keywords[$key])) {
704+
if ($throw) {
705+
throw new InvalidArgumentException(sprintf('The configuration key "%s" is unsupported for definition "%s" in "%s". Allowed configuration keys are "%s".', $key, $id, $file, implode('", "', $keywords)));
706+
}
707+
708+
@trigger_error(sprintf('The configuration key "%s" is unsupported for service definition "%s" in "%s". Allowed configuration keys are "%s". The YamlFileLoader object will raise an exception instead in Symfony 4.0 when detecting an unsupported service configuration key.', $key, $id, $file, implode('", "', $keywords)), E_USER_DEPRECATED);
670709
}
671710
}
672711
}

src/Symfony/Component/DependencyInjection/Loader/schema/dic/services/services-1.0.xsd

+24
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
</xsd:annotation>
5555
<xsd:choice maxOccurs="unbounded">
5656
<xsd:element name="service" type="service" minOccurs="1" />
57+
<xsd:element name="prototype" type="prototype" minOccurs="0" />
5758
<xsd:element name="defaults" type="defaults" minOccurs="0" maxOccurs="1" />
5859
</xsd:choice>
5960
</xsd:complexType>
@@ -136,6 +137,29 @@
136137
<xsd:attribute name="inherit-tags" type="boolean" />
137138
</xsd:complexType>
138139

140+
<xsd:complexType name="prototype">
141+
<xsd:choice maxOccurs="unbounded">
142+
<xsd:element name="argument" type="argument" minOccurs="0" maxOccurs="unbounded" />
143+
<xsd:element name="configurator" type="callable" minOccurs="0" maxOccurs="1" />
144+
<xsd:element name="factory" type="callable" minOccurs="0" maxOccurs="1" />
145+
<xsd:element name="deprecated" type="xsd:string" minOccurs="0" maxOccurs="1" />
146+
<xsd:element name="call" type="call" minOccurs="0" maxOccurs="unbounded" />
147+
<xsd:element name="tag" type="tag" minOccurs="0" maxOccurs="unbounded" />
148+
<xsd:element name="property" type="property" minOccurs="0" maxOccurs="unbounded" />
149+
<xsd:element name="getter" type="getter" minOccurs="0" maxOccurs="unbounded" />
150+
<xsd:element name="autowire" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
151+
</xsd:choice>
152+
<xsd:attribute name="namespace" type="xsd:string" use="required" />
153+
<xsd:attribute name="resources" type="xsd:string" use="required" />
154+
<xsd:attribute name="shared" type="boolean" />
155+
<xsd:attribute name="public" type="boolean" />
156+
<xsd:attribute name="lazy" type="boolean" />
157+
<xsd:attribute name="abstract" type="boolean" />
158+
<xsd:attribute name="parent" type="xsd:string" />
159+
<xsd:attribute name="autowire" type="boolean" />
160+
<xsd:attribute name="inherit-tags" type="boolean" />
161+
</xsd:complexType>
162+
139163
<xsd:complexType name="tag">
140164
<xsd:attribute name="name" type="xsd:string" use="required" />
141165
<xsd:anyAttribute namespace="##any" processContents="lax" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype;
4+
5+
class Foo
6+
{
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype;
4+
5+
class MissingParent extends NotExistingParent
6+
{
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Sub;
4+
5+
class Bar
6+
{
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<container xmlns="http://symfony.com/schema/dic/services" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
3+
<services>
4+
<prototype namespace="Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\" resources="../Prototype/*" />
5+
</services>
6+
</container>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
services:
2+
Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\:
3+
resources: ../Prototype

src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php

+23
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
use Symfony\Component\DependencyInjection\Loader\IniFileLoader;
2121
use Symfony\Component\Config\Loader\LoaderResolver;
2222
use Symfony\Component\Config\FileLocator;
23+
use Symfony\Component\Config\Resource\DirectoryResource;
24+
use Symfony\Component\Config\Resource\FileResource;
2325
use Symfony\Component\DependencyInjection\Tests\Fixtures\CaseSensitiveClass;
26+
use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype;
2427
use Symfony\Component\ExpressionLanguage\Expression;
2528

2629
class XmlFileLoaderTest extends \PHPUnit_Framework_TestCase
@@ -608,6 +611,26 @@ public function testClassFromId()
608611
$this->assertEquals(CaseSensitiveClass::class, $container->getDefinition(CaseSensitiveClass::class)->getClass());
609612
}
610613

614+
public function testPrototype()
615+
{
616+
$container = new ContainerBuilder();
617+
$loader = new XmlFileLoader($container, new FileLocator(self::$fixturesPath.'/xml'));
618+
$loader->load('services_prototype.xml');
619+
620+
$ids = array_keys($container->getDefinitions());
621+
sort($ids);
622+
$this->assertSame(array(Prototype\Foo::class, Prototype\Sub\Bar::class), $ids);
623+
624+
$resources = $container->getResources();
625+
626+
$fixturesDir = dirname(__DIR__).DIRECTORY_SEPARATOR.'Fixtures'.DIRECTORY_SEPARATOR;
627+
$this->assertEquals(new FileResource($fixturesDir.'xml'.DIRECTORY_SEPARATOR.'services_prototype.xml'), $resources[0]);
628+
$this->assertEquals(new DirectoryResource($fixturesDir.'Prototype', '/^$/'), $resources[1]);
629+
$resources = array_map('strval', $resources);
630+
$this->assertContains('reflection.Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Foo', $resources);
631+
$this->assertContains('reflection.Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Sub\Bar', $resources);
632+
}
633+
611634
/**
612635
* @group legacy
613636
* @expectedDeprecation Using the attribute "class" is deprecated for the service "bar" which is defined as an alias %s.

0 commit comments

Comments
 (0)