RouteProvider.php 13 KB
Newer Older
1 2 3 4
<?php

namespace Drupal\Core\Routing;

5
use Drupal\Core\Cache\Cache;
6
use Drupal\Core\Cache\CacheBackendInterface;
7
use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
8
use Drupal\Core\Path\CurrentPathStack;
9
use Drupal\Core\PathProcessor\InboundPathProcessorInterface;
10
use Drupal\Core\State\StateInterface;
11 12
use Symfony\Cmf\Component\Routing\PagedRouteCollection;
use Symfony\Cmf\Component\Routing\PagedRouteProviderInterface;
13
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
14
use Symfony\Component\HttpFoundation\Request;
15
use Symfony\Component\Routing\Exception\RouteNotFoundException;
16 17 18 19
use Symfony\Component\Routing\RouteCollection;
use \Drupal\Core\Database\Connection;

/**
Crell's avatar
Crell committed
20
 * A Route Provider front-end for all Drupal-stored routes.
21
 */
22
class RouteProvider implements PreloadableRouteProviderInterface, PagedRouteProviderInterface, EventSubscriberInterface {
23 24 25 26

  /**
   * The database connection from which to read route information.
   *
27
   * @var \Drupal\Core\Database\Connection
28 29 30 31 32 33 34 35 36 37
   */
  protected $connection;

  /**
   * The name of the SQL table from which to read the routes.
   *
   * @var string
   */
  protected $tableName;

38 39 40
  /**
   * The state.
   *
41
   * @var \Drupal\Core\State\StateInterface
42 43 44
   */
  protected $state;

45 46 47
  /**
   * A cache of already-loaded routes, keyed by route name.
   *
48
   * @var \Symfony\Component\Routing\Route[]
49
   */
50
  protected $routes = array();
51

52 53 54 55 56 57 58
  /**
   * A cache of already-loaded serialized routes, keyed by route name.
   *
   * @var string[]
   */
  protected $serializedRoutes = [];

59 60 61 62 63 64 65
  /**
   * The current path.
   *
   * @var \Drupal\Core\Path\CurrentPathStack
   */
  protected $currentPath;

66 67 68 69 70 71 72
  /**
   * The cache backend.
   *
   * @var \Drupal\Core\Cache\CacheBackendInterface
   */
  protected $cache;

73 74 75 76 77 78 79
  /**
   * The cache tag invalidator.
   *
   * @var \Drupal\Core\Cache\CacheTagsInvalidatorInterface
   */
  protected $cacheTagInvalidator;

80 81 82 83 84 85 86
  /**
   * A path processor manager for resolving the system path.
   *
   * @var \Drupal\Core\PathProcessor\InboundPathProcessorInterface
   */
  protected $pathProcessor;

87 88 89 90 91
  /**
   * Cache ID prefix used to load routes.
   */
  const ROUTE_LOAD_CID_PREFIX = 'route_provider.route_load:';

92 93 94 95 96
  /**
   * Constructs a new PathMatcher.
   *
   * @param \Drupal\Core\Database\Connection $connection
   *   A database connection object.
97
   * @param \Drupal\Core\State\StateInterface $state
98
   *   The state.
99
   * @param \Drupal\Core\Path\CurrentPathStack $current_path
100 101 102 103 104
   *   The current path.
   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
   *   The cache backend.
   * @param \Drupal\Core\PathProcessor\InboundPathProcessorInterface $path_processor
   *   The path processor.
105 106
   * @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $cache_tag_invalidator
   *   The cache tag invalidator.
107
   * @param string $table
108
   *   (Optional) The table in the database to use for matching. Defaults to 'router'
109
   */
110
  public function __construct(Connection $connection, StateInterface $state, CurrentPathStack $current_path, CacheBackendInterface $cache_backend, InboundPathProcessorInterface $path_processor, CacheTagsInvalidatorInterface $cache_tag_invalidator, $table = 'router') {
111
    $this->connection = $connection;
112
    $this->state = $state;
113
    $this->currentPath = $current_path;
114
    $this->cache = $cache_backend;
115
    $this->cacheTagInvalidator = $cache_tag_invalidator;
116
    $this->pathProcessor = $path_processor;
117
    $this->tableName = $table;
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136
  }

  /**
   * Finds routes that may potentially match the request.
   *
   * This may return a mixed list of class instances, but all routes returned
   * must extend the core symfony route. The classes may also implement
   * RouteObjectInterface to link to a content document.
   *
   * This method may not throw an exception based on implementation specific
   * restrictions on the url. That case is considered a not found - returning
   * an empty array. Exceptions are only used to abort the whole request in
   * case something is seriously broken, like the storage backend being down.
   *
   * Note that implementations may not implement an optimal matching
   * algorithm, simply a reasonable first pass.  That allows for potentially
   * very large route sets to be filtered down to likely candidates, which
   * may then be filtered in memory more completely.
   *
137 138
   * @param Request $request
   *   A request against which to match.
139 140 141
   *
   * @return \Symfony\Component\Routing\RouteCollection with all urls that
   *      could potentially match $request. Empty collection if nothing can
142
   *      match.
143 144
   */
  public function getRouteCollectionForRequest(Request $request) {
145 146
    // Cache both the system path as well as route parameters and matching
    // routes.
147
    $cid = 'route:' . $request->getPathInfo() . ':' . $request->getQueryString();
148 149 150 151 152 153
    if ($cached = $this->cache->get($cid)) {
      $this->currentPath->setPath($cached->data['path'], $request);
      $request->query->replace($cached->data['query']);
      return $cached->data['routes'];
    }
    else {
154 155 156
      // Just trim on the right side.
      $path = $request->getPathInfo();
      $path = $path === '/' ? $path : rtrim($request->getPathInfo(), '/');
157
      $path = $this->pathProcessor->processInbound($path, $request);
158
      $this->currentPath->setPath($path, $request);
159 160
      // Incoming path processors may also set query parameters.
      $query_parameters = $request->query->all();
161
      $routes = $this->getRoutesByPath(rtrim($path, '/'));
162
      $cache_value = [
163
        'path' => $path,
164 165 166 167 168 169
        'query' => $query_parameters,
        'routes' => $routes,
      ];
      $this->cache->set($cid, $cache_value, CacheBackendInterface::CACHE_PERMANENT, ['route_match']);
      return $routes;
    }
170 171 172
  }

  /**
173
   * Find the route using the provided route name (and parameters).
174
   *
175 176
   * @param string $name
   *   The route name to fetch
177 178
   *
   * @return \Symfony\Component\Routing\Route
179
   *   The found route.
180
   *
181 182
   * @throws \Symfony\Component\Routing\Exception\RouteNotFoundException
   *   Thrown if there is no route with that name in this repository.
183
   */
184 185
  public function getRouteByName($name) {
    $routes = $this->getRoutesByNames(array($name));
186 187 188 189 190 191 192 193
    if (empty($routes)) {
      throw new RouteNotFoundException(sprintf('Route "%s" does not exist.', $name));
    }

    return reset($routes);
  }

  /**
194
   * {@inheritdoc}
195
   */
196
  public function preLoadRoutes($names) {
197 198 199 200
    if (empty($names)) {
      throw new \InvalidArgumentException('You must specify the route names to load');
    }

201
    $routes_to_load = array_diff($names, array_keys($this->routes), array_keys($this->serializedRoutes));
202
    if ($routes_to_load) {
203 204 205 206 207 208

      $cid = static::ROUTE_LOAD_CID_PREFIX . hash('sha512', serialize($routes_to_load));
      if ($cache = $this->cache->get($cid)) {
        $routes = $cache->data;
      }
      else {
209 210
        try {
          $result = $this->connection->query('SELECT name, route FROM {' . $this->connection->escapeTable($this->tableName) . '} WHERE name IN ( :names[] )', array(':names[]' => $routes_to_load));
211 212 213
          $routes = $result->fetchAllKeyed();

          $this->cache->set($cid, $routes, Cache::PERMANENT, ['routes']);
214 215
        }
        catch (\Exception $e) {
216
          $routes = [];
217
        }
218 219
      }

220 221 222 223 224 225 226 227 228
      $this->serializedRoutes += $routes;
    }
  }

  /**
   * {@inheritdoc}
   */
  public function getRoutesByNames($names) {
    $this->preLoadRoutes($names);
229

230 231 232 233 234
    foreach ($names as $name) {
      // The specified route name might not exist or might be serialized.
      if (!isset($this->routes[$name]) && isset($this->serializedRoutes[$name])) {
        $this->routes[$name] = unserialize($this->serializedRoutes[$name]);
        unset($this->serializedRoutes[$name]);
235
      }
236 237
    }

238
    return array_intersect_key($this->routes, array_flip($names));
239 240 241 242 243 244 245 246 247 248 249
  }

  /**
   * Returns an array of path pattern outlines that could match the path parts.
   *
   * @param array $parts
   *   The parts of the path for which we want candidates.
   *
   * @return array
   *   An array of outlines that could match the specified path parts.
   */
250
  protected function getCandidateOutlines(array $parts) {
251 252
    $number_parts = count($parts);
    $ancestors = array();
253
    $length = $number_parts - 1;
254 255 256 257
    $end = (1 << $number_parts) - 1;

    // The highest possible mask is a 1 bit for every part of the path. We will
    // check every value down from there to generate a possible outline.
258 259 260
    if ($number_parts == 1) {
      $masks = array(1);
    }
261
    elseif ($number_parts <= 3 && $number_parts > 0) {
262 263 264 265 266 267 268 269 270 271 272 273 274 275
      // Optimization - don't query the state system for short paths. This also
      // insulates against the state entry for masks going missing for common
      // user-facing paths since we generate all values without checking state.
      $masks = range($end, 1);
    }
    elseif ($number_parts <= 0) {
      // No path can match, short-circuit the process.
      $masks = array();
    }
    else {
      // Get the actual patterns that exist out of state.
      $masks = (array) $this->state->get('routing.menu_masks.' . $this->tableName, array());
    }

276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306
    // Only examine patterns that actually exist as router items (the masks).
    foreach ($masks as $i) {
      if ($i > $end) {
        // Only look at masks that are not longer than the path of interest.
        continue;
      }
      elseif ($i < (1 << $length)) {
        // We have exhausted the masks of a given length, so decrease the length.
        --$length;
      }
      $current = '';
      for ($j = $length; $j >= 0; $j--) {
        // Check the bit on the $j offset.
        if ($i & (1 << $j)) {
          // Bit one means the original value.
          $current .= $parts[$length - $j];
        }
        else {
          // Bit zero means means wildcard.
          $current .= '%';
        }
        // Unless we are at offset 0, add a slash.
        if ($j) {
          $current .= '/';
        }
      }
      $ancestors[] = '/' . $current;
    }
    return $ancestors;
  }

307 308 309 310 311 312 313 314 315 316 317 318 319
  /**
   * {@inheritdoc}
   */
  public function getRoutesByPattern($pattern) {
    $path = RouteCompiler::getPatternOutline($pattern);

    return $this->getRoutesByPath($path);
  }

  /**
   * Get all routes which match a certain pattern.
   *
   * @param string $path
320
   *   The route pattern to search for (contains % as placeholders).
321 322
   *
   * @return \Symfony\Component\Routing\RouteCollection
323
   *   Returns a route collection of matching routes.
324 325
   */
  protected function getRoutesByPath($path) {
326
    // Split the path up on the slashes, ignoring multiple slashes in a row
327 328
    // or leading or trailing slashes.
    $parts = preg_split('@/+@', $path, NULL, PREG_SPLIT_NO_EMPTY);
329

330 331
    $collection = new RouteCollection();

332
    $ancestors = $this->getCandidateOutlines($parts);
333 334 335
    if (empty($ancestors)) {
      return $collection;
    }
336

337 338 339
    // The >= check on number_parts allows us to match routes with optional
    // trailing wildcard parts as long as the pattern matches, since we
    // dump the route pattern without those optional parts.
340 341 342 343 344 345 346 347 348
    try {
      $routes = $this->connection->query("SELECT name, route, fit FROM {" . $this->connection->escapeTable($this->tableName) . "} WHERE pattern_outline IN ( :patterns[] ) AND number_parts >= :count_parts", array(
        ':patterns[]' => $ancestors, ':count_parts' => count($parts),
      ))
        ->fetchAll(\PDO::FETCH_ASSOC);
    }
    catch (\Exception $e) {
      $routes = [];
    }
349

350
    // We sort by fit and name in PHP to avoid a SQL filesort.
351 352 353 354
    usort($routes, array($this, 'routeProviderRouteCompare'));

    foreach ($routes as $row) {
      $collection->add($row['name'], unserialize($row['route']));
355 356 357 358 359
    }

    return $collection;
  }

360 361 362
  /**
   * Comparison function for usort on routes.
   */
363
  protected function routeProviderRouteCompare(array $a, array $b) {
364 365 366 367 368 369 370 371
    if ($a['fit'] == $b['fit']) {
      return strcmp($a['name'], $b['name']);
    }
    // Reverse sort from highest to lowest fit. PHP should cast to int, but
    // the explicit cast makes this sort more robust against unexpected input.
    return (int) $a['fit'] < (int) $b['fit'] ? 1 : -1;
  }

372 373 374 375
  /**
   * {@inheritdoc}
   */
  public function getAllRoutes() {
376
    return new PagedRouteCollection($this);
377 378
  }

379 380 381 382 383
  /**
   * {@inheritdoc}
   */
  public function reset() {
    $this->routes  = array();
384
    $this->serializedRoutes = array();
385
    $this->cacheTagInvalidator->invalidateTags(['routes']);
386 387 388 389 390 391 392 393 394 395
  }

  /**
   * {@inheritdoc}
   */
  static function getSubscribedEvents() {
    $events[RoutingEvents::FINISHED][] = array('reset');
    return $events;
  }

396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423
  /**
   * {@inheritdoc}
   */
  public function getRoutesPaged($offset, $length = NULL) {
    $select = $this->connection->select($this->tableName, 'router')
      ->fields('router', ['name', 'route']);

    if (isset($length)) {
      $select->range($offset, $length);
    }

    $routes = $select->execute()->fetchAllKeyed();

    $result = [];
    foreach ($routes as $name => $route) {
      $result[$name] = unserialize($route);
    }

    return $result;
  }

  /**
   * {@inheritdoc}
   */
  public function getRoutesCount() {
    return $this->connection->query("SELECT COUNT(*) FROM {" . $this->connection->escapeTable($this->tableName) . "}")->fetchField();
  }

424
}