diff core/modules/migrate/tests/src/Unit/MigrateSqlIdMapTest.php @ 0:4c8ae668cc8c

Initial import (non-working)
author Chris Cannam
date Wed, 29 Nov 2017 16:09:58 +0000
parents
children 1fec387a4317
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/core/modules/migrate/tests/src/Unit/MigrateSqlIdMapTest.php	Wed Nov 29 16:09:58 2017 +0000
@@ -0,0 +1,1013 @@
+<?php
+
+namespace Drupal\Tests\migrate\Unit;
+
+use Drupal\Core\Database\Driver\sqlite\Connection;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate\MigrateException;
+use Drupal\migrate\Plugin\MigrateIdMapInterface;
+use Drupal\migrate\Row;
+
+/**
+ * Tests the SQL ID map plugin.
+ *
+ * @group migrate
+ */
+class MigrateSqlIdMapTest extends MigrateTestCase {
+
+  /**
+   * The migration configuration, initialized to set the ID and destination IDs.
+   *
+   * @var array
+   */
+  protected $migrationConfiguration = [
+    'id' => 'sql_idmap_test',
+  ];
+
+  /**
+   * The source IDs.
+   *
+   * @var array
+   */
+  protected $sourceIds = [
+    'source_id_property' => [
+      'type' => 'string',
+    ],
+  ];
+
+  /**
+   * The destination IDs.
+   *
+   * @var array
+   */
+  protected $destinationIds = [
+    'destination_id_property' => [
+      'type' => 'string',
+    ],
+  ];
+
+  /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $database;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    $this->database = $this->getDatabase([]);
+  }
+
+  /**
+   * Saves a single ID mapping row in the database.
+   *
+   * @param array $map
+   *   The row to save.
+   */
+  protected function saveMap(array $map) {
+    $table = 'migrate_map_sql_idmap_test';
+
+    $schema = $this->database->schema();
+    // If the table already exists, add any columns which are in the map array,
+    // but don't yet exist in the table. Yay, flexibility!
+    if ($schema->tableExists($table)) {
+      foreach (array_keys($map) as $field) {
+        if (!$schema->fieldExists($table, $field)) {
+          $schema->addField($table, $field, ['type' => 'text']);
+        }
+      }
+    }
+    else {
+      $schema->createTable($table, $this->createSchemaFromRow($map));
+    }
+
+    $this->database->insert($table)->fields($map)->execute();
+  }
+
+  /**
+   * Creates a test SQL ID map plugin.
+   *
+   * @return \Drupal\Tests\migrate\Unit\TestSqlIdMap
+   *   A SQL ID map plugin test instance.
+   */
+  protected function getIdMap() {
+    $migration = $this->getMigration();
+
+    $plugin = $this->getMock('Drupal\migrate\Plugin\MigrateSourceInterface');
+    $plugin
+      ->method('getIds')
+      ->willReturn($this->sourceIds);
+    $migration
+      ->method('getSourcePlugin')
+      ->willReturn($plugin);
+
+    $plugin = $this->getMock('Drupal\migrate\Plugin\MigrateDestinationInterface');
+    $plugin
+      ->method('getIds')
+      ->willReturn($this->destinationIds);
+    $migration
+      ->method('getDestinationPlugin')
+      ->willReturn($plugin);
+    $event_dispatcher = $this->getMock('Symfony\Component\EventDispatcher\EventDispatcherInterface');
+
+    $id_map = new TestSqlIdMap($this->database, [], 'sql', [], $migration, $event_dispatcher);
+    $migration
+      ->method('getIdMap')
+      ->willReturn($id_map);
+
+    return $id_map;
+  }
+
+  /**
+   * Sets defaults for SQL ID map plugin tests.
+   *
+   * @return array
+   *   An associative array with the following keys:
+   *   - source_row_status
+   *   - rollback_action
+   *   - hash
+   */
+  protected function idMapDefaults() {
+    $defaults = [
+      'source_row_status' => MigrateIdMapInterface::STATUS_IMPORTED,
+      'rollback_action' => MigrateIdMapInterface::ROLLBACK_DELETE,
+      'hash' => '',
+    ];
+    // By default, the PDO SQLite driver strongly prefers to return strings
+    // from SELECT queries. Even for columns that don't store strings. Even
+    // if the connection's STRINGIFY_FETCHES attribute is FALSE. This can cause
+    // assertSame() calls to fail, since 0 !== '0'. Casting these values to
+    // strings isn't the most elegant workaround, but it allows the assertions
+    // to pass properly.
+    if ($this->database->driver() == 'sqlite') {
+      $defaults['source_row_status'] = (string) $defaults['source_row_status'];
+      $defaults['rollback_action'] = (string) $defaults['rollback_action'];
+    }
+    return $defaults;
+  }
+
+  /**
+   * Tests the ID mapping method.
+   *
+   * Create two ID mappings and update the second to verify that:
+   * - saving new to empty tables work.
+   * - saving new to nonempty tables work.
+   * - updating work.
+   */
+  public function testSaveIdMapping() {
+    $source = [
+      'source_id_property' => 'source_value',
+    ];
+    $row = new Row($source, ['source_id_property' => []]);
+    $id_map = $this->getIdMap();
+    $id_map->saveIdMapping($row, ['destination_id_property' => 2]);
+    $expected_result = [
+      [
+        'sourceid1' => 'source_value',
+        'source_ids_hash' => $this->getIdMap()->getSourceIDsHash($source),
+        'destid1' => 2,
+      ] + $this->idMapDefaults(),
+    ];
+    $this->queryResultTest($this->getIdMapContents(), $expected_result);
+    $source = [
+      'source_id_property' => 'source_value_1',
+    ];
+    $row = new Row($source, ['source_id_property' => []]);
+    $id_map->saveIdMapping($row, ['destination_id_property' => 3]);
+    $expected_result[] = [
+      'sourceid1' => 'source_value_1',
+      'source_ids_hash' => $this->getIdMap()->getSourceIDsHash($source),
+      'destid1' => 3,
+    ] + $this->idMapDefaults();
+    $this->queryResultTest($this->getIdMapContents(), $expected_result);
+    $id_map->saveIdMapping($row, ['destination_id_property' => 4]);
+    $expected_result[1]['destid1'] = 4;
+    $this->queryResultTest($this->getIdMapContents(), $expected_result);
+  }
+
+  /**
+   * Tests the SQL ID map set message method.
+   */
+  public function testSetMessage() {
+    $message = $this->getMock('Drupal\migrate\MigrateMessageInterface');
+    $id_map = $this->getIdMap();
+    $id_map->setMessage($message);
+    $this->assertAttributeEquals($message, 'message', $id_map);
+  }
+
+  /**
+   * Tests the clear messages method.
+   */
+  public function testClearMessages() {
+    $message = 'Hello world.';
+    $expected_results = [0, 1, 2, 3];
+    $id_map = $this->getIdMap();
+
+    // Insert 4 message for later delete.
+    foreach ($expected_results as $key => $expected_result) {
+      $id_map->saveMessage(['source_id_property' => $key], $message);
+    }
+
+    // Truncate and check that 4 messages were deleted.
+    $this->assertEquals($id_map->messageCount(), 4);
+    $id_map->clearMessages();
+    $count = $id_map->messageCount();
+    $this->assertEquals($count, 0);
+  }
+
+  /**
+   * Tests the getRowsNeedingUpdate method for rows that need an update.
+   */
+  public function testGetRowsNeedingUpdate() {
+    $id_map = $this->getIdMap();
+    $row_statuses = [
+      MigrateIdMapInterface::STATUS_IMPORTED,
+      MigrateIdMapInterface::STATUS_NEEDS_UPDATE,
+      MigrateIdMapInterface::STATUS_IGNORED,
+      MigrateIdMapInterface::STATUS_FAILED,
+    ];
+    // Create a mapping row for each STATUS constant.
+    foreach ($row_statuses as $status) {
+      $source = ['source_id_property' => 'source_value_' . $status];
+      $row = new Row($source, ['source_id_property' => []]);
+      $destination = ['destination_id_property' => 'destination_value_' . $status];
+      $id_map->saveIdMapping($row, $destination, $status);
+      $expected_results[] = [
+        'sourceid1' => 'source_value_' . $status,
+        'source_ids_hash' => $this->getIdMap()->getSourceIDsHash($source),
+        'destid1' => 'destination_value_' . $status,
+        'source_row_status' => $status,
+        'rollback_action' => MigrateIdMapInterface::ROLLBACK_DELETE,
+        'hash' => '',
+      ];
+      // Assert zero rows need an update.
+      if ($status == MigrateIdMapInterface::STATUS_IMPORTED) {
+        $rows_needing_update = $id_map->getRowsNeedingUpdate(1);
+        $this->assertCount(0, $rows_needing_update);
+      }
+    }
+    // Assert that test values exist.
+    $this->queryResultTest($this->getIdMapContents(), $expected_results);
+
+    // Assert a single row needs an update.
+    $row_needing_update = $id_map->getRowsNeedingUpdate(1);
+    $this->assertCount(1, $row_needing_update);
+
+    // Assert the row matches its original source.
+    $source_id = $expected_results[MigrateIdMapInterface::STATUS_NEEDS_UPDATE]['sourceid1'];
+    $test_row = $id_map->getRowBySource(['source_id_property' => $source_id]);
+    // $row_needing_update is an array of objects returned from the database,
+    // but $test_row is an array, so the cast is necessary.
+    $this->assertSame($test_row, (array) $row_needing_update[0]);
+
+    // Add additional row that needs an update.
+    $source = ['source_id_property' => 'source_value_multiple'];
+    $row = new Row($source, ['source_id_property' => []]);
+    $destination = ['destination_id_property' => 'destination_value_multiple'];
+    $id_map->saveIdMapping($row, $destination, MigrateIdMapInterface::STATUS_NEEDS_UPDATE);
+
+    // Assert multiple rows need an update.
+    $rows_needing_update = $id_map->getRowsNeedingUpdate(2);
+    $this->assertCount(2, $rows_needing_update);
+  }
+
+  /**
+   * Tests the SQL ID map message count method by counting and saving messages.
+   */
+  public function testMessageCount() {
+    $message = 'Hello world.';
+    $expected_results = [0, 1, 2, 3];
+    $id_map = $this->getIdMap();
+
+    // Test count message multiple times starting from 0.
+    foreach ($expected_results as $key => $expected_result) {
+      $count = $id_map->messageCount();
+      $this->assertEquals($expected_result, $count);
+      $id_map->saveMessage(['source_id_property' => $key], $message);
+    }
+  }
+
+  /**
+   * Tests the SQL ID map save message method.
+   */
+  public function testMessageSave() {
+    $message = 'Hello world.';
+    $original_values = [
+      1 => ['message' => $message, 'level' => MigrationInterface::MESSAGE_ERROR],
+      2 => ['message' => $message, 'level' => MigrationInterface::MESSAGE_WARNING],
+      3 => ['message' => $message, 'level' => MigrationInterface::MESSAGE_NOTICE],
+      4 => ['message' => $message, 'level' => MigrationInterface::MESSAGE_INFORMATIONAL],
+    ];
+    $expected_results = [
+      '7ad742edb7e866caa78ced1e4455d2e9cbd8adb2074e7c323d21b4e67732e755' => ['message' => $message, 'level' => MigrationInterface::MESSAGE_ERROR],
+      '2d3ec2b0c547e819346e6ae03f881fd9f5c978ff3cbe29dfb807d40735e53703' => ['message' => $message, 'level' => MigrationInterface::MESSAGE_WARNING],
+      '12a042f72cad9a2a8c7715df0c7695d762975f0687d87f5d480725dae1432a6f' => ['message' => $message, 'level' => MigrationInterface::MESSAGE_NOTICE],
+      'd9d1fd27a2447ace48f47a2e9ff649673f67b446d9381a7963c949fc083f8791' => ['message' => $message, 'level' => MigrationInterface::MESSAGE_INFORMATIONAL],
+    ];
+    $id_map = $this->getIdMap();
+
+    foreach ($original_values as $key => $original_value) {
+      $id_map->saveMessage(['source_id_property' => $key], $message, $original_value['level']);
+    }
+
+    foreach ($id_map->getMessageIterator() as $message_row) {
+      $key = $message_row->source_ids_hash;
+      $this->assertEquals($expected_results[$key]['message'], $message_row->message);
+      $this->assertEquals($expected_results[$key]['level'], $message_row->level);
+    }
+
+    // Insert with default level.
+    $message_default = 'Hello world default.';
+    $id_map->saveMessage(['source_id_property' => 5], $message_default);
+    $messages = $id_map->getMessageIterator(['source_id_property' => 5]);
+    $count = 0;
+    foreach ($messages as $key => $message_row) {
+      $count = 1;
+      $this->assertEquals($message_default, $message_row->message);
+      $this->assertEquals(MigrationInterface::MESSAGE_ERROR, $message_row->level);
+    }
+    $this->assertEquals($count, 1);
+
+    // Retrieve messages with a specific level.
+    $messages = $id_map->getMessageIterator([], MigrationInterface::MESSAGE_WARNING);
+    $count = 0;
+    foreach ($messages as $key => $message_row) {
+      $count = 1;
+      $this->assertEquals(MigrationInterface::MESSAGE_WARNING, $message_row->level);
+    }
+    $this->assertEquals($count, 1);
+  }
+
+  /**
+   * Tests the getRowBySource method.
+   */
+  public function testGetRowBySource() {
+    $this->getDatabase([]);
+    $row = [
+      'sourceid1' => 'source_id_value_1',
+      'sourceid2' => 'source_id_value_2',
+      'source_ids_hash' => $this->getIdMap()->getSourceIDsHash(['source_id_property' => 'source_id_value_1']),
+      'destid1' => 'destination_id_value_1',
+    ] + $this->idMapDefaults();
+    $this->saveMap($row);
+    $row = [
+      'sourceid1' => 'source_id_value_3',
+      'sourceid2' => 'source_id_value_4',
+      'source_ids_hash' => $this->getIdMap()->getSourceIDsHash(['source_id_property' => 'source_id_value_3', 'sourceid2' => 'source_id_value_4']),
+      'destid1' => 'destination_id_value_2',
+    ] + $this->idMapDefaults();
+    $this->saveMap($row);
+    $source_id_values = ['source_id_property' => $row['sourceid1'], 'sourceid2' => $row['sourceid2']];
+    $id_map = $this->getIdMap();
+    $result_row = $id_map->getRowBySource($source_id_values);
+    $this->assertSame($row, $result_row);
+    $source_id_values = ['source_id_property' => 'missing_value_1', 'sourceid2' => 'missing_value_2'];
+    $result_row = $id_map->getRowBySource($source_id_values);
+    $this->assertFalse($result_row);
+  }
+
+  /**
+   * Data provider for testLookupDestinationIdMapping().
+   *
+   * Scenarios to test (for both hits and misses) are:
+   * - Single-value source ID to single-value destination ID.
+   * - Multi-value source ID to multi-value destination ID.
+   * - Single-value source ID to multi-value destination ID.
+   * - Multi-value source ID to single-value destination ID.
+   *
+   * @return array
+   *   An array of data values.
+   */
+  public function lookupDestinationIdMappingDataProvider() {
+    return [
+      [1, 1],
+      [2, 2],
+      [1, 2],
+      [2, 1],
+    ];
+  }
+
+  /**
+   * Performs destination ID test on source and destination fields.
+   *
+   * @param int $num_source_fields
+   *   Number of source fields to test.
+   * @param int $num_destination_fields
+   *   Number of destination fields to test.
+   *
+   * @dataProvider lookupDestinationIdMappingDataProvider
+   */
+  public function testLookupDestinationIdMapping($num_source_fields, $num_destination_fields) {
+    // Adjust the migration configuration according to the number of source and
+    // destination fields.
+    $this->sourceIds = [];
+    $this->destinationIds = [];
+    $source_id_values = [];
+    $nonexistent_id_values = [];
+    $row = $this->idMapDefaults();
+    for ($i = 1; $i <= $num_source_fields; $i++) {
+      $row["sourceid$i"] = "source_id_value_$i";
+      $source_id_values[] = "source_id_value_$i";
+      $nonexistent_id_values[] = "nonexistent_source_id_value_$i";
+      $this->sourceIds["source_id_property_$i"] = [];
+    }
+    $expected_result = [];
+    for ($i = 1; $i <= $num_destination_fields; $i++) {
+      $row["destid$i"] = "destination_id_value_$i";
+      $expected_result[] = "destination_id_value_$i";
+      $this->destinationIds["destination_id_property_$i"] = [];
+    }
+    $row['source_ids_hash'] = $this->getIdMap()->getSourceIDsHash($source_id_values);
+    $this->saveMap($row);
+    $id_map = $this->getIdMap();
+    // Test for a valid hit.
+    $destination_id = $id_map->lookupDestinationId($source_id_values);
+    $this->assertSame($expected_result, $destination_id);
+    // Test for a miss.
+    $destination_id = $id_map->lookupDestinationId($nonexistent_id_values);
+    $this->assertSame(0, count($destination_id));
+  }
+
+  /**
+   * Setup a database with the given rows.
+   *
+   * @param array $source_keys
+   *   The source keys for the ID map table.
+   * @param array $dest_keys
+   *   The destination keys for the ID map table.
+   * @param array $rows
+   *   An array of source and destination value arrays for the ID map table.
+   *
+   * @return \Drupal\Tests\migrate\Unit\TestSqlIdMap
+   *   An ID map instance for testing.
+   */
+  protected function setupRows($source_keys, $dest_keys, $rows) {
+    $this->database = $this->getDatabase([]);
+    $this->sourceIds = array_fill_keys($source_keys, []);
+    $this->destinationIds = array_fill_keys($dest_keys, []);
+
+    $db_keys = [];
+    foreach (array_keys($source_keys) as $i) {
+      $db_keys[] = 'sourceid' . ($i + 1);
+    }
+    foreach (array_keys($dest_keys) as $i) {
+      $db_keys[] = 'destid' . ($i + 1);
+    }
+    foreach ($rows as $row) {
+      $values = array_combine($db_keys, $row);
+      $source_values = array_slice($row, 0, count($source_keys));
+      $values['source_ids_hash'] = $this->getIdMap()->getSourceIDsHash($source_values);
+      $this->saveMap($values);
+    }
+
+    return $this->getIdMap();
+  }
+
+  /**
+   * Tests lookupDestinationIds().
+   */
+  public function testLookupDestinationIds() {
+    // Simple map with one source and one destination ID.
+    $id_map = $this->setupRows(['nid'], ['nid'], [
+      [1, 101],
+      [2, 102],
+      [3, 103],
+    ]);
+
+    // Lookup nothing, gives nothing.
+    $this->assertEquals([], $id_map->lookupDestinationIds([]));
+    // Lookup by complete non-associative list.
+    $this->assertEquals([[101]], $id_map->lookupDestinationIds([1]));
+    $this->assertEquals([[102]], $id_map->lookupDestinationIds([2]));
+    $this->assertEquals([], $id_map->lookupDestinationIds([99]));
+    // Lookup by complete associative list.
+    $this->assertEquals([[101]], $id_map->lookupDestinationIds(['nid' => 1]));
+    $this->assertEquals([[102]], $id_map->lookupDestinationIds(['nid' => 2]));
+    $this->assertEquals([], $id_map->lookupDestinationIds(['nid' => 99]));
+
+    // Map with multiple source and destination IDs.
+    $id_map = $this->setupRows(['nid', 'language'], ['nid', 'langcode'], [
+      [1, 'en', 101, 'en'],
+      [1, 'fr', 101, 'fr'],
+      [1, 'de', 101, 'de'],
+      [2, 'en', 102, 'en'],
+    ]);
+
+    // Lookup nothing, gives nothing.
+    $this->assertEquals([], $id_map->lookupDestinationIds([]));
+    // Lookup by complete non-associative list.
+    $this->assertEquals([[101, 'en']], $id_map->lookupDestinationIds([1, 'en']));
+    $this->assertEquals([[101, 'fr']], $id_map->lookupDestinationIds([1, 'fr']));
+    $this->assertEquals([[102, 'en']], $id_map->lookupDestinationIds([2, 'en']));
+    $this->assertEquals([], $id_map->lookupDestinationIds([2, 'fr']));
+    $this->assertEquals([], $id_map->lookupDestinationIds([99, 'en']));
+    // Lookup by complete associative list.
+    $this->assertEquals([[101, 'en']], $id_map->lookupDestinationIds(['nid' => 1, 'language' => 'en']));
+    $this->assertEquals([[101, 'fr']], $id_map->lookupDestinationIds(['nid' => 1, 'language' => 'fr']));
+    $this->assertEquals([[102, 'en']], $id_map->lookupDestinationIds(['nid' => 2, 'language' => 'en']));
+    $this->assertEquals([], $id_map->lookupDestinationIds(['nid' => 2, 'language' => 'fr']));
+    $this->assertEquals([], $id_map->lookupDestinationIds(['nid' => 99, 'language' => 'en']));
+    // Lookup by partial non-associative list.
+    $this->assertEquals([[101, 'en'], [101, 'fr'], [101, 'de']], $id_map->lookupDestinationIds([1]));
+    $this->assertEquals([[102, 'en']], $id_map->lookupDestinationIds([2]));
+    $this->assertEquals([], $id_map->lookupDestinationIds([99]));
+    // Lookup by partial associative list.
+    $this->assertEquals([[101, 'en'], [101, 'fr'], [101, 'de']], $id_map->lookupDestinationIds(['nid' => 1]));
+    $this->assertEquals([[102, 'en']], $id_map->lookupDestinationIds(['nid' => 2]));
+    $this->assertEquals([], $id_map->lookupDestinationIds(['nid' => 99]));
+    // Out-of-order partial associative list.
+    $this->assertEquals([[101, 'en'], [102, 'en']], $id_map->lookupDestinationIds(['language' => 'en']));
+    $this->assertEquals([[101, 'fr']], $id_map->lookupDestinationIds(['language' => 'fr']));
+    $this->assertEquals([], $id_map->lookupDestinationIds(['language' => 'zh']));
+    // Error conditions.
+    try {
+      $id_map->lookupDestinationIds([1, 2, 3]);
+      $this->fail('Too many source IDs should throw');
+    }
+    catch (MigrateException $e) {
+      $this->assertEquals("Extra unknown items in source IDs", $e->getMessage());
+    }
+    try {
+      $id_map->lookupDestinationIds(['nid' => 1, 'aaa' => '2']);
+      $this->fail('Unknown source ID key should throw');
+    }
+    catch (MigrateException $e) {
+      $this->assertEquals("Extra unknown items in source IDs", $e->getMessage());
+    }
+
+    // Verify that we are looking up by source_id_hash when all source IDs are
+    // passed in.
+    $id_map->getDatabase()->update($id_map->mapTableName())
+      ->condition('sourceid1', 1)
+      ->condition('sourceid2', 'en')
+      ->fields([TestSqlIdMap::SOURCE_IDS_HASH => uniqid()])
+      ->execute();
+    $this->assertNotEquals([[101, 'en']], $id_map->lookupDestinationIds([1, 'en']));
+  }
+
+  /**
+   * Tests the getRowByDestination method.
+   */
+  public function testGetRowByDestination() {
+    $row = [
+      'sourceid1' => 'source_id_value_1',
+      'sourceid2' => 'source_id_value_2',
+      'source_ids_hash' => $this->getIdMap()->getSourceIDsHash(['source_id_property' => 'source_id_value_1']),
+      'destid1' => 'destination_id_value_1',
+    ] + $this->idMapDefaults();
+    $this->saveMap($row);
+    $row = [
+      'sourceid1' => 'source_id_value_3',
+      'sourceid2' => 'source_id_value_4',
+      'source_ids_hash' => $this->getIdMap()->getSourceIDsHash(['source_id_property' => 'source_id_value_3']),
+      'destid1' => 'destination_id_value_2',
+    ] + $this->idMapDefaults();
+    $this->saveMap($row);
+    $dest_id_values = ['destination_id_property' => $row['destid1']];
+    $id_map = $this->getIdMap();
+    $result_row = $id_map->getRowByDestination($dest_id_values);
+    $this->assertSame($row, $result_row);
+    // This value does not exist.
+    $dest_id_values = ['destination_id_property' => 'invalid_destination_id_property'];
+    $id_map = $this->getIdMap();
+    $result_row = $id_map->getRowByDestination($dest_id_values);
+    $this->assertFalse($result_row);
+  }
+
+  /**
+   * Data provider for testLookupSourceIDMapping().
+   *
+   * Scenarios to test (for both hits and misses) are:
+   * - Single-value destination ID to single-value source ID.
+   * - Multi-value destination ID to multi-value source ID.
+   * - Single-value destination ID to multi-value source ID.
+   * - Multi-value destination ID to single-value source ID.
+   *
+   * @return array
+   *   An array of data values.
+   */
+  public function lookupSourceIDMappingDataProvider() {
+    return [
+      [1, 1],
+      [2, 2],
+      [1, 2],
+      [2, 1],
+    ];
+  }
+
+  /**
+   * Performs the source ID test on source and destination fields.
+   *
+   * @param int $num_source_fields
+   *   Number of source fields to test.
+   * @param int $num_destination_fields
+   *   Number of destination fields to test.
+   *
+   * @dataProvider lookupSourceIDMappingDataProvider
+   */
+  public function testLookupSourceIDMapping($num_source_fields, $num_destination_fields) {
+    // Adjust the migration configuration according to the number of source and
+    // destination fields.
+    $this->sourceIds = [];
+    $this->destinationIds = [];
+    $row = $this->idMapDefaults();
+    $source_ids_values = [];
+    $expected_result = [];
+    for ($i = 1; $i <= $num_source_fields; $i++) {
+      $row["sourceid$i"] = "source_id_value_$i";
+      $source_ids_values = [$row["sourceid$i"]];
+      $expected_result["source_id_property_$i"] = "source_id_value_$i";
+      $this->sourceIds["source_id_property_$i"] = [];
+    }
+    $destination_id_values = [];
+    $nonexistent_id_values = [];
+    for ($i = 1; $i <= $num_destination_fields; $i++) {
+      $row["destid$i"] = "destination_id_value_$i";
+      $destination_id_values["destination_id_property_$i"] = "destination_id_value_$i";
+      $nonexistent_id_values["destination_id_property_$i"] = "nonexistent_destination_id_value_$i";
+      $this->destinationIds["destination_id_property_$i"] = [];
+    }
+    $row['source_ids_hash'] = $this->getIdMap()->getSourceIDsHash($source_ids_values);
+    $this->saveMap($row);
+    $id_map = $this->getIdMap();
+    // Test for a valid hit.
+    $source_id = $id_map->lookupSourceID($destination_id_values);
+    $this->assertSame($expected_result, $source_id);
+    // Test for a miss.
+    $source_id = $id_map->lookupSourceID($nonexistent_id_values);
+    $this->assertSame(0, count($source_id));
+  }
+
+  /**
+   * Tests currentDestination() and currentSource().
+   */
+  public function testCurrentDestinationAndSource() {
+    // Simple map with one source and one destination ID.
+    $id_map = $this->setupRows(['nid'], ['nid'], [
+      [1, 101],
+      [2, 102],
+      [3, 103],
+      // Mock a failed row by setting the destination ID to NULL.
+      [4, NULL],
+    ]);
+
+    // The rows are ordered by destination ID so the failed row should be first.
+    $id_map->rewind();
+    $this->assertEquals([], $id_map->currentDestination());
+    $this->assertEquals(['nid' => 4], $id_map->currentSource());
+    $id_map->next();
+    $this->assertEquals(['nid' => 101], $id_map->currentDestination());
+    $this->assertEquals(['nid' => 1], $id_map->currentSource());
+    $id_map->next();
+    $this->assertEquals(['nid' => 102], $id_map->currentDestination());
+    $this->assertEquals(['nid' => 2], $id_map->currentSource());
+    $id_map->next();
+    $this->assertEquals(['nid' => 103], $id_map->currentDestination());
+    $this->assertEquals(['nid' => 3], $id_map->currentSource());
+    $id_map->next();
+  }
+
+  /**
+   * Tests the imported count method.
+   *
+   * Scenarios to test for:
+   * - No imports.
+   * - One import.
+   * - Multiple imports.
+   */
+  public function testImportedCount() {
+    $id_map = $this->getIdMap();
+    // Add a single failed row and assert zero imported rows.
+    $source = ['source_id_property' => 'source_value_failed'];
+    $row = new Row($source, ['source_id_property' => []]);
+    $destination = ['destination_id_property' => 'destination_value_failed'];
+    $id_map->saveIdMapping($row, $destination, MigrateIdMapInterface::STATUS_FAILED);
+    $this->assertSame(0, (int) $id_map->importedCount());
+
+    // Add an imported row and assert single count.
+    $source = ['source_id_property' => 'source_value_imported'];
+    $row = new Row($source, ['source_id_property' => []]);
+    $destination = ['destination_id_property' => 'destination_value_imported'];
+    $id_map->saveIdMapping($row, $destination, MigrateIdMapInterface::STATUS_IMPORTED);
+    $this->assertSame(1, (int) $id_map->importedCount());
+
+    // Add a row needing update and assert multiple imported rows.
+    $source = ['source_id_property' => 'source_value_update'];
+    $row = new Row($source, ['source_id_property' => []]);
+    $destination = ['destination_id_property' => 'destination_value_update'];
+    $id_map->saveIdMapping($row, $destination, MigrateIdMapInterface::STATUS_NEEDS_UPDATE);
+    $this->assertSame(2, (int) $id_map->importedCount());
+  }
+
+  /**
+   * Tests the number of processed source rows.
+   *
+   * Scenarios to test for:
+   * - No processed rows.
+   * - One processed row.
+   * - Multiple processed rows.
+   */
+  public function testProcessedCount() {
+    $id_map = $this->getIdMap();
+    // Assert zero rows have been processed before adding rows.
+    $this->assertSame(0, (int) $id_map->processedCount());
+    $row_statuses = [
+      MigrateIdMapInterface::STATUS_IMPORTED,
+      MigrateIdMapInterface::STATUS_NEEDS_UPDATE,
+      MigrateIdMapInterface::STATUS_IGNORED,
+      MigrateIdMapInterface::STATUS_FAILED,
+    ];
+    // Create a mapping row for each STATUS constant.
+    foreach ($row_statuses as $status) {
+      $source = ['source_id_property' => 'source_value_' . $status];
+      $row = new Row($source, ['source_id_property' => []]);
+      $destination = ['destination_id_property' => 'destination_value_' . $status];
+      $id_map->saveIdMapping($row, $destination, $status);
+      if ($status == MigrateIdMapInterface::STATUS_IMPORTED) {
+        // Assert a single row has been processed.
+        $this->assertSame(1, (int) $id_map->processedCount());
+      }
+    }
+    // Assert multiple rows have been processed.
+    $this->assertSame(count($row_statuses), (int) $id_map->processedCount());
+  }
+
+  /**
+   * Data provider for testUpdateCount().
+   *
+   * Scenarios to test for:
+   * - No updates.
+   * - One update.
+   * - Multiple updates.
+   *
+   * @return array
+   *   An array of data values.
+   */
+  public function updateCountDataProvider() {
+    return [
+      [0],
+      [1],
+      [3],
+    ];
+  }
+
+  /**
+   * Performs the update count test with a given number of update rows.
+   *
+   * @param int $num_update_rows
+   *   The number of update rows to test.
+   *
+   * @dataProvider updateCountDataProvider
+   */
+  public function testUpdateCount($num_update_rows) {
+    for ($i = 0; $i < 5; $i++) {
+      $row = $this->idMapDefaults();
+      $row['sourceid1'] = "source_id_value_$i";
+      $row['source_ids_hash'] = $this->getIdMap()->getSourceIDsHash(['source_id_property' => $row['sourceid1']]);
+      $row['destid1'] = "destination_id_value_$i";
+      $row['source_row_status'] = MigrateIdMapInterface::STATUS_IMPORTED;
+      $this->saveMap($row);
+    }
+    for (; $i < 5 + $num_update_rows; $i++) {
+      $row = $this->idMapDefaults();
+      $row['sourceid1'] = "source_id_value_$i";
+      $row['source_ids_hash'] = $this->getIdMap()->getSourceIDsHash(['source_id_property' => $row['sourceid1']]);
+      $row['destid1'] = "destination_id_value_$i";
+      $row['source_row_status'] = MigrateIdMapInterface::STATUS_NEEDS_UPDATE;
+      $this->saveMap($row);
+    }
+    $id_map = $this->getIdMap();
+    $this->assertSame($num_update_rows, (int) $id_map->updateCount());
+  }
+
+  /**
+   * Data provider for testErrorCount().
+   *
+   * Scenarios to test for:
+   * - No errors.
+   * - One error.
+   * - Multiple errors.
+   *
+   * @return array
+   *   An array of data values.
+   */
+  public function errorCountDataProvider() {
+    return [
+      [0],
+      [1],
+      [3],
+    ];
+  }
+
+  /**
+   * Performs error count test with a given number of error rows.
+   *
+   * @param int $num_error_rows
+   *   Number of error rows to test.
+   *
+   * @dataProvider errorCountDataProvider
+   */
+  public function testErrorCount($num_error_rows) {
+    for ($i = 0; $i < 5; $i++) {
+      $row = $this->idMapDefaults();
+      $row['sourceid1'] = "source_id_value_$i";
+      $row['source_ids_hash'] = $this->getIdMap()->getSourceIDsHash(['source_id_property' => $row['sourceid1']]);
+      $row['destid1'] = "destination_id_value_$i";
+      $row['source_row_status'] = MigrateIdMapInterface::STATUS_IMPORTED;
+      $this->saveMap($row);
+    }
+    for (; $i < 5 + $num_error_rows; $i++) {
+      $row = $this->idMapDefaults();
+      $row['sourceid1'] = "source_id_value_$i";
+      $row['source_ids_hash'] = $this->getIdMap()->getSourceIDsHash(['source_id_property' => $row['sourceid1']]);
+      $row['destid1'] = "destination_id_value_$i";
+      $row['source_row_status'] = MigrateIdMapInterface::STATUS_FAILED;
+      $this->saveMap($row);
+    }
+
+    $this->assertSame($num_error_rows, (int) $this->getIdMap()->errorCount());
+  }
+
+  /**
+   * Tests setting a row source_row_status to STATUS_NEEDS_UPDATE.
+   */
+  public function testSetUpdate() {
+    $id_map = $this->getIdMap();
+    $row_statuses = [
+      MigrateIdMapInterface::STATUS_IMPORTED,
+      MigrateIdMapInterface::STATUS_NEEDS_UPDATE,
+      MigrateIdMapInterface::STATUS_IGNORED,
+      MigrateIdMapInterface::STATUS_FAILED,
+    ];
+    // Create a mapping row for each STATUS constant.
+    foreach ($row_statuses as $status) {
+      $source = ['source_id_property' => 'source_value_' . $status];
+      $row = new Row($source, ['source_id_property' => []]);
+      $destination = ['destination_id_property' => 'destination_value_' . $status];
+      $id_map->saveIdMapping($row, $destination, $status);
+      $expected_results[] = [
+        'sourceid1' => 'source_value_' . $status,
+        'source_ids_hash' => $this->getIdMap()->getSourceIDsHash($source),
+        'destid1' => 'destination_value_' . $status,
+        'source_row_status' => $status,
+        'rollback_action' => MigrateIdMapInterface::ROLLBACK_DELETE,
+        'hash' => '',
+      ];
+    }
+    // Assert that test values exist.
+    $this->queryResultTest($this->getIdMapContents(), $expected_results);
+    // Mark each row as STATUS_NEEDS_UPDATE.
+    foreach ($row_statuses as $status) {
+      $id_map->setUpdate(['source_id_property' => 'source_value_' . $status]);
+    }
+    // Update expected results.
+    foreach ($expected_results as $key => $value) {
+      $expected_results[$key]['source_row_status'] = MigrateIdMapInterface::STATUS_NEEDS_UPDATE;
+    }
+    // Assert that updated expected values match.
+    $this->queryResultTest($this->getIdMapContents(), $expected_results);
+    // Assert an exception is thrown when source identifiers are not provided.
+    try {
+      $id_map->setUpdate([]);
+      $this->assertFalse(FALSE, 'MigrateException not thrown, when source identifiers were provided to update.');
+    }
+    catch (MigrateException $e) {
+      $this->assertTrue(TRUE, "MigrateException thrown, when source identifiers were not provided to update.");
+    }
+  }
+
+  /**
+   * Tests prepareUpdate().
+   */
+  public function testPrepareUpdate() {
+    $id_map = $this->getIdMap();
+    $row_statuses = [
+      MigrateIdMapInterface::STATUS_IMPORTED,
+      MigrateIdMapInterface::STATUS_NEEDS_UPDATE,
+      MigrateIdMapInterface::STATUS_IGNORED,
+      MigrateIdMapInterface::STATUS_FAILED,
+    ];
+
+    // Create a mapping row for each STATUS constant.
+    foreach ($row_statuses as $status) {
+      $source = ['source_id_property' => 'source_value_' . $status];
+      $row = new Row($source, ['source_id_property' => []]);
+      $destination = ['destination_id_property' => 'destination_value_' . $status];
+      $id_map->saveIdMapping($row, $destination, $status);
+      $expected_results[] = [
+        'sourceid1' => 'source_value_' . $status,
+        'destid1' => 'destination_value_' . $status,
+        'source_row_status' => $status,
+        'rollback_action' => MigrateIdMapInterface::ROLLBACK_DELETE,
+        'hash' => '',
+      ];
+    }
+
+    // Assert that test values exist.
+    $this->queryResultTest($this->getIdMapContents(), $expected_results);
+
+    // Mark all rows as STATUS_NEEDS_UPDATE.
+    $id_map->prepareUpdate();
+
+    // Update expected results.
+    foreach ($expected_results as $key => $value) {
+      $expected_results[$key]['source_row_status'] = MigrateIdMapInterface::STATUS_NEEDS_UPDATE;
+    }
+    // Assert that updated expected values match.
+    $this->queryResultTest($this->getIdMapContents(), $expected_results);
+  }
+
+  /**
+   * Tests the destroy method.
+   *
+   * Scenarios to test for:
+   * - No errors.
+   * - One error.
+   * - Multiple errors.
+   */
+  public function testDestroy() {
+    $id_map = $this->getIdMap();
+    // Initialize the ID map.
+    $id_map->getDatabase();
+    $map_table_name = $id_map->mapTableName();
+    $message_table_name = $id_map->messageTableName();
+    $row = new Row(['source_id_property' => 'source_value'], ['source_id_property' => []]);
+    $id_map->saveIdMapping($row, ['destination_id_property' => 2]);
+    $id_map->saveMessage(['source_id_property' => 'source_value'], 'A message');
+    $this->assertTrue($this->database->schema()->tableExists($map_table_name),
+                      "$map_table_name exists");
+    $this->assertTrue($this->database->schema()->tableExists($message_table_name),
+                      "$message_table_name exists");
+    $id_map->destroy();
+    $this->assertFalse($this->database->schema()->tableExists($map_table_name),
+                       "$map_table_name does not exist");
+    $this->assertFalse($this->database->schema()->tableExists($message_table_name),
+                       "$message_table_name does not exist");
+  }
+
+  /**
+   * Tests the getQualifiedMapTable method with a prefixed database.
+   */
+  public function testGetQualifiedMapTablePrefix() {
+    $connection_options = [
+      'database' => ':memory:',
+      'prefix' => 'prefix',
+    ];
+    $pdo = Connection::open($connection_options);
+    $this->database = new Connection($pdo, $connection_options);
+    $qualified_map_table = $this->getIdMap()->getQualifiedMapTableName();
+    // The SQLite driver is a special flower. It will prefix tables with
+    // PREFIX.TABLE, instead of the standard PREFIXTABLE.
+    // @see \Drupal\Core\Database\Driver\sqlite\Connection::__construct()
+    $this->assertEquals('prefix.migrate_map_sql_idmap_test', $qualified_map_table);
+  }
+
+  /**
+   * Tests all the iterator methods in one swing.
+   *
+   * The iterator methods are:
+   * - Sql::rewind()
+   * - Sql::next()
+   * - Sql::valid()
+   * - Sql::key()
+   * - Sql::current()
+   */
+  public function testIterators() {
+    for ($i = 0; $i < 3; $i++) {
+      $row = $this->idMapDefaults();
+      $row['sourceid1'] = "source_id_value_$i";
+      $row['source_ids_hash'] = $this->getIdMap()->getSourceIDsHash(['source_id_property' => $row['sourceid1']]);
+      $row['destid1'] = "destination_id_value_$i";
+      $row['source_row_status'] = MigrateIdMapInterface::STATUS_IMPORTED;
+      $expected_results[serialize(['sourceid1' => $row['sourceid1']])] = ['destid1' => $row['destid1']];
+      $this->saveMap($row);
+    }
+
+    $this->assertSame(iterator_to_array($this->getIdMap()), $expected_results);
+  }
+
+  /**
+   * Retrieves the contents of an ID map.
+   *
+   * @return array
+   *   The contents of an ID map.
+   */
+  private function getIdMapContents() {
+    $result = $this->database
+      ->select('migrate_map_sql_idmap_test', 't')
+      ->fields('t')
+      ->execute();
+
+    // The return value needs to be countable, or it will fail certain
+    // assertions. iterator_to_array() will not suffice because it won't
+    // respect the PDO fetch mode, if specified.
+    $contents = [];
+    foreach ($result as $row) {
+      $contents[] = (array) $row;
+    }
+    return $contents;
+  }
+
+}