Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions src/Codeception/Module/Symfony.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
* ## Parts
*
* * `services`: Includes methods related to the Symfony dependency injection container (DIC):
* * grabContainer
* * grabService
* * mockService
* * persistService
Expand Down Expand Up @@ -286,6 +287,11 @@ protected function onReconfigure(array $settings = []): void
* persistent: that preserves the open test transaction across reboots while
* the freshly rebuilt EntityManager runs on top of it.
*
* If the resolved EntityManager was closed by a failed flush, it is reopened through
* Doctrine's registry before being handed out, so one broken write does not cascade
* through the rest of the test. Nothing is cached: the container stays the single
* source of truth.
*
* @see https://github.com/Codeception/module-symfony/issues/34
*/
public function _getEntityManager(): EntityManagerInterface
Expand All @@ -300,9 +306,10 @@ public function _getEntityManager(): EntityManagerInterface
}
}

$em = $this->getService($emService);
if (!$em instanceof EntityManagerInterface) {
Assert::fail(sprintf('Service "%s" is not an instance of EntityManagerInterface.', $emService));
$em = $this->resolveEntityManager($emService);

if (!$em->isOpen() && $this->resetManagerThroughRegistry(null)) {
$em = $this->resolveEntityManager($emService);
}

return $em;
Expand Down
123 changes: 123 additions & 0 deletions src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

namespace Codeception\Module\Symfony;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Tools\SchemaValidator;
use Doctrine\Persistence\ManagerRegistry;
use PHPUnit\Framework\Assert;
use Symfony\Bridge\Doctrine\DataCollector\DoctrineDataCollector;
use Throwable;

use function array_count_values;
use function array_filter;
Expand Down Expand Up @@ -53,6 +56,28 @@ public function dontSeeDuplicateQueries(): void
);
}

/**
* Returns the Doctrine EntityManager the module is configured to use:
* the `em_service` option, `doctrine.orm.entity_manager` by default.
*
* The manager is resolved from the container on every call, so it always belongs to the
* kernel that is currently booted. Don't keep it in a property across requests:
* [`amOnPage()`](#amOnPage) and friends reboot the kernel, which builds a new manager.
*
* To reach a manager other than the configured one, grab it by service id:
* `$I->grabService('doctrine.orm.other_entity_manager')`.
*
* ```php
* <?php
* $em = $I->grabEntityManager();
* $user = $em->getRepository(User::class)->findOneBy(['email' => 'john_doe@gmail.com']);
* ```
*/
public function grabEntityManager(): EntityManagerInterface
{
return $this->_getEntityManager();
}

/**
* Returns the number of rows that match the given criteria for the
* specified Doctrine entity.
Expand Down Expand Up @@ -135,6 +160,53 @@ public function seeNumQueriesIsLessThan(int $expectedCount): void
);
}

/**
* Resets the Doctrine EntityManager.
*
* Doctrine closes the EntityManager as soon as an exception escapes a `flush()`:
* a unique constraint violation, a deadlock, a failed transaction. Every write after
* that throws `EntityManagerClosed`, which usually surfaces as an unrelated failure
* further down the test. Call this after deliberately provoking such an error to carry
* on with a healthy manager.
*
* If the manager is still open it is only cleared, detaching every managed entity,
* which is handy to prove that the next read really hits the database.
* The open test transaction is preserved either way: the manager is rebuilt,
* the DBAL connection underneath it is not.
*
* ```php
* <?php
* $I->amOnPage('/register');
* $I->resetDoctrineManager();
* $I->seeNumRecords(1, User::class);
* ```
*
* @param non-empty-string|null $name Manager name as registered in Doctrine's registry,
* `null` for the default one.
*/
public function resetDoctrineManager(?string $name = null): void
{
$em = $this->_getEntityManager();

if ($em->isOpen()) {
$em->clear();

return;
}

if (!$this->resetManagerThroughRegistry($name) || !$this->_getEntityManager()->isOpen()) {
$this->rebootClientKernel();
}

if (!$this->_getEntityManager()->isOpen()) {
Assert::fail(
"The Doctrine EntityManager is still closed after resetting it.\n"
. "Check that the module's 'em_service' option points at your entity manager "
. 'and that the container can rebuild it.'
);
}
}

/**
* Asserts that a given number of records exists for the entity.
* 'id' is the default search parameter.
Expand Down Expand Up @@ -244,4 +316,55 @@ private function isTransactionStatement(string $sql): bool
{
return preg_match('/^\s*("|`)?(START\s+TRANSACTION|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE\s+SAVEPOINT)\b/i', $sql) === 1;
}

/**
* Rebuilds the manager through Doctrine's registry, which swaps the lazy service in
* place: everything already holding the manager, the application and the Doctrine
* module included, sees the reopened one.
*
* Returns false when the app has no registry, or when the manager service is not lazy
* and therefore cannot be reset that way.
*
* @param non-empty-string|null $name
*/
private function resetManagerThroughRegistry(?string $name): bool
{
if (!interface_exists(ManagerRegistry::class)) {
return false;
}

$registry = $this->getService('doctrine');
if (!$registry instanceof ManagerRegistry) {
return false;
}

try {
$registry->resetManager($name);
} catch (Throwable) {
return false;
}

return true;
}

/**
* Resolves the configured entity manager service, failing with a hint
* instead of a type error when Doctrine isn't wired up.
*
* @param non-empty-string $serviceId
*/
private function resolveEntityManager(string $serviceId): EntityManagerInterface
{
$em = $this->getService($serviceId);

if (!$em instanceof EntityManagerInterface) {
Assert::fail(sprintf(
"The '%s' service is not a Doctrine EntityManager.\n"
. "Install and enable doctrine/doctrine-bundle, or point the module's 'em_service' option at your entity manager.",
$serviceId
));
}

return $em;
}
}
25 changes: 25 additions & 0 deletions src/Codeception/Module/Symfony/ServicesAssertionsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use InvalidArgumentException;
use PHPUnit\Framework\Assert;
use Symfony\Component\DependencyInjection\ContainerInterface;

trait ServicesAssertionsTrait
{
Expand All @@ -23,6 +24,30 @@ trait ServicesAssertionsTrait
*/
protected array $permanentServices = [];

/**
* Returns the Symfony dependency injection container (DIC).
*
* In the "test" environment this is Symfony's special `test.service_container`,
* so private services are reachable too, unlike
* `$I->grabService('kernel')->getContainer()`, which only ever exposes the public ones.
*
* The container belongs to the kernel that is currently booted, so grab it again after
* a request or a [`rebootClientKernel()`](#rebootClientKernel) instead of keeping it
* in a property.
*
* ```php
* <?php
* $container = $I->grabContainer();
* $isDebug = $container->getParameter('kernel.debug');
* ```
*
* @part services
*/
public function grabContainer(): ContainerInterface
{
return $this->_getContainer();
}

/**
* Grabs a service from the Symfony dependency injection container (DIC).
* In the "test" environment, Symfony uses a special `test.service_container`.
Expand Down
24 changes: 24 additions & 0 deletions tests/DoctrineAssertionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Codeception\Module\Symfony\DoctrineAssertionsTrait;
use PHPUnit\Framework\AssertionFailedError;
use Tests\App\Doctrine\TestManagerRegistry;
use Tests\App\Entity\User;
use Tests\App\Repository\UserRepository;
use Tests\App\Repository\UserRepositoryInterface;
Expand All @@ -30,6 +31,11 @@ public function testDontSeeDuplicateQueriesDetectsDuplicates(): void
$this->dontSeeDuplicateQueries();
}

public function testGrabEntityManager(): void
{
$this->assertSame($this->_getEntityManager(), $this->grabEntityManager());
}

public function testGrabNumRecords(): void
{
$this->assertSame(1, $this->grabNumRecords(User::class));
Expand All @@ -50,6 +56,24 @@ public function testSeeNumQueriesIsLessThan(): void
$this->seeNumQueriesIsLessThan(3);
}

public function testResetDoctrineManager(): void
{
$em = $this->grabEntityManager();
$user = $em->getRepository(User::class)->findOneBy(['email' => 'john_doe@gmail.com']);
$this->assertTrue($em->contains($user));

$this->resetDoctrineManager();
$this->assertFalse($this->grabEntityManager()->contains($user));

/** @var TestManagerRegistry $registry */
$registry = $this->grabService('doctrine');
$this->grabEntityManager()->close();
$this->resetDoctrineManager();

$this->assertSame(1, $registry->resets);
$this->assertTrue($this->grabEntityManager()->isOpen());
}

public function testSeeNumRecords(): void
{
$this->seeNumRecords(1, User::class);
Expand Down
8 changes: 8 additions & 0 deletions tests/ServicesAssertionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ final class ServicesAssertionsTest extends CodeceptTestCase
{
use ServicesAssertionsTrait;

public function testGrabContainer(): void
{
$container = $this->grabContainer();

$this->assertSame($this->_getContainer(), $container);
$this->assertTrue($container->hasParameter('app.param'));
}

public function testGrabService(): void
{
$this->assertIsObject($this->grabService('security.helper'));
Expand Down
43 changes: 33 additions & 10 deletions tests/_app/Doctrine/DoctrineSetup.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DriverManager;
use Doctrine\ORM\Configuration;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\ORMSetup;
Expand All @@ -21,10 +22,40 @@ public static function createConnection(): Connection

public static function createEntityManager(): EntityManagerInterface
{
if (self::$entityManager !== null && self::$entityManager->isOpen()) {
if (self::$entityManager !== null) {
return self::$entityManager;
}

$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);

return self::$entityManager = self::makeEntityManager($connection);
}

/**
* Rebuilds the manager on the same connection, mirroring what
* ManagerRegistry::resetManager() does for a lazy service. Reusing the connection
* matters here: the fixture database is in-memory, so reconnecting would lose the schema.
*/
public static function resetEntityManager(): void
{
$connection = self::createEntityManager()->getConnection();

self::$entityManager = self::makeEntityManager($connection);
}

private static function makeEntityManager(Connection $connection): EntityManagerInterface
{
$config = self::createConfiguration();

if (method_exists(EntityManager::class, 'create')) {
return EntityManager::create($connection, $config);
}

return new EntityManager($connection, $config);
}

private static function createConfiguration(): Configuration
{
$entityDir = dirname(__DIR__) . '/Entity';

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

$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);

if (method_exists(EntityManager::class, 'create')) {
self::$entityManager = EntityManager::create($connection, $config);
} else {
self::$entityManager = new EntityManager($connection, $config);
}

return self::$entityManager;
return $config;
}
}
Loading