Skip to content

Commit 4fb4628

Browse files
committed
[Decorator] Add Decorator component and framework integration
1 parent f91514d commit 4fb4628

35 files changed

+1143
-0
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"symfony/config": "self.version",
6767
"symfony/console": "self.version",
6868
"symfony/css-selector": "self.version",
69+
"symfony/decorator": "self.version",
6970
"symfony/dependency-injection": "self.version",
7071
"symfony/debug-bundle": "self.version",
7172
"symfony/doctrine-bridge": "self.version",

src/Symfony/Bridge/Doctrine/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ CHANGELOG
55
---
66

77
* Accept `ReadableCollection` in `CollectionToArrayTransformer`
8+
* Add `Transactional` attribute and `DoctrineTransactionDecorator`
89

910
7.1
1011
---
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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\Bridge\Doctrine\Decorator;
13+
14+
use Symfony\Component\Decorator\Attribute\DecoratorMetadata;
15+
16+
/**
17+
* Wraps persistence method operations within a single Doctrine transaction.
18+
*
19+
* @author Yonel Ceruto <open@yceruto.dev>
20+
*/
21+
#[\Attribute(\Attribute::TARGET_METHOD)]
22+
class Transactional extends DecoratorMetadata
23+
{
24+
/**
25+
* @param string|null $name The entity manager name (null for the default one)
26+
*/
27+
public function __construct(
28+
public ?string $name = null,
29+
) {
30+
}
31+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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\Bridge\Doctrine\Decorator;
13+
14+
use Doctrine\ORM\EntityManagerInterface;
15+
use Doctrine\Persistence\ManagerRegistry;
16+
use Symfony\Component\Decorator\DecoratorInterface;
17+
18+
/**
19+
* @author Yonel Ceruto <open@yceruto.dev>
20+
*/
21+
class TransactionalDecorator implements DecoratorInterface
22+
{
23+
public function __construct(
24+
private readonly ManagerRegistry $managerRegistry,
25+
) {
26+
}
27+
28+
public function decorate(\Closure $func, Transactional $transactional = new Transactional()): \Closure
29+
{
30+
$entityManager = $this->managerRegistry->getManager($transactional->name);
31+
32+
if (!$entityManager instanceof EntityManagerInterface) {
33+
throw new \RuntimeException(\sprintf('The manager "%s" is not an entity manager.', $transactional->name));
34+
}
35+
36+
return static function (mixed ...$args) use ($func, $entityManager) {
37+
$entityManager->getConnection()->beginTransaction();
38+
39+
try {
40+
$return = $func(...$args);
41+
42+
$entityManager->flush();
43+
$entityManager->getConnection()->commit();
44+
45+
return $return;
46+
} catch (\Throwable $e) {
47+
$entityManager->close();
48+
$entityManager->getConnection()->rollBack();
49+
50+
throw $e;
51+
}
52+
};
53+
}
54+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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\Bridge\Doctrine\Tests\Decorator;
13+
14+
use Doctrine\DBAL\Connection;
15+
use Doctrine\ORM\EntityManagerInterface;
16+
use Doctrine\Persistence\ManagerRegistry;
17+
use PHPUnit\Framework\TestCase;
18+
use Symfony\Bridge\Doctrine\Decorator\Transactional;
19+
use Symfony\Bridge\Doctrine\Decorator\TransactionalDecorator;
20+
use Symfony\Component\Decorator\CallableDecorator;
21+
use Symfony\Component\Decorator\Resolver\DecoratorResolver;
22+
23+
class DoctrineTransactionDecoratorTest extends TestCase
24+
{
25+
private ManagerRegistry $managerRegistry;
26+
private Connection $connection;
27+
private EntityManagerInterface $entityManager;
28+
private CallableDecorator $decorator;
29+
30+
protected function setUp(): void
31+
{
32+
$this->connection = $this->createMock(Connection::class);
33+
34+
$this->entityManager = $this->createMock(EntityManagerInterface::class);
35+
$this->entityManager->method('getConnection')->willReturn($this->connection);
36+
37+
$this->managerRegistry = $this->createMock(ManagerRegistry::class);
38+
$this->managerRegistry->method('getManager')->willReturn($this->entityManager);
39+
40+
$this->decorator = new CallableDecorator(new DecoratorResolver([
41+
TransactionalDecorator::class => fn () => new TransactionalDecorator($this->managerRegistry),
42+
]));
43+
}
44+
45+
public function testDecoratorWrapsInTransactionAndFlushes()
46+
{
47+
$handler = new TestHandler();
48+
49+
$this->connection->expects($this->once())->method('beginTransaction');
50+
$this->connection->expects($this->once())->method('commit');
51+
$this->entityManager->expects($this->once())->method('flush');
52+
53+
$result = $this->decorator->call($handler->handle(...));
54+
$this->assertSame('success', $result);
55+
}
56+
57+
public function testTransactionIsRolledBackOnException()
58+
{
59+
$this->connection->expects($this->once())->method('beginTransaction');
60+
$this->connection->expects($this->once())->method('rollBack');
61+
62+
$handler = new TestHandler();
63+
64+
$this->expectException(\RuntimeException::class);
65+
$this->expectExceptionMessage('A runtime error.');
66+
67+
$this->decorator->call($handler->handleWithError(...));
68+
}
69+
70+
public function testInvalidEntityManagerThrowsException()
71+
{
72+
$this->managerRegistry
73+
->method('getManager')
74+
->with('unknown_manager')
75+
->willThrowException(new \InvalidArgumentException());
76+
77+
$handler = new TestHandler();
78+
79+
$this->expectException(\InvalidArgumentException::class);
80+
81+
$this->decorator->call($handler->handleWithUnknownManager(...));
82+
}
83+
}
84+
85+
class TestHandler
86+
{
87+
#[Transactional]
88+
public function handle(): string
89+
{
90+
return 'success';
91+
}
92+
93+
#[Transactional]
94+
public function handleWithError(): void
95+
{
96+
throw new \RuntimeException('A runtime error.');
97+
}
98+
99+
#[Transactional('unknown_manager')]
100+
public function handleWithUnknownManager(): void
101+
{
102+
}
103+
}

src/Symfony/Bridge/Doctrine/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"require-dev": {
2828
"symfony/cache": "^6.4|^7.0",
2929
"symfony/config": "^6.4|^7.0",
30+
"symfony/decorator": "^7.2",
3031
"symfony/dependency-injection": "^6.4|^7.0",
3132
"symfony/doctrine-messenger": "^6.4|^7.0",
3233
"symfony/expression-language": "^6.4|^7.0",

src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ CHANGELOG
1414
* Enable `json_decode_detailed_errors` in the default serializer context in debug mode by default when `seld/jsonlint` is installed
1515
* Register `Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter` as a service named `serializer.name_converter.snake_case_to_camel_case` if available
1616
* Deprecate `session.sid_length` and `session.sid_bits_per_character` config options
17+
* Add Decorator component integration
18+
* Add controller decoration support
1719

1820
7.1
1921
---

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
use Symfony\Component\Console\DataCollector\CommandDataCollector;
5353
use Symfony\Component\Console\Debug\CliRequest;
5454
use Symfony\Component\Console\Messenger\RunCommandMessageHandler;
55+
use Symfony\Component\Decorator\DecoratorInterface;
5556
use Symfony\Component\DependencyInjection\Alias;
5657
use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument;
5758
use Symfony\Component\DependencyInjection\ChildDefinition;
@@ -224,6 +225,10 @@ public function load(array $configs, ContainerBuilder $container): void
224225
$loader->load('fragment_renderer.php');
225226
$loader->load('error_renderer.php');
226227

228+
if (ContainerBuilder::willBeAvailable('symfony/decorator', DecoratorInterface::class, ['symfony/framework-bundle'])) {
229+
$loader->load('decorator.php');
230+
}
231+
227232
if (!ContainerBuilder::willBeAvailable('symfony/clock', ClockInterface::class, ['symfony/framework-bundle'])) {
228233
$container->removeDefinition('clock');
229234
$container->removeAlias(ClockInterface::class);
@@ -658,6 +663,8 @@ public function load(array $configs, ContainerBuilder $container): void
658663
->addTag('mime.mime_type_guesser');
659664
$container->registerForAutoconfiguration(LoggerAwareInterface::class)
660665
->addMethodCall('setLogger', [new Reference('logger')]);
666+
$container->registerForAutoconfiguration(DecoratorInterface::class)
667+
->addTag('decorator');
661668

662669
$container->registerAttributeForAutoconfiguration(AsEventListener::class, static function (ChildDefinition $definition, AsEventListener $attribute, \ReflectionClass|\ReflectionMethod $reflector) {
663670
$tagAttributes = get_object_vars($attribute);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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\Bundle\FrameworkBundle\EventListener;
13+
14+
use Symfony\Component\Decorator\DecoratorInterface;
15+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
16+
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
17+
use Symfony\Component\HttpKernel\KernelEvents;
18+
19+
/**
20+
* @author Yonel Ceruto <open@yceruto.dev>
21+
*/
22+
class DecorateControllerListener implements EventSubscriberInterface
23+
{
24+
public function __construct(
25+
private readonly DecoratorInterface $decorator,
26+
) {
27+
}
28+
29+
public function decorate(ControllerArgumentsEvent $event): void
30+
{
31+
$event->setController($this->decorator->decorate($event->getController()(...)));
32+
}
33+
34+
public static function getSubscribedEvents(): array
35+
{
36+
return [
37+
KernelEvents::CONTROLLER_ARGUMENTS => ['decorate', -1024],
38+
];
39+
}
40+
}

src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
use Symfony\Component\Config\Resource\ClassExistenceResource;
3434
use Symfony\Component\Console\ConsoleEvents;
3535
use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass;
36+
use Symfony\Component\Decorator\DependencyInjection\DecoratorsPass;
3637
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
3738
use Symfony\Component\DependencyInjection\Compiler\RegisterReverseContainerPass;
3839
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -173,6 +174,7 @@ public function build(ContainerBuilder $container): void
173174
// must be registered after MonologBundle's LoggerChannelPass
174175
$container->addCompilerPass(new ErrorLoggerCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -32);
175176
$container->addCompilerPass(new VirtualRequestStackPass());
177+
$this->addCompilerPassIfExists($container, DecoratorsPass::class);
176178

177179
if ($container->getParameter('kernel.debug')) {
178180
$container->addCompilerPass(new AddDebugLogProcessorPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 2);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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\Loader\Configurator;
13+
14+
use Symfony\Bundle\FrameworkBundle\EventListener\DecorateControllerListener;
15+
use Symfony\Component\Decorator\CallableDecorator;
16+
use Symfony\Component\Decorator\DecoratorInterface;
17+
use Symfony\Component\Decorator\Resolver\DecoratorResolverInterface;
18+
19+
return static function (ContainerConfigurator $container) {
20+
$container->services()
21+
->set('decorator.callable_decorator', CallableDecorator::class)
22+
->args([
23+
service(DecoratorResolverInterface::class),
24+
])
25+
26+
->alias(DecoratorInterface::class, 'decorator.callable_decorator')
27+
28+
->set('decorator.decorate_controller.listener', DecorateControllerListener::class)
29+
->args([
30+
service('decorator.callable_decorator'),
31+
])
32+
->tag('kernel.event_subscriber')
33+
;
34+
};

src/Symfony/Bundle/FrameworkBundle/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"symfony/console": "^6.4|^7.0",
4343
"symfony/clock": "^6.4|^7.0",
4444
"symfony/css-selector": "^6.4|^7.0",
45+
"symfony/decorator": "^7.2",
4546
"symfony/dom-crawler": "^6.4|^7.0",
4647
"symfony/dotenv": "^6.4|^7.0",
4748
"symfony/polyfill-intl-icu": "~1.0",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/Tests export-ignore
2+
/phpunit.xml.dist export-ignore
3+
/.git* export-ignore
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
vendor/
2+
composer.lock
3+
phpunit.xml
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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\Decorator\Attribute;
13+
14+
/**
15+
* DecoratorMetadata is the abstract class for all decorator metadata attributes.
16+
*
17+
* @author Yonel Ceruto <open@yceruto.dev>
18+
*
19+
* @experimental
20+
*/
21+
abstract class DecoratorMetadata
22+
{
23+
public function decoratedBy(): string
24+
{
25+
return static::class.'Decorator';
26+
}
27+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CHANGELOG
2+
=========
3+
4+
7.2
5+
---
6+
7+
* Add the component as experimental

0 commit comments

Comments
 (0)