wse-1.0.x-dev/modules/wse_menu/src/MemoryMenuTreeStorage.php

modules/wse_menu/src/MemoryMenuTreeStorage.php
<?php

namespace Drupal\wse_menu;

use Drupal\Component\Graph\Graph;
use Drupal\Component\Plugin\Exception\PluginException;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Menu\MenuTreeParameters;
use Drupal\Core\Menu\MenuTreeStorageInterface;
use Drupal\workspaces\WorkspaceManagerInterface;

/**
 * Provides a menu tree storage using a content entity.
 *
 * This tree storage implementation was initially designed to handle menu tree
 * definitions stored in a content entity, but it can be repurposed into a more
 * generic "memory-only" menu tree storage.
 */
class MemoryMenuTreeStorage implements MenuTreeStorageInterface {

  /**
   * The maximum depth of a menu links tree.
   */
  const MAX_DEPTH = 9;

  /**
   * The menu tree entity that stores the menu link definitions.
   *
   * @var \Drupal\wse_menu\WseMenuTreeInterface
   */
  protected $menuTreeEntity;

  /**
   * Stores definitions that have already been loaded for better performance.
   *
   * An array of plugin definition arrays, keyed by plugin ID.
   *
   * @var array
   */
  protected $definitions = [];

  /**
   * List of plugin definition fields.
   *
   * @var array
   *
   * @todo Decide how to keep these field definitions in sync.
   *   https://www.drupal.org/node/2302085
   *
   * @see \Drupal\Core\Menu\MenuLinkManager::$defaults
   */
  protected $definitionFields = [
    'menu_name',
    'route_name',
    'route_parameters',
    'route_param_key',
    'url',
    'title',
    'description',
    'parent',
    'weight',
    'options',
    'expanded',
    'enabled',
    'provider',
    'metadata',
    'class',
    'form_class',
    'id',
  ];

  public function __construct(
    protected MenuTreeStorageInterface $inner,
    protected WorkspaceManagerInterface $workspaceManager,
    protected EntityTypeManagerInterface $entityTypeManager,
  ) {}

  /**
   * {@inheritdoc}
   */
  public function maxDepth() {
    return static::MAX_DEPTH;
  }

  /**
   * {@inheritdoc}
   */
  public function resetDefinitions() {
    if (!$this->applies()) {
      return $this->inner->resetDefinitions();
    }

    $this->definitions = $this->getMenuTreeEntity()->getDefinitions();
    $this->rebuildTree();
  }

  /**
   * {@inheritdoc}
   */
  public function rebuild(array $definitions) {
    if (!$this->applies()) {
      return $this->inner->rebuild($definitions);
    }

    foreach ($definitions as $definition) {
      // Flag this link as discovered, i.e. saved via rebuild().
      $definition['discovered'] = 1;

      // Force orphan links to be top-level.
      if (!empty($definition['parent']) && !isset($definitions[$definition['parent']])) {
        $definition['parent'] = '';
      }
    }

    // Filter out discovered definitions that no longer exist.
    $no_longer_existing_definitions = array_filter($this->definitions, function (array $definition) use ($definitions) {
      return !empty($definition['discovered']) && !isset($definitions[$definition['id']]);
    });
    $this->definitions = array_diff_key($this->definitions, $no_longer_existing_definitions);

    // Append the definitions stored in the menu tree entity in order to keep
    // custom link definitions, for example those provided by the
    // 'menu_link_content' entity type.
    $this->definitions = $definitions + $this->definitions;
    $this->getMenuTreeEntity()->setDefinitions($definitions)->save();
    $this->rebuildTree();
  }

  /**
   * {@inheritdoc}
   */
  public function load($id) {
    if (!$this->applies()) {
      return $this->inner->load($id);
    }

    return $this->definitions[$id] ?? FALSE;
  }

  /**
   * {@inheritdoc}
   */
  public function loadMultiple(array $ids) {
    if (!$this->applies()) {
      return $this->inner->loadMultiple($ids);
    }

    return $ids ? array_intersect_key($this->definitions, array_flip($ids)) : $this->definitions;
  }

  /**
   * {@inheritdoc}
   */
  public function loadByProperties(array $properties, bool $allow_all_properties = FALSE) {
    if (!$this->applies()) {
      return $this->inner->loadByProperties($properties);
    }

    if (!$allow_all_properties && $diff = array_diff(array_keys($properties), $this->definitionFields)) {
      $name = reset($diff);
      $fields = implode(', ', $this->definitionFields);
      throw new \InvalidArgumentException("An invalid property name, $name was specified. Allowed property names are: $fields.");
    }

    $parameters = new MenuTreeParameters();
    foreach ($properties as $name => $value) {
      $parameters->addCondition($name, $value);
    }

    return $this->loadLinks(NULL, $parameters);
  }

  /**
   * {@inheritdoc}
   */
  public function loadByRoute($route_name, array $route_parameters = [], $menu_name = NULL) {
    if (!$this->applies()) {
      return $this->inner->loadByRoute($route_name, $route_parameters, $menu_name);
    }

    $properties = array_filter([
      'route_name' => $route_name,
      'route_parameters' => $route_parameters,
      'menu_name' => $menu_name,
    ]);

    $filtered = $this->loadByProperties($properties);

    return $filtered;
  }

  /**
   * {@inheritdoc}
   */
  public function save(array $definition) {
    if (!$this->applies()) {
      return $this->inner->save($definition);
    }

    // Gather the affected menu names.
    $affected_menus[$definition['menu_name']] = $definition['menu_name'];

    // The link might have moved to a different menu.
    if ($definition['parent'] && isset($this->definitions[$definition['parent']])) {
      $parent = $this->definitions[$definition['parent']];
      $definition['menu_name'] = $this->definitions[$definition['parent']]['menu_name'];
      $affected_menus[$definition['menu_name']] = $definition['menu_name'];
    }

    // If the definition has been re-parented, ensure that the new tree doesn't
    // exceed the maximum depth.
    if (isset($this->definitions[$definition['id']])) {
      $limit = $this->maxDepth() - $this->doFindChildrenRelativeDepth($this->definitions[$definition['id']]) - 1;
    }
    else {
      $limit = $this->maxDepth() - 1;
    }

    if (isset($parent) && $parent['depth'] > $limit) {
      throw new PluginException("The link with ID {$definition['id']} or its children exceeded the maximum depth of {$this->maxDepth()}");
    }

    // If the maximum depth of the tree is not exceed, we can save the new tree.
    $this->definitions[$definition['id']] = $definition;
    $this->getMenuTreeEntity()->setDefinitions($this->definitions)->save();
    $this->rebuildTree();

    return $affected_menus;
  }

  /**
   * {@inheritdoc}
   */
  public function delete($id) {
    if (!$this->applies()) {
      return $this->inner->delete($id);
    }

    if (isset($this->definitions[$id])) {
      // Re-parent all possible children.
      $definition = $this->definitions[$id];
      if ($children = $this->loadByProperties(['parent' => $id])) {
        foreach (array_keys($children) as $child_id) {
          $this->definitions[$child_id]['parent'] = $definition['parent'];
        }
      }

      // Remove the definition and rebuild the tree.
      unset($this->definitions[$id]);
      $this->getMenuTreeEntity()->setDefinitions($this->definitions)->save();
      $this->rebuildTree();
    }
  }

  /**
   * {@inheritdoc}
   */
  public function loadTreeData($menu_name, MenuTreeParameters $parameters) {
    if (!$this->applies()) {
      return $this->inner->loadTreeData($menu_name, $parameters);
    }

    $links = $this->loadLinks($menu_name, $parameters);
    $data['tree'] = $this->treeDataRecursive(array_keys($links), $parameters->activeTrail);
    $data['route_names'] = array_unique(array_filter(array_column($links, 'route_name')));

    return $data;
  }

  /**
   * Builds the data representing a menu tree.
   *
   * @param array $link_ids
   *   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 $active_trail
   *   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 array $child_ids
   *   A flat array of child menu links.
   * @param array $visited
   *   An array containing link IDs that were already placed in the tree.
   *
   * @return array
   *   The fully built tree.
   */
  protected function treeDataRecursive(array $link_ids, array $active_trail, array $child_ids = [], array &$visited = []): array {
    $tree = [];

    $item_ids = $child_ids ?: $link_ids;
    foreach ($item_ids as $id) {
      if (isset($visited[$id])) {
        continue;
      }

      $tree[$id] = [
        'definition' => array_intersect_key($this->definitions[$id], array_flip($this->definitionFields)),
        'has_children' => $this->definitions[$id]['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($id, $active_trail, TRUE),
        'subtree' => [],
        'depth' => $this->definitions[$id]['depth'],
      ];

      if ($this->definitions[$id]['has_children']) {
        $child_ids = array_keys(array_filter($this->definitions, function ($child) use ($id) {
          return $child['parent'] == $id;
        }));
        $tree[$id]['subtree'] = $this->treeDataRecursive($link_ids, $active_trail, $child_ids, $visited);
      }

      $visited[$id] = TRUE;
    }

    return $tree;
  }

  /**
   * {@inheritdoc}
   */
  public function loadAllChildren($id, $max_relative_depth = NULL) {
    if (!$this->applies()) {
      return $this->inner->loadAllChildren($id, $max_relative_depth);
    }

    $parameters = new MenuTreeParameters();
    $parameters->setRoot($id)->excludeRoot()->setMaxDepth($max_relative_depth)->onlyEnabledLinks();
    return $this->loadLinks(NULL, $parameters);
  }

  /**
   * {@inheritdoc}
   */
  public function getAllChildIds($id) {
    if (!$this->applies()) {
      return $this->inner->getAllChildIds($id);
    }

    return $this->definitions[$id]['descendants'] ?? [];
  }

  /**
   * {@inheritdoc}
   */
  public function loadSubtreeData($id, $max_relative_depth = NULL) {
    if (!$this->applies()) {
      return $this->inner->loadSubtreeData($id, $max_relative_depth);
    }

    if (!isset($this->definitions[$id])) {
      return [];
    }

    $parameters = new MenuTreeParameters();
    $parameters->setRoot($id)->onlyEnabledLinks();
    return $this->loadTreeData($this->definitions[$id]['menu_name'], $parameters);
  }

  /**
   * {@inheritdoc}
   */
  public function getRootPathIds($id) {
    if (!$this->applies()) {
      return $this->inner->getRootPathIds($id);
    }

    if (isset($this->definitions[$id])) {
      return array_merge([$id], $this->definitions[$id]['ancestors']);
    }

    return [];
  }

  /**
   * {@inheritdoc}
   */
  public function getExpanded($menu_name, array $parents) {
    if (!$this->applies()) {
      return $this->inner->getExpanded($menu_name, $parents);
    }

    $parameters = new MenuTreeParameters();
    $parameters->addCondition('expanded', 1);
    $parameters->addCondition('has_children', 1);
    $parameters->addCondition('enabled', 1);
    $parameters->addCondition('parent', $parents, 'IN');
    $parameters->addCondition('id', $parents, 'NOT IN');

    return array_keys($this->loadLinks($menu_name, $parameters));
  }

  /**
   * {@inheritdoc}
   */
  public function getSubtreeHeight($id) {
    if (!$this->applies()) {
      return $this->inner->getSubtreeHeight($id);
    }

    return isset($this->definitions[$id]) ? $this->doFindChildrenRelativeDepth($this->definitions[$id]) + 1 : 0;
  }

  /**
   * Finds the relative depth of this link's deepest child.
   *
   * @param array $definition
   *   The parent definition used to find the depth.
   *
   * @return int
   *   Returns the relative depth.
   */
  protected function doFindChildrenRelativeDepth(array $definition) {
    // Find the maximum depth of the link's descendants.
    if (isset($this->definitions[$definition['id']]) && !empty($this->definitions[$definition['id']]['descendants'])) {
      $descendants = $this->loadMultiple($this->definitions[$definition['id']]['descendants']);
      $max_depth = max(array_column($descendants, 'depth'));
    }

    return isset($max_depth) ? $max_depth - $definition['depth'] : 0;
  }

  /**
   * {@inheritdoc}
   */
  public function menuNameInUse($menu_name) {
    if (!$this->applies()) {
      return $this->inner->menuNameInUse($menu_name);
    }

    return in_array($menu_name, $this->getMenuNames(), TRUE);
  }

  /**
   * {@inheritdoc}
   */
  public function getMenuNames() {
    if (!$this->applies()) {
      return $this->inner->getMenuNames();
    }

    return array_unique(array_column($this->definitions, 'menu_name'));
  }

  /**
   * {@inheritdoc}
   */
  public function countMenuLinks($menu_name = NULL) {
    if (!$this->applies()) {
      return $this->inner->countMenuLinks($menu_name);
    }

    if ($menu_name) {
      return count($this->loadByProperties(['menu_name' => $menu_name]));
    }

    return count($this->definitions);
  }

  /**
   * Determines whether this storage should be used.
   *
   * @return bool
   *   TRUE if there is an active workspace, FALSE otherwise.
   */
  protected function applies(): bool {
    if ($this->workspaceManager->hasActiveWorkspace()) {
      // Lazy load the entire definitions array in memory.
      if (!$this->definitions) {
        if ($definitions = $this->getMenuTreeEntity()->getDefinitions()) {
          $this->definitions = $definitions;
        }
        else {
          // If there's no overridden data for this workspace, we simply load
          // all the definitions provided by the decorated service.
          $this->definitions = $this->inner->loadByProperties([]);
        }
        $this->rebuildTree();
      }

      return TRUE;
    }

    return FALSE;
  }

  /**
   * Gets the wse_menu_tree entity which stores per-workspace menu trees.
   *
   * @return \Drupal\wse_menu\WseMenuTreeInterface
   *   The menu tree entity that stores the menu link definitions.
   */
  protected function getMenuTreeEntity() {
    if (!$this->menuTreeEntity) {
      $storage = $this->entityTypeManager->getStorage('wse_menu_tree');
      $this->menuTreeEntity = $storage->load(1);

      if (!$this->menuTreeEntity) {
        // Create the wse_menu_tree entity if it doesn't exist.
        $this->menuTreeEntity = $this->workspaceManager->executeOutsideWorkspace(function () use ($storage) {
          $entity = $storage->create(['id' => 1]);
          $entity->save();

          return $entity;
        });
      }
    }

    return $this->menuTreeEntity;
  }

  /**
   * Adds useful extra properties for all link definitions, and sorts the tree.
   */
  protected function rebuildTree() {
    $graph = [];
    foreach ($this->definitions as $definition_id => $definition) {
      $graph[$definition_id]['edges'] = [];
      if ($definition['parent']) {
        $graph[$definition_id]['edges'][$definition['parent']] = TRUE;

        // 'has_children' is really just a rendering hint, it conveys whether a
        // link has any 'visible' children.
        if ($definition['enabled']) {
          $has_visible_children[$definition['parent']] = TRUE;
        }
      }
    }
    $graph = (new Graph($graph))->searchAndSort();

    foreach ($this->definitions as $definition_id => &$definition) {
      // The menu system expects roots to have a depth of 1.
      $definition['depth'] = count($graph[$definition_id]['paths']) + 1;
      $definition['ancestors'] = array_keys($graph[$definition_id]['paths']);
      $definition['descendants'] = isset($graph[$definition_id]['reverse_paths']) ? array_keys($graph[$definition_id]['reverse_paths']) : [];
      $definition['has_children'] = $has_visible_children[$definition_id] ?? FALSE;
    }

    // Sort the definitions by menu_name, depth and weight, so they're
    // consistently ordered at all times.
    $menu_name = array_column($this->definitions, 'menu_name');
    $depth = array_column($this->definitions, 'depth');
    $weight = array_column($this->definitions, 'weight');
    array_multisort($menu_name, SORT_ASC, $depth, SORT_ASC, $weight, SORT_ASC, $this->definitions);
  }

  /**
   * 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) {
    $conditions = [];

    // 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 !== '') {
      // If the custom root does not exist, we cannot load the links below it.
      if (!isset($this->definitions[$parameters->root])) {
        return [];
      }

      $root = $this->definitions[$parameters->root];

      // 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.
      $conditions[] = ['id', array_merge([$parameters->root], $root['descendants']), 'IN'];

      // 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;
      }
    }

    if ($menu_name) {
      $conditions[] = ['menu_name', $menu_name];
    }

    if (!empty($parameters->expandedParents)) {
      $conditions[] = ['parent', $parameters->expandedParents, 'IN'];
    }
    if (isset($parameters->minDepth) && $parameters->minDepth > 1) {
      $conditions[] = ['depth', $parameters->minDepth, '>='];
    }
    if (isset($parameters->maxDepth)) {
      $conditions[] = ['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 ($column === 'route_parameters') {
          // Sort the route parameters so the query string will be the same.
          asort($value);
          $operator = '=';
        }
        elseif (is_array($value)) {
          $operator = $value[1];
          $value = $value[0];
        }
        else {
          $operator = '=';
        }
        $conditions[] = [$column, $value, $operator];
      }
    }

    $filtered = array_filter($this->definitions, function (array $definition) use ($conditions) {
      foreach ($conditions as $condition) {
        $column = $condition[0];
        $value = $condition[1];
        $operator = $condition[2] ?? (is_array($value) ? 'IN' : '=');

        // Process the value for the operator that uses it.
        if (!in_array($operator, ['IS NULL', 'IS NOT NULL'], TRUE)) {
          // Lowercase condition value(s) for case-insensitive matches.
          if (is_array($value)) {
            $value = array_map('mb_strtolower', $value);
          }
          elseif (!is_bool($value)) {
            $value = mb_strtolower($value);
          }
        }

        // If any condition fails, filter out this link definition.
        if (!$this->match(['value' => $value, 'operator' => $operator], $definition[$column])) {
          return FALSE;
        }
      }

      // If all conditions matched, include this definition.
      return TRUE;
    });

    return $filtered;
  }

  /**
   * Matches a condition against a value.
   *
   * Lifted from \Drupal\Core\Config\Entity\Query\Condition::match().
   *
   * @param array $condition
   *   An array containing the condition operator and the value.
   * @param string|null $value
   *   The value to match against.
   *
   * @return bool
   *   TRUE when it matches, FALSE otherwise.
   */
  protected function match(array $condition, ?string $value = NULL): bool {
    // "IS NULL" and "IS NOT NULL" conditions can also deal with array values,
    // so we return early for them to avoid problems.
    if (in_array($condition['operator'], ['IS NULL', 'IS NOT NULL'], TRUE)) {
      $should_be_set = $condition['operator'] === 'IS NOT NULL';
      return $should_be_set === isset($value);
    }

    if (isset($value)) {
      // We always want a case-insensitive match.
      if (!is_bool($value) && !is_array($value)) {
        $value = mb_strtolower($value);
      }

      switch ($condition['operator']) {
        case '=':
          return $value == $condition['value'];

        case '>':
          return $value > $condition['value'];

        case '<':
          return $value < $condition['value'];

        case '>=':
          return $value >= $condition['value'];

        case '<=':
          return $value <= $condition['value'];

        case '<>':
          return $value != $condition['value'];

        case 'IN':
          return array_search($value, $condition['value']) !== FALSE;

        case 'NOT IN':
          return array_search($value, $condition['value']) === FALSE;

        case 'STARTS_WITH':
          return strpos($value, $condition['value']) === 0;

        case 'CONTAINS':
          return strpos($value, $condition['value']) !== FALSE;

        case 'ENDS_WITH':
          return substr($value, -strlen($condition['value'])) === (string) $condition['value'];

        default:
          throw new \InvalidArgumentException('Invalid condition operator.');
      }
    }
    return FALSE;
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc