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