Skip to content

Commit f91acad

Browse files
[DependencyInjection] Add #[WhenNot] attribute
1 parent 2be6b0c commit f91acad

21 files changed

+242
-15
lines changed

src/Symfony/Component/Config/CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.2
5+
---
6+
7+
* Add `#[WhenNot]` attribute to prevent service from being registered in a specific environment
8+
49
7.1
510
---
611

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\DependencyInjection\Attribute;
13+
14+
/**
15+
* An attribute to tell under which environment this class should NOT be registered as a service.
16+
*
17+
* @author Alexandre Daubois <alex.daubois@gmail.com>
18+
*/
19+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION | \Attribute::IS_REPEATABLE)]
20+
class WhenNot
21+
{
22+
public function __construct(
23+
public string $env,
24+
) {
25+
}
26+
}

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

+24-4
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
2222
use Symfony\Component\DependencyInjection\Attribute\Exclude;
2323
use Symfony\Component\DependencyInjection\Attribute\When;
24+
use Symfony\Component\DependencyInjection\Attribute\WhenNot;
2425
use Symfony\Component\DependencyInjection\ChildDefinition;
2526
use Symfony\Component\DependencyInjection\Compiler\RegisterAutoconfigureAttributesPass;
2627
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -154,14 +155,33 @@ public function registerClasses(Definition $prototype, string $namespace, string
154155
continue;
155156
}
156157
if ($this->env) {
157-
$attribute = null;
158-
foreach ($r->getAttributes(When::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
158+
$excluded = true;
159+
$whenAttributes = $r->getAttributes(When::class, \ReflectionAttribute::IS_INSTANCEOF);
160+
$notWhenAttributes = $r->getAttributes(WhenNot::class, \ReflectionAttribute::IS_INSTANCEOF);
161+
162+
if ($whenAttributes && $notWhenAttributes) {
163+
throw new LogicException(sprintf('The "%s" class cannot have both #[When] and #[WhenNot] attributes.', $class));
164+
}
165+
166+
if (!$whenAttributes && !$notWhenAttributes) {
167+
$excluded = false;
168+
}
169+
170+
foreach ($whenAttributes as $attribute) {
159171
if ($this->env === $attribute->newInstance()->env) {
160-
$attribute = null;
172+
$excluded = false;
161173
break;
162174
}
163175
}
164-
if (null !== $attribute) {
176+
177+
if ($notWhenAttributes) {
178+
$forbiddenEnvs = array_map(fn (\ReflectionAttribute $attribute) => $attribute->newInstance()->env, $notWhenAttributes);
179+
if (!\in_array($this->env, $forbiddenEnvs, true)) {
180+
$excluded = false;
181+
}
182+
}
183+
184+
if ($excluded) {
165185
$this->addContainerExcludedTag($class, $source);
166186
continue;
167187
}

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

+25-4
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
use Symfony\Component\Config\Builder\ConfigBuilderInterface;
1717
use Symfony\Component\Config\FileLocatorInterface;
1818
use Symfony\Component\DependencyInjection\Attribute\When;
19+
use Symfony\Component\DependencyInjection\Attribute\WhenNot;
1920
use Symfony\Component\DependencyInjection\Container;
2021
use Symfony\Component\DependencyInjection\ContainerBuilder;
2122
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
23+
use Symfony\Component\DependencyInjection\Exception\LogicException;
2224
use Symfony\Component\DependencyInjection\Extension\ConfigurationExtensionInterface;
2325
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
2426
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
@@ -97,14 +99,33 @@ private function executeCallback(callable $callback, ContainerConfigurator $cont
9799
$configBuilders = [];
98100
$r = new \ReflectionFunction($callback);
99101

100-
$attribute = null;
101-
foreach ($r->getAttributes(When::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
102+
$excluded = true;
103+
$whenAttributes = $r->getAttributes(When::class, \ReflectionAttribute::IS_INSTANCEOF);
104+
$notWhenAttributes = $r->getAttributes(WhenNot::class, \ReflectionAttribute::IS_INSTANCEOF);
105+
106+
if ($whenAttributes && $notWhenAttributes) {
107+
throw new LogicException('Using both #[When] and #[WhenNot] attributes on the same target is not allowed.');
108+
}
109+
110+
if (!$whenAttributes && !$notWhenAttributes) {
111+
$excluded = false;
112+
}
113+
114+
foreach ($whenAttributes as $attribute) {
102115
if ($this->env === $attribute->newInstance()->env) {
103-
$attribute = null;
116+
$excluded = false;
104117
break;
105118
}
106119
}
107-
if (null !== $attribute) {
120+
121+
if ($notWhenAttributes) {
122+
$forbiddenEnvs = array_map(fn (\ReflectionAttribute $attribute) => $attribute->newInstance()->env, $notWhenAttributes);
123+
if (!\in_array($this->env, $forbiddenEnvs, true)) {
124+
$excluded = false;
125+
}
126+
}
127+
128+
if ($excluded) {
108129
return;
109130
}
110131

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\BadAttributes;
4+
5+
use Symfony\Component\DependencyInjection\Attribute\WhenNot;
6+
use Symfony\Component\DependencyInjection\Attribute\When;
7+
8+
#[When(env: 'dev')]
9+
#[WhenNot(env: 'test')]
10+
class WhenNotWhenFoo
11+
{
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype;
4+
5+
use Symfony\Component\DependencyInjection\Attribute\WhenNot;
6+
7+
#[NeverInProduction]
8+
#[WhenNot(env: 'dev')]
9+
class NotFoo
10+
{
11+
public function __construct($bar = null, ?iterable $foo = null, ?object $baz = null)
12+
{
13+
}
14+
15+
public function setFoo(self $foo)
16+
{
17+
}
18+
}
19+
20+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION | \Attribute::IS_REPEATABLE)]
21+
class NeverInProduction extends WhenNot
22+
{
23+
public function __construct()
24+
{
25+
parent::__construct('prod');
26+
}
27+
}

src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/instanceof.expected.yml

+3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ services:
1616

1717
shared: false
1818
configurator: c
19+
Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\NotFoo:
20+
class: Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\NotFoo
21+
public: true
1922
foo:
2023
class: App\FooService
2124
public: true
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
use Symfony\Component\DependencyInjection\Attribute\WhenNot;
4+
5+
return #[WhenNot(env: 'prod')] function () {
6+
throw new RuntimeException('This code should not be run.');
7+
};

src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/prototype.expected.yml

+13
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@ services:
1616
message: '%service_id%'
1717
arguments: [1]
1818
factory: f
19+
Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\NotFoo:
20+
class: Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\NotFoo
21+
public: true
22+
tags:
23+
- foo
24+
- baz
25+
deprecated:
26+
package: vendor/package
27+
version: '1.1'
28+
message: '%service_id%'
29+
lazy: true
30+
arguments: [1]
31+
factory: f
1932
Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Sub\Bar:
2033
class: Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Sub\Bar
2134
public: true

src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/prototype.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
$di->load(Prototype::class.'\\', '../Prototype')
1111
->public()
1212
->autoconfigure()
13-
->exclude('../Prototype/{OtherDir,BadClasses,SinglyImplementedInterface,StaticConstructor}')
13+
->exclude('../Prototype/{OtherDir,BadClasses,BadAttributes,SinglyImplementedInterface,StaticConstructor}')
1414
->factory('f')
1515
->deprecate('vendor/package', '1.1', '%service_id%')
1616
->args([0])

src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/prototype_array.expected.yml

+13
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@ services:
1616
message: '%service_id%'
1717
arguments: [1]
1818
factory: f
19+
Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\NotFoo:
20+
class: Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\NotFoo
21+
public: true
22+
tags:
23+
- foo
24+
- baz
25+
deprecated:
26+
package: vendor/package
27+
version: '1.1'
28+
message: '%service_id%'
29+
lazy: true
30+
arguments: [1]
31+
factory: f
1932
Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Sub\Bar:
2033
class: Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Sub\Bar
2134
public: true

src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/prototype_array.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
$di->load(Prototype::class.'\\', '../Prototype')
1111
->public()
1212
->autoconfigure()
13-
->exclude(['../Prototype/OtherDir', '../Prototype/BadClasses', '../Prototype/SinglyImplementedInterface', '../Prototype/StaticConstructor'])
13+
->exclude(['../Prototype/OtherDir', '../Prototype/BadClasses', '../Prototype/BadAttributes', '../Prototype/SinglyImplementedInterface', '../Prototype/StaticConstructor'])
1414
->factory('f')
1515
->deprecate('vendor/package', '1.1', '%service_id%')
1616
->args([0])
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
use Symfony\Component\DependencyInjection\Attribute\WhenNot;
4+
use Symfony\Component\DependencyInjection\Attribute\When;
5+
6+
return #[When(env: 'dev')] #[WhenNot(env: 'prod')] function () {
7+
throw new RuntimeException('This code should not be run.');
8+
};
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<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 https://symfony.com/schema/dic/services/services-1.0.xsd">
33
<services>
4-
<prototype namespace="Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\" resource="../Prototype/*" exclude="../Prototype/{OtherDir,BadClasses,SinglyImplementedInterface,StaticConstructor}" />
4+
<prototype namespace="Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\" resource="../Prototype/*" exclude="../Prototype/{OtherDir,BadClasses,BadAttributes,SinglyImplementedInterface,StaticConstructor}" />
55
</services>
66
</container>

src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/services_prototype_array.xml

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<prototype namespace="Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\" resource="../Prototype/*">
55
<exclude>../Prototype/OtherDir</exclude>
66
<exclude>../Prototype/BadClasses</exclude>
7+
<exclude>../Prototype/BadAttributes</exclude>
78
<exclude>../Prototype/SinglyImplementedInterface</exclude>
89
<exclude>../Prototype/StaticConstructor</exclude>
910
</prototype>

src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/services_prototype_array_with_space_node.xml

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<prototype namespace="Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\" resource="../Prototype/*">
55
<exclude>../Prototype/OtherDir</exclude>
66
<exclude>../Prototype/BadClasses</exclude>
7+
<exclude>../Prototype/BadAttributes</exclude>
78
<exclude>../Prototype/SinglyImplementedInterface</exclude>
89
<exclude>../Prototype/StaticConstructor</exclude>
910
<exclude> </exclude>
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
services:
22
Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\:
33
resource: ../Prototype
4-
exclude: '../Prototype/{OtherDir,BadClasses,SinglyImplementedInterface,StaticConstructor}'
4+
exclude: '../Prototype/{OtherDir,BadClasses,BadAttributes,SinglyImplementedInterface,StaticConstructor}'

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

+43
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\BadClasses\MissingParent;
3030
use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Foo;
3131
use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\FooInterface;
32+
use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\NotFoo;
3233
use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\OtherDir\AnotherSub;
3334
use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\OtherDir\AnotherSub\DeeperBaz;
3435
use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\OtherDir\Baz;
@@ -196,6 +197,7 @@ public function testNestedRegisterClasses()
196197
$this->assertTrue($container->has(Bar::class));
197198
$this->assertTrue($container->has(Baz::class));
198199
$this->assertTrue($container->has(Foo::class));
200+
$this->assertTrue($container->has(NotFoo::class));
199201

200202
$this->assertEquals([FooInterface::class], array_keys($container->getAliases()));
201203

@@ -302,6 +304,47 @@ public function testRegisterClassesWithWhenEnv(?string $env, bool $expected)
302304
$this->assertSame($expected, $container->getDefinition(Foo::class)->hasTag('container.excluded'));
303305
}
304306

307+
/**
308+
* @dataProvider provideEnvAndExpectedExclusions
309+
*/
310+
public function testRegisterWithNotWhenAttributes(string $env, bool $expectedNotFooExclusion)
311+
{
312+
$container = new ContainerBuilder();
313+
$loader = new TestFileLoader($container, new FileLocator(self::$fixturesPath.'/Fixtures'), $env);
314+
315+
$loader->registerClasses(
316+
(new Definition())->setAutoconfigured(true),
317+
'Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\\',
318+
'Prototype/*',
319+
'Prototype/BadAttributes/*'
320+
);
321+
322+
$this->assertTrue($container->has(NotFoo::class));
323+
$this->assertSame($expectedNotFooExclusion, $container->getDefinition(NotFoo::class)->hasTag('container.excluded'));
324+
}
325+
326+
public static function provideEnvAndExpectedExclusions(): iterable
327+
{
328+
yield ['dev', true];
329+
yield ['prod', true];
330+
yield ['test', false];
331+
}
332+
333+
public function testRegisterThrowsWithBothWhenAndNotWhenAttribute()
334+
{
335+
$container = new ContainerBuilder();
336+
$loader = new TestFileLoader($container, new FileLocator(self::$fixturesPath.'/Fixtures'), 'dev');
337+
338+
$this->expectException(LogicException::class);
339+
$this->expectExceptionMessage('The "Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\BadAttributes\WhenNotWhenFoo" class cannot have both #[When] and #[WhenNot] attributes.');
340+
341+
$loader->registerClasses(
342+
(new Definition())->setAutoconfigured(true),
343+
'Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\BadAttributes\\',
344+
'Prototype/BadAttributes/*',
345+
);
346+
}
347+
305348
/**
306349
* @dataProvider provideResourcesWithAsAliasAttributes
307350
*/

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

+24
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Symfony\Component\DependencyInjection\Dumper\PhpDumper;
2323
use Symfony\Component\DependencyInjection\Dumper\YamlDumper;
2424
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
25+
use Symfony\Component\DependencyInjection\Exception\LogicException;
2526
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
2627
use Symfony\Component\DependencyInjection\Reference;
2728
use Symfony\Component\DependencyInjection\Tests\Fixtures\FooClassWithEnumAttribute;
@@ -226,6 +227,29 @@ public function testWhenEnv()
226227
$loader->load($fixtures.'/config/when_env.php');
227228
}
228229

230+
public function testNotWhenEnv()
231+
{
232+
$this->expectNotToPerformAssertions();
233+
234+
$fixtures = realpath(__DIR__.'/../Fixtures');
235+
$container = new ContainerBuilder();
236+
$loader = new PhpFileLoader($container, new FileLocator(), 'prod', new ConfigBuilderGenerator(sys_get_temp_dir()));
237+
238+
$loader->load($fixtures.'/config/not_when_env.php');
239+
}
240+
241+
public function testUsingBothWhenAndNotWhenEnv()
242+
{
243+
$fixtures = realpath(__DIR__.'/../Fixtures');
244+
$container = new ContainerBuilder();
245+
$loader = new PhpFileLoader($container, new FileLocator(), 'prod', new ConfigBuilderGenerator(sys_get_temp_dir()));
246+
247+
$this->expectException(LogicException::class);
248+
$this->expectExceptionMessage('Using both #[When] and #[WhenNot] attributes on the same target is not allowed.');
249+
250+
$loader->load($fixtures.'/config/when_not_when_env.php');
251+
}
252+
229253
public function testServiceWithServiceLocatorArgument()
230254
{
231255
$fixtures = realpath(__DIR__.'/../Fixtures');

0 commit comments

Comments
 (0)