Version 1
[yaffs-website] / web / core / tests / Drupal / KernelTests / Core / Database / TransactionTest.php
1 <?php
2
3 namespace Drupal\KernelTests\Core\Database;
4
5 use Drupal\Core\Database\Database;
6 use Drupal\Core\Database\TransactionOutOfOrderException;
7 use 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  */
30 class 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     $connection = Database::getConnection();
56     $depth = $connection->transactionDepth();
57     $txn = db_transaction();
58
59     // Insert a single row into the testing table.
60     db_insert('test')
61       ->fields([
62         'name' => 'David' . $suffix,
63         'age' => '24',
64       ])
65       ->execute();
66
67     $this->assertTrue($connection->inTransaction(), 'In transaction before calling nested transaction.');
68
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);
72
73     $this->assertTrue($connection->inTransaction(), 'In transaction after calling nested transaction.');
74
75     if ($rollback) {
76       // Roll back the transaction, if requested.
77       // This rollback should propagate to the last savepoint.
78       $txn->rollBack();
79       $this->assertTrue(($connection->transactionDepth() == $depth), 'Transaction has rolled back to the last savepoint after calling rollBack().');
80     }
81   }
82
83   /**
84    * Creates an "inner layer" transaction.
85    *
86    * This "inner layer" transaction is either used alone or nested inside of the
87    * "outer layer" transaction.
88    *
89    * @param $suffix
90    *   Suffix to add to field values to differentiate tests.
91    * @param $rollback
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.
95    */
96   protected function transactionInnerLayer($suffix, $rollback = FALSE, $ddl_statement = FALSE) {
97     $connection = Database::getConnection();
98
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();
105
106     $depth2 = $connection->transactionDepth();
107     $this->assertTrue($depth < $depth2, 'Transaction depth is has increased with new transaction.');
108
109     // Insert a single row into the testing table.
110     db_insert('test')
111       ->fields([
112         'name' => 'Daniel' . $suffix,
113         'age' => '19',
114       ])
115       ->execute();
116
117     $this->assertTrue($connection->inTransaction(), 'In transaction inside nested transaction.');
118
119     if ($ddl_statement) {
120       $table = [
121         'fields' => [
122           'id' => [
123             'type' => 'serial',
124             'unsigned' => TRUE,
125             'not null' => TRUE,
126           ],
127         ],
128         'primary key' => ['id'],
129       ];
130       db_create_table('database_test_1', $table);
131
132       $this->assertTrue($connection->inTransaction(), 'In transaction inside nested transaction.');
133     }
134
135     if ($rollback) {
136       // Roll back the transaction, if requested.
137       // This rollback should propagate to the last savepoint.
138       $txn->rollBack();
139       $this->assertTrue(($connection->transactionDepth() == $depth), 'Transaction has rolled back to the last savepoint after calling rollBack().');
140     }
141   }
142
143   /**
144    * Tests transaction rollback on a database that supports transactions.
145    *
146    * If the active connection does not support transactions, this test does
147    * nothing.
148    */
149   public function testTransactionRollBackSupported() {
150     // This test won't work right if transactions are not supported.
151     if (!Database::getConnection()->supportsTransactions()) {
152       return;
153     }
154     try {
155       // Create two nested transactions. Roll back from the inner one.
156       $this->transactionOuterLayer('B', TRUE);
157
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.');
164     }
165     catch (\Exception $e) {
166       $this->fail($e->getMessage());
167     }
168   }
169
170   /**
171    * Tests transaction rollback on a database that doesn't support transactions.
172    *
173    * If the active driver supports transactions, this test does nothing.
174    */
175   public function testTransactionRollBackNotSupported() {
176     // This test won't work right if transactions are supported.
177     if (Database::getConnection()->supportsTransactions()) {
178       return;
179     }
180     try {
181       // Create two nested transactions. Attempt to roll back from the inner one.
182       $this->transactionOuterLayer('B', TRUE);
183
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.');
190     }
191     catch (\Exception $e) {
192       $this->fail($e->getMessage());
193     }
194   }
195
196   /**
197    * Tests a committed transaction.
198    *
199    * The behavior of this test should be identical for connections that support
200    * transactions and those that do not.
201    */
202   public function testCommittedTransaction() {
203     try {
204       // Create two nested transactions. The changes should be committed.
205       $this->transactionOuterLayer('A');
206
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.');
212     }
213     catch (\Exception $e) {
214       $this->fail($e->getMessage());
215     }
216   }
217
218   /**
219    * Tests the compatibility of transactions with DDL statements.
220    */
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();
226     unset($transaction);
227     $this->assertRowPresent('row');
228
229     // Even in different order.
230     $this->cleanUp();
231     $transaction = db_transaction();
232     $this->executeDDLStatement();
233     $this->insertRow('row');
234     unset($transaction);
235     $this->assertRowPresent('row');
236
237     // Even with stacking.
238     $this->cleanUp();
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);
246     unset($transaction);
247     $this->assertRowPresent('row');
248
249     // A transaction after a DDL statement should still work the same.
250     $this->cleanUp();
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);
259     unset($transaction);
260     $this->assertRowAbsent('row');
261
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.
266       $this->cleanUp();
267       $transaction = db_transaction();
268       $this->insertRow('row');
269       $this->executeDDLStatement();
270       $transaction->rollBack();
271       unset($transaction);
272       $this->assertRowAbsent('row');
273
274       // Including with stacking.
275       $this->cleanUp();
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();
284       unset($transaction);
285       $this->assertRowAbsent('row');
286     }
287     else {
288       // For database servers that do not support transactional DDL,
289       // the DDL statement should commit the transaction stack.
290       $this->cleanUp();
291       $transaction = db_transaction();
292       $this->insertRow('row');
293       $this->executeDDLStatement();
294       // Rollback the outer transaction.
295       try {
296         $transaction->rollBack();
297         unset($transaction);
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.'));
301       }
302       catch (TransactionNoActiveException $e) {
303         $this->pass('Rolling back a transaction containing DDL should fail.');
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     db_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     db_create_table('database_test_' . ++$count, $table);
336   }
337
338   /**
339    * Starts over for a new test.
340    */
341   protected function cleanUp() {
342     db_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 = format_string('Row %name is present.', ['%name' => $name]);
357     }
358     $present = (boolean) db_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 = format_string('Row %name is absent.', ['%name' => $name]);
373     }
374     $present = (boolean) db_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 (!Database::getConnection()->supportsTransactions()) {
384       return;
385     }
386
387     $database = Database::getConnection();
388
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.
398     unset($transaction);
399     $this->assertFalse($database->inTransaction(), 'Transaction closed after popping the outer transaction');
400     $this->assertRowPresent('outer');
401     $this->assertRowPresent('inner');
402
403     // Pop the transaction in a different order they have been pushed.
404     $this->cleanUp();
405     $transaction = db_transaction();
406     $this->insertRow('outer');
407     $transaction2 = db_transaction();
408     $this->insertRow('inner');
409     // Pop the outer transaction, nothing should happen.
410     unset($transaction);
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');
419
420     // Rollback the inner transaction.
421     $this->cleanUp();
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');
432     unset($transaction);
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');
437
438     // Rollback the inner transaction after committing the outer one.
439     $this->cleanUp();
440     $transaction = db_transaction();
441     $this->insertRow('outer');
442     $transaction2 = db_transaction();
443     $this->insertRow('inner');
444     // Pop the outer transaction, nothing should happen.
445     unset($transaction);
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');
453
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.
457     $this->cleanUp();
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.
465     try {
466       $transaction->rollBack();
467       unset($transaction);
468       $this->fail('Rolling back the outer transaction while the inner transaction is active resulted in an exception.');
469     }
470     catch (TransactionOutOfOrderException $e) {
471       $this->pass('Rolling back the outer transaction while the inner transaction is active resulted in an exception.');
472     }
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.
478     try {
479       $transaction->rollBack();
480       unset($transaction2);
481       $this->fail('Trying to commit an inner transaction resulted in an exception.');
482     }
483     catch (TransactionNoActiveException $e) {
484       $this->pass('Trying to commit an inner transaction resulted in an exception.');
485     }
486     $this->assertRowAbsent('outer');
487     $this->assertRowAbsent('inner');
488     $this->assertRowAbsent('inner2');
489   }
490
491   /**
492    * Tests that transactions can continue to be used if a query fails.
493    */
494   public function testQueryFailureInTransaction() {
495     $connection = Database::getConnection();
496     $transaction = $connection->startTransaction('test_transaction');
497     $connection->schema()->dropTable('test');
498
499     // Test a failed query using the query() method.
500     try {
501       $connection->query('SELECT age FROM {test} WHERE name = :name', [':name' => 'David'])->fetchField();
502       $this->fail('Using the query method failed.');
503     }
504     catch (\Exception $e) {
505       $this->pass('Using the query method failed.');
506     }
507
508     // Test a failed select query.
509     try {
510       $connection->select('test')
511         ->fields('test', ['name'])
512         ->execute();
513
514       $this->fail('Select query failed.');
515     }
516     catch (\Exception $e) {
517       $this->pass('Select query failed.');
518     }
519
520     // Test a failed insert query.
521     try {
522       $connection->insert('test')
523         ->fields([
524           'name' => 'David',
525           'age' => '24',
526         ])
527         ->execute();
528
529       $this->fail('Insert query failed.');
530     }
531     catch (\Exception $e) {
532       $this->pass('Insert query failed.');
533     }
534
535     // Test a failed update query.
536     try {
537       $connection->update('test')
538         ->fields(['name' => 'Tiffany'])
539         ->condition('id', 1)
540         ->execute();
541
542       $this->fail('Update query failed.');
543     }
544     catch (\Exception $e) {
545       $this->pass('Update query failed.');
546     }
547
548     // Test a failed delete query.
549     try {
550       $connection->delete('test')
551         ->condition('id', 1)
552         ->execute();
553
554       $this->fail('Delete query failed.');
555     }
556     catch (\Exception $e) {
557       $this->pass('Delete query failed.');
558     }
559
560     // Test a failed merge query.
561     try {
562       $connection->merge('test')
563         ->key('job', 'Presenter')
564         ->fields([
565           'age' => '31',
566           'name' => 'Tiffany',
567         ])
568         ->execute();
569
570       $this->fail('Merge query failed.');
571     }
572     catch (\Exception $e) {
573       $this->pass('Merge query failed.');
574     }
575
576     // Test a failed upsert query.
577     try {
578       $connection->upsert('test')
579         ->key('job')
580         ->fields(['job', 'age', 'name'])
581         ->values([
582           'job' => 'Presenter',
583           'age' => 31,
584           'name' => 'Tiffany',
585         ])
586         ->execute();
587
588       $this->fail('Upset query failed.');
589     }
590     catch (\Exception $e) {
591       $this->pass('Upset query failed.');
592     }
593
594     // Create the missing schema and insert a row.
595     $this->installSchema('database_test', ['test']);
596     $connection->insert('test')
597       ->fields([
598         'name' => 'David',
599         'age' => '24',
600       ])
601       ->execute();
602
603     // Commit the transaction.
604     unset($transaction);
605
606     $saved_age = $connection->query('SELECT age FROM {test} WHERE name = :name', [':name' => 'David'])->fetchField();
607     $this->assertEqual('24', $saved_age);
608   }
609
610 }