Files
michaelschiemer/src/Framework/Core/RouteCompiler.php
Michael Schiemer 5050c7d73a docs: consolidate documentation into organized structure
- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
2025-10-05 11:05:04 +02:00

336 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Framework\Core;
use App\Framework\Attributes\Route;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\Router\CompiledPattern;
use App\Framework\Router\CompiledRoutes;
use App\Framework\Router\RouteData;
use App\Framework\Router\RouteNameInterface;
use App\Framework\Router\ValueObjects\MethodParameter;
use App\Framework\Router\ValueObjects\ParameterCollection;
use App\Framework\Router\ValueObjects\SubdomainPattern;
final readonly class RouteCompiler
{
/** @var array<string, StaticRoute|DynamicRoute> */
private array $named;
/**
* Compile routes directly from DiscoveredAttribute objects with subdomain support
* @return array<string, array<string, array{static: array<string, StaticRoute>, dynamic: array<int, DynamicRoute>}>>
*/
public function compile(DiscoveredAttribute ...$discoveredRoutes): array
{
$compiled = [];
$named = [];
foreach ($discoveredRoutes as $discoveredAttribute) {
// Create actual Route attribute instance
$routeAttribute = $discoveredAttribute->createAttributeInstance();
if (! $routeAttribute instanceof Route) {
continue;
}
// Extract route data directly from the Route attribute
$method = strtoupper($routeAttribute->method->value);
$path = $routeAttribute->getPathAsString();
$routeName = $this->extractRouteName($routeAttribute->name);
// Process subdomain patterns
$subdomainPatterns = SubdomainPattern::fromInput($routeAttribute->subdomain);
// If no subdomains specified, use default
if (empty($subdomainPatterns)) {
$subdomainPatterns = [new SubdomainPattern('')];
}
foreach ($subdomainPatterns as $subdomainPattern) {
$subdomainKey = $subdomainPattern->getCompilationKey();
$compiled[$method] ??= [];
$compiled[$method][$subdomainKey] ??= ['static' => [], 'dynamic' => []];
if (! str_contains($path, '{')) {
// Static route
$parameterCollection = $this->createParameterCollection($discoveredAttribute->additionalData['parameters'] ?? []);
$staticRoute = new StaticRoute(
controller : $discoveredAttribute->className->getFullyQualified(),
action : $discoveredAttribute->methodName?->toString() ?? '',
parameters : $discoveredAttribute->additionalData['parameters'] ?? [],
name : $routeName ?? '',
path : $path,
attributes : $discoveredAttribute->additionalData['attributes'] ?? [],
parameterCollection: $parameterCollection
);
$compiled[$method][$subdomainKey]['static'][$path] = $staticRoute;
if ($routeName) {
$named[$routeName] = $staticRoute;
}
} else {
// Dynamic route
$paramNames = [];
$regex = $this->convertPathToRegex($path, $paramNames);
$parameterCollection = $this->createParameterCollection($discoveredAttribute->additionalData['parameters'] ?? []);
$dynamicRoute = new DynamicRoute(
regex : $regex,
paramNames : $paramNames,
controller : $discoveredAttribute->className->getFullyQualified(),
action : $discoveredAttribute->methodName?->toString() ?? '',
parameters : $discoveredAttribute->additionalData['parameters'] ?? [],
paramValues : [],
name : $routeName ?? '',
path : $path,
attributes : $discoveredAttribute->additionalData['attributes'] ?? [],
parameterCollection: $parameterCollection
);
$compiled[$method][$subdomainKey]['dynamic'][] = $dynamicRoute;
if ($routeName) {
$named[$routeName] = $dynamicRoute;
}
}
}
}
if (! isset($this->named)) {
$this->named = $named;
}
return $compiled;
}
/**
* Convert legacy parameters array to ParameterCollection
*/
private function createParameterCollection(array $parameters): ParameterCollection
{
$methodParameters = [];
foreach ($parameters as $param) {
$methodParameters[] = new MethodParameter(
name: $param['name'],
type: $param['type'],
isBuiltin: $param['isBuiltin'],
hasDefault: $param['hasDefault'],
default: $param['default'] ?? null
);
}
return new ParameterCollection(...$methodParameters);
}
/**
* @param array<int, array{method: string, path: string, controller: class-string, handler: string}> $routes
* @return array<string, StaticRoute|DynamicRoute>
*/
public function compileNamedRoutes(array $routes): array
{
return $this->named;
}
/**
* Konvertiert zB. /user/{id}/edit → ~^/user/([^/]+)/edit$~ und gibt ['id'] als Parameternamen zurück.
*
* @param string $path
* @param array<string> &$paramNames
* @return string
*/
private function convertPathToRegex(string $path, array &$paramNames): string
{
$paramNames = [];
$regex = preg_replace_callback('#\{(\w+)(\*)?}#', function ($matches) use (&$paramNames) {
$paramNames[] = $matches[1];
// Wenn {id*} dann erlaube Slashes, aber mache es non-greedy
if (isset($matches[2]) && $matches[2] === '*') {
return '(.+?)'; // Non-greedy: matcht so wenig wie möglich
}
return '([^/]+)'; // Keine Slashes
}, $path);
return '~^' . $regex . '$~';
}
public function getAttributeClass(): string
{
return Route::class;
}
/**
* Compile optimized routes directly from DiscoveredAttribute objects with subdomain support
*/
public function compileOptimized(DiscoveredAttribute ...$discoveredRoutes): CompiledRoutes
{
$compiled = $this->compile(...$discoveredRoutes);
$optimizedStatic = [];
$optimizedDynamic = [];
foreach ($compiled as $method => $subdomainRoutes) {
foreach ($subdomainRoutes as $subdomainKey => $routes) {
$optimizedStatic[$method][$subdomainKey] = $routes['static'];
if (! empty($routes['dynamic'])) {
$optimizedDynamic[$method][$subdomainKey] = $this->createCompiledPattern($routes['dynamic']);
}
}
}
return new CompiledRoutes($optimizedStatic, $optimizedDynamic, $this->named);
}
/**
* Creates optimized compiled patterns with route prioritization
* @param array<int, DynamicRoute> $dynamicRoutes
*/
private function createCompiledPattern(array $dynamicRoutes): CompiledPattern
{
// Sort routes by priority (specific patterns first, then by path complexity)
$prioritizedRoutes = $this->prioritizeRoutes($dynamicRoutes);
// Group routes into batches for smaller regex patterns
$routeBatches = $this->groupRoutesByComplexity($prioritizedRoutes);
$compiledBatches = [];
/** @var array<int, RouteData> $routeData */
$routeData = [];
$globalIndex = 0;
foreach ($routeBatches as $batchName => $batch) {
$patterns = [];
$currentIndex = 1; // After full match (Index 0)
foreach ($batch as $route) {
$pattern = $this->stripAnchors($route->regex);
$patterns[] = "({$pattern})";
// This route's main capture group index
$routeGroupIndex = $currentIndex;
// Parameter-Mapping berechnen - parameters are INSIDE the route pattern
// So their indices are routeGroupIndex + 1, routeGroupIndex + 2, etc.
$paramMap = [];
$paramOffset = 1; // Parameters start right after the route's capture group
foreach ($route->paramNames as $paramName) {
$paramMap[$paramName] = $routeGroupIndex + $paramOffset++;
}
// Now advance currentIndex past ALL capture groups in this route's pattern
// That's 1 (the route group) + count(paramNames) (the parameters inside it)
$currentIndex += 1 + count($route->paramNames);
$routeData[$globalIndex] = new RouteData(
route: $route,
paramMap: $paramMap,
routeGroupIndex: $routeGroupIndex,
batch: $batchName,
regex: '~^' . $this->stripAnchors($route->regex) . '$~' // Pre-compiled individual regex
);
$globalIndex++;
}
$compiledBatches[$batchName] = [
'regex' => '~^(?:' . implode('|', $patterns) . ')$~',
'routes' => array_slice($routeData, $globalIndex - count($batch), count($batch), true),
];
}
// Use the first batch's regex for backward compatibility, but store all batches
$primaryRegex = ! empty($compiledBatches) ? reset($compiledBatches)['regex'] : '~^$~';
return new CompiledPattern($primaryRegex, $routeData, $compiledBatches);
}
/**
* Prioritize routes by specificity and expected frequency
* @param array<DynamicRoute> $routes
* @return array<DynamicRoute>
*/
private function prioritizeRoutes(array $routes): array
{
usort($routes, function ($a, $b) {
// 1. API routes first (typically more frequent)
$aIsApi = str_starts_with($a->path, '/api/');
$bIsApi = str_starts_with($b->path, '/api/');
if ($aIsApi !== $bIsApi) {
return $bIsApi <=> $aIsApi; // API routes first
}
// 2. Routes with fewer parameters first (more specific)
$aParamCount = count($a->paramNames);
$bParamCount = count($b->paramNames);
if ($aParamCount !== $bParamCount) {
return $aParamCount <=> $bParamCount;
}
// 3. Shorter paths first (typically more specific)
$aLength = strlen($a->path);
$bLength = strlen($b->path);
return $aLength <=> $bLength;
});
return $routes;
}
/**
* Group routes by complexity for optimized batch processing
* @param array<DynamicRoute> $routes
* @return array<string, array<DynamicRoute>>
*/
private function groupRoutesByComplexity(array $routes): array
{
$groups = [
'simple' => [], // /api/{id}, /user/{id} - 1 parameter
'medium' => [], // /api/{type}/{id} - 2 parameters
'complex' => [], // 3+ parameters or wildcards
];
foreach ($routes as $route) {
$paramCount = count($route->paramNames);
$hasWildcard = str_contains($route->path, '{*}');
if ($hasWildcard || $paramCount >= 3) {
$groups['complex'][] = $route;
} elseif ($paramCount === 2) {
$groups['medium'][] = $route;
} else {
$groups['simple'][] = $route;
}
}
// Remove empty groups
return array_filter($groups, fn ($group) => ! empty($group));
}
private function stripAnchors(string $regex): string
{
if (preg_match('/^~\^(.+)\$~$/', $regex, $matches)) {
return $matches[1];
}
return $regex;
}
/**
* Extract route name as string from enum or string
*/
private function extractRouteName(RouteNameInterface|string|null $routeName): string
{
if ($routeName instanceof RouteNameInterface) {
return $routeName->value;
}
return (string) ($routeName ?? '');
}
}