Problem/Motivation

The following code causes a deprecation in PHP 8.1.

$config_override->set(str_replace(Row::PROPERTY_SEPARATOR, '.', $row->getDestinationProperty('property')), $row->getDestinationProperty('translation'));

It's because $row->getDestinationProperty('property') returns NULL. So one fix would be to do (string) $row->getDestinationProperty('property') - which is what happens in HEAD on other versions of PHP. However calling $config_override->set('', 'some value') does not make any sense so this needs more investigation.

This had lead to the discovery that for many config translation migrations rollback will not work because translations is not set to TRUE. See CR https://www.drupal.org/node/3242221 for the necessary changes to migrations.

Steps to reproduce

  1. Migrate Block translations on Drupal 6
  2. Try to rollback

-> configuration translations will still exist.

Proposed resolution

Use the translations destination property to alway determine if the migration is for config translations rather than detecting based on keys in the source.

Remaining tasks

User interface changes

API changes

Data model changes

The destination translations property must be set to true to create (and rollback) configuration translations.

Release notes snippet

CommentFileSizeAuthor
#51 3239298-4-51.patch21 KBalexpott
#51 50-51-interdiff.txt1.26 KBalexpott
#50 3239298-4-50.patch20.97 KBalexpott
#50 48-50-interdiff.txt4.09 KBalexpott
#48 3239298-48.patch20.09 KBandypost
#48 interdiff-45-48.txt887 bytesandypost
#46 3239298-46.patch20.09 KBandypost
#46 interdiff.txt885 bytesandypost
#45 3239298-45.patch20.12 KBandypost
#45 interdiff.txt771 bytesandypost
#36 3239298-3-33.patch20.14 KBalexpott
#36 23-33-interdiff.txt12.98 KBalexpott
#23 3239298-23.patch9.82 KBquietone
#23 interdiff-22-23.txt597 bytesquietone
#22 3239298-22.patch9.79 KBandypost
#22 interdiff.txt641 bytesandypost
#18 3239298-18.patch9.17 KBquietone
#18 interdiff-15-18.txt1.75 KBquietone
#15 fix_drupal_migrate_p-3239298-15.patch7.42 KBquietone
#15 interdiff-12-15.txt1.46 KBquietone
#12 3239298-12.patch5.8 KBalexpott
#12 11-12-interdiff.txt2.63 KBalexpott
#11 3239298-10.patch3.17 KBalexpott
#11 7-10-interdiff.txt3.26 KBalexpott
#7 3239298-7.patch1.07 KBalexpott
#7 5-7-interdiff.txt1.07 KBalexpott
#5 3239298-5.patch1.79 KBalexpott
#5 3-5-interdiff.txt1.07 KBalexpott
#3 3239298-2.patch1.07 KBalexpott
#2 3239298-2.patch0 bytesalexpott
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

alexpott created an issue. See original summary.

alexpott’s picture

Let's investigate where this is happening.

alexpott’s picture

Status: Needs review » Needs work

The last submitted patch, 3: 3239298-2.patch, failed testing. View results

alexpott’s picture

Status: Needs work » Needs review
FileSize
1.07 KB
1.79 KB

Here's a potential fix. I'm not sure we should be create config overrides if we don't have a property or translation to set.

daffie’s picture

Component: base system » migration system
Status: Needs review » Needs work
+++ b/core/modules/migrate/src/Plugin/migrate/destination/EntityConfigBase.php
@@ -205,7 +205,11 @@ protected function updateEntity(EntityInterface $entity, Row $row) {
-      $config_override->set(str_replace(Row::PROPERTY_SEPARATOR, '.', $row->getDestinationProperty('property')), $row->getDestinationProperty('translation'));
+      $key = str_replace(Row::PROPERTY_SEPARATOR, '.', $row->getDestinationProperty('property'));
+      if (empty($key)) {
+        throw new \RuntimeException('Unexpected empty key');
+      }
+      $config_override->set($key, $row->getDestinationProperty('translation'));

Do we need this change with the adding of $row->hasDestinationProperty('property') && $row->hasDestinationProperty('translation') && to the if-statement? When we add this hunk, should we also add to the docblock the possibility that a exception can be thrown? Should we add testing for it?

Moving this to the migration subsystem.

alexpott’s picture

@daffie that exception is now unreachable - I left it in #5to prove the change fixes the issue. Here's a patch without the exception.

I have suspicions that this code is still not correct though. It's pulling random info from the destination and doing stuff with it. Can we be sure that translation and property always mean what we want them to mean? This code was introduced by #2225717: Add config translation support to migrations and implement for Drupal 6 user profile fields

daffie’s picture

  1. Can we add a comment about why $row->hasDestinationProperty('property') && $row->hasDestinationProperty('translation') && was added to the if-statement.
  2. It's pulling random info from the destination and doing stuff with it. Can we be sure that translation and property always mean what we want them to mean?

    Shall we create a followup so that the migration experts can fix that?

alexpott’s picture

We don;'t need a comment in the code about $row->hasDestinationProperty('property') && $row->hasDestinationProperty('translation') - we're adding them because we depend on them in the block below.

I think we should try to fix this in a better way here.

daffie’s picture

Status: Needs review » Needs work

I think we should try to fix this in a better way here.

Back to needs work then.

alexpott’s picture

Status: Needs work » Needs review
FileSize
3.26 KB
3.17 KB

Okay this is a bit of a mess. The following code in HEAD \Drupal\migrate\Plugin\migrate\destination\EntityConfigBase::updateEntity()

    // This is a translation if the language in the active config does not
    // match the language of this row.
    $translation = FALSE;
    if ($row->hasDestinationProperty('langcode') && $this->languageManager instanceof ConfigurableLanguageManager) {
      $config = $entity->getConfigDependencyName();
      $langcode = $this->configFactory->get('langcode');
      if ($langcode != $row->getDestinationProperty('langcode')) {
        $translation = TRUE;
      }
    }

IS working out if we are creating a translation... and if it thinks we are - ie. the config entity langcode does not match the config langcode it is going to create a config language override by doing:

    if ($translation) {
      $config_override = $this->languageManager->getLanguageConfigOverride($row->getDestinationProperty('langcode'), $config);
      $config_override->set(str_replace(Row::PROPERTY_SEPARATOR, '.', $row->getDestinationProperty('property')), $row->getDestinationProperty('translation'));
      $config_override->save();
    }

This relies on the destination having a 'property' and a 'translation' property.

HOWEVER... elsewhere in the code for example in the caller of EntityConfigBase::updateEntity() we do:

    $entity = $this->getEntity($row, $old_destination_id_values);
    // Translations are already saved in updateEntity by configuration override.
    if (!$this->isTranslationDestination()) {
      $entity->save();
    }

So we should be calling $this->isTranslationDestination() in \Drupal\migrate\Plugin\migrate\destination\EntityConfigBase::updateEntity(). But this is going to break things because not all migrations for config translations have this set properly. Compare core/modules/config_translation/migrations/d6_field_instance_label_description_translation.yml (has it) with core/modules/config_translation/migrations/d7_field_instance_label_description_translation.yml (does not).

This is a bug issue because if translations: true is not set correctly we'll be doing unnecessary entity saves and \Drupal\migrate\Plugin\migrate\destination\EntityConfigBase::rollback() will not work as expected.

The patch attached will show us how broken all of this is.

alexpott’s picture

Here are some more migrations that are not configured correctly and can't be rolled back.

The last submitted patch, 11: 3239298-10.patch, failed testing. View results

andypost’s picture

As this migrations supporting translations it makes sense to add this property but it needs ++ on behavior change (bug fix)

+++ b/core/modules/migrate/src/Plugin/migrate/destination/EntityConfigBase.php
@@ -190,12 +192,16 @@ public function getIds() {
-    if ($row->hasDestinationProperty('langcode') && $this->languageManager instanceof ConfigurableLanguageManager) {
+    if ($this->isTranslationDestination() && $row->hasDestinationProperty('langcode') && $this->languageManager instanceof ConfigurableLanguageManager) {

it needs subsystem maintainer's review

quietone’s picture

Sorry for forgetting to add translations: true to many of the config translation issues.

I don't recall a test of rolling back config translation so have added one to d7/MigrateMenuTranslationTest.php. The test fails because the migration does the label and the description in two separate source rows and thus there are two map rows for that language for a single menu. When looping through the id_map table the rollback will delete the entity on the first pass, not just remove the translated property. So, on the second pass the rollback can't find the entity to delete.

There is test of configuration rollback, \Drupal\Tests\migrate\Kernel\MigrateConfigRollbackTest, but that happens to test with two D7 variables which do not have more than one property than can be translated so the problem shown above with the Menu rollback does not occur.

No fix yet, just identify the problem.

Status: Needs review » Needs work

The last submitted patch, 15: fix_drupal_migrate_p-3239298-15.patch, failed testing. View results

andypost’s picture

Looks strange failure

        $map_row = $id_map->getRowByDestination($destination_key);
        if ($map_row['rollback_action'] == MigrateIdMapInterface::ROLLBACK_DELETE) {
There was 1 error:

1) Drupal\Tests\system\Kernel\Migrate\d7\MigrateMenuTranslationTest::testMenuTranslation
Undefined array key "rollback_action"

/var/www/html/core/modules/migrate/src/MigrateExecutable.php:316
/var/www/html/core/modules/system/tests/src/Kernel/Migrate/d7/MigrateMenuTranslationTest.php:69
/var/www/html/vendor/phpunit/phpunit/src/Framework/TestResult.php:722
quietone’s picture

Status: Needs work » Needs review
FileSize
1.75 KB
9.17 KB

Finally got back to this. getRowByDestination returns an array and since hat can be an empty array we should check the array before using it.

alexpott’s picture

I think we need to call $this->setRollbackAction($row->getIdMap(), $rollback_action); inside the if ($translation) { bit in \Drupal\migrate\Plugin\migrate\destination\EntityConfigBase::updateEntity()

alexpott’s picture

Hmmm that does not fix the problem. But if we don't do this then how can we be sure that the config translation is deleted on rollback...

andypost’s picture

Thank you @quietone! I think it just needs code-comment to explain why map row is empty

+++ b/core/modules/migrate/src/MigrateExecutable.php
@@ -313,15 +313,17 @@ public function rollback() {
         $map_row = $id_map->getRowByDestination($destination_key);
-        if ($map_row['rollback_action'] == MigrateIdMapInterface::ROLLBACK_DELETE) {
...
+        if ($map_row) {
+          if ($map_row['rollback_action'] == MigrateIdMapInterface::ROLLBACK_DELETE) {

It smells behavior change but it looks right

andypost’s picture

I think improving interface docs \Drupal\migrate\Plugin\MigrateIdMapInterface::getRowByDestination() could prevent checking both implementations

quietone’s picture

Yes, both of the two implementations of getRowByDestination() return an empty array so any usage should be able to handle an empty array. And the only usage in core is in \Drupal\migrate\MigrateExecutable::rollback().

I've tried to make the comment, added in #22, easier to read in this patch.

alexpott’s picture

This is looking pretty good now I think. We have way better test coverage we've fixed a bug in rollbacks and tested it. Nice work everyone!

+++ b/core/modules/config_translation/migrations/d6_taxonomy_vocabulary_translation.yml
@@ -25,6 +25,7 @@ process:
 destination:
   plugin: entity:taxonomy_vocabulary
   destination_module: config_translation
+  translations: true
 migration_dependencies:

Do we need a CR for this? It's kinda true atm (rollback does not work on config translation migrations without this) but may have been missed if people copied and pasted from many of the core examples.

quietone’s picture

I am not convinced a CR is needed but more information can't hurt so I made one.

alexpott’s picture

@quietone thanks for the CR.

I'm not sure about

Before this change the property was not set and rolling back the migrations caused an Undefined array key warning.

I think that before this change \Drupal\migrate\Plugin\migrate\destination\EntityConfigBase::rollback() would not rollback config translations if translations: true was not set because \Drupal\migrate\Plugin\migrate\destination\EntityConfigBase::isTranslationDestination() would return FALSE if the migration was missing it.

quietone’s picture

@alexpott, thanks. You are correct and I have modified the CR.

andypost’s picture

alexpott’s picture

@quietone thanks for confirming. I've tweaked the CR to contain the important information for anyone who has custom configuration translation migrations and to be as clears as possible what needs to be done - while not getting into the weeds. If people want to know the details as to why they can read the discussions on this issue.

I think this issue is ready now :)

andypost’s picture

I curious about how custom migrations could identify places where to put new line?

andypost’s picture

Status: Needs review » Reviewed & tested by the community

I got it - when we use translatable (i18n) to config mapping

+++ b/core/modules/migrate/src/Plugin/migrate/destination/EntityConfigBase.php
@@ -190,12 +192,16 @@ public function getIds() {
+   * @throws \LogicException
+   *   Thrown if the destination is for translations and either the "property"
+   *   or "translation" property do not exist.

Missed this hunk, sorry

quietone’s picture

There is an issue that has a more comprehensive fix for the undefined array key problem, #3227660: MigrateExecutable::rollback incorrectly assumes MigrateMapInterface::getRowByDestination() returns an array with 'rollback_action' key. In that issue array_key_exists is used to specifically test for 'rollback_action' and there is a new Unit test. It was RTBC and now at NR because I changed a few lines to use defined constants.

Is it within scope to merge those changes in this issue?

alexpott credited huzooka.

alexpott credited larowlan.

alexpott’s picture

After discussing this with @catch I've merged the tests from #3227660: MigrateExecutable::rollback incorrectly assumes MigrateMapInterface::getRowByDestination() returns an array with 'rollback_action' key and slightly improve on both issues logic of checking the result of ... going for:

        $map_row = $id_map->getRowByDestination($destination_key);
        if (isset($map_row['rollback_action']) && $map_row['rollback_action'] == MigrateIdMapInterface::ROLLBACK_DELETE) {

isset($map_row) will not error for $map_row = FALSE.

Crediting the people who worked on that issue and will mark that one as a duplicate.

huzooka’s picture

FYI the error/warning can be triggered with PHP 7.4 as well.

But since an underlying sql map interface implementation was fixed weeks ago (and commited into 9.3.x), imho it is enough to check for the array key.

Based on the interface, the migrate sql id map implemetnation should return an array.

huzooka’s picture

alexpott’s picture

@huzooka the code

if (isset($map_row['rollback_action']) && $map_row['rollback_action'] == MigrateIdMapInterface::ROLLBACK_DELETE) {

checks for an array key - it also happens to be a bit more robust if $map_row happens to be false. There's no need to use array_key_exists() here because if $map_row['rollback_action'] is equal to NULL then.... oh well... const ROLLBACK_DELETE = 0; and $map_row['rollback_action'] == MigrateIdMapInterface::ROLLBACK_DELETE. That sucks... $map_row['rollback_action'] = NULL will return TRUE atm. We need to stop using == everywhere. See https://3v4l.org/628ck

Personally I think we should not care about $map_row['rollback_action'] = NULL. The looseness in this code atm is a bug creator.

huzooka’s picture

@alexpott, the sql id map plugin interface declares that getRowByDestination() must return an array.

If it returns something else, then the plugin violates its interface:

https://git.drupalcode.org/project/drupal/-/blob/9.3.x/core/modules/migr...

huzooka’s picture

It feels easier and more straightforward solving these separate bugs in their own task...

alexpott’s picture

Title: Fix \Drupal\migrate\Plugin\migrate\destination\EntityConfigBase::updateEntity() to not trigger deprecations in PHP 8.1 » Fix \Drupal\migrate\Plugin\migrate\destination\EntityConfigBase::updateEntity() so that config translation migrations can be rolled back
Issue summary: View changes

@huzooka it's not forced by the interface only by documentation - we have far too many places in core where documentation != reality. If it was public function getRowByDestination(array $destination_id_values): array; then sure but something may have extended \Drupal\migrate\Plugin\migrate\id_map\Sql in custom and is still returning FALSE, NULL or whatever.

These bugs are intimately linked. We can't fix config translation rollback without fixing the bug in \Drupal\migrate\MigrateExecutable::rollback() as well. And I think not being able to rollback a migration is a way more significant bug than a PHP notice.

Updating the issue summary so we're clear about what is being fixed here.

catch’s picture

There's no need to use array_key_exists() here because if $map_row['rollback_action'] is equal to NULL then.... oh well... const ROLLBACK_DELETE = 0; and $map_row['rollback_action'] == MigrateIdMapInterface::ROLLBACK_DELETE. That sucks... $map_row['rollback_action'] = NULL will return TRUE atm.

I think we can just ignore the behaviour change for $map_row['rollback_action'] === NULL since it's not really supported, just accidental and unlikely to be relied upon. We could have a follow-up to add an assert for a valid value, but prefer the simpler code, explicitly accounting for NULL with array_key_exists() makes it look like a meaningful option when it isn't.

+++ b/core/modules/migrate/src/Plugin/migrate/destination/EntityConfigBase.php
@@ -190,12 +192,16 @@ public function getIds() {
+   *
+   * @throws \LogicException
+   *   Thrown if the destination is for translations and either the "property"
+   *   or "translation" property do not exist.
    */

Extreme nit: I think this should be 'does not' instead of 'do not', because it's singular.

andypost’s picture

andypost’s picture

Fix #43-nit

I see no reason in array_key_exists() and having no rollback is totally valid case

andypost’s picture

Status: Needs review » Needs work

The last submitted patch, 46: 3239298-46.patch, failed testing. View results

andypost’s picture

longwave’s picture

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

This is confusing to read, and then I learned from earlier in the thread that we are actually testing if (($var ?? NULL) == 0) here, which results in TRUE if the var is not set - would prefer this to be explicit, or explained with a comment if we are keeping it like this.

alexpott’s picture

@longwave I agree. Let's make it way more explicit and actually test this behaviour properly.

alexpott’s picture

alexpott’s picture

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

This is the new version of the contentious if. It will not emit an error if $map_row is an array without a rollback_action or $map_row is FALSE... and it behaves the same way - i.e. if if can't determine a rollback_action it assumes the ->rollback() should be called.

We could add a comment like:

        // The default behaviour if $map_row['rollback_action'] is not set or
        // NULL is to assume that $destination->rollback() should be called.

But given this is now !isset($map_row['rollback_action']) || I think it is explicit and obvious enough. Plus we have tests.

alexpott’s picture

Here's evidence that the new formulation works the same way as the old - https://3v4l.org/hGIMP - with we less PHP notices... see https://3v4l.org/Lf6NF for only the new version.

If $map_row is FALSE we will get a deprecation on PHP 8.1 because as pointed out a few times that's against the interface documentation so I think that is okay. It won't be broken and if you have custom code that triggers this you should fix it.

longwave’s picture

Status: Needs review » Reviewed & tested by the community

+1 for the additional testing, and happy with the fix to the if - as we are now explicit about the !isset I don't think we need an additional comment.

The rest of the changes look fine, so RTBC assuming bot agrees.

  • catch committed c1933e7 on 9.3.x
    Issue #3239298 by alexpott, andypost, quietone, huzooka, daffie,...
catch’s picture

Status: Reviewed & tested by the community » Fixed

Committed c1933e7 and pushed to 9.3.x. Thanks!

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.