Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Phaseolies/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -1395,7 +1395,7 @@ protected function reportDeferFailure(\Throwable $failure, ?\Throwable $original
*/
protected function cleanupRequestScopedServices(): void
{
if ($this->has('auth')) {
if ($this->hasInstance('auth')) {
$auth = $this->make('auth');

if ($auth instanceof ActorManager) {
Expand Down
106 changes: 100 additions & 6 deletions src/Phaseolies/DI/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ class Container implements ArrayAccess
*/
private array $resolving = [];

/**
* Cached reflection metadata for concrete classes.
*
* @var array<class-string, array{reflector: \ReflectionClass, parameters: array<int, array{name: string, type: \ReflectionType|null, typeName: string|null, allowsNull: bool, builtin: bool, defaultAvailable: bool, default: mixed, variadic: bool}>}>
*/
private array $buildMetadata = [];

/**
* Whether each class seen by freezeResolved() is a frozen service.
*
Expand Down Expand Up @@ -384,19 +391,18 @@ public function build(string $concrete, array $parameters = []): object
return new $concrete(...$parameters);
}

$reflector = new \ReflectionClass($concrete);
$metadata = $this->buildMetadata[$concrete] ??= $this->inspectBuildMetadata($concrete);
$reflector = $metadata['reflector'];

if (!$reflector->isInstantiable()) {
throw new \RuntimeException("Target [{$concrete}] is not instantiable");
}

$constructor = $reflector->getConstructor();

if (is_null($constructor)) {
if ($metadata['parameters'] === []) {
$instance = new $concrete(...$parameters);
} else {
$dependencies = $this->resolveDependencies(
$constructor->getParameters(),
$dependencies = $this->resolveCachedDependencies(
$metadata['parameters'],
$parameters,
$concrete
);
Expand All @@ -408,6 +414,37 @@ public function build(string $concrete, array $parameters = []): object
return $instance;
}

/**
* Inspect a concrete class once and reuse the metadata for future builds.
*
* @param class-string $concrete
* @return array{reflector: \ReflectionClass, parameters: array<int, array{name: string, type: \ReflectionType|null, typeName: string|null, allowsNull: bool, builtin: bool, defaultAvailable: bool, default: mixed, variadic: bool}>}
*/
private function inspectBuildMetadata(string $concrete): array
{
$reflector = new \ReflectionClass($concrete);

$constructor = $reflector->getConstructor();
$parameters = [];

foreach ($constructor?->getParameters() ?? [] as $parameter) {
$type = $parameter->getType();

$parameters[] = [
'name' => $parameter->getName(),
'type' => $type,
'typeName' => $type instanceof \ReflectionNamedType ? $type->getName() : null,
'allowsNull' => $type?->allowsNull() ?? false,
'builtin' => $type instanceof \ReflectionNamedType && $type->isBuiltin(),
'defaultAvailable' => $parameter->isDefaultValueAvailable(),
'default' => $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null,
'variadic' => $parameter->isVariadic(),
];
}

return ['reflector' => $reflector, 'parameters' => $parameters];
}

/**
* Freeze an object handed out by a closure binding or instance()
*
Expand Down Expand Up @@ -470,6 +507,62 @@ protected function resolveDependencies(array $parameters, array $primitives = []
return $dependencies;
}

/**
* Resolve constructor dependencies from cached parameter metadata.
*
* @param array<int, array{name: string, type: \ReflectionType|null, typeName: string|null, allowsNull: bool, builtin: bool, defaultAvailable: bool, default: mixed, variadic: bool}> $parameters
* @param array $primitives
* @param string $className
* @return array
*/
protected function resolveCachedDependencies(array $parameters, array $primitives = [], string $className = ''): array
{
$dependencies = [];

foreach ($parameters as $parameter) {
$name = $parameter['name'];
$type = $parameter['type'];

if (!empty($primitives) && array_key_exists($name, $primitives)) {
$dependencies[] = $primitives[$name];
continue;
}

if ($type !== null && !$parameter['builtin'] && $parameter['typeName'] !== null) {
$typeName = $parameter['typeName'];

if ($parameter['allowsNull'] && !$this->has($typeName)) {
$dependencies[] = null;
continue;
}

$dependencies[] = $this->get($typeName);
continue;
}

if ($parameter['defaultAvailable']) {
$dependencies[] = $parameter['default'];
continue;
}

if ($parameter['variadic']) {
$dependencies[] = $primitives;
continue;
}

if (!empty($primitives)) {
$dependencies[] = array_shift($primitives);
continue;
}

throw new \RuntimeException(
"Unresolvable dependency resolving [\${$name}] in class " . $className
);
}

return $dependencies;
}

/**
* Resolve a single dependency
*
Expand Down Expand Up @@ -626,6 +719,7 @@ public function flush(): void
$this->instances = [];
$this->aliases = [];
$this->resolving = [];
$this->buildMetadata = [];
$this->bootBindingKeys = [];
$this->bootBindingsSnapshotted = false;
}
Expand Down
2 changes: 1 addition & 1 deletion src/Phaseolies/Http/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,7 @@ public function merge(array $input): self
*/
public function getPath(): string
{
return urldecode(
return $this->requestUri ??= urldecode(
parse_url($this->server->get("REQUEST_URI", "/"), PHP_URL_PATH)
);
}
Expand Down
140 changes: 133 additions & 7 deletions src/Phaseolies/Support/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ class Router
*/
private ?ActionPlanStore $actionPlans = null;

/**
* Compiled route matchers keyed by HTTP method and route pattern.
*
* @var array<string, array<string, array{regex: string, parameters: array<int, string>}>>
*/
private array $compiledRouteMatchers = [];

/**
* Static routes that can be resolved without scanning the route table.
*
* @var array<string, array<string, mixed>>
*/
private array $staticRouteLookup = [];

private bool $routeIndexesBuilt = false;

/**
* The route dispatched for each request by getCallback()
*
Expand Down Expand Up @@ -221,12 +237,15 @@ public function cacheRoutes(): void

$cacheData = [
'routes' => $this->getCacheableRoutes(),
'compiledRoutes' => $this->compileRouteMatchers(self::$routes),
'namedRoutes' => self::$namedRoutes,
'namedRouteMethods' => self::$namedRouteMethods,
'routeMiddlewares' => self::$routeMiddlewares,
'timestamp' => time()
];

$this->compiledRouteMatchers = $cacheData['compiledRoutes'];

file_put_contents(static::$cachePath, '<?php return ' . var_export($cacheData, true) . ';');

$this->cacheActionPlans($cacheData['routes']);
Expand Down Expand Up @@ -333,6 +352,9 @@ public function loadCachedRoutes(): bool
// A cache from before names kept their paths per method resolves by path alone.
self::$namedRouteMethods = array_filter($cached['namedRouteMethods'] ?? [], 'is_array');
self::$routeMiddlewares = $cached['routeMiddlewares'] ?? [];
$this->compiledRouteMatchers = $cached['compiledRoutes'] ?? [];
$this->staticRouteLookup = [];
$this->routeIndexesBuilt = false;

static::$cacheLoaded = true;

Expand Down Expand Up @@ -768,6 +790,9 @@ protected function addRoute(string $method, string $path, $callback, ?string $do
: $callback;

self::$routes[$method][$fullPath] = $entry;
$this->routeIndexesBuilt = false;
$this->staticRouteLookup = [];
$this->compiledRouteMatchers[$method] = [];
$this->currentRoutePath = $fullPath;
$this->currentRequestMethod = $method;

Expand Down Expand Up @@ -1266,6 +1291,17 @@ protected function matchRoute(string $method, $request): ?array

$url = ($url !== '/') ? rtrim($url, '/') : $url;

$this->buildRouteIndexes();

if (array_key_exists($url, $this->staticRouteLookup[$method] ?? [])) {
$entry = $this->staticRouteLookup[$method][$url];
['callback' => $callback, 'domain' => $domain] = $this->unwrapRouteEntry($entry);

if ($domain === null || $this->matchesDomain($domain, $request)) {
return ['method' => $method, 'route' => $url, 'callback' => $callback];
}
}

$routes = self::$routes[$method] ?? [];

foreach ($routes as $route => $entry) {
Expand All @@ -1283,11 +1319,16 @@ protected function matchRoute(string $method, $request): ?array
return $match;
}

if (!str_contains((string) $route, '{') && !str_contains((string) $route, '*')) {
continue;
}

// Pattern match with named parameters
$routeRegex = $this->convertRouteToRegex($route);
$matcher = $this->compiledRouteMatchers[$method][$route]
??= $this->compileRouteMatcher((string) $route);

if (preg_match($routeRegex, $url, $matches)) {
$params = $this->extractRouteParameters($route, $matches);
if (preg_match($matcher['regex'], $url, $matches)) {
$params = $this->extractCompiledRouteParameters($matcher['parameters'], $matches);
if ($params !== false) {
$request->setRouteParams(array_merge($request->getRouteParams(), $params));

Expand Down Expand Up @@ -1351,13 +1392,16 @@ protected function matchesDomain(string $domain, $request): bool
* @param string $route
* @return string
*/
protected function convertRouteToRegex(string $route): string
protected function convertRouteToRegex(string $route, ?array &$parameterNames = null): string
{
$parameterNames ??= [];

// Lift the parameters out first: a constraint can contain `/` and `*`,
// which must not be touched by the escaping and wildcard steps below.
$params = [];
$route = preg_replace_callback(self::PARAM_PATTERN, function (array $m) use (&$params) {
$route = preg_replace_callback(self::PARAM_PATTERN, function (array $m) use (&$params, &$parameterNames) {
$params[] = [$m[1], $m[2] ?? null];
$parameterNames[] = $m[1];

return "\x00" . (count($params) - 1) . "\x00";
}, $route);
Expand Down Expand Up @@ -1386,11 +1430,23 @@ protected function convertRouteToRegex(string $route): string
*/
protected function extractRouteParameters(string $route, array $matches): array|false
{
// Get all named parameters from the route pattern
preg_match_all(self::PARAM_PATTERN, $route, $paramNames);

return $this->extractCompiledRouteParameters($paramNames[1], $matches);
}

/**
* Extract route parameters from a precompiled parameter-name list.
*
* @param array<int, string> $parameterNames
* @param array<string|int, mixed> $matches
* @return array<string, string>|false
*/
protected function extractCompiledRouteParameters(array $parameterNames, array $matches): array|false
{
$params = [];

foreach ($paramNames[1] as $name) {
foreach ($parameterNames as $name) {
if (!isset($matches[$name])) {
return false;
}
Expand All @@ -1400,6 +1456,76 @@ protected function extractRouteParameters(string $route, array $matches): array|
return $params;
}

/**
* Compile all dynamic routes for persistence in the route cache.
*
* @param array<string, array<string, mixed>> $routes
* @return array<string, array<string, array{regex: string, parameters: array<int, string>}>>
*/
protected function compileRouteMatchers(array $routes): array
{
$compiled = [];

foreach ($routes as $method => $methodRoutes) {
foreach (array_keys($methodRoutes) as $route) {
if ($route === '(.*)' || (!str_contains((string) $route, '{') && !str_contains((string) $route, '*'))) {
continue;
}

$compiled[$method][$route] = $this->compileRouteMatcher((string) $route);
}
}

return $compiled;
}

/**
* Build the safe direct-lookup index for static routes.
*
* @return void
*/
protected function buildRouteIndexes(): void
{
if ($this->routeIndexesBuilt) {
return;
}

$this->staticRouteLookup = [];

foreach (self::$routes as $method => $routes) {
$dynamicRouteSeen = false;

foreach ($routes as $route => $entry) {
$route = (string) $route;

if ($route === '(.*)' || str_contains($route, '{') || str_contains($route, '*')) {
$dynamicRouteSeen = true;
continue;
}

if (!$dynamicRouteSeen) {
$this->staticRouteLookup[$method][$route] = $entry;
}
}
}

$this->routeIndexesBuilt = true;
}

/**
* Compile one dynamic route and retain its parameter names.
*
* @param string $route
* @return array{regex: string, parameters: array<int, string>}
*/
protected function compileRouteMatcher(string $route): array
{
$parameters = [];
$regex = $this->convertRouteToRegex($route, $parameters);

return ['regex' => $regex, 'parameters' => $parameters];
}

/**
* Checks if the request is a modifying request (POST, PUT, PATCH, DELETE).
*
Expand Down
Loading
Loading