Skip to content

Commit 935d4ba

Browse files
SamMousasamdark
authored andcommitted
Unbind specific event handler instead of all class level events when … (#5045)
* Unbind specific event handler instead of all class level events when loading fixtures * Refactored connecition / transaction watching to separate classes. * Fixed Nitpick-CI * Use the connection watcher for closing fixture connections as well. * Prevent crash in case database connection for a transaction is closed
1 parent 4443518 commit 935d4ba

File tree

3 files changed

+191
-89
lines changed

3 files changed

+191
-89
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
4+
namespace Codeception\Lib\Connector\Yii2;
5+
6+
use yii\base\Event;
7+
use yii\db\Connection;
8+
9+
/**
10+
* Class ConnectionWatcher
11+
* This class will watch for new database connection and store a reference to the connection object.
12+
* @package Codeception\Lib\Connector\Yii2
13+
*/
14+
class ConnectionWatcher
15+
{
16+
private $handler;
17+
18+
/** @var Connection[] */
19+
private $connections = [];
20+
21+
public function __construct()
22+
{
23+
$this->handler = function (Event $event) {
24+
if ($event->sender instanceof Connection) {
25+
$this->connectionOpened($event->sender);
26+
}
27+
};
28+
}
29+
30+
protected function connectionOpened(Connection $connection)
31+
{
32+
$this->debug('Connection opened!');
33+
if ($connection instanceof Connection) {
34+
$this->connections[] = $connection;
35+
}
36+
}
37+
38+
public function start()
39+
{
40+
Event::on(Connection::class, Connection::EVENT_AFTER_OPEN, $this->handler);
41+
$this->debug('watching new connections');
42+
}
43+
44+
public function stop()
45+
{
46+
Event::off(Connection::class, Connection::EVENT_AFTER_OPEN, $this->handler);
47+
$this->debug('no longer watching new connections');
48+
}
49+
50+
public function closeAll()
51+
{
52+
$count = count($this->connections);
53+
$this->debug("closing all ($count) connections");
54+
foreach ($this->connections as $connection) {
55+
$connection->close();
56+
}
57+
}
58+
59+
protected function debug($message)
60+
{
61+
$title = (new \ReflectionClass($this))->getShortName();
62+
if (is_array($message) or is_object($message)) {
63+
$message = stripslashes(json_encode($message));
64+
}
65+
codecept_debug("[$title] $message");
66+
}
67+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
4+
namespace Codeception\Lib\Connector\Yii2;
5+
6+
use yii\base\Event;
7+
use yii\db\Connection;
8+
use yii\db\Transaction;
9+
10+
/**
11+
* Class TransactionForcer
12+
* This class adds support for forcing transactions as well as reusing PDO objects.
13+
* @package Codeception\Lib\Connector\Yii2
14+
*/
15+
class TransactionForcer extends ConnectionWatcher
16+
{
17+
private $ignoreCollidingDSN;
18+
19+
private $pdoCache = [];
20+
21+
private $dsnCache;
22+
23+
private $transactions = [];
24+
25+
public function __construct(
26+
$ignoreCollidingDSN
27+
) {
28+
parent::__construct();
29+
$this->ignoreCollidingDSN = $ignoreCollidingDSN;
30+
}
31+
32+
33+
protected function connectionOpened(Connection $connection)
34+
{
35+
parent::connectionOpened($connection);
36+
/**
37+
* We should check if the known PDO objects are the same, in which case we should reuse the PDO
38+
* object so only 1 transaction is started and multiple connections to the same database see the
39+
* same data (due to writes inside a transaction not being visible from the outside).
40+
*
41+
*/
42+
$key = md5(json_encode([
43+
'dsn' => $connection->dsn,
44+
'user' => $connection->username,
45+
'pass' => $connection->password,
46+
'attributes' => $connection->attributes,
47+
'emulatePrepare' => $connection->emulatePrepare,
48+
'charset' => $connection->charset
49+
]));
50+
51+
/*
52+
* If keys match we assume connections are "similar enough".
53+
*/
54+
if (isset($this->pdoCache[$key])) {
55+
$connection->pdo = $this->pdoCache[$key];
56+
} else {
57+
$this->pdoCache[$key] = $connection->pdo;
58+
}
59+
60+
if (isset($this->dsnCache[$connection->dsn])
61+
&& $this->dsnCache[$connection->dsn] !== $key
62+
&& !$this->ignoreCollidingDSN
63+
) {
64+
$this->debug(<<<TEXT
65+
You use multiple connections to the same DSN ({$connection->dsn}) with different configuration.
66+
These connections will not see the same database state since we cannot share a transaction between different PDO
67+
instances.
68+
You can remove this message by adding 'ignoreCollidingDSN = true' in the module configuration.
69+
TEXT
70+
);
71+
Debug::pause();
72+
}
73+
74+
if (isset($this->transactions[$key])) {
75+
$this->debug('Reusing PDO, so no need for a new transaction');
76+
return;
77+
}
78+
79+
$this->debug('Transaction started for: ' . $connection->dsn);
80+
$this->transactions[$key] = $connection->beginTransaction();
81+
}
82+
83+
public function rollbackAll()
84+
{
85+
/** @var Transaction $transaction */
86+
foreach ($this->transactions as $transaction) {
87+
if ($transaction->db->isActive) {
88+
$transaction->rollBack();
89+
$this->debug('Transaction cancelled; all changes reverted.');
90+
}
91+
}
92+
93+
$this->transactions = [];
94+
$this->pdoCache = [];
95+
$this->dsnCache = [];
96+
}
97+
}

src/Codeception/Module/Yii2.php

Lines changed: 27 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -167,22 +167,21 @@ class Yii2 extends Framework implements ActiveRecord, PartedModule
167167
protected $requiredFields = ['configFile'];
168168

169169
/**
170-
* @var array Array of Transaction objects indexed by a string key
171-
*/
172-
private $transactions = [];
173-
/**
174-
* @var \PDO[] Array of PDO objects indexed by a string key
170+
* @var Yii2Connector\FixturesStore[]
175171
*/
176-
private $pdoCache = [];
172+
public $loadedFixtures = [];
173+
177174
/**
178-
* @var string[] Array of cache keys indexes by their DSN
175+
* Helper to manage database connections
176+
* @var Yii2Connector\ConnectionWatcher
179177
*/
180-
private $dsnCache = [];
178+
private $connectionWatcher;
181179

182180
/**
183-
* @var Yii2Connector\FixturesStore[]
181+
* Helper to force database transaction
182+
* @var Yii2Connector\TransactionForcer
184183
*/
185-
public $loadedFixtures = [];
184+
private $transactionForcer;
186185

187186
/**
188187
* @var array The contents of $_SERVER upon initialization of this object.
@@ -299,13 +298,17 @@ public function _before(TestInterface $test)
299298
$this->recreateClient();
300299
$this->client->startApp();
301300

301+
$this->connectionWatcher = new Yii2Connector\ConnectionWatcher();
302+
$this->connectionWatcher->start();
303+
302304
// load fixtures before db transaction
303305
if ($test instanceof \Codeception\Test\Cest) {
304306
$this->loadFixtures($test->getTestClass());
305307
} else {
306308
$this->loadFixtures($test);
307309
}
308310

311+
309312
$this->startTransactions();
310313
}
311314

@@ -317,24 +320,14 @@ public function _before(TestInterface $test)
317320
private function loadFixtures($test)
318321
{
319322
$this->debugSection('Fixtures', 'Loading fixtures');
320-
/** @var Connection[] $connections */
321-
$connections = [];
322-
// Register event handler.
323-
Event::on(Connection::class, Connection::EVENT_AFTER_OPEN, function (Event $event) use (&$connections) {
324-
$this->debugSection('Fixtures', 'Opened database connection: ' . $event->sender->dsn);
325-
$connections[] = $event->sender;
326-
});
327323
if (empty($this->loadedFixtures)
328324
&& method_exists($test, $this->_getConfig('fixturesMethod'))
329325
) {
326+
$connectionWatcher = new Yii2Connector\ConnectionWatcher();
327+
$connectionWatcher->start();
330328
$this->haveFixtures(call_user_func([$test, $this->_getConfig('fixturesMethod')]));
331-
}
332-
333-
Event::offAll();
334-
// Close all connections so they get properly reopened after the transaction handler has been attached.
335-
foreach ($connections as $connection) {
336-
$this->debugSection('Fixtures', 'Closing database connection: ' . $connection->dsn);
337-
$connection->close();
329+
$connectionWatcher->stop();
330+
$connectionWatcher->closeAll();
338331
}
339332
$this->debugSection('Fixtures', 'Done');
340333
}
@@ -350,6 +343,10 @@ public function _after(TestInterface $test)
350343

351344
$this->rollbackTransactions();
352345

346+
$this->connectionWatcher->stop();
347+
$this->connectionWatcher->closeAll();
348+
unset($this->connectionWatcher);
349+
353350
if ($this->config['cleanup']) {
354351
foreach ($this->loadedFixtures as $fixture) {
355352
$fixture->unloadFixtures();
@@ -365,80 +362,21 @@ public function _after(TestInterface $test)
365362
parent::_after($test);
366363
}
367364

368-
public function connectionOpenHandler(Event $event)
369-
{
370-
if ($event->sender instanceof Connection) {
371-
$connection = $event->sender;
372-
/*
373-
* We should check if the known PDO objects are the same, in which case we should reuse the PDO
374-
* object so only 1 transaction is started and multiple connections to the same database see the
375-
* same data (due to writes inside a transaction not being visible from the outside).
376-
*
377-
*/
378-
$key = md5(json_encode([
379-
'dsn' => $connection->dsn,
380-
'user' => $connection->username,
381-
'pass' => $connection->password,
382-
'attributes' => $connection->attributes,
383-
'emulatePrepare' => $connection->emulatePrepare,
384-
'charset' => $connection->charset
385-
]));
386-
387-
/*
388-
* If keys match we assume connections are "similar enough".
389-
*/
390-
if (isset($this->pdoCache[$key])) {
391-
$connection->pdo = $this->pdoCache[$key];
392-
} else {
393-
$this->pdoCache[$key] = $connection->pdo;
394-
}
395-
396-
if (isset($this->dsnCache[$connection->dsn])
397-
&& $this->dsnCache[$connection->dsn] !== $key
398-
&& !$this->config['ignoreCollidingDSN']
399-
) {
400-
$this->debugSection('WARNING', <<<TEXT
401-
You use multiple connections to the same DSN ({$connection->dsn}) with different configuration.
402-
These connections will not see the same database state since we cannot share a transaction between different PDO
403-
instances.
404-
You can remove this message by adding 'ignoreCollidingDSN = true' in the module configuration.
405-
TEXT
406-
);
407-
Debug::pause();
408-
}
409-
410-
if (isset($this->transactions[$key])) {
411-
$this->debugSection('Database', 'Reusing PDO, so no need for a new transaction');
412-
return;
413-
}
414-
415-
$this->debugSection('Database', 'Transaction started for: ' . $connection->dsn);
416-
$this->transactions[$key] = $connection->beginTransaction();
417-
}
418-
419-
}
420-
421365
protected function startTransactions()
422366
{
423367
if ($this->config['transaction']) {
424-
// This should register handlers that start a transaction whenever a connection opens and add it to the transactions array.
425-
$this->debug('Transaction', 'Registering connection event handler');
426-
Event::on(Connection::class, Connection::EVENT_AFTER_OPEN, [$this, 'connectionOpenHandler']);
368+
$this->transactionForcer = new Yii2Connector\TransactionForcer($this->config['ignoreCollidingDSN']);
369+
$this->transactionForcer->start();
427370
}
428371
}
429372

430373
protected function rollbackTransactions()
431374
{
432-
$this->debugSection('Transaction', 'Rolling back ' . count($this->transactions) . ' transactions');
433-
Event::off(Connection::class, Connection::EVENT_AFTER_OPEN, [$this, 'connectionOpenHandler']);
434-
/** @var Transaction $transaction */
435-
foreach ($this->transactions as $transaction) {
436-
$transaction->rollBack();
437-
$this->debugSection('Database', 'Transaction cancelled; all changes reverted.');
375+
if (isset($this->transactionForcer)) {
376+
$this->transactionForcer->rollbackAll();
377+
$this->transactionForcer->stop();
378+
unset($this->transactionForcer);
438379
}
439-
$this->transactions = [];
440-
$this->pdoCache = [];
441-
$this->dsnCache = [];
442380
}
443381

444382
public function _parts()

0 commit comments

Comments
 (0)