diff --git a/benchmarks/benchmarks/Http/HttpKernelBench.php b/benchmarks/benchmarks/Http/HttpKernelBench.php index 365e616..49d304f 100644 --- a/benchmarks/benchmarks/Http/HttpKernelBench.php +++ b/benchmarks/benchmarks/Http/HttpKernelBench.php @@ -5,7 +5,9 @@ namespace Evolve\Benchmarks\PhpBench\Http; use Evolve\Benchmarks\Support\BenchmarkFixtureFactory; +use Evolve\Http\HttpKernel; use PhpBench\Attributes as Bench; +use Psr\Http\Message\ServerRequestInterface; use Throwable; #[Bench\Revs(50)] @@ -13,29 +15,56 @@ #[Bench\Warmup(2)] final class HttpKernelBench { + private HttpKernel $kernel; + + private ServerRequestInterface $request; + + private string $scenario = 'static'; + + #[Bench\BeforeMethods(['setUpKernelScenario'])] #[Bench\Groups(['http', 'kernel'])] #[Bench\ParamProviders(['kernelScenarios'])] public function benchHttpKernelScenario(array $params): void + { + $this->handlePreparedKernelRequest(); + } + + #[Bench\BeforeMethods(['setUpWarmStaticScenario'])] + #[Bench\Groups(['http', 'kernel', 'warm'])] + public function benchRepeatedWarmStaticRequestsThroughSameKernel(): void + { + // One prepared handle() call per revolution preserves the intended warm-kernel benchmark + // semantics without re-creating the fixture inside the timed subject. + $this->handlePreparedKernelRequest(); + } + + public function setUpKernelScenario(array $params): void { $fixture = BenchmarkFixtureFactory::httpKernelFixture($params['scenario']); + $this->scenario = $params['scenario']; + $this->kernel = $fixture['kernel']; + $this->request = $fixture['request']; + } + public function setUpWarmStaticScenario(): void + { + $fixture = BenchmarkFixtureFactory::httpKernelFixture('static'); + $this->scenario = 'static'; + $this->kernel = $fixture['kernel']; + $this->request = $fixture['request']; + } + + public function handlePreparedKernelRequest(): void + { try { - $fixture['kernel']->handle($fixture['request']); + $this->kernel->handle($this->request); } catch (Throwable $exception) { - if (!in_array($params['scenario'], ['not-found', 'method-mismatch'], true)) { + if (!in_array($this->scenario, ['not-found', 'method-mismatch'], true)) { throw $exception; } } } - #[Bench\Groups(['http', 'kernel', 'warm'])] - public function benchRepeatedWarmStaticRequestsThroughSameKernel(): void - { - $fixture = BenchmarkFixtureFactory::httpKernelFixture('static'); - $fixture['kernel']->handle($fixture['request']); - $fixture['kernel']->handle($fixture['request']); - } - /** * @return array */ diff --git a/benchmarks/tests/FixtureCorrectnessTest.php b/benchmarks/tests/FixtureCorrectnessTest.php index edeb120..c99b489 100644 --- a/benchmarks/tests/FixtureCorrectnessTest.php +++ b/benchmarks/tests/FixtureCorrectnessTest.php @@ -124,6 +124,34 @@ public function testContainerResolutionBenchmarkPreparesCachedResolutionBeforeMe self::assertSame('bench.application.9', $bench->applicationServiceId()); } + public function testHttpKernelBenchmarkPreparesKernelThroughBeforeMethods(): void + { + $bench = new \Evolve\Benchmarks\PhpBench\Http\HttpKernelBench(); + $scenarioMethod = new \ReflectionMethod(\Evolve\Benchmarks\PhpBench\Http\HttpKernelBench::class, 'benchHttpKernelScenario'); + $setupMethod = new \ReflectionMethod(\Evolve\Benchmarks\PhpBench\Http\HttpKernelBench::class, 'setUpKernelScenario'); + + self::assertNotEmpty($scenarioMethod->getAttributes('PhpBench\\Attributes\\BeforeMethods')); + self::assertSame(['setUpKernelScenario'], $scenarioMethod->getAttributes('PhpBench\\Attributes\\BeforeMethods')[0]->newInstance()->methods); + self::assertNotEmpty($setupMethod); + + $bench->setUpKernelScenario(['scenario' => 'static']); + $bench->handlePreparedKernelRequest(); + self::assertSame('static', $this->readScenario($bench)); + } + + public function testWarmStaticBenchmarkUsesPreparedKernelAndWarmupExecution(): void + { + $bench = new \Evolve\Benchmarks\PhpBench\Http\HttpKernelBench(); + $warmMethod = new \ReflectionMethod(\Evolve\Benchmarks\PhpBench\Http\HttpKernelBench::class, 'benchRepeatedWarmStaticRequestsThroughSameKernel'); + + self::assertNotEmpty($warmMethod->getAttributes('PhpBench\\Attributes\\BeforeMethods')); + self::assertSame(['setUpWarmStaticScenario'], $warmMethod->getAttributes('PhpBench\\Attributes\\BeforeMethods')[0]->newInstance()->methods); + + $bench->setUpWarmStaticScenario(); + $bench->handlePreparedKernelRequest(); + self::assertSame('static', $this->readScenario($bench)); + } + public function testFirstResolutionBenchmarkMethodsDeclareSingleRevAndZeroWarmup(): void { $appMethod = new \ReflectionMethod(\Evolve\Benchmarks\PhpBench\Core\ContainerResolutionBench::class, 'benchApplicationFirstResolution'); @@ -135,6 +163,14 @@ public function testFirstResolutionBenchmarkMethodsDeclareSingleRevAndZeroWarmup self::assertSame([0], $this->warmupFor($executionMethod)); } + private function readScenario(object $bench): string + { + $reflection = new \ReflectionProperty($bench, 'scenario'); + $reflection->setAccessible(true); + + return $reflection->getValue($bench); + } + private function revsFor(\ReflectionMethod $method): array { $attributes = $method->getAttributes('PhpBench\\Attributes\\Revs'); diff --git a/packages/http/src/Routing/Internal/RoutePattern.php b/packages/http/src/Routing/Internal/RoutePattern.php index fab666b..cc5ea75 100644 --- a/packages/http/src/Routing/Internal/RoutePattern.php +++ b/packages/http/src/Routing/Internal/RoutePattern.php @@ -16,6 +16,8 @@ */ private array $segments; + private bool $isStatic; + /** * @param list $segments */ @@ -24,6 +26,21 @@ private function __construct( array $segments, ) { $this->segments = $segments; + $this->isStatic = !$this->hasParameters($segments); + } + + /** + * @param list $segments + */ + private static function hasParameters(array $segments): bool + { + foreach ($segments as $segment) { + if ($segment['kind'] === 'parameter') { + return true; + } + } + + return false; } public static function fromPath(string $path): self @@ -101,10 +118,25 @@ public function match(string $path): ?array return null; } + if ($this->isStatic) { + return $path === $this->path ? [] : null; + } + $candidateSegments = $path === '/' ? [] : explode('/', substr($path, 1)); + return $this->matchSegments($candidateSegments); + } + + /** + * @param list $candidateSegments + * @return array|null + * + * @internal + */ + public function matchSegments(array $candidateSegments): ?array + { if (count($candidateSegments) !== count($this->segments)) { return null; } @@ -131,4 +163,12 @@ public function match(string $path): ?array return $parameters; } + + /** + * @internal + */ + public function isStatic(): bool + { + return $this->isStatic; + } } diff --git a/packages/http/src/Routing/RouteMatcher.php b/packages/http/src/Routing/RouteMatcher.php index f16c406..32f42af 100644 --- a/packages/http/src/Routing/RouteMatcher.php +++ b/packages/http/src/Routing/RouteMatcher.php @@ -33,8 +33,22 @@ public function match(ServerRequestInterface $request): ?RouteMatch $method = $request->getMethod(); $path = $request->getUri()->getPath(); + $candidateSegments = null; + $segmentsParsed = false; + foreach ($this->compiledRoutes as $entry) { - $parameters = $entry['pattern']->match($path); + $pattern = $entry['pattern']; + + if ($pattern->isStatic()) { + $parameters = $pattern->match($path); + } else { + if (!$segmentsParsed) { + $candidateSegments = $this->parseCandidate($path); + $segmentsParsed = true; + } + + $parameters = $pattern->matchSegments($candidateSegments); + } if ($parameters === null) { continue; @@ -50,6 +64,22 @@ public function match(ServerRequestInterface $request): ?RouteMatch return null; } + /** + * @return list + */ + private function parseCandidate(string $path): array + { + if ($path === '') { + return []; + } + + if ($path[0] !== '/') { + return []; + } + + return $path === '/' ? [] : explode('/', substr($path, 1)); + } + /** * @return list */ @@ -57,9 +87,24 @@ public function allowedMethods(string $path): array { $methods = []; $seen = []; + $candidateSegments = null; + $segmentsParsed = false; foreach ($this->compiledRoutes as $entry) { - if ($entry['pattern']->match($path) === null) { + $pattern = $entry['pattern']; + + if ($pattern->isStatic()) { + $matches = $pattern->match($path) !== null; + } else { + if (!$segmentsParsed) { + $candidateSegments = $this->parseCandidate($path); + $segmentsParsed = true; + } + + $matches = $pattern->matchSegments($candidateSegments) !== null; + } + + if (!$matches) { continue; } diff --git a/packages/http/tests/Unit/Routing/RoutingFoundationTest.php b/packages/http/tests/Unit/Routing/RoutingFoundationTest.php index 09f4fe1..ecce9bd 100644 --- a/packages/http/tests/Unit/Routing/RoutingFoundationTest.php +++ b/packages/http/tests/Unit/Routing/RoutingFoundationTest.php @@ -403,6 +403,75 @@ public function test_parameters_from_one_request_do_not_leak_into_another(): voi self::assertSame(['id' => '99'], $matcher->match($this->request('GET', '/users/99'))?->parameters()); } + public function test_large_static_route_table_matches_correctly(): void + { + $routes = []; + for ($i = 0; $i < 100; $i++) { + $routes[] = $this->route(['GET'], "/static/path/{$i}"); + } + $targetRoute = $this->route(['GET'], '/static/path/50'); + $routes[50] = $targetRoute; + + $matcher = $this->matcher($routes); + + self::assertSame($targetRoute, $matcher->match($this->request('GET', '/static/path/50'))?->route()); + } + + public function test_mixed_static_and_parameterized_large_table_maintains_order(): void + { + $routes = []; + for ($i = 0; $i < 50; $i++) { + $routes[] = $this->route(['GET'], "/static/item/{$i}"); + $routes[] = $this->route(['POST'], '/param/item/{id}'); + } + + $matcher = $this->matcher($routes); + + $match = $matcher->match($this->request('GET', '/static/item/0')); + self::assertNotNull($match); + self::assertSame('/static/item/0', $match->route()->path()); + + $match = $matcher->match($this->request('POST', '/param/item/xyz')); + self::assertNotNull($match); + self::assertSame(['id' => 'xyz'], $match->parameters()); + } + + public function test_allowed_methods_with_mixed_static_and_parameterized_routes(): void + { + $routes = [ + $this->route(['GET'], '/users/{id}'), + $this->route(['POST'], '/users/{id}/action'), + $this->route(['PUT'], '/users/{id}'), + $this->route(['PATCH'], '/users/42'), + ]; + + $matcher = $this->matcher($routes); + + self::assertSame(['GET', 'PUT', 'PATCH'], $matcher->allowedMethods('/users/42')); + self::assertSame(['GET', 'PUT'], $matcher->allowedMethods('/users/99')); + } + + public function test_repeated_matching_with_same_matcher_instance(): void + { + $routes = [ + $this->route(['GET'], '/api/users/{id}'), + $this->route(['POST'], '/api/data/{type}'), + ]; + + $matcher = $this->matcher($routes); + + $match1 = $matcher->match($this->request('GET', '/api/users/alice')); + self::assertSame(['id' => 'alice'], $match1?->parameters()); + + $match2 = $matcher->match($this->request('POST', '/api/data/reports')); + self::assertSame(['type' => 'reports'], $match2?->parameters()); + + $match3 = $matcher->match($this->request('GET', '/api/users/bob')); + self::assertSame(['id' => 'bob'], $match3?->parameters()); + + self::assertNotSame($match1->parameters(), $match3->parameters()); + } + /** * @param iterable $routes */