diff --git a/core/lib/Drupal/Core/Routing/CompiledRoute.php b/core/lib/Drupal/Core/Routing/CompiledRoute.php new file mode 100644 index 0000000..d8e93f8 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/CompiledRoute.php @@ -0,0 +1,150 @@ +route = $route; + $this->fit = $fit; + $this->patternOutline = $pattern_outline; + $this->numParts = $num_parts; + } + + /** + * Returns the fit of this route + * + * See RouteCompiler for a definition of how the fit is calculated. + * + * @return int + * The fit of the route. + */ + public function getFit() { + return $this->fit; + } + + /** + * Returns the number of parts in this route's path. + * + * The string "foo/bar/baz" has 3 parts, regardless of how many of them are + * placeholders. + * + * @return int + * The number of parts in the path. + */ + public function getNumParts() { + return $this->numParts; + } + + /** + * Returns the pattern outline of this route. + * + * The pattern outline of a route is the path pattern of the route, but + * normalized such that all placeholders are replaced with %. + * + * @return string + * The normalized path pattern. + */ + public function getPatternOutline() { + return $this->patternOutline; + } + + /** + * Returns the Route instance. + * + * @return Route + * A Route instance + */ + public function getRoute() { + return $this->route; + } + + /** + * Returns the pattern. + * + * @return string The pattern + */ + public function getPattern() { + return $this->route->getPattern(); + } + + /** + * Returns the options. + * + * @return array The options + */ + public function getOptions() { + return $this->route->getOptions(); + } + + /** + * Returns the defaults. + * + * @return array The defaults + */ + public function getDefaults() { + return $this->route->getDefaults(); + } + + /** + * Returns the requirements. + * + * @return array The requirements + */ + public function getRequirements() { + return $this->route->getRequirements(); + } + +} + diff --git a/core/lib/Drupal/Core/Routing/FinalMatcherInterface.php b/core/lib/Drupal/Core/Routing/FinalMatcherInterface.php new file mode 100644 index 0000000..2c8c800 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/FinalMatcherInterface.php @@ -0,0 +1,34 @@ +routes = $collection; + + return $this; + } + + + public function matchRequest(Request $request) { + // Return whatever the first route in the collection is. + foreach ($this->routes as $name => $route) { + return array_merge($this->mergeDefaults(array(), $route->getDefaults()), array('_route' => $name)); + } + } + + /** + * Get merged default parameters. + * + * @param array $params + * The parameters + * @param array $defaults + * The defaults + * + * @return array + * Merged default parameters + */ + protected function mergeDefaults($params, $defaults) { + $parameters = $defaults; + foreach ($params as $key => $value) { + if (!is_int($key)) { + $parameters[$key] = $value; + } + } + + return $parameters; + } + +} + diff --git a/core/lib/Drupal/Core/Routing/HttpMethodMatcher.php b/core/lib/Drupal/Core/Routing/HttpMethodMatcher.php new file mode 100644 index 0000000..b0a1878 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/HttpMethodMatcher.php @@ -0,0 +1,53 @@ +getMethod(); + + $collection = new RouteCollection(); + + foreach ($this->routes->all() as $name => $route) { + // _method could be a |-delimited list of allowed methods, or null. If + // null, we accept any method. + $allowed_methods = array_filter(explode('|', strtoupper($route->getRequirement('_method')))); + if (empty($allowed_methods) || in_array($method, $allowed_methods)) { + $collection->add($name, $route); + } + else { + // Build a list of methods that would have matched. Note that we only + // need to do this if a route doesn't match, because if even one route + // passes then we'll never throw the exception that needs this array. + $possible_methods += $allowed_methods; + } + } + + if (!count($collection->all())) { + throw new MethodNotAllowedException(array_unique($possible_methods)); + } + + return $collection; + } + +} + diff --git a/core/lib/Drupal/Core/Routing/InitialMatcherInterface.php b/core/lib/Drupal/Core/Routing/InitialMatcherInterface.php new file mode 100644 index 0000000..a08cb12 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/InitialMatcherInterface.php @@ -0,0 +1,22 @@ +connection = $connection; + + $this->tableName = $table; + } + + /** + * Adds additional routes to be dumped. + * + * @param RouteCollection $routes + */ + public function addRoutes(RouteCollection $routes) { + if (empty($this->routes)) { + $this->routes = $routes; + } + else { + $this->routes->addCollection($routes); + } + } + + /** + * Dumps a set of routes to a PHP class. + * + * Available options: + * + * * route_set: The route grouping that is being dumped. All existing + * routes with this route set will be deleted on dump. + * * base_class: The base class name + * + * @param $options array + * $options An array of options + */ + public function dump(array $options = array()) { + $options += array( + 'route_set' => '', + ); + + //$compiled = $this->compileRoutes($this->routes, $route_set); + + // Convert all of the routes into database records. + $insert = $this->connection->insert($this->tableName)->fields(array( + 'name', + 'route_set', + 'fit', + 'pattern', + 'pattern_outline', + 'number_parts', + 'route', + )); + + foreach ($this->routes as $name => $route) { + $route->setOption('compiler_class', '\Drupal\Core\Routing\RouteCompiler'); + $compiled = $route->compile(); + $values = array( + 'name' => $name, + 'route_set' => $options['route_set'], + 'fit' => $compiled->getFit(), + 'pattern' => $compiled->getPattern(), + 'pattern_outline' => $compiled->getPatternOutline(), + 'number_parts' => $compiled->getNumParts(), + // This is only temporary. We need to strip off the compiled route from + // route object in order to serialize it. Cloning strips off the + // compiled route object. Remove this once + // https://github.com/symfony/symfony/pull/4755 is merged and brought + // back downstream. + 'route' => serialize(clone($route)), + ); + $insert->values($values); + } + + // Delete any old records in this route set first, then insert the new ones. + // That avoids stale data. The transaction makes it atomic to avoid + // unstable router states due to random failures. + $txn = $this->connection->startTransaction(); + + $this->connection->delete($this->tableName) + ->condition('route_set', $options['route_set']) + ->execute(); + + $insert->execute(); + + // Transaction ends here. + } + + /** + * Gets the routes to match. + * + * @return RouteCollection + * A RouteCollection instance representing all routes currently in the + * dumper. + */ + public function getRoutes() { + return $this->routes; + } + + protected function compileRoutes(RouteCollection $routes, $route_set) { + + // First pass: separate callbacks from paths, making paths ready for + // matching. Calculate fitness, and fill some default values. + $menu = array(); + $masks = array(); + foreach ($routes as $name => $item) { + $path = $item->getPattern(); + $move = FALSE; + + $parts = explode('/', $path, static::MAX_PARTS); + $number_parts = count($parts); + // We store the highest index of parts here to save some work in the fit + // calculation loop. + $slashes = $number_parts - 1; + + $num_placeholders = count(array_filter($parts, function($value) { + return strpos($value, '{') !== FALSE; + })); + + $fit = $this->getFit($path); + + if ($fit) { + $move = TRUE; + } + else { + // If there is no placeholder, it fits maximally. + $fit = (1 << $number_parts) - 1; + } + + $masks[$fit] = 1; + $item += array( + 'title' => '', + 'weight' => 0, + 'type' => MENU_NORMAL_ITEM, + 'module' => '', + '_number_parts' => $number_parts, + '_parts' => $parts, + '_fit' => $fit, + ); + + if ($move) { + $new_path = implode('/', $item['_parts']); + $menu[$new_path] = $item; + $sort[$new_path] = $number_parts; + } + else { + $menu[$path] = $item; + $sort[$path] = $number_parts; + } + } + + // Sort the route list. + array_multisort($sort, SORT_NUMERIC, $menu); + // Apply inheritance rules. + foreach ($menu as $path => $v) { + $item = &$menu[$path]; + + for ($i = $item['_number_parts'] - 1; $i; $i--) { + $parent_path = implode('/', array_slice($item['_parts'], 0, $i)); + if (isset($menu[$parent_path])) { + + $parent = &$menu[$parent_path]; + + // If an access callback is not found for a default local task we use + // the callback from the parent, since we expect them to be identical. + // In all other cases, the access parameters must be specified. + if (($item['type'] == MENU_DEFAULT_LOCAL_TASK) && !isset($item['access callback']) && isset($parent['access callback'])) { + $item['access callback'] = $parent['access callback']; + if (!isset($item['access arguments']) && isset($parent['access arguments'])) { + $item['access arguments'] = $parent['access arguments']; + } + } + + // Same for theme callbacks. + if (!isset($item['theme callback']) && isset($parent['theme callback'])) { + $item['theme callback'] = $parent['theme callback']; + if (!isset($item['theme arguments']) && isset($parent['theme arguments'])) { + $item['theme arguments'] = $parent['theme arguments']; + } + } + } + } + if (!isset($item['access callback']) && isset($item['access arguments'])) { + // Default callback. + $item['access callback'] = 'user_access'; + } + if (!isset($item['access callback']) || empty($item['page callback'])) { + $item['access callback'] = 0; + } + if (is_bool($item['access callback'])) { + $item['access callback'] = intval($item['access callback']); + } + + $item += array( + 'access arguments' => array(), + 'access callback' => '', + 'page arguments' => array(), + 'page callback' => '', + 'delivery callback' => '', + 'title arguments' => array(), + 'title callback' => 't', + 'theme arguments' => array(), + 'theme callback' => '', + 'description' => '', + 'position' => '', + 'context' => 0, + 'tab_parent' => '', + 'tab_root' => $path, + 'path' => $path, + 'file' => '', + 'file path' => '', + 'include file' => '', + ); + + // Calculate out the file to be included for each callback, if any. + if ($item['file']) { + $file_path = $item['file path'] ? $item['file path'] : drupal_get_path('module', $item['module']); + $item['include file'] = $file_path . '/' . $item['file']; + } + } + + // Sort the masks so they are in order of descending fit. + $masks = array_keys($masks); + rsort($masks); + + return array($menu, $masks); + + + // The old menu_router record structure, copied here for easy referencing. + array( + 'path' => $item['path'], + 'load_functions' => $item['load_functions'], + 'to_arg_functions' => $item['to_arg_functions'], + 'access_callback' => $item['access callback'], + 'access_arguments' => serialize($item['access arguments']), + 'page_callback' => $item['page callback'], + 'page_arguments' => serialize($item['page arguments']), + 'delivery_callback' => $item['delivery callback'], + 'fit' => $item['_fit'], + 'number_parts' => $item['_number_parts'], + 'context' => $item['context'], + 'tab_parent' => $item['tab_parent'], + 'tab_root' => $item['tab_root'], + 'title' => $item['title'], + 'title_callback' => $item['title callback'], + 'title_arguments' => ($item['title arguments'] ? serialize($item['title arguments']) : ''), + 'theme_callback' => $item['theme callback'], + 'theme_arguments' => serialize($item['theme arguments']), + 'type' => $item['type'], + 'description' => $item['description'], + 'position' => $item['position'], + 'weight' => $item['weight'], + 'include_file' => $item['include file'], + ); + } + + /** + * Determines the fitness of the provided path. + * + * @param string $path + * The path whose fitness we want. + * + * @return int + * The fitness of the path, as an integer. + */ + public function getFit($path) { + $fit = 0; + + $parts = explode('/', $path, static::MAX_PARTS); + foreach ($parts as $k => $part) { + if (strpos($part, '{') === FALSE) { + $fit |= 1 << ($slashes - $k); + } + } + + return $fit; + } +} + diff --git a/core/lib/Drupal/Core/Routing/NestedMatcher.php b/core/lib/Drupal/Core/Routing/NestedMatcher.php new file mode 100644 index 0000000..db8a429 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/NestedMatcher.php @@ -0,0 +1,149 @@ +partialMatchers[] = $matcher; + + return $this; + } + + /** + * Sets the final matcher for the matching plan. + * + * @param UrlMatcherInterface $final + * The matcher that will be called last to ensure only a single route is + * found. + * + * @return NestedMatcherInterface + * The current matcher. + */ + public function setFinalMatcher(FinalMatcherInterface $final) { + $this->finalMatcher = $final; + + return $this; + } + + /** + * Sets the first matcher for the matching plan. + * + * Partial matchers will be run in the order in which they are added. + * + * @param InitialMatcherInterface $matcher + * An initial matcher. It is responsible for its own configuration and + * initial route collection + * + * @return NestedMatcherInterface + * The current matcher. + */ + public function setInitialMatcher(InitialMatcherInterface $initial) { + $this->initialMatcher = $initial; + + return $this; + } + + /** + * Tries to match a request with a set of routes. + * + * If the matcher can not find information, it must throw one of the exceptions documented + * below. + * + * @param Request $request The request to match + * + * @return array An array of parameters + * + * @throws ResourceNotFoundException If no matching resource could be found + * @throws MethodNotAllowedException If a matching resource was found but the request method is not allowed + */ + public function matchRequest(Request $request) { + $collection = $this->initialMatcher->matchRequestPartial($request); + + foreach ($this->partialMatchers as $matcher) { + if ($collection) { + $matcher->setCollection($collection); + } + $collection = $matcher->matchRequestPartial($request); + } + + $attributes = $this->finalMatcher->setCollection($collection)->matchRequest($request); + + return $attributes; + } + + /** + * Sets the request context. + * + * This method is unused. It is here only to satisfy the interface. + * + * @param RequestContext $context The context + */ + public function setContext(RequestContext $context) { + $this->context = $context; + } + + /** + * Gets the request context. + * + * This method is unused. It is here only to satisfy the interface. + * + * @return RequestContext The context + */ + public function getContext() { + return $this->context; + } + +} + diff --git a/core/lib/Drupal/Core/Routing/NestedMatcherInterface.php b/core/lib/Drupal/Core/Routing/NestedMatcherInterface.php new file mode 100644 index 0000000..6ae7428 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/NestedMatcherInterface.php @@ -0,0 +1,50 @@ +routes = $collection; + + return $this; + } + +} diff --git a/core/lib/Drupal/Core/Routing/PartialMatcherInterface.php b/core/lib/Drupal/Core/Routing/PartialMatcherInterface.php new file mode 100644 index 0000000..1b234e8 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/PartialMatcherInterface.php @@ -0,0 +1,34 @@ +connection = $connection; + $this->tableName = $table; + } + + /** + * Matches a request against multiple routes. + * + * @param Request $request + * A Request object against which to match. + * + * @return RouteCollection + * A RouteCollection of matched routes. + */ + public function matchRequestPartial(Request $request) { + + $path = $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->tableName}} WHERE pattern_outline IN (:patterns) ORDER BY fit", array( + ':patterns' => $ancestors, + )) + ->fetchAllKeyed(); + + $collection = new RouteCollection(); + foreach ($routes as $name => $route) { + $collection->add($name, unserialize($route)); + } + + if (!count($collection->all())) { + throw new ResourceNotFoundException(); + } + + return $collection; + } + + /** + * 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); + $length = $number_parts - 1; + $end = (1 << $number_parts) - 1; + $candidates = array(); + + $start = pow($number_parts-1, 2); + + // 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, $start); + + foreach ($masks as $i) { + $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 .= '/'; + } + } + $candidates[] = $current; + } + + return $candidates; + } +} + diff --git a/core/lib/Drupal/Core/Routing/RouteCompiler.php b/core/lib/Drupal/Core/Routing/RouteCompiler.php new file mode 100644 index 0000000..c05e725 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/RouteCompiler.php @@ -0,0 +1,83 @@ +getFit($route->getPattern()); + + $pattern_outline = $this->getPatternOutline($route->getPattern()); + + $num_parts = count(explode('/', $pattern_outline)); + + return new CompiledRoute($route, $fit, $pattern_outline, $num_parts); + + } + + /** + * Returns the pattern outline. + * + * The pattern outline is the path pattern but normalized so that all + * placeholders are equal strings. + * + * @param string $path + * The path pattern to normalize to an outline. + * + * @return string + * The path pattern outline. + */ + public function getPatternOutline($path) { + return preg_replace('#\{\w+\}#', '%', $path); + } + + /** + * Determines the fitness of the provided path. + * + * @param string $path + * The path whose fitness we want. + * + * @return int + * The fitness of the path, as an integer. + */ + public function getFit($path) { + + $parts = explode('/', trim($path, '/'), static::MAX_PARTS); + $number_parts = count($parts); + // We store the highest index of parts here to save some work in the fit + // calculation loop. + $slashes = $number_parts - 1; + + $fit = 0; + foreach ($parts as $k => $part) { + if (strpos($part, '{') === FALSE) { + $fit |= 1 << ($slashes - $k); + } + } + + return $fit; + } +} + diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/HttpMethodMatcherTest.php b/core/modules/system/lib/Drupal/system/Tests/Routing/HttpMethodMatcherTest.php new file mode 100644 index 0000000..687db2c --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/HttpMethodMatcherTest.php @@ -0,0 +1,109 @@ + 'Partial matcher HTTP Method tests', + 'description' => 'Confirm that the Http Method partial matcher is functioning properly.', + 'group' => 'Routing', + ); + } + + function __construct($test_id = NULL) { + parent::__construct($test_id); + + $this->fixtures = new RoutingFixtures(); + } + public function setUp() { + parent::setUp(); + } + + /** + * Confirms that the HttpMethod matcher matches properly. + */ + public function testFilterRoutes() { + + $matcher = new HttpMethodMatcher(); + $matcher->setCollection($this->fixtures->sampleRouteCollection()); + + $routes = $matcher->matchRequestPartial(Request::create('path/one', 'GET')); + + $this->assertEqual(count($routes->all()), 4, t('The correct number of routes was found.')); + $this->assertNotNull($routes->get('route_a'), t('The first matching route was found.')); + $this->assertNull($routes->get('route_b'), t('The non-matching route was not found.')); + $this->assertNotNull($routes->get('route_c'), t('The second matching route was found.')); + $this->assertNotNull($routes->get('route_d'), t('The all-matching route was found.')); + $this->assertNotNull($routes->get('route_e'), t('The multi-matching route was found.')); + } + + /** + * Confirms we can nest multiple partial matchers. + */ + public function testNestedMatcher() { + + $matcher = new NestedMatcher(); + + $matcher->setInitialMatcher(new MockPathMatcher($this->fixtures->sampleRouteCollection())); + $matcher->addPartialMatcher(new HttpMethodMatcher()); + $matcher->setFinalMatcher(new FirstEntryFinalMatcher()); + + $request = Request::create('/path/one', 'GET'); + + $attributes = $matcher->matchRequest($request); + + $this->assertEqual($attributes['_route'], 'route_a', t('The correct matching route was found.')); + } + + /** + * Confirms that the HttpMethod matcher throws an exception for no-route. + */ + public function testNoRouteFound() { + $matcher = new HttpMethodMatcher(); + + // Remove the sample route that would match any method. + $routes = $this->fixtures->sampleRouteCollection(); + $routes->remove('route_d'); + + $matcher->setCollection($routes); + + try { + $routes = $matcher->matchRequestPartial(Request::create('path/one', 'DELETE')); + $this->fail(t('No exception was thrown.')); + } + catch (Exception $e) { + $this->assertTrue($e instanceof MethodNotAllowedException, t('The correct exception was thrown.')); + } + + } +} + diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/MatcherDumperTest.php b/core/modules/system/lib/Drupal/system/Tests/Routing/MatcherDumperTest.php new file mode 100644 index 0000000..d7c9a01 --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/MatcherDumperTest.php @@ -0,0 +1,144 @@ + 'Dumper tests', + 'description' => 'Confirm that the matcher dumper is functioning properly.', + 'group' => 'Routing', + ); + } + + function __construct($test_id = NULL) { + parent::__construct($test_id); + + $this->fixtures = new RoutingFixtures(); + } + + function setUp() { + parent::setUp(); + } + + /** + * Confirms that the dumper can be instantiated successfuly. + */ + function testCreate() { + $connection = Database::getConnection(); + $dumper= new MatcherDumper($connection); + + $class_name = 'Drupal\Core\Routing\MatcherDumper'; + $this->assertTrue($dumper instanceof $class_name, t('Dumper created successfully')); + } + + /** + * Confirms that we can add routes to the dumper. + */ + function testAddRoutes() { + $connection = Database::getConnection(); + $dumper= new MatcherDumper($connection); + + $route = new Route('test'); + $collection = new RouteCollection(); + $collection->add('test_route', $route); + + $dumper->addRoutes($collection); + + $dumper_routes = $dumper->getRoutes()->all(); + $collection_routes = $collection->all(); + + foreach ($dumper_routes as $name => $route) { + $this->assertEqual($route->getPattern(), $collection_routes[$name]->getPattern(), t('Routes match')); + } + } + + /** + * Confirms that we can add routes to the dumper when it already has some. + */ + function testAddAdditionalRoutes() { + $connection = Database::getConnection(); + $dumper= new MatcherDumper($connection); + + $route = new Route('test'); + $collection = new RouteCollection(); + $collection->add('test_route', $route); + $dumper->addRoutes($collection); + + $route = new Route('test2'); + $collection2 = new RouteCollection(); + $collection2->add('test_route2', $route); + $dumper->addRoutes($collection2); + + // Merge the two collections together so we can test them. + $collection->addCollection(clone $collection2); + + $dumper_routes = $dumper->getRoutes()->all(); + $collection_routes = $collection->all(); + + $success = TRUE; + foreach ($collection_routes as $name => $route) { + if (empty($dumper_routes[$name])) { + $success = FALSE; + $this->fail(t('Not all routes found in the dumper.')); + } + } + + if ($success) { + $this->pass('All routes found in the dumper.'); + } + } + + /** + * Confirm that we can dump a route collection to the database. + */ + public function testDump() { + $connection = Database::getConnection(); + $dumper= new MatcherDumper($connection, 'test_routes'); + + $route = new Route('/test/{my}/path'); + $route->setOption('compiler_class', 'Drupal\Core\Routing\RouteCompiler'); + $collection = new RouteCollection(); + $collection->add('test_route', $route); + + $dumper->addRoutes($collection); + + $this->fixtures->createTables($connection); + + $dumper->dump(array('route_set' => 'test')); + + $record = $connection->query("SELECT * FROM {test_routes} WHERE name= :name", array(':name' => 'test_route'))->fetchObject(); + + $loaded_route = unserialize($record->route); + + $this->assertEqual($record->name, 'test_route', t('Dumped route has correct name.')); + $this->assertEqual($record->pattern, '/test/{my}/path', t('Dumped route has correct pattern.')); + $this->assertEqual($record->pattern_outline, '/test/%/path', t('Dumped route has correct pattern outline.')); + $this->assertEqual($record->fit, 5 /* 101 in binary */, t('Dumped route has correct fit.')); + $this->assertTrue($loaded_route instanceof Route, t('Route object retrieved successfully.')); + + } +} diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/MockPathMatcher.php b/core/modules/system/lib/Drupal/system/Tests/Routing/MockPathMatcher.php new file mode 100644 index 0000000..b545ebe --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/MockPathMatcher.php @@ -0,0 +1,50 @@ +routes = $routes; + } + + /** + * Matches a request against multiple routes. + * + * @param Request $request + * A Request object against which to match. + * + * @return RouteCollection + * A RouteCollection of matched routes. + */ + public function matchRequestPartial(Request $request) { + // For now for testing we'll just do a straight string match. + + $path = $request->getPathInfo(); + + $return = new RouteCollection(); + + foreach ($this->routes as $name => $route) { + if ($route->getPattern() == $path) { + $return->add($name, $route); + } + } + + return $return; + } + + +} diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/NestedMatcherTest.php b/core/modules/system/lib/Drupal/system/Tests/Routing/NestedMatcherTest.php new file mode 100644 index 0000000..5f351ac --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/NestedMatcherTest.php @@ -0,0 +1,69 @@ + 'NestedMatcher tests', + 'description' => 'Confirm that the NestedMatcher system is working properly.', + 'group' => 'Routing', + ); + } + + function __construct($test_id = NULL) { + parent::__construct($test_id); + + $this->fixtures = new RoutingFixtures(); + } + public function setUp() { + parent::setUp(); + } + + /** + * Confirms we can nest multiple partial matchers. + */ + public function testNestedMatcher() { + + $matcher = new NestedMatcher(); + + $matcher->setInitialMatcher(new MockPathMatcher($this->fixtures->sampleRouteCollection())); + $matcher->addPartialMatcher(new HttpMethodMatcher()); + $matcher->setFinalMatcher(new FirstEntryFinalMatcher()); + + $request = Request::create('/path/one', 'GET'); + + $attributes = $matcher->matchRequest($request); + + $this->assertEqual($attributes['_route'], 'route_a', t('The correct matching route was found.')); + } +} + diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/PathMatcherTest.php b/core/modules/system/lib/Drupal/system/Tests/Routing/PathMatcherTest.php new file mode 100644 index 0000000..1e91a34 --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/PathMatcherTest.php @@ -0,0 +1,157 @@ + 'Path matcher tests', + 'description' => 'Confirm that the path matching library 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(); + $matcher = new PathMatcher($connection); + + $parts = array('node', '5', 'edit'); + + $candidates = $matcher->getCandidateOutlines($parts); + + //debug($candidates); + + $candidates = array_flip($candidates); + + $this->assertTrue(count($candidates) == 4, t('Correct number of candidates found')); + $this->assertTrue(array_key_exists('/node/5/edit', $candidates), t('First candidate found.')); + $this->assertTrue(array_key_exists('/node/5/%', $candidates), t('Second candidate found.')); + $this->assertTrue(array_key_exists('/node/%/edit', $candidates), t('Third candidate found.')); + $this->assertTrue(array_key_exists('/node/%/%', $candidates), t('Fourth candidate found.')); + } + + /** + * Confirms that we can find routes with the exact incoming path. + */ + function testExactPathMatch() { + $connection = Database::getConnection(); + $matcher = new PathMatcher($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 = $matcher->matchRequestPartial($request); + + foreach ($routes as $route) { + $this->assertEqual($route->getPattern(), $path, t('Found path has correct pattern')); + } + } + + /** + * Confirms that we can find routes whose pattern would match the request. + */ + function testOutlinePathMatch() { + $connection = Database::getConnection(); + $matcher = new PathMatcher($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 = $matcher->matchRequestPartial($request); + + // All of the matching paths have the correct pattern. + foreach ($routes as $route) { + $this->assertEqual($route->compile()->getPatternOutline(), '/path/%/one', t('Found path has correct pattern')); + } + + $this->assertEqual(count($routes->all()), 2, t('The correct number of routes was found.')); + $this->assertNotNull($routes->get('route_a'), t('The first matching route was found.')); + $this->assertNotNull($routes->get('route_b'), t('The second matching route was not found.')); + } + + /** + * Confirm that an exception is thrown when no matching path is found. + */ + function testOutlinePathNoMatch() { + $connection = Database::getConnection(); + $matcher = new PathMatcher($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 = $matcher->matchRequestPartial($request); + $this->fail(t('No exception was thrown.')); + } + catch (Exception $e) { + $this->assertTrue($e instanceof ResourceNotFoundException, t('The correct exception was thrown.')); + } + + } + +} diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/RouteTest.php b/core/modules/system/lib/Drupal/system/Tests/Routing/RouteTest.php new file mode 100644 index 0000000..af840b2 --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/RouteTest.php @@ -0,0 +1,41 @@ + 'Routes', + 'description' => 'Confirm that route object is functioning properly.', + 'group' => 'Routing', + ); + } + + function setUp() { + parent::setUp(); + } + + public function testCompilation() { + $route = new Route('/test/{something}/more'); + $route->setOption('compiler_class', 'Drupal\Core\Routing\RouteCompiler'); + $compiled = $route->compile(); + + $this->assertEqual($route, $compiled->getRoute(), t('Compiled route has the correct route object.')); + $this->assertEqual($compiled->getFit(), 5 /* That's 101 binary*/, t('The fit was correct.')); + $this->assertEqual($compiled->getPatternOutline(), '/test/%/more', t('The pattern outline was correct.')); + } + +} diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/RoutingFixtures.php b/core/modules/system/lib/Drupal/system/Tests/Routing/RoutingFixtures.php new file mode 100644 index 0000000..8937c75 --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/RoutingFixtures.php @@ -0,0 +1,167 @@ +routingTableDefinition(); + $schema = $connection->schema(); + + foreach ($tables as $name => $table) { + $schema->dropTable($name); + $schema->createTable($name, $table); + } + } + + public function dropTables(Connection $connection) { + $tables = $this->routingTableDefinition(); + $schema = $connection->schema(); + + foreach ($tables as $name => $table) { + $schema->dropTable($name); + } + } + + /** + * Returns a standard set of routes for testing. + * + * @return \Symfony\Component\Routing\RouteCollection + */ + public function sampleRouteCollection() { + $collection = new RouteCollection(); + + $route = new Route('path/one'); + $route->setRequirement('_method', 'GET'); + $collection->add('route_a', $route); + + $route = new Route('path/one'); + $route->setRequirement('_method', 'PUT'); + $collection->add('route_b', $route); + + $route = new Route('path/two'); + $route->setRequirement('_method', 'GET'); + $collection->add('route_c', $route); + + $route = new Route('path/three'); + $collection->add('route_d', $route); + + $route = new Route('path/two'); + $route->setRequirement('_method', 'GET|HEAD'); + $collection->add('route_e', $route); + + return $collection; + } + + /** + * Returns a complex set of routes for testing. + * + * @return \Symfony\Component\Routing\RouteCollection + */ + public function complexRouteCollection() { + $collection = new RouteCollection(); + + $route = new Route('/path/{thing}/one'); + $route->setRequirement('_method', 'GET'); + $collection->add('route_a', $route); + + $route = new Route('/path/{thing}/one'); + $route->setRequirement('_method', 'PUT'); + $collection->add('route_b', $route); + + $route = new Route('/somewhere/{item}/over/the/rainbow'); + $route->setRequirement('_method', 'GET'); + $collection->add('route_c', $route); + + $route = new Route('/another/{thing}/about/{item}'); + $collection->add('route_d', $route); + + $route = new Route('/path/add/one'); + $route->setRequirement('_method', 'GET|HEAD'); + $collection->add('route_e', $route); + + return $collection; + } + + public function routingTableDefinition() { + + $tables['test_routes'] = array( + 'description' => 'Maps paths to various callbacks (access, page and title)', + 'fields' => array( + 'name' => array( + 'description' => 'Primary Key: Machine name of this route', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'pattern' => array( + 'description' => 'The path pattern for this URI', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'pattern_outline' => array( + 'description' => 'The pattern', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'route_set' => array( + 'description' => 'The route set grouping to which a route belongs.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'access_callback' => array( + 'description' => 'The callback which determines the access to this router path. Defaults to user_access.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'access_arguments' => array( + 'description' => 'A serialized array of arguments for the access callback.', + 'type' => 'blob', + 'not null' => FALSE, + ), + 'fit' => array( + 'description' => 'A numeric representation of how specific the path is.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ), + 'number_parts' => array( + 'description' => 'Number of parts in this router path.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'small', + ), + 'route' => array( + 'description' => 'A serialized Route object', + 'type' => 'text', + ), + ), + 'indexes' => array( + 'fit' => array('fit'), + 'pattern_outline' => array('pattern_outline'), + 'route_set' => array('route_set'), + ), + 'primary key' => array('name'), + ); + + return $tables; + } +} diff --git a/core/vendor/Routing b/core/vendor/Routing new file mode 160000 index 0000000..a05bcaa --- /dev/null +++ b/core/vendor/Routing @@ -0,0 +1 @@ +Subproject commit a05bcaaaa43025037a0667e158aed9b65a147e80