diff --git a/src/Router.php b/src/Router.php index 2444101..30bfc67 100644 --- a/src/Router.php +++ b/src/Router.php @@ -125,7 +125,8 @@ public static function match(string $method, string $path): Route|null } $segments = array_values(array_filter(explode('/', $path))); - $normalizedPath = '/' . implode('/', $segments); + $patternKey = implode('/', $segments); + $normalizedPath = '/' . $patternKey; $cacheKey = $method . ':' . $normalizedPath; if (array_key_exists($cacheKey, self::$matchCache)) { $cached = self::$matchCache[$cacheKey]; @@ -143,6 +144,13 @@ public static function match(string $method, string $path): Route|null $segmentsCount = count($segments); + if (isset(self::$routes[$method][$patternKey])) { + $route = self::$routes[$method][$patternKey]; + $route->setMatchedPath($patternKey); + self::cacheResult($cacheKey, $route, $patternKey); + return $route; + } + if (isset(self::$tries[$method])) { $result = self::$tries[$method]->match($segments); diff --git a/src/RouterTrie.php b/src/RouterTrie.php index 1f9c254..db2f98c 100644 --- a/src/RouterTrie.php +++ b/src/RouterTrie.php @@ -5,29 +5,88 @@ class RouterTrie { protected ?Route $route = null; - protected ?array $children = null; + + /** @var array,node:RouterTrie}>|null */ + protected ?array $edges = null; protected ?string $matchedPattern = null; + private static function token(string $segment): string + { + return str_starts_with($segment, ':') ? ':' : $segment; + } + /** * @param string $matchedPattern The matched path pattern for this route */ public function insert(array $segments, Route $route, string $matchedPattern): void { - + $tokens = array_map([self::class, 'token'], $segments); $node = $this; + $index = 0; + $count = count($tokens); + + while ($index < $count) { + $key = $tokens[$index]; + + if ($node->edges === null || !isset($node->edges[$key])) { + $child = new self(); + $node->edges ??= []; + $node->edges[$key] = [ + 'label' => array_slice($tokens, $index), + 'node' => $child, + ]; + $node = $child; + $index = $count; + break; + } - foreach ($segments as $segment) { - $key = str_starts_with($segment, ':') ? ':' : $segment; + $edge = $node->edges[$key]; + $label = $edge['label']; + $remaining = array_slice($tokens, $index); + $max = min(count($label), count($remaining)); + + $common = 0; + while ($common < $max && $label[$common] === $remaining[$common]) { + $common++; + } - if ($node->children === null) { - $node->children = []; + if ($common === count($label)) { + $node = $edge['node']; + $index += $common; + continue; } - if (!isset($node->children[$key])) { - $node->children[$key] = new self(); + $splitNode = new self(); + $prefix = array_slice($label, 0, $common); + $oldRemainder = array_slice($label, $common); + + $splitNode->edges = [ + $oldRemainder[0] => [ + 'label' => $oldRemainder, + 'node' => $edge['node'], + ], + ]; + + $node->edges[$key] = [ + 'label' => $prefix, + 'node' => $splitNode, + ]; + + $newRemainder = array_slice($remaining, $common); + if (empty($newRemainder)) { + $splitNode->route = $route; + $splitNode->matchedPattern = $matchedPattern; + return; } - $node = $node->children[$key]; + $newChild = new self(); + $splitNode->edges[$newRemainder[0]] = [ + 'label' => $newRemainder, + 'node' => $newChild, + ]; + $node = $newChild; + $index = $count; + break; } $node->route = $route; @@ -39,7 +98,7 @@ public function insert(array $segments, Route $route, string $matchedPattern): v */ public function match(array $segments): array { - $result = $this->matchRecursive($segments, count($segments), 0); + $result = $this->matchRecursive($segments, 0, count($segments)); return [ 'route' => $result['route'], 'pattern' => $result['pattern'] @@ -49,7 +108,7 @@ public function match(array $segments): array /** * @return array{route:Route|null,pattern:string|null} */ - private function matchRecursive(array $segments, int $segmentsCount, int $index): array + private function matchRecursive(array $segments, int $index, int $segmentsCount): array { if ($index >= $segmentsCount) { return [ @@ -60,23 +119,49 @@ private function matchRecursive(array $segments, int $segmentsCount, int $index) $segment = $segments[$index]; - if ($this->children !== null && isset($this->children[$segment])) { - $result = $this->children[$segment]->matchRecursive($segments, $segmentsCount, $index + 1); - if ($result['route'] !== null) { - return $result; - } + $result = $this->matchEdge($segment, $segments, $index, $segmentsCount); + if ($result['route'] !== null) { + return $result; } - if ($this->children !== null && isset($this->children[':'])) { - $result = $this->children[':']->matchRecursive($segments, $segmentsCount, $index + 1); - if ($result['route'] !== null) { - return $result; - } + $result = $this->matchEdge(':', $segments, $index, $segmentsCount); + if ($result['route'] !== null) { + return $result; } return ['route' => null, 'pattern' => null]; } + /** + * @return array{route:Route|null,pattern:string|null} + */ + private function matchEdge(string $key, array $segments, int $index, int $segmentsCount): array + { + if ($this->edges === null || !isset($this->edges[$key])) { + return ['route' => null, 'pattern' => null]; + } + + $edge = $this->edges[$key]; + $label = $edge['label']; + $labelCount = count($label); + + if ($index + $labelCount > $segmentsCount) { + return ['route' => null, 'pattern' => null]; + } + + for ($i = 0; $i < $labelCount; $i++) { + $token = $label[$i]; + if ($token === ':') { + continue; + } + if ($token !== $segments[$index + $i]) { + return ['route' => null, 'pattern' => null]; + } + } + + return $edge['node']->matchRecursive($segments, $index + $labelCount, $segmentsCount); + } + /** * Get trie statistics for debugging * @@ -109,9 +194,10 @@ private function collectStats(int &$nodes, int &$maxDepth, int &$routes, int $cu $routes++; } - if ($this->children !== null) { - foreach ($this->children as $child) { - $child->collectStats($nodes, $maxDepth, $routes, $currentDepth + 1); + if ($this->edges !== null) { + foreach ($this->edges as $edge) { + $edgeDepth = $currentDepth + count($edge['label']); + $edge['node']->collectStats($nodes, $maxDepth, $routes, $edgeDepth); } } } diff --git a/tests/RouterStressTest.php b/tests/RouterStressTest.php new file mode 100644 index 0000000..2f8f9ce --- /dev/null +++ b/tests/RouterStressTest.php @@ -0,0 +1,103 @@ +assertSame($staticRoutes[$staticKey], $matched); + $this->assertEquals("static/$staticKey", $matched->getMatchedPath()); + } + + for ($i = 0; $i < $count; $i++) { + $dynamicKey = "d$i"; + $matched = Router::match(App::REQUEST_METHOD_GET, "/dyn/$dynamicKey/value"); + $this->assertSame($dynamicRoutes[$dynamicKey], $matched); + $this->assertEquals("dyn/$dynamicKey/:::", $matched->getMatchedPath()); + } + + $matched = Router::match(App::REQUEST_METHOD_GET, '/wild/anything/else'); + $this->assertSame($wildcard, $matched); + $this->assertEquals('wild/*', $matched->getMatchedPath()); + + $reflection = new ReflectionClass(Router::class); + $property = $reflection->getProperty('matchCache'); + $cache = $property->getValue(); + $this->assertLessThanOrEqual(Router::ROUTE_MATCH_CACHE_LIMIT, count($cache)); + } + + public function testStressDeepDynamicRoutes(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/a/:b/c/:d/e/:f/g/:h/i/:j/k/:l/m/:n'); + Router::addRoute($route); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/a/1/c/2/e/3/g/4/i/5/k/6/m/7'); + $this->assertSame($route, $matched); + $this->assertEquals('a/:::/c/:::/e/:::/g/:::/i/:::/k/:::/m/:::', $matched->getMatchedPath()); + } + + public function testStressCacheEviction(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/users/:id'); + Router::addRoute($route); + + $total = Router::ROUTE_MATCH_CACHE_LIMIT + 100; + for ($i = 0; $i < $total; $i++) { + Router::match(App::REQUEST_METHOD_GET, "/users/$i"); + } + + $reflection = new ReflectionClass(Router::class); + $property = $reflection->getProperty('matchCache'); + $cache = $property->getValue(); + + $this->assertCount(Router::ROUTE_MATCH_CACHE_LIMIT, $cache); + $this->assertArrayHasKey('GET:/users/' . ($total - 1), $cache); + } +}