Skip to content

Commit f38670a

Browse files
📝 Add docstrings to tree-node-storage
Docstrings generation was requested by @koriym. * #208 (comment) The following files were modified: * `src/Map.php` * `src/Matcher.php` * `tests/Benchmark/GeneratorBench.php` * `tests/Benchmark/MatchBench.php`
1 parent f1c0107 commit f38670a

File tree

4 files changed

+260
-46
lines changed

4 files changed

+260
-46
lines changed

src/Map.php

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -387,23 +387,15 @@ public function put($name, $path, $handler = null)
387387
return $route;
388388
}
389389

390-
/**
391-
*
392-
* Attaches routes to a specific path prefix, and prefixes the attached
393-
* route names.
394-
*
395-
* @param string $namePrefix The prefix for all route names being attached.
396-
*
397-
* @param string $pathPrefix The prefix for all route paths being attached.
398-
*
399-
* @param callable $callable A callable that uses the Map to add new
400-
* routes. Its signature is `function (\Aura\Router\Map $map)`; $this
401-
* Map instance will be passed to the callable.
390+
/****
391+
* Groups routes under a common name and path prefix, applying the prefixes to all routes added within the provided callable.
402392
*
403-
* @throws Exception\ImmutableProperty
404-
*
405-
* @return void
393+
* Temporarily updates the prototype route with the given name and path prefixes, invokes the callable to add routes using the updated prototype, and then restores the original prototype. All routes added within the callable will have the specified prefixes applied.
406394
*
395+
* @param string $namePrefix Prefix to prepend to the names of attached routes.
396+
* @param string $pathPrefix Prefix to prepend to the paths of attached routes.
397+
* @param callable $callable Function that receives this map instance and adds routes.
398+
* @throws Exception\ImmutableProperty If the prototype route's properties are immutable.
407399
*/
408400
public function attach($namePrefix, $pathPrefix, callable $callable)
409401
{
@@ -420,4 +412,53 @@ public function attach($namePrefix, $pathPrefix, callable $callable)
420412
$callable($this);
421413
$this->protoRoute = $old;
422414
}
415+
416+
/****
417+
* Converts all routable routes with defined paths into a hierarchical tree structure keyed by path segments.
418+
*
419+
* Each route path is normalized by replacing grouped optional parameters and individual parameters with generic placeholders (`{}`), then split into segments to build a nested associative array. Routes are stored at leaf nodes keyed by their object hash, and routes with grouped optional parameters are also stored at parent nodes. This structure optimizes route matching by reducing the number of routes to check per segment.
420+
*
421+
* @return array<string, Route|array<string, mixed>> Nested array representing the route tree, where each segment is a key and parameter segments use the key '{}'.
422+
*/
423+
public function getAsTreeRouteNode()
424+
{
425+
$treeRoutes = [];
426+
foreach ($this->routes as $route) {
427+
if (! $route->isRoutable || $route->path === null) {
428+
continue;
429+
}
430+
431+
// replace "{/year,month,day}" parameters with /{}/{}/{}
432+
$routePath = preg_replace_callback(
433+
'~{/((?:\w+,?)+)}~',
434+
static function (array $matches) {
435+
$variables = explode(',', $matches[1]);
436+
437+
return '/' . implode('/', array_fill(0, count($variables), '{}'));
438+
},
439+
$route->path
440+
) ?: $route->path;
441+
$paramsAreOptional = $routePath !== $route->path;
442+
443+
// This regexp will also work with "{controller:[a-zA-Z][a-zA-Z0-9_-]{1,}}"
444+
$routePath = preg_replace('~{(?:[^{}]*|(?R))*}~', '{}', $routePath) ?: $routePath;
445+
$node = &$treeRoutes;
446+
foreach (explode('/', trim($routePath, '/')) as $segment) {
447+
if (strpos($segment, '{') === 0) {
448+
if ($paramsAreOptional) {
449+
$node[spl_object_hash($route)] = $route;
450+
}
451+
$node = &$node['{}'];
452+
$node[spl_object_hash($route)] = $route;
453+
continue;
454+
}
455+
$node = &$node[$segment];
456+
}
457+
458+
$node[spl_object_hash($route)] = $route;
459+
unset($node);
460+
}
461+
462+
return $treeRoutes;
463+
}
423464
}

src/Matcher.php

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -96,15 +96,13 @@ public function __construct(
9696
$this->ruleIterator = $ruleIterator;
9797
}
9898

99-
/**
100-
*
101-
* Gets a route that matches the request.
99+
/****
100+
* Attempts to find and return the first route that matches the given HTTP request.
102101
*
103-
* @param ServerRequestInterface $request The incoming request.
104-
*
105-
* @return Route|false Returns a route object when it finds a match, or
106-
* boolean false if there is no match.
102+
* Filters candidate routes based on the request path, then applies matching rules to each candidate until a match is found. Returns the matched route or false if no route matches.
107103
*
104+
* @param ServerRequestInterface $request The HTTP request to match against available routes.
105+
* @return Route|false The matched route object, or false if no route matches the request.
108106
*/
109107
public function match(ServerRequestInterface $request)
110108
{
@@ -113,8 +111,12 @@ public function match(ServerRequestInterface $request)
113111
$this->failedScore = 0;
114112
$path = $request->getUri()->getPath();
115113

116-
foreach ($this->map as $name => $proto) {
117-
$route = $this->requestRoute($request, $proto, $name, $path);
114+
$possibleRoutes = $this->getMatchedTree($path);
115+
foreach ($possibleRoutes as $proto) {
116+
if (is_array($proto)) {
117+
continue;
118+
}
119+
$route = $this->requestRoute($request, $proto, $path);
118120
if ($route) {
119121
return $route;
120122
}
@@ -123,28 +125,23 @@ public function match(ServerRequestInterface $request)
123125
return false;
124126
}
125127

126-
/**
127-
*
128-
* Match a request to a route.
128+
/****
129+
* Attempts to match a proto-route to the given request and path.
129130
*
130-
* @param ServerRequestInterface $request The request to match against.
131-
*
132-
* @param Route $proto The proto-route to match against.
133-
*
134-
* @param string $name The route name.
131+
* Clones the provided proto-route and applies matching rules to determine if it matches the request and path.
135132
*
133+
* @param ServerRequestInterface $request The HTTP request to match.
134+
* @param Route $proto The proto-route candidate.
136135
* @param string $path The request path.
137-
*
138-
* @return mixed False on failure, or a Route on match.
139-
*
136+
* @return Route|false The matched Route on success, or false if the proto-route is not routable or does not match.
140137
*/
141-
protected function requestRoute($request, $proto, $name, $path)
138+
protected function requestRoute($request, $proto, $path)
142139
{
143140
if (! $proto->isRoutable) {
144-
return;
141+
return false;
145142
}
146143
$route = clone $proto;
147-
return $this->applyRules($request, $route, $name, $path);
144+
return $this->applyRules($request, $route, $route->name, $path);
148145
}
149146

150147
/**
@@ -247,18 +244,35 @@ public function getFailedRoute()
247244
return $this->failedRoute;
248245
}
249246

250-
/**
251-
*
252-
* Returns the result of the call to match() again so you don't need to
253-
* run the matching process again.
254-
*
255-
* @return Route|false|null Returns null if match() has not been called
256-
* yet, false if it has and there was no match, or a Route object if there
257-
* was a match.
247+
/****
248+
* Retrieves the result of the most recent route matching attempt.
258249
*
250+
* @return Route|false|null The matched Route object, false if no route matched, or null if matching has not been attempted.
259251
*/
260252
public function getMatchedRoute()
261253
{
262254
return $this->matchedRoute;
263255
}
256+
257+
/****
258+
* Traverses the route map tree according to the given URL path segments and returns an iterator over the matching subtree of routes.
259+
*
260+
* @param string $path The URL path to match, e.g., "/users/123".
261+
* @return \RecursiveArrayIterator Iterator over the subtree of routes matching the path segments.
262+
*/
263+
private function getMatchedTree($path)
264+
{
265+
$node = $this->map->getAsTreeRouteNode();
266+
foreach (explode('/', trim($path, '/')) as $segment) {
267+
if (isset($node[$segment])) {
268+
$node = $node[$segment];
269+
continue;
270+
}
271+
if (isset($node['{}'])) {
272+
$node = $node['{}'];
273+
}
274+
}
275+
276+
return new \RecursiveArrayIterator($node);
277+
}
264278
}

tests/Benchmark/GeneratorBench.php

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
namespace Aura\Router\Benchmark;
4+
5+
use Aura\Router\Generator;
6+
use Aura\Router\Map;
7+
use Aura\Router\Route;
8+
9+
/**
10+
* @BeforeMethods("setUp")
11+
*/
12+
class GeneratorBench
13+
{
14+
/**
15+
* @var Generator
16+
*/
17+
private $generator;
18+
19+
/**
20+
* Prepares the route map and initializes the Generator instance for benchmarking.
21+
*
22+
* Populates the route map with dynamically generated routes and a complex 'dummy' route, then creates the Generator used in benchmark tests.
23+
*/
24+
public function setUp()
25+
{
26+
$map = new Map(new Route());
27+
foreach ($this->routesProvider() as $key => $route) {
28+
$map->get($key, $route, static function () use ($key) { return $key; });
29+
}
30+
31+
$map->get('dummy', '/api/user/{id}/{action}/{controller:[a-zA-Z][a-zA-Z0-9_-]{1,}}{/param1,param2}');
32+
$this->generator = new Generator($map);
33+
}
34+
35+
36+
/**
37+
* Yields a series of route paths with incremental segments and numeric suffixes.
38+
*
39+
* Each yielded key is a string in the format "{segmentIndex}-{routeNumber}", and the value is the corresponding route path composed of cumulative segments and a numeric suffix.
40+
*
41+
* @return \Generator<string, string> Generator yielding route keys and their corresponding paths.
42+
*/
43+
private function routesProvider()
44+
{
45+
$segments = ['one', 'two', 'three', 'four', 'five', 'six'];
46+
$routesPerSegment = 100;
47+
48+
$routeSegment = '';
49+
foreach ($segments as $index => $segment) {
50+
$routeSegment .= '/' . $segment;
51+
for ($i = 1; $i <= $routesPerSegment; $i++) {
52+
yield $index . '-' . $i => $routeSegment . $i;
53+
}
54+
}
55+
}
56+
57+
58+
/**
59+
* Benchmarks the URL generation for the 'dummy' route with a fixed set of parameters.
60+
*
61+
* Executes 1000 repetitions per iteration over 10 iterations to measure the performance of the route generator.
62+
*/
63+
public function benchMatch()
64+
{
65+
$this->generator->generate('dummy', [
66+
'id' => 1,
67+
'action' => 'doSomethingAction',
68+
'controller' => 'My_User-Controller1',
69+
'param1' => 'value1',
70+
'param2' => 'value2',
71+
]);
72+
}
73+
}

tests/Benchmark/MatchBench.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
namespace Aura\Router\Benchmark;
4+
5+
use Aura\Router\RouterContainer;
6+
use GuzzleHttp\Psr7\ServerRequest;
7+
use Psr\Http\Message\ServerRequestInterface;
8+
9+
/**
10+
* @BeforeMethods("setUp")
11+
*/
12+
class MatchBench
13+
{
14+
/** @var RouterContainer $container */
15+
private $container;
16+
/**
17+
* @var \Aura\Router\Route[]|\mixed[][]
18+
*/
19+
private $treeNodes;
20+
21+
/**
22+
* Prepares the router container and populates it with a set of generated GET routes for benchmarking.
23+
*
24+
* Initializes the route map with routes from the provider and stores the resulting route tree structure for later use.
25+
*/
26+
public function setUp()
27+
{
28+
$this->container = new RouterContainer();
29+
$map = $this->container->getMap();
30+
31+
foreach ($this->routesProvider() as $key => $route) {
32+
$map->get($key, $route, static function () use ($key) { return $key; });
33+
}
34+
35+
$this->treeNodes = $map->getAsTreeRouteNode();
36+
}
37+
38+
/**
39+
* Benchmarks the performance of matching a set of generated routes against the router.
40+
*
41+
* Restores the pre-built route tree, then iterates through all generated routes, converting each to a request and verifying that it matches. Throws a RuntimeException if any route fails to match.
42+
*/
43+
public function benchMatch()
44+
{
45+
$this->container->getMap()->treeRoutes = $this->treeNodes;
46+
$matcher = $this->container->getMatcher();
47+
foreach ($this->routesProvider() as $route) {
48+
$result = $matcher->match($this->stringToRequest($route));
49+
if ($result === false) {
50+
throw new \RuntimeException(sprintf('Expected route "%s" to be an match', $route));
51+
}
52+
}
53+
}
54+
55+
/**
56+
* Generates a sequence of route strings by incrementally building path segments and appending numeric suffixes.
57+
*
58+
* Yields 600 routes in total, with each route keyed by its segment index and number (e.g., "2-45") and valued as the corresponding path (e.g., "/one/two/three45").
59+
*
60+
* @return \Generator<string, string> Route keys mapped to their corresponding path strings.
61+
*/
62+
private function routesProvider()
63+
{
64+
$segments = ['one', 'two', 'three', 'four', 'five', 'six'];
65+
$routesPerSegment = 100;
66+
67+
$routeSegment = '';
68+
foreach ($segments as $index => $segment) {
69+
$routeSegment .= '/' . $segment;
70+
for ($i = 1; $i <= $routesPerSegment; $i++) {
71+
yield $index . '-' . $i => $routeSegment . $i;
72+
}
73+
}
74+
}
75+
76+
/****
77+
* Creates a PSR-7 ServerRequest object for a GET request to the specified URL.
78+
*
79+
* @param string $url The URL to use for the request.
80+
* @return ServerRequestInterface A ServerRequest instance representing a GET request to the given URL.
81+
*/
82+
private function stringToRequest($url)
83+
{
84+
return new ServerRequest('GET', $url, [], null, '1.1', []);
85+
}
86+
}

0 commit comments

Comments
 (0)