We ran into this problem: An editor edits a menu to change out which stories on the site are being linked to from the menu in question. The links are edited in place as opposed to deleting and re-adding a whole new menu item. This causes duplicate menu_node rows to be created, because menu_node_exists() is checking for the combo of nid/mlid, and is assuming that, if it doesn't exist, a new row needs to be inserted. Later down the road, we're using menu_node_views to filter based on the menu name, and the view is returning more entries than it should, because there are extra rows in the table.

I think this is actually a semi-fundamental flaw in how the menu_node module works. menu_node should really be considered an extension of the menu_links table. It has a much more intimate relationship with menu_links than node. In fact, it could theoretically be re-implemented with a schema_alter on the menu_links table to just add the nid column, but I don't necessarily think that's the correct course of action. In any case, when thinking this way, it makes sense to have the mlid be the sole field for the primary key. This allows us to check to see if a row with a certain mlid already exists, and, if so, update it (with drupal_write_record) instead of adding a new row (and thereby creating duplicate-mlid rows). It is obvious that a node can be referenced by multiple menu items, so multiple nids in the table is correct, but there can't be a case that I can see where multiple mlids should exist, so reworking this piece allows us to make sure that's the case and allow for row updates where it makes sense.

Attached is the patch that I worked up for us that makes these changes.

Comments

agentrickard’s picture

I believe it is theoretically possible for one node to have two menu entries, in two separate menus. Which is why this was written the way it is.

What does "edited in place" mean in this context?

agentrickard’s picture

I checked. It is possible. So the debate is over the design of Drupal's menu system.

agentrickard’s picture

Status: Needs review » Needs work

Essentially, then, this is By Design, due to the behavior of Drupal core.

An acceptable patch would be a submodule or settings that _prevents_ nodes from being added to more than one menu, and enforces the single nid rule without altering the database schema.

Otherwise, we'll have people complaining about the reverse problem.

agentrickard’s picture

For your Views, you should add a menu filter to weed out the duplicates.

agentrickard’s picture

Category: bug » feature

Not a bug.

mgriego’s picture

Category: feature » bug
Status: Needs work » Needs review

This *is* actually a bug. I think you're misunderstanding what I'm saying. Of course a node can be referenced by multiple menu items. BUT, multiple menu items with the same mlid can *never* exist. If you have multiple rows in menu_node with the same *mlid* (not nid), then there is a problem. This is the duplicate problem I was referring to.

Try this. This is the "edit in place" I was referring to. Create a custom menu, and add a link to a node on the site. Now go back and edit the same menu item. Only change the link to be a different node. What *should* happen is that the menu_node row should either be deleted before a new one is inserted or (as my patch does) updates the row in place to reference the new node id. Instead, what happens, is that the existing row is left behind, basically an orphan, and a new row is inserted to join the node and menu_links together. The fact that the old row is left behind is not a problem of removing duplicate rows in Views (which I'm very aware of how to do), it's a problem of stale and incorrect data.

This gets back to my fundamental point that there really should never be an instance where there are duplicate mlid's in the menu_node table. It should never happen. *That's* how the Drupal Menu system is designed.

agentrickard’s picture

That makes sense. It's been a while since I touched this code. Thanks.

This is the part I glossed over:

Instead, what happens, is that the existing row is left behind, basically an orphan, and a new row is inserted to join the node and menu_links together.

I read the key part of the patch backwards.

agentrickard’s picture

Wondering where my head was at when I coded this originally.

Two questions:

1) Do we need an update hook to remove the deadwood?
2) How can we write that efficiently?

mgriego’s picture

Hah, I've often looked back at old code and wondered what I was thinking at the time...

In any case, you're probably right. Knowing the condition things can be in, it would probably be best to code something into the update hook before the indexes are recreated. In our environment, I caught it early enough that I was just going to remove the deadwood manually before running this update hook in production. In the wild, though, it's a whole different ball game. Though, I could see it working something like this:

/**
 * Update to the new schema with only mlid as primary key.
 */
function menu_node_update_6001() {
  $ret = array();
  // First remove any deadwood that may have accumulated
  $result = db_query('SELECT mlid, count(mlid) AS rowcount FROM {menu_node} GROUP BY mlid HAVING rowcount > 1');
  while($mlid = db_fetch_object($result)) {
    $link_path = db_result(db_query("SELECT link_path FROM {menu_links} WHERE mlid = %d AND link_path LIKE 'node/%' AND router_path = 'node/%%'", $mlid->mlid));
    if (empty($link_path)) {
      /*
       * Either menu_link row no longer eists or doesn't point to a node.
       * Delete all rows with that mlid
       */
       $ret[] = update_sql('DELETE FROM {menu_node} WHERE mlid = '.$mlid->mlid);
    } else {
      // Delete any rows not referencing the returned nid
      $nid = str_replace('node/', '', $link_path);
      if (is_numeric($nid)) {
        $ret[] = update_sql('DELETE FROM {menu_node} WHERE mlid = '.$mlid->mlid.' AND nid <> '.$nid);
      }
    }
  }
  // Now drop the old primary key and create the new primary key and index
  db_drop_primary_key($ret, 'menu_node');
  db_add_primary_key($ret, 'menu_node', array('mlid'));
  db_add_index($ret, 'menu_node', 'nid', array('nid'));
  return $ret;
}
agentrickard’s picture

Lucky me, I have a site with 6000 menu nodes I can test this on....

derjochenmeyer’s picture

subscribing

agentrickard’s picture

StatusFileSize
new3.28 KB

Updated patch for testing.

derjochenmeyer’s picture

Thanks, I tested the patch. It solves the problem mentioned by mgriego in #6

agentrickard’s picture

Did you happen to test the update function?

derjochenmeyer’s picture

No, I only tested the problem mentioned by mgriego in #6

agentrickard’s picture

OK, thanks. I'd love for someone to validate the update function, but it should be ready to go.

agentrickard’s picture

Status: Needs review » Fixed

Committed. Shame no reviews.

bstoppel’s picture

I just applied the patch from menu_node 1.3 to 1.4. Before I applied the update I looked at the menu_node table. There are 459 rows. 458 are distinct.

The update didn't apply fully because of the duplicate. (This is obviously easy for me to fix.)

Here's the the relevant part of the output...

Update #6002

* ALTER TABLE {menu_node} DROP PRIMARY KEY
* Failed: ALTER TABLE {menu_node} DROP INDEX nid
* Failed: ALTER TABLE {menu_node} ADD PRIMARY KEY (mlid)
* ALTER TABLE {menu_node} ADD INDEX nid (nid)

bstoppel’s picture

BTW if anyone is trying to find the dups in their menu_node table, use this query (assumes menu_node has not been prefaced.)

select mlid, count(mlid) from menu_node group by mlid having count(mlid) > 1;

agentrickard’s picture

You need to open a new issue, please, with regard to the update failure. I cannot tell if the update failed because the code is wrong or because you edited your database in some way.

bstoppel’s picture

I started a new issue. http://drupal.org/node/1018692

Status: Fixed » Closed (fixed)

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