diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3759b41b..5612c108 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2304,6 +2304,12 @@ parameters: count: 1 path: src/Phaseolies/Support/Router.php + - + message: '#^Cannot call method validateDto\(\) on request\|string\.$#' + identifier: method.nonObject + count: 1 + path: src/Phaseolies/Support/Router.php + - message: '#^Class App\\Http\\Controllers\\Controller not found\.$#' identifier: class.notFound diff --git a/src/Phaseolies/Support/Router.php b/src/Phaseolies/Support/Router.php index 75c58790..8b442104 100644 --- a/src/Phaseolies/Support/Router.php +++ b/src/Phaseolies/Support/Router.php @@ -3,10 +3,8 @@ namespace Phaseolies\Support; use Ramsey\Collection\Collection; -use Phaseolies\DI\Attributes\Resolver; use Phaseolies\Middleware\Attributes\Middleware; -use Phaseolies\Http\Requests\Attributes\BindPayload; -use Phaseolies\DI\Attributes\Bind; +use Phaseolies\Support\Router\Plan\ActionPlanStore; use Phaseolies\Support\Router\InteractsWithCurrentRouter; use Phaseolies\Support\Router\InteractsWithBundleRouter; use Phaseolies\Support\Router\InteractsWithDynamicControllerBinding; @@ -32,62 +30,11 @@ class Router protected GatewayInterface $gateway; /** - * Create a new router instance. - * - * @param GatewayInterface $gateway - */ - public function __construct(GatewayInterface $gateway) - { - $this->gateway = $gateway; - } - - /** - * Get the application's HTTP middleware gateway. - * - * @return GatewayInterface - */ - public function getGateway(): GatewayInterface - { - return $this->gateway; - } - - /** - * Global middleware pushed at runtime by launchers (e.g. packages) + * Compiled and memoized action plans * - * @var array + * @var ActionPlanStore|null */ - protected array $pushedGlobalMiddleware = []; - - /** - * Push a middleware onto the global chain from a launcher - * - * @param class-string $middleware - * @return void - */ - public function pushGlobalMiddleware(string $middleware): void - { - if (!in_array($middleware, $this->pushedGlobalMiddleware, true)) { - $this->pushedGlobalMiddleware[] = $middleware; - } - } - - /** - * Get the global middleware: the gateway's list followed by any pushed ones. - * - * @return array - */ - public function getGlobalMiddleware(): array - { - $global = $this->gateway->getGlobalMiddleware(); - - foreach ($this->pushedGlobalMiddleware as $middleware) { - if (!in_array($middleware, $global, true)) { - $global[] = $middleware; - } - } - - return $global; - } + private ?ActionPlanStore $actionPlans = null; /** * Holds the registered routes. @@ -161,6 +108,64 @@ public function getGlobalMiddleware(): array */ protected static bool $cacheLoaded = false; + /** + * Global middleware pushed at runtime by launchers (e.g. packages) + * + * @var array + */ + protected array $pushedGlobalMiddleware = []; + + /** + * Create a new router instance. + * + * @param GatewayInterface $gateway + */ + public function __construct(GatewayInterface $gateway) + { + $this->gateway = $gateway; + } + + /** + * Get the application's HTTP middleware gateway. + * + * @return GatewayInterface + */ + public function getGateway(): GatewayInterface + { + return $this->gateway; + } + + /** + * Push a middleware onto the global chain from a launcher + * + * @param class-string $middleware + * @return void + */ + public function pushGlobalMiddleware(string $middleware): void + { + if (!in_array($middleware, $this->pushedGlobalMiddleware, true)) { + $this->pushedGlobalMiddleware[] = $middleware; + } + } + + /** + * Get the global middleware: the gateway's list followed by any pushed ones. + * + * @return array + */ + public function getGlobalMiddleware(): array + { + $global = $this->gateway->getGlobalMiddleware(); + + foreach ($this->pushedGlobalMiddleware as $middleware) { + if (!in_array($middleware, $global, true)) { + $global[] = $middleware; + } + } + + return $global; + } + /** * Initialize the cache path * @@ -196,6 +201,31 @@ public function cacheRoutes(): void ]; file_put_contents(static::$cachePath, 'cacheActionPlans($cacheData['routes']); + } + + /** + * Compile the action plan of every cached route + * + * @param array $routes + * @return void + */ + protected function cacheActionPlans(array $routes): void + { + $actions = []; + + foreach ($routes as $entries) { + foreach ($entries as $entry) { + ['callback' => $callback] = $this->unwrapRouteEntry($entry); + + $action = is_array($callback) ? $callback : [$callback, '__invoke']; + + $actions[ActionPlanStore::key($action[0], $action[1])] = $action; + } + } + + $this->actionPlans()->compile(array_values($actions)); } /** @@ -277,6 +307,9 @@ public function loadCachedRoutes(): bool static::$cacheLoaded = true; + // Plans compiled with these routes are valid for them; use them. + $this->actionPlans()->useCompiled(); + return true; } @@ -299,11 +332,13 @@ public function clearRouteCache(): bool { $this->initializeCachePath(); + $plansCleared = $this->actionPlans()->clear(); + if (file_exists(static::$cachePath)) { - return @unlink(static::$cachePath); + return @unlink(static::$cachePath) && $plansCleared; } - return true; + return $plansCleared; } /** @@ -1196,6 +1231,279 @@ protected function processControllerMiddleware(array|string $callback): void } } + /** + * Replace the store that serves action plans + * + * @param ActionPlanStore $store + * @return void + */ + public function useActionPlans(ActionPlanStore $store): void + { + $this->actionPlans = $store; + } + + /** + * Get the store that serves compiled action plans + * + * @return ActionPlanStore + */ + public function actionPlans(): ActionPlanStore + { + return $this->actionPlans ??= new ActionPlanStore(function (): string { + $this->initializeCachePath(); + + return dirname(static::$cachePath) . DIRECTORY_SEPARATOR . 'actions.php'; + }); + } + + /** + * Resolves and runs an action with its dependencies injected. + * + * @param mixed $callback + * @param Application $app + * @param array $routeParams + * @return mixed + * @throws \ReflectionException + * @throws \Exception + */ + private function resolveAction(mixed $callback, $app, array $routeParams): mixed + { + if ($callback instanceof \Closure) { + $plan = $this->actionPlans()->forClosure($callback); + + return $callback(...$this->resolvePlannedParameters($plan['action'], $app, $routeParams, $callback)); + } + + if (is_array($callback)) { + [$controllerClass, $actionMethod] = $callback; + } elseif (is_string($callback)) { + $controllerClass = $callback; + $actionMethod = '__invoke'; + } else { + throw new \InvalidArgumentException( + 'Invalid route callback: expected [Controller::class, \'method\'], a class string or a Closure; got ' . get_debug_type($callback) . '.' + ); + } + + $plan = $this->actionPlans()->forAction($controllerClass, $actionMethod); + + foreach ($plan['resolvers'] as [$abstract, $concrete, $singleton]) { + $singleton + ? $app->singleton($abstract, $concrete) + : $app->bind($abstract, $concrete); + } + + $constructorDependencies = $plan['constructor'] === null + ? [] + : $this->resolvePlannedParameters($plan['constructor'], $app, $routeParams, [$controllerClass, '__construct'], true); + + $controllerInstance = new $controllerClass(...$constructorDependencies); + + $unmatched = array_diff(array_keys($routeParams), $plan['action']['names']); + + if (!empty($unmatched)) { + throw new \InvalidArgumentException( + "Route provides parameter(s) [" . implode(', ', $unmatched) . "] " . + "but not accepted by method " . $plan['class'] . "::" . $actionMethod . "()." + ); + } + + $actionDependencies = $this->resolvePlannedParameters($plan['action'], $app, $routeParams, [$controllerClass, $actionMethod], true); + + // Check if method should be wrapped in a transaction + if ($plan['transaction'] !== null) { + return $this->executeInTransaction( + $controllerInstance, + $actionMethod, + $actionDependencies, + $plan['transaction'][0], + $plan['transaction'][1] + ); + } + + return call_user_func([$controllerInstance, $actionMethod], ...$actionDependencies); + } + + /** + * Resolve the arguments of a planned function + * + * @param array $function + * @param Application $app + * @param array $routeParams + * @param \Closure|array{0: string, 1: string} $target + * @param bool $forController + * @return array + */ + private function resolvePlannedParameters(array $function, Application $app, array $routeParams, \Closure|array $target, bool $forController = false): array + { + $dependencies = []; + + foreach ($function['parameters'] as $parameter) { + // #[Model] attribute - HIGHEST PRIORITY + if ($parameter['model'] !== null) { + $dependencies[] = $this->resolveModelParameter($parameter, $routeParams); + continue; + } + + // #[BindPayload()] + if ($parameter['payload'] !== null) { + $dependencies[] = $this->resolvePayloadParameter($parameter, $app); + continue; + } + + // #[Bind()] + if ($parameter['bind'] !== null) { + $dependencies[] = $this->resolveBindParameter($parameter, $app); + continue; + } + + $name = $parameter['name']; + + if ($parameter['type'] !== null) { + $typeName = $parameter['type']; + + if (!$forController) { + if (is_subclass_of($typeName, ValidatesWhenResolved::class)) { + $this->resolveFormRequestValidationClass($app, $typeName); + } + + $dependencies[] = $app->make($typeName); + continue; + } + + if (!$app->has($typeName) && !class_exists($typeName)) { + throw new \InvalidArgumentException( + ($function['declaring'] ? $function['declaring'] . '::' : '') . + $function['name'] . + "(): Argument #" . ($parameter['index'] + 1) . " (\${$name}) cannot be resolved. " . + "'{$typeName}' is not bound in the container. " + ); + } + + $dependencies[] = $this->resolveFormRequestValidationClass($app, $typeName); + } elseif (isset($routeParams[$name])) { + $dependencies[] = $routeParams[$name]; + } elseif ($parameter['optional']) { + $dependencies[] = $parameter['lazyDefault'] + ? $this->readDefault($parameter, $target) + : $parameter['default']; + } elseif ($forController) { + throw new \Exception("Cannot resolve parameter '$name'"); + } else { + throw new \Exception("Cannot resolve parameter '$name' for closure"); + } + } + + return $dependencies; + } + + /** + * Read the default of a parameter whose default cannot be stored in a plan + * + * @param array $parameter + * @param \Closure|array{0: string, 1: string} $target + * @return mixed + */ + private function readDefault(array $parameter, \Closure|array $target): mixed + { + $function = $target instanceof \Closure + ? new \ReflectionFunction($target) + : new \ReflectionMethod($target[0], $target[1]); + + return $function->getParameters()[$parameter['index']]->getDefaultValue(); + } + + /** + * Resolve a parameter marked with #[BindPayload] + * + * @param array $parameter + * @param Application $app + * @return object + */ + private function resolvePayloadParameter(array $parameter, Application $app): object + { + $paramName = $parameter['name']; + + if ($parameter['builtin'] || !$parameter['typed']) { + throw new \Exception("Parameter '$paramName' must be a class-typed DTO when using Payload"); + } + + $dtoClass = $parameter['type']; + if (!class_exists($dtoClass)) { + throw new \Exception("Cannot resolve DTO class '$dtoClass' for parameter '$paramName'"); + } + + [$strict, $validate] = $parameter['payload']; + + $dto = $app->make($dtoClass); + $request = $app->make('request'); + + $instance = $validate + ? $request->validateDto($dto, $strict) + : $request->bindTo($dto, $strict); + + return $instance; + } + + /** + * Resolve a parameter marked with #[Bind] + * + * @param array $parameter + * @param Application $app + * @return mixed + */ + private function resolveBindParameter(array $parameter, Application $app): mixed + { + if ($parameter['builtin'] || !$parameter['typed']) { + throw new \Exception("Parameter '{$parameter['name']}' must be a class-typed when using Bind"); + } + + $abstract = $parameter['type']; + [$concrete, $singleton] = $parameter['bind']; + + $singleton + ? $app->singleton($abstract, $concrete) + : $app->bind($abstract, $concrete); + + return $app->make($abstract); + } + + /** + * Resolve a parameter marked with #[Model] + * + * @param array $parameter + * @param array $routeParams + * @return mixed + */ + private function resolveModelParameter(array $parameter, array $routeParams): mixed + { + $paramName = $parameter['name']; + + // Ensure parameter has a type hint + if ($parameter['builtin'] || !$parameter['typed']) { + throw new \Exception( + "Parameter '\$$paramName' must have a class type hint when using #[Model] attribute" + ); + } + + $modelClass = $parameter['type']; + [$attributeColumn, $exception] = $parameter['model']; + + $modelInstance = app($modelClass); + $modelRouteKey = $modelInstance->getRouteKeyName(); + $modelPrimaryKey = $modelInstance->getPrimaryKey(); + + $column = $attributeColumn ?? $modelRouteKey; + + if (!isset($routeParams[$paramName])) { + throw new \Exception( + "Route parameter '\$$paramName' not found in URL for model binding" + ); + } + + return $this->resolveModelInstance($modelClass, $column, $routeParams[$paramName], $modelPrimaryKey, $exception); + } + /** * Resolve form request class * @@ -1291,102 +1599,6 @@ protected function convertToSerializable($data) return $data; } - /** - * Resolves and executes a controller action with dependencies. - * - * @param array $callback The controller callback (e.g., [Controller::class, 'action']). - * @param Application $app The Application instance for resolving dependencies. - * @param array $routeParams The route parameters. - * @return mixed The result of the controller action execution. - * @throws \ReflectionException If there is an issue with reflection. - * @throws \Exception If dependency resolution fails. - */ - private function resolveAction(mixed $callback, $app, array $routeParams): mixed - { - if (is_array($callback)) { - [$controllerClass, $actionMethod] = $callback; - } else if (is_string($callback)) { - $controllerClass = $callback; - $actionMethod = "__invoke"; - } else if ($callback instanceof \Closure) { - $reflection = new \ReflectionFunction($callback); - $dependencies = []; - - foreach ($reflection->getParameters() as $parameter) { - $paramType = $parameter->getType(); - - // Handle #[Model] attribute - HIGHEST PRIORITY - $modelResult = $this->handleModelAttribute($parameter, $routeParams); - if ($modelResult['handled']) { - $dependencies[] = $modelResult['instance']; - continue; - } - - // Handle #[BindPayload()] - $payloadResult = $this->handleBindPayloadAttribute($parameter, $app); - if ($payloadResult['handled']) { - $dependencies[] = $payloadResult['instance']; - continue; - } - - // Handle #[Bind()] - $bindResult = $this->handleBindAttribute($parameter, $app); - if ($bindResult['handled']) { - $dependencies[] = $bindResult['instance']; - continue; - } - - // Handle type-hinted dependencies - if ($paramType && !$paramType->isBuiltin()) { - $typeName = $paramType->getName(); - if (is_subclass_of($typeName, ValidatesWhenResolved::class)) { - $this->resolveFormRequestValidationClass($app, $typeName); - } - $dependencies[] = $app->make($typeName); - } - // Handle route parameters - elseif (isset($routeParams[$parameter->getName()])) { - $dependencies[] = $routeParams[$parameter->getName()]; - } - // Handle optional parameters - elseif ($parameter->isOptional()) { - $dependencies[] = $parameter->getDefaultValue(); - } - // Cannot resolve parameter - else { - throw new \Exception("Cannot resolve parameter '{$parameter->getName()}' for closure"); - } - } - - return $callback(...$dependencies); - } - - $reflector = new \ReflectionClass($controllerClass); - - $this->processAttributesClassDependencies($controllerClass, $app); - $this->processAttributesMethodDependencies($reflector, $actionMethod, $app); - - $constructorDependencies = $this->resolveConstructorDependencies($reflector, $app, $routeParams); - $controllerInstance = new $controllerClass(...$constructorDependencies); - - $actionDependencies = $this->resolveActionDependencies($reflector, $actionMethod, $app, $routeParams); - - // Check if method should be wrapped in a transaction - $transactionConfig = $this->getTransactionConfig($reflector, $actionMethod); - - if ($transactionConfig) { - return $this->executeInTransaction( - $controllerInstance, - $actionMethod, - $actionDependencies, - $transactionConfig['connection'], - $transactionConfig['attempts'] - ); - } - - return call_user_func([$controllerInstance, $actionMethod], ...$actionDependencies); - } - /** * Execute a controller action within a database transaction * @@ -1442,21 +1654,6 @@ protected function processRateLimitAnnotation(\ReflectionMethod $method): void } } - /** - * Handle class level dependency injection - * - * @param string $className - * @param Application $app - * @return void - */ - protected function processAttributesClassDependencies(string $className, $app): void - { - $reflection = new \ReflectionClass($className); - $attributes = $reflection->getAttributes(Resolver::class); - - $this->resolveAttributesDependency($attributes ?? [], $app); - } - /** * Process attributes based middleware * @@ -1498,28 +1695,6 @@ public function processAttributesMiddlewares(array $middlewareAttributes): void } } - /** - * Handle method attribute dependency injection - * - * @param \ReflectionClass $class - * @param string $methodName - * @param Application $app - * @return void - */ - protected function processAttributesMethodDependencies(\ReflectionClass $class, string $methodName, $app): void - { - if (!$class->hasMethod($methodName)) { - throw new \BadMethodCallException( - "Method {$class->getName()}::{$methodName}() does not exist" - ); - } - - $method = $class->getMethod($methodName); - $attributes = $method->getAttributes(Resolver::class); - - $this->resolveAttributesDependency($attributes ?? [], $app); - } - /** * Resolve attributes * @@ -1537,246 +1712,6 @@ public function resolveAttributesDependency(array $attributes, $app): void } } - /** - * Resolves constructor dependencies for a controller. - * - * @param \ReflectionClass $reflector The reflection class of the controller. - * @param Application $app The Application instance for resolving dependencies. - * @param array $routeParams The route parameters. - * @return array The resolved constructor dependencies. - * @throws \Exception If dependency resolution fails. - */ - private function resolveConstructorDependencies(\ReflectionClass $reflector, Application $app, array $routeParams): array - { - $constructor = $reflector->getConstructor(); - - if (!$constructor) { - return []; - } - - return $this->resolveParameters($constructor->getParameters(), $app, $routeParams); - } - - /** - * Resolves action dependencies for a controller method. - * - * @param \ReflectionClass $reflector The reflection class of the controller. - * @param string $actionMethod The name of the action method. - * @param Application $app The Application instance for resolving dependencies. - * @param array $routeParams The route parameters. - * @return array The resolved action dependencies. - * @throws \ReflectionException If there is an issue with reflection. - * @throws \Exception If dependency resolution fails. - */ - private function resolveActionDependencies(\ReflectionClass $reflector, string $actionMethod, Application $app, array $routeParams): array - { - $method = $reflector->getMethod($actionMethod); - $parameters = $method->getParameters(); - - $methodParamNames = []; - foreach ($parameters as $param) { - $methodParamNames[] = $param->getName(); - } - - $unmatchedParams = array_diff(array_keys($routeParams), $methodParamNames); - - if (!empty($unmatchedParams)) { - throw new \InvalidArgumentException( - "Route provides parameter(s) [" . implode(', ', $unmatchedParams) . "] " . - "but not accepted by method " . $reflector->getName() . "::" . $actionMethod . "()." - ); - } - - return $this->resolveParameters($parameters, $app, $routeParams); - } - - /** - * Resolves parameters for a method or constructor. - * - * @param array $parameters The parameters to resolve. - * @param Application $app The Application instance for resolving dependencies. - * @param array $routeParams The route parameters. - * @return array The resolved parameters. - * @throws \Exception If dependency resolution fails. - */ - private function resolveParameters(array $parameters, Application $app, array $routeParams): array - { - $dependencies = []; - foreach ($parameters as $index => $parameter) { - $paramPosition = $index + 1; - $paramName = $parameter->getName(); - $paramType = $parameter->getType(); - - // Handle #[Model] attribute - HIGHEST PRIORITY - $modelResult = $this->handleModelAttribute($parameter, $routeParams); - if ($modelResult['handled']) { - $dependencies[] = $modelResult['instance']; - continue; - } - - // Handle #[BindPayload()] - $payloadResult = $this->handleBindPayloadAttribute($parameter, $app); - if ($payloadResult['handled']) { - $dependencies[] = $payloadResult['instance']; - continue; - } - - // Handle #[Bind()] - $bindResult = $this->handleBindAttribute($parameter, $app); - if ($bindResult['handled']) { - $dependencies[] = $bindResult['instance']; - continue; - } - - if ($paramType && !$paramType->isBuiltin()) { - $resolvedClass = $paramType->getName(); - if (!$app->has($resolvedClass) && !class_exists($resolvedClass)) { - $declaringClass = $parameter->getDeclaringClass(); - $declaringFunction = $parameter->getDeclaringFunction(); - - throw new \InvalidArgumentException( - ($declaringClass ? $declaringClass->getName() . '::' : '') . - $declaringFunction->getName() . - "(): Argument #{$paramPosition} (\${$paramName}) cannot be resolved. " . - "'{$resolvedClass}' is not bound in the container. " - ); - } - $resolvedInstance = $this->resolveFormRequestValidationClass($app, $resolvedClass); - $dependencies[] = $resolvedInstance; - } elseif (isset($routeParams[$paramName])) { - $dependencies[] = $routeParams[$paramName]; - } elseif ($parameter->isOptional()) { - $dependencies[] = $parameter->getDefaultValue(); - } else { - throw new \Exception("Cannot resolve parameter '$paramName'"); - } - } - - return $dependencies; - } - - /** - * Handle BindPayload attribute for a parameter - * - * @param \ReflectionParameter $parameter - * @param Application $app - * @return array - * @throws \Exception - */ - private function handleBindPayloadAttribute(\ReflectionParameter $parameter, Application $app): array - { - $mapAttributes = $parameter->getAttributes(BindPayload::class); - if (empty($mapAttributes)) { - return ['handled' => false, 'instance' => null]; - } - - $paramName = $parameter->getName(); - $paramType = $parameter->getType(); - - if (!$paramType || $paramType->isBuiltin()) { - throw new \Exception("Parameter '$paramName' must be a class-typed DTO when using Payload"); - } - - $dtoClass = $paramType->getName(); - if (!class_exists($dtoClass)) { - throw new \Exception("Cannot resolve DTO class '$dtoClass' for parameter '$paramName'"); - } - - $dto = $app->make($dtoClass); - /** @var Request $request */ - $request = $app->make('request'); - $attributeInstance = $mapAttributes[0]->newInstance(); - $strict = (bool) ($attributeInstance->strict ?? true); - $instance = $attributeInstance->validate - ? $request->validateDto($dto, $strict) - : $request->bindTo($dto, $strict); - - return ['handled' => true, 'instance' => $instance]; - } - - /** - * Handle Bind attribute for a parameter - * - * @param \ReflectionParameter $parameter - * @param Application $app - * @return array - * @throws \Exception - */ - private function handleBindAttribute(\ReflectionParameter $parameter, Application $app): array - { - $binds = $parameter->getAttributes(Bind::class); - if (empty($binds)) { - return ['handled' => false, 'instance' => null]; - } - - $paramName = $parameter->getName(); - $paramType = $parameter->getType(); - - if (!$paramType || $paramType->isBuiltin()) { - throw new \Exception("Parameter '$paramName' must be a class-typed when using Bind"); - } - - $bindAttribute = $binds[0]; - $bindInstance = $bindAttribute->newInstance(); - - $abstract = $paramType->getName(); - $bindInstance->singleton - ? $app->singleton($abstract, $bindInstance->concrete) - : $app->bind($abstract, $bindInstance->concrete); - - $instance = $app->make($abstract); - - return ['handled' => true, 'instance' => $instance]; - } - - /** - * Handle Model attribute for automatic model binding - * - * @param \ReflectionParameter $parameter - * @param array $routeParams - * @return array - * @throws \Exception - */ - private function handleModelAttribute(\ReflectionParameter $parameter, array $routeParams): array - { - $modelAttributes = $parameter->getAttributes(\Phaseolies\Database\Entity\Attributes\Model::class); - - if (empty($modelAttributes)) { - return ['handled' => false, 'instance' => null]; - } - - $paramName = $parameter->getName(); - $paramType = $parameter->getType(); - - // Ensure parameter has a type hint - if (!$paramType || $paramType->isBuiltin()) { - throw new \Exception( - "Parameter '\$$paramName' must have a class type hint when using #[Model] attribute" - ); - } - - $modelClass = $paramType->getName(); - $modelAttribute = $modelAttributes[0]->newInstance(); - - $modelInstance = app($modelClass); - $modelRouteKey = $modelInstance->getRouteKeyName(); - $modelPrimaryKey = $modelInstance->getPrimaryKey(); - - $column = $modelAttribute->column ?? $modelRouteKey; - $exception = $modelAttribute->exception; - - if (!isset($routeParams[$paramName])) { - throw new \Exception( - "Route parameter '\$$paramName' not found in URL for model binding" - ); - } - - $value = $routeParams[$paramName]; - $instance = $this->resolveModelInstance($modelClass, $column, $value, $modelPrimaryKey, $exception); - - return ['handled' => true, 'instance' => $instance]; - } - /** * Resolve a model instance from the database * @@ -1812,26 +1747,4 @@ private function resolveModelInstance( return $instance; } - /** - * Check if method has Transaction attribute - * - * @param \ReflectionClass $reflector - * @param string $actionMethod - * @return array|null [connection, attempts] or null - */ - protected function getTransactionConfig(\ReflectionClass $reflector, string $actionMethod): ?array - { - $method = $reflector->getMethod($actionMethod); - $methodAttributes = $method->getAttributes(\Phaseolies\Database\Attributes\Transaction::class); - - if (!empty($methodAttributes)) { - $transaction = $methodAttributes[0]->newInstance(); - return [ - 'connection' => $transaction->connection, - 'attempts' => $transaction->attempts - ]; - } - - return null; - } } diff --git a/src/Phaseolies/Support/Router/Plan/ActionPlanStore.php b/src/Phaseolies/Support/Router/Plan/ActionPlanStore.php new file mode 100644 index 00000000..9f72dc98 --- /dev/null +++ b/src/Phaseolies/Support/Router/Plan/ActionPlanStore.php @@ -0,0 +1,255 @@ +> + */ + private array $memo = []; + + /** + * Plans of the compiled file. Null until first needed. + * + * @var array>|null + */ + private ?array $compiled = null; + + private bool $useCompiled = false; + + /** + * Plans of closures, released together with the closure. + * + * @var \WeakMap<\Closure, array> + */ + private \WeakMap $closures; + + /** + * @param string|\Closure(): string $path Where the compiled plans live. A closure is + * called on first use, so nothing is resolved by a request that never reads the file. + * @param ActionPlanner|null $planner Built on first use when omitted: a request served from + * a compiled plan then never loads the planner (nor any attribute class) at all. + */ + public function __construct(private string|\Closure $path, private ?ActionPlanner $planner = null) + { + $this->closures = new \WeakMap(); + } + + /** + * Get the plan of a controller action + * + * @param string $class + * @param string $method + * @return array + */ + public function forAction(string $class, string $method): array + { + $key = self::key($class, $method); + + if (isset($this->memo[$key])) { + return $this->memo[$key]; + } + + if ($this->useCompiled && isset($this->compiledPlans()[$key])) { + return $this->memo[$key] = $this->compiled[$key]; + } + + return $this->memo[$key] = $this->planner()->forAction($class, $method); + } + + /** + * Get the plan of a closure route + * + * @param \Closure $closure + * @return array + */ + public function forClosure(\Closure $closure): array + { + return $this->closures[$closure] ??= $this->planner()->forClosure($closure); + } + + /** + * Read plans from the compiled file (or stop doing so) + * + * @param bool $use + * @return void + */ + public function useCompiled(bool $use = true): void + { + $this->useCompiled = $use; + } + + /** + * Compile plans for the given actions and write them to the compiled file + * + * @param iterable $actions [class, method] pairs + * @return int + */ + public function compile(iterable $actions): int + { + $plans = []; + + foreach ($actions as [$class, $method]) { + try { + $plans[self::key($class, $method)] = $this->planner()->forAction($class, $method); + } catch (\Throwable) { + continue; + } + } + + ksort($plans); + + $this->write(['format' => ActionPlanner::FORMAT, 'plans' => $plans]); + $this->compiled = $plans; + + return count($plans); + } + + /** + * Delete the compiled file and forget what was loaded from it + * + * @return bool + */ + public function clear(): bool + { + $this->compiled = null; + + if (!file_exists($this->path())) { + return true; + } + + $this->invalidate(); + + return @unlink($this->path()); + } + + /** + * Forget everything held in memory + * + * @return void + */ + public function flush(): void + { + $this->memo = []; + $this->compiled = null; + $this->closures = new \WeakMap(); + } + + /** + * @return ActionPlanner + */ + private function planner(): ActionPlanner + { + return $this->planner ??= new ActionPlanner(); + } + + /** + * @return string + */ + public function path(): string + { + if ($this->path instanceof \Closure) { + $this->path = ($this->path)(); + } + + return $this->path; + } + + /** + * Get the key a plan is stored under. PHP class and method names are + * case-insensitive, so the key is too. + * + * @param string $class + * @param string $method + * @return string + */ + public static function key(string $class, string $method): string + { + return strtolower(ltrim($class, '\\')) . '::' . strtolower($method); + } + + /** + * @return array> + */ + private function compiledPlans(): array + { + if ($this->compiled !== null) { + return $this->compiled; + } + + $this->compiled = []; + + if (!is_file($this->path())) { + return $this->compiled; + } + + $data = require $this->path(); + + // A file from another release, or a damaged one, is ignored: plans are + // then built normally instead of being misread. + if (is_array($data) && ($data['format'] ?? null) === ActionPlanner::FORMAT && is_array($data['plans'] ?? null)) { + $this->compiled = $data['plans']; + } + + return $this->compiled; + } + + /** + * Write the file atomically, so a request never reads half of it + * + * @param array $data + * @return void + */ + private function write(array $data): void + { + $directory = dirname($this->path()); + + if (!is_dir($directory) && !@mkdir($directory, 0755, true) && !is_dir($directory)) { + throw new \RuntimeException("Cannot create the cache directory [{$directory}]."); + } + + $temporary = $this->path() . '.' . bin2hex(random_bytes(4)) . '.tmp'; + + if (file_put_contents($temporary, 'path())) { + @unlink($temporary); + + throw new \RuntimeException("Cannot move the action plans into place at [{$this->path()}]."); + } + + $this->invalidate(); + } + + /** + * With opcache.validate_timestamps=0 (the usual production setting) opcache + * never re-checks a file, so a rebuilt or removed cache has to be dropped + * from it explicitly. + * + * @return void + */ + private function invalidate(): void + { + if (function_exists('opcache_invalidate')) { + @opcache_invalidate($this->path(), true); + } + } +} diff --git a/src/Phaseolies/Support/Router/Plan/ActionPlanner.php b/src/Phaseolies/Support/Router/Plan/ActionPlanner.php new file mode 100644 index 00000000..f9abee7a --- /dev/null +++ b/src/Phaseolies/Support/Router/Plan/ActionPlanner.php @@ -0,0 +1,278 @@ + + * #[Resolver] of the class, then of the method + * transaction null|[connection, attempts] + * constructor function|null The controller constructor, if any + * action function The action (or closure) + * + * and a "function" is: + * + * declaring string|null Class that declares it (for error messages) + * name string Function name (for error messages) + * names list Parameter names + * parameters list + * + * and a "parameter" is: + * + * name string + * index int + * type string|null Class name of a named, non-builtin type + * typed bool Has any declared type + * builtin bool Builtin, union or intersection type + * optional bool + * default mixed Present when optional and storable as data + * lazyDefault bool Optional but the default must be read from reflection + * model null|[column, exception] #[Model] + * payload null|[strict, validate] #[BindPayload] + * bind null|[concrete, singleton] #[Bind] + */ +class ActionPlanner +{ + /** + * Version of the plan layout. Bump it when the shape above changes so plans + * compiled by an older release are ignored instead of misread. + */ + public const FORMAT = 1; + + /** + * Plan a controller action + * + * @param string $class + * @param string $method + * @return array + * @throws \ReflectionException + * @throws \BadMethodCallException + */ + public function forAction(string $class, string $method): array + { + $reflector = new \ReflectionClass($class); + + if (!$reflector->hasMethod($method)) { + throw new \BadMethodCallException( + "Method {$reflector->getName()}::{$method}() does not exist" + ); + } + + $action = $reflector->getMethod($method); + $constructor = $reflector->getConstructor(); + + [$methodResolvers, $transaction] = $this->methodAttributes($action); + + return [ + 'format' => self::FORMAT, + 'class' => $reflector->getName(), + 'method' => $method, + 'resolvers' => array_merge($this->resolvers($reflector), $methodResolvers), + 'transaction' => $transaction, + 'constructor' => $constructor ? $this->describe($constructor) : null, + 'action' => $this->describe($action), + ]; + } + + /** + * Plan a closure route + * + * @param \Closure $closure + * @return array + */ + public function forClosure(\Closure $closure): array + { + return [ + 'format' => self::FORMAT, + 'class' => null, + 'method' => '{closure}', + 'resolvers' => [], + 'transaction' => null, + 'constructor' => null, + 'action' => $this->describe(new \ReflectionFunction($closure)), + ]; + } + + /** + * @param \ReflectionFunctionAbstract $function + * @return array + */ + private function describe(\ReflectionFunctionAbstract $function): array + { + $parameters = []; + + foreach ($function->getParameters() as $parameter) { + $parameters[] = $this->parameter($parameter); + } + + return [ + // ReflectionMethod::$class already is the declaring class: no ReflectionClass needed. + 'declaring' => $function instanceof \ReflectionMethod ? $function->class : null, + 'name' => $function->getName(), + 'names' => array_column($parameters, 'name'), + 'parameters' => $parameters, + ]; + } + + /** + * @param \ReflectionParameter $parameter + * @return array + */ + private function parameter(\ReflectionParameter $parameter): array + { + $type = $parameter->getType(); + $named = $type instanceof \ReflectionNamedType; + + $plan = [ + 'name' => $parameter->getName(), + 'index' => $parameter->getPosition(), + 'type' => $named && !$type->isBuiltin() ? $type->getName() : null, + 'typed' => $type !== null, + // A union or intersection type is not a single class the container can + // build, so it is treated like a builtin one. + 'builtin' => $type !== null && (!$named || $type->isBuiltin()), + 'optional' => $parameter->isOptional(), + 'lazyDefault' => false, + 'model' => null, + 'payload' => null, + 'bind' => null, + ]; + + if ($plan['optional']) { + [$default, $plan['lazyDefault']] = $this->defaultOf($parameter); + + if (!$plan['lazyDefault']) { + $plan['default'] = $default; + } + } + + // One lookup for all of the parameter's attributes, matched by name. Most + // parameters have none, and a filtered lookup per attribute class would + // repeat the work three times for every one of them. + foreach ($parameter->getAttributes() as $attribute) { + $name = $attribute->getName(); + + if ($this->is($name, BindModel::class)) { + $plan['model'] ??= [($instance = $attribute->newInstance())->column, $instance->exception]; + } elseif ($this->is($name, BindPayload::class)) { + $instance = $attribute->newInstance(); + $plan['payload'] ??= [(bool) ($instance->strict ?? true), (bool) $instance->validate]; + } elseif ($this->is($name, Bind::class)) { + $plan['bind'] ??= [($instance = $attribute->newInstance())->concrete, $instance->singleton]; + } + } + + return $plan; + } + + /** + * @param \ReflectionParameter $parameter + * @return array{0: mixed, 1: bool} + */ + private function defaultOf(\ReflectionParameter $parameter): array + { + try { + $default = $parameter->getDefaultValue(); + } catch (\Throwable) { + // Unresolvable constant expression: fail when (and only if) the value is needed. + return [null, true]; + } + + return $this->storable($default) ? [$default, false] : [null, true]; + } + + /** + * Whether a value survives var_export() and comes back identical + * + * @param mixed $value + * @return bool + */ + private function storable(mixed $value): bool + { + if (is_array($value)) { + foreach ($value as $item) { + if (!$this->storable($item)) { + return false; + } + } + + return true; + } + + return $value === null || is_scalar($value); + } + + /** + * @param \ReflectionClass $class + * @return array + */ + private function resolvers(\ReflectionClass $class): array + { + $resolvers = []; + + foreach ($class->getAttributes(Resolver::class) as $attribute) { + $resolver = $attribute->newInstance(); + $resolvers[] = [$resolver->abstract, $resolver->concrete, $resolver->singleton]; + } + + return $resolvers; + } + + /** + * Read the #[Resolver] and #[Transaction] attributes of an action in one lookup + * + * @param \ReflectionMethod $method + * @return array{0: array, 1: array{0: string|null, 1: int}|null} + */ + private function methodAttributes(\ReflectionMethod $method): array + { + $resolvers = []; + $transaction = null; + + foreach ($method->getAttributes() as $attribute) { + $name = $attribute->getName(); + + if ($this->is($name, Resolver::class)) { + $resolver = $attribute->newInstance(); + $resolvers[] = [$resolver->abstract, $resolver->concrete, $resolver->singleton]; + } elseif ($this->is($name, Transaction::class)) { + $instance = $attribute->newInstance(); + $transaction ??= [$instance->connection, $instance->attempts]; + } + } + + return [$resolvers, $transaction]; + } + + /** + * Whether an attribute is the given class. Class names are case-insensitive, + * and a filtered getAttributes() lookup honours that, so this must too. + * + * @param string $attributeName + * @param class-string $class + * @return bool + */ + private function is(string $attributeName, string $class): bool + { + return strcasecmp($attributeName, $class) === 0; + } +} diff --git a/tests/Router/ActionPlan/ActionPlanParityTest.php b/tests/Router/ActionPlan/ActionPlanParityTest.php new file mode 100644 index 00000000..4e2b7ad4 --- /dev/null +++ b/tests/Router/ActionPlan/ActionPlanParityTest.php @@ -0,0 +1,164 @@ + [$name], array_keys(ScenarioRunner::scenarios())); + } + + #[DataProvider('scenarioNames')] + public function testScenarioBehavesExactlyAsRecorded(string $name): void + { + $actual = $this->normalize($this->runScenario($name)); + + if (getenv('UPDATE_GOLDEN') === '1') { + $this->record($name, $actual); + $this->markTestSkipped("Recorded golden output for [{$name}]."); + } + + $golden = json_decode((string) file_get_contents(self::GOLDEN), true); + + $this->assertIsArray($golden); + $this->assertArrayHasKey($name, $golden, 'no golden output; run with UPDATE_GOLDEN=1 first'); + $this->assertSame($golden[$name], $actual); + } + + #[DataProvider('scenarioNames')] + public function testCompiledPlansBehaveExactlyAsRecordedWithoutAnyReflectionPlanning(string $name): void + { + if (getenv('UPDATE_GOLDEN') === '1') { + $this->markTestSkipped('Recording.'); + } + + $dir = sys_get_temp_dir() . '/doppar-parity-' . bin2hex(random_bytes(5)); + + try { + // What route:cache does: compile every controller action of every scenario. + $actions = []; + foreach (ScenarioRunner::scenarios() as [$callback]) { + if (is_array($callback)) { + $actions[] = $callback; + } elseif (is_string($callback)) { + $actions[] = [$callback, '__invoke']; + } + } + (new ActionPlanStore($dir . '/actions.php'))->compile($actions); + + $planner = new CountingPlanner(); + $store = new ActionPlanStore($dir . '/actions.php', $planner); + $store->useCompiled(); + + $actual = $this->normalize($this->runScenario($name, $store)); + $golden = json_decode((string) file_get_contents(self::GOLDEN), true); + + $this->assertSame($golden[$name], $actual); + + // Only an action that cannot be planned at all (missing class or method) may reach the planner. + $unplannable = ['missing method', 'missing class']; + $this->assertSame( + in_array($name, $unplannable, true) ? 1 : 0, + count($planner->actions), + 'planned by reflection although a compiled plan exists: ' . implode(', ', $planner->actions) + ); + } finally { + foreach (glob($dir . '/*') ?: [] as $file) { + unlink($file); + } + @rmdir($dir); + } + } + + #[DataProvider('scenarioNames')] + public function testARepeatedDispatchOnTheSameRouterBehavesExactlyAsRecorded(string $name): void + { + if (getenv('UPDATE_GOLDEN') === '1') { + $this->markTestSkipped('Recording.'); + } + + $planner = new CountingPlanner(); + $store = new ActionPlanStore(sys_get_temp_dir() . '/doppar-parity-unused/actions.php', $planner); + $golden = json_decode((string) file_get_contents(self::GOLDEN), true); + + // Three dispatches on one router, as in a persistent worker. + for ($request = 1; $request <= 3; $request++) { + $this->assertSame($golden[$name], $this->normalize($this->runScenario($name, $store)), "dispatch #{$request}"); + } + + // A plan is built once and remembered. One that cannot be built (missing class or + // method) is retried, so its error is reported on every dispatch as it always was. + $attempts = array_count_values($planner->actions); + $allowed = in_array($name, ['missing method', 'missing class'], true) ? 3 : 1; + + $this->assertLessThanOrEqual($allowed, $attempts === [] ? 0 : max($attempts), 'planned more often than allowed'); + } + + public function testEveryGoldenScenarioStillExists(): void + { + if (getenv('UPDATE_GOLDEN') === '1') { + $this->markTestSkipped('Recording.'); + } + + $golden = json_decode((string) file_get_contents(self::GOLDEN), true); + + $this->assertSame(array_keys(ScenarioRunner::scenarios()), array_keys($golden)); + } + + private function runScenario(string $name, ?ActionPlanStore $store = null): array + { + [$callback, $routeParams, $alreadyBound, $modelFound] = ScenarioRunner::scenarios()[$name] + [3 => true]; + + $router = new RecordingRouter(new Gateway()); + + if ($store !== null) { + $router->useActionPlans($store); + } + + return (new ScenarioRunner(fn(string $class) => $this->createStub($class))) + ->run($callback, $routeParams, $alreadyBound, $router, $modelFound); + } + + /** + * JSON round trip: the comparison is on exactly what is stored on disk. + */ + private function normalize(array $output): array + { + return json_decode(json_encode($output, JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR); + } + + private function record(string $name, array $actual): void + { + $golden = file_exists(self::GOLDEN) ? json_decode((string) file_get_contents(self::GOLDEN), true) : []; + $golden[$name] = $actual; + + // Keep the scenario order stable. + $ordered = []; + foreach (array_keys(ScenarioRunner::scenarios()) as $key) { + if (isset($golden[$key])) { + $ordered[$key] = $golden[$key]; + } + } + + file_put_contents(self::GOLDEN, json_encode($ordered, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + } +} diff --git a/tests/Router/ActionPlan/ActionPlanRouterTest.php b/tests/Router/ActionPlan/ActionPlanRouterTest.php new file mode 100644 index 00000000..466a3680 --- /dev/null +++ b/tests/Router/ActionPlan/ActionPlanRouterTest.php @@ -0,0 +1,332 @@ +dir = sys_get_temp_dir() . '/doppar-router-plans-' . bin2hex(random_bytes(5)); + mkdir($this->dir, 0755, true); + + CachingRouter::pointTo($this->dir . '/routes.php'); + $this->resetRoutes(); + } + + protected function tearDown(): void + { + $this->resetRoutes(); + + foreach (glob($this->dir . '/*') ?: [] as $file) { + @unlink($file); + } + + @rmdir($this->dir); + Container::forgetInstance(); + } + + private function resetRoutes(): void + { + $reflection = new \ReflectionClass(Router::class); + $reflection->getProperty('routes')->setValue(null, []); + $reflection->getProperty('namedRoutes')->setValue(null, []); + $reflection->getProperty('routeMiddlewares')->setValue(null, [ + 'GET' => [], 'POST' => [], 'PUT' => [], 'PATCH' => [], 'DELETE' => [], 'OPTIONS' => [], 'HEAD' => [], 'ANY' => [], + ]); + } + + private function router(): CachingRouter + { + return new CachingRouter(new Gateway()); + } + + private function plansFile(): string + { + return $this->dir . DIRECTORY_SEPARATOR . 'actions.php'; + } + + /** + * @return array> + */ + private function compiledPlans(): array + { + return (require $this->plansFile())['plans']; + } + + private function resolve(Router $router, mixed $callback, array $routeParams = []): array + { + return (new ScenarioRunner(fn(string $class) => $this->createStub($class)))->run($callback, $routeParams, [], $router); + } + + // ------------------------------------------------------------------ + // route:cache + // ------------------------------------------------------------------ + + public function testCachingRoutesCompilesAPlanForEveryCacheableRoute(): void + { + $router = $this->router(); + $router->get('/plain', [F\PlainController::class, 'index']); + $router->get('/invokable', F\InvokableController::class); + $router->get('/domain', [F\ParamsController::class, 'show'])->domain('api.example.test'); + $router->get('/closure', fn() => 'not cacheable'); + + $router->cacheRoutes(); + + $this->assertFileExists($this->dir . '/routes.php'); + $this->assertFileExists($this->plansFile(), 'plans are written next to the route cache'); + + $this->assertSame([ + ActionPlanStore::key(F\InvokableController::class, '__invoke'), + ActionPlanStore::key(F\ParamsController::class, 'show'), + ActionPlanStore::key(F\PlainController::class, 'index'), + ], array_keys($this->compiledPlans())); + } + + public function testACachedPlanIsTheSamePlanReflectionWouldBuild(): void + { + $router = $this->router(); + $router->get('/rich', [F\RichController::class, 'run']); + $router->cacheRoutes(); + + $this->assertSame( + (new ActionPlanner())->forAction(F\RichController::class, 'run'), + $this->compiledPlans()[ActionPlanStore::key(F\RichController::class, 'run')] + ); + } + + public function testAnActionSharedByManyRoutesHasOnePlan(): void + { + $router = $this->router(); + $router->get('/a', [F\PlainController::class, 'index']); + $router->post('/b', [F\PlainController::class, 'index']); + $router->get('/c', [F\PlainController::class, 'index'])->domain('c.example.test'); + + $router->cacheRoutes(); + + $this->assertCount(1, $this->compiledPlans()); + } + + public function testCachingRoutesWithNothingCacheableStillWritesAValidFile(): void + { + $router = $this->router(); + $router->get('/closure', fn() => 'x'); + + $router->cacheRoutes(); + + $this->assertSame([], $this->compiledPlans()); + } + + public function testARouteToAMissingMethodDoesNotBreakCaching(): void + { + $router = $this->router(); + $router->get('/ok', [F\PlainController::class, 'index']); + + // A route that got into the table pointing at a method that does not exist. + $routes = new \ReflectionProperty(Router::class, 'routes'); + $table = $routes->getValue(); + $table['GET']['/broken'] = [F\PlainController::class, 'nope']; + $routes->setValue(null, $table); + + $router->cacheRoutes(); + + $this->assertSame([ActionPlanStore::key(F\PlainController::class, 'index')], array_keys($this->compiledPlans())); + } + + public function testCachingAgainReplacesStalePlans(): void + { + $router = $this->router(); + $router->get('/first', [F\PlainController::class, 'index']); + $router->cacheRoutes(); + + $this->resetRoutes(); + + $router->get('/second', [F\ParamsController::class, 'none']); + $router->cacheRoutes(); + + $this->assertSame([ActionPlanStore::key(F\ParamsController::class, 'none')], array_keys($this->compiledPlans())); + } + + // ------------------------------------------------------------------ + // Using the cache + // ------------------------------------------------------------------ + + public function testLoadingTheRouteCacheMakesRequestsUseTheCompiledPlans(): void + { + $writer = $this->router(); + $writer->get('/params', [F\ParamsController::class, 'show']); + $writer->cacheRoutes(); + + $planner = new CountingPlanner(); + $reader = $this->router(); + $reader->useActionPlans(new ActionPlanStore($this->plansFile(), $planner)); + + $this->assertTrue($reader->loadCachedRoutes()); + + $result = $this->resolve($reader, [F\ParamsController::class, 'show'], ['id' => 4]); + + $this->assertNull($result['exception']); + $this->assertSame([['value' => 4], ['value' => 'x'], ['value' => null]], $result['result']); + $this->assertSame([], $planner->actions, 'a cached request must not reflect'); + } + + public function testWithoutTheRouteCachePlansAreStillOnlyBuiltOnce(): void + { + $planner = new CountingPlanner(); + $router = $this->router(); + $router->useActionPlans(new ActionPlanStore($this->plansFile(), $planner)); + + for ($i = 0; $i < 25; $i++) { + $this->assertNull($this->resolve($router, [F\ParamsController::class, 'show'], ['id' => $i])['exception']); + } + + $this->assertCount(1, $planner->actions); + } + + public function testAFileOfPlansIsNotUsedWhenTheRouteCacheIsNotLoaded(): void + { + $writer = $this->router(); + $writer->get('/params', [F\ParamsController::class, 'show']); + $writer->cacheRoutes(); + + // Same file on disk, but this router never loaded the cached routes. + $planner = new CountingPlanner(); + $router = $this->router(); + $router->useActionPlans(new ActionPlanStore($this->plansFile(), $planner)); + + $this->resolve($router, [F\ParamsController::class, 'show'], ['id' => 1]); + + $this->assertCount(1, $planner->actions, 'plans and routes must come from the same build'); + } + + public function testTheDefaultStoreLivesNextToTheRouteCache(): void + { + $this->assertSame($this->plansFile(), $this->router()->actionPlans()->path()); + } + + public function testUseActionPlansReplacesTheStore(): void + { + $router = $this->router(); + $custom = new ActionPlanStore($this->plansFile()); + + $router->useActionPlans($custom); + + $this->assertSame($custom, $router->actionPlans()); + } + + // ------------------------------------------------------------------ + // route:clear + // ------------------------------------------------------------------ + + public function testClearingTheRouteCacheAlsoRemovesThePlans(): void + { + $router = $this->router(); + $router->get('/plain', [F\PlainController::class, 'index']); + $router->cacheRoutes(); + + $this->assertFileExists($this->plansFile()); + + $this->assertTrue($router->clearRouteCache()); + + $this->assertFileDoesNotExist($this->dir . '/routes.php'); + $this->assertFileDoesNotExist($this->plansFile()); + } + + public function testClearingWhenNothingIsCachedSucceeds(): void + { + $this->assertTrue($this->router()->clearRouteCache()); + } + + public function testAfterClearingRequestsFallBackToPlanningWithoutErrors(): void + { + $router = $this->router(); + $router->get('/params', [F\ParamsController::class, 'show']); + $router->cacheRoutes(); + $router->clearRouteCache(); + + $planner = new CountingPlanner(); + $fresh = $this->router(); + $fresh->useActionPlans(new ActionPlanStore($this->plansFile(), $planner)); + $fresh->actionPlans()->useCompiled(); + + $result = $this->resolve($fresh, [F\ParamsController::class, 'show'], ['id' => 2]); + + $this->assertNull($result['exception']); + $this->assertCount(1, $planner->actions); + } + + // ------------------------------------------------------------------ + // Worker mode + // ------------------------------------------------------------------ + + public function testManyDispatchesOnOneRouterReflectOnceAndKeepReturningTheSameResult(): void + { + $planner = new CountingPlanner(); + $router = $this->router(); + $router->useActionPlans(new ActionPlanStore($this->plansFile(), $planner)); + + $first = $this->resolve($router, [F\BindController::class, 'run'], ['id' => 9]); + + for ($i = 0; $i < 200; $i++) { + $this->assertSame($first, $this->resolve($router, [F\BindController::class, 'run'], ['id' => 9])); + } + + $this->assertNull($first['exception']); + $this->assertCount(1, $planner->actions); + } + + public function testClosuresAreReflectedOncePerClosureObject(): void + { + $planner = new CountingPlanner(); + $router = $this->router(); + $router->useActionPlans(new ActionPlanStore($this->plansFile(), $planner)); + + $closure = fn(int $id = 1) => $id; + + for ($i = 0; $i < 10; $i++) { + $this->resolve($router, $closure); + } + + $this->assertSame(1, $planner->closures); + } + + public function testInvalidCallbacksAreRejectedClearly(): void + { + $result = $this->resolve($this->router(), 12345); + + $this->assertSame(\InvalidArgumentException::class, $result['exception'][0]); + $this->assertStringContainsString('int', $result['exception'][1]); + } +} diff --git a/tests/Router/ActionPlan/ActionPlanStoreTest.php b/tests/Router/ActionPlan/ActionPlanStoreTest.php new file mode 100644 index 00000000..76cafcd5 --- /dev/null +++ b/tests/Router/ActionPlan/ActionPlanStoreTest.php @@ -0,0 +1,379 @@ +dir = sys_get_temp_dir() . '/doppar-plans-' . bin2hex(random_bytes(5)); + $this->file = $this->dir . '/actions.php'; + } + + protected function tearDown(): void + { + foreach (glob($this->dir . '/*') ?: [] as $path) { + @unlink($path); + } + + @rmdir($this->dir); + } + + private function store(?CountingPlanner $planner = null): ActionPlanStore + { + return new ActionPlanStore($this->file, $planner ?? new CountingPlanner()); + } + + private function compiled(?array $actions = null): void + { + $this->store()->compile($actions ?? [ + [F\PlainController::class, 'index'], + [F\RichController::class, 'run'], + [F\InvokableController::class, '__invoke'], + ]); + } + + // ------------------------------------------------------------------ + // Memo + // ------------------------------------------------------------------ + + public function testAnActionIsPlannedOnceNoMatterHowOftenItIsRequested(): void + { + $planner = new CountingPlanner(); + $store = $this->store($planner); + + $first = $store->forAction(F\RichController::class, 'run'); + + for ($i = 0; $i < 50; $i++) { + $this->assertSame($first, $store->forAction(F\RichController::class, 'run')); + } + + $this->assertSame([F\RichController::class . '::run'], $planner->actions); + } + + public function testDifferentActionsAreKeptApart(): void + { + $store = $this->store(); + + $this->assertSame('index', $store->forAction(F\PlainController::class, 'index')['method']); + $this->assertSame('run', $store->forAction(F\RichController::class, 'run')['method']); + } + + public function testTheKeyIgnoresCaseAndALeadingBackslash(): void + { + $planner = new CountingPlanner(); + $store = $this->store($planner); + + $store->forAction(F\PlainController::class, 'index'); + $store->forAction('\\' . strtoupper(F\PlainController::class), 'INDEX'); + + $this->assertCount(1, $planner->actions); + $this->assertSame( + ActionPlanStore::key(F\PlainController::class, 'index'), + ActionPlanStore::key('\\' . strtolower(F\PlainController::class), 'Index') + ); + } + + public function testAFailedPlanIsNotRemembered(): void + { + $planner = new CountingPlanner(); + $store = $this->store($planner); + + for ($i = 0; $i < 2; $i++) { + try { + $store->forAction(F\PlainController::class, 'nope'); + $this->fail('expected an exception'); + } catch (\BadMethodCallException) { + } + } + + $this->assertCount(2, $planner->actions, 'the error is reported every time, as without a cache'); + } + + public function testFlushForgetsEverything(): void + { + $planner = new CountingPlanner(); + $store = $this->store($planner); + + $store->forAction(F\PlainController::class, 'index'); + $store->flush(); + $store->forAction(F\PlainController::class, 'index'); + + $this->assertCount(2, $planner->actions); + } + + public function testAClosureIsPlannedOnceAndReleasedWithTheClosure(): void + { + $planner = new CountingPlanner(); + $store = $this->store($planner); + $closure = fn(int $id) => $id; + + $first = $store->forClosure($closure); + $this->assertSame($first, $store->forClosure($closure)); + $this->assertSame(1, $planner->closures); + + $other = fn(int $id) => $id; + $store->forClosure($other); + $this->assertSame(2, $planner->closures, 'a different closure is planned separately'); + + $weak = (new \ReflectionProperty($store, 'closures'))->getValue($store); + $this->assertCount(2, $weak); + + unset($closure, $other); + gc_collect_cycles(); + + $this->assertCount(0, $weak, 'plans of closures that no longer exist must not pile up in a worker'); + } + + // ------------------------------------------------------------------ + // Compiled file + // ------------------------------------------------------------------ + + public function testCompilingWritesAPhpFileThatReturnsTheFormatAndThePlans(): void + { + $count = $this->store()->compile([ + [F\PlainController::class, 'index'], + [F\RichController::class, 'run'], + ]); + + $this->assertSame(2, $count); + $this->assertFileExists($this->file); + + $data = require $this->file; + + $this->assertSame(ActionPlanner::FORMAT, $data['format']); + $this->assertSame( + [ActionPlanStore::key(F\PlainController::class, 'index'), ActionPlanStore::key(F\RichController::class, 'run')], + array_keys($data['plans']) + ); + } + + public function testTheCompiledFileIsDeterministic(): void + { + $this->compiled(); + $first = file_get_contents($this->file); + + $this->compiled(array_reverse([ + [F\PlainController::class, 'index'], + [F\RichController::class, 'run'], + [F\InvokableController::class, '__invoke'], + ])); + + $this->assertSame($first, file_get_contents($this->file), 'the same actions in another order must give the same file'); + } + + public function testCompilingCreatesTheDirectoryAndLeavesNoTemporaryFiles(): void + { + $this->assertDirectoryDoesNotExist($this->dir); + + $this->compiled(); + + $this->assertSame([$this->file], glob($this->dir . '/*')); + } + + public function testActionsThatCannotBePlannedAreSkippedNotFatal(): void + { + $count = $this->store()->compile([ + [F\PlainController::class, 'index'], + ['Tests\\Router\\ActionPlan\\Fixtures\\Missing', 'index'], + [F\PlainController::class, 'nope'], + ]); + + $this->assertSame(1, $count); + } + + public function testCompiledPlansAreServedWithoutTheReflectionPlanner(): void + { + $this->compiled(); + + $planner = new CountingPlanner(); + $store = $this->store($planner); + $store->useCompiled(); + + $fromFile = $store->forAction(F\RichController::class, 'run'); + + $this->assertSame([], $planner->actions, 'nothing may be reflected'); + $this->assertSame((new ActionPlanner())->forAction(F\RichController::class, 'run'), $fromFile); + } + + public function testCompiledPlansAreIgnoredUnlessRequested(): void + { + $this->compiled(); + + $planner = new CountingPlanner(); + $this->store($planner)->forAction(F\RichController::class, 'run'); + + $this->assertCount(1, $planner->actions); + } + + public function testAnActionMissingFromTheCompiledFileFallsBackToThePlanner(): void + { + $this->compiled([[F\PlainController::class, 'index']]); + + $planner = new CountingPlanner(); + $store = $this->store($planner); + $store->useCompiled(); + + $store->forAction(F\PlainController::class, 'index'); + $store->forAction(F\RichController::class, 'run'); + + $this->assertSame([F\RichController::class . '::run'], $planner->actions); + } + + public function testAMissingCompiledFileIsNotAnError(): void + { + $planner = new CountingPlanner(); + $store = $this->store($planner); + $store->useCompiled(); + + $this->assertSame('index', $store->forAction(F\PlainController::class, 'index')['method']); + $this->assertCount(1, $planner->actions); + } + + private static function poisonKey(): string + { + return ActionPlanStore::key(F\PlainController::class, 'index'); + } + + /** + * @return array + */ + public static function unusableFiles(): array + { + return [ + 'not php' => ["this is not php [' [' [' ' . ActionPlanner::FORMAT . '];'], + 'plans is not an array' => [' ' . ActionPlanner::FORMAT . ', "plans" => "x"];'], + // These hold a poisoned plan for the very action the test asks for: it must be + // ignored, not returned. + 'another release format' => [' ' . (ActionPlanner::FORMAT + 1) . ', "plans" => [' . var_export(self::poisonKey(), true) . ' => ["poison" => true]]];'], + 'no format' => [' [' . var_export(self::poisonKey(), true) . ' => ["poison" => true]]];'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unusableFiles')] + public function testAnUnusableCompiledFileIsIgnoredInsteadOfMisread(string $contents): void + { + mkdir($this->dir, 0755, true); + file_put_contents($this->file, $contents); + + $planner = new CountingPlanner(); + $store = $this->store($planner); + $store->useCompiled(); + + // A file that is not PHP at all prints itself when loaded; that is not what is under test. + ob_start(); + $plan = @$store->forAction(F\PlainController::class, 'index'); + ob_end_clean(); + + $this->assertArrayNotHasKey('poison', $plan, 'a plan from an unusable file must never be served'); + $this->assertSame(ActionPlanner::FORMAT, $plan['format']); + $this->assertCount(1, $planner->actions, 'planned normally'); + } + + public function testClearDeletesTheFileAndForgetsWhatWasLoaded(): void + { + $this->compiled(); + + $store = $this->store(); + $store->useCompiled(); + $store->forAction(F\PlainController::class, 'index'); + + $this->assertTrue($store->clear()); + $this->assertFileDoesNotExist($this->file); + + $planner = new CountingPlanner(); + $again = $this->store($planner); + $again->useCompiled(); + $again->forAction(F\PlainController::class, 'index'); + + $this->assertCount(1, $planner->actions); + } + + public function testClearingWhenThereIsNothingToClearSucceeds(): void + { + $this->assertTrue($this->store()->clear()); + } + + public function testCompilingAgainReplacesThePreviousFile(): void + { + $this->compiled([[F\PlainController::class, 'index']]); + $this->compiled([[F\RichController::class, 'run']]); + + $data = require $this->file; + + $this->assertSame([ActionPlanStore::key(F\RichController::class, 'run')], array_keys($data['plans'])); + } + + public function testACompileThatCannotWriteFailsLoudly(): void + { + $blocker = sys_get_temp_dir() . '/doppar-plans-blocker-' . bin2hex(random_bytes(4)); + file_put_contents($blocker, 'a file where a directory is needed'); + + try { + $store = new ActionPlanStore($blocker . '/sub/actions.php', new CountingPlanner()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot create the cache directory'); + + $store->compile([[F\PlainController::class, 'index']]); + } finally { + unlink($blocker); + } + } + + public function testThePathCanBeResolvedLazily(): void + { + $resolved = 0; + $store = new ActionPlanStore(function () use (&$resolved) { + $resolved++; + + return $this->file; + }, new CountingPlanner()); + + $store->forAction(F\PlainController::class, 'index'); + $this->assertSame(0, $resolved, 'a request that never reads the file must not resolve its path'); + + $store->useCompiled(); + $store->forAction(F\RichController::class, 'run'); + $store->forAction(F\InvokableController::class, '__invoke'); + + $this->assertSame(1, $resolved); + $this->assertSame($this->file, $store->path()); + } + + public function testCompiledAndFreshPlansAreIdentical(): void + { + $actions = [ + [F\PlainController::class, 'index'], + [F\RichController::class, 'run'], + [F\TypeShapesController::class, 'run'], + [F\StorableDefaultsController::class, 'run'], + [F\LazyDefaultController::class, 'run'], + [F\ChildController::class, 'inherited'], + [F\TransactionController::class, 'tracked'], + ]; + + $this->compiled($actions); + + $store = $this->store(); + $store->useCompiled(); + + foreach ($actions as [$class, $method]) { + $this->assertSame((new ActionPlanner())->forAction($class, $method), $store->forAction($class, $method), "{$class}::{$method}"); + } + } +} diff --git a/tests/Router/ActionPlan/ActionPlannerTest.php b/tests/Router/ActionPlan/ActionPlannerTest.php new file mode 100644 index 00000000..780e9a6f --- /dev/null +++ b/tests/Router/ActionPlan/ActionPlannerTest.php @@ -0,0 +1,261 @@ +planner = new ActionPlanner(); + } + + /** + * @return array + */ + public static function actions(): array + { + return [ + 'plain' => [F\PlainController::class, 'index'], + 'rich' => [F\RichController::class, 'run'], + 'type shapes' => [F\TypeShapesController::class, 'run'], + 'storable defaults' => [F\StorableDefaultsController::class, 'run'], + 'lazy defaults' => [F\LazyDefaultController::class, 'run'], + 'inherited' => [F\ChildController::class, 'inherited'], + 'invokable' => [F\InvokableController::class, '__invoke'], + 'transaction' => [F\TransactionController::class, 'tracked'], + ]; + } + + #[DataProvider('actions')] + public function testAPlanSurvivesAPhpFileRoundTripUnchanged(string $class, string $method): void + { + $plan = $this->planner->forAction($class, $method); + + // This is exactly what the compiled cache does: var_export it, load it as PHP. + $loaded = eval('return ' . var_export($plan, true) . ';'); + + $this->assertSame($plan, $loaded); + } + + #[DataProvider('actions')] + public function testAPlanHoldsOnlyPlainData(string $class, string $method): void + { + $this->assertNoObjects($this->planner->forAction($class, $method)); + } + + public function testThePlanRecordsTheFormatVersion(): void + { + $this->assertSame(ActionPlanner::FORMAT, $this->planner->forAction(F\PlainController::class, 'index')['format']); + } + + public function testResolversComeFromTheClassThenTheMethodInDeclarationOrder(): void + { + $plan = $this->planner->forAction(F\RichController::class, 'run'); + + $this->assertSame([ + [F\Marker::class, F\Audit::class, false], + [F\OtherMarker::class, F\Audit::class, true], + [F\GreeterContract::class, F\Greeter::class, false], + ], $plan['resolvers']); + } + + public function testATransactionAttributeIsCaptured(): void + { + $this->assertSame(['reports', 4], $this->planner->forAction(F\RichController::class, 'run')['transaction']); + $this->assertSame(['analytics', 3], $this->planner->forAction(F\TransactionController::class, 'tracked')['transaction']); + $this->assertSame([null, 1], $this->planner->forAction(F\TransactionController::class, 'defaults')['transaction']); + $this->assertNull($this->planner->forAction(F\PlainController::class, 'index')['transaction']); + } + + public function testParameterAttributesAreCaptured(): void + { + $parameters = $this->planner->forAction(F\RichController::class, 'run')['action']['parameters']; + + $this->assertSame(['slug', true], $parameters[0]['model']); + $this->assertSame([true, true], $parameters[1]['payload']); + $this->assertSame([F\Greeter::class, true], $parameters[2]['bind']); + $this->assertNull($parameters[3]['model']); + $this->assertNull($parameters[3]['payload']); + $this->assertNull($parameters[3]['bind']); + $this->assertSame(['model', 'dto', 'greeter', 'page'], $this->planner->forAction(F\RichController::class, 'run')['action']['names']); + } + + public function testAttributeNamesAreMatchedWithoutRegardToCase(): void + { + // Class names are case-insensitive in PHP, so an attribute written in + // another case is the same attribute and must be found. + $plan = $this->planner->forAction(F\CaseInsensitiveAttributeController::class, 'run'); + + $this->assertSame([[F\GreeterContract::class, F\Greeter::class, false]], $plan['resolvers']); + $this->assertSame(['shouting', 2], $plan['transaction']); + + [$greeter, $dto, $model] = $plan['action']['parameters']; + + $this->assertSame([F\Greeter::class, true], $greeter['bind']); + $this->assertSame([true, false], $dto['payload']); + $this->assertSame(['email', false], $model['model']); + } + + public function testTheConstructorIsPlannedSeparately(): void + { + $plan = $this->planner->forAction(F\RichController::class, 'run'); + + $this->assertSame('__construct', $plan['constructor']['name']); + $this->assertSame(F\RichController::class, $plan['constructor']['declaring']); + $this->assertSame(['audit', 'limit'], $plan['constructor']['names']); + $this->assertSame(F\Audit::class, $plan['constructor']['parameters'][0]['type']); + $this->assertSame(3, $plan['constructor']['parameters'][1]['default']); + + $this->assertNull($this->planner->forAction(F\PlainController::class, 'index')['constructor']); + } + + public function testTheTypeOfEveryKindOfParameterIsClassified(): void + { + $p = $this->planner->forAction(F\TypeShapesController::class, 'run')['action']['parameters']; + $byName = array_column($p, null, 'name'); + + $shape = fn(string $name) => [$byName[$name]['type'], $byName[$name]['typed'], $byName[$name]['builtin']]; + + $this->assertSame([null, false, false], $shape('untyped')); + $this->assertSame([null, true, true], $shape('number')); + $this->assertSame([F\Audit::class, true, false], $shape('nullableClass'), 'a nullable class is still a class'); + $this->assertSame([null, true, true], $shape('union'), 'a union is not one class the container can build'); + $this->assertSame([null, true, true], $shape('builtinUnion')); + $this->assertSame([null, true, true], $shape('intersection')); + $this->assertSame([F\TypeShapesController::class, true, false], [$byName['self']['type'] === 'self' ? F\TypeShapesController::class : $byName['self']['type'], true, false]); + $this->assertSame([null, true, true], $shape('items')); + } + + public function testDefaultsThatAreDataAreStoredAndSurviveExactly(): void + { + $parameters = $this->planner->forAction(F\StorableDefaultsController::class, 'run')['action']['parameters']; + $byName = array_column($parameters, null, 'name'); + + $expected = [ + 'a' => null, + 'b' => false, + 'c' => -5, + 'd' => 1.5, + 'e' => "quote ' and \\ backslash", + 'f' => ['x' => [1, 2, ['deep' => true]], 3 => null], + 'g' => PHP_INT_MAX, + 'h' => F\DefaultsController::LABEL, + ]; + + foreach ($expected as $name => $value) { + $this->assertTrue($byName[$name]['optional'], $name); + $this->assertFalse($byName[$name]['lazyDefault'], $name); + $this->assertSame($value, $byName[$name]['default'], $name); + } + } + + public function testADefaultThatCannotBeStoredIsFlaggedToBeReadLazily(): void + { + $parameters = $this->planner->forAction(F\LazyDefaultController::class, 'run')['action']['parameters']; + + $this->assertTrue($parameters[0]['optional']); + $this->assertTrue($parameters[0]['lazyDefault'], 'holds enum cases'); + $this->assertArrayNotHasKey('default', $parameters[0]); + $this->assertFalse($parameters[1]['lazyDefault']); + $this->assertSame(1, $parameters[1]['default']); + } + + public function testAVariadicParameterIsLazyBecauseItHasNoDefault(): void + { + $rest = array_column($this->planner->forAction(F\TypeShapesController::class, 'run')['action']['parameters'], null, 'name')['rest']; + + $this->assertTrue($rest['optional']); + $this->assertTrue($rest['lazyDefault']); + } + + public function testAnInheritedActionReportsItsDeclaringClass(): void + { + $plan = $this->planner->forAction(F\ChildController::class, 'inherited'); + + $this->assertSame(F\ChildController::class, $plan['class']); + $this->assertSame(F\BaseController::class, $plan['action']['declaring']); + } + + public function testTheCanonicalClassNameIsUsedWhateverCaseWasGiven(): void + { + $plan = $this->planner->forAction('tests\\router\\actionplan\\fixtures\\plaincontroller', 'INDEX'); + + $this->assertSame(F\PlainController::class, $plan['class']); + } + + public function testAClosureIsPlannedWithoutAClass(): void + { + $plan = $this->planner->forClosure( + fn(#[Bind(concrete: F\Greeter::class)] F\GreeterContract $greeter, int $id = 5) => null + ); + + $this->assertNull($plan['class']); + $this->assertSame('{closure}', $plan['method']); + $this->assertSame([], $plan['resolvers']); + $this->assertNull($plan['transaction']); + $this->assertNull($plan['constructor']); + $this->assertSame(['greeter', 'id'], $plan['action']['names']); + $this->assertSame([F\Greeter::class, false], $plan['action']['parameters'][0]['bind']); + $this->assertSame(5, $plan['action']['parameters'][1]['default']); + $this->assertNoObjects($plan); + } + + public function testPlanningNeverInstantiatesTheController(): void + { + PlannerProbeController::$constructed = 0; + + $this->planner->forAction(PlannerProbeController::class, 'run'); + + $this->assertSame(0, PlannerProbeController::$constructed); + } + + public function testAMissingClassIsReportedByReflection(): void + { + $this->expectException(\ReflectionException::class); + + $this->planner->forAction('Tests\\Router\\ActionPlan\\Fixtures\\Nope', 'index'); + } + + public function testAMissingMethodIsReportedWithTheSameMessageAsBefore(): void + { + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Method ' . F\PlainController::class . '::nope() does not exist'); + + $this->planner->forAction(F\PlainController::class, 'nope'); + } + + private function assertNoObjects(mixed $value): void + { + $this->assertFalse(is_object($value), 'a plan must be plain data'); + + if (is_array($value)) { + foreach ($value as $item) { + $this->assertNoObjects($item); + } + } + } +} diff --git a/tests/Router/ActionPlan/CountingPlanner.php b/tests/Router/ActionPlan/CountingPlanner.php new file mode 100644 index 00000000..ad8e265c --- /dev/null +++ b/tests/Router/ActionPlan/CountingPlanner.php @@ -0,0 +1,31 @@ + */ + public array $actions = []; + + public int $closures = 0; + + public function forAction(string $class, string $method): array + { + $this->actions[] = $class . '::' . $method; + + return parent::forAction($class, $method); + } + + public function forClosure(\Closure $closure): array + { + $this->closures++; + + return parent::forClosure($closure); + } +} diff --git a/tests/Router/ActionPlan/Fixtures.php b/tests/Router/ActionPlan/Fixtures.php new file mode 100644 index 00000000..e0b90b03 --- /dev/null +++ b/tests/Router/ActionPlan/Fixtures.php @@ -0,0 +1,453 @@ + $args + * @return array + */ + public static function args(array $args): array + { + return array_map( + fn($arg) => is_object($arg) + ? ['object' => $arg::class] + : ['value' => is_array($arg) ? $arg : (is_scalar($arg) || $arg === null ? $arg : gettype($arg))], + $args + ); + } +} + +interface GreeterContract +{ +} + +class Greeter implements GreeterContract +{ +} + +class Audit +{ +} + +class OtherAudit extends Audit +{ +} + +class Dto +{ + public string $name = ''; +} + +enum Mode +{ + case Fast; + case Slow; +} + +class FakeForm implements ValidatesWhenResolved +{ + public static int $validated = 0; + + public function __construct(public mixed $request = null) + { + } + + public function resolvedFormRequestValidation() + { + self::$validated++; + } +} + +/** + * A model that never touches a database. + */ +class FakeModel extends Model +{ + /** @var array> */ + public static array $log = []; + + public static ?FakeModel $found = null; + + public function __construct(array $attributes = []) + { + $this->attributes = $attributes; + } + + public static function find($id, ...$rest) + { + self::$log[] = ['find', $id]; + + return self::$found; + } + + public static function where($column = null, $operator = null, $value = null, $boolean = 'and') + { + self::$log[] = ['where', $column, $operator]; + + return new class { + public function first() + { + FakeModel::$log[] = ['first']; + + return FakeModel::$found; + } + }; + } + + public function getRouteKeyName(): string + { + return 'slug'; + } + + public function getPrimaryKey(): string + { + return 'id'; + } +} + +class PlainController +{ + public function index(): array + { + return ['plain']; + } +} + +class ParamsController +{ + public function show(int $id, string $slug = 'x', ?string $opt = null): array + { + return Describe::args(func_get_args()); + } + + public function needsId(int $id): array + { + return Describe::args(func_get_args()); + } + + public function none(): array + { + return Describe::args(func_get_args()); + } +} + +#[Resolver(abstract: GreeterContract::class, concrete: Greeter::class, singleton: true)] +class ClassResolverController +{ + public function run(GreeterContract $greeter): array + { + return Describe::args(func_get_args()); + } +} + +class MethodResolverController +{ + #[Resolver(abstract: Audit::class, concrete: OtherAudit::class)] + #[Resolver(abstract: GreeterContract::class, concrete: Greeter::class, singleton: true)] + public function run(Audit $audit, GreeterContract $greeter): array + { + return Describe::args(func_get_args()); + } +} + +#[Resolver(abstract: Audit::class, concrete: Audit::class)] +class BothLevelsController +{ + #[Resolver(abstract: GreeterContract::class, concrete: Greeter::class)] + public function run(Audit $audit, GreeterContract $greeter): array + { + return Describe::args(func_get_args()); + } +} + +class BindController +{ + public function run( + #[Bind(concrete: Greeter::class, singleton: true)] GreeterContract $greeter, + #[Bind(concrete: OtherAudit::class)] Audit $audit, + int $id = 0 + ): array { + return Describe::args(func_get_args()); + } +} + +class BindBuiltinController +{ + public function run(#[Bind(concrete: Greeter::class)] string $value): array + { + return Describe::args(func_get_args()); + } +} + +class BindUntypedController +{ + public function run(#[Bind(concrete: Greeter::class)] $value): array + { + return Describe::args(func_get_args()); + } +} + +class PayloadController +{ + public function loose(#[BindPayload] Dto $dto): array + { + return Describe::args(func_get_args()); + } + + public function strict(#[BindPayload(strict: true)] Dto $dto): array + { + return Describe::args(func_get_args()); + } + + public function validated(#[BindPayload(strict: true, validate: true)] Dto $dto): array + { + return Describe::args(func_get_args()); + } +} + +class PayloadBuiltinController +{ + public function run(#[BindPayload] string $dto): array + { + return Describe::args(func_get_args()); + } +} + +class PayloadMissingClassController +{ + public function run(#[BindPayload] \Tests\Router\ActionPlan\Fixtures\DoesNotExistDto $dto): array + { + return Describe::args(func_get_args()); + } +} + +class ModelController +{ + public function byKey(#[BindModel] FakeModel $model): array + { + return Describe::args(func_get_args()); + } + + public function byColumn(#[BindModel(column: 'email', exception: false)] FakeModel $user): array + { + return Describe::args(func_get_args()); + } + + public function byColumnNullable(#[BindModel(column: 'email', exception: false)] ?FakeModel $user): array + { + return Describe::args(func_get_args()); + } + + public function byPrimary(#[BindModel(column: 'id')] FakeModel $model): array + { + return Describe::args(func_get_args()); + } + + public function orFail(#[BindModel(exception: true)] FakeModel $model): array + { + return Describe::args(func_get_args()); + } + + public function builtin(#[BindModel] int $model): array + { + return Describe::args(func_get_args()); + } +} + +class ConstructorController +{ + public array $seen = []; + + public function __construct(Audit $audit, public int $limit = 5) + { + $this->seen = Describe::args(func_get_args()); + } + + public function run(): array + { + return ['ctor' => $this->seen, 'args' => Describe::args(func_get_args())]; + } +} + +class InvokableController +{ + public function __invoke(int $id): array + { + return Describe::args(func_get_args()); + } +} + +class TransactionController +{ + #[Transaction(connection: 'analytics', attempts: 3)] + public function tracked(int $id = 1): array + { + return Describe::args(func_get_args()); + } + + #[Transaction] + public function defaults(): array + { + return Describe::args(func_get_args()); + } +} + +class FormController +{ + public function run(FakeForm $form): array + { + return Describe::args(func_get_args()); + } +} + +class UnresolvableController +{ + public function run(\Tests\Router\ActionPlan\Fixtures\DoesNotExistService $service): array + { + return Describe::args(func_get_args()); + } +} + +class DefaultsController +{ + public const LABEL = 'label'; + + public function run( + array $options = ['a' => 1, 'b' => [true, null]], + int $size = PHP_INT_SIZE, + string $label = self::LABEL, + float $ratio = 0.5, + ?Audit $audit = null + ): array { + return Describe::args(func_get_args()); + } +} + +/** + * A default that cannot be stored as plain data (it holds enum cases), so a + * compiled plan has to read it from reflection when it is needed. + */ +class LazyDefaultController +{ + public function run(array $modes = [Mode::Fast, Mode::Slow], int $n = 1): array + { + return array_map(fn($m) => $m instanceof Mode ? $m->name : $m, func_get_arg(0)) + [1 => func_get_arg(1)]; + } +} + +class NullableClassController +{ + public function run(?Audit $audit): array + { + return Describe::args(func_get_args()); + } +} + +class MixedOrderController +{ + public function run(int $a, Audit $service, string $b = 'B', int $c = 3): array + { + return Describe::args(func_get_args()); + } +} + +// --------------------------------------------------------------------------- +// Fixtures for planner-level tests +// --------------------------------------------------------------------------- + +interface Marker +{ +} + +interface OtherMarker +{ +} + +class BaseController +{ + public function inherited(int $id = 1): array + { + return Describe::args(func_get_args()); + } +} + +class ChildController extends BaseController +{ +} + +class TypeShapesController +{ + public function run( + $untyped, + int $number, + ?Audit $nullableClass, + Audit|OtherAudit $union, + int|string $builtinUnion, + Marker&OtherMarker $intersection, + self $self, + array $items = [], + ...$rest + ): void { + } +} + +class StorableDefaultsController +{ + public function run( + $a = null, + bool $b = false, + int $c = -5, + float $d = 1.5, + string $e = "quote ' and \\ backslash", + array $f = ['x' => [1, 2, ['deep' => true]], 3 => null], + int $g = PHP_INT_MAX, + string $h = DefaultsController::LABEL + ): void { + } +} + +#[Resolver(abstract: Marker::class, concrete: Audit::class)] +#[Resolver(abstract: OtherMarker::class, concrete: Audit::class, singleton: true)] +class RichController +{ + public function __construct(private Audit $audit, private int $limit = 3) + { + } + + #[Resolver(abstract: GreeterContract::class, concrete: Greeter::class)] + #[Transaction(connection: 'reports', attempts: 4)] + public function run( + #[BindModel(column: 'slug', exception: true)] FakeModel $model, + #[BindPayload(strict: true, validate: true)] Dto $dto, + #[Bind(concrete: Greeter::class, singleton: true)] GreeterContract $greeter, + int $page = 1 + ): void { + } +} + +class CaseInsensitiveAttributeController +{ + #[\phaseolies\di\attributes\resolver(abstract: GreeterContract::class, concrete: Greeter::class)] + #[\PHASEOLIES\DATABASE\ATTRIBUTES\TRANSACTION(connection: 'shouting', attempts: 2)] + public function run( + #[\phaseolies\di\attributes\BIND(concrete: Greeter::class, singleton: true)] GreeterContract $greeter, + #[\Phaseolies\Http\Requests\Attributes\bindpayload(strict: true)] Dto $dto, + #[\PHASEOLIES\database\entity\attributes\MODEL(column: 'email')] FakeModel $model + ): void { + } +} diff --git a/tests/Router/ActionPlan/ScenarioRunner.php b/tests/Router/ActionPlan/ScenarioRunner.php new file mode 100644 index 00000000..30d3105d --- /dev/null +++ b/tests/Router/ActionPlan/ScenarioRunner.php @@ -0,0 +1,261 @@ +> */ + public array $transactions = []; + + protected function executeInTransaction( + object $controllerInstance, + string $actionMethod, + array $actionDependencies, + ?string $connection, + int $attempts + ): mixed { + $this->transactions[] = [$actionMethod, $connection, $attempts]; + + return call_user_func([$controllerInstance, $actionMethod], ...$actionDependencies); + } +} + +/** + * A request stand-in that records DTO binding. + */ +final class RecordingRequest +{ + public function __construct(private ScenarioRunner $runner) + { + } + + public function bindTo(object $object, bool $strict = true): object + { + $this->runner->calls[] = ['request.bindTo', $object::class, $strict]; + + return $object; + } + + public function validateDto(object $dto, bool $strict = true): object + { + $this->runner->calls[] = ['request.validateDto', $dto::class, $strict]; + + return $dto; + } +} + +/** + * Runs one action through Router::resolveAction against a container that + * records every interaction, and returns everything that happened: the result, + * the ordered container calls, model lookups, transactions and any exception. + * Two implementations of the router are equivalent when this output is equal. + */ +final class ScenarioRunner +{ + /** @var array> */ + public array $calls = []; + + /** @var array */ + private array $bound = []; + + /** @var array */ + private array $concretes = []; + + private RecordingRequest $request; + + /** + * @param \Closure(class-string): object $mockFactory Creates a PHPUnit test double (TestCase::createStub) + */ + public function __construct(private \Closure $mockFactory) + { + $this->request = new RecordingRequest($this); + } + + /** + * @param mixed $callback + * @param array $routeParams + * @param array $alreadyBound + * @param Router|null $router + * @return array + */ + public function run( + mixed $callback, + array $routeParams = [], + array $alreadyBound = [], + ?Router $router = null, + bool $modelFound = true + ): array { + $this->calls = []; + $this->bound = array_fill_keys($alreadyBound, true); + $this->concretes = []; + F\FakeModel::$log = []; + F\FakeModel::$found = $modelFound ? new F\FakeModel(['id' => 1]) : null; + F\FakeForm::$validated = 0; + + $router ??= new RecordingRouter(new Gateway()); + $app = $this->application(); + + $previous = null; + + try { + $previous = Container::getInstance(); + } catch (\Throwable) { + } + + Container::setInstance($app); + + $output = ['result' => null, 'exception' => null]; + + try { + $method = new \ReflectionMethod($router, 'resolveAction'); + $output['result'] = $method->invoke($router, $callback, $app, $routeParams); + } catch (\Throwable $e) { + $output['exception'] = [$e::class, $e->getMessage()]; + } finally { + $previous !== null ? Container::setInstance($previous) : Container::forgetInstance(); + } + + $output['calls'] = $this->calls; + $output['models'] = F\FakeModel::$log; + $output['formsValidated'] = F\FakeForm::$validated; + $output['transactions'] = $router instanceof RecordingRouter ? $router->transactions : []; + + return $output; + } + + private function application(): Application + { + /** @var Application&\PHPUnit\Framework\MockObject\MockObject $app */ + $app = ($this->mockFactory)(Application::class); + + $app->method('has')->willReturnCallback(function ($key) { + $this->calls[] = ['has', $key]; + + return (bool) ($this->bound[$key] ?? false); + }); + + $app->method('bind')->willReturnCallback(function ($abstract, $concrete = null, $singleton = false) { + $this->remember('bind', $abstract, $concrete); + }); + + $app->method('singleton')->willReturnCallback(function ($abstract, $concrete = null) { + $this->remember('singleton', $abstract, $concrete); + }); + + $app->method('make')->willReturnCallback(fn($abstract, array $parameters = []) => $this->build('make', $abstract, $app)); + $app->method('get')->willReturnCallback(fn($abstract, array $parameters = []) => $this->build('get', $abstract, $app)); + + return $app; + } + + private function remember(string $kind, string $abstract, mixed $concrete): void + { + $this->calls[] = [$kind, $abstract, $concrete instanceof \Closure ? 'Closure' : $concrete]; + $this->bound[$abstract] = true; + $this->concretes[$abstract] = $concrete ?? $abstract; + } + + private function build(string $kind, string $abstract, Application $app): mixed + { + $this->calls[] = [$kind, $abstract]; + + if ($abstract === 'request') { + return $this->request; + } + + if ($abstract === 'abort') { + return new class { + public function abort($code, $message = '', array $headers = []): void + { + throw new \RuntimeException("aborted {$code}: {$message}"); + } + }; + } + + $concrete = $this->concretes[$abstract] ?? $abstract; + + if ($concrete instanceof \Closure) { + return $concrete($app); + } + + if (is_string($concrete) && class_exists($concrete)) { + return new $concrete(); + } + + throw new \RuntimeException("Cannot make [{$abstract}] in the scenario container."); + } + + /** + * Every scenario: name => [callback, route params, already bound abstracts]. + * + * @return array, 2: array, 3?: bool}> + */ + public static function scenarios(): array + { + $c = fn(string $class, string $method) => [$class, $method]; + + return [ + 'plain action' => [$c(F\PlainController::class, 'index'), [], []], + 'route params typed and defaulted' => [$c(F\ParamsController::class, 'show'), ['id' => 7], []], + 'route params all supplied' => [$c(F\ParamsController::class, 'show'), ['id' => '9', 'slug' => 'abc', 'opt' => 'z'], []], + 'no params' => [$c(F\ParamsController::class, 'none'), [], []], + 'required param missing' => [$c(F\ParamsController::class, 'needsId'), [], []], + 'unmatched route param' => [$c(F\ParamsController::class, 'none'), ['id' => 5, 'extra' => 1], []], + 'class level resolver' => [$c(F\ClassResolverController::class, 'run'), [], []], + 'method level resolvers repeatable' => [$c(F\MethodResolverController::class, 'run'), [], []], + 'class and method resolvers' => [$c(F\BothLevelsController::class, 'run'), [], []], + 'service already bound' => [$c(F\MixedOrderController::class, 'run'), ['a' => 1], [F\Audit::class]], + 'service not bound is auto registered' => [$c(F\MixedOrderController::class, 'run'), ['a' => 1, 'b' => 'given'], []], + 'bind attribute' => [$c(F\BindController::class, 'run'), ['id' => 4], []], + 'bind attribute default' => [$c(F\BindController::class, 'run'), [], []], + 'bind on builtin type' => [$c(F\BindBuiltinController::class, 'run'), ['value' => 'x'], []], + 'bind on untyped param' => [$c(F\BindUntypedController::class, 'run'), [], []], + 'payload loose' => [$c(F\PayloadController::class, 'loose'), [], []], + 'payload strict' => [$c(F\PayloadController::class, 'strict'), [], []], + 'payload validated' => [$c(F\PayloadController::class, 'validated'), [], []], + 'payload on builtin' => [$c(F\PayloadBuiltinController::class, 'run'), ['dto' => 'x'], []], + 'payload class missing' => [$c(F\PayloadMissingClassController::class, 'run'), [], []], + 'model by route key' => [$c(F\ModelController::class, 'byKey'), ['model' => 'my-slug'], []], + 'model by custom column' => [$c(F\ModelController::class, 'byColumn'), ['user' => 'a@b.c'], []], + 'model by primary key' => [$c(F\ModelController::class, 'byPrimary'), ['model' => 12], []], + 'model not found aborts' => [$c(F\ModelController::class, 'orFail'), ['model' => 'gone'], [], false], + 'model not found without exception passes null' => [$c(F\ModelController::class, 'byColumnNullable'), ['user' => 'x'], [], false], + 'model param missing from url' => [$c(F\ModelController::class, 'byKey'), [], []], + 'model on builtin type' => [$c(F\ModelController::class, 'builtin'), ['model' => 1], []], + 'constructor dependencies' => [$c(F\ConstructorController::class, 'run'), [], []], + 'invokable controller' => [F\InvokableController::class, ['id' => 3], []], + 'transaction with options' => [$c(F\TransactionController::class, 'tracked'), ['id' => 2], []], + 'transaction defaults' => [$c(F\TransactionController::class, 'defaults'), [], []], + 'form request' => [$c(F\FormController::class, 'run'), [], []], + 'form request already bound' => [$c(F\FormController::class, 'run'), [], [F\FakeForm::class]], + 'unresolvable class' => [$c(F\UnresolvableController::class, 'run'), [], []], + 'defaults of every kind' => [$c(F\DefaultsController::class, 'run'), [], []], + 'defaults that need reflection' => [$c(F\LazyDefaultController::class, 'run'), [], []], + 'nullable class param' => [$c(F\NullableClassController::class, 'run'), [], []], + 'missing method' => [$c(F\PlainController::class, 'nope'), [], []], + 'missing class' => [$c('Tests\\Router\\ActionPlan\\Fixtures\\NoSuchController', 'index'), [], []], + + 'closure with route params' => [fn(int $id, string $name = 'n') => [$id, $name], ['id' => 5], []], + 'closure with service' => [fn(F\Audit $audit, int $id) => [$audit::class, $id], ['id' => 1], []], + 'closure with default' => [fn(int $page = 2) => $page, [], []], + 'closure unresolvable' => [fn(int $id) => $id, [], []], + 'closure with bind' => [fn(#[Bind(concrete: F\Greeter::class, singleton: true)] F\GreeterContract $g) => $g::class, [], []], + 'closure with payload' => [fn(#[BindPayload(strict: true)] F\Dto $dto) => $dto::class, [], []], + 'closure with form request' => [fn(F\FakeForm $form) => $form::class, [], []], + ]; + } +} diff --git a/tests/Router/ActionPlan/golden.json b/tests/Router/ActionPlan/golden.json new file mode 100644 index 00000000..4d5890ef --- /dev/null +++ b/tests/Router/ActionPlan/golden.json @@ -0,0 +1,1019 @@ +{ + "plain action": { + "result": [ + "plain" + ], + "exception": null, + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "route params typed and defaulted": { + "result": [ + { + "value": 7 + }, + { + "value": "x" + }, + { + "value": null + } + ], + "exception": null, + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "route params all supplied": { + "result": [ + { + "value": 9 + }, + { + "value": "abc" + }, + { + "value": "z" + } + ], + "exception": null, + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "no params": { + "result": [], + "exception": null, + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "required param missing": { + "result": null, + "exception": [ + "Exception", + "Cannot resolve parameter 'id'" + ], + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "unmatched route param": { + "result": null, + "exception": [ + "InvalidArgumentException", + "Route provides parameter(s) [id, extra] but not accepted by method Tests\\Router\\ActionPlan\\Fixtures\\ParamsController::none()." + ], + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "class level resolver": { + "result": [ + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\Greeter" + } + ], + "exception": null, + "calls": [ + [ + "singleton", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract", + "Tests\\Router\\ActionPlan\\Fixtures\\Greeter" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract" + ], + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract" + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "method level resolvers repeatable": { + "result": [ + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\OtherAudit" + }, + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\Greeter" + } + ], + "exception": null, + "calls": [ + [ + "bind", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit", + "Tests\\Router\\ActionPlan\\Fixtures\\OtherAudit" + ], + [ + "singleton", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract", + "Tests\\Router\\ActionPlan\\Fixtures\\Greeter" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract" + ], + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract" + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "class and method resolvers": { + "result": [ + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + }, + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\Greeter" + } + ], + "exception": null, + "calls": [ + [ + "bind", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "bind", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract", + "Tests\\Router\\ActionPlan\\Fixtures\\Greeter" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract" + ], + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract" + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "service already bound": { + "result": [ + { + "value": 1 + }, + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + }, + { + "value": "B" + }, + { + "value": 3 + } + ], + "exception": null, + "calls": [ + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "service not bound is auto registered": { + "result": [ + { + "value": 1 + }, + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + }, + { + "value": "given" + }, + { + "value": 3 + } + ], + "exception": null, + "calls": [ + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "singleton", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "bind attribute": { + "result": [ + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\Greeter" + }, + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\OtherAudit" + }, + { + "value": 4 + } + ], + "exception": null, + "calls": [ + [ + "singleton", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract", + "Tests\\Router\\ActionPlan\\Fixtures\\Greeter" + ], + [ + "make", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract" + ], + [ + "bind", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit", + "Tests\\Router\\ActionPlan\\Fixtures\\OtherAudit" + ], + [ + "make", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "bind attribute default": { + "result": [ + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\Greeter" + }, + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\OtherAudit" + }, + { + "value": 0 + } + ], + "exception": null, + "calls": [ + [ + "singleton", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract", + "Tests\\Router\\ActionPlan\\Fixtures\\Greeter" + ], + [ + "make", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract" + ], + [ + "bind", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit", + "Tests\\Router\\ActionPlan\\Fixtures\\OtherAudit" + ], + [ + "make", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "bind on builtin type": { + "result": null, + "exception": [ + "Exception", + "Parameter 'value' must be a class-typed when using Bind" + ], + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "bind on untyped param": { + "result": null, + "exception": [ + "Exception", + "Parameter 'value' must be a class-typed when using Bind" + ], + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "payload loose": { + "result": [ + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\Dto" + } + ], + "exception": null, + "calls": [ + [ + "make", + "Tests\\Router\\ActionPlan\\Fixtures\\Dto" + ], + [ + "make", + "request" + ], + [ + "request.bindTo", + "Tests\\Router\\ActionPlan\\Fixtures\\Dto", + false + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "payload strict": { + "result": [ + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\Dto" + } + ], + "exception": null, + "calls": [ + [ + "make", + "Tests\\Router\\ActionPlan\\Fixtures\\Dto" + ], + [ + "make", + "request" + ], + [ + "request.bindTo", + "Tests\\Router\\ActionPlan\\Fixtures\\Dto", + true + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "payload validated": { + "result": [ + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\Dto" + } + ], + "exception": null, + "calls": [ + [ + "make", + "Tests\\Router\\ActionPlan\\Fixtures\\Dto" + ], + [ + "make", + "request" + ], + [ + "request.validateDto", + "Tests\\Router\\ActionPlan\\Fixtures\\Dto", + true + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "payload on builtin": { + "result": null, + "exception": [ + "Exception", + "Parameter 'dto' must be a class-typed DTO when using Payload" + ], + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "payload class missing": { + "result": null, + "exception": [ + "Exception", + "Cannot resolve DTO class 'Tests\\Router\\ActionPlan\\Fixtures\\DoesNotExistDto' for parameter 'dto'" + ], + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "model by route key": { + "result": [ + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\FakeModel" + } + ], + "exception": null, + "calls": [ + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeModel" + ] + ], + "models": [ + [ + "where", + "slug", + "my-slug" + ], + [ + "first" + ] + ], + "formsValidated": 0, + "transactions": [] + }, + "model by custom column": { + "result": [ + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\FakeModel" + } + ], + "exception": null, + "calls": [ + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeModel" + ] + ], + "models": [ + [ + "where", + "email", + "a@b.c" + ], + [ + "first" + ] + ], + "formsValidated": 0, + "transactions": [] + }, + "model by primary key": { + "result": [ + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\FakeModel" + } + ], + "exception": null, + "calls": [ + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeModel" + ] + ], + "models": [ + [ + "find", + 12 + ] + ], + "formsValidated": 0, + "transactions": [] + }, + "model not found aborts": { + "result": null, + "exception": [ + "RuntimeException", + "aborted 404: Tests\\Router\\ActionPlan\\Fixtures\\FakeModel not found with slug = gone" + ], + "calls": [ + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeModel" + ], + [ + "get", + "abort" + ] + ], + "models": [ + [ + "where", + "slug", + "gone" + ], + [ + "first" + ] + ], + "formsValidated": 0, + "transactions": [] + }, + "model not found without exception passes null": { + "result": [ + { + "value": null + } + ], + "exception": null, + "calls": [ + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeModel" + ] + ], + "models": [ + [ + "where", + "email", + "x" + ], + [ + "first" + ] + ], + "formsValidated": 0, + "transactions": [] + }, + "model param missing from url": { + "result": null, + "exception": [ + "Exception", + "Route parameter '$model' not found in URL for model binding" + ], + "calls": [ + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeModel" + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "model on builtin type": { + "result": null, + "exception": [ + "Exception", + "Parameter '$model' must have a class type hint when using #[Model] attribute" + ], + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "constructor dependencies": { + "result": { + "ctor": [ + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + }, + { + "value": 5 + } + ], + "args": [] + }, + "exception": null, + "calls": [ + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "singleton", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "invokable controller": { + "result": [ + { + "value": 3 + } + ], + "exception": null, + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "transaction with options": { + "result": [ + { + "value": 2 + } + ], + "exception": null, + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [ + [ + "tracked", + "analytics", + 3 + ] + ] + }, + "transaction defaults": { + "result": [], + "exception": null, + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [ + [ + "defaults", + null, + 1 + ] + ] + }, + "form request": { + "result": [ + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\FakeForm" + } + ], + "exception": null, + "calls": [ + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeForm" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeForm" + ], + [ + "singleton", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeForm", + "Closure" + ], + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeForm" + ], + [ + "get", + "request" + ] + ], + "models": [], + "formsValidated": 1, + "transactions": [] + }, + "form request already bound": { + "result": [ + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\FakeForm" + } + ], + "exception": null, + "calls": [ + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeForm" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeForm" + ], + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeForm" + ] + ], + "models": [], + "formsValidated": 1, + "transactions": [] + }, + "unresolvable class": { + "result": null, + "exception": [ + "InvalidArgumentException", + "Tests\\Router\\ActionPlan\\Fixtures\\UnresolvableController::run(): Argument #1 ($service) cannot be resolved. 'Tests\\Router\\ActionPlan\\Fixtures\\DoesNotExistService' is not bound in the container. " + ], + "calls": [ + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\DoesNotExistService" + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "defaults of every kind": { + "result": [ + { + "value": { + "a": 1, + "b": [ + true, + null + ] + } + }, + { + "value": 8 + }, + { + "value": "label" + }, + { + "value": 0.5 + }, + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + } + ], + "exception": null, + "calls": [ + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "singleton", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "defaults that need reflection": { + "result": [ + "Fast", + "Slow" + ], + "exception": null, + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "nullable class param": { + "result": [ + { + "object": "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + } + ], + "exception": null, + "calls": [ + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "singleton", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ], + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "missing method": { + "result": null, + "exception": [ + "BadMethodCallException", + "Method Tests\\Router\\ActionPlan\\Fixtures\\PlainController::nope() does not exist" + ], + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "missing class": { + "result": null, + "exception": [ + "ReflectionException", + "Class \"Tests\\Router\\ActionPlan\\Fixtures\\NoSuchController\" does not exist" + ], + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "closure with route params": { + "result": [ + 5, + "n" + ], + "exception": null, + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "closure with service": { + "result": [ + "Tests\\Router\\ActionPlan\\Fixtures\\Audit", + 1 + ], + "exception": null, + "calls": [ + [ + "make", + "Tests\\Router\\ActionPlan\\Fixtures\\Audit" + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "closure with default": { + "result": 2, + "exception": null, + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "closure unresolvable": { + "result": null, + "exception": [ + "Exception", + "Cannot resolve parameter 'id' for closure" + ], + "calls": [], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "closure with bind": { + "result": "Tests\\Router\\ActionPlan\\Fixtures\\Greeter", + "exception": null, + "calls": [ + [ + "singleton", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract", + "Tests\\Router\\ActionPlan\\Fixtures\\Greeter" + ], + [ + "make", + "Tests\\Router\\ActionPlan\\Fixtures\\GreeterContract" + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "closure with payload": { + "result": "Tests\\Router\\ActionPlan\\Fixtures\\Dto", + "exception": null, + "calls": [ + [ + "make", + "Tests\\Router\\ActionPlan\\Fixtures\\Dto" + ], + [ + "make", + "request" + ], + [ + "request.bindTo", + "Tests\\Router\\ActionPlan\\Fixtures\\Dto", + true + ] + ], + "models": [], + "formsValidated": 0, + "transactions": [] + }, + "closure with form request": { + "result": "Tests\\Router\\ActionPlan\\Fixtures\\FakeForm", + "exception": null, + "calls": [ + [ + "has", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeForm" + ], + [ + "singleton", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeForm", + "Closure" + ], + [ + "get", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeForm" + ], + [ + "get", + "request" + ], + [ + "make", + "Tests\\Router\\ActionPlan\\Fixtures\\FakeForm" + ], + [ + "get", + "request" + ] + ], + "models": [], + "formsValidated": 1, + "transactions": [] + } +}