3 namespace Drupal\KernelTests\Core\Database;
5 use Drupal\Core\Database\Database;
6 use Drupal\Core\Database\TransactionOutOfOrderException;
7 use Drupal\Core\Database\TransactionNoActiveException;
10 * Tests the transaction abstraction system.
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
20 * transactionOuterLayer()
22 * transactionInnerLayer()
23 * Start transaction (does nothing in database)
24 * [Maybe decide to roll back]
26 * Should still be in transaction A
30 class TransactionTest extends DatabaseTestBase {
33 * Encapsulates a transaction's "inner layer" with an "outer layer".
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.
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
48 * Suffix to add to field values to differentiate tests.
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.
54 protected function transactionOuterLayer($suffix, $rollback = FALSE, $ddl_statement = FALSE) {
55 $connection = Database::getConnection();
56 $depth = $connection->transactionDepth();
57 $txn = db_transaction();
59 // Insert a single row into the testing table.
62 'name' => 'David' . $suffix,
67 $this->assertTrue($connection->inTransaction(), 'In transaction before calling nested transaction.');
69 // We're already in a transaction, but we call ->transactionInnerLayer
70 // to nest another transaction inside the current one.
71 $this->transactionInnerLayer($suffix, $rollback, $ddl_statement);
73 $this->assertTrue($connection->inTransaction(), 'In transaction after calling nested transaction.');
76 // Roll back the transaction, if requested.
77 // This rollback should propagate to the last savepoint.
79 $this->assertTrue(($connection->transactionDepth() == $depth), 'Transaction has rolled back to the last savepoint after calling rollBack().');
84 * Creates an "inner layer" transaction.
86 * This "inner layer" transaction is either used alone or nested inside of the
87 * "outer layer" transaction.
90 * Suffix to add to field values to differentiate tests.
92 * Whether or not to try rolling back the transaction when we're done.
93 * @param $ddl_statement
94 * Whether to execute a DDL statement during the transaction.
96 protected function transactionInnerLayer($suffix, $rollback = FALSE, $ddl_statement = FALSE) {
97 $connection = Database::getConnection();
99 $depth = $connection->transactionDepth();
100 // Start a transaction. If we're being called from ->transactionOuterLayer,
101 // then we're already in a transaction. Normally, that would make starting
102 // a transaction here dangerous, but the database API handles this problem
103 // for us by tracking the nesting and avoiding the danger.
104 $txn = db_transaction();
106 $depth2 = $connection->transactionDepth();
107 $this->assertTrue($depth < $depth2, 'Transaction depth is has increased with new transaction.');
109 // Insert a single row into the testing table.
112 'name' => 'Daniel' . $suffix,
117 $this->assertTrue($connection->inTransaction(), 'In transaction inside nested transaction.');
119 if ($ddl_statement) {
128 'primary key' => ['id'],
130 db_create_table('database_test_1', $table);
132 $this->assertTrue($connection->inTransaction(), 'In transaction inside nested transaction.');
136 // Roll back the transaction, if requested.
137 // This rollback should propagate to the last savepoint.
139 $this->assertTrue(($connection->transactionDepth() == $depth), 'Transaction has rolled back to the last savepoint after calling rollBack().');
144 * Tests transaction rollback on a database that supports transactions.
146 * If the active connection does not support transactions, this test does
149 public function testTransactionRollBackSupported() {
150 // This test won't work right if transactions are not supported.
151 if (!Database::getConnection()->supportsTransactions()) {
155 // Create two nested transactions. Roll back from the inner one.
156 $this->transactionOuterLayer('B', TRUE);
158 // Neither of the rows we inserted in the two transaction layers
159 // should be present in the tables post-rollback.
160 $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', [':name' => 'DavidB'])->fetchField();
161 $this->assertNotIdentical($saved_age, '24', 'Cannot retrieve DavidB row after commit.');
162 $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', [':name' => 'DanielB'])->fetchField();
163 $this->assertNotIdentical($saved_age, '19', 'Cannot retrieve DanielB row after commit.');
165 catch (\Exception $e) {
166 $this->fail($e->getMessage());
171 * Tests transaction rollback on a database that doesn't support transactions.
173 * If the active driver supports transactions, this test does nothing.
175 public function testTransactionRollBackNotSupported() {
176 // This test won't work right if transactions are supported.
177 if (Database::getConnection()->supportsTransactions()) {
181 // Create two nested transactions. Attempt to roll back from the inner one.
182 $this->transactionOuterLayer('B', TRUE);
184 // Because our current database claims to not support transactions,
185 // the inserted rows should be present despite the attempt to roll back.
186 $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', [':name' => 'DavidB'])->fetchField();
187 $this->assertIdentical($saved_age, '24', 'DavidB not rolled back, since transactions are not supported.');
188 $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', [':name' => 'DanielB'])->fetchField();
189 $this->assertIdentical($saved_age, '19', 'DanielB not rolled back, since transactions are not supported.');
191 catch (\Exception $e) {
192 $this->fail($e->getMessage());
197 * Tests a committed transaction.
199 * The behavior of this test should be identical for connections that support
200 * transactions and those that do not.
202 public function testCommittedTransaction() {
204 // Create two nested transactions. The changes should be committed.
205 $this->transactionOuterLayer('A');
207 // Because we committed, both of the inserted rows should be present.
208 $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', [':name' => 'DavidA'])->fetchField();
209 $this->assertIdentical($saved_age, '24', 'Can retrieve DavidA row after commit.');
210 $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', [':name' => 'DanielA'])->fetchField();
211 $this->assertIdentical($saved_age, '19', 'Can retrieve DanielA row after commit.');
213 catch (\Exception $e) {
214 $this->fail($e->getMessage());
219 * Tests the compatibility of transactions with DDL statements.
221 public function testTransactionWithDdlStatement() {
222 // First, test that a commit works normally, even with DDL statements.
223 $transaction = db_transaction();
224 $this->insertRow('row');
225 $this->executeDDLStatement();
227 $this->assertRowPresent('row');
229 // Even in different order.
231 $transaction = db_transaction();
232 $this->executeDDLStatement();
233 $this->insertRow('row');
235 $this->assertRowPresent('row');
237 // Even with stacking.
239 $transaction = db_transaction();
240 $transaction2 = db_transaction();
241 $this->executeDDLStatement();
242 unset($transaction2);
243 $transaction3 = db_transaction();
244 $this->insertRow('row');
245 unset($transaction3);
247 $this->assertRowPresent('row');
249 // A transaction after a DDL statement should still work the same.
251 $transaction = db_transaction();
252 $transaction2 = db_transaction();
253 $this->executeDDLStatement();
254 unset($transaction2);
255 $transaction3 = db_transaction();
256 $this->insertRow('row');
257 $transaction3->rollBack();
258 unset($transaction3);
260 $this->assertRowAbsent('row');
262 // The behavior of a rollback depends on the type of database server.
263 if (Database::getConnection()->supportsTransactionalDDL()) {
264 // For database servers that support transactional DDL, a rollback
265 // of a transaction including DDL statements should be possible.
267 $transaction = db_transaction();
268 $this->insertRow('row');
269 $this->executeDDLStatement();
270 $transaction->rollBack();
272 $this->assertRowAbsent('row');
274 // Including with stacking.
276 $transaction = db_transaction();
277 $transaction2 = db_transaction();
278 $this->executeDDLStatement();
279 unset($transaction2);
280 $transaction3 = db_transaction();
281 $this->insertRow('row');
282 unset($transaction3);
283 $transaction->rollBack();
285 $this->assertRowAbsent('row');
288 // For database servers that do not support transactional DDL,
289 // the DDL statement should commit the transaction stack.
291 $transaction = db_transaction();
292 $this->insertRow('row');
293 $this->executeDDLStatement();
294 // Rollback the outer transaction.
296 $transaction->rollBack();
298 // @TODO: an exception should be triggered here, but is not, because
299 // "ROLLBACK" fails silently in MySQL if there is no transaction active.
300 // $this->fail(t('Rolling back a transaction containing DDL should fail.'));
302 catch (TransactionNoActiveException $e) {
303 $this->pass('Rolling back a transaction containing DDL should fail.');
305 $this->assertRowPresent('row');
310 * Inserts a single row into the testing table.
312 protected function insertRow($name) {
321 * Executes a DDL statement.
323 protected function executeDDLStatement() {
333 'primary key' => ['id'],
335 db_create_table('database_test_' . ++$count, $table);
339 * Starts over for a new test.
341 protected function cleanUp() {
347 * Asserts that a given row is present in the test table.
350 * The name of the row.
352 * The message to log for the assertion.
354 public function assertRowPresent($name, $message = NULL) {
355 if (!isset($message)) {
356 $message = format_string('Row %name is present.', ['%name' => $name]);
358 $present = (boolean) db_query('SELECT 1 FROM {test} WHERE name = :name', [':name' => $name])->fetchField();
359 return $this->assertTrue($present, $message);
363 * Asserts that a given row is absent from the test table.
366 * The name of the row.
368 * The message to log for the assertion.
370 public function assertRowAbsent($name, $message = NULL) {
371 if (!isset($message)) {
372 $message = format_string('Row %name is absent.', ['%name' => $name]);
374 $present = (boolean) db_query('SELECT 1 FROM {test} WHERE name = :name', [':name' => $name])->fetchField();
375 return $this->assertFalse($present, $message);
379 * Tests transaction stacking, commit, and rollback.
381 public function testTransactionStacking() {
382 // This test won't work right if transactions are not supported.
383 if (!Database::getConnection()->supportsTransactions()) {
387 $database = Database::getConnection();
389 // Standard case: pop the inner transaction before the outer transaction.
390 $transaction = db_transaction();
391 $this->insertRow('outer');
392 $transaction2 = db_transaction();
393 $this->insertRow('inner');
394 // Pop the inner transaction.
395 unset($transaction2);
396 $this->assertTrue($database->inTransaction(), 'Still in a transaction after popping the inner transaction');
397 // Pop the outer transaction.
399 $this->assertFalse($database->inTransaction(), 'Transaction closed after popping the outer transaction');
400 $this->assertRowPresent('outer');
401 $this->assertRowPresent('inner');
403 // Pop the transaction in a different order they have been pushed.
405 $transaction = db_transaction();
406 $this->insertRow('outer');
407 $transaction2 = db_transaction();
408 $this->insertRow('inner');
409 // Pop the outer transaction, nothing should happen.
411 $this->insertRow('inner-after-outer-commit');
412 $this->assertTrue($database->inTransaction(), 'Still in a transaction after popping the outer transaction');
413 // Pop the inner transaction, the whole transaction should commit.
414 unset($transaction2);
415 $this->assertFalse($database->inTransaction(), 'Transaction closed after popping the inner transaction');
416 $this->assertRowPresent('outer');
417 $this->assertRowPresent('inner');
418 $this->assertRowPresent('inner-after-outer-commit');
420 // Rollback the inner transaction.
422 $transaction = db_transaction();
423 $this->insertRow('outer');
424 $transaction2 = db_transaction();
425 $this->insertRow('inner');
426 // Now rollback the inner transaction.
427 $transaction2->rollBack();
428 unset($transaction2);
429 $this->assertTrue($database->inTransaction(), 'Still in a transaction after popping the outer transaction');
430 // Pop the outer transaction, it should commit.
431 $this->insertRow('outer-after-inner-rollback');
433 $this->assertFalse($database->inTransaction(), 'Transaction closed after popping the inner transaction');
434 $this->assertRowPresent('outer');
435 $this->assertRowAbsent('inner');
436 $this->assertRowPresent('outer-after-inner-rollback');
438 // Rollback the inner transaction after committing the outer one.
440 $transaction = db_transaction();
441 $this->insertRow('outer');
442 $transaction2 = db_transaction();
443 $this->insertRow('inner');
444 // Pop the outer transaction, nothing should happen.
446 $this->assertTrue($database->inTransaction(), 'Still in a transaction after popping the outer transaction');
447 // Now rollback the inner transaction, it should rollback.
448 $transaction2->rollBack();
449 unset($transaction2);
450 $this->assertFalse($database->inTransaction(), 'Transaction closed after popping the inner transaction');
451 $this->assertRowPresent('outer');
452 $this->assertRowAbsent('inner');
454 // Rollback the outer transaction while the inner transaction is active.
455 // In that case, an exception will be triggered because we cannot
456 // ensure that the final result will have any meaning.
458 $transaction = db_transaction();
459 $this->insertRow('outer');
460 $transaction2 = db_transaction();
461 $this->insertRow('inner');
462 $transaction3 = db_transaction();
463 $this->insertRow('inner2');
464 // Rollback the outer transaction.
466 $transaction->rollBack();
468 $this->fail('Rolling back the outer transaction while the inner transaction is active resulted in an exception.');
470 catch (TransactionOutOfOrderException $e) {
471 $this->pass('Rolling back the outer transaction while the inner transaction is active resulted in an exception.');
473 $this->assertFalse($database->inTransaction(), 'No more in a transaction after rolling back the outer transaction');
474 // Try to commit one inner transaction.
475 unset($transaction3);
476 $this->pass('Trying to commit an inner transaction resulted in an exception.');
477 // Try to rollback one inner transaction.
479 $transaction->rollBack();
480 unset($transaction2);
481 $this->fail('Trying to commit an inner transaction resulted in an exception.');
483 catch (TransactionNoActiveException $e) {
484 $this->pass('Trying to commit an inner transaction resulted in an exception.');
486 $this->assertRowAbsent('outer');
487 $this->assertRowAbsent('inner');
488 $this->assertRowAbsent('inner2');
492 * Tests that transactions can continue to be used if a query fails.
494 public function testQueryFailureInTransaction() {
495 $connection = Database::getConnection();
496 $transaction = $connection->startTransaction('test_transaction');
497 $connection->schema()->dropTable('test');
499 // Test a failed query using the query() method.
501 $connection->query('SELECT age FROM {test} WHERE name = :name', [':name' => 'David'])->fetchField();
502 $this->fail('Using the query method failed.');
504 catch (\Exception $e) {
505 $this->pass('Using the query method failed.');
508 // Test a failed select query.
510 $connection->select('test')
511 ->fields('test', ['name'])
514 $this->fail('Select query failed.');
516 catch (\Exception $e) {
517 $this->pass('Select query failed.');
520 // Test a failed insert query.
522 $connection->insert('test')
529 $this->fail('Insert query failed.');
531 catch (\Exception $e) {
532 $this->pass('Insert query failed.');
535 // Test a failed update query.
537 $connection->update('test')
538 ->fields(['name' => 'Tiffany'])
542 $this->fail('Update query failed.');
544 catch (\Exception $e) {
545 $this->pass('Update query failed.');
548 // Test a failed delete query.
550 $connection->delete('test')
554 $this->fail('Delete query failed.');
556 catch (\Exception $e) {
557 $this->pass('Delete query failed.');
560 // Test a failed merge query.
562 $connection->merge('test')
563 ->key('job', 'Presenter')
570 $this->fail('Merge query failed.');
572 catch (\Exception $e) {
573 $this->pass('Merge query failed.');
576 // Test a failed upsert query.
578 $connection->upsert('test')
580 ->fields(['job', 'age', 'name'])
582 'job' => 'Presenter',
588 $this->fail('Upset query failed.');
590 catch (\Exception $e) {
591 $this->pass('Upset query failed.');
594 // Create the missing schema and insert a row.
595 $this->installSchema('database_test', ['test']);
596 $connection->insert('test')
603 // Commit the transaction.
606 $saved_age = $connection->query('SELECT age FROM {test} WHERE name = :name', [':name' => 'David'])->fetchField();
607 $this->assertEqual('24', $saved_age);