Skip to content

Commit 53c37cc

Browse files
authored
Add grabEntityManager(), resetDoctrineManager() and grabContainer() (#248)
* Add grabEntityManager() to reach the Doctrine EntityManager _getEntityManager() is underscore-prefixed, so ModuleContainer excludes it from the Actor and it cannot be called as $I->_getEntityManager(). Reaching the EntityManager from a test therefore meant a getModule('Symfony') reach-through, a grabService() call with a hardcoded service id, or a custom Helper module. grabEntityManager() exposes it on the Actor. It delegates to _getEntityManager(), so it keeps resolving the manager from the current container on every call and honours the em_service option. Extract the service resolution into resolveEntityManager(), which also improves the failure message: the previous one reported "is not an instance of EntityManagerInterface" both when the service was missing entirely and when it was the wrong type, without hinting that doctrine-bundle might not be installed or that em_service might be misconfigured. * Recover from a closed Doctrine EntityManager Doctrine closes the EntityManager when an exception escapes a flush(), and every write after that throws EntityManagerClosed. Users worked around this by shipping an EntityManagerReset helper module that resets the manager in _before; it is the most copy-pasted workaround in the dependent ecosystem. Add resetDoctrineManager() as the supported replacement: an open manager is cleared, a closed one is rebuilt through Doctrine's registry, which swaps the lazy service in place so the application and the Doctrine module see the reopened manager too. When the registry cannot do it, because the manager service is not lazy on Symfony 5.4 or the app has no DoctrineBundle, the client kernel is rebooted instead. The DBAL connection stays a permanent service either way, so the open test transaction survives. _getEntityManager() applies the same registry recovery before handing the manager out, so a single broken write no longer cascades through the rest of the test. Nothing is cached; the container remains the source of truth. The fixture app wires Doctrine by hand and had no registry to reset through, so add a minimal TestManagerRegistry, rebuild the manager on the existing connection (the fixture database is in-memory, reconnecting would drop the schema), and stop sharing the entity manager service so the container hands out the rebuilt one. * Add grabContainer() to reach the service container _getContainer() is underscore-prefixed, so ModuleContainer excludes it from the Actor. Tests that need the container reached it through a getModule('Symfony') call in a custom Helper, or wrote $I->grabService('kernel')->getContainer(). That second form is subtly wrong: the kernel exposes the application container, which cannot see private services, while _getContainer() returns Symfony's test.service_container, which can. grabContainer() puts the correct one on the Actor, tagged as part of the services part alongside grabService().
1 parent b336cca commit 53c37cc

8 files changed

Lines changed: 275 additions & 14 deletions

File tree

‎src/Codeception/Module/Symfony.php‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
* ## Parts
111111
*
112112
* * `services`: Includes methods related to the Symfony dependency injection container (DIC):
113+
* * grabContainer
113114
* * grabService
114115
* * mockService
115116
* * persistService
@@ -286,6 +287,11 @@ protected function onReconfigure(array $settings = []): void
286287
* persistent: that preserves the open test transaction across reboots while
287288
* the freshly rebuilt EntityManager runs on top of it.
288289
*
290+
* If the resolved EntityManager was closed by a failed flush, it is reopened through
291+
* Doctrine's registry before being handed out, so one broken write does not cascade
292+
* through the rest of the test. Nothing is cached: the container stays the single
293+
* source of truth.
294+
*
289295
* @see https://github.com/Codeception/module-symfony/issues/34
290296
*/
291297
public function _getEntityManager(): EntityManagerInterface
@@ -300,9 +306,10 @@ public function _getEntityManager(): EntityManagerInterface
300306
}
301307
}
302308

303-
$em = $this->getService($emService);
304-
if (!$em instanceof EntityManagerInterface) {
305-
Assert::fail(sprintf('Service "%s" is not an instance of EntityManagerInterface.', $emService));
309+
$em = $this->resolveEntityManager($emService);
310+
311+
if (!$em->isOpen() && $this->resetManagerThroughRegistry(null)) {
312+
$em = $this->resolveEntityManager($emService);
306313
}
307314

308315
return $em;

‎src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44

55
namespace Codeception\Module\Symfony;
66

7+
use Doctrine\ORM\EntityManagerInterface;
78
use Doctrine\ORM\EntityRepository;
89
use Doctrine\ORM\Tools\SchemaValidator;
10+
use Doctrine\Persistence\ManagerRegistry;
911
use PHPUnit\Framework\Assert;
1012
use Symfony\Bridge\Doctrine\DataCollector\DoctrineDataCollector;
13+
use Throwable;
1114

1215
use function array_count_values;
1316
use function array_filter;
@@ -53,6 +56,28 @@ public function dontSeeDuplicateQueries(): void
5356
);
5457
}
5558

59+
/**
60+
* Returns the Doctrine EntityManager the module is configured to use:
61+
* the `em_service` option, `doctrine.orm.entity_manager` by default.
62+
*
63+
* The manager is resolved from the container on every call, so it always belongs to the
64+
* kernel that is currently booted. Don't keep it in a property across requests:
65+
* [`amOnPage()`](#amOnPage) and friends reboot the kernel, which builds a new manager.
66+
*
67+
* To reach a manager other than the configured one, grab it by service id:
68+
* `$I->grabService('doctrine.orm.other_entity_manager')`.
69+
*
70+
* ```php
71+
* <?php
72+
* $em = $I->grabEntityManager();
73+
* $user = $em->getRepository(User::class)->findOneBy(['email' => 'john_doe@gmail.com']);
74+
* ```
75+
*/
76+
public function grabEntityManager(): EntityManagerInterface
77+
{
78+
return $this->_getEntityManager();
79+
}
80+
5681
/**
5782
* Returns the number of rows that match the given criteria for the
5883
* specified Doctrine entity.
@@ -135,6 +160,53 @@ public function seeNumQueriesIsLessThan(int $expectedCount): void
135160
);
136161
}
137162

163+
/**
164+
* Resets the Doctrine EntityManager.
165+
*
166+
* Doctrine closes the EntityManager as soon as an exception escapes a `flush()`:
167+
* a unique constraint violation, a deadlock, a failed transaction. Every write after
168+
* that throws `EntityManagerClosed`, which usually surfaces as an unrelated failure
169+
* further down the test. Call this after deliberately provoking such an error to carry
170+
* on with a healthy manager.
171+
*
172+
* If the manager is still open it is only cleared, detaching every managed entity,
173+
* which is handy to prove that the next read really hits the database.
174+
* The open test transaction is preserved either way: the manager is rebuilt,
175+
* the DBAL connection underneath it is not.
176+
*
177+
* ```php
178+
* <?php
179+
* $I->amOnPage('/register');
180+
* $I->resetDoctrineManager();
181+
* $I->seeNumRecords(1, User::class);
182+
* ```
183+
*
184+
* @param non-empty-string|null $name Manager name as registered in Doctrine's registry,
185+
* `null` for the default one.
186+
*/
187+
public function resetDoctrineManager(?string $name = null): void
188+
{
189+
$em = $this->_getEntityManager();
190+
191+
if ($em->isOpen()) {
192+
$em->clear();
193+
194+
return;
195+
}
196+
197+
if (!$this->resetManagerThroughRegistry($name) || !$this->_getEntityManager()->isOpen()) {
198+
$this->rebootClientKernel();
199+
}
200+
201+
if (!$this->_getEntityManager()->isOpen()) {
202+
Assert::fail(
203+
"The Doctrine EntityManager is still closed after resetting it.\n"
204+
. "Check that the module's 'em_service' option points at your entity manager "
205+
. 'and that the container can rebuild it.'
206+
);
207+
}
208+
}
209+
138210
/**
139211
* Asserts that a given number of records exists for the entity.
140212
* 'id' is the default search parameter.
@@ -244,4 +316,55 @@ private function isTransactionStatement(string $sql): bool
244316
{
245317
return preg_match('/^\s*("|`)?(START\s+TRANSACTION|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE\s+SAVEPOINT)\b/i', $sql) === 1;
246318
}
319+
320+
/**
321+
* Rebuilds the manager through Doctrine's registry, which swaps the lazy service in
322+
* place: everything already holding the manager, the application and the Doctrine
323+
* module included, sees the reopened one.
324+
*
325+
* Returns false when the app has no registry, or when the manager service is not lazy
326+
* and therefore cannot be reset that way.
327+
*
328+
* @param non-empty-string|null $name
329+
*/
330+
private function resetManagerThroughRegistry(?string $name): bool
331+
{
332+
if (!interface_exists(ManagerRegistry::class)) {
333+
return false;
334+
}
335+
336+
$registry = $this->getService('doctrine');
337+
if (!$registry instanceof ManagerRegistry) {
338+
return false;
339+
}
340+
341+
try {
342+
$registry->resetManager($name);
343+
} catch (Throwable) {
344+
return false;
345+
}
346+
347+
return true;
348+
}
349+
350+
/**
351+
* Resolves the configured entity manager service, failing with a hint
352+
* instead of a type error when Doctrine isn't wired up.
353+
*
354+
* @param non-empty-string $serviceId
355+
*/
356+
private function resolveEntityManager(string $serviceId): EntityManagerInterface
357+
{
358+
$em = $this->getService($serviceId);
359+
360+
if (!$em instanceof EntityManagerInterface) {
361+
Assert::fail(sprintf(
362+
"The '%s' service is not a Doctrine EntityManager.\n"
363+
. "Install and enable doctrine/doctrine-bundle, or point the module's 'em_service' option at your entity manager.",
364+
$serviceId
365+
));
366+
}
367+
368+
return $em;
369+
}
247370
}

‎src/Codeception/Module/Symfony/ServicesAssertionsTrait.php‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use InvalidArgumentException;
88
use PHPUnit\Framework\Assert;
9+
use Symfony\Component\DependencyInjection\ContainerInterface;
910

1011
trait ServicesAssertionsTrait
1112
{
@@ -23,6 +24,30 @@ trait ServicesAssertionsTrait
2324
*/
2425
protected array $permanentServices = [];
2526

27+
/**
28+
* Returns the Symfony dependency injection container (DIC).
29+
*
30+
* In the "test" environment this is Symfony's special `test.service_container`,
31+
* so private services are reachable too, unlike
32+
* `$I->grabService('kernel')->getContainer()`, which only ever exposes the public ones.
33+
*
34+
* The container belongs to the kernel that is currently booted, so grab it again after
35+
* a request or a [`rebootClientKernel()`](#rebootClientKernel) instead of keeping it
36+
* in a property.
37+
*
38+
* ```php
39+
* <?php
40+
* $container = $I->grabContainer();
41+
* $isDebug = $container->getParameter('kernel.debug');
42+
* ```
43+
*
44+
* @part services
45+
*/
46+
public function grabContainer(): ContainerInterface
47+
{
48+
return $this->_getContainer();
49+
}
50+
2651
/**
2752
* Grabs a service from the Symfony dependency injection container (DIC).
2853
* In the "test" environment, Symfony uses a special `test.service_container`.

‎tests/DoctrineAssertionsTest.php‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Codeception\Module\Symfony\DoctrineAssertionsTrait;
88
use PHPUnit\Framework\AssertionFailedError;
9+
use Tests\App\Doctrine\TestManagerRegistry;
910
use Tests\App\Entity\User;
1011
use Tests\App\Repository\UserRepository;
1112
use Tests\App\Repository\UserRepositoryInterface;
@@ -30,6 +31,11 @@ public function testDontSeeDuplicateQueriesDetectsDuplicates(): void
3031
$this->dontSeeDuplicateQueries();
3132
}
3233

34+
public function testGrabEntityManager(): void
35+
{
36+
$this->assertSame($this->_getEntityManager(), $this->grabEntityManager());
37+
}
38+
3339
public function testGrabNumRecords(): void
3440
{
3541
$this->assertSame(1, $this->grabNumRecords(User::class));
@@ -50,6 +56,24 @@ public function testSeeNumQueriesIsLessThan(): void
5056
$this->seeNumQueriesIsLessThan(3);
5157
}
5258

59+
public function testResetDoctrineManager(): void
60+
{
61+
$em = $this->grabEntityManager();
62+
$user = $em->getRepository(User::class)->findOneBy(['email' => 'john_doe@gmail.com']);
63+
$this->assertTrue($em->contains($user));
64+
65+
$this->resetDoctrineManager();
66+
$this->assertFalse($this->grabEntityManager()->contains($user));
67+
68+
/** @var TestManagerRegistry $registry */
69+
$registry = $this->grabService('doctrine');
70+
$this->grabEntityManager()->close();
71+
$this->resetDoctrineManager();
72+
73+
$this->assertSame(1, $registry->resets);
74+
$this->assertTrue($this->grabEntityManager()->isOpen());
75+
}
76+
5377
public function testSeeNumRecords(): void
5478
{
5579
$this->seeNumRecords(1, User::class);

‎tests/ServicesAssertionsTest.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ final class ServicesAssertionsTest extends CodeceptTestCase
1313
{
1414
use ServicesAssertionsTrait;
1515

16+
public function testGrabContainer(): void
17+
{
18+
$container = $this->grabContainer();
19+
20+
$this->assertSame($this->_getContainer(), $container);
21+
$this->assertTrue($container->hasParameter('app.param'));
22+
}
23+
1624
public function testGrabService(): void
1725
{
1826
$this->assertIsObject($this->grabService('security.helper'));

‎tests/_app/Doctrine/DoctrineSetup.php‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Doctrine\DBAL\Connection;
88
use Doctrine\DBAL\DriverManager;
9+
use Doctrine\ORM\Configuration;
910
use Doctrine\ORM\EntityManager;
1011
use Doctrine\ORM\EntityManagerInterface;
1112
use Doctrine\ORM\ORMSetup;
@@ -21,10 +22,40 @@ public static function createConnection(): Connection
2122

2223
public static function createEntityManager(): EntityManagerInterface
2324
{
24-
if (self::$entityManager !== null && self::$entityManager->isOpen()) {
25+
if (self::$entityManager !== null) {
2526
return self::$entityManager;
2627
}
2728

29+
$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
30+
31+
return self::$entityManager = self::makeEntityManager($connection);
32+
}
33+
34+
/**
35+
* Rebuilds the manager on the same connection, mirroring what
36+
* ManagerRegistry::resetManager() does for a lazy service. Reusing the connection
37+
* matters here: the fixture database is in-memory, so reconnecting would lose the schema.
38+
*/
39+
public static function resetEntityManager(): void
40+
{
41+
$connection = self::createEntityManager()->getConnection();
42+
43+
self::$entityManager = self::makeEntityManager($connection);
44+
}
45+
46+
private static function makeEntityManager(Connection $connection): EntityManagerInterface
47+
{
48+
$config = self::createConfiguration();
49+
50+
if (method_exists(EntityManager::class, 'create')) {
51+
return EntityManager::create($connection, $config);
52+
}
53+
54+
return new EntityManager($connection, $config);
55+
}
56+
57+
private static function createConfiguration(): Configuration
58+
{
2859
$entityDir = dirname(__DIR__) . '/Entity';
2960

3061
if (method_exists(ORMSetup::class, 'createAttributeMetadataConfig')) {
@@ -46,14 +77,6 @@ public static function createEntityManager(): EntityManagerInterface
4677
$config->enableNativeLazyObjects(true);
4778
}
4879

49-
$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
50-
51-
if (method_exists(EntityManager::class, 'create')) {
52-
self::$entityManager = EntityManager::create($connection, $config);
53-
} else {
54-
self::$entityManager = new EntityManager($connection, $config);
55-
}
56-
57-
return self::$entityManager;
80+
return $config;
5881
}
5982
}

0 commit comments

Comments
 (0)