You are here

class MigrateSqlIdMapTest in Drupal 10

Same name and namespace in other branches
  1. 8 core/modules/migrate/tests/src/Unit/MigrateSqlIdMapTest.php \Drupal\Tests\migrate\Unit\MigrateSqlIdMapTest
  2. 9 core/modules/migrate/tests/src/Unit/MigrateSqlIdMapTest.php \Drupal\Tests\migrate\Unit\MigrateSqlIdMapTest

Tests the SQL ID map plugin.

@group migrate

Hierarchy

Expanded class hierarchy of MigrateSqlIdMapTest

File

core/modules/migrate/tests/src/Unit/MigrateSqlIdMapTest.php, line 16

Namespace

Drupal\Tests\migrate\Unit
View source
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() : void {
    $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
      ->createMock('Drupal\\migrate\\Plugin\\MigrateSourceInterface');
    $plugin
      ->method('getIds')
      ->willReturn($this->sourceIds);
    $migration
      ->method('getSourcePlugin')
      ->willReturn($plugin);
    $plugin = $this
      ->createMock('Drupal\\migrate\\Plugin\\MigrateDestinationInterface');
    $plugin
      ->method('getIds')
      ->willReturn($this->destinationIds);
    $migration
      ->method('getDestinationPlugin')
      ->willReturn($plugin);
    $event_dispatcher = $this
      ->createMock('Symfony\\Contracts\\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
      ->createMock('Drupal\\migrate\\MigrateMessageInterface');
    $id_map = $this
      ->getIdMap();
    $id_map
      ->setMessage($message);
    $this
      ->assertEquals($message, $id_map->message);
  }

  /**
   * 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
      ->assertSame($id_map
      ->messageCount(), 4);
    $id_map
      ->clearMessages();
    $count = $id_map
      ->messageCount();
    $this
      ->assertSame($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
        ->assertSame($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
      ->getMessages() 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
      ->getMessages([
      '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(1, $count);

    // Retrieve messages with a specific level.
    $messages = $id_map
      ->getMessages([], MigrationInterface::MESSAGE_WARNING);
    $count = 0;
    foreach ($messages as $key => $message_row) {
      $count = 1;
      $this
        ->assertEquals(MigrationInterface::MESSAGE_WARNING, $message_row->level);
    }
    $this
      ->assertEquals(1, $count);
  }

  /**
   * 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_ids = $id_map
      ->lookupDestinationIds($source_id_values);
    $this
      ->assertSame([
      $expected_result,
    ], $destination_ids);

    // Test for a miss.
    $destination_ids = $id_map
      ->lookupDestinationIds($nonexistent_id_values);
    $this
      ->assertCount(0, $destination_ids);
  }

  /**
   * 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,
    ]));
    $this
      ->assertEquals([
      [
        101,
        'en',
      ],
      [
        101,
        'fr',
      ],
      [
        101,
        'de',
      ],
    ], $id_map
      ->lookupDestinationIds([
      'nid' => 1,
      'language' => NULL,
    ]));
    $this
      ->assertEquals([
      [
        102,
        'en',
      ],
    ], $id_map
      ->lookupDestinationIds([
      'nid' => 2,
      'language' => NULL,
    ]));

    // 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 for map migrate_map_sql_idmap_test in source IDs: array (\n  0 => 3,\n)", $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 for map migrate_map_sql_idmap_test in source IDs: array (\n  'aaa' => '2',\n)", $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, getRowByDestination should return an (empty)
    // array.
    // @see \Drupal\migrate\Plugin\MigrateIdMapInterface::getRowByDestination()
    $missing_result_row = $id_map
      ->getRowByDestination([
      'destination_id_property' => 'invalid_destination_id_property',
    ]);
    $this
      ->assertEquals([], $missing_result_row);

    // The destination ID values array does not contain all the destination ID
    // keys, we expect an empty array.
    $invalid_result_row = $id_map
      ->getRowByDestination([
      'invalid_destination_key' => 'invalid_destination_id_property',
    ]);
    $this
      ->assertEquals([], $invalid_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
      ->assertCount(0, $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, $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, $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, $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, $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, $id_map
          ->processedCount());
      }
    }

    // Assert multiple rows have been processed.
    $this
      ->assertSame(count($row_statuses), $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, $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, $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\sqlite\Driver\Database\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;
  }

  /**
   * Tests the delayed creation of the "map" and "message" migrate tables.
   */
  public function testMapTableCreation() {
    $id_map = $this
      ->getIdMap();
    $map_table_name = $id_map
      ->mapTableName();
    $message_table_name = $id_map
      ->messageTableName();

    // Check that tables names do exist.
    $this
      ->assertEquals('migrate_map_sql_idmap_test', $map_table_name);
    $this
      ->assertEquals('migrate_message_sql_idmap_test', $message_table_name);

    // Check that tables don't exist.
    $this
      ->assertFalse($this->database
      ->schema()
      ->tableExists($map_table_name));
    $this
      ->assertFalse($this->database
      ->schema()
      ->tableExists($message_table_name));
    $id_map
      ->getDatabase();

    // Check that tables do exist.
    $this
      ->assertTrue($this->database
      ->schema()
      ->tableExists($map_table_name));
    $this
      ->assertTrue($this->database
      ->schema()
      ->tableExists($message_table_name));
  }

  /**
   * Tests getHighestId method.
   *
   * @param array $destination_ids
   *   Array of destination ids.
   * @param array $rows
   *   Array of map table rows.
   * @param int $expected
   *   Expected highest id value.
   *
   * @dataProvider getHighestIdDataProvider
   */
  public function testGetHighestId(array $destination_ids, array $rows, $expected) {
    $this->database = $this
      ->getDatabase([]);
    $this->sourceIds = $destination_ids;
    $this->destinationIds = $destination_ids;
    $db_keys = [];
    $dest_id_count = count($destination_ids);
    for ($i = 1; $i <= $dest_id_count; $i++) {
      $db_keys[$i] = "sourceid{$i}";
    }
    for ($i = 1; $i <= $dest_id_count; $i++) {
      $db_keys[] = "destid{$i}";
    }
    $id_map = $this
      ->getIdMap();
    foreach ($rows as $row) {
      $values = array_combine($db_keys, $row);
      $source_values = array_slice($row, 0, $dest_id_count);
      $values['source_ids_hash'] = $id_map
        ->getSourceIdsHash($source_values);
      $this
        ->saveMap($values);
    }
    $actual = $id_map
      ->getHighestId();
    $this
      ->assertSame($expected, $actual);
  }

  /**
   * Data provider for getHighestId().
   *
   * Scenarios to test:
   * - Destination ID type integer.
   * - Destination ID types integer and string.
   *
   * @return array
   *   An array of data values.
   */
  public function getHighestIdDataProvider() {
    return [
      'Destination ID type integer' => [
        'dest_ids' => [
          'nid' => [
            'type' => 'integer',
          ],
        ],
        'rows' => [
          [
            1,
            2,
          ],
          [
            2,
            1,
          ],
          [
            4,
            3,
          ],
          [
            9,
            5,
          ],
        ],
        'expected' => 5,
      ],
      'Destination ID types integer and string' => [
        'dest_ids' => [
          'nid' => [
            'type' => 'integer',
          ],
          'vid' => [
            'type' => 'integer',
          ],
          'language' => [
            'type' => 'string',
          ],
        ],
        'rows' => [
          [
            1,
            1,
            'en',
            1,
            6,
            'en',
          ],
          [
            1,
            4,
            'fr',
            1,
            6,
            'fr',
          ],
          [
            1,
            6,
            'de',
            1,
            6,
            'de',
          ],
          [
            2,
            8,
            'en',
            2,
            8,
            'en',
          ],
        ],
        'expected' => 2,
      ],
    ];
  }

  /**
   * Tests getHighestId method with invalid data.
   *
   * @param array $destination_ids
   *   Array of destination ids.
   *
   * @dataProvider getHighestIdInvalidDataProvider
   */
  public function testGetHighestIdInvalid(array $destination_ids) {
    $this
      ->expectException(\LogicException::class);
    $this
      ->expectExceptionMessage('To determine the highest migrated ID the first ID must be an integer');
    $this->destinationIds = $destination_ids;
    $id_map = $this
      ->getIdMap();
    $id_map
      ->getHighestId();
  }

  /**
   * Data provider for testGetHighestIdInvalid().
   *
   * Scenarios to test:
   * - Destination ID type string.
   * - Destination ID types int (not integer) and string.
   *
   * @return array
   *   An array of data values.
   */
  public function getHighestIdInvalidDataProvider() {
    return [
      'Destination ID type string' => [
        'ids' => [
          'language' => [
            'type' => 'string',
          ],
        ],
      ],
      'Destination ID types int (not integer) and string' => [
        'ids' => [
          'nid' => [
            'type' => 'int',
          ],
          'language' => [
            'type' => 'string',
          ],
        ],
      ],
    ];
  }

}

Members