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