diff --git a/src/ActionManager.php b/src/ActionManager.php index 94647b6..10f5ed2 100755 --- a/src/ActionManager.php +++ b/src/ActionManager.php @@ -73,7 +73,7 @@ public function getDesignPatterns(): array public function registerDesignPattern(DesignPattern $designPattern): ActionManager { $this->designPatterns[] = $designPattern; - + return $this; } @@ -136,7 +136,7 @@ public function identifyFromBacktrace($usedTraits, ?BacktraceFrame &$frame = nul $designPatterns = $this->getDesignPatternsMatching($usedTraits); $backtraceOptions = DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS; - + $ownNumberOfFrames = 2; $frames = array_slice( debug_backtrace($backtraceOptions, $ownNumberOfFrames + $this->backtraceLimit), diff --git a/src/ActionServiceProvider.php b/src/ActionServiceProvider.php index 4fe9b87..82c280c 100644 --- a/src/ActionServiceProvider.php +++ b/src/ActionServiceProvider.php @@ -8,6 +8,7 @@ use Lorisleiva\Actions\DesignPatterns\CommandDesignPattern; use Lorisleiva\Actions\DesignPatterns\ControllerDesignPattern; use Lorisleiva\Actions\DesignPatterns\ListenerDesignPattern; +use Lorisleiva\Actions\DesignPatterns\PipelineDesignPattern; class ActionServiceProvider extends ServiceProvider { @@ -21,6 +22,7 @@ public function register(): void new ControllerDesignPattern(), new ListenerDesignPattern(), new CommandDesignPattern(), + new PipelineDesignPattern(), ]); }); diff --git a/src/Concerns/AsAction.php b/src/Concerns/AsAction.php index c34ff59..6c813e3 100644 --- a/src/Concerns/AsAction.php +++ b/src/Concerns/AsAction.php @@ -10,4 +10,5 @@ trait AsAction use AsJob; use AsCommand; use AsFake; + use AsPipeline; } diff --git a/src/Concerns/AsPipeline.php b/src/Concerns/AsPipeline.php new file mode 100644 index 0000000..2fbb9ce --- /dev/null +++ b/src/Concerns/AsPipeline.php @@ -0,0 +1,8 @@ +setAction($action); + } + + public function __invoke(mixed ...$arguments): mixed + { + return $this->handleFromAnyMethod(...$arguments); + } + + public function handle(mixed ...$arguments): mixed + { + return $this->handleFromAnyMethod(...$arguments); + } + + /** + * Typical pipeline behavior expects two things: + * + * 1) The pipe class to expect a single incoming parameter (along with + * a closure) and single return value. + * 2) The pipe class to be aware of the next closure and determine what + * should be passed into the next pipe. + * + * Because of these expectations, this behavior is asserting two opinions: + * + * 1) Regardless of the number of parameters provided to the asPipeline + * method implemented here, only the first will be supplied to the + * invoked Action. + * 2) If the invoked Action does not return anything, then the next + * closure will be supplied the same parameter. However, if the + * invoked action does return a non-null value, that value will + * be supplied to the next closure. + */ + protected function handleFromAnyMethod(mixed ...$arguments): mixed + { + $passable = array_shift($arguments); + $closure = array_pop($arguments); + $returned = null; + + if ($this->hasMethod('asPipeline')) { + $returned = $this->callMethod('asPipeline', [$passable]); + } elseif ($this->hasMethod('handle')) { + $returned = $this->callMethod('handle', [$passable]); + } + + return $closure($returned ?? $passable); + } +} diff --git a/src/DesignPatterns/PipelineDesignPattern.php b/src/DesignPatterns/PipelineDesignPattern.php new file mode 100644 index 0000000..b11e0d3 --- /dev/null +++ b/src/DesignPatterns/PipelineDesignPattern.php @@ -0,0 +1,27 @@ +matches(Pipeline::class, 'Illuminate\Pipeline\{closure}'); + } + + public function decorate($instance, BacktraceFrame $frame) + { + return app(PipelineDecorator::class, ['action' => $instance]); + } +} diff --git a/tests/AsPipelineTest.php b/tests/AsPipelineTest.php new file mode 100644 index 0000000..e00cecc --- /dev/null +++ b/tests/AsPipelineTest.php @@ -0,0 +1,210 @@ +count++; + } +} + +class AsPipelineExplicitTest +{ + use AsPipeline; + + public function handle(AsPipelinePassable $passable): void + { + $passable->increment(); + } + + public function asPipeline(AsPipelinePassable $passable): AsPipelinePassable + { + $this->handle($passable); + + return $passable; + } +} + +class AsPipelineImplicitTest +{ + use AsAction; + + public function handle(AsPipelinePassable $passable): void + { + $passable->increment(); + } + + public function asPipeline(AsPipelinePassable $passable): AsPipelinePassable + { + $this->handle($passable); + + return $passable; + } +} + +class AsPipelineMultipleParamTest +{ + use AsAction; + + public function handle(AsPipelinePassable $passable): void + { + $passable->increment(); + } + + public function asPipeline(AsPipelinePassable $passable, int $foo): AsPipelinePassable + { + $this->handle($passable); + + return $passable; + } +} + +class AsPipelineSingleParamHandleOnlyTest +{ + use AsAction; + + public function handle(AsPipelinePassable $passable): void + { + $passable->increment(); + } +} + +class AsPipelineMultipleParamHandleOnlyTest +{ + use AsAction; + + public function handle(AsPipelinePassable $passable, int $foo): void + { + $passable->increment(); + } +} + +class AsPipelineWithoutHandleOrAsPipeline +{ + use AsAction; +} + +function getAnonymous() { + return function (AsPipelinePassable $p, $next) { + $p->increment(); + + return $next($p); + }; +} + +function getPassable() { + return new AsPipelinePassable; +} + +it('can run as a pipe in a pipeline, with explicit trait', function () { + $anonymous = getAnonymous(); + $passable = Pipeline::send(getPassable()) + ->through([ + AsPipelineExplicitTest::class, + $anonymous, + AsPipelineExplicitTest::class, + $anonymous, + ]) + ->thenReturn(); + + expect(is_object($passable))->toBe(true); + expect($passable->count)->toBe(4); +}); + +it('can run as a pipe in a pipeline, with implicit trait', function () { + $anonymous = getAnonymous(); + $passable = Pipeline::send(getPassable()) + ->through([ + AsPipelineImplicitTest::class, + $anonymous, + AsPipelineImplicitTest::class, + $anonymous, + ]) + ->thenReturn(); + + expect(is_object($passable))->toBe(true); + expect($passable->count)->toBe(4); +}); + +it('can run as a pipe in a pipeline, without an explicit asPipeline method', function () { + $anonymous = getAnonymous(); + $passable = Pipeline::send(getPassable()) + ->through([ + AsPipelineSingleParamHandleOnlyTest::class, + $anonymous, + AsPipelineSingleParamHandleOnlyTest::class, + $anonymous, + ]) + ->thenReturn(); + + expect(is_object($passable))->toBe(true); + expect($passable->count)->toBe(4); +}); + +it('can run as a noop/passthrough pipe in a pipeline, without a handle or asPipeline method', function () { + $anonymous = getAnonymous(); + $passable = Pipeline::send(getPassable()) + ->through([ + AsPipelineWithoutHandleOrAsPipeline::class, + $anonymous, + AsPipelineWithoutHandleOrAsPipeline::class, + $anonymous, + ]) + ->thenReturn(); + + expect(is_object($passable))->toBe(true); + expect($passable->count)->toBe(2); +}); + +it('can run with an arbitrary via method configured on Pipeline', function () { + $anonymous = getAnonymous(); + $passable = Pipeline::send(getPassable()) + ->via('foobar') + ->through([ + AsPipelineImplicitTest::class, + $anonymous, + AsPipelineImplicitTest::class, + $anonymous, + ]) + ->thenReturn(); + + expect(is_object($passable))->toBe(true); + expect($passable->count)->toBe(4); +}); + +it('cannot run as a pipe in a pipeline, with an explicit asPipeline method expecting multiple non-optional params', function () { + $anonymous = getAnonymous(); + $passable = Pipeline::send(getPassable()) + ->through([ + AsPipelineMultipleParamTest::class, + $anonymous, + AsPipelineMultipleParamTest::class, + $anonymous, + ]) + ->thenReturn(); +})->throws(ArgumentCountError::class, 'Too few arguments to function Lorisleiva\Actions\Tests\AsPipelineMultipleParamTest::asPipeline(), 1 passed and exactly 2 expected'); + +it('cannot run as a pipe in a pipeline, without an explicit asPipeline method and multiple non-optional handle params', function () { + $anonymous = getAnonymous(); + $passable = Pipeline::send(getPassable()) + ->through([ + AsPipelineMultipleParamHandleOnlyTest::class, + $anonymous, + AsPipelineMultipleParamHandleOnlyTest::class, + $anonymous, + ]) + ->thenReturn(); +})->throws(ArgumentCountError::class, 'Too few arguments to function Lorisleiva\Actions\Tests\AsPipelineMultipleParamHandleOnlyTest::handle(), 1 passed and exactly 2 expected');