Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 4fb4628

Browse filesBrowse files
committed
[Decorator] Add Decorator component and framework integration
1 parent f91514d commit 4fb4628
Copy full SHA for 4fb4628

35 files changed

+1143
-0
lines changed

‎composer.json

Copy file name to clipboardExpand all lines: composer.json
+1Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Bridge/Doctrine/CHANGELOG.md
+1Lines 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
---
+31Lines 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+
}
+54Lines 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+
}
+103Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Bridge/Doctrine/composer.json
+1Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
+2Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
+7Lines 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);
+40Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php
+2Lines 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);
+34Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/composer.json
+1Lines 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",
+3Lines 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
+3Lines 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
+27Lines 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+
}
+7Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.