We recently got a bug report from a customer: their menu links weren’t migrated from their Drupal 7 site to Drupal 9. They had some links which were, but unfortunately the majority of their links were gone.

After some digging we saw that the client had a very complex menu tree, including more menu levels, with various types of links.

Why did this happen to them?

In core’s d7_menu_link migration we cannot create stub menu_link_content entities. But this is essential if the menu link we migrate has a parent menu link.

Let’s assume that this menu link structure is our source:

# An example menu tree

Main menu
─────────
├─<LINK#2>(blog node/1)
│ │
│ └─<LINK#1>(blog node/2)
│
├─<LINK#3>(blog node/3)
│ │
│ └─<LINK#4>(article node/4)
│
└─<LINK#5>(node/add or a views page link)
  │
  ├─<LINK#6>(user/6)
  │
  └─<LINK#7>(blog node/7)

Problem #1

If the menu link we want to migrate isn’t a custom or customized menu link, then the menu link cannot be migrated. This is because core only migrates custom or customized menu links (custom menu link condition here, customized condition here.

Problem #2

By default, the URI’s of the migrated menu links are validated. This means that if we try to stub a menu link’s parent menu link (e.g. a node, or views page) that’s target isn’t available, then the migration will be skipped.

Example: We cannot migrate <LINK#6> or <LINK#7>, because their parent menu link’s target won’t be available ever (there is no source row for <LINK#5>).

Problem #3

A menu link source in d7_menu_links might be skipped for a valid reason, for example because it is migrated in d7_menu_links_localized, or by node_translation_menu_links.

Problem #4

d7_menu_links got a new source property 'skip_translation' (Achtung! Misleading var name!)1 recently. This source property is calculated and added in the MenuLink source plugin’s prepareRow method. And then, if a particular menu link’s skip_translation property is equal to a boolean FALSE, then the menu link’s migration will be skipped.

I have to repeat this again, making super-clean what happens:

  • skip_translation == TRUE → We will try to migrate the menu link.
  • skip_translation == FALSE (or NULL, 0, '0', '' etc) → we don’t migrate

Since core’s underlying stub service doesn’t invoke the migrate source plugin’s prepareRow method, this means that whenever a menu link has a parent menu link which isn’t yet migrated, then the current migration code cannot create a stub entity for the not-yet-migrated parent menu link, because 'skip_translation' will be NULL (MigrateExecutable checks source properties by calling $row->get(<property>) which returns null for missing properties.

Tasks to solve

Idea addressing #1 and #2

We should try disabling route validation, and then:

  • The MenuLink source plugin should include every source item what is used as the parent ID of customized menu links

    or

  • We need to make it possible to create stubs for missing source records (I don’t know if Migrate API provides any infra for this)

Idea addressing #3

MenuLinkParent process plugin should collect every migration that’s destination is a menu_link_content entity (but not an entity translation), filter out migrations whose “Drupal #” tag does not match with the current migration’s tag, and then try to create a stub in every matching migration (derivative).

Idea addressing #4

Since Migrate Magician’s MigMagMigrateStub service invokes MigrateSourceInterface::prepareRow (so it returns a fully prepared source Row), using that service in MenuLinkParent instead of the original one would solve this issue.

The Solution

We completely rewrote the process pipeline of the parent destination property in every related menu link migration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
 id: d7_menu_links
 [...]
 process:
 [...]
   weight: weight
   expanded: expanded
   enabled: enabled
+  parent_uuid:
+    -
+      plugin: skip_on_empty
+      source: plid
+      method: process
+    -
+      plugin: migmag_lookup
+      source: plid
+      default_values:
+        menu_name: '@menu_name'
+      migration:
+        - 'd7_menu_links'
+        - 'd7_menu_links_localized'
+        - 'd7_menu_links_translation'
+        - 'node_translation_menu_links'
+      fallback_stub_id: missing_menu_link_trap
+    -
+      plugin: migmag_get_entity_property
+      entity_type_id: 'menu_link_content'
+      property: uuid
  parent:
-    plugin: menu_link_parent
-    source:
-      - plid
-      - '@menu_name'
-      - parent_link_path
+    -
+      plugin: default_value
+      source: '@parent_uuid'
+      default_value: null
+    -
+      plugin: skip_on_empty
+      method: process
+    -
+      plugin: concat
+      source:
+        - 'constants/plugin_prefix'
+        - '@parent_uuid'
+      delimiter: ':'
   changed: updated
 destination:
   plugin: entity:menu_link_content
   no_stub: true

You can see that we dropped out the menu_link_parent process plugin. To get the parent menu link content entity’s ID we use migmag_lookup instead. And we also wrote another plugin which loads the given entity and returns one of its properties.

You may notice that a weirdly named migration plugin ID is used as a fallback migration for creating stubs: missing_menu_link_trap. This migration is used for creating stubs for missing parent menu links:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
id: missing_menu_link_trap
source:
  plugin: embedded_data
  data_rows: []
  ids:
    mlid:
      type: string
  source_module: menu
  constants:
    bundle: menu_link_content
    langcode: und
    title: '(missing menu link stubbed by AMA)'
    url: 'route:<none>'
    options: {}
    enabled: true
    menu_name_placeholder: ''
process:
  # Trap should ignore mlid = 0.
  id:
    plugin: skip_on_empty
    source: mlid
    method: row
  langcode: 'constants/langcode'
  bundle: 'constants/bundle'
  title: 
    plugin: concat
    source:
      - mlid
      - 'constants/title'
    delimiter: ' '
  # Menu name will be set by migmag_lookup.
  menu_name: 'constants/menu_name_placeholder'
  'link/uri': 'constants/url'
  'link/options': 'constants/options'
  enabled: 'constants/enabled'
destination:
  plugin: entity:menu_link_content

And basically, with these changes, our customer was able to proceed, and migrate their menu links, and their complex link structure to Drupal 9, and we haven’t seen any failed rows in their map tables anymore!


Footnotes:

  1. The correct property name should be do_not_skip_because_this_is_not_translated