diff --git a/core/core.services.yml b/core/core.services.yml index 0bb13d0..5a6c58c 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -63,6 +63,13 @@ services: factory_method: get factory_service: cache_factory arguments: [entity] + cache.menu: + class: Drupal\Core\Cache\CacheBackendInterface + tags: + - { name: cache.bin } + factory_method: get + factory_service: cache_factory + arguments: [menu] cache.render: class: Drupal\Core\Cache\CacheBackendInterface tags: @@ -265,6 +272,9 @@ services: plugin.manager.action: class: Drupal\Core\Action\ActionManager arguments: ['@container.namespaces', '@cache.discovery', '@module_handler'] + plugin.manager.menu.link: + class: Drupal\Core\Menu\MenuLinkManager + arguments: ['@menu.tree_storage', '@menu_link.static.overrides', '@module_handler'] plugin.manager.menu.local_action: class: Drupal\Core\Menu\LocalActionManager arguments: ['@controller_resolver', '@request_stack', '@router.route_provider', '@module_handler', '@cache.discovery', '@language_manager', '@access_manager', '@current_user'] @@ -279,6 +289,13 @@ services: parent: default_plugin_manager plugin.cache_clearer: class: Drupal\Core\Plugin\CachedDiscoveryClearer + menu.tree_storage: + class: Drupal\Core\Menu\MenuTreeStorage + arguments: ['@database', '@cache.menu', 'menu_tree'] + public: false # Private to plugin.manager.menu.link and menu.link_tree + menu_link.static.overrides: + class: Drupal\Core\Menu\StaticMenuLinkOverrides + arguments: ['@config.factory'] request: class: Symfony\Component\HttpFoundation\Request synthetic: true diff --git a/core/lib/Drupal/Core/Menu/MenuLinkBase.php b/core/lib/Drupal/Core/Menu/MenuLinkBase.php new file mode 100644 index 0000000..438d862 --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuLinkBase.php @@ -0,0 +1,197 @@ +pluginDefinition['weight'])) { + $this->pluginDefinition['weight'] = 0; + } + return $this->pluginDefinition['weight']; + } + + /** + * {@inheritdoc} + */ + public function getTitle() { + // Subclasses may pull in the request or specific attributes as parameters. + $options = array(); + if (!empty($this->pluginDefinition['title_context'])) { + $options['context'] = $this->pluginDefinition['title_context']; + } + $args = array(); + if (isset($this->pluginDefinition['title_arguments']) && $title_arguments = $this->pluginDefinition['title_arguments']) { + $args = (array) $title_arguments; + } + return $this->t($this->pluginDefinition['title'], $args, $options); + } + + /** + * {@inheritdoc} + */ + public function getMenuName() { + return $this->pluginDefinition['menu_name']; + } + + /** + * {@inheritdoc} + */ + public function getProvider() { + return $this->pluginDefinition['provider']; + } + + /** + * {@inheritdoc} + */ + public function getParent() { + return $this->pluginDefinition['parent']; + } + + /** + * {@inheritdoc} + */ + public function isHidden() { + return (bool) $this->pluginDefinition['hidden']; + } + + /** + * {@inheritdoc} + */ + public function isExpanded() { + return (bool) $this->pluginDefinition['expanded']; + } + + /** + * {@inheritdoc} + */ + public function isResetable() { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function isTranslatable() { + return (bool) $this->getTranslateRoute(); + } + + /** + * {@inheritdoc} + */ + public function isDeletable() { + return (bool) $this->getDeleteRoute(); + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + if ($this->pluginDefinition['description']) { + return $this->t($this->pluginDefinition['description']); + } + return ''; + } + + /** + * {@inheritdoc} + */ + public function getOptions() { + return $this->pluginDefinition['options'] ?: array(); + } + + /** + * {@inheritdoc} + */ + public function getMetaData() { + return $this->pluginDefinition['metadata'] ?: array(); + } + + /** + * {@inheritdoc} + */ + public function isCacheable() { + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function getUrlObject($title_attribute = TRUE) { + $options = $this->getOptions(); + $description = $this->getDescription(); + if ($title_attribute && $description) { + $options['attributes']['title'] = $description; + } + if (empty($this->pluginDefinition['url'])) { + return new Url($this->pluginDefinition['route_name'], $this->pluginDefinition['route_parameters'], $options); + } + else { + $url = Url::createFromPath($this->pluginDefinition['url']); + $url->setOptions($options); + return $url; + } + } + + /** + * {@inheritdoc} + */ + public function getFormClass() { + return $this->pluginDefinition['form_class']; + } + + /** + * {@inheritdoc} + */ + public function getDeleteRoute() { + return NULL; + } + + /** + * {@inheritdoc} + */ + public function getEditRoute() { + return NULL; + } + + /** + * {@inheritdoc} + */ + public function getTranslateRoute() { + return NULL; + } + + /** + * {@inheritdoc} + */ + public function deleteLink() { + throw new PluginException(String::format('Menu link plugin with ID @id does not support deletion', array('@id' => $this->getPluginId()))); + } + +} diff --git a/core/lib/Drupal/Core/Menu/MenuLinkDefault.php b/core/lib/Drupal/Core/Menu/MenuLinkDefault.php new file mode 100644 index 0000000..0439937 --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuLinkDefault.php @@ -0,0 +1,88 @@ + 1, + 'parent' => 1, + 'weight' => 1, + 'expanded' => 1, + 'hidden' => 1, + ); + + /** + * The static menu link service used to store updates to weight/parent etc. + * + * @var \Drupal\Core\Menu\StaticMenuLinkOverridesInterface + */ + protected $staticOverride; + + /** + * Constructs a new MenuLinkDefault. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Drupal\Core\Menu\StaticMenuLinkOverridesInterface $static_override + * The static override storage. + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, StaticMenuLinkOverridesInterface $static_override) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + + $this->staticOverride = $static_override; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('menu_link.static.overrides') + ); + } + + /** + * {@inheritdoc} + */ + public function isResetable() { + // The link can be reset if it has an override. + return (bool) $this->staticOverride->loadOverride($this->getPluginId()); + } + + /** + * {@inheritdoc} + */ + public function updateLink(array $new_definition_values, $persist) { + // Filter the list of updates to only those that are allowed. + $overrides = array_intersect_key($new_definition_values, $this->overrideAllowed); + if ($persist) { + $this->staticOverride->saveOverride($this->getPluginId(), $overrides); + } + // Update the definition. + $this->pluginDefinition = $overrides + $this->getPluginDefinition(); + return $this->pluginDefinition; + } + +} diff --git a/core/lib/Drupal/Core/Menu/MenuLinkInterface.php b/core/lib/Drupal/Core/Menu/MenuLinkInterface.php new file mode 100644 index 0000000..6ab1434 --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuLinkInterface.php @@ -0,0 +1,233 @@ + 'tools', + // (required) The name of the route this links to, unless it's external. + 'route_name' => '', + // Parameters for route variables when generating a link. + 'route_parameters' => array(), + // The external URL if this link has one (required if route_name is empty). + 'url' => '', + // The static title for the menu link. You can specify placeholders like on + // any translatable string and the values in title_arguments. + 'title' => '', + // The values for the menu link placeholders. + 'title_arguments' => array(), + // A context for the title string. + // @see \Drupal\Core\StringTranslation\TranslationInterface::translate() + 'title_context' => '', + // The description. + 'description' => '', + // The plugin ID of the parent link (or NULL for a top-level link). + 'parent' => '', + // The weight of the link. + 'weight' => 0, + // The default link options. + 'options' => array(), + 'expanded' => 0, + 'hidden' => 0, + // The name of the module providing this link. + 'provider' => '', + 'metadata' => array(), + // Default class for local task implementations. + 'class' => 'Drupal\Core\Menu\MenuLinkDefault', + 'form_class' => 'Drupal\Core\Menu\Form\MenuLinkDefaultForm', + // The plugin ID. Set by the plugin system based on the top-level YAML key. + 'id' => '', + ); + + /** + * The object that discovers plugins managed by this manager. + * + * @var \Drupal\Component\Plugin\Discovery\DiscoveryInterface + */ + protected $discovery; + + /** + * The object that instantiates plugins managed by this manager. + * + * @var \Drupal\Component\Plugin\Factory\FactoryInterface + */ + protected $factory; + + /** + * The menu link tree storage. + * + * @var \Drupal\Core\Menu\MenuTreeStorageInterface + */ + protected $treeStorage; + + /** + * Service providing overrides for static links. + * + * @var \Drupal\Core\Menu\StaticMenuLinkOverridesInterface + */ + protected $overrides; + + /** + * The module handler. + * + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + protected $moduleHandler; + + + /** + * Constructs a \Drupal\Core\Menu\MenuLinkManager object. + * + * @param \Drupal\Core\Menu\MenuTreeStorageInterface $tree_storage + * The menu link tree storage. + * @param \Drupal\Core\Menu\StaticMenuLinkOverridesInterface $overrides + * The service providing overrides for static links. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler. + */ + public function __construct(MenuTreeStorageInterface $tree_storage, StaticMenuLinkOverridesInterface $overrides, ModuleHandlerInterface $module_handler) { + $this->treeStorage = $tree_storage; + $this->overrides = $overrides; + $this->factory = new ContainerFactory($this); + $this->moduleHandler = $module_handler; + } + + /** + * Performs extra processing on plugin definitions. + * + * By default we add defaults for the type to the definition. If a type has + * additional processing logic, the logic can be added by replacing or + * extending this method. + * + * @param array $definition + * The definition to be processed and modified by reference. + * @param $plugin_id + * The ID of the plugin this definition is being used for. + */ + protected function processDefinition(array &$definition, $plugin_id) { + $definition = NestedArray::mergeDeep($this->defaults, $definition); + // Typecast so NULL, no parent, will be an empty string since the parent ID + // should be a string. + $definition['parent'] = (string) $definition['parent']; + $definition['id'] = $plugin_id; + } + + /** + * Instantiates if necessary and returns a YamlDiscovery instance. + * + * Since the discovery is very rarely used - only when the rebuild() method + * is called - it's instantiated only when actually needed instead of in the + * constructor. + * + * @return \Drupal\Core\Plugin\Discovery\ContainerDerivativeDiscoveryDecorator + * A plugin discovery instance. + */ + protected function getDiscovery() { + if (empty($this->discovery)) { + $yaml = new YamlDiscovery('menu_links', $this->moduleHandler->getModuleDirectories()); + $this->discovery = new ContainerDerivativeDiscoveryDecorator($yaml); + } + return $this->discovery; + } + + /** + * {@inheritdoc} + */ + public function getDefinitions() { + // Since this function is called rarely, instantiate the discovery here. + $definitions = $this->getDiscovery()->getDefinitions(); + + $this->moduleHandler->alter('menu_links_discovered', $definitions); + + foreach ($definitions as $plugin_id => &$definition) { + $definition['id'] = $plugin_id; + $this->processDefinition($definition, $plugin_id); + } + + // If this plugin was provided by a module that does not exist, remove the + // plugin definition. + // @todo Address what to do with an invalid plugin. + // https://www.drupal.org/node/2302623 + foreach ($definitions as $plugin_id => $plugin_definition) { + if (!empty($plugin_definition['provider']) && !$this->moduleHandler->moduleExists($plugin_definition['provider'])) { + unset($definitions[$plugin_id]); + } + } + return $definitions; + } + + /** + * {@inheritdoc} + */ + public function rebuild() { + $definitions = $this->getDefinitions(); + // Apply overrides from config. + $overrides = $this->overrides->loadMultipleOverrides(array_keys($definitions)); + foreach ($overrides as $id => $changes) { + if (!empty($definitions[$id])) { + $definitions[$id] = $changes + $definitions[$id]; + } + } + $this->treeStorage->rebuild($definitions); + } + + /** + * {@inheritdoc} + */ + public function getDefinition($plugin_id, $exception_on_invalid = TRUE) { + $definition = $this->treeStorage->load($plugin_id); + if (empty($definition) && $exception_on_invalid) { + throw new PluginNotFoundException(String::format('@plugin_id could not be found', array('@plugin_id' => $plugin_id))); + } + return $definition; + } + + /** + * {@inheritdoc} + */ + public function hasDefinition($plugin_id) { + return (bool) $this->getDefinition($plugin_id, FALSE); + } + + /** + * Returns a pre-configured menu link plugin instance. + * + * @param string $plugin_id + * The ID of the plugin being instantiated. + * @param array $configuration + * An array of configuration relevant to the plugin instance. + * + * @return \Drupal\Core\Menu\MenuLinkInterface + * A menu link instance. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * If the instance cannot be created, such as if the ID is invalid. + */ + public function createInstance($plugin_id, array $configuration = array()) { + return $this->factory->createInstance($plugin_id, $configuration); + } + + /** + * {@inheritdoc} + */ + public function getInstance(array $options) { + if (isset($options['id'])) { + return $this->createInstance($options['id']); + } + } + + /** + * {@inheritdoc} + */ + public function deleteLinksInMenu($menu_name) { + foreach ($this->treeStorage->loadByProperties(array('menu_name' => $menu_name)) as $plugin_id => $definition) { + $instance = $this->createInstance($plugin_id); + if ($instance->isDeletable()) { + $this->deleteInstance($instance, TRUE); + } + elseif ($instance->isResetable()) { + $new_instance = $this->resetInstance($instance); + $affected_menus[$new_instance->getMenuName()] = $new_instance->getMenuName(); + } + } + } + + /** + * Deletes a specific instance. + * + * @param \Drupal\Core\Menu\MenuLinkInterface $instance + * The plugin instance to be deleted. + * @param bool $persist + * If TRUE, calls MenuLinkInterface::deleteLink() on the instance. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * If the plugin instance does not support deletion. + */ + protected function deleteInstance(MenuLinkInterface $instance, $persist) { + $id = $instance->getPluginId(); + if ($instance->isDeletable()) { + if ($persist) { + $instance->deleteLink(); + } + } + else { + throw new PluginException(String::format('Menu link plugin with ID @id does not support deletion', array('@id' => $id))); + } + $this->treeStorage->delete($id); + } + + /** + * {@inheritdoc} + */ + public function removeDefinition($id, $persist = TRUE) { + $definition = $this->treeStorage->load($id); + // It's possible the definition has already been deleted, or doesn't exist. + if ($definition) { + $instance = $this->createInstance($id); + $this->deleteInstance($instance, $persist); + } + } + + /** + * {@inheritdoc} + */ + public function menuNameInUse($menu_name) { + $this->treeStorage->menuNameInUse($menu_name); + } + + /** + * {@inheritdoc} + */ + public function countMenuLinks($menu_name = NULL) { + return $this->treeStorage->countMenuLinks($menu_name); + } + + /** + * {@inheritdoc} + */ + public function getParentIds($id) { + if ($this->getDefinition($id, FALSE)) { + return $this->treeStorage->getRootPathIds($id); + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function getChildIds($id) { + if ($this->getDefinition($id, FALSE)) { + return $this->treeStorage->getAllChildIds($id); + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function loadLinksByRoute($route_name, array $route_parameters = array(), $menu_name = NULL) { + $instances = array(); + $loaded = $this->treeStorage->loadByRoute($route_name, $route_parameters, $menu_name); + foreach ($loaded as $plugin_id => $definition) { + $instances[$plugin_id] = $this->createInstance($plugin_id); + } + return $instances; + } + + /** + * {@inheritdoc} + */ + public function addDefinition($id, array $definition) { + if ($this->treeStorage->load($id) || $id === '') { + throw new PluginException(String::format('The ID @id already exists as a plugin definition or is not valid', array('@id' => $id))); + } + // Add defaults, so there is no requirement to specify everything. + $this->processDefinition($definition, $id); + // Store the new link in the tree. + $this->treeStorage->save($definition); + return $this->createInstance($id); + } + + /** + * {@inheritdoc} + */ + public function updateDefinition($id, array $new_definition_values, $persist = TRUE) { + $instance = $this->createInstance($id); + if ($instance) { + $new_definition_values['id'] = $id; + $changed_definition = $instance->updateLink($new_definition_values, $persist); + $this->treeStorage->save($changed_definition); + } + return $instance; + } + + /** + * {@inheritdoc} + */ + public function resetLink($id) { + $instance = $this->createInstance($id); + $new_instance = $this->resetInstance($instance); + return $new_instance; + } + + /** + * Resets the menu link to its default settings. + * + * @param \Drupal\Core\Menu\MenuLinkInterface $instance + * The menu link which should be reset. + * + * @return \Drupal\Core\Menu\MenuLinkInterface + * The reset menu link. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * Thrown when the menu link is not resetable. + */ + protected function resetInstance(MenuLinkInterface $instance) { + $id = $instance->getPluginId(); + + if (!$instance->isResetable()) { + throw new PluginException(String::format('Menu link %id is not resetable', array('%id' => $id))); + } + // Get the original data from disk, reset the override and re-save the menu + // tree for this link. + $definition = $this->getDefinitions()[$id]; + $this->overrides->deleteOverride($id); + $this->treeStorage->save($definition); + return $this->createInstance($id); + } + + /** + * {@inheritdoc} + */ + public function resetDefinitions() { + $this->treeStorage->resetDefinitions(); + } + +} diff --git a/core/lib/Drupal/Core/Menu/MenuLinkManagerInterface.php b/core/lib/Drupal/Core/Menu/MenuLinkManagerInterface.php new file mode 100644 index 0000000..1556aa6 --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuLinkManagerInterface.php @@ -0,0 +1,200 @@ +root = (string) $root; + return $this; + } + + /** + * Sets a minimum depth; loads a menu tree from the given level. + * + * @param int $min_depth + * The (root-relative) minimum depth to apply. + * + * @return $this + */ + public function setMinDepth($min_depth) { + $this->minDepth = max(1, $min_depth); + return $this; + } + + /** + * Sets a minimum depth; loads a menu tree up to the given level. + * + * @param int $max_depth + * The (root-relative) maximum depth to apply. + * + * @return $this + * + * @codeCoverageIgnore + */ + public function setMaxDepth($max_depth) { + $this->maxDepth = $max_depth; + return $this; + } + + /** + * Adds parent menu links IDs to restrict the tree (only show children). + * + * @param string[] $parents + * An array containing the parent IDs to limit the tree. + * + * @return $this + */ + public function addExpandedParents(array $parents) { + $this->expandedParents = array_merge($this->expandedParents, $parents); + $this->expandedParents = array_unique($this->expandedParents); + return $this; + } + + /** + * Sets the active trail IDs used to set the inActiveTrail property. + * + * @param string[] $active_trail + * An array containing the active trail: a list of menu link plugin IDs. + * + * @return $this + * + * @see \Drupal\Core\Menu\MenuActiveTrail::getActiveTrailIds() + * + * @codeCoverageIgnore + */ + public function setActiveTrail(array $active_trail) { + $this->activeTrail = $active_trail; + return $this; + } + + /** + * Adds a custom query condition. + * + * @param string $definition_field + * Only conditions that are testing menu link definition fields are allowed. + * @param mixed $value + * The value to test the link definition field against. In most cases, this + * is a scalar. For more complex options, it is an array. The meaning of + * each element in the array is dependent on the $operator. + * @param string|null $operator + * (optional) The comparison operator, such as =, <, or >=. It also accepts + * more complex options such as IN, LIKE, or BETWEEN. If NULL, defaults to + * the = operator. + * + * @return $this + */ + public function addCondition($definition_field, $value, $operator = NULL) { + if (!isset($operator)) { + $this->conditions[$definition_field] = $value; + } + else { + $this->conditions[$definition_field] = array($value, $operator); + } + return $this; + } + + /** + * Excludes hidden links. + * + * @return $this + */ + public function excludeHiddenLinks() { + $this->addCondition('hidden', 0); + return $this; + } + + /** + * Ensures only the top level of the tree is loaded. + * + * @return $this + */ + public function topLevelOnly() { + $this->setMaxDepth(1); + return $this; + } + + /** + * Excludes the root menu link from the tree. + * + * Note that this is only necessary when you specified a custom root, because + * the normal root ID is the empty string, '', which does not correspond to an + * actual menu link. Hence when loading a menu link tree without specifying a + * custom root the tree will start at the children even if this method has not + * been called. + * + * @return $this + */ + public function excludeRoot() { + $this->setMinDepth(1); + return $this; + } + +} diff --git a/core/lib/Drupal/Core/Menu/MenuTreeStorage.php b/core/lib/Drupal/Core/Menu/MenuTreeStorage.php new file mode 100644 index 0000000..6741d35 --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuTreeStorage.php @@ -0,0 +1,1441 @@ +connection = $connection; + $this->menuCacheBackend = $menu_cache_backend; + $this->table = $table; + $this->options = $options; + } + + /** + * {@inheritdoc} + */ + public function maxDepth() { + return static::MAX_DEPTH; + } + + /** + * {@inheritdoc} + */ + public function resetDefinitions() { + $this->definitions = array(); + } + + /** + * {@inheritdoc} + */ + public function rebuild(array $definitions) { + $links = array(); + $children = array(); + $top_links = array(); + // 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; + 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) { + // Force it to the top level. + $links[$id]['parent'] = ''; + $this->saveRecursive($id, $children, $links); + } + } + // Find any previously discovered menu links that no longer exist. + 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 = array(); + } + + // 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::invalidateTags(array('menu' => $affected_menus)); + $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(array('parent' => $id)); + foreach ($children as $child) { + $child['parent'] = $link['parent']; + $this->save($child); + } + } + } + $query = $this->connection->delete($this->table, $this->options); + $query->condition('id', $ids, 'IN'); + $query->execute(); + } + + /** + * 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 $e; + } + } + + /** + * {@inheritdoc} + */ + public function save(array $link) { + $affected_menus = $this->doSave($link); + $this->resetDefinitions(); + Cache::invalidateTags(array('menu' => $affected_menus)); + 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) { + $original = $this->loadFull($link['id']); + // @todo Should we just return here if the link values match the original + // values completely? + // https://www.drupal.org/node/2302137 + $affected_menus = array(); + + $transaction = $this->connection->startTransaction(); + try { + if ($original) { + $link['mlid'] = $original['mlid']; + $link['has_children'] = $original['has_children']; + $affected_menus[$original['menu_name']] = $original['menu_name']; + } + else { + // Generate a new mlid. + $options = array('return' => Database::RETURN_INSERT_ID) + $this->options; + $link['mlid'] = $this->connection->insert($this->table, $options) + ->fields(array('id' => $link['id'], 'menu_name' => $link['menu_name'])) + ->execute(); + } + $fields = $this->preSave($link, $original); + // 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) { + $fields[$name] = serialize($fields[$name]); + } + + // Directly fill parents for top-level links. + if (empty($link['parent'])) { + $fields['p1'] = $link['mlid']; + for ($i = 2; $i <= $this->maxDepth(); $i++) { + $fields["p$i"] = 0; + } + $fields['depth'] = 1; + } + // Otherwise, ensure that this link's depth is not beyond the maximum depth + // and fill parents based on the parent link. + else { + // @todo We want to also check $original['has_children'] here, but that + // will be 0 even if there are children if those are hidden. + // 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(String::format('The link with ID @id or its children exceeded the maximum depth of @depth', array('@id' => $link['id'], '@depth' => $this->maxDepth()))); + } + $this->setParents($fields, $parent); + } + + // 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['hidden'] = (int) $fields['hidden']; + $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(array('parent' => $id)); + foreach ($children as $child) { + $child['parent'] = $parent; + $this->save($child); + } + + $this->connection->delete($this->table, $this->options) + ->condition('id', $id) + ->execute(); + + $this->updateParentalStatus($item); + // Many children may have moved. + $this->resetDefinitions(); + Cache::invalidateTags(array('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, $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 $parent + * The parent menu link. + */ + protected function setParents(array &$fields, array $parent) { + $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(array('menu_name' => $fields['menu_name'])); + + $expressions = array(); + for ($i = 1; $i <= $fields['depth']; $i++) { + $expressions[] = array("p$i", ":p_$i", array(":p_$i" => $fields["p$i"])); + } + $j = $original['depth'] + 1; + while ($i <= $this->maxDepth() && $j <= $this->maxDepth()) { + $expressions[] = array('p' . $i++, 'p' . $j++, array()); + } + while ($i <= $this->maxDepth()) { + $expressions[] = array('p' . $i++, 0, array()); + } + + $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', array(':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 = array(); + 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, $this->options); + $query->addExpression('1'); + $query->range(0, 1); + $query + ->condition('menu_name', $link['menu_name']) + ->condition('parent', $link['parent']) + ->condition('hidden', 0); + + $parent_has_children = ((bool) $query->execute()->fetchField()) ? 1 : 0; + $this->connection->update($this->table, $this->options) + ->fields(array('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) { + $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) { + // @todo Only allow loading by plugin definition properties. + https://www.drupal.org/node/2302165 + $query = $this->connection->select($this->table, $this->options); + $query->fields($this->table, $this->definitionFields()); + foreach ($properties as $name => $value) { + $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 = array(), $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, $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, $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(array($id)); + return isset($loaded[$id]) ? $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(array($id)); + return isset($loaded[$id]) ? $loaded[$id] : array(); + } + + /** + * 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, $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) { + $link[$name] = unserialize($link[$name]); + } + } + return $loaded; + } + + /** + * {@inheritdoc} + */ + public function getRootPathIds($id) { + $subquery = $this->connection->select($this->table, $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, array('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, $this->options); + $query->fields($this->table, array('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 array(); + } + + /** + * {@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, $this->options); + $query->fields($this->table, array('id')); + $query->condition('menu_name', $menu_name); + $query->condition('expanded', 1); + $query->condition('has_children', 1); + $query->condition('hidden', 0); + $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) { + // Build the cache ID; sort 'expanded' and 'conditions' to prevent duplicate + // cache items. + sort($parameters->expandedParents); + sort($parameters->conditions); + $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'] = array(); + $data['route_names'] = $this->collectRoutesAndDefinitions($data['tree'], $data['definitions']); + $this->menuCacheBackend->set($tree_cid, $data, Cache::PERMANENT, array('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, $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 array(); + } + + // 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())); + foreach ($parameters->conditions as $column => $value) { + if (!is_array($value)) { + $query->condition($column, $value); + } + else { + $operator = $value[1]; + $value = $value[0]; + $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 = array(); + foreach (array_keys($tree) as $id) { + $definitions[$id] = $this->definitions[$id]; + if (!empty($definition['route_name'])) { + $route_names[$definition['route_name']] = $definition['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 = array(); + $root = $this->loadFull($id); + if (!$root) { + return $tree; + } + $parameters = new MenuTreeParameters(); + $parameters->setRoot($id)->excludeHiddenLinks(); + return $this->loadTreeData($root['menu_name'], $parameters); + } + + /** + * {@inheritdoc} + */ + public function menuNameInUse($menu_name) { + $query = $this->connection->select($this->table, $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, $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, $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 array(); + } + $query = $this->connection->select($this->table, $this->options); + $query->fields($this->table, array('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)->excludeHiddenLinks(); + $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 = array(), $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 = array(); + while ($tree_link_definition = array_pop($links)) { + $tree[$tree_link_definition['id']] = array( + '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' => array(), + '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. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * If a database error occurs. + */ + protected function ensureTableExists() { + try { + if (!$this->connection->schema()->tableExists($this->table)) { + $this->connection->schema()->createTable($this->table, static::schemaDefinition()); + return TRUE; + } + } + catch (SchemaObjectExistsException $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. + return TRUE; + } + catch (\Exception $e) { + throw new PluginException($e->getMessage(), NULL, $e); + } + return FALSE; + } + + /** + * 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. + */ + protected static function schemaDefinition() { + $schema = array( + 'description' => 'Contains the menu tree hierarchy.', + 'fields' => array( + 'menu_name' => array( + 'description' => "The menu name. All links with the same menu name (such as 'tools') are part of the same menu.", + 'type' => 'varchar', + 'length' => 32, + 'not null' => TRUE, + 'default' => '', + ), + 'mlid' => array( + 'description' => 'The menu link ID (mlid) is the integer primary key.', + 'type' => 'serial', + 'unsigned' => TRUE, + 'not null' => TRUE, + ), + 'id' => array( + 'description' => 'Unique machine name: the plugin ID.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + ), + 'parent' => array( + 'description' => 'The plugin ID for the parent of this link.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'route_name' => array( + 'description' => 'The machine name of a defined Symfony Route this menu item represents.', + 'type' => 'varchar', + 'length' => 255, + ), + 'route_param_key' => array( + 'description' => 'An encoded string of route parameters for loading by route.', + 'type' => 'varchar', + 'length' => 255, + ), + 'route_parameters' => array( + 'description' => 'Serialized array of route parameters of this menu link.', + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'serialize' => TRUE, + ), + 'url' => array( + 'description' => 'The external path this link points to (when not using a route).', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'title' => array( + 'description' => 'The text displayed for the link.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'title_arguments' => array( + 'description' => 'A serialized array of arguments to be passed to t() (if this plugin uses it).', + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'serialize' => TRUE, + ), + 'title_context' => array( + 'description' => 'The translation context for the link title.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'description' => array( + 'description' => 'The description of this link - used for admin pages and title attribute.', + 'type' => 'text', + 'not null' => FALSE, + ), + 'class' => array( + 'description' => 'The class for this link plugin.', + 'type' => 'text', + 'not null' => FALSE, + ), + 'options' => array( + 'description' => 'A serialized array of options to be passed to the url() or l() function, such as a query string or HTML attributes.', + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'serialize' => TRUE, + ), + 'provider' => array( + 'description' => 'The name of the module that generated this link.', + 'type' => 'varchar', + 'length' => DRUPAL_EXTENSION_NAME_MAX_LENGTH, + 'not null' => TRUE, + 'default' => 'system', + ), + 'hidden' => array( + 'description' => 'A flag for whether the link should be rendered in menus. (1 = a disabled menu item that may be shown on admin screens, 0 = a normal, visible link)', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'small', + ), + 'discovered' => array( + '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' => array( + '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' => array( + 'description' => 'Link weight among links in the same menu at the same depth.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ), + 'metadata' => array( + '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' => array( + 'description' => 'Flag indicating whether any non-hidden links have this link as a parent (1 = children exist, 0 = no children).', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'small', + ), + 'depth' => array( + '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' => array( + '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' => array( + 'description' => 'The second mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'p3' => array( + 'description' => 'The third mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'p4' => array( + 'description' => 'The fourth mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'p5' => array( + 'description' => 'The fifth mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'p6' => array( + 'description' => 'The sixth mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'p7' => array( + 'description' => 'The seventh mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'p8' => array( + 'description' => 'The eighth mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'p9' => array( + 'description' => 'The ninth mlid in the materialized path. See p1.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + ), + 'form_class' => array( + 'description' => 'meh', + 'type' => 'varchar', + 'length' => 255, + ), + ), + 'indexes' => array( + 'menu_parents' => array( + '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' => array( + 'menu_name', 'expanded', + 'has_children', + array('parent', 16), + ), + 'route_values' => array( + array('route_name', 32), + array('route_param_key', 16), + ), + ), + 'primary key' => array('mlid'), + 'unique keys' => array( + 'id' => array('id'), + ), + ); + + return $schema; + } + +} diff --git a/core/lib/Drupal/Core/Menu/MenuTreeStorageInterface.php b/core/lib/Drupal/Core/Menu/MenuTreeStorageInterface.php new file mode 100644 index 0000000..a5d512d --- /dev/null +++ b/core/lib/Drupal/Core/Menu/MenuTreeStorageInterface.php @@ -0,0 +1,278 @@ + 1, + * 'p2' => 6, + * 'p3' => 8, + * 'p4' => 0, + * 'p5' => 0, + * 'p6' => 0, + * 'p7' => 0, + * 'p8' => 0, + * 'p9' => 0 + * ) + * @endcode + */ + public function getRootPathIds($id); + + /** + * Finds expanded links in a menu given a set of possible parents. + * + * @param string $menu_name + * The menu name. + * @param array $parents + * One or more parent IDs to match. + * + * @return array + * The menu link IDs that are flagged as expanded in this menu. + */ + public function getExpanded($menu_name, array $parents); + + /** + * Finds the height of a subtree rooted by the given ID. + * + * @param string $id + * The ID of an item in the storage. + * + * @return int + * Returns the height of the subtree. This will be at least 1 if the ID + * exists, or 0 if the ID does not exist in the storage. + */ + public function getSubtreeHeight($id); + + /** + * Determines whether a specific menu name is used in the tree. + * + * @param string $menu_name + * The menu name. + * + * @return bool + * Returns TRUE if the given menu name is used, otherwise FALSE. + */ + public function menuNameInUse($menu_name); + + /** + * Returns the used menu names in the tree storage. + * + * @return array + * The menu names. + */ + public function getMenuNames(); + + /** + * Counts the total number of menu links in one menu or all menus. + * + * @param string $menu_name + * (optional) The menu name to count by. Defaults to all menus. + * + * @return int + * The number of menu links in the named menu, or in all menus if the menu + * name is NULL. + */ + public function countMenuLinks($menu_name = NULL); + +} diff --git a/core/lib/Drupal/Core/Menu/StaticMenuLinkOverrides.php b/core/lib/Drupal/Core/Menu/StaticMenuLinkOverrides.php new file mode 100644 index 0000000..8b79bff --- /dev/null +++ b/core/lib/Drupal/Core/Menu/StaticMenuLinkOverrides.php @@ -0,0 +1,163 @@ +configFactory = $config_factory; + } + + /** + * Gets the configuration object when needed. + * + * Since this service is injected into all static menu link objects, but + * only used when updating one, avoid actually loading the config when it's + * not needed. + */ + protected function getConfig() { + if (empty($this->config)) { + $this->config = $this->configFactory->get($this->configName); + } + return $this->config; + } + + /** + * {@inheritdoc} + */ + public function reload() { + $this->config = NULL; + $this->configFactory->reset($this->configName); + } + + /** + * {@inheritdoc} + */ + public function loadOverride($id) { + $all_overrides = $this->getConfig()->get('definitions'); + $id = static::encodeId($id); + return $id && isset($all_overrides[$id]) ? $all_overrides[$id] : array(); + } + + /** + * {@inheritdoc} + */ + public function deleteMultipleOverrides(array $ids) { + $all_overrides = $this->getConfig()->get('definitions'); + $save = FALSE; + foreach ($ids as $id) { + $id = static::encodeId($id); + if (isset($all_overrides[$id])) { + unset($all_overrides[$id]); + $save = TRUE; + } + } + if ($save) { + $this->getConfig()->set('definitions', $all_overrides)->save(); + } + return $save; + } + + /** + * {@inheritdoc} + */ + public function deleteOverride($id) { + return $this->deleteMultipleOverrides(array($id)); + } + + /** + * {@inheritdoc} + */ + public function loadMultipleOverrides(array $ids) { + $result = array(); + if ($ids) { + $all_overrides = $this->getConfig()->get('definitions') ?: array(); + foreach ($ids as $id) { + $encoded_id = static::encodeId($id); + if (isset($all_overrides[$encoded_id])) { + $result[$id] = $all_overrides[$encoded_id]; + } + } + } + return $result; + } + + /** + * {@inheritdoc} + */ + public function saveOverride($id, array $definition) { + // Only allow to override a specific subset of the keys. + $expected = array( + 'menu_name' => 1, + 'parent' => 1, + 'weight' => 1, + 'expanded' => 1, + 'hidden' => 1, + ); + // Filter the overrides to only those that are expected. + $definition = array_intersect_key($definition, $expected); + if ($definition) { + $id = static::encodeId($id); + $all_overrides = $this->getConfig()->get('definitions'); + // Combine with any existing data. + $all_overrides[$id] = $definition + $this->loadOverride($id); + $this->getConfig()->set('definitions', $all_overrides)->save(); + } + return array_keys($definition); + } + + /** + * Encodes the ID by replacing dots with double underscores. + * + * This is done because config schema uses dots for its internal type + * hierarchy. Double underscores are converted to triple underscores to + * avoid accidental conflicts. + * + * @param string $id + * The menu plugin ID. + * + * @return string + * The menu plugin ID with double underscore instead of dots. + */ + protected static function encodeId($id) { + return strtr($id, array('.' => '__', '__' => '___')); + } + +} diff --git a/core/lib/Drupal/Core/Menu/StaticMenuLinkOverridesInterface.php b/core/lib/Drupal/Core/Menu/StaticMenuLinkOverridesInterface.php new file mode 100644 index 0000000..0ffb21a --- /dev/null +++ b/core/lib/Drupal/Core/Menu/StaticMenuLinkOverridesInterface.php @@ -0,0 +1,87 @@ +getDefinition('plugin.cache_clearer'); foreach ($container->getDefinitions() as $service_id => $definition) { if (strpos($service_id, 'plugin.manager.') === 0 || $definition->hasTag('plugin_manager_cache_clear')) { - $cache_clearer_definition->addMethodCall('addCachedDiscovery', array(new Reference($service_id))); + if (is_subclass_of($definition->getClass(), '\Drupal\Component\Plugin\Discovery\CachedDiscoveryInterface')) { + $cache_clearer_definition->addMethodCall('addCachedDiscovery', array(new Reference($service_id))); + } } } } diff --git a/core/modules/menu_link_content/menu_link_content.info.yml b/core/modules/menu_link_content/menu_link_content.info.yml new file mode 100644 index 0000000..5ea5cc7 --- /dev/null +++ b/core/modules/menu_link_content/menu_link_content.info.yml @@ -0,0 +1,6 @@ +name: 'Custom Menu Links' +type: module +description: 'Allows administrators to create custom menu links.' +package: Core +version: VERSION +core: 8.x diff --git a/core/modules/menu_link_content/menu_link_content.module b/core/modules/menu_link_content/menu_link_content.module new file mode 100644 index 0000000..25dfb26 --- /dev/null +++ b/core/modules/menu_link_content/menu_link_content.module @@ -0,0 +1,17 @@ +getStorage('menu_link_content'); + $menu_links = $storage->loadByProperties(array('menu_name' => $menu->id())); + $storage->delete($menu_links); +} diff --git a/core/modules/menu_link_content/src/Entity/MenuLinkContent.php b/core/modules/menu_link_content/src/Entity/MenuLinkContent.php new file mode 100644 index 0000000..c5f28db --- /dev/null +++ b/core/modules/menu_link_content/src/Entity/MenuLinkContent.php @@ -0,0 +1,385 @@ +insidePlugin = TRUE; + } + + /** + * {@inheritdoc} + */ + public function getTitle() { + return $this->get('title')->value; + } + + /** + * {@inheritdoc} + */ + public function getRouteName() { + return $this->get('route_name')->value; + } + + /** + * {@inheritdoc} + */ + public function getRouteParameters() { + return $this->get('route_parameters')->first()->getValue(); + } + + /** + * {@inheritdoc} + */ + public function setRouteParameters(array $route_parameters) { + $this->set('route_parameters', array($route_parameters)); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getUrl() { + return $this->get('url')->value ?: NULL; + } + + /** + * {@inheritdoc} + */ + public function getUrlObject() { + if ($route_name = $this->getRouteName()) { + $url = new Url($route_name, $this->getRouteParameters(), $this->getOptions()); + } + else { + $path = $this->getUrl(); + if (isset($path)) { + $url = Url::createFromPath($path); + } + else { + $url = new Url(''); + } + } + + return $url; + } + + /** + * {@inheritdoc} + */ + public function getMenuName() { + return $this->get('menu_name')->value; + } + + /** + * {@inheritdoc} + */ + public function getOptions() { + return $this->get('options')->first()->getValue(); + } + + /** + * {@inheritdoc} + */ + public function setOptions(array $options) { + $this->set('options', array($options)); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + return $this->get('description')->value; + } + + /** + * {@inheritdoc} + */ + public function getPluginId() { + return 'menu_link_content:' . $this->uuid(); + } + + /** + * {@inheritdoc} + */ + public function isHidden() { + return (bool) $this->get('hidden')->value; + } + + /** + * {@inheritdoc} + */ + public function isExpanded() { + return (bool) $this->get('expanded')->value; + } + + /** + * {@inheritdoc} + */ + public function getParentId() { + return $this->get('parent')->value; + } + + /** + * {@inheritdoc} + */ + public function getWeight() { + return (int) $this->get('weight')->value; + } + + /** + * Builds up the menu link plugin definition for this entity. + * + * @return array + * The plugin definition corresponding to this entity. + * + * @see \Drupal\Core\Menu\MenuLinkTree::$defaults + */ + protected function getPluginDefinition() { + $definition = array(); + $definition['class'] = 'Drupal\menu_link_content\Plugin\Menu\MenuLinkContent'; + $definition['menu_name'] = $this->getMenuName(); + $definition['route_name'] = $this->getRouteName(); + $definition['route_parameters'] = $this->getRouteParameters(); + $definition['url'] = $this->getUrl(); + $definition['options'] = $this->getOptions(); + $definition['title'] = $this->getTitle(); + $definition['description'] = $this->getDescription(); + $definition['weight'] = $this->getWeight(); + $definition['id'] = $this->getPluginId(); + $definition['metadata'] = array('entity_id' => $this->id()); + $definition['form_class'] = '\Drupal\menu_link_content\Form\MenuLinkContentForm'; + $definition['hidden'] = $this->isHidden() ? 1 : 0; + $definition['expanded'] = $this->isExpanded() ? 1 : 0; + $definition['provider'] = 'menu_link_content'; + $definition['discovered'] = 0; + $definition['parent'] = $this->getParentId(); + + return $definition; + } + + /** + * {@inheritdoc} + */ + public function postSave(EntityStorageInterface $storage, $update = TRUE) { + parent::postSave($storage, $update); + + /** @var \Drupal\Core\Menu\MenuLinkManagerInterface $menu_link_manager */ + $menu_link_manager = \Drupal::service('plugin.manager.menu.link'); + + // The menu link can just be updated if there is already an menu link entry + // on both entity and menu link plugin level. + if ($update && $menu_link_manager->getDefinition($this->getPluginId())) { + // When the entity is saved via a plugin instance, we should not call + // the menu tree manager to update the definition a second time. + if (!$this->insidePlugin) { + $menu_link_manager->updateDefinition($this->getPluginId(), $this->getPluginDefinition(), FALSE); + } + } + else { + $menu_link_manager->addDefinition($this->getPluginId(), $this->getPluginDefinition()); + } + } + + /** + * {@inheritdoc} + */ + public static function preDelete(EntityStorageInterface $storage, array $entities) { + parent::preDelete($storage, $entities); + + /** @var \Drupal\Core\Menu\MenuLinkManagerInterface $menu_link_manager */ + $menu_link_manager = \Drupal::service('plugin.manager.menu.link'); + + foreach ($entities as $menu_link) { + /** @var \Drupal\menu_link_content\Entity\MenuLinkContent $menu_link */ + $menu_link_manager->removeDefinition($menu_link->getPluginId(), FALSE); + } + } + + /** + * {@inheritdoc} + */ + public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { + $fields['id'] = FieldDefinition::create('integer') + ->setLabel(t('Content menu link ID')) + ->setDescription(t('The menu link ID.')) + ->setReadOnly(TRUE) + ->setSetting('unsigned', TRUE); + + $fields['uuid'] = FieldDefinition::create('uuid') + ->setLabel(t('UUID')) + ->setDescription(t('The content menu link UUID.')) + ->setReadOnly(TRUE); + + $fields['bundle'] = FieldDefinition::create('string') + ->setLabel(t('Bundle')) + ->setDescription(t('The content menu link bundle.')) + ->setSetting('max_length', EntityTypeInterface::BUNDLE_MAX_LENGTH) + ->setReadOnly(TRUE); + + $fields['title'] = FieldDefinition::create('string') + ->setLabel(t('Menu link title')) + ->setDescription(t('The text to be used for this link in the menu.')) + ->setRequired(TRUE) + ->setTranslatable(TRUE) + ->setSettings(array( + 'default_value' => '', + 'max_length' => 255, + )) + ->setDisplayOptions('view', array( + 'label' => 'hidden', + 'type' => 'string', + 'weight' => -5, + )) + ->setDisplayOptions('form', array( + 'type' => 'string', + 'weight' => -5, + )) + ->setDisplayConfigurable('form', TRUE); + + $fields['description'] = FieldDefinition::create('string') + ->setLabel(t('Description')) + ->setDescription(t('Shown when hovering over the menu link.')) + ->setTranslatable(TRUE) + ->setSettings(array( + 'default_value' => '', + 'max_length' => 255, + )) + ->setDisplayOptions('view', array( + 'label' => 'hidden', + 'type' => 'string', + 'weight' => 0, + )) + ->setDisplayOptions('form', array( + 'type' => 'string', + 'weight' => 0, + )); + + $fields['menu_name'] = FieldDefinition::create('string') + ->setLabel(t('Menu name')) + ->setDescription(t('The menu name. All links with the same menu name (such as "tools") are part of the same menu.')) + ->setSetting('default_value', 'tools'); + + // @todo Use a link field https://www.drupal.org/node/2302205. + $fields['route_name'] = FieldDefinition::create('string') + ->setLabel(t('Route name')) + ->setDescription(t('The machine name of a defined Symfony Route this menu item represents.')); + + $fields['route_parameters'] = FieldDefinition::create('map') + ->setLabel(t('Route parameters')) + ->setDescription(t('A serialized array of route parameters of this menu link.')); + + $fields['url'] = FieldDefinition::create('string') + ->setLabel(t('External link url')) + ->setDescription(t('The url of the link, in case you have an external link.')); + + $fields['options'] = FieldDefinition::create('map') + ->setLabel(t('Options')) + ->setDescription(t('A serialized array of options to be passed to the url() or l() function, such as a query string or HTML attributes.')) + ->setSetting('default_value', array()); + + $fields['external'] = FieldDefinition::create('boolean') + ->setLabel(t('External')) + ->setDescription(t('A flag to indicate if the link points to a full URL starting with a protocol, like http:// (1 = external, 0 = internal).')) + ->setSetting('default_value', 0); + + // The form widget doesn't work yet for core fields, so we skip the + // for display and manually create form elements for the boolean fields. + // @see https://drupal.org/node/2226493 + // @see https://drupal.org/node/2150511 + $fields['expanded'] = FieldDefinition::create('boolean') + ->setLabel(t('Expanded')) + ->setDescription(t('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).')) + ->setSetting('default_value', 0) + ->setDisplayOptions('view', array( + 'label' => 'hidden', + 'type' => 'boolean', + 'weight' => 0, + )); + + // We manually create a form element for this, since the form logic is + // is inverted to show enabled. + $fields['hidden'] = FieldDefinition::create('boolean') + ->setLabel(t('Hidden')) + ->setDescription(t('A flag for whether the link should be hidden in menus or rendered normally.')) + ->setSetting('default_value', 0); + + $fields['weight'] = FieldDefinition::create('integer') + ->setLabel(t('Weight')) + ->setDescription(t('Link weight among links in the same menu at the same depth.')) + ->setSetting('default_value', 0) + ->setDisplayOptions('view', array( + 'label' => 'hidden', + 'type' => 'integer', + 'weight' => 0, + )) + ->setDisplayOptions('form', array( + 'type' => 'integer', + 'weight' => 0, + )); + + $fields['langcode'] = FieldDefinition::create('language') + ->setLabel(t('Language code')) + ->setDescription(t('The node language code.')); + + $fields['parent'] = FieldDefinition::create('string') + ->setLabel(t('Parent menu link ID')) + ->setDescription(t('The parent menu link ID.')); + + return $fields; + } + +} diff --git a/core/modules/menu_link_content/src/Entity/MenuLinkContentInterface.php b/core/modules/menu_link_content/src/Entity/MenuLinkContentInterface.php new file mode 100644 index 0000000..d64bab9 --- /dev/null +++ b/core/modules/menu_link_content/src/Entity/MenuLinkContentInterface.php @@ -0,0 +1,152 @@ + 2) + * @endcode + * + * @return $this + */ + public function setRouteParameters(array $route_parameters); + + /** + * Gets the external URL. + * + * @return string|NULL + * Returns the external URL if the menu link points to an external URL, + * otherwise NULL. + */ + public function getUrl(); + + /** + * Gets the url object pointing to the URL of the menu link content entity. + * + * @return \Drupal\Core\Url + * A Url object instance. + */ + public function getUrlObject(); + + /** + * Gets the menu name of the custom menu link. + * + * @return string + * The menu ID. + */ + public function getMenuName(); + + /** + * Gets the options for the menu link content entity. + * + * @return array + * The options that may be passed to the URL generator. + */ + public function getOptions(); + + /** + * Sets the query options of the menu link content entity. + * + * @param array $options + * The new option. + * + * @return $this + */ + public function setOptions(array $options); + + /** + * Gets the description of the menu link for the UI. + * + * @return string + * The description to use on admin pages or as a title attribute. + */ + public function getDescription(); + + /** + * Gets the menu plugin ID associated with this entity. + * + * @return string + * The plugin ID. + */ + public function getPluginId(); + + /** + * Returns whether the menu link is marked as hidden. + * + * @return bool + * TRUE if is not enabled, otherwise FALSE. + */ + public function isHidden(); + + /** + * Returns whether the menu link is marked as always expanded. + * + * @return bool + * TRUE for expanded, FALSE otherwise. + */ + public function isExpanded(); + + /** + * Gets the plugin ID of the parent menu link. + * + * @return string + * A plugin ID, or empty string if this link is at the top level. + */ + public function getParentId(); + + /** + * Returns the weight of the menu link content entity. + * + * @return int + * A weight for use when ordering links. + */ + public function getWeight(); + +} diff --git a/core/modules/menu_link_content/src/MenuLinkContentAccessController.php b/core/modules/menu_link_content/src/MenuLinkContentAccessController.php new file mode 100644 index 0000000..c99109e --- /dev/null +++ b/core/modules/menu_link_content/src/MenuLinkContentAccessController.php @@ -0,0 +1,68 @@ +accessManager = $access_manager; + } + + /** + * {@inheritdoc} + */ + public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) { + return new static($entity_type, $container->get('access_manager')); + } + + /** + * {@inheritdoc} + */ + protected function checkAccess(EntityInterface $entity, $operation, $langcode, AccountInterface $account) { + switch ($operation) { + case 'view': + // There is no direct view. + return FALSE; + + case 'update': + // If there is a URL, this is an external link so always accessible. + return $account->hasPermission('administer menu') && ($entity->getUrl() || $this->accessManager->checkNamedRoute($entity->getRouteName(), $entity->getRouteParameters(), $account)); + + case 'delete': + return !$entity->isNew() && $account->hasPermission('administer menu'); + } + } + +} diff --git a/core/modules/menu_link_content/src/Plugin/Menu/MenuLinkContent.php b/core/modules/menu_link_content/src/Plugin/Menu/MenuLinkContent.php new file mode 100644 index 0000000..70fbeae --- /dev/null +++ b/core/modules/menu_link_content/src/Plugin/Menu/MenuLinkContent.php @@ -0,0 +1,249 @@ + 1, + 'parent' => 1, + 'weight' => 1, + 'expanded' => 1, + 'hidden' => 1, + 'title' => 1, + 'description' => 1, + 'route_name' => 1, + 'route_parameters' => 1, + 'url' => 1, + 'options' => 1, + ); + + /** + * The menu link content entity connected to this plugin instance. + * + * @var \Drupal\menu_link_content\Entity\MenuLinkContentInterface + */ + protected $entity; + + /** + * The entity manager. + * + * @var \Drupal\Core\Entity\EntityManagerInterface + */ + protected $entityManager; + + /** + * The language manager. + * + * @var \Drupal\Core\Language\LanguageManagerInterface + */ + protected $languageManager; + + /** + * Constructs a new MenuLinkContent. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager + * The entity manager. + * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager + * The language manager. + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityManagerInterface $entity_manager, LanguageManagerInterface $language_manager) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + + if (!empty($this->pluginDefinition['metadata']['entity_id'])) { + $entity_id = $this->pluginDefinition['metadata']['entity_id']; + // Builds a list of entity IDs to take advantage of the more efficient + // EntityStorageInterface::loadMultiple() in getEntity() at render time. + static::$entityIdsToLoad[$entity_id] = $entity_id; + } + + $this->entityManager = $entity_manager; + $this->langaugeManager = $language_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('entity.manager'), + $container->get('language_manager') + ); + } + + /** + * Loads the entity associated with this menu link. + * + * @return \Drupal\menu_link_content\Entity\MenuLinkContentInterface + * The menu link content entity. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * If the entity ID and UUID are both invalid or missing. + */ + protected function getEntity() { + if (empty($this->entity)) { + $entity = NULL; + $storage = $this->entityManager->getStorage('menu_link_content'); + if (!empty($this->pluginDefinition['metadata']['entity_id'])) { + $entity_id = $this->pluginDefinition['metadata']['entity_id']; + // Make sure the current ID is in the list, which may include multiple + // IDs added earlier in each plugin's constructor. + static::$entityIdsToLoad[$entity_id] = $entity_id; + $entities = $storage->loadMultiple(array_values(static::$entityIdsToLoad)); + $entity = isset($entities[$entity_id]) ? $entities[$entity_id] : NULL; + static::$entityIdsToLoad = array(); + } + if(!$entity) { + // Fallback to the loading by the UUID. + $uuid = $this->getDerivativeId(); + $links = $storage->loadByProperties(array('uuid' => $uuid)); + $entity = reset($links); + } + if (!$entity) { + throw new PluginException(String::format('Entity not found through the menu link plugin definition and could not fallback on UUID @uuid', array('@uuid' => $uuid))); + } + // Clone the entity object to avoid tampering with the static cache. + $this->entity = clone $entity; + $the_entity = $this->entityManager->getTranslationFromContext($this->entity); + /** @var \Drupal\menu_link_content\Entity\MenuLinkContentInterface $the_entity */ + $this->entity = $the_entity; + $this->entity->setInsidePlugin(); + } + return $this->entity; + } + + /** + * {@inheritdoc} + */ + public function getTitle() { + // We only need to get the title from the actual entity if it may be a + // translation based on the current language context. This can only happen + // if the site is configured to be multilingual. + if ($this->langaugeManager->isMultilingual()) { + return $this->getEntity()->getTitle(); + } + return $this->pluginDefinition['title']; + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + // We only need to get the description from the actual entity if it may be a + // translation based on the current language context. This can only happen + // if the site is configured to be multilingual. + if ($this->langaugeManager->isMultilingual()) { + return $this->getEntity()->getDescription(); + } + return $this->pluginDefinition['description']; + } + + /** + * {@inheritdoc} + */ + public function getDeleteRoute() { + return array( + 'route_name' => 'menu_link_content.link_delete', + 'route_parameters' => array('menu_link_content' => $this->getEntity()->id()), + ); + } + + /** + * {@inheritdoc} + */ + public function getEditRoute() { + return array( + 'route_name' => 'menu_link_content.link_edit', + 'route_parameters' => array('menu_link_content' => $this->getEntity()->id()), + ); + } + + /** + * {@inheritdoc} + */ + public function getTranslateRoute() { + $entity_type = 'menu_link_content'; + return array( + 'route_name' => 'content_translation.translation_overview_' . $entity_type, + 'route_parameters' => array( $entity_type => $this->getEntity()->id()), + ); + } + + /** + * {@inheritdoc} + */ + public function updateLink(array $new_definition_values, $persist) { + // Filter the list of updates to only those that are allowed. + $overrides = array_intersect_key($new_definition_values, $this->overrideAllowed); + // Update the definition. + $this->pluginDefinition = $overrides + $this->getPluginDefinition(); + if ($persist) { + $entity = $this->getEntity(); + foreach ($overrides as $key => $value) { + $entity->{$key}->value = $value; + } + $this->entityManager->getStorage('menu_link_content')->save($entity); + } + + return $this->pluginDefinition; + } + + /** + * {@inheritdoc} + */ + public function isDeletable() { + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function isTranslatable() { + return $this->getEntity()->isTranslatable(); + } + + /** + * {@inheritdoc} + */ + public function deleteLink() { + $this->getEntity()->delete(); + } + +} diff --git a/core/modules/system/src/Tests/Menu/MenuTreeStorageTest.php b/core/modules/system/src/Tests/Menu/MenuTreeStorageTest.php new file mode 100644 index 0000000..75b4f1e --- /dev/null +++ b/core/modules/system/src/Tests/Menu/MenuTreeStorageTest.php @@ -0,0 +1,389 @@ +treeStorage = new MenuTreeStorage($this->container->get('database'), $this->container->get('cache.menu'), 'menu_tree'); + $this->connection = $this->container->get('database'); + $this->installEntitySchema('menu_link_content'); + } + + /** + * Tests the tree storage when no tree was built yet. + */ + public function testBasicMethods() { + $this->doTestEmptyStorage(); + $this->doTestTable(); + } + + /** + * Ensures that there are no menu links by default. + */ + protected function doTestEmptyStorage() { + $this->assertEqual(0, $this->treeStorage->countMenuLinks()); + } + + /** + * Ensures that table gets created on the fly. + */ + protected function doTestTable() { + // Test that we can create a tree storage with an arbitrary table name and + // that selecting from the storage creates the table. + $tree_storage = new MenuTreeStorage($this->container->get('database'), $this->container->get('cache.menu'), 'test_menu_tree'); + $this->assertFalse($this->connection->schema()->tableExists('test_menu_tree'), 'Test table is not yet created'); + $tree_storage->countMenuLinks(); + $this->assertTrue($this->connection->schema()->tableExists('test_menu_tree'), 'Test table was created'); + } + + /** + * Tests with a simple linear hierarchy. + */ + public function testSimpleHierarchy() { + // Add some links with parent on the previous one and test some values. + // + // - test1 + // -- test2 + // --- test3 + $this->addMenuLink('test1', ''); + $this->assertMenuLink('test1', array('has_children' => 0, 'depth' => 1)); + + $this->addMenuLink('test2', 'test1'); + $this->assertMenuLink('test1', array('has_children' => 1, 'depth' => 1), array(), array('test2')); + $this->assertMenuLink('test2', array('has_children' => 0, 'depth' => 2), array('test1')); + + $this->addMenuLink('test3', 'test2'); + $this->assertMenuLink('test1', array('has_children' => 1, 'depth' => 1), array(), array('test2', 'test3')); + $this->assertMenuLink('test2', array('has_children' => 1, 'depth' => 2), array('test1'), array('test3')); + $this->assertMenuLink('test3', array('has_children' => 0, 'depth' => 3), array('test2', 'test1')); + } + + /** + * Tests the tree with moving links inside the hierarchy. + */ + public function testMenuLinkMoving() { + // Before the move. + // + // - test1 + // -- test2 + // --- test3 + // - test4 + // -- test5 + // --- test6 + + $this->addMenuLink('test1', ''); + $this->addMenuLink('test2', 'test1'); + $this->addMenuLink('test3', 'test2'); + $this->addMenuLink('test4', ''); + $this->addMenuLink('test5', 'test4'); + $this->addMenuLink('test6', 'test5'); + + $this->assertMenuLink('test1', array('has_children' => 1, 'depth' => 1), array(), array('test2', 'test3')); + $this->assertMenuLink('test2', array('has_children' => 1, 'depth' => 2), array('test1'), array('test3')); + $this->assertMenuLink('test4', array('has_children' => 1, 'depth' => 1), array(), array('test5', 'test6')); + $this->assertMenuLink('test5', array('has_children' => 1, 'depth' => 2), array('test4'), array('test6')); + $this->assertMenuLink('test6', array('has_children' => 0, 'depth' => 3), array('test5', 'test4')); + + $this->moveMenuLink('test2', 'test5'); + // After the 1st move. + // + // - test1 + // - test4 + // -- test5 + // --- test2 + // ---- test3 + // --- test6 + + $this->assertMenuLink('test1', array('has_children' => 0, 'depth' => 1)); + $this->assertMenuLink('test2', array('has_children' => 1, 'depth' => 3), array('test5', 'test4'), array('test3')); + $this->assertMenuLink('test3', array('has_children' => 0, 'depth' => 4), array('test2', 'test5', 'test4')); + $this->assertMenuLink('test4', array('has_children' => 1, 'depth' => 1), array(), array('test5', 'test2', 'test3', 'test6')); + $this->assertMenuLink('test5', array('has_children' => 1, 'depth' => 2), array('test4'), array('test2', 'test3', 'test6')); + $this->assertMenuLink('test6', array('has_children' => 0, 'depth' => 3), array('test5', 'test4')); + + $this->moveMenuLink('test4', 'test1'); + $this->moveMenuLink('test3', 'test1'); + // After the next 2 moves. + // + // - test1 + // -- test3 + // -- test4 + // --- test5 + // ---- test2 + // ---- test6 + + $this->assertMenuLink('test1', array('has_children' => 1, 'depth' => 1), array(), array('test4', 'test5', 'test2', 'test3', 'test6')); + $this->assertMenuLink('test2', array('has_children' => 0, 'depth' => 4), array('test5', 'test4', 'test1')); + $this->assertMenuLink('test3', array('has_children' => 0, 'depth' => 2), array('test1')); + $this->assertMenuLink('test4', array('has_children' => 1, 'depth' => 2), array('test1'), array('test2', 'test5', 'test6')); + $this->assertMenuLink('test5', array('has_children' => 1, 'depth' => 3), array('test4', 'test1'), array('test2', 'test6')); + $this->assertMenuLink('test6', array('has_children' => 0, 'depth' => 4), array('test5', 'test4', 'test1')); + + // Deleting a link in the middle should re-attach child links to the parent. + $this->treeStorage->delete('test4'); + // After the delete. + // + // - test1 + // -- test3 + // -- test5 + // --- test2 + // --- test6 + $this->assertMenuLink('test1', array('has_children' => 1, 'depth' => 1), array(), array('test5', 'test2', 'test3', 'test6')); + $this->assertMenuLink('test2', array('has_children' => 0, 'depth' => 3), array('test5', 'test1')); + $this->assertMenuLink('test3', array('has_children' => 0, 'depth' => 2), array('test1')); + $this->assertFalse($this->treeStorage->load('test4')); + $this->assertMenuLink('test5', array('has_children' => 1, 'depth' => 2), array('test1'), array('test2', 'test6')); + $this->assertMenuLink('test6', array('has_children' => 0, 'depth' => 3), array('test5', 'test1')); + } + + /** + * Tests with hidden child links. + */ + public function testMenuHiddenChildLinks() { + // Add some links with parent on the previous one and test some values. + // + // - test1 + // -- test2 (hidden) + + $this->addMenuLink('test1', ''); + $this->assertMenuLink('test1', array('has_children' => 0, 'depth' => 1)); + + $this->addMenuLink('test2', 'test1', '', array(), 'tools', array('hidden' => 1)); + // The 1st link does not have any visible children, so has_children is 0. + $this->assertMenuLink('test1', array('has_children' => 0, 'depth' => 1)); + $this->assertMenuLink('test2', array('has_children' => 0, 'depth' => 2, 'hidden' => 1), array('test1')); + + // Add more links with parent on the previous one. + //