Problem/Motivation

\Drupal\migrate\MigrateExecutable::rollback() incorrectly assumes that Drupal\migrate\PluginMigrateMapInterface::getRowByDestination() returns an array with rollback_action key.
Nothing guarantees that the returned value has the key rollback_action (and right now the Sql plugin returns FALSE, violating MigrateMapInterface, see #3227549: Sql id map plugin's getRowByDestination shouldn't return FALSE).

This is not an issue on PHP 7.3 and below, but the most recent PHP versions are throwing notice if you try to access an offset of a non-array variable.

Proposed resolution

Check whether the rollback_action key exists before comparing its value.

Remaining tasks

Comments

huzooka created an issue. See original summary.

huzooka’s picture

Let's see.

I wasn't able to find a rollback test, so I added a new test to \Drupal\Tests\migrate\Unit\MigrateExecutableTest and slightly modified \Drupal\Tests\migrate\Unit\MigrateTestCase::getMigration().

huzooka’s picture

Assigned: huzooka » Unassigned
Status: Active » Needs review
huzooka’s picture

Assigned: Unassigned » huzooka
Status: Needs review » Needs work

SubProcessTest failed.

huzooka’s picture

Assigned: huzooka » Unassigned
Status: Needs work » Needs review
StatusFileSize
new10.98 KB
new11.86 KB

SubProcessTest was misusing \Drupal\Tests\migrate\Unit\MigrateTestCase::getMigration().

huzooka’s picture

StatusFileSize
new816 bytes

Forgot the inderdiff. Here it is!

huzooka’s picture

Issue summary: View changes
wim leers’s picture

Status: Needs review » Needs work
+++ b/core/modules/migrate/src/MigrateExecutable.php
@@ -313,7 +313,7 @@ public function rollback() {
         $map_row = $id_map->getRowByDestination($destination_key);
-        if ($map_row['rollback_action'] == MigrateIdMapInterface::ROLLBACK_DELETE) {
+        if ($map_row && array_key_exists('rollback_action', $map_row) && $map_row['rollback_action'] == MigrateIdMapInterface::ROLLBACK_DELETE) {

Per \Drupal\migrate\Plugin\MigrateIdMapInterface::getRowByDestination()'s docs, $map_row must indeed ben an array:

   * @return array
   *   The row(s) of data.
   */
  public function getRowByDestination(array $destination_id_values);

And indeed, until #3227549: Sql id map plugin's getRowByDestination shouldn't return FALSE lands, it actually could be FALSE instead of an array.

👍 But even after that lands, it's indeed unguaranteed that a rollback_action key actually exists.


  1. +++ b/core/modules/migrate/tests/src/Unit/MigrateExecutableTest.php
    @@ -498,4 +515,176 @@ protected function getMockSource() {
    +      'Rolling back with ID map having records with duplicated destination ID' => [
    +        'ID map records' => [
    +          [
    +            'source_1' => '1',
    +            'source_2' => '1',
    +            'destination' => '1',
    +            'rollback_action' => '0',
    +          ],
    +          [
    +            'source_1' => '2',
    +            'source_2' => '2',
    +            'destination' => '2',
    +            'rollback_action' => '1',
    +          ],
    +          [
    +            'source_1' => '3',
    +            'source_2' => '3',
    +            'destination' => '1',
    +            'rollback_action' => '0',
    +          ],
    +        ],
    

    This is the one that's failing, the second time we call

    $id_map->getRowByDestination($destination_key)
    

    with
    $destination_key = ['destination' => '1'];

    … but 100% of these ID map record have rollback_action specified. So … not sure I 100% understand what's going on here yet …

  2. +++ b/core/modules/migrate/tests/src/Unit/MigrateExecutableTest.php
    @@ -498,4 +515,176 @@ protected function getMockSource() {
    +    $id_map->getRowByDestination(Argument::type('array'))->will(function () {
    +      $destination_ids = func_get_args()[0][0];
    +      $return = array_reduce(self::$idMapRecords, function (array $carry, array $record) use ($destination_ids) {
    +        if (array_merge($record, $destination_ids) === $record) {
    +          $carry = $record;
    +        }
    +        return $carry;
    +      }, []);
    +
    +      return $return;
    +    });
    

    Aha! So this is mocking the behavior of \Drupal\migrate\Plugin\MigrateIdMapInterface::getRowByDestination() and really of \Drupal\migrate\Plugin\migrate\id_map\Sql::getRowByDestination().

    I had to step through this with a debugger to understand.

    What this is doing is reducing the original records in the data provider to just the ones that matched. Which means that a repeat call for the same destination will yield a different result: it'll have been reduced away…

    🤔 This does not seem to really match the real-world behavior?


So … +1 for the hardening, but I still have doubts about the test coverage. @huzooka, could you clarify? 😊🙏

wim leers’s picture

Status: Needs work » Reviewed & tested by the community

What this is doing is reducing the original records in the data provider to just the ones that matched. Which means that a repeat call for the same destination will yield a different result: it'll have been reduced away…

🤔 This does not seem to really match the real-world behavior?

@huzooka just walked me through this!~ Even though this seems weird and wrong, it actually is accurately mocking the behavior of \Drupal\migrate\MigrateExecutable::rollback(), where it does:

        $map_row = $id_map->getRowByDestination($destination_key);
        if ($map_row['rollback_action'] == MigrateIdMapInterface::ROLLBACK_DELETE) {
          $this->getEventDispatcher()
            ->dispatch(new MigrateRowDeleteEvent($this->migration, $destination_key), MigrateEvents::PRE_ROW_DELETE);
          $destination->rollback($destination_key);
          $this->getEventDispatcher()
            ->dispatch(new MigrateRowDeleteEvent($this->migration, $destination_key), MigrateEvents::POST_ROW_DELETE);
        }
        // We're now done with this row, so remove it from the map.
        $id_map->deleteDestination($destination_key);

That last line is deleting every row from the mapping table that contains the given destination key (which btw is an array of destination IDs, not a single key — so the name is pretty misleading), which is equivalent to the mocked behavior that I quoted in my previous comment! 👍👍👍

So … while this is perhaps a bit complicated/weird mock, it really is due to the weirdness in:

  1. \Drupal\migrate\Plugin\migrate\id_map\Sql::getRowByDestination() which is not aware of the fact that multiple record can have the same destination ID — which particularly in d7_field_formatter_settings is not a correct assumption
  2. \Drupal\migrate\Plugin\migrate\id_map\Sql::deleteDestination()
  3. \Drupal\migrate\MigrateExecutable::rollback()

and the way these three interact.

larowlan’s picture

Looks good, just a micro-optimisation suggestion

+++ b/core/modules/migrate/src/MigrateExecutable.php
@@ -313,7 +313,7 @@ public function rollback() {
-        if ($map_row['rollback_action'] == MigrateIdMapInterface::ROLLBACK_DELETE) {
+        if ($map_row && array_key_exists('rollback_action', $map_row) && $map_row['rollback_action'] == MigrateIdMapInterface::ROLLBACK_DELETE) {

Now that we have null coalesce, we could write this as

if (($map_row['rollback_action'] ?? NULL) === MigrateIdMapInterface::ROLLBACK_DELETE)

See https://3v4l.org/C6dJp

quietone’s picture

#10.3, Can anyone provide an example where the map for d7_field_formatter_settings can can multiple records with the same destination IDs?

huzooka’s picture

Re #11:

At the time I wrote that, #3227549: Sql id map plugin's getRowByDestination shouldn't return FALSE wasn't committed. Now I prefer keeping array_key_exists only.

Re #12:

I made a rollbackable destination plugin which is able to restore the original state of the appropriate entity display after rolling back the migration. This source plugin identifies the destination entity display component based on the target entity type, bundle, display mode, and the name of the field.

But in d7_field_formatter_settings, formatter settings source records of a field are overriding each other if one of the settings we migrate comes from the default, and the other one comes from the full view mode. In this case, my destination IDs are the same (this is obvious since the target is the same thing). And since on rollback the removal of the ID map records happens based on the saved destination IDs, on a subsequent loop it can happen that the ID map plugin returns an empty array (or FALSE with older core versions).

But the root cause is at #3227391: d7_field_formatter_settings should migrate the default view mode to default not full.

quietone’s picture

Status: Reviewed & tested by the community » Needs review
StatusFileSize
new1.8 KB
new12.07 KB

I was re reading this change and think that using the rollback constants defined in MigrateIdMapInterface make the test a bit more readable, even though the comments are excellent. So I made that change.

quietone’s picture

Status: Needs review » Closed (duplicate)
Issue tags: -Contributed project blocker

This was to be marked a duplicate in [#3239298#comment-36]. Doing that now.