association-1.0.0-alpha2/modules/association_menu/src/AssociationMenuStorage.php
modules/association_menu/src/AssociationMenuStorage.php
<?php namespace Drupal\association_menu; use Drupal\association\Entity\AssociatedEntityInterface; use Drupal\association\Entity\AssociationInterface; use Drupal\association_menu\Event\AssociationMenuEvents; use Drupal\association_menu\Event\MenuLinksAlterEvent; use Drupal\association_menu\Event\MenuLinksLoadEvent; use Drupal\Component\Serialization\SerializationInterface; use Drupal\Core\Access\AccessManagerInterface; use Drupal\Core\Cache\Cache; use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Database\Connection; use Drupal\Core\Database\DatabaseException; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Logger\LoggerChannelTrait; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Utility\Error; use Symfony\Component\EventDispatcher\EventDispatcherInterface; /** * The association storage for managing the menu link items. */ class AssociationMenuStorage implements AssociationMenuStorageInterface { use LoggerChannelTrait; /** * An array of cached navigation data, already sorted and access checked. * * @var \Drupal\association_menu\MenuItemInterface[] */ protected $menus = []; /** * Database connection where the association navigation data is stored. * * @var \Drupal\Core\Database\Connection */ protected $db; /** * The entity type manager. * * @var \Drupal\Core\Entity\EntityTypeManagerInterface */ protected $entityTypeManager; /** * The access manager. * * @var \Drupal\Core\Access\AccessManagerInterface */ protected $accessManager; /** * The event dispatcher. * * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface */ protected $eventDispatcher; /** * The JSON data serializer. * * @var \Drupal\Component\Serialization\SerializationInterface */ protected $json; /** * The association menu cache backend. * * @var \Drupal\Core\Cache\CacheBackendInterface */ protected $cacheBackend; /** * The account to use for access checks by default. * * @var \Drupal\Core\Session\AccountInterface */ protected $account; /** * Create a new instance of the AssociationMenuStorage manager class. * * @param \Drupal\Core\Database\Connection $database * The database connection to use for storage and retrieval of menu items. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. * @param \Drupal\Core\Access\AccessManagerInterface $access_manager * The access manager. * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher * The event dispatcher. * @param \Drupal\Component\Serialization\SerializationInterface $json * The database storage JSON serializer for array data. * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend * The association menu cache backend. * @param \Drupal\Core\Session\AccountInterface $account * The account to use for access checks by default. */ public function __construct(Connection $database, EntityTypeManagerInterface $entity_type_manager, AccessManagerInterface $access_manager, EventDispatcherInterface $event_dispatcher, SerializationInterface $json, CacheBackendInterface $cache_backend, AccountInterface $account) { $this->db = $database; $this->entityTypeManager = $entity_type_manager; $this->accessManager = $access_manager; $this->eventDispatcher = $event_dispatcher; $this->json = $json; $this->cacheBackend = $cache_backend; $this->account = $account; } /** * {@inheritdoc} */ public function getCacheTags(AssociationInterface $association): array { return [ 'association:menu:' . $association->id(), ]; } /** * Get the cache key identifier to use for caching the menu data. * * @return string * A cache key identifier for this menu storage data. */ protected function getCacheId($association): string { $assocId = $association instanceof AssociationInterface ? $association->id() : $association; return 'association_menu:' . $assocId; } /** * {@inheritdoc} */ public function clearCache($assocId = NULL): void { if (isset($assocId)) { $cid = $this->getCacheId($assocId); $this->cacheBackend->delete($cid); unset($this->menus[$assocId]); } else { $this->menus = []; $this->cacheBackend->invalidateAll(); } } /** * Ensure the data unserialized and loaded into a MenuItemInterface instance. * * @param array $values * The raw menu item data (usually from DB), to load and clean up. These * values are loaded into the appropriate menu item instance type. * * @return \Drupal\association_menu\MenuItemInterface|null * Returns a loaded menu item object from the menu values. */ protected function createItem(array $values): ?MenuItemInterface { if (!empty($values['title'])) { $values['title'] = unserialize($values['title'], [ 'allowed_classes' => [ '\Drupal\Component\Render\FormattableMarkup', '\Drupal\Core\StringTranslation\TranslatableMarkup', ], ]); } $values['options'] = !empty($values['options']) ? $this->json->decode($values['options']) : []; if (!empty($values['entity'])) { [$entityType, $entityId] = explode(':', $values['entity'], 2); if ($entityType && $entityId) { $entity = $this->entityTypeManager ->getStorage($entityType) ->load($entityId); if ($entity) { return new AssociatedEntityMenuItem($values, $entity); } } } elseif (!empty($values['route'])) { $route = $this->json->decode($values['route']); if (!empty($route['route_name'])) { return new RoutedMenuItem($values, $route['route_name'], $route['route_parameters']); } } elseif (!empty($values['uri'])) { return new UriMenuItem($values, $values['uri']); } return NULL; } /** * {@inheritdoc} */ public function getMenu(AssociationInterface $association, AccountInterface $account = NULL): array { $assocId = $association->id(); $account = $account ?? $this->account; // Fetch and build menu items if they have not already been retrieved // recently for the requested user. $cid = $this->getCacheId($association); $cache = new CacheableMetadata(); if ($cached = $this->cacheBackend->get($cid)) { $data = $cached->data; $menuItems = $data['items']; $cache->setCacheTags($data['cache']['tags'] ?? []); $cache->setCacheContexts($data['cache']['contexts'] ?? []); } else { $cache->addCacheTags($this->getCacheTags($association)); $filters = ['enabled' => static::ITEM_ENABLED]; $menuItems = $this->getMenuItems($association, $filters, TRUE); // Apply cache metadata from menu items with associated entities. foreach ($menuItems as $item) { if ($item instanceof AssociatedEntityMenuItem) { $entity = $item->getEntity(); $cache->addCacheableDependency($entity); } } $loadEvent = new MenuLinksLoadEvent($menuItems, $association, $cache); // Per discussion https://github.com/phpstan/phpstan-symfony/issue/59 // @phpstan-ignore-next-line $this->eventDispatcher->dispatch($loadEvent, AssociationMenuEvents::MENU_LINKS_LOAD); // Cache the menu items and cache data into menu item storage. $cacheData = []; $cacheData['items'] = $menuItems; $cacheData['cache'] = [ 'tags' => $cache->getCacheTags(), 'contexts' => $cache->getCacheContexts(), ]; $this->cacheBackend->set($cid, $cacheData, Cache::PERMANENT, $cache->getCacheTags()); } // Allow altering of the menu links, this allows for conditional items // to be added, as these changes are not cached. $alterEvent = new MenuLinksAlterEvent($menuItems, $association, $account, $cache); // Per discussion https://github.com/phpstan/phpstan-symfony/issue/59 // @phpstan-ignore-next-line $this->eventDispatcher->dispatch($alterEvent, AssociationMenuEvents::MENU_LINKS_ALTER); foreach ($menuItems as $menuId => $item) { try { $url = $item->getUrl(); if ($url && $url->isRouted()) { $routeName = $url->getRouteName(); $routeParams = $url->getRouteParameters(); $access = $this->accessManager->checkNamedRoute($routeName, $routeParams, $account, TRUE); // Capture the menu link access, and any caching data that goes with // that access resolution. We still need this menu item even if it // is not accessible, for menu tree building. $item->setAccess($access->isAllowed()); $cache->addCacheableDependency($access); } else { $item->setAccess(TRUE); } } catch (\Exception $e) { unset($menuItems[$menuId]); Error::logException($this->getLogger('association_menu'), $e); } } return [ 'id' => $assocId, 'cache' => $cache, 'items' => $menuItems, ]; } /** * {@inheritdoc} */ public function deleteMenu(AssociationInterface $association): void { $assocId = $association->id(); $this->db ->delete(static::TABLE_NAME) ->condition('association', $assocId) ->execute(); $this->clearCache($assocId); Cache::invalidateTags($this->getCacheTags($association)); } /** * {@inheritdoc} */ public function getMenuItem(AssociationInterface $association, $menu_item_id): MenuItemInterface { $values = $this->db ->select(static::TABLE_NAME, 'nav') ->fields('nav') ->condition('id', $menu_item_id) ->condition('association', $association->id()) ->execute() ->fetchAssoc(); if ($values) { return $this->createItem($values); } // Report that the requested menu item isn't available. $msg = sprintf('Unable to find menu item ID: %s for Association ID: %s', $menu_item_id, $association->id()); throw new \InvalidArgumentException($msg); } /** * {@inheritdoc} */ public function getMenuItems(AssociationInterface $association, array $filters = [], $flatten = FALSE): array { $menuItems = []; try { $query = $this->db ->select(static::TABLE_NAME, 'nav') ->fields('nav') ->condition('association', $association->id()) ->orderBy('depth', 'ASC') ->orderBy('parent', 'ASC') ->orderBy('weight', 'ASC'); // Apply any additional filters as required. foreach ($filters as $field => $value) { $query->condition($field, $value); } $rs = $query->execute(); $rs->setFetchMode(\PDO::FETCH_ASSOC); foreach ($rs as $values) { if ($item = $this->createItem($values)) { $menuItems[$item->id()] = $item; } } } catch (DatabaseException $e) { // Issue with fetching items from the database. } // If $flatten is FALSE, build the menu into a tree structure. if (!$flatten) { $tree = []; foreach ($menuItems as $id => $item) { $parent = $item->getParentId(); if ($parent && !empty($menuItems[$parent])) { $menuItems[$parent]->getChildren()[$id] = $item; } else { $tree[$id] = $item; } } return $tree; } return $menuItems; } /** * {@inheritdoc} */ public function saveMenuItem(AssociationInterface $association, array $values): void { $assocId = $association->id(); $fields = [ 'title' => !empty($values['title']) ? serialize($values['title']) : NULL, 'options' => $this->json->encode($values['options'] ?? []), ]; if (isset($values['enabled'])) { $fields['enabled'] = (bool) $values['enabled']; } if (isset($values['expanded'])) { $fields['expanded'] = (bool) $values['expanded']; } if (empty($values['entity'])) { if (!empty($values['route']['route_name'])) { $values['route'] += ['route_parameters' => []]; $fields['route'] = $this->json->encode($values['route']); $fields['uri'] = NULL; } elseif (!empty($values['uri'])) { $fields['uri'] = (string) $values['uri']; $fields['route'] = NULL; } else { // If not valid route or URI data is available, then this menu link // is invalid. A valid route "<nolink>" should be used if the menu item // intentionally has no URL link value. throw new \InvalidArgumentException('Menu item is missing URL data and cannot be saved.'); } } if (empty($values['id'])) { $fields['association'] = $assocId; $this->db ->insert(static::TABLE_NAME) ->fields($fields) ->execute(); } else { $this->db ->update(static::TABLE_NAME) ->fields($fields) ->condition('association', $assocId) ->condition('id', $values['id']) ->execute(); } $this->clearCache($assocId); Cache::invalidateTags($this->getCacheTags($association)); } /** * {@inheritdoc} */ public function deleteMenuItem(AssociationInterface $association, $menu_item_id): bool { $assocId = $association->id(); // Only delete if the association owns the menu item requested, and the // link does not have entity data (is associated entity link). $successful = (bool) $this->db ->delete(static::TABLE_NAME) ->condition('association', $assocId) ->condition('id', $menu_item_id) ->isNull('entity') ->execute(); if ($successful) { $this->clearCache($assocId); Cache::invalidateTags($this->getCacheTags($association)); } return $successful; } /** * {@inheritdoc} */ public function updateMenuTree(AssociationInterface $association, array $menu_items): void { $assocId = $association->id(); // Only allows the update of the menu tree related data. This prevents // accidental data from altering the URL or URL options unintentionally. // Use static::saveMenuItem() to save link and attributes. $allowedFields = [ 'parent' => 0, 'enabled' => TRUE, 'expanded' => TRUE, 'depth' => TRUE, 'weight' => 0, ]; foreach ($menu_items as $item) { $fields = array_intersect_key($item, $allowedFields); $this->db ->update(static::TABLE_NAME) ->fields($fields) ->condition('id', $item['id']) ->condition('association', $assocId) ->execute(); } $this->clearCache($assocId); Cache::invalidateTags($this->getCacheTags($association)); } /** * {@inheritdoc} */ public function addAssociated(AssociatedEntityInterface $entity, array $options = []): void { if ($association = $entity->getAssociation()) { $assocId = $association->id(); $type = $entity->getEntityTypeId(); // Only insert if not already included as a menu item. $this->db ->merge(static::TABLE_NAME) ->keys([ 'association' => $assocId, 'entity' => $type . ':' . $entity->id(), ]) ->insertFields([ 'association' => $assocId, 'entity' => $type . ':' . $entity->id(), 'enabled' => static::ITEM_ENABLED, 'options' => $this->json->encode($options), ]) ->execute(); $this->clearCache($assocId); Cache::invalidateTags($this->getCacheTags($association)); } } /** * {@inheritdoc} */ public function removeAssociated(AssociatedEntityInterface $entity): void { $this->db ->delete(static::TABLE_NAME) ->condition('entity', $entity->getEntityTypeId() . ':' . $entity->id()) ->execute(); if ($association = $entity->getAssociation()) { $this->clearCache($association->id()); Cache::invalidateTags($this->getCacheTags($association)); } } }