Drupal’s Menu Link Migration Mess
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
(orNULL
,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:
-
The correct property name should be
do_not_skip_because_this_is_not_translated
. ↩