Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for AsPipeline concern #304

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
4 changes: 2 additions & 2 deletions src/ActionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public function getDesignPatterns(): array
public function registerDesignPattern(DesignPattern $designPattern): ActionManager
{
$this->designPatterns[] = $designPattern;

return $this;
}

Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions src/ActionServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -21,6 +22,7 @@ public function register(): void
new ControllerDesignPattern(),
new ListenerDesignPattern(),
new CommandDesignPattern(),
new PipelineDesignPattern(),
]);
});

Expand Down
1 change: 1 addition & 0 deletions src/Concerns/AsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ trait AsAction
use AsJob;
use AsCommand;
use AsFake;
use AsPipeline;
}
8 changes: 8 additions & 0 deletions src/Concerns/AsPipeline.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Lorisleiva\Actions\Concerns;

trait AsPipeline
{
//
}
60 changes: 60 additions & 0 deletions src/Decorators/PipelineDecorator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace Lorisleiva\Actions\Decorators;

use Closure;
use Exception;
use Lorisleiva\Actions\Concerns\DecorateActions;

class PipelineDecorator
{
use DecorateActions;

public function __construct($action)
{
$this->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]);
Copy link

@edalzell edalzell Jan 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100% personal preference here but I'd prefer:

return $closure($this->callMethod('asPipeline', [$passable]) ?? $passable);

Here to remove the else

} elseif ($this->hasMethod('handle')) {
$returned = $this->callMethod('handle', [$passable]);
}

return $closure($returned ?? $passable);
}
}
27 changes: 27 additions & 0 deletions src/DesignPatterns/PipelineDesignPattern.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Lorisleiva\Actions\DesignPatterns;

use Illuminate\Pipeline\Pipeline;
use Lorisleiva\Actions\BacktraceFrame;
use Lorisleiva\Actions\Concerns\AsPipeline;
use Lorisleiva\Actions\Decorators\PipelineDecorator;
use Lorisleiva\Actions\DesignPatterns\DesignPattern;

class PipelineDesignPattern extends DesignPattern
{
public function getTrait(): string
{
return AsPipeline::class;
}

public function recognizeFrame(BacktraceFrame $frame): bool
{
return $frame->matches(Pipeline::class, 'Illuminate\Pipeline\{closure}');
}

public function decorate($instance, BacktraceFrame $frame)
{
return app(PipelineDecorator::class, ['action' => $instance]);
}
}
210 changes: 210 additions & 0 deletions tests/AsPipelineTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
<?php

namespace Lorisleiva\Actions\Tests;

use ArgumentCountError;
use Closure;
use Illuminate\Support\Facades\Pipeline;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Concerns\AsPipeline;

class AsPipelinePassable
{
public function __construct(public int $count = 0)
{
//
}

public function increment()
{
$this->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');