diff --git a/core/lib/Drupal/Core/Routing/RouteProvider.php b/core/lib/Drupal/Core/Routing/RouteProvider.php new file mode 100644 index 00000000000..63fb7014e66 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/RouteProvider.php @@ -0,0 +1,211 @@ +connection = $connection; + $this->tableName = $table; + } + + /** + * 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. + * + * @param Request $request A request against which to match. + * + * @return \Symfony\Component\Routing\RouteCollection with all urls that + * could potentially match $request. Empty collection if nothing can + * match. + */ + public function getRouteCollectionForRequest(Request $request) { + + // The 'system_path' has language prefix stripped and path alias resolved, + // whereas getPathInfo() returns the requested path. In Drupal, the request + // always contains a system_path attribute, but this component may get + // adopted by non-Drupal projects. Some unit tests also skip initializing + // 'system_path'. + // @todo Consider abstracting this to a separate object. + if ($request->attributes->has('system_path')) { + // system_path never has leading or trailing slashes. + $path = '/' . $request->attributes->get('system_path'); + } + else { + // getPathInfo() always has leading slash, and might or might not have a + // trailing slash. + $path = rtrim($request->getPathInfo(), '/'); + } + + $parts = array_slice(array_filter(explode('/', $path)), 0, MatcherDumper::MAX_PARTS); + + $ancestors = $this->getCandidateOutlines($parts); + + $routes = $this->connection->query("SELECT name, route FROM {" . $this->connection->escapeTable($this->tableName) . "} WHERE pattern_outline IN (:patterns) ORDER BY fit", array( + ':patterns' => $ancestors, + )) + ->fetchAllKeyed(); + + $collection = new RouteCollection(); + foreach ($routes as $name => $route) { + $route = unserialize($route); + if (preg_match($route->compile()->getRegex(), $path, $matches)) { + $collection->add($name, $route); + } + } + + if (!count($collection)) { + throw new ResourceNotFoundException(); + } + + return $collection; + } + + /** + * Find the route using the provided route name (and parameters) + * + * @param string $name the route name to fetch + * @param array $parameters the parameters as they are passed to the + * UrlGeneratorInterface::generate call + * + * @return \Symfony\Component\Routing\Route + * + * @throws \Symfony\Component\Routing\Exception\RouteNotFoundException if + * there is no route with that name in this repository + */ + public function getRouteByName($name, $parameters = array()) { + $routes = $this->getRoutesByNames(array($name), $parameters); + if (empty($routes)) { + throw new RouteNotFoundException(sprintf('Route "%s" does not exist.', $name)); + } + + return reset($routes); + } + + /** + * Find many routes by their names using the provided list of names + * + * Note that this method may not throw an exception if some of the routes + * are not found. It will just return the list of those routes it found. + * + * This method exists in order to allow performance optimizations. The + * simple implementation could be to just repeatedly call + * $this->getRouteByName() + * + * @param array $names the list of names to retrieve + * @param array $parameters the parameters as they are passed to the + * UrlGeneratorInterface::generate call. (Only one array, not one for + * each entry in $names. + * + * @return \Symfony\Component\Routing\Route[] iterable thing with the keys + * the names of the $names argument. + */ + public function getRoutesByNames($names, $parameters = array()) { + $result = $this->connection->query('SELECT name, route FROM {' . $this->connection->escapeTable($this->table) . '} WHERE name IN :names', array(':names' => $names)); + $routes = $result->fetchAllKeyed(); + + $return = array(); + foreach ($routes as $name => $route) { + $return[$name] = unserialize($route); + } + + return $return; + } + + /** + * 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. + */ + public function getCandidateOutlines(array $parts) { + $number_parts = count($parts); + $ancestors = array(); + $length = $number_parts - 1; + $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. + $masks = range($end, pow($number_parts - 1, 2)); + + // 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; + } + +} diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/RouteProviderTest.php b/core/modules/system/lib/Drupal/system/Tests/Routing/RouteProviderTest.php new file mode 100644 index 00000000000..9ebd2ed14dd --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/RouteProviderTest.php @@ -0,0 +1,322 @@ + 'Route Provider tests', + 'description' => 'Confirm that the default route provider is working correctly.', + 'group' => 'Routing', + ); + } + + function __construct($test_id = NULL) { + parent::__construct($test_id); + + $this->fixtures = new RoutingFixtures(); + } + + public function tearDown() { + $this->fixtures->dropTables(Database::getConnection()); + + parent::tearDown(); + } + + /** + * Confirms that the correct candidate outlines are generated. + */ + public function testCandidateOutlines() { + + $connection = Database::getConnection(); + $provider = new RouteProvider($connection); + + $parts = array('node', '5', 'edit'); + + $candidates = $provider->getCandidateOutlines($parts); + + $candidates = array_flip($candidates); + + $this->assertTrue(count($candidates) == 4, 'Correct number of candidates found'); + $this->assertTrue(array_key_exists('/node/5/edit', $candidates), 'First candidate found.'); + $this->assertTrue(array_key_exists('/node/5/%', $candidates), 'Second candidate found.'); + $this->assertTrue(array_key_exists('/node/%/edit', $candidates), 'Third candidate found.'); + $this->assertTrue(array_key_exists('/node/%/%', $candidates), 'Fourth candidate found.'); + } + + /** + * Confirms that we can find routes with the exact incoming path. + */ + function testExactPathMatch() { + $connection = Database::getConnection(); + $provider = new RouteProvider($connection, 'test_routes'); + + $this->fixtures->createTables($connection); + + $dumper = new MatcherDumper($connection, 'test_routes'); + $dumper->addRoutes($this->fixtures->sampleRouteCollection()); + $dumper->dump(); + + $path = '/path/one'; + + $request = Request::create($path, 'GET'); + + $routes = $provider->getRouteCollectionForRequest($request); + + foreach ($routes as $route) { + $this->assertEqual($route->getPattern(), $path, 'Found path has correct pattern'); + } + } + + /** + * Confirms that we can find routes whose pattern would match the request. + */ + function testOutlinePathMatch() { + $connection = Database::getConnection(); + $provider = new RouteProvider($connection, 'test_routes'); + + $this->fixtures->createTables($connection); + + $dumper = new MatcherDumper($connection, 'test_routes'); + $dumper->addRoutes($this->fixtures->complexRouteCollection()); + $dumper->dump(); + + $path = '/path/1/one'; + + $request = Request::create($path, 'GET'); + + $routes = $provider->getRouteCollectionForRequest($request); + + // All of the matching paths have the correct pattern. + foreach ($routes as $route) { + $this->assertEqual($route->compile()->getPatternOutline(), '/path/%/one', 'Found path has correct pattern'); + } + + $this->assertEqual(count($routes), 2, 'The correct number of routes was found.'); + $this->assertNotNull($routes->get('route_a'), 'The first matching route was found.'); + $this->assertNotNull($routes->get('route_b'), 'The second matching route was not found.'); + } + + /** + * Confirms that a trailing slash on the request doesn't result in a 404. + */ + function testOutlinePathMatchTrailingSlash() { + $connection = Database::getConnection(); + $provider = new RouteProvider($connection, 'test_routes'); + + $this->fixtures->createTables($connection); + + $dumper = new MatcherDumper($connection, 'test_routes'); + $dumper->addRoutes($this->fixtures->complexRouteCollection()); + $dumper->dump(); + + $path = '/path/1/one/'; + + $request = Request::create($path, 'GET'); + + $routes = $provider->getRouteCollectionForRequest($request); + + // All of the matching paths have the correct pattern. + foreach ($routes as $route) { + $this->assertEqual($route->compile()->getPatternOutline(), '/path/%/one', 'Found path has correct pattern'); + } + + $this->assertEqual(count($routes), 2, 'The correct number of routes was found.'); + $this->assertNotNull($routes->get('route_a'), 'The first matching route was found.'); + $this->assertNotNull($routes->get('route_b'), 'The second matching route was not found.'); + } + + /** + * Confirms that we can find routes whose pattern would match the request. + */ + function testOutlinePathMatchDefaults() { + $connection = Database::getConnection(); + $provider = new RouteProvider($connection, 'test_routes'); + + $this->fixtures->createTables($connection); + + $collection = new RouteCollection(); + $collection->add('poink', new Route('/some/path/{value}', array( + 'value' => 'poink', + ))); + + $dumper = new MatcherDumper($connection, 'test_routes'); + $dumper->addRoutes($collection); + $dumper->dump(); + + $path = '/some/path'; + + $request = Request::create($path, 'GET'); + + try { + $routes = $provider->getRouteCollectionForRequest($request); + + // All of the matching paths have the correct pattern. + foreach ($routes as $route) { + $this->assertEqual($route->compile()->getPatternOutline(), '/some/path', 'Found path has correct pattern'); + } + + $this->assertEqual(count($routes), 1, 'The correct number of routes was found.'); + $this->assertNotNull($routes->get('poink'), 'The first matching route was found.'); + } + catch (ResourceNotFoundException $e) { + $this->fail('No matching route found with default argument value.'); + } + } + + /** + * Confirms that we can find routes whose pattern would match the request. + */ + function testOutlinePathMatchDefaultsCollision() { + $connection = Database::getConnection(); + $provider = new RouteProvider($connection, 'test_routes'); + + $this->fixtures->createTables($connection); + + $collection = new RouteCollection(); + $collection->add('poink', new Route('/some/path/{value}', array( + 'value' => 'poink', + ))); + $collection->add('narf', new Route('/some/path/here')); + + $dumper = new MatcherDumper($connection, 'test_routes'); + $dumper->addRoutes($collection); + $dumper->dump(); + + $path = '/some/path'; + + $request = Request::create($path, 'GET'); + + try { + $routes = $provider->getRouteCollectionForRequest($request); + + // All of the matching paths have the correct pattern. + foreach ($routes as $route) { + $this->assertEqual($route->compile()->getPatternOutline(), '/some/path', 'Found path has correct pattern'); + } + + $this->assertEqual(count($routes), 1, 'The correct number of routes was found.'); + $this->assertNotNull($routes->get('poink'), 'The first matching route was found.'); + } + catch (ResourceNotFoundException $e) { + $this->fail('No matching route found with default argument value.'); + } + } + + /** + * Confirms that we can find routes whose pattern would match the request. + */ + function testOutlinePathMatchDefaultsCollision2() { + $connection = Database::getConnection(); + $provider = new RouteProvider($connection, 'test_routes'); + + $this->fixtures->createTables($connection); + + $collection = new RouteCollection(); + $collection->add('poink', new Route('/some/path/{value}', array( + 'value' => 'poink', + ))); + $collection->add('narf', new Route('/some/path/here')); + + $dumper = new MatcherDumper($connection, 'test_routes'); + $dumper->addRoutes($collection); + $dumper->dump(); + + $path = '/some/path/here'; + + $request = Request::create($path, 'GET'); + + try { + $routes = $provider->getRouteCollectionForRequest($request); + + // All of the matching paths have the correct pattern. + foreach ($routes as $route) { + $this->assertEqual($route->compile()->getPatternOutline(), '/some/path/here', 'Found path has correct pattern'); + } + + $this->assertEqual(count($routes), 1, 'The correct number of routes was found.'); + $this->assertNotNull($routes->get('narf'), 'The first matching route was found.'); + } + catch (ResourceNotFoundException $e) { + $this->fail('No matching route found with default argument value.'); + } + } + + /** + * Confirms that an exception is thrown when no matching path is found. + */ + function testOutlinePathNoMatch() { + $connection = Database::getConnection(); + $provider = new RouteProvider($connection, 'test_routes'); + + $this->fixtures->createTables($connection); + + $dumper = new MatcherDumper($connection, 'test_routes'); + $dumper->addRoutes($this->fixtures->complexRouteCollection()); + $dumper->dump(); + + $path = '/no/such/path'; + + $request = Request::create($path, 'GET'); + + try { + $routes = $provider->getRouteCollectionForRequest($request); + $this->fail(t('No exception was thrown.')); + } + catch (Exception $e) { + $this->assertTrue($e instanceof ResourceNotFoundException, 'The correct exception was thrown.'); + } + } + + /** + * Confirms that system_path attribute overrides request path. + */ + function testSystemPathMatch() { + $connection = Database::getConnection(); + $provider = new RouteProvider($connection, 'test_routes'); + + $this->fixtures->createTables($connection); + + $dumper = new MatcherDumper($connection, 'test_routes'); + $dumper->addRoutes($this->fixtures->sampleRouteCollection()); + $dumper->dump(); + + $request = Request::create('/path/one', 'GET'); + $request->attributes->set('system_path', 'path/two'); + + $routes = $provider->getRouteCollectionForRequest($request); + + foreach ($routes as $route) { + $this->assertEqual($route->getPattern(), '/path/two', 'Found path has correct pattern'); + } + } + +}