class MenuTreeStorage in Drupal 10
Same name and namespace in other branches
- 8 core/lib/Drupal/Core/Menu/MenuTreeStorage.php \Drupal\Core\Menu\MenuTreeStorage
- 9 core/lib/Drupal/Core/Menu/MenuTreeStorage.php \Drupal\Core\Menu\MenuTreeStorage
Provides a menu tree storage using the database.
Hierarchy
- class \Drupal\Core\Menu\MenuTreeStorage implements \Drupal\Core\Menu\MenuTreeStorageInterface
Expanded class hierarchy of MenuTreeStorage
1 file declares its use of MenuTreeStorage
- MenuTreeStorageTest.php in core/
tests/ Drupal/ KernelTests/ Core/ Menu/ MenuTreeStorageTest.php
1 string reference to 'MenuTreeStorage'
- core.services.yml in core/
core.services.yml - core/core.services.yml
1 service uses MenuTreeStorage
File
- core/
lib/ Drupal/ Core/ Menu/ MenuTreeStorage.php, line 18
Namespace
Drupal\Core\MenuView source
class MenuTreeStorage implements MenuTreeStorageInterface {
/**
* The maximum depth of a menu links tree.
*/
const MAX_DEPTH = 9;
/**
* The database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $connection;
/**
* Cache backend instance for the extracted tree data.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected $menuCacheBackend;
/**
* The cache tags invalidator.
*
* @var \Drupal\Core\Cache\CacheTagsInvalidatorInterface
*/
protected $cacheTagsInvalidator;
/**
* The database table name.
*
* @var string
*/
protected $table;
/**
* Additional database connection options to use in queries.
*
* @var array
*/
protected $options = [];
/**
* Stores definitions that have already been loaded for better performance.
*
* An array of plugin definition arrays, keyed by plugin ID.
*
* @var array
*/
protected $definitions = [];
/**
* List of serialized fields.
*
* @var array
*/
protected $serializedFields;
/**
* List of plugin definition fields.
*
* @todo Decide how to keep these field definitions in sync.
* https://www.drupal.org/node/2302085
*
* @see \Drupal\Core\Menu\MenuLinkManager::$defaults
*
* @var array
*/
protected $definitionFields = [
'menu_name',
'route_name',
'route_parameters',
'url',
'title',
'description',
'parent',
'weight',
'options',
'expanded',
'enabled',
'provider',
'metadata',
'class',
'form_class',
'id',
];
/**
* Constructs a new \Drupal\Core\Menu\MenuTreeStorage.
*
* @param \Drupal\Core\Database\Connection $connection
* A Database connection to use for reading and writing configuration data.
* @param \Drupal\Core\Cache\CacheBackendInterface $menu_cache_backend
* Cache backend instance for the extracted tree data.
* @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $cache_tags_invalidator
* The cache tags invalidator.
* @param string $table
* A database table name to store configuration data in.
* @param array $options
* (optional) Any additional database connection options to use in queries.
*/
public function __construct(Connection $connection, CacheBackendInterface $menu_cache_backend, CacheTagsInvalidatorInterface $cache_tags_invalidator, $table, array $options = []) {
$this->connection = $connection;
$this->menuCacheBackend = $menu_cache_backend;
$this->cacheTagsInvalidator = $cache_tags_invalidator;
$this->table = $table;
$this->options = $options;
}
/**
* {@inheritdoc}
*/
public function maxDepth() {
return static::MAX_DEPTH;
}
/**
* {@inheritdoc}
*/
public function resetDefinitions() {
$this->definitions = [];
}
/**
* {@inheritdoc}
*/
public function rebuild(array $definitions) {
$links = [];
$children = [];
$top_links = [];
// Fetch the list of existing menus, in case some are not longer populated
// after the rebuild.
$before_menus = $this
->getMenuNames();
if ($definitions) {
foreach ($definitions as $id => $link) {
// Flag this link as discovered, i.e. saved via rebuild().
$link['discovered'] = 1;
// Note: The parent we set here might be just stored in the {menu_tree}
// table, so it will not end up in $top_links. Therefore the later loop
// on the orphan links, will handle those cases.
if (!empty($link['parent'])) {
$children[$link['parent']][$id] = $id;
}
else {
// A top level link - we need them to root our tree.
$top_links[$id] = $id;
$link['parent'] = '';
}
$links[$id] = $link;
}
}
foreach ($top_links as $id) {
$this
->saveRecursive($id, $children, $links);
}
// Handle any children we didn't find starting from top-level links.
foreach ($children as $orphan_links) {
foreach ($orphan_links as $id) {
// Check for a parent that is not loaded above since only internal links
// are loaded above.
$parent = $this
->loadFull($links[$id]['parent']);
// If there is a parent add it to the links to be used in
// ::saveRecursive().
if ($parent) {
$links[$links[$id]['parent']] = $parent;
}
else {
// Force it to the top level.
$links[$id]['parent'] = '';
}
$this
->saveRecursive($id, $children, $links);
}
}
$result = $this
->findNoLongerExistingLinks($definitions);
// Remove all such items.
if ($result) {
$this
->purgeMultiple($result);
}
$this
->resetDefinitions();
$affected_menus = $this
->getMenuNames() + $before_menus;
// Invalidate any cache tagged with any menu name.
$cache_tags = Cache::buildTags('config:system.menu', $affected_menus, '.');
$this->cacheTagsInvalidator
->invalidateTags($cache_tags);
$this
->resetDefinitions();
// Every item in the cache bin should have one of the menu cache tags but it
// is not guaranteed, so invalidate everything in the bin.
$this->menuCacheBackend
->invalidateAll();
}
/**
* Purges multiple menu links that no longer exist.
*
* @param array $ids
* An array of menu link IDs.
*/
protected function purgeMultiple(array $ids) {
$loaded = $this
->loadFullMultiple($ids);
foreach ($loaded as $id => $link) {
if ($link['has_children']) {
$children = $this
->loadByProperties([
'parent' => $id,
]);
foreach ($children as $child) {
$child['parent'] = $link['parent'];
$this
->save($child);
}
}
}
$this
->doDeleteMultiple($ids);
}
/**
* Executes a select query while making sure the database table exists.
*
* @param \Drupal\Core\Database\Query\SelectInterface $query
* The select object to be executed.
*
* @return \Drupal\Core\Database\StatementInterface|null
* A prepared statement, or NULL if the query is not valid.
*
* @throws \Exception
* Thrown if the table could not be created or the database connection
* failed.
*/
protected function safeExecuteSelect(SelectInterface $query) {
try {
return $query
->execute();
} catch (\Exception $e) {
// If there was an exception, try to create the table.
if ($this
->ensureTableExists()) {
return $query
->execute();
}
// Some other failure that we can not recover from.
throw new PluginException($e
->getMessage(), 0, $e);
}
}
/**
* {@inheritdoc}
*/
public function save(array $link) {
$affected_menus = $this
->doSave($link);
$this
->resetDefinitions();
$cache_tags = Cache::buildTags('config:system.menu', $affected_menus, '.');
$this->cacheTagsInvalidator
->invalidateTags($cache_tags);
return $affected_menus;
}
/**
* Saves a link without clearing caches.
*
* @param array $link
* A definition, according to $definitionFields, for a
* \Drupal\Core\Menu\MenuLinkInterface plugin.
*
* @return array
* The menu names affected by the save operation. This will be one menu
* name if the link is saved to the sane menu, or two if it is saved to a
* new menu.
*
* @throws \Exception
* Thrown if the storage back-end does not exist and could not be created.
* @throws \Drupal\Component\Plugin\Exception\PluginException
* Thrown if the definition is invalid, for example, if the specified parent
* would cause the links children to be moved to greater than the maximum
* depth.
*/
protected function doSave(array $link) {
$affected_menus = [];
// Get the existing definition if it exists. This does not use
// self::loadFull() to avoid the unserialization of fields with 'serialize'
// equal to TRUE as defined in self::schemaDefinition(). The makes $original
// easier to compare with the return value of self::preSave().
$query = $this->connection
->select($this->table, NULL, $this->options);
$query
->fields($this->table);
$query
->condition('id', $link['id']);
$original = $this
->safeExecuteSelect($query)
->fetchAssoc();
if ($original) {
$link['mlid'] = $original['mlid'];
$link['has_children'] = $original['has_children'];
$affected_menus[$original['menu_name']] = $original['menu_name'];
$fields = $this
->preSave($link, $original);
// If $link matches the $original data then exit early as there are no
// changes to make. Use array_diff_assoc() to check if they match because:
// - Some of the data types of the values are not the same. The values
// in $original are all strings because they have come from database but
// $fields contains typed values.
// - MenuTreeStorage::preSave() removes the 'mlid' from $fields.
// - The order of the keys in $original and $fields is different.
if (array_diff_assoc($fields, $original) == [] && array_diff_assoc($original, $fields) == [
'mlid' => $link['mlid'],
]) {
return $affected_menus;
}
}
$transaction = $this->connection
->startTransaction();
try {
if (!$original) {
// Generate a new mlid.
// @todo Remove the 'return' option in Drupal 11.
// @see https://www.drupal.org/project/drupal/issues/3256524
$options = [
'return' => Database::RETURN_INSERT_ID,
] + $this->options;
$link['mlid'] = $this->connection
->insert($this->table, $options)
->fields([
'id' => $link['id'],
'menu_name' => $link['menu_name'],
])
->execute();
$fields = $this
->preSave($link, []);
}
// We may be moving the link to a new menu.
$affected_menus[$fields['menu_name']] = $fields['menu_name'];
$query = $this->connection
->update($this->table, $this->options);
$query
->condition('mlid', $link['mlid']);
$query
->fields($fields)
->execute();
if ($original) {
$this
->updateParentalStatus($original);
}
$this
->updateParentalStatus($link);
} catch (\Exception $e) {
$transaction
->rollBack();
throw $e;
}
return $affected_menus;
}
/**
* Fills in all the fields the database save needs, using the link definition.
*
* @param array $link
* The link definition to be updated.
* @param array $original
* The link definition before the changes. May be empty if not found.
*
* @return array
* The values which will be stored.
*
* @throws \Drupal\Component\Plugin\Exception\PluginException
* Thrown when the specific depth exceeds the maximum.
*/
protected function preSave(array &$link, array $original) {
static $schema_fields, $schema_defaults;
if (empty($schema_fields)) {
$schema = static::schemaDefinition();
$schema_fields = $schema['fields'];
foreach ($schema_fields as $name => $spec) {
if (isset($spec['default'])) {
$schema_defaults[$name] = $spec['default'];
}
}
}
// Try to find a parent link. If found, assign it and derive its menu.
$parent = $this
->findParent($link, $original);
if ($parent) {
$link['parent'] = $parent['id'];
$link['menu_name'] = $parent['menu_name'];
}
else {
$link['parent'] = '';
}
// If no corresponding parent link was found, move the link to the
// top-level.
foreach ($schema_defaults as $name => $default) {
if (!isset($link[$name])) {
$link[$name] = $default;
}
}
$fields = array_intersect_key($link, $schema_fields);
// Sort the route parameters so that the query string will be the same.
asort($fields['route_parameters']);
// Since this will be urlencoded, it's safe to store and match against a
// text field.
$fields['route_param_key'] = $fields['route_parameters'] ? UrlHelper::buildQuery($fields['route_parameters']) : '';
foreach ($this
->serializedFields() as $name) {
if (isset($fields[$name])) {
$fields[$name] = serialize($fields[$name]);
}
}
$this
->setParents($fields, $parent, $original);
// Need to check both parent and menu_name, since parent can be empty in any
// menu.
if ($original && ($link['parent'] != $original['parent'] || $link['menu_name'] != $original['menu_name'])) {
$this
->moveChildren($fields, $original);
}
// We needed the mlid above, but not in the update query.
unset($fields['mlid']);
// Cast Booleans to int, if needed.
$fields['enabled'] = (int) $fields['enabled'];
$fields['expanded'] = (int) $fields['expanded'];
return $fields;
}
/**
* {@inheritdoc}
*/
public function delete($id) {
// Children get re-attached to the menu link's parent.
$item = $this
->loadFull($id);
// It's possible the link is already deleted.
if ($item) {
$parent = $item['parent'];
$children = $this
->loadByProperties([
'parent' => $id,
]);
foreach ($children as $child) {
$child['parent'] = $parent;
$this
->save($child);
}
$this
->doDeleteMultiple([
$id,
]);
$this
->updateParentalStatus($item);
// Many children may have moved.
$this
->resetDefinitions();
$this->cacheTagsInvalidator
->invalidateTags([
'config:system.menu.' . $item['menu_name'],
]);
}
}
/**
* {@inheritdoc}
*/
public function getSubtreeHeight($id) {
$original = $this
->loadFull($id);
return $original ? $this
->doFindChildrenRelativeDepth($original) + 1 : 0;
}
/**
* Finds the relative depth of this link's deepest child.
*
* @param array $original
* The parent definition used to find the depth.
*
* @return int
* Returns the relative depth.
*/
protected function doFindChildrenRelativeDepth(array $original) {
$query = $this->connection
->select($this->table, NULL, $this->options);
$query
->addField($this->table, 'depth');
$query
->condition('menu_name', $original['menu_name']);
$query
->orderBy('depth', 'DESC');
$query
->range(0, 1);
for ($i = 1; $i <= static::MAX_DEPTH && $original["p{$i}"]; $i++) {
$query
->condition("p{$i}", $original["p{$i}"]);
}
$max_depth = $this
->safeExecuteSelect($query)
->fetchField();
return $max_depth > $original['depth'] ? $max_depth - $original['depth'] : 0;
}
/**
* Sets the materialized path field values based on the parent.
*
* @param array $fields
* The menu link.
* @param array|false $parent
* The parent menu link.
* @param array $original
* The original menu link.
*/
protected function setParents(array &$fields, $parent, array $original) {
// Directly fill parents for top-level links.
if (empty($fields['parent'])) {
$fields['p1'] = $fields['mlid'];
for ($i = 2; $i <= $this
->maxDepth(); $i++) {
$fields["p{$i}"] = 0;
}
$fields['depth'] = 1;
}
else {
// @todo We want to also check $original['has_children'] here, but that
// will be 0 even if there are children if those are not enabled.
// has_children is really just the rendering hint. So, we either need
// to define another column (has_any_children), or do the extra query.
// https://www.drupal.org/node/2302149
if ($original) {
$limit = $this
->maxDepth() - $this
->doFindChildrenRelativeDepth($original) - 1;
}
else {
$limit = $this
->maxDepth() - 1;
}
if ($parent['depth'] > $limit) {
throw new PluginException("The link with ID {$fields['id']} or its children exceeded the maximum depth of {$this->maxDepth()}");
}
$fields['depth'] = $parent['depth'] + 1;
$i = 1;
while ($i < $fields['depth']) {
$p = 'p' . $i++;
$fields[$p] = $parent[$p];
}
$p = 'p' . $i++;
// The parent (p1 - p9) corresponding to the depth always equals the mlid.
$fields[$p] = $fields['mlid'];
while ($i <= static::MAX_DEPTH) {
$p = 'p' . $i++;
$fields[$p] = 0;
}
}
}
/**
* Re-parents a link's children when the link itself is moved.
*
* @param array $fields
* The changed menu link.
* @param array $original
* The original menu link.
*/
protected function moveChildren($fields, $original) {
$query = $this->connection
->update($this->table, $this->options);
$query
->fields([
'menu_name' => $fields['menu_name'],
]);
$expressions = [];
for ($i = 1; $i <= $fields['depth']; $i++) {
$expressions[] = [
"p{$i}",
":p_{$i}",
[
":p_{$i}" => $fields["p{$i}"],
],
];
}
$j = $original['depth'] + 1;
while ($i <= $this
->maxDepth() && $j <= $this
->maxDepth()) {
$expressions[] = [
'p' . $i++,
'p' . $j++,
[],
];
}
while ($i <= $this
->maxDepth()) {
$expressions[] = [
'p' . $i++,
0,
[],
];
}
$shift = $fields['depth'] - $original['depth'];
if ($shift > 0) {
// The order of expressions must be reversed so the new values don't
// overwrite the old ones before they can be used because "Single-table
// UPDATE assignments are generally evaluated from left to right".
// @see http://dev.mysql.com/doc/refman/5.0/en/update.html
$expressions = array_reverse($expressions);
}
foreach ($expressions as $expression) {
$query
->expression($expression[0], $expression[1], $expression[2]);
}
$query
->expression('depth', '[depth] + :depth', [
':depth' => $shift,
]);
$query
->condition('menu_name', $original['menu_name']);
for ($i = 1; $i <= $this
->maxDepth() && $original["p{$i}"]; $i++) {
$query
->condition("p{$i}", $original["p{$i}"]);
}
$query
->execute();
}
/**
* Loads the parent definition if it exists.
*
* @param array $link
* The link definition to find the parent of.
* @param array|false $original
* The original link that might be used to find the parent if the parent
* is not set on the $link, or FALSE if the original could not be loaded.
*
* @return array|false
* Returns a definition array, or FALSE if no parent was found.
*/
protected function findParent($link, $original) {
$parent = FALSE;
// This item is explicitly top-level, skip the rest of the parenting.
if (isset($link['parent']) && empty($link['parent'])) {
return $parent;
}
// If we have a parent link ID, try to use that.
$candidates = [];
if (isset($link['parent'])) {
$candidates[] = $link['parent'];
}
elseif (!empty($original['parent']) && $link['menu_name'] == $original['menu_name']) {
// Otherwise, fall back to the original parent.
$candidates[] = $original['parent'];
}
foreach ($candidates as $id) {
$parent = $this
->loadFull($id);
if ($parent) {
break;
}
}
return $parent;
}
/**
* Sets has_children for the link's parent if it has visible children.
*
* @param array $link
* The link to get a parent ID from.
*/
protected function updateParentalStatus(array $link) {
// If parent is empty, there is nothing to update.
if (!empty($link['parent'])) {
// Check if at least one visible child exists in the table.
$query = $this->connection
->select($this->table, NULL, $this->options);
$query
->addExpression('1');
$query
->range(0, 1);
$query
->condition('menu_name', $link['menu_name'])
->condition('parent', $link['parent'])
->condition('enabled', 1);
$parent_has_children = (bool) $query
->execute()
->fetchField() ? 1 : 0;
$this->connection
->update($this->table, $this->options)
->fields([
'has_children' => $parent_has_children,
])
->condition('id', $link['parent'])
->execute();
}
}
/**
* Prepares a link by unserializing values and saving the definition.
*
* @param array $link
* The data loaded in the query.
* @param bool $intersect
* If TRUE, filter out values that are not part of the actual definition.
*
* @return array
* The prepared link data.
*/
protected function prepareLink(array $link, $intersect = FALSE) {
foreach ($this
->serializedFields() as $name) {
if (isset($link[$name])) {
$link[$name] = unserialize($link[$name]);
}
}
if ($intersect) {
$link = array_intersect_key($link, array_flip($this
->definitionFields()));
}
$this->definitions[$link['id']] = $link;
return $link;
}
/**
* {@inheritdoc}
*/
public function loadByProperties(array $properties) {
$query = $this->connection
->select($this->table, NULL, $this->options);
$query
->fields($this->table, $this
->definitionFields());
foreach ($properties as $name => $value) {
if (!in_array($name, $this
->definitionFields(), TRUE)) {
$fields = implode(', ', $this
->definitionFields());
throw new \InvalidArgumentException("An invalid property name, {$name} was specified. Allowed property names are: {$fields}.");
}
$query
->condition($name, $value);
}
$loaded = $this
->safeExecuteSelect($query)
->fetchAllAssoc('id', \PDO::FETCH_ASSOC);
foreach ($loaded as $id => $link) {
$loaded[$id] = $this
->prepareLink($link);
}
return $loaded;
}
/**
* {@inheritdoc}
*/
public function loadByRoute($route_name, array $route_parameters = [], $menu_name = NULL) {
// Sort the route parameters so that the query string will be the same.
asort($route_parameters);
// Since this will be urlencoded, it's safe to store and match against a
// text field.
// @todo Standardize an efficient way to load by route name and parameters
// in place of system path. https://www.drupal.org/node/2302139
$param_key = $route_parameters ? UrlHelper::buildQuery($route_parameters) : '';
$query = $this->connection
->select($this->table, NULL, $this->options);
$query
->fields($this->table, $this
->definitionFields());
$query
->condition('route_name', $route_name);
$query
->condition('route_param_key', $param_key);
if ($menu_name) {
$query
->condition('menu_name', $menu_name);
}
// Make the ordering deterministic.
$query
->orderBy('depth');
$query
->orderBy('weight');
$query
->orderBy('id');
$loaded = $this
->safeExecuteSelect($query)
->fetchAllAssoc('id', \PDO::FETCH_ASSOC);
foreach ($loaded as $id => $link) {
$loaded[$id] = $this
->prepareLink($link);
}
return $loaded;
}
/**
* {@inheritdoc}
*/
public function loadMultiple(array $ids) {
$missing_ids = array_diff($ids, array_keys($this->definitions));
if ($missing_ids) {
$query = $this->connection
->select($this->table, NULL, $this->options);
$query
->fields($this->table, $this
->definitionFields());
$query
->condition('id', $missing_ids, 'IN');
$loaded = $this
->safeExecuteSelect($query)
->fetchAllAssoc('id', \PDO::FETCH_ASSOC);
foreach ($loaded as $id => $link) {
$this->definitions[$id] = $this
->prepareLink($link);
}
}
return array_intersect_key($this->definitions, array_flip($ids));
}
/**
* {@inheritdoc}
*/
public function load($id) {
if (isset($this->definitions[$id])) {
return $this->definitions[$id];
}
$loaded = $this
->loadMultiple([
$id,
]);
return $loaded[$id] ?? FALSE;
}
/**
* Loads all table fields, not just those that are in the plugin definition.
*
* @param string $id
* The menu link ID.
*
* @return array
* The loaded menu link definition or an empty array if not be found.
*/
protected function loadFull($id) {
$loaded = $this
->loadFullMultiple([
$id,
]);
return $loaded[$id] ?? [];
}
/**
* Loads all table fields for multiple menu link definitions by ID.
*
* @param array $ids
* The IDs to load.
*
* @return array
* The loaded menu link definitions.
*/
protected function loadFullMultiple(array $ids) {
$query = $this->connection
->select($this->table, NULL, $this->options);
$query
->fields($this->table);
$query
->condition('id', $ids, 'IN');
$loaded = $this
->safeExecuteSelect($query)
->fetchAllAssoc('id', \PDO::FETCH_ASSOC);
foreach ($loaded as &$link) {
foreach ($this
->serializedFields() as $name) {
if (isset($link[$name])) {
$link[$name] = unserialize($link[$name]);
}
}
}
return $loaded;
}
/**
* {@inheritdoc}
*/
public function getRootPathIds($id) {
$subquery = $this->connection
->select($this->table, NULL, $this->options);
// @todo Consider making this dynamic based on static::MAX_DEPTH or from the
// schema if that is generated using static::MAX_DEPTH.
// https://www.drupal.org/node/2302043
$subquery
->fields($this->table, [
'p1',
'p2',
'p3',
'p4',
'p5',
'p6',
'p7',
'p8',
'p9',
]);
$subquery
->condition('id', $id);
$result = current($subquery
->execute()
->fetchAll(\PDO::FETCH_ASSOC));
$ids = array_filter($result);
if ($ids) {
$query = $this->connection
->select($this->table, NULL, $this->options);
$query
->fields($this->table, [
'id',
]);
$query
->orderBy('depth', 'DESC');
$query
->condition('mlid', $ids, 'IN');
// @todo Cache this result in memory if we find it is being used more
// than once per page load. https://www.drupal.org/node/2302185
return $this
->safeExecuteSelect($query)
->fetchAllKeyed(0, 0);
}
return [];
}
/**
* {@inheritdoc}
*/
public function getExpanded($menu_name, array $parents) {
// @todo Go back to tracking in state or some other way which menus have
// expanded links? https://www.drupal.org/node/2302187
do {
$query = $this->connection
->select($this->table, NULL, $this->options);
$query
->fields($this->table, [
'id',
]);
$query
->condition('menu_name', $menu_name);
$query
->condition('expanded', 1);
$query
->condition('has_children', 1);
$query
->condition('enabled', 1);
$query
->condition('parent', $parents, 'IN');
$query
->condition('id', $parents, 'NOT IN');
$result = $this
->safeExecuteSelect($query)
->fetchAllKeyed(0, 0);
$parents += $result;
} while (!empty($result));
return $parents;
}
/**
* Saves menu links recursively.
*
* @param string $id
* The definition ID.
* @param array $children
* An array of IDs of child links collected by parent ID.
* @param array $links
* An array of all definitions keyed by ID.
*/
protected function saveRecursive($id, &$children, &$links) {
if (!empty($links[$id]['parent']) && empty($links[$links[$id]['parent']])) {
// Invalid parent ID, so remove it.
$links[$id]['parent'] = '';
}
$this
->doSave($links[$id]);
if (!empty($children[$id])) {
foreach ($children[$id] as $next_id) {
$this
->saveRecursive($next_id, $children, $links);
}
}
// Remove processed link names so we can find stragglers.
unset($children[$id]);
}
/**
* {@inheritdoc}
*/
public function loadTreeData($menu_name, MenuTreeParameters $parameters) {
$tree_cid = "tree-data:{$menu_name}:" . serialize($parameters);
$cache = $this->menuCacheBackend
->get($tree_cid);
if ($cache && isset($cache->data)) {
$data = $cache->data;
// Cache the definitions in memory so they don't need to be loaded again.
$this->definitions += $data['definitions'];
unset($data['definitions']);
}
else {
$links = $this
->loadLinks($menu_name, $parameters);
$data['tree'] = $this
->doBuildTreeData($links, $parameters->activeTrail, $parameters->minDepth);
$data['definitions'] = [];
$data['route_names'] = $this
->collectRoutesAndDefinitions($data['tree'], $data['definitions']);
$this->menuCacheBackend
->set($tree_cid, $data, Cache::PERMANENT, [
'config:system.menu.' . $menu_name,
]);
// The definitions were already added to $this->definitions in
// $this->doBuildTreeData()
unset($data['definitions']);
}
return $data;
}
/**
* Loads links in the given menu, according to the given tree parameters.
*
* @param string $menu_name
* A menu name.
* @param \Drupal\Core\Menu\MenuTreeParameters $parameters
* The parameters to determine which menu links to be loaded into a tree.
* This method will set the absolute minimum depth, which is used in
* MenuTreeStorage::doBuildTreeData().
*
* @return array
* A flat array of menu links that are part of the menu. Each array element
* is an associative array of information about the menu link, containing
* the fields from the {menu_tree} table. This array must be ordered
* depth-first.
*/
protected function loadLinks($menu_name, MenuTreeParameters $parameters) {
$query = $this->connection
->select($this->table, NULL, $this->options);
$query
->fields($this->table);
// Allow a custom root to be specified for loading a menu link tree. If
// omitted, the default root (i.e. the actual root, '') is used.
if ($parameters->root !== '') {
$root = $this
->loadFull($parameters->root);
// If the custom root does not exist, we cannot load the links below it.
if (!$root) {
return [];
}
// When specifying a custom root, we only want to find links whose
// parent IDs match that of the root; that's how we ignore the rest of the
// tree. In other words: we exclude everything unreachable from the
// custom root.
for ($i = 1; $i <= $root['depth']; $i++) {
$query
->condition("p{$i}", $root["p{$i}"]);
}
// When specifying a custom root, the menu is determined by that root.
$menu_name = $root['menu_name'];
// If the custom root exists, then we must rewrite some of our
// parameters; parameters are relative to the root (default or custom),
// but the queries require absolute numbers, so adjust correspondingly.
if (isset($parameters->minDepth)) {
$parameters->minDepth += $root['depth'];
}
else {
$parameters->minDepth = $root['depth'];
}
if (isset($parameters->maxDepth)) {
$parameters->maxDepth += $root['depth'];
}
}
// If no minimum depth is specified, then set the actual minimum depth,
// depending on the root.
if (!isset($parameters->minDepth)) {
if ($parameters->root !== '' && $root) {
$parameters->minDepth = $root['depth'];
}
else {
$parameters->minDepth = 1;
}
}
for ($i = 1; $i <= $this
->maxDepth(); $i++) {
$query
->orderBy('p' . $i, 'ASC');
}
$query
->condition('menu_name', $menu_name);
if (!empty($parameters->expandedParents)) {
$query
->condition('parent', $parameters->expandedParents, 'IN');
}
if (isset($parameters->minDepth) && $parameters->minDepth > 1) {
$query
->condition('depth', $parameters->minDepth, '>=');
}
if (isset($parameters->maxDepth)) {
$query
->condition('depth', $parameters->maxDepth, '<=');
}
// Add custom query conditions, if any were passed.
if (!empty($parameters->conditions)) {
// Only allow conditions that are testing definition fields.
$parameters->conditions = array_intersect_key($parameters->conditions, array_flip($this
->definitionFields()));
$serialized_fields = $this
->serializedFields();
foreach ($parameters->conditions as $column => $value) {
if (is_array($value)) {
$operator = $value[1];
$value = $value[0];
}
else {
$operator = '=';
}
if (in_array($column, $serialized_fields)) {
$value = serialize($value);
}
$query
->condition($column, $value, $operator);
}
}
$links = $this
->safeExecuteSelect($query)
->fetchAllAssoc('id', \PDO::FETCH_ASSOC);
return $links;
}
/**
* Traverses the menu tree and collects all the route names and definitions.
*
* @param array $tree
* The menu tree you wish to operate on.
* @param array $definitions
* An array to accumulate definitions by reference.
*
* @return array
* Array of route names, with all values being unique.
*/
protected function collectRoutesAndDefinitions(array $tree, array &$definitions) {
return array_values($this
->doCollectRoutesAndDefinitions($tree, $definitions));
}
/**
* Collects all the route names and definitions.
*
* @param array $tree
* A menu link tree from MenuTreeStorage::doBuildTreeData()
* @param array $definitions
* The collected definitions which are populated by reference.
*
* @return array
* The collected route names.
*/
protected function doCollectRoutesAndDefinitions(array $tree, array &$definitions) {
$route_names = [];
foreach (array_keys($tree) as $id) {
$definitions[$id] = $this->definitions[$id];
if (!empty($definitions[$id]['route_name'])) {
$route_names[$definitions[$id]['route_name']] = $definitions[$id]['route_name'];
}
if ($tree[$id]['subtree']) {
$route_names += $this
->doCollectRoutesAndDefinitions($tree[$id]['subtree'], $definitions);
}
}
return $route_names;
}
/**
* {@inheritdoc}
*/
public function loadSubtreeData($id, $max_relative_depth = NULL) {
$tree = [];
$root = $this
->loadFull($id);
if (!$root) {
return $tree;
}
$parameters = new MenuTreeParameters();
$parameters
->setRoot($id)
->onlyEnabledLinks();
return $this
->loadTreeData($root['menu_name'], $parameters);
}
/**
* {@inheritdoc}
*/
public function menuNameInUse($menu_name) {
$query = $this->connection
->select($this->table, NULL, $this->options);
$query
->addField($this->table, 'mlid');
$query
->condition('menu_name', $menu_name);
$query
->range(0, 1);
return (bool) $this
->safeExecuteSelect($query);
}
/**
* {@inheritdoc}
*/
public function getMenuNames() {
$query = $this->connection
->select($this->table, NULL, $this->options);
$query
->addField($this->table, 'menu_name');
$query
->distinct();
return $this
->safeExecuteSelect($query)
->fetchAllKeyed(0, 0);
}
/**
* {@inheritdoc}
*/
public function countMenuLinks($menu_name = NULL) {
$query = $this->connection
->select($this->table, NULL, $this->options);
if ($menu_name) {
$query
->condition('menu_name', $menu_name);
}
return $this
->safeExecuteSelect($query
->countQuery())
->fetchField();
}
/**
* {@inheritdoc}
*/
public function getAllChildIds($id) {
$root = $this
->loadFull($id);
if (!$root) {
return [];
}
$query = $this->connection
->select($this->table, NULL, $this->options);
$query
->fields($this->table, [
'id',
]);
$query
->condition('menu_name', $root['menu_name']);
for ($i = 1; $i <= $root['depth']; $i++) {
$query
->condition("p{$i}", $root["p{$i}"]);
}
// The next p column should not be empty. This excludes the root link.
$query
->condition("p{$i}", 0, '>');
return $this
->safeExecuteSelect($query)
->fetchAllKeyed(0, 0);
}
/**
* {@inheritdoc}
*/
public function loadAllChildren($id, $max_relative_depth = NULL) {
$parameters = new MenuTreeParameters();
$parameters
->setRoot($id)
->excludeRoot()
->setMaxDepth($max_relative_depth)
->onlyEnabledLinks();
$links = $this
->loadLinks(NULL, $parameters);
foreach ($links as $id => $link) {
$links[$id] = $this
->prepareLink($link);
}
return $links;
}
/**
* Prepares the data for calling $this->treeDataRecursive().
*/
protected function doBuildTreeData(array $links, array $parents = [], $depth = 1) {
// Reverse the array so we can use the more efficient array_pop() function.
$links = array_reverse($links);
return $this
->treeDataRecursive($links, $parents, $depth);
}
/**
* Builds the data representing a menu tree.
*
* The function is a bit complex because the rendering of a link depends on
* the next menu link.
*
* @param array $links
* A flat array of menu links that are part of the menu. Each array element
* is an associative array of information about the menu link, containing
* the fields from the $this->table. This array must be ordered
* depth-first. MenuTreeStorage::loadTreeData() includes a sample query.
* @param array $parents
* An array of the menu link ID values that are in the path from the current
* page to the root of the menu tree.
* @param int $depth
* The minimum depth to include in the returned menu tree.
*
* @return array
* The fully built tree.
*
* @see \Drupal\Core\Menu\MenuTreeStorage::loadTreeData()
*/
protected function treeDataRecursive(array &$links, array $parents, $depth) {
$tree = [];
while ($tree_link_definition = array_pop($links)) {
$tree[$tree_link_definition['id']] = [
'definition' => $this
->prepareLink($tree_link_definition, TRUE),
'has_children' => $tree_link_definition['has_children'],
// We need to determine if we're on the path to root so we can later
// build the correct active trail.
'in_active_trail' => in_array($tree_link_definition['id'], $parents),
'subtree' => [],
'depth' => $tree_link_definition['depth'],
];
// Look ahead to the next link, but leave it on the array so it's
// available to other recursive function calls if we return or build a
// sub-tree.
$next = end($links);
// Check whether the next link is the first in a new sub-tree.
if ($next && $next['depth'] > $depth) {
// Recursively call doBuildTreeData to build the sub-tree.
$tree[$tree_link_definition['id']]['subtree'] = $this
->treeDataRecursive($links, $parents, $next['depth']);
// Fetch next link after filling the sub-tree.
$next = end($links);
}
// Determine if we should exit the loop and return.
if (!$next || $next['depth'] < $depth) {
break;
}
}
return $tree;
}
/**
* Checks if the tree table exists and create it if not.
*
* @return bool
* TRUE if the table was created, FALSE otherwise.
*/
protected function ensureTableExists() {
try {
$this->connection
->schema()
->createTable($this->table, static::schemaDefinition());
} catch (DatabaseException $e) {
// If another process has already created the config table, attempting to
// recreate it will throw an exception. In this case just catch the
// exception and do nothing.
} catch (\Exception $e) {
return FALSE;
}
return TRUE;
}
/**
* Determines serialized fields in the storage.
*
* @return array
* A list of fields that are serialized in the database.
*/
protected function serializedFields() {
if (empty($this->serializedFields)) {
$schema = static::schemaDefinition();
foreach ($schema['fields'] as $name => $field) {
if (!empty($field['serialize'])) {
$this->serializedFields[] = $name;
}
}
}
return $this->serializedFields;
}
/**
* Determines fields that are part of the plugin definition.
*
* @return array
* The list of the subset of fields that are part of the plugin definition.
*/
protected function definitionFields() {
return $this->definitionFields;
}
/**
* Defines the schema for the tree table.
*
* @return array
* The schema API definition for the SQL storage table.
*
* @internal
*/
protected static function schemaDefinition() {
$schema = [
'description' => 'Contains the menu tree hierarchy.',
'fields' => [
'menu_name' => [
'description' => "The menu name. All links with the same menu name (such as 'tools') are part of the same menu.",
'type' => 'varchar_ascii',
'length' => 32,
'not null' => TRUE,
'default' => '',
],
'mlid' => [
'description' => 'The menu link ID (mlid) is the integer primary key.',
'type' => 'serial',
'unsigned' => TRUE,
'not null' => TRUE,
],
'id' => [
'description' => 'Unique machine name: the plugin ID.',
'type' => 'varchar_ascii',
'length' => 255,
'not null' => TRUE,
],
'parent' => [
'description' => 'The plugin ID for the parent of this link.',
'type' => 'varchar_ascii',
'length' => 255,
'not null' => TRUE,
'default' => '',
],
'route_name' => [
'description' => 'The machine name of a defined Symfony Route this menu link represents.',
'type' => 'varchar_ascii',
'length' => 255,
],
'route_param_key' => [
'description' => 'An encoded string of route parameters for loading by route.',
'type' => 'varchar',
'length' => 255,
],
'route_parameters' => [
'description' => 'Serialized array of route parameters of this menu link.',
'type' => 'blob',
'size' => 'big',
'not null' => FALSE,
'serialize' => TRUE,
],
'url' => [
'description' => 'The external path this link points to (when not using a route).',
'type' => 'varchar',
'length' => 255,
'not null' => TRUE,
'default' => '',
],
'title' => [
'description' => 'The serialized title for the link. May be a TranslatableMarkup.',
'type' => 'blob',
'size' => 'big',
'not null' => FALSE,
'serialize' => TRUE,
],
'description' => [
'description' => 'The serialized description of this link - used for admin pages and title attribute. May be a TranslatableMarkup.',
'type' => 'blob',
'size' => 'big',
'not null' => FALSE,
'serialize' => TRUE,
],
'class' => [
'description' => 'The class for this link plugin.',
'type' => 'text',
'not null' => FALSE,
],
'options' => [
'description' => 'A serialized array of URL options, such as a query string or HTML attributes.',
'type' => 'blob',
'size' => 'big',
'not null' => FALSE,
'serialize' => TRUE,
],
'provider' => [
'description' => 'The name of the module that generated this link.',
'type' => 'varchar_ascii',
'length' => DRUPAL_EXTENSION_NAME_MAX_LENGTH,
'not null' => TRUE,
'default' => 'system',
],
'enabled' => [
'description' => 'A flag for whether the link should be rendered in menus. (0 = a disabled menu link that may be shown on admin screens, 1 = a normal, visible link)',
'type' => 'int',
'not null' => TRUE,
'default' => 1,
'size' => 'small',
],
'discovered' => [
'description' => 'A flag for whether the link was discovered, so can be purged on rebuild',
'type' => 'int',
'not null' => TRUE,
'default' => 0,
'size' => 'small',
],
'expanded' => [
'description' => 'Flag for whether this link should be rendered as expanded in menus - expanded links always have their child links displayed, instead of only when the link is in the active trail (1 = expanded, 0 = not expanded)',
'type' => 'int',
'not null' => TRUE,
'default' => 0,
'size' => 'small',
],
'weight' => [
'description' => 'Link weight among links in the same menu at the same depth.',
'type' => 'int',
'not null' => TRUE,
'default' => 0,
],
'metadata' => [
'description' => 'A serialized array of data that may be used by the plugin instance.',
'type' => 'blob',
'size' => 'big',
'not null' => FALSE,
'serialize' => TRUE,
],
'has_children' => [
'description' => 'Flag indicating whether any enabled links have this link as a parent (1 = enabled children exist, 0 = no enabled children).',
'type' => 'int',
'not null' => TRUE,
'default' => 0,
'size' => 'small',
],
'depth' => [
'description' => 'The depth relative to the top level. A link with empty parent will have depth == 1.',
'type' => 'int',
'not null' => TRUE,
'default' => 0,
'size' => 'small',
],
'p1' => [
'description' => 'The first mlid in the materialized path. If N = depth, then pN must equal the mlid. If depth > 1 then p(N-1) must equal the parent link mlid. All pX where X > depth must equal zero. The columns p1 .. p9 are also called the parents.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
],
'p2' => [
'description' => 'The second mlid in the materialized path. See p1.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
],
'p3' => [
'description' => 'The third mlid in the materialized path. See p1.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
],
'p4' => [
'description' => 'The fourth mlid in the materialized path. See p1.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
],
'p5' => [
'description' => 'The fifth mlid in the materialized path. See p1.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
],
'p6' => [
'description' => 'The sixth mlid in the materialized path. See p1.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
],
'p7' => [
'description' => 'The seventh mlid in the materialized path. See p1.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
],
'p8' => [
'description' => 'The eighth mlid in the materialized path. See p1.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
],
'p9' => [
'description' => 'The ninth mlid in the materialized path. See p1.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
],
'form_class' => [
'description' => 'meh',
'type' => 'varchar',
'length' => 255,
],
],
'indexes' => [
'menu_parents' => [
'menu_name',
'p1',
'p2',
'p3',
'p4',
'p5',
'p6',
'p7',
'p8',
'p9',
],
// @todo Test this index for effectiveness.
// https://www.drupal.org/node/2302197
'menu_parent_expand_child' => [
'menu_name',
'expanded',
'has_children',
[
'parent',
16,
],
],
'route_values' => [
[
'route_name',
32,
],
[
'route_param_key',
16,
],
],
],
'primary key' => [
'mlid',
],
'unique keys' => [
'id' => [
'id',
],
],
];
return $schema;
}
/**
* Find any previously discovered menu links that no longer exist.
*
* @param array $definitions
* The new menu link definitions.
*
* @return array
* A list of menu link IDs that no longer exist.
*/
protected function findNoLongerExistingLinks(array $definitions) {
if ($definitions) {
$query = $this->connection
->select($this->table, NULL, $this->options);
$query
->addField($this->table, 'id');
$query
->condition('discovered', 1);
$query
->condition('id', array_keys($definitions), 'NOT IN');
// Starting from links with the greatest depth will minimize the amount
// of re-parenting done by the menu storage.
$query
->orderBy('depth', 'DESC');
$result = $query
->execute()
->fetchCol();
}
else {
$result = [];
}
return $result;
}
/**
* Purge menu links from the database.
*
* @param array $ids
* A list of menu link IDs to be purged.
*/
protected function doDeleteMultiple(array $ids) {
$this->connection
->delete($this->table, $this->options)
->condition('id', $ids, 'IN')
->execute();
}
}