Skip to content

Commit 9d480cf

Browse files
committed
add Doctrine transaction decorator
1 parent 7846475 commit 9d480cf

File tree

5 files changed

+199
-0
lines changed

5 files changed

+199
-0
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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\Attribute;
13+
14+
use Symfony\Bridge\Doctrine\Decorator\DoctrineTransactionDecorator;
15+
use Symfony\Component\Decorator\Attribute\Decorate;
16+
17+
/**
18+
* Indicates that an object method should be wrapped within a single Doctrine transaction.
19+
*
20+
* @author Yonel Ceruto <open@yceruto.dev>
21+
*/
22+
#[\Attribute(\Attribute::TARGET_METHOD)]
23+
class Transactional extends Decorate
24+
{
25+
/**
26+
* @param string|null $name The entity manager name (null for the default one)
27+
*/
28+
public function __construct(?string $name = null)
29+
{
30+
parent::__construct(DoctrineTransactionDecorator::class, ['name' => $name]);
31+
}
32+
}

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 `DoctrineTransactionDecorator` and `Transactional` attribute
89

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

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",

0 commit comments

Comments
 (0)