diff --git a/src/Map.php b/src/Map.php index 4789202..f827feb 100644 --- a/src/Map.php +++ b/src/Map.php @@ -420,4 +420,52 @@ public function attach($namePrefix, $pathPrefix, callable $callable) $callable($this); $this->protoRoute = $old; } + + /** + * Convert all routes into a node tree array structure that contains all possible routes per route segment + * This will reduce the amount of possible routes to check + * + * @return array> + */ + public function getAsTreeRouteNode() + { + $treeRoutes = []; + foreach ($this->routes as $route) { + if (! $route->isRoutable || $route->path === null) { + continue; + } + + // replace "{/year,month,day}" parameters with /{}/{}/{} + $routePath = preg_replace_callback( + '~{/((?:\w+,?)+)}~', + static function (array $matches) { + $variables = explode(',', $matches[1]); + + return '/' . implode('/', array_fill(0, count($variables), '{}')); + }, + $route->path + ) ?: $route->path; + $paramsAreOptional = $routePath !== $route->path; + + // This regexp will also work with "{controller:[a-zA-Z][a-zA-Z0-9_-]{1,}}" + $routePath = preg_replace('~{(?:[^{}]*|(?R))*}~', '{}', $routePath) ?: $routePath; + $node = &$treeRoutes; + foreach (explode('/', trim($routePath, '/')) as $segment) { + if (strpos($segment, '{') === 0) { + if ($paramsAreOptional) { + $node[spl_object_hash($route)] = $route; + } + $node = &$node['{}']; + $node[spl_object_hash($route)] = $route; + continue; + } + $node = &$node[$segment]; + } + + $node[spl_object_hash($route)] = $route; + unset($node); + } + + return $treeRoutes; + } } diff --git a/src/Matcher.php b/src/Matcher.php index f2d9722..fb9c4d9 100644 --- a/src/Matcher.php +++ b/src/Matcher.php @@ -113,8 +113,12 @@ public function match(ServerRequestInterface $request) $this->failedScore = 0; $path = $request->getUri()->getPath(); - foreach ($this->map as $name => $proto) { - $route = $this->requestRoute($request, $proto, $name, $path); + $possibleRoutes = $this->getMatchedTree($path); + foreach ($possibleRoutes as $proto) { + if (is_array($proto)) { + continue; + } + $route = $this->requestRoute($request, $proto, $path); if ($route) { return $route; } @@ -131,20 +135,18 @@ public function match(ServerRequestInterface $request) * * @param Route $proto The proto-route to match against. * - * @param string $name The route name. - * * @param string $path The request path. * * @return mixed False on failure, or a Route on match. * */ - protected function requestRoute($request, $proto, $name, $path) + protected function requestRoute($request, $proto, $path) { if (! $proto->isRoutable) { - return; + return false; } $route = clone $proto; - return $this->applyRules($request, $route, $name, $path); + return $this->applyRules($request, $route, $route->name, $path); } /** @@ -261,4 +263,27 @@ public function getMatchedRoute() { return $this->matchedRoute; } + + /** + * Split the URL into URL Segments and check for matching routes per segment + * This segment could return a list of possible routes + * + * @param string $path + * @return \RecursiveArrayIterator + */ + private function getMatchedTree($path) + { + $node = $this->map->getAsTreeRouteNode(); + foreach (explode('/', trim($path, '/')) as $segment) { + if (isset($node[$segment])) { + $node = $node[$segment]; + continue; + } + if (isset($node['{}'])) { + $node = $node['{}']; + } + } + + return new \RecursiveArrayIterator($node); + } } diff --git a/tests/Benchmark/GeneratorBench.php b/tests/Benchmark/GeneratorBench.php new file mode 100644 index 0000000..bb18e25 --- /dev/null +++ b/tests/Benchmark/GeneratorBench.php @@ -0,0 +1,60 @@ +routesProvider() as $key => $route) { + $map->get($key, $route, static function () use ($key) { return $key; }); + } + + $map->get('dummy', '/api/user/{id}/{action}/{controller:[a-zA-Z][a-zA-Z0-9_-]{1,}}{/param1,param2}'); + $this->generator = new Generator($map); + } + + + private function routesProvider() + { + $segments = ['one', 'two', 'three', 'four', 'five', 'six']; + $routesPerSegment = 100; + + $routeSegment = ''; + foreach ($segments as $index => $segment) { + $routeSegment .= '/' . $segment; + for ($i = 1; $i <= $routesPerSegment; $i++) { + yield $index . '-' . $i => $routeSegment . $i; + } + } + } + + + /** + * @Revs(1000) + * @Iterations (10) + */ + public function benchMatch() + { + $this->generator->generate('dummy', [ + 'id' => 1, + 'action' => 'doSomethingAction', + 'controller' => 'My_User-Controller1', + 'param1' => 'value1', + 'param2' => 'value2', + ]); + } +} \ No newline at end of file diff --git a/tests/Benchmark/MatchBench.php b/tests/Benchmark/MatchBench.php new file mode 100644 index 0000000..ff3ab6f --- /dev/null +++ b/tests/Benchmark/MatchBench.php @@ -0,0 +1,70 @@ +container = new RouterContainer(); + $map = $this->container->getMap(); + + foreach ($this->routesProvider() as $key => $route) { + $map->get($key, $route, static function () use ($key) { return $key; }); + } + + $this->treeNodes = $map->getAsTreeRouteNode(); + } + + /** + * @Iterations(3) + */ + public function benchMatch() + { + $this->container->getMap()->treeRoutes = $this->treeNodes; + $matcher = $this->container->getMatcher(); + foreach ($this->routesProvider() as $route) { + $result = $matcher->match($this->stringToRequest($route)); + if ($result === false) { + throw new \RuntimeException(sprintf('Expected route "%s" to be an match', $route)); + } + } + } + + private function routesProvider() + { + $segments = ['one', 'two', 'three', 'four', 'five', 'six']; + $routesPerSegment = 100; + + $routeSegment = ''; + foreach ($segments as $index => $segment) { + $routeSegment .= '/' . $segment; + for ($i = 1; $i <= $routesPerSegment; $i++) { + yield $index . '-' . $i => $routeSegment . $i; + } + } + } + + /** + * @param string $url + * @return ServerRequestInterface + */ + private function stringToRequest($url) + { + return new ServerRequest('GET', $url, [], null, '1.1', []); + } +} \ No newline at end of file diff --git a/tests/MapTest.php b/tests/MapTest.php index 13681bd..0195274 100644 --- a/tests/MapTest.php +++ b/tests/MapTest.php @@ -137,4 +137,53 @@ public function testGetAndSetRoutes() $this->assertIsRoute($actual['page.read']); $this->assertEquals('/page/{id}{format}', $actual['page.read']->path); } + + public function testGetAsTreeRouteNodeSuccess() + { + $routes = [ + (new Route())->path('/api/users'), + (new Route())->path('/api/users/{id}'), + (new Route())->path('/api/users/{id}/delete'), + (new Route())->path('/api/archive{/year,month,day}'), + (new Route())->path('/api/{controller:[a-zA-Z][a-zA-Z0-9_-]{1,}}/{action}'), + (new Route())->path('/api/users/{id}/not-routeable')->isRoutable(false), + ]; + $sut = new Map(new Route()); + $sut->setRoutes($routes); + + $result = $sut->getAsTreeRouteNode(); + + $this->assertSame([ + 'api' => [ + 'users' => [ + spl_object_hash($routes[0]) => $routes[0], + '{}' => [ + spl_object_hash($routes[1]) => $routes[1], + spl_object_hash($routes[2]) => $routes[2], + 'delete' => [ + spl_object_hash($routes[2]) => $routes[2], + ], + ], + ], + 'archive' => [ + spl_object_hash($routes[3]) => $routes[3], + '{}' => [ // year + spl_object_hash($routes[3]) => $routes[3], + '{}' => [ // month + spl_object_hash($routes[3]) => $routes[3], + '{}' => [ // day + spl_object_hash($routes[3]) => $routes[3], + ], + ], + ], + ], + '{}' => [ + spl_object_hash($routes[4]) => $routes[4], + '{}' => [ + spl_object_hash($routes[4]) => $routes[4], + ], + ], + ], + ], $result); + } } diff --git a/tests/MatcherTest.php b/tests/MatcherTest.php index 67113c4..155b398 100644 --- a/tests/MatcherTest.php +++ b/tests/MatcherTest.php @@ -1,6 +1,8 @@ match($request); $expect = [ - 'debug: /bar FAILED Aura\Router\Rule\Path ON foo', 'debug: /bar MATCHED ON bar', ]; $actual = $logger->lines; $this->assertSame($expect, $actual); $this->assertRoute($bar, $matcher->getMatchedRoute()); } + + public function testMatchWithMatchedTree() + { + $routes = [ + (new Route())->path('/api/users'), + (new Route())->path('/api/users/{id}'), + (new Route())->path('/api/users/{id}/delete'), + (new Route())->path('/api/archive{/year,month,day}'), + (new Route())->path('/api/{controller:[a-zA-Z][a-zA-Z0-9_-]{1,}}/{action}'), + (new Route())->path('/not-routeable')->isRoutable(false), + ]; + $map = new Map(new Route()); + $map->setRoutes($routes); + + $sut = new Matcher( + $map, + $this->createMock(LoggerInterface::class), + new RuleIterator() + ); + + self::assertNotFalse($sut->match($this->newRequest('/api/users'))); + self::assertNotFalse($sut->match($this->newRequest('/api/users/1'))); + self::assertNotFalse($sut->match($this->newRequest('/api/users/1/delete'))); + self::assertNotFalse($sut->match($this->newRequest('/api/archive'))); + self::assertNotFalse($sut->match($this->newRequest('/api/archive/2025'))); + self::assertNotFalse($sut->match($this->newRequest('/api/archive/2025/05'))); + self::assertNotFalse($sut->match($this->newRequest('/api/archive/2025/05/22'))); + self::assertNotFalse($sut->match($this->newRequest('/api/valid-controller-name/action'))); + + self::assertFalse($sut->match($this->newRequest('/not-routeable'))); + } }