TransactionTest.php 21.7 KB
Newer Older
1 2
<?php

3
namespace Drupal\KernelTests\Core\Database;
4

5
use Drupal\Component\Render\FormattableMarkup;
6 7 8 9
use Drupal\Core\Database\TransactionOutOfOrderException;
use Drupal\Core\Database\TransactionNoActiveException;

/**
10
 * Tests the transaction abstraction system.
11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
 *
 * We test nesting by having two transaction layers, an outer and inner. The
 * outer layer encapsulates the inner layer. Our transaction nesting abstraction
 * should allow the outer layer function to call any function it wants,
 * especially the inner layer that starts its own transaction, and be
 * confident that, when the function it calls returns, its own transaction
 * is still "alive."
 *
 * Call structure:
 *   transactionOuterLayer()
 *     Start transaction
 *     transactionInnerLayer()
 *       Start transaction (does nothing in database)
 *       [Maybe decide to roll back]
 *     Do more stuff
 *     Should still be in transaction A
 *
28
 * @group Database
29 30 31 32
 */
class TransactionTest extends DatabaseTestBase {

  /**
33 34 35
   * Encapsulates a transaction's "inner layer" with an "outer layer".
   *
   * This "outer layer" transaction starts and then encapsulates the "inner
36 37 38 39
   * layer" transaction. This nesting is used to evaluate whether the database
   * transaction API properly supports nesting. By "properly supports," we mean
   * the outer transaction continues to exist regardless of what functions are
   * called and whether those functions start their own transactions.
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
   *
   * In contrast, a typical database would commit the outer transaction, start
   * a new transaction for the inner layer, commit the inner layer transaction,
   * and then be confused when the outer layer transaction tries to commit its
   * transaction (which was already committed when the inner transaction
   * started).
   *
   * @param $suffix
   *   Suffix to add to field values to differentiate tests.
   * @param $rollback
   *   Whether or not to try rolling back the transaction when we're done.
   * @param $ddl_statement
   *   Whether to execute a DDL statement during the inner transaction.
   */
  protected function transactionOuterLayer($suffix, $rollback = FALSE, $ddl_statement = FALSE) {
55 56
    $depth = $this->connection->transactionDepth();
    $txn = $this->connection->startTransaction();
57 58

    // Insert a single row into the testing table.
59
    $this->connection->insert('test')
60
      ->fields([
61 62
        'name' => 'David' . $suffix,
        'age' => '24',
63
      ])
64 65
      ->execute();

66
    $this->assertTrue($this->connection->inTransaction(), 'In transaction before calling nested transaction.');
67 68 69 70 71

    // We're already in a transaction, but we call ->transactionInnerLayer
    // to nest another transaction inside the current one.
    $this->transactionInnerLayer($suffix, $rollback, $ddl_statement);

72
    $this->assertTrue($this->connection->inTransaction(), 'In transaction after calling nested transaction.');
73 74 75 76

    if ($rollback) {
      // Roll back the transaction, if requested.
      // 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 84 85 86
   * Creates an "inner layer" transaction.
   *
   * This "inner layer" transaction is either used alone or nested inside of the
   * "outer layer" transaction.
87 88 89 90 91 92 93 94 95
   *
   * @param $suffix
   *   Suffix to add to field values to differentiate tests.
   * @param $rollback
   *   Whether or not to try rolling back the transaction when we're done.
   * @param $ddl_statement
   *   Whether to execute a DDL statement during the transaction.
   */
  protected function transactionInnerLayer($suffix, $rollback = FALSE, $ddl_statement = FALSE) {
96
    $depth = $this->connection->transactionDepth();
97 98 99 100
    // Start a transaction. If we're being called from ->transactionOuterLayer,
    // then we're already in a transaction. Normally, that would make starting
    // a transaction here dangerous, but the database API handles this problem
    // 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 110
        'name' => 'Daniel' . $suffix,
        'age' => '19',
111
      ])
112 113
      ->execute();

114
    $this->assertTrue($this->connection->inTransaction(), 'In transaction inside nested transaction.');
115 116

    if ($ddl_statement) {
117 118 119
      $table = [
        'fields' => [
          'id' => [
120 121 122
            'type' => 'serial',
            'unsigned' => TRUE,
            'not null' => TRUE,
123 124 125 126
          ],
        ],
        'primary key' => ['id'],
      ];
127
      $this->connection->schema()->createTable('database_test_1', $table);
128

129
      $this->assertTrue($this->connection->inTransaction(), 'In transaction inside nested transaction.');
130 131 132 133 134
    }

    if ($rollback) {
      // Roll back the transaction, if requested.
      // 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 144
   * If the active connection does not support transactions, this test does
   * nothing.
145
   */
146
  public function testTransactionRollBackSupported() {
147
    // This test won't work right if transactions are not supported.
148 149
    if (!$this->connection->supportsTransactions()) {
      $this->markTestSkipped("The '{$this->connection->driver()}' database driver does not support transactions.");
150
    }
151

152 153 154 155 156 157
    try {
      // Create two nested transactions. Roll back from the inner one.
      $this->transactionOuterLayer('B', TRUE);

      // Neither of the rows we inserted in the two transaction layers
      // 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 165 166 167 168
      $this->fail($e->getMessage());
    }
  }

  /**
169
   * Tests transaction rollback on a database that doesn't support transactions.
170 171 172
   *
   * If the active driver supports transactions, this test does nothing.
   */
173
  public function testTransactionRollBackNotSupported() {
174
    // This test won't work right if transactions are supported.
175 176
    if ($this->connection->supportsTransactions()) {
      $this->markTestSkipped("The '{$this->connection->driver()}' database driver supports transactions.");
177
    }
178

179 180 181 182 183 184
    try {
      // Create two nested transactions. Attempt to roll back from the inner one.
      $this->transactionOuterLayer('B', TRUE);

      // Because our current database claims to not support transactions,
      // 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 192 193 194 195
      $this->fail($e->getMessage());
    }
  }

  /**
196
   * Tests a committed transaction.
197 198 199 200
   *
   * The behavior of this test should be identical for connections that support
   * transactions and those that do not.
   */
201
  public function testCommittedTransaction() {
202 203 204 205 206
    try {
      // Create two nested transactions. The changes should be committed.
      $this->transactionOuterLayer('A');

      // 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 214 215 216 217
      $this->fail($e->getMessage());
    }
  }

  /**
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 224 225 226 227 228 229
    $this->insertRow('row');
    $this->executeDDLStatement();
    unset($transaction);
    $this->assertRowPresent('row');

    // Even in different order.
    $this->cleanUp();
230
    $transaction = $this->connection->startTransaction();
231 232 233 234 235 236 237
    $this->executeDDLStatement();
    $this->insertRow('row');
    unset($transaction);
    $this->assertRowPresent('row');

    // Even with stacking.
    $this->cleanUp();
238 239
    $transaction = $this->connection->startTransaction();
    $transaction2 = $this->connection->startTransaction();
240 241
    $this->executeDDLStatement();
    unset($transaction2);
242
    $transaction3 = $this->connection->startTransaction();
243 244 245 246 247 248 249
    $this->insertRow('row');
    unset($transaction3);
    unset($transaction);
    $this->assertRowPresent('row');

    // A transaction after a DDL statement should still work the same.
    $this->cleanUp();
250 251
    $transaction = $this->connection->startTransaction();
    $transaction2 = $this->connection->startTransaction();
252 253
    $this->executeDDLStatement();
    unset($transaction2);
254
    $transaction3 = $this->connection->startTransaction();
255
    $this->insertRow('row');
256
    $transaction3->rollBack();
257 258 259 260 261
    unset($transaction3);
    unset($transaction);
    $this->assertRowAbsent('row');

    // The behavior of a rollback depends on the type of database server.
262
    if ($this->connection->supportsTransactionalDDL()) {
263 264 265
      // For database servers that support transactional DDL, a rollback
      // of a transaction including DDL statements should be possible.
      $this->cleanUp();
266
      $transaction = $this->connection->startTransaction();
267 268
      $this->insertRow('row');
      $this->executeDDLStatement();
269
      $transaction->rollBack();
270 271 272 273 274
      unset($transaction);
      $this->assertRowAbsent('row');

      // Including with stacking.
      $this->cleanUp();
275 276
      $transaction = $this->connection->startTransaction();
      $transaction2 = $this->connection->startTransaction();
277 278
      $this->executeDDLStatement();
      unset($transaction2);
279
      $transaction3 = $this->connection->startTransaction();
280 281
      $this->insertRow('row');
      unset($transaction3);
282
      $transaction->rollBack();
283 284 285 286 287 288 289
      unset($transaction);
      $this->assertRowAbsent('row');
    }
    else {
      // For database servers that do not support transactional DDL,
      // the DDL statement should commit the transaction stack.
      $this->cleanUp();
290
      $transaction = $this->connection->startTransaction();
291 292 293 294
      $this->insertRow('row');
      $this->executeDDLStatement();
      // Rollback the outer transaction.
      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 300
        // @see https://www.drupal.org/project/drupal/issues/2736777
        // $this->fail('Rolling back a transaction containing DDL should fail.');
301 302
      }
      catch (TransactionNoActiveException $e) {
303
        // Expected exception; just continue testing.
304 305 306 307 308 309
      }
      $this->assertRowPresent('row');
    }
  }

  /**
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 318 319 320
      ->execute();
  }

  /**
321
   * Executes a DDL statement.
322 323 324
   */
  protected function executeDDLStatement() {
    static $count = 0;
325 326 327
    $table = [
      'fields' => [
        'id' => [
328 329 330
          'type' => 'serial',
          'unsigned' => TRUE,
          'not null' => TRUE,
331 332 333 334
        ],
      ],
      'primary key' => ['id'],
    ];
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 344 345 346
      ->execute();
  }

  /**
347
   * Asserts that a given row is present in the test table.
348 349 350 351 352 353
   *
   * @param $name
   *   The name of the row.
   * @param $message
   *   The message to log for the assertion.
   */
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 360 361 362
    return $this->assertTrue($present, $message);
  }

  /**
363
   * Asserts that a given row is absent from the test table.
364 365 366 367 368 369
   *
   * @param $name
   *   The name of the row.
   * @param $message
   *   The message to log for the assertion.
   */
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 376 377 378
    return $this->assertFalse($present, $message);
  }

  /**
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 384
    if (!$this->connection->supportsTransactions()) {
      $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 392 393
    $this->insertRow('inner');
    // Pop the inner transaction.
    unset($transaction2);
394
    $this->assertTrue($this->connection->inTransaction(), 'Still in a transaction after popping the inner transaction');
395 396
    // Pop the outer transaction.
    unset($transaction);
397
    $this->assertFalse($this->connection->inTransaction(), 'Transaction closed after popping the outer transaction');
398 399 400 401 402
    $this->assertRowPresent('outer');
    $this->assertRowPresent('inner');

    // Pop the transaction in a different order they have been pushed.
    $this->cleanUp();
403
    $transaction = $this->connection->startTransaction();
404
    $this->insertRow('outer');
405
    $transaction2 = $this->connection->startTransaction();
406 407 408 409
    $this->insertRow('inner');
    // Pop the outer transaction, nothing should happen.
    unset($transaction);
    $this->insertRow('inner-after-outer-commit');
410
    $this->assertTrue($this->connection->inTransaction(), 'Still in a transaction after popping the outer transaction');
411 412
    // Pop the inner transaction, the whole transaction should commit.
    unset($transaction2);
413
    $this->assertFalse($this->connection->inTransaction(), 'Transaction closed after popping the inner transaction');
414 415 416 417 418 419
    $this->assertRowPresent('outer');
    $this->assertRowPresent('inner');
    $this->assertRowPresent('inner-after-outer-commit');

    // Rollback the inner transaction.
    $this->cleanUp();
420
    $transaction = $this->connection->startTransaction();
421
    $this->insertRow('outer');
422
    $transaction2 = $this->connection->startTransaction();
423 424
    $this->insertRow('inner');
    // 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 429 430
    // Pop the outer transaction, it should commit.
    $this->insertRow('outer-after-inner-rollback');
    unset($transaction);
431
    $this->assertFalse($this->connection->inTransaction(), 'Transaction closed after popping the inner transaction');
432 433 434 435 436 437
    $this->assertRowPresent('outer');
    $this->assertRowAbsent('inner');
    $this->assertRowPresent('outer-after-inner-rollback');

    // Rollback the inner transaction after committing the outer one.
    $this->cleanUp();
438
    $transaction = $this->connection->startTransaction();
439
    $this->insertRow('outer');
440
    $transaction2 = $this->connection->startTransaction();
441 442 443
    $this->insertRow('inner');
    // Pop the outer transaction, nothing should happen.
    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 450 451 452 453 454 455
    $this->assertRowPresent('outer');
    $this->assertRowAbsent('inner');

    // Rollback the outer transaction while the inner transaction is active.
    // In that case, an exception will be triggered because we cannot
    // ensure that the final result will have any meaning.
    $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 462 463
    $this->insertRow('inner2');
    // Rollback the outer transaction.
    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 473
    // Try to commit one inner transaction.
    unset($transaction3);
474

475 476
    // Try to rollback one inner transaction.
    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 485 486 487 488
    }
    $this->assertRowAbsent('outer');
    $this->assertRowAbsent('inner');
    $this->assertRowAbsent('inner2');
  }

489 490 491 492
  /**
   * Tests that transactions can continue to be used if a query fails.
   */
  public function testQueryFailureInTransaction() {
493 494
    $transaction = $this->connection->startTransaction('test_transaction');
    $this->connection->schema()->dropTable('test');
495 496 497

    // Test a failed query using the query() method.
    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 506
    }

    // Test a failed select query.
    try {
507
      $this->connection->select('test')
508 509 510
        ->fields('test', ['name'])
        ->execute();

511
      $this->fail('Select query should have failed.');
512 513
    }
    catch (\Exception $e) {
514
      // Just continue testing.
515 516 517 518
    }

    // Test a failed insert query.
    try {
519
      $this->connection->insert('test')
520 521 522 523 524 525
        ->fields([
          'name' => 'David',
          'age' => '24',
        ])
        ->execute();

526
      $this->fail('Insert query should have failed.');
527 528
    }
    catch (\Exception $e) {
529
      // Just continue testing.
530 531 532 533
    }

    // Test a failed update query.
    try {
534
      $this->connection->update('test')
535 536 537 538
        ->fields(['name' => 'Tiffany'])
        ->condition('id', 1)
        ->execute();

539
      $this->fail('Update query should have failed.');
540 541
    }
    catch (\Exception $e) {
542
      // Just continue testing.
543 544 545 546
    }

    // Test a failed delete query.
    try {
547
      $this->connection->delete('test')
548 549 550
        ->condition('id', 1)
        ->execute();

551
      $this->fail('Delete query should have failed.');
552 553
    }
    catch (\Exception $e) {
554
      // Just continue testing.
555 556 557 558
    }

    // Test a failed merge query.
    try {
559
      $this->connection->merge('test')
560 561 562 563 564 565 566
        ->key('job', 'Presenter')
        ->fields([
          'age' => '31',
          'name' => 'Tiffany',
        ])
        ->execute();

567
      $this->fail('Merge query should have failed.');
568 569
    }
    catch (\Exception $e) {
570
      // Just continue testing.
571 572 573 574
    }

    // Test a failed upsert query.
    try {
575
      $this->connection->upsert('test')
576 577 578 579 580 581 582 583 584
        ->key('job')
        ->fields(['job', 'age', 'name'])
        ->values([
          'job' => 'Presenter',
          'age' => 31,
          'name' => 'Tiffany',
        ])
        ->execute();

585
      $this->fail('Upsert query should have failed.');
586 587
    }
    catch (\Exception $e) {
588
      // Just continue testing.
589 590 591 592
    }

    // Create the missing schema and insert a row.
    $this->installSchema('database_test', ['test']);
593
    $this->connection->insert('test')
594
      ->fields([
595 596
        'name' => 'David',
        'age' => '24',
597
      ])
598 599 600 601 602
      ->execute();

    // Commit the transaction.
    unset($transaction);

603
    $saved_age = $this->connection->query('SELECT [age] FROM {test} WHERE [name] = :name', [':name' => 'David'])->fetchField();
604 605 606 607
    $this->assertEqual('24', $saved_age);
  }

}