diff --git a/src/Console/src/Command.php b/src/Console/src/Command.php index 95e03d266..2a6ab2772 100644 --- a/src/Console/src/Command.php +++ b/src/Console/src/Command.php @@ -113,7 +113,7 @@ function () use ($method) { return $core instanceof HandlerInterface ? (int)$core->handle(new CallContext( - Target::fromReflection(new \ReflectionMethod(static::class, $method)), + Target::fromPair($this, $method), $arguments, )) : (int)$core->callAction(static::class, $method, $arguments); diff --git a/src/Events/src/EventDispatcher.php b/src/Events/src/EventDispatcher.php index 67680962d..47e5b2231 100644 --- a/src/Events/src/EventDispatcher.php +++ b/src/Events/src/EventDispatcher.php @@ -24,7 +24,7 @@ public function dispatch(object $event): object return $this->isLegacy ? $this->core->callAction($event::class, 'dispatch', ['event' => $event]) : $this->core->handle(new CallContext( - Target::fromReflection(new \ReflectionMethod($event::class, 'dispatch')), + Target::fromPair($event, 'dispatch'), ['event' => $event], )); } diff --git a/src/Hmvc/src/Core/AbstractCore.php b/src/Hmvc/src/Core/AbstractCore.php index 6c770a26e..09029a8e7 100644 --- a/src/Hmvc/src/Core/AbstractCore.php +++ b/src/Hmvc/src/Core/AbstractCore.php @@ -33,9 +33,11 @@ public function __construct( // TODO: can we simplify this? // resolver is usually the container itself /** @psalm-suppress MixedAssignment */ - $this->resolver = $container - ->get(InvokerInterface::class) - ->invoke(static fn (#[Proxy] ResolverInterface $resolver) => $resolver); + $this->resolver = $container instanceof ResolverInterface + ? $container + : $container + ->get(InvokerInterface::class) + ->invoke(static fn (#[Proxy] ResolverInterface $resolver) => $resolver); } /** @@ -49,7 +51,7 @@ public function callAction(string $controller, string $action, array $parameters // Validate method ActionResolver::validateControllerMethod($method); - return $this->invoke($controller, $method, $parameters); + return $this->invoke(null, $controller, $method, $parameters); } public function handle(CallContext $context): mixed @@ -57,7 +59,7 @@ public function handle(CallContext $context): mixed $target = $context->getTarget(); $reflection = $target->getReflection(); return $reflection instanceof \ReflectionMethod - ? $this->invoke($target->getPath()[0], $reflection, $context->getArguments()) + ? $this->invoke($target->getObject(), $target->getPath()[0], $reflection, $context->getArguments()) : $this->callAction($target->getPath()[0], $target->getPath()[1], $context->getArguments()); } @@ -82,7 +84,7 @@ protected function resolveArguments(\ReflectionMethod $method, array $parameters /** * @throws \Throwable */ - private function invoke(string $class, \ReflectionMethod $method, array $arguments): mixed + private function invoke(?object $object, string $class, \ReflectionMethod $method, array $arguments): mixed { try { $args = $this->resolveArguments($method, $arguments); @@ -105,6 +107,6 @@ private function invoke(string $class, \ReflectionMethod $method, array $argumen ); } - return $method->invokeArgs($this->container->get($class), $args); + return $method->invokeArgs($object ?? $this->container->get($class), $args); } } diff --git a/src/Hmvc/src/Interceptors/Context/Target.php b/src/Hmvc/src/Interceptors/Context/Target.php index 40180cd03..b246fa78b 100644 --- a/src/Hmvc/src/Interceptors/Context/Target.php +++ b/src/Hmvc/src/Interceptors/Context/Target.php @@ -4,71 +4,147 @@ namespace Spiral\Interceptors\Context; +/** + * @template-covariant TController of object|null + * @implements TargetInterface + */ final class Target implements TargetInterface { /** * @param list $path * @param \ReflectionFunctionAbstract|null $reflection + * @param TController|null $object */ private function __construct( - private ?array $path = null, + private array $path, private ?\ReflectionFunctionAbstract $reflection = null, - private readonly string $delimiter = '.', + private readonly ?object $object = null, + private string $delimiter = '.', ) { } public function __toString(): string { - return match (true) { - $this->path !== null => \implode($this->delimiter, $this->path), - $this->reflection !== null => $this->reflection->getName(), - }; + return \implode($this->delimiter, $this->path); } - public static function fromReflection(\ReflectionFunctionAbstract $reflection): self + /** + * Create a target from a method reflection. + * + * @template T of object + * + * @param \ReflectionMethod $reflection + * @param class-string|T $classOrObject The original class name or object. + * It's required because the reflection may be referring to a parent class method. + * THe path will contain the original class name and the method name. + * + * @psalmif + * + * @return self + */ + public static function fromReflectionMethod( + \ReflectionFunctionAbstract $reflection, + string|object $classOrObject, + ): self { + /** @var self $result */ + $result = \is_object($classOrObject) + ? new self( + path: [$classOrObject::class, $reflection->getName()], + reflection: $reflection, + object: $classOrObject, + delimiter: $reflection->isStatic() ? '::' : '->', + ) + : new self( + path: [$classOrObject, $reflection->getName()], + reflection: $reflection, + delimiter: $reflection->isStatic() ? '::' : '->', + ); + return $result; + } + + /** + * Create a target from a function reflection. + * + * @param list $path + * + * @return self + */ + public static function fromReflectionFunction(\ReflectionFunction $reflection, array $path = []): self + { + /** @var self $result */ + $result = new self(path: $path, reflection: $reflection); + return $result; + } + + /** + * Create a target from a closure. + * + * @param list $path + * + * @return self + */ + public static function fromClosure(\Closure $closure, array $path = []): self { - return new self(reflection: $reflection); + return self::fromReflectionFunction(new \ReflectionFunction($closure), $path); } + /** + * Create a target from a path string without reflection. + * + * @param non-empty-string $delimiter + * + * @return self + */ public static function fromPathString(string $path, string $delimiter = '.'): self { - /** @psalm-suppress ArgumentTypeCoercion */ - return new self(path: \explode($delimiter, $path), delimiter: $delimiter); + return self::fromPathArray(\explode($delimiter, $path), $delimiter); } /** + * Create a target from a path array without reflection. + * * @param list $path + * @return self */ public static function fromPathArray(array $path, string $delimiter = '.'): self { - return new self(path: $path, delimiter: $delimiter); + /** @var self $result */ + $result = new self(path: $path, delimiter: $delimiter); + return $result; } - public static function fromPair(string $controller, string $action): self + /** + * Create a target from a controller and action pair. + * If the action is a method of the controller, the reflection will be set. + * + * @template T of object + * + * @param non-empty-string|class-string|T $controller + * @param non-empty-string $action + * + * @return ($controller is class-string|T ? self : self) + */ + public static function fromPair(string|object $controller, string $action): self { - $target = \method_exists($controller, $action) - ? self::fromReflection(new \ReflectionMethod($controller, $action)) - : self::fromPathArray([$controller, $action]); - return $target->withPath([$controller, $action]); + /** @psalm-suppress ArgumentTypeCoercion */ + if (\is_object($controller) || \method_exists($controller, $action)) { + /** @var T|class-string $controller */ + return self::fromReflectionMethod(new \ReflectionMethod($controller, $action), $controller); + } + + return self::fromPathArray([$controller, $action]); } public function getPath(): array { - return match (true) { - $this->path !== null => $this->path, - $this->reflection instanceof \ReflectionMethod => [ - $this->reflection->getDeclaringClass()->getName(), - $this->reflection->getName(), - ], - $this->reflection instanceof \ReflectionFunction => [$this->reflection->getName()], - default => [], - }; + return $this->path; } - public function withPath(array $path): static + public function withPath(array $path, ?string $delimiter = null): static { $clone = clone $this; $clone->path = $path; + $clone->delimiter = $delimiter ?? $clone->delimiter; return $clone; } @@ -77,10 +153,8 @@ public function getReflection(): ?\ReflectionFunctionAbstract return $this->reflection; } - public function withReflection(?\ReflectionFunctionAbstract $reflection): static + public function getObject(): ?object { - $clone = clone $this; - $clone->reflection = $reflection; - return $clone; + return $this->object; } } diff --git a/src/Hmvc/src/Interceptors/Context/TargetInterface.php b/src/Hmvc/src/Interceptors/Context/TargetInterface.php index a5c49bbac..fbf4b097b 100644 --- a/src/Hmvc/src/Interceptors/Context/TargetInterface.php +++ b/src/Hmvc/src/Interceptors/Context/TargetInterface.php @@ -9,32 +9,46 @@ /** * The target may be a concrete reflection or an alias. * In both cases, you can get a path to the target. + * + * @template-covariant TController of object|null */ interface TargetInterface extends Stringable { /** - * @return list + * Returns the path to the target. + * If the target was created from a method reflection, the path will contain + * the class name and the method name by default. + * + * @return list|list{class-string, non-empty-string} */ public function getPath(): array; /** * @param list $path + * @param string|null $delimiter The delimiter to use when converting the path to a string. */ - public function withPath(array $path): static; + public function withPath(array $path, ?string $delimiter = null): static; /** * Returns the reflection of the target. * * It may be {@see \ReflectionFunction} or {@see \ReflectionMethod}. - * Note: {@see \ReflectionMethod::getDeclaringClass()} may return a parent class, but not the class used - * when the target was created. Use {@see Target::getPath()} to get the original target class. + * + * NOTE: + * The method {@see \ReflectionMethod::getDeclaringClass()} may return a parent class, + * but not the class used when the target was created. + * Use {@see getObject()} or {@see Target::getPath()}[0] to get the original object or class name. * * @psalm-pure */ public function getReflection(): ?\ReflectionFunctionAbstract; /** - * @param \ReflectionFunctionAbstract|null $reflection Pass null to remove the reflection. + * Returns the object associated with the target. + * + * If the object is present, it always corresponds to the method reflection from {@see getReflection()}. + * + * @return TController|null */ - public function withReflection(?\ReflectionFunctionAbstract $reflection): static; + public function getObject(): ?object; } diff --git a/src/Hmvc/src/Interceptors/Handler/ReflectionHandler.php b/src/Hmvc/src/Interceptors/Handler/ReflectionHandler.php index 20511d6f8..ff65a0c6a 100644 --- a/src/Hmvc/src/Interceptors/Handler/ReflectionHandler.php +++ b/src/Hmvc/src/Interceptors/Handler/ReflectionHandler.php @@ -62,7 +62,7 @@ public function handle(CallContext $context): mixed throw new TargetCallException("Action not found for target `{$context->getTarget()}`."); } - $controller = $this->container->get($path[0]); + $controller = $context->getTarget()->getObject() ?? $this->container->get($path[0]); // Validate method and controller ActionResolver::validateControllerMethod($method, $controller); diff --git a/src/Hmvc/tests/Core/CoreTest.php b/src/Hmvc/tests/Core/CoreTest.php index b48eb210d..d45c15302 100644 --- a/src/Hmvc/tests/Core/CoreTest.php +++ b/src/Hmvc/tests/Core/CoreTest.php @@ -4,9 +4,13 @@ namespace Spiral\Tests\Core; +use Psr\Container\ContainerInterface; +use Spiral\Core\Container; use Spiral\Core\Exception\ControllerException; +use Spiral\Core\ResolverInterface; use Spiral\Interceptors\Context\CallContext; use Spiral\Interceptors\Context\Target; +use Spiral\Interceptors\Handler\ReflectionHandler; use Spiral\Testing\Attribute\TestScope; use Spiral\Testing\TestCase; use Spiral\Tests\Core\Fixtures\CleanController; @@ -213,4 +217,18 @@ public function testHandleReflectionMethodFromExtendedAbstractClass(): void self::assertSame('hello', $result); } + + public function testHandleReflectionMethodWithObject(): void + { + $c = new Container(); + $handler = new SampleCore($c); + // Call Context + $service = new \Spiral\Tests\Interceptors\Unit\Stub\TestService(); + $ctx = (new CallContext(Target::fromPair($service, 'parentMethod')->withPath(['foo', 'bar']))) + ->withArguments(['HELLO']); + + $result = $handler->handle($ctx); + + self::assertSame('hello', $result); + } } diff --git a/src/Hmvc/tests/Interceptors/Unit/Context/TargetTest.php b/src/Hmvc/tests/Interceptors/Unit/Context/TargetTest.php index 1f8c8c653..f11c390bb 100644 --- a/src/Hmvc/tests/Interceptors/Unit/Context/TargetTest.php +++ b/src/Hmvc/tests/Interceptors/Unit/Context/TargetTest.php @@ -15,40 +15,51 @@ public function testCreateFromReflectionFunction(): void { $reflection = new \ReflectionFunction('print_r'); - $target = Target::fromReflection($reflection); + $target = Target::fromReflectionFunction($reflection, ['print_r-path']); self::assertSame($reflection, $target->getReflection()); - self::assertSame('print_r', (string)$target); + self::assertSame('print_r-path', (string)$target); + self::assertNull($target->getObject()); } - public function testCreateFromReflectionMethod(): void + public function testCreateFromClosure(): void + { + $target = Target::fromClosure(\print_r(...), ['print_r-path']); + + self::assertNotNull($target->getReflection()); + self::assertSame('print_r-path', (string)$target); + self::assertNull($target->getObject()); + } + + public function testCreateFromClosureWithContext(): void + { + $target = Target::fromClosure($this->{__FUNCTION__}(...), ['print_r-path']); + + self::assertNotNull($target->getReflection()); + self::assertSame('print_r-path', (string)$target); + self::assertNull($target->getObject()); + } + + public function testCreateFromReflectionMethodClassName(): void { $reflection = new \ReflectionMethod($this, __FUNCTION__); - $target = Target::fromReflection($reflection); + $target = Target::fromReflectionMethod($reflection, __CLASS__); self::assertSame($reflection, $target->getReflection()); - self::assertSame(__FUNCTION__, (string)$target); + self::assertSame(__CLASS__ . '->' . __FUNCTION__, (string)$target); + self::assertNull($target->getObject()); } - public function testWithReflectionFunction(): void + public function testCreateFromReflectionMethodObject(): void { - $reflection = new \ReflectionFunction('print_r'); + $reflection = new \ReflectionMethod($this, __FUNCTION__); - $target = Target::fromPathArray(['foo', 'bar']); - $target2 = $target->withReflection($reflection); + $target = Target::fromReflectionMethod($reflection, $this); - // Immutability - self::assertNotSame($target, $target2); - // First target is not changed - self::assertSame(['foo', 'bar'], $target->getPath()); - self::assertNull($target->getReflection()); - self::assertSame('foo.bar', (string)$target); - // Second target is changed - self::assertSame(['foo', 'bar'], $target2->getPath()); - self::assertSame($reflection, $target2->getReflection()); - // Reflection does'n affect the string representation if path is set - self::assertSame('foo.bar', (string)$target); + self::assertSame($reflection, $target->getReflection()); + self::assertSame(__CLASS__ . '->' . __FUNCTION__, (string)$target); + self::assertNotNull($target->getObject()); } public function testCreateFromPathStringWithPath(): void @@ -112,12 +123,25 @@ public function testCreateFromPair(string $controller, string $action, bool $has self::assertSame([$controller, $action], $target->getPath()); $reflection = $target->getReflection(); self::assertSame($hasReflection, $reflection !== null); + self::assertNull($target->getObject()); if ($hasReflection) { self::assertInstanceOf(\ReflectionMethod::class, $reflection); self::assertSame($action, $reflection->getName()); } } + public function testCreateFromObject(): void + { + $service = new TestService(); + $target = Target::fromPair($service, 'parentMethod'); + + self::assertSame([TestService::class, 'parentMethod'], $target->getPath()); + $reflection = $target->getReflection(); + self::assertInstanceOf(\ReflectionMethod::class, $reflection); + self::assertSame('parentMethod', $reflection->getName()); + self::assertSame($service, $target->getObject()); + } + public function testCreateFromPathStringDefaultSeparator(): void { $str = 'foo.bar.baz'; diff --git a/src/Hmvc/tests/Interceptors/Unit/Handler/ReflectionHandlerTest.php b/src/Hmvc/tests/Interceptors/Unit/Handler/ReflectionHandlerTest.php index c19172dd9..cf00c25e9 100644 --- a/src/Hmvc/tests/Interceptors/Unit/Handler/ReflectionHandlerTest.php +++ b/src/Hmvc/tests/Interceptors/Unit/Handler/ReflectionHandlerTest.php @@ -40,7 +40,7 @@ public function testHandleReflectionFunction(): void ->willReturn($c); $handler = new ReflectionHandler($container, false); // Call Context - $ctx = new CallContext(Target::fromReflection(new \ReflectionFunction('strtoupper'))); + $ctx = new CallContext(Target::fromReflectionFunction(new \ReflectionFunction('strtoupper'))); $ctx = $ctx->withArguments(['hello']); $result = $handler->handle($ctx); @@ -48,32 +48,24 @@ public function testHandleReflectionFunction(): void self::assertSame('HELLO', $result); } - public function testHandleWrongReflectionFunction(): void + public function testHandleReflectionMethodWithObject(): void { - $handler = $this->createHandler(); + $c = new Container(); + $container = self::createMock(ContainerInterface::class); + $container + ->expects(self::once()) + ->method('get') + ->with(ResolverInterface::class) + ->willReturn($c); + $handler = new ReflectionHandler($container, false); // Call Context - $ctx = new CallContext(Target::fromReflection(new class extends \ReflectionFunctionAbstract { - /** @psalm-immutable */ - public function getName(): string - { - return 'testReflection'; - } - - public function __toString(): string - { - return 'really?'; - } - - public static function export(): void - { - // do nothing - } - })); + $service = new TestService(); + $ctx = (new CallContext(Target::fromPair($service, 'parentMethod'))) + ->withArguments(['HELLO']); - self::expectException(TargetCallException::class); - self::expectExceptionMessageMatches('/Action not found for target `testReflection`/'); + $result = $handler->handle($ctx); - $handler->handle($ctx); + self::assertSame('hello', $result); } public function testWithoutResolvingFromPathAndReflection(): void @@ -124,7 +116,7 @@ public function testUsingResolver(): void { $handler = $this->createHandler(); $ctx = new CallContext( - Target::fromReflection(new \ReflectionFunction(fn (string $value):string => \strtoupper($value))) + Target::fromReflectionFunction(new \ReflectionFunction(fn (string $value):string => \strtoupper($value))) ); $ctx = $ctx->withArguments(['word' => 'world!', 'value' => 'hello']);