TransactionTest.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. <?php
  2. namespace Drupal\KernelTests\Core\Database;
  3. use Drupal\Component\Render\FormattableMarkup;
  4. use Drupal\Core\Database\TransactionOutOfOrderException;
  5. use Drupal\Core\Database\TransactionNoActiveException;
  6. /**
  7. * Tests the transaction abstraction system.
  8. *
  9. * We test nesting by having two transaction layers, an outer and inner. The
  10. * outer layer encapsulates the inner layer. Our transaction nesting abstraction
  11. * should allow the outer layer function to call any function it wants,
  12. * especially the inner layer that starts its own transaction, and be
  13. * confident that, when the function it calls returns, its own transaction
  14. * is still "alive."
  15. *
  16. * Call structure:
  17. * transactionOuterLayer()
  18. * Start transaction
  19. * transactionInnerLayer()
  20. * Start transaction (does nothing in database)
  21. * [Maybe decide to roll back]
  22. * Do more stuff
  23. * Should still be in transaction A
  24. *
  25. * @group Database
  26. */
  27. class TransactionTest extends DatabaseTestBase {
  28. /**
  29. * Encapsulates a transaction's "inner layer" with an "outer layer".
  30. *
  31. * This "outer layer" transaction starts and then encapsulates the "inner
  32. * layer" transaction. This nesting is used to evaluate whether the database
  33. * transaction API properly supports nesting. By "properly supports," we mean
  34. * the outer transaction continues to exist regardless of what functions are
  35. * called and whether those functions start their own transactions.
  36. *
  37. * In contrast, a typical database would commit the outer transaction, start
  38. * a new transaction for the inner layer, commit the inner layer transaction,
  39. * and then be confused when the outer layer transaction tries to commit its
  40. * transaction (which was already committed when the inner transaction
  41. * started).
  42. *
  43. * @param $suffix
  44. * Suffix to add to field values to differentiate tests.
  45. * @param $rollback
  46. * Whether or not to try rolling back the transaction when we're done.
  47. * @param $ddl_statement
  48. * Whether to execute a DDL statement during the inner transaction.
  49. */
  50. protected function transactionOuterLayer($suffix, $rollback = FALSE, $ddl_statement = FALSE) {
  51. $depth = $this->connection->transactionDepth();
  52. $txn = $this->connection->startTransaction();
  53. // Insert a single row into the testing table.
  54. $this->connection->insert('test')
  55. ->fields([
  56. 'name' => 'David' . $suffix,
  57. 'age' => '24',
  58. ])
  59. ->execute();
  60. $this->assertTrue($this->connection->inTransaction(), 'In transaction before calling nested transaction.');
  61. // We're already in a transaction, but we call ->transactionInnerLayer
  62. // to nest another transaction inside the current one.
  63. $this->transactionInnerLayer($suffix, $rollback, $ddl_statement);
  64. $this->assertTrue($this->connection->inTransaction(), 'In transaction after calling nested transaction.');
  65. if ($rollback) {
  66. // Roll back the transaction, if requested.
  67. // This rollback should propagate to the last savepoint.
  68. $txn->rollBack();
  69. $this->assertTrue(($this->connection->transactionDepth() == $depth), 'Transaction has rolled back to the last savepoint after calling rollBack().');
  70. }
  71. }
  72. /**
  73. * Creates an "inner layer" transaction.
  74. *
  75. * This "inner layer" transaction is either used alone or nested inside of the
  76. * "outer layer" transaction.
  77. *
  78. * @param $suffix
  79. * Suffix to add to field values to differentiate tests.
  80. * @param $rollback
  81. * Whether or not to try rolling back the transaction when we're done.
  82. * @param $ddl_statement
  83. * Whether to execute a DDL statement during the transaction.
  84. */
  85. protected function transactionInnerLayer($suffix, $rollback = FALSE, $ddl_statement = FALSE) {
  86. $depth = $this->connection->transactionDepth();
  87. // Start a transaction. If we're being called from ->transactionOuterLayer,
  88. // then we're already in a transaction. Normally, that would make starting
  89. // a transaction here dangerous, but the database API handles this problem
  90. // for us by tracking the nesting and avoiding the danger.
  91. $txn = $this->connection->startTransaction();
  92. $depth2 = $this->connection->transactionDepth();
  93. $this->assertTrue($depth < $depth2, 'Transaction depth is has increased with new transaction.');
  94. // Insert a single row into the testing table.
  95. $this->connection->insert('test')
  96. ->fields([
  97. 'name' => 'Daniel' . $suffix,
  98. 'age' => '19',
  99. ])
  100. ->execute();
  101. $this->assertTrue($this->connection->inTransaction(), 'In transaction inside nested transaction.');
  102. if ($ddl_statement) {
  103. $table = [
  104. 'fields' => [
  105. 'id' => [
  106. 'type' => 'serial',
  107. 'unsigned' => TRUE,
  108. 'not null' => TRUE,
  109. ],
  110. ],
  111. 'primary key' => ['id'],
  112. ];
  113. $this->connection->schema()->createTable('database_test_1', $table);
  114. $this->assertTrue($this->connection->inTransaction(), 'In transaction inside nested transaction.');
  115. }
  116. if ($rollback) {
  117. // Roll back the transaction, if requested.
  118. // This rollback should propagate to the last savepoint.
  119. $txn->rollBack();
  120. $this->assertTrue(($this->connection->transactionDepth() == $depth), 'Transaction has rolled back to the last savepoint after calling rollBack().');
  121. }
  122. }
  123. /**
  124. * Tests transaction rollback on a database that supports transactions.
  125. *
  126. * If the active connection does not support transactions, this test does
  127. * nothing.
  128. */
  129. public function testTransactionRollBackSupported() {
  130. // This test won't work right if transactions are not supported.
  131. if (!$this->connection->supportsTransactions()) {
  132. $this->markTestSkipped("The '{$this->connection->driver()}' database driver does not support transactions.");
  133. }
  134. try {
  135. // Create two nested transactions. Roll back from the inner one.
  136. $this->transactionOuterLayer('B', TRUE);
  137. // Neither of the rows we inserted in the two transaction layers
  138. // should be present in the tables post-rollback.
  139. $saved_age = $this->connection->query('SELECT age FROM {test} WHERE name = :name', [':name' => 'DavidB'])->fetchField();
  140. $this->assertNotIdentical($saved_age, '24', 'Cannot retrieve DavidB row after commit.');
  141. $saved_age = $this->connection->query('SELECT age FROM {test} WHERE name = :name', [':name' => 'DanielB'])->fetchField();
  142. $this->assertNotIdentical($saved_age, '19', 'Cannot retrieve DanielB row after commit.');
  143. }
  144. catch (\Exception $e) {
  145. $this->fail($e->getMessage());
  146. }
  147. }
  148. /**
  149. * Tests transaction rollback on a database that doesn't support transactions.
  150. *
  151. * If the active driver supports transactions, this test does nothing.
  152. */
  153. public function testTransactionRollBackNotSupported() {
  154. // This test won't work right if transactions are supported.
  155. if ($this->connection->supportsTransactions()) {
  156. $this->markTestSkipped("The '{$this->connection->driver()}' database driver supports transactions.");
  157. }
  158. try {
  159. // Create two nested transactions. Attempt to roll back from the inner one.
  160. $this->transactionOuterLayer('B', TRUE);
  161. // Because our current database claims to not support transactions,
  162. // the inserted rows should be present despite the attempt to roll back.
  163. $saved_age = $this->connection->query('SELECT age FROM {test} WHERE name = :name', [':name' => 'DavidB'])->fetchField();
  164. $this->assertIdentical($saved_age, '24', 'DavidB not rolled back, since transactions are not supported.');
  165. $saved_age = $this->connection->query('SELECT age FROM {test} WHERE name = :name', [':name' => 'DanielB'])->fetchField();
  166. $this->assertIdentical($saved_age, '19', 'DanielB not rolled back, since transactions are not supported.');
  167. }
  168. catch (\Exception $e) {
  169. $this->fail($e->getMessage());
  170. }
  171. }
  172. /**
  173. * Tests a committed transaction.
  174. *
  175. * The behavior of this test should be identical for connections that support
  176. * transactions and those that do not.
  177. */
  178. public function testCommittedTransaction() {
  179. try {
  180. // Create two nested transactions. The changes should be committed.
  181. $this->transactionOuterLayer('A');
  182. // Because we committed, both of the inserted rows should be present.
  183. $saved_age = $this->connection->query('SELECT age FROM {test} WHERE name = :name', [':name' => 'DavidA'])->fetchField();
  184. $this->assertIdentical($saved_age, '24', 'Can retrieve DavidA row after commit.');
  185. $saved_age = $this->connection->query('SELECT age FROM {test} WHERE name = :name', [':name' => 'DanielA'])->fetchField();
  186. $this->assertIdentical($saved_age, '19', 'Can retrieve DanielA row after commit.');
  187. }
  188. catch (\Exception $e) {
  189. $this->fail($e->getMessage());
  190. }
  191. }
  192. /**
  193. * Tests the compatibility of transactions with DDL statements.
  194. */
  195. public function testTransactionWithDdlStatement() {
  196. // First, test that a commit works normally, even with DDL statements.
  197. $transaction = $this->connection->startTransaction();
  198. $this->insertRow('row');
  199. $this->executeDDLStatement();
  200. unset($transaction);
  201. $this->assertRowPresent('row');
  202. // Even in different order.
  203. $this->cleanUp();
  204. $transaction = $this->connection->startTransaction();
  205. $this->executeDDLStatement();
  206. $this->insertRow('row');
  207. unset($transaction);
  208. $this->assertRowPresent('row');
  209. // Even with stacking.
  210. $this->cleanUp();
  211. $transaction = $this->connection->startTransaction();
  212. $transaction2 = $this->connection->startTransaction();
  213. $this->executeDDLStatement();
  214. unset($transaction2);
  215. $transaction3 = $this->connection->startTransaction();
  216. $this->insertRow('row');
  217. unset($transaction3);
  218. unset($transaction);
  219. $this->assertRowPresent('row');
  220. // A transaction after a DDL statement should still work the same.
  221. $this->cleanUp();
  222. $transaction = $this->connection->startTransaction();
  223. $transaction2 = $this->connection->startTransaction();
  224. $this->executeDDLStatement();
  225. unset($transaction2);
  226. $transaction3 = $this->connection->startTransaction();
  227. $this->insertRow('row');
  228. $transaction3->rollBack();
  229. unset($transaction3);
  230. unset($transaction);
  231. $this->assertRowAbsent('row');
  232. // The behavior of a rollback depends on the type of database server.
  233. if ($this->connection->supportsTransactionalDDL()) {
  234. // For database servers that support transactional DDL, a rollback
  235. // of a transaction including DDL statements should be possible.
  236. $this->cleanUp();
  237. $transaction = $this->connection->startTransaction();
  238. $this->insertRow('row');
  239. $this->executeDDLStatement();
  240. $transaction->rollBack();
  241. unset($transaction);
  242. $this->assertRowAbsent('row');
  243. // Including with stacking.
  244. $this->cleanUp();
  245. $transaction = $this->connection->startTransaction();
  246. $transaction2 = $this->connection->startTransaction();
  247. $this->executeDDLStatement();
  248. unset($transaction2);
  249. $transaction3 = $this->connection->startTransaction();
  250. $this->insertRow('row');
  251. unset($transaction3);
  252. $transaction->rollBack();
  253. unset($transaction);
  254. $this->assertRowAbsent('row');
  255. }
  256. else {
  257. // For database servers that do not support transactional DDL,
  258. // the DDL statement should commit the transaction stack.
  259. $this->cleanUp();
  260. $transaction = $this->connection->startTransaction();
  261. $this->insertRow('row');
  262. $this->executeDDLStatement();
  263. // Rollback the outer transaction.
  264. try {
  265. $transaction->rollBack();
  266. unset($transaction);
  267. // @todo An exception should be triggered here, but is not because
  268. // "ROLLBACK" fails silently in MySQL if there is no transaction active.
  269. // @see https://www.drupal.org/project/drupal/issues/2736777
  270. // $this->fail('Rolling back a transaction containing DDL should fail.');
  271. }
  272. catch (TransactionNoActiveException $e) {
  273. // Expected exception; just continue testing.
  274. }
  275. $this->assertRowPresent('row');
  276. }
  277. }
  278. /**
  279. * Inserts a single row into the testing table.
  280. */
  281. protected function insertRow($name) {
  282. $this->connection->insert('test')
  283. ->fields([
  284. 'name' => $name,
  285. ])
  286. ->execute();
  287. }
  288. /**
  289. * Executes a DDL statement.
  290. */
  291. protected function executeDDLStatement() {
  292. static $count = 0;
  293. $table = [
  294. 'fields' => [
  295. 'id' => [
  296. 'type' => 'serial',
  297. 'unsigned' => TRUE,
  298. 'not null' => TRUE,
  299. ],
  300. ],
  301. 'primary key' => ['id'],
  302. ];
  303. $this->connection->schema()->createTable('database_test_' . ++$count, $table);
  304. }
  305. /**
  306. * Starts over for a new test.
  307. */
  308. protected function cleanUp() {
  309. $this->connection->truncate('test')
  310. ->execute();
  311. }
  312. /**
  313. * Asserts that a given row is present in the test table.
  314. *
  315. * @param $name
  316. * The name of the row.
  317. * @param $message
  318. * The message to log for the assertion.
  319. */
  320. public function assertRowPresent($name, $message = NULL) {
  321. if (!isset($message)) {
  322. $message = new FormattableMarkup('Row %name is present.', ['%name' => $name]);
  323. }
  324. $present = (boolean) $this->connection->query('SELECT 1 FROM {test} WHERE name = :name', [':name' => $name])->fetchField();
  325. return $this->assertTrue($present, $message);
  326. }
  327. /**
  328. * Asserts that a given row is absent from the test table.
  329. *
  330. * @param $name
  331. * The name of the row.
  332. * @param $message
  333. * The message to log for the assertion.
  334. */
  335. public function assertRowAbsent($name, $message = NULL) {
  336. if (!isset($message)) {
  337. $message = new FormattableMarkup('Row %name is absent.', ['%name' => $name]);
  338. }
  339. $present = (boolean) $this->connection->query('SELECT 1 FROM {test} WHERE name = :name', [':name' => $name])->fetchField();
  340. return $this->assertFalse($present, $message);
  341. }
  342. /**
  343. * Tests transaction stacking, commit, and rollback.
  344. */
  345. public function testTransactionStacking() {
  346. // This test won't work right if transactions are not supported.
  347. if (!$this->connection->supportsTransactions()) {
  348. $this->markTestSkipped("The '{$this->connection->driver()}' database driver does not support transactions.");
  349. }
  350. // Standard case: pop the inner transaction before the outer transaction.
  351. $transaction = $this->connection->startTransaction();
  352. $this->insertRow('outer');
  353. $transaction2 = $this->connection->startTransaction();
  354. $this->insertRow('inner');
  355. // Pop the inner transaction.
  356. unset($transaction2);
  357. $this->assertTrue($this->connection->inTransaction(), 'Still in a transaction after popping the inner transaction');
  358. // Pop the outer transaction.
  359. unset($transaction);
  360. $this->assertFalse($this->connection->inTransaction(), 'Transaction closed after popping the outer transaction');
  361. $this->assertRowPresent('outer');
  362. $this->assertRowPresent('inner');
  363. // Pop the transaction in a different order they have been pushed.
  364. $this->cleanUp();
  365. $transaction = $this->connection->startTransaction();
  366. $this->insertRow('outer');
  367. $transaction2 = $this->connection->startTransaction();
  368. $this->insertRow('inner');
  369. // Pop the outer transaction, nothing should happen.
  370. unset($transaction);
  371. $this->insertRow('inner-after-outer-commit');
  372. $this->assertTrue($this->connection->inTransaction(), 'Still in a transaction after popping the outer transaction');
  373. // Pop the inner transaction, the whole transaction should commit.
  374. unset($transaction2);
  375. $this->assertFalse($this->connection->inTransaction(), 'Transaction closed after popping the inner transaction');
  376. $this->assertRowPresent('outer');
  377. $this->assertRowPresent('inner');
  378. $this->assertRowPresent('inner-after-outer-commit');
  379. // Rollback the inner transaction.
  380. $this->cleanUp();
  381. $transaction = $this->connection->startTransaction();
  382. $this->insertRow('outer');
  383. $transaction2 = $this->connection->startTransaction();
  384. $this->insertRow('inner');
  385. // Now rollback the inner transaction.
  386. $transaction2->rollBack();
  387. unset($transaction2);
  388. $this->assertTrue($this->connection->inTransaction(), 'Still in a transaction after popping the outer transaction');
  389. // Pop the outer transaction, it should commit.
  390. $this->insertRow('outer-after-inner-rollback');
  391. unset($transaction);
  392. $this->assertFalse($this->connection->inTransaction(), 'Transaction closed after popping the inner transaction');
  393. $this->assertRowPresent('outer');
  394. $this->assertRowAbsent('inner');
  395. $this->assertRowPresent('outer-after-inner-rollback');
  396. // Rollback the inner transaction after committing the outer one.
  397. $this->cleanUp();
  398. $transaction = $this->connection->startTransaction();
  399. $this->insertRow('outer');
  400. $transaction2 = $this->connection->startTransaction();
  401. $this->insertRow('inner');
  402. // Pop the outer transaction, nothing should happen.
  403. unset($transaction);
  404. $this->assertTrue($this->connection->inTransaction(), 'Still in a transaction after popping the outer transaction');
  405. // Now rollback the inner transaction, it should rollback.
  406. $transaction2->rollBack();
  407. unset($transaction2);
  408. $this->assertFalse($this->connection->inTransaction(), 'Transaction closed after popping the inner transaction');
  409. $this->assertRowPresent('outer');
  410. $this->assertRowAbsent('inner');
  411. // Rollback the outer transaction while the inner transaction is active.
  412. // In that case, an exception will be triggered because we cannot
  413. // ensure that the final result will have any meaning.
  414. $this->cleanUp();
  415. $transaction = $this->connection->startTransaction();
  416. $this->insertRow('outer');
  417. $transaction2 = $this->connection->startTransaction();
  418. $this->insertRow('inner');
  419. $transaction3 = $this->connection->startTransaction();
  420. $this->insertRow('inner2');
  421. // Rollback the outer transaction.
  422. try {
  423. $transaction->rollBack();
  424. unset($transaction);
  425. $this->fail('Rolling back the outer transaction while the inner transaction is active resulted in an exception.');
  426. }
  427. catch (TransactionOutOfOrderException $e) {
  428. // Expected exception; just continue testing.
  429. }
  430. $this->assertFalse($this->connection->inTransaction(), 'No more in a transaction after rolling back the outer transaction');
  431. // Try to commit one inner transaction.
  432. unset($transaction3);
  433. // Try to rollback one inner transaction.
  434. try {
  435. $transaction->rollBack();
  436. unset($transaction2);
  437. $this->fail('Trying to commit an inner transaction resulted in an exception.');
  438. }
  439. catch (TransactionNoActiveException $e) {
  440. // Expected exception; just continue testing.
  441. }
  442. $this->assertRowAbsent('outer');
  443. $this->assertRowAbsent('inner');
  444. $this->assertRowAbsent('inner2');
  445. }
  446. /**
  447. * Tests that transactions can continue to be used if a query fails.
  448. */
  449. public function testQueryFailureInTransaction() {
  450. $transaction = $this->connection->startTransaction('test_transaction');
  451. $this->connection->schema()->dropTable('test');
  452. // Test a failed query using the query() method.
  453. try {
  454. $this->connection->query('SELECT age FROM {test} WHERE name = :name', [':name' => 'David'])->fetchField();
  455. $this->fail('Using the query method should have failed.');
  456. }
  457. catch (\Exception $e) {
  458. // Just continue testing.
  459. }
  460. // Test a failed select query.
  461. try {
  462. $this->connection->select('test')
  463. ->fields('test', ['name'])
  464. ->execute();
  465. $this->fail('Select query should have failed.');
  466. }
  467. catch (\Exception $e) {
  468. // Just continue testing.
  469. }
  470. // Test a failed insert query.
  471. try {
  472. $this->connection->insert('test')
  473. ->fields([
  474. 'name' => 'David',
  475. 'age' => '24',
  476. ])
  477. ->execute();
  478. $this->fail('Insert query should have failed.');
  479. }
  480. catch (\Exception $e) {
  481. // Just continue testing.
  482. }
  483. // Test a failed update query.
  484. try {
  485. $this->connection->update('test')
  486. ->fields(['name' => 'Tiffany'])
  487. ->condition('id', 1)
  488. ->execute();
  489. $this->fail('Update query sould have failed.');
  490. }
  491. catch (\Exception $e) {
  492. // Just continue testing.
  493. }
  494. // Test a failed delete query.
  495. try {
  496. $this->connection->delete('test')
  497. ->condition('id', 1)
  498. ->execute();
  499. $this->fail('Delete query should have failed.');
  500. }
  501. catch (\Exception $e) {
  502. // Just continue testing.
  503. }
  504. // Test a failed merge query.
  505. try {
  506. $this->connection->merge('test')
  507. ->key('job', 'Presenter')
  508. ->fields([
  509. 'age' => '31',
  510. 'name' => 'Tiffany',
  511. ])
  512. ->execute();
  513. $this->fail('Merge query should have failed.');
  514. }
  515. catch (\Exception $e) {
  516. // Just continue testing.
  517. }
  518. // Test a failed upsert query.
  519. try {
  520. $this->connection->upsert('test')
  521. ->key('job')
  522. ->fields(['job', 'age', 'name'])
  523. ->values([
  524. 'job' => 'Presenter',
  525. 'age' => 31,
  526. 'name' => 'Tiffany',
  527. ])
  528. ->execute();
  529. $this->fail('Upsert query should have failed.');
  530. }
  531. catch (\Exception $e) {
  532. // Just continue testing.
  533. }
  534. // Create the missing schema and insert a row.
  535. $this->installSchema('database_test', ['test']);
  536. $this->connection->insert('test')
  537. ->fields([
  538. 'name' => 'David',
  539. 'age' => '24',
  540. ])
  541. ->execute();
  542. // Commit the transaction.
  543. unset($transaction);
  544. $saved_age = $this->connection->query('SELECT age FROM {test} WHERE name = :name', [':name' => 'David'])->fetchField();
  545. $this->assertEqual('24', $saved_age);
  546. }
  547. }