diff --git a/src/Illuminate/Contracts/Pipeline/Hub.php b/src/Illuminate/Contracts/Pipeline/Hub.php new file mode 100644 index 00000000..1ae675f7 --- /dev/null +++ b/src/Illuminate/Contracts/Pipeline/Hub.php @@ -0,0 +1,15 @@ +container = $container; + } + + /** + * Define the default named pipeline. + * + * @param \Closure $callback + * @return void + */ + public function defaults(Closure $callback) + { + $this->pipeline('default', $callback); + } + + /** + * Define a new named pipeline. + * + * @param string $name + * @param \Closure $callback + * @return void + */ + public function pipeline($name, Closure $callback) + { + $this->pipelines[$name] = $callback; + } + + /** + * Send an object through one of the available pipelines. + * + * @param mixed $object + * @param string|null $pipeline + * @return mixed + */ + public function pipe($object, $pipeline = null) + { + $pipeline = $pipeline ?: 'default'; + + if (! isset($this->pipelines[$pipeline])) { + throw new InvalidArgumentException("Pipeline [{$pipeline}] is not defined."); + } + + return call_user_func( + $this->pipelines[$pipeline], new Pipeline($this->container), $object + ); + } + + /** + * Get the container instance used by the hub. + * + * @return \Illuminate\Contracts\Container\Container + */ + public function getContainer() + { + return $this->container; + } + + /** + * Set the container instance used by the hub. + * + * @param \Illuminate\Contracts\Container\Container $container + * @return $this + */ + public function setContainer(Container $container) + { + $this->container = $container; + + return $this; + } +} diff --git a/src/Illuminate/Pipeline/Pipeline.php b/src/Illuminate/Pipeline/Pipeline.php new file mode 100644 index 00000000..dd29236a --- /dev/null +++ b/src/Illuminate/Pipeline/Pipeline.php @@ -0,0 +1,325 @@ +container = $container; + } + + /** + * Set the object being sent through the pipeline. + * + * @param mixed $passable + * @return $this + */ + public function send($passable) + { + $this->passable = $passable; + + return $this; + } + + /** + * Set the array of pipes. + * + * @param mixed $pipes + * @return $this + */ + public function through($pipes) + { + $this->pipes = is_array($pipes) ? $pipes : func_get_args(); + + return $this; + } + + /** + * Push additional pipes onto the pipeline. + * + * @param mixed $pipes + * @return $this + */ + public function pipe($pipes) + { + array_push($this->pipes, ...(is_array($pipes) ? $pipes : func_get_args())); + + return $this; + } + + /** + * Set the method to call on the pipes. + * + * @param string $method + * @return $this + */ + public function via($method) + { + $this->method = $method; + + return $this; + } + + /** + * Run the pipeline with a final destination callback. + * + * @param \Closure $destination + * @return mixed + */ + public function then(Closure $destination) + { + $pipeline = array_reduce( + array_reverse($this->pipes()), $this->carry(), $this->prepareDestination($destination) + ); + + try { + return $this->withinTransaction !== false + ? $this->getContainer()->make('db')->connection($this->withinTransaction)->transaction(fn () => $pipeline($this->passable)) + : $pipeline($this->passable); + } finally { + if ($this->finally) { + ($this->finally)($this->passable); + } + } + } + + /** + * Run the pipeline and return the result. + * + * @return mixed + */ + public function thenReturn() + { + return $this->then(function ($passable) { + return $passable; + }); + } + + /** + * Set a final callback to be executed after the pipeline ends regardless of the outcome. + * + * @param \Closure $callback + * @return $this + */ + public function finally(Closure $callback) + { + $this->finally = $callback; + + return $this; + } + + /** + * Get the final piece of the Closure onion. + * + * @param \Closure $destination + * @return \Closure + */ + protected function prepareDestination(Closure $destination) + { + return function ($passable) use ($destination) { + try { + return $destination($passable); + } catch (Throwable $e) { + return $this->handleException($passable, $e); + } + }; + } + + /** + * Get a Closure that represents a slice of the application onion. + * + * @return \Closure + */ + protected function carry() + { + return function ($stack, $pipe) { + return function ($passable) use ($stack, $pipe) { + try { + if (is_callable($pipe)) { + // If the pipe is a callable, then we will call it directly, but otherwise we + // will resolve the pipes out of the dependency container and call it with + // the appropriate method and arguments, returning the results back out. + return $pipe($passable, $stack); + } elseif (! is_object($pipe)) { + [$name, $parameters] = $this->parsePipeString($pipe); + + // If the pipe is a string we will parse the string and resolve the class out + // of the dependency injection container. We can then build a callable and + // execute the pipe function giving in the parameters that are required. + $pipe = $this->getContainer()->make($name); + + $parameters = array_merge([$passable, $stack], $parameters); + } else { + // If the pipe is already an object we'll just make a callable and pass it to + // the pipe as-is. There is no need to do any extra parsing and formatting + // since the object we're given was already a fully instantiated object. + $parameters = [$passable, $stack]; + } + + $carry = method_exists($pipe, $this->method) + ? $pipe->{$this->method}(...$parameters) + : $pipe(...$parameters); + + return $this->handleCarry($carry); + } catch (Throwable $e) { + return $this->handleException($passable, $e); + } + }; + }; + } + + /** + * Parse full pipe string to get name and parameters. + * + * @param string $pipe + * @return array + */ + protected function parsePipeString($pipe) + { + [$name, $parameters] = array_pad(explode(':', $pipe, 2), 2, null); + + if (! is_null($parameters)) { + $parameters = explode(',', $parameters); + } else { + $parameters = []; + } + + return [$name, $parameters]; + } + + /** + * Get the array of configured pipes. + * + * @return array + */ + protected function pipes() + { + return $this->pipes; + } + + /** + * Execute each pipeline step within a database transaction. + * + * @param string|null|\UnitEnum|false $withinTransaction + * @return $this + */ + public function withinTransaction($withinTransaction = null) + { + $this->withinTransaction = $withinTransaction; + + return $this; + } + + /** + * Get the container instance. + * + * @return \Illuminate\Contracts\Container\Container + * + * @throws \RuntimeException + */ + protected function getContainer() + { + if (! $this->container) { + throw new RuntimeException('A container instance has not been passed to the Pipeline.'); + } + + return $this->container; + } + + /** + * Set the container instance. + * + * @param \Illuminate\Contracts\Container\Container $container + * @return $this + */ + public function setContainer(Container $container) + { + $this->container = $container; + + return $this; + } + + /** + * Handle the value returned from each pipe before passing it to the next. + * + * @param mixed $carry + * @return mixed + */ + protected function handleCarry($carry) + { + return $carry; + } + + /** + * Handle the given exception. + * + * @param mixed $passable + * @param \Throwable $e + * @return mixed + * + * @throws \Throwable + */ + protected function handleException($passable, Throwable $e) + { + throw $e; + } +} diff --git a/src/Illuminate/Pipeline/PipelineServiceProvider.php b/src/Illuminate/Pipeline/PipelineServiceProvider.php new file mode 100644 index 00000000..7b60ddec --- /dev/null +++ b/src/Illuminate/Pipeline/PipelineServiceProvider.php @@ -0,0 +1,38 @@ +app->singleton( + PipelineHubContract::class, + Hub::class + ); + + $this->app->bind('pipeline', fn ($app) => new Pipeline($app)); + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides() + { + return [ + PipelineHubContract::class, + 'pipeline', + ]; + } +} diff --git a/src/Illuminate/Support/HigherOrderWhenProxy.php b/src/Illuminate/Support/HigherOrderWhenProxy.php new file mode 100644 index 00000000..0a694c24 --- /dev/null +++ b/src/Illuminate/Support/HigherOrderWhenProxy.php @@ -0,0 +1,108 @@ +target = $target; + } + + /** + * Set the condition on the proxy. + * + * @param bool $condition + * @return $this + */ + public function condition($condition) + { + [$this->condition, $this->hasCondition] = [$condition, true]; + + return $this; + } + + /** + * Indicate that the condition should be negated. + * + * @return $this + */ + public function negateConditionOnCapture() + { + $this->negateConditionOnCapture = true; + + return $this; + } + + /** + * Proxy accessing an attribute onto the target. + * + * @param string $key + * @return mixed + */ + public function __get($key) + { + if (! $this->hasCondition) { + $condition = $this->target->{$key}; + + return $this->condition($this->negateConditionOnCapture ? ! $condition : $condition); + } + + return $this->condition + ? $this->target->{$key} + : $this->target; + } + + /** + * Proxy a method call on the target. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + if (! $this->hasCondition) { + $condition = $this->target->{$method}(...$parameters); + + return $this->condition($this->negateConditionOnCapture ? ! $condition : $condition); + } + + return $this->condition + ? $this->target->{$method}(...$parameters) + : $this->target; + } +} diff --git a/src/Illuminate/Support/Traits/Conditionable.php b/src/Illuminate/Support/Traits/Conditionable.php new file mode 100644 index 00000000..5e3194bb --- /dev/null +++ b/src/Illuminate/Support/Traits/Conditionable.php @@ -0,0 +1,73 @@ +condition($value); + } + + if ($value) { + return $callback($this, $value) ?? $this; + } elseif ($default) { + return $default($this, $value) ?? $this; + } + + return $this; + } + + /** + * Apply the callback if the given "value" is (or resolves to) falsy. + * + * @template TUnlessParameter + * @template TUnlessReturnType + * + * @param (\Closure($this): TUnlessParameter)|TUnlessParameter|null $value + * @param (callable($this, TUnlessParameter): TUnlessReturnType)|null $callback + * @param (callable($this, TUnlessParameter): TUnlessReturnType)|null $default + * @return $this|TUnlessReturnType + */ + public function unless($value = null, ?callable $callback = null, ?callable $default = null) + { + $value = $value instanceof Closure ? $value($this) : $value; + + if (func_num_args() === 0) { + return (new HigherOrderWhenProxy($this))->negateConditionOnCapture(); + } + + if (func_num_args() === 1) { + return (new HigherOrderWhenProxy($this))->condition(! $value); + } + + if (! $value) { + return $callback($this, $value) ?? $this; + } elseif ($default) { + return $default($this, $value) ?? $this; + } + + return $this; + } +} diff --git a/src/Illuminate/Support/Traits/Macroable.php b/src/Illuminate/Support/Traits/Macroable.php new file mode 100644 index 00000000..2ee06e17 --- /dev/null +++ b/src/Illuminate/Support/Traits/Macroable.php @@ -0,0 +1,134 @@ +getMethods( + ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED + ); + + foreach ($methods as $method) { + if ($replace || ! static::hasMacro($method->name)) { + static::macro($method->name, $method->invoke($mixin)); + } + } + } + + /** + * Checks if macro is registered. + * + * @param string $name + * @return bool + */ + public static function hasMacro($name) + { + return isset(static::$macros[$name]); + } + + /** + * Flush the existing macros. + * + * @return void + */ + public static function flushMacros() + { + static::$macros = []; + } + + /** + * Dynamically handle calls to the class. + * + * @param string $method + * @param array $parameters + * @return mixed + * + * @throws \BadMethodCallException + */ + public static function __callStatic($method, $parameters) + { + if (! static::hasMacro($method)) { + throw new BadMethodCallException(sprintf( + 'Method %s::%s does not exist.', static::class, $method + )); + } + + $macro = static::$macros[$method]; + + if ($macro instanceof Closure) { + $macro = $macro->bindTo(null, static::class); + } + + return $macro(...$parameters); + } + + /** + * Dynamically handle calls to the class. + * + * @param string $method + * @param array $parameters + * @return mixed + * + * @throws \BadMethodCallException + */ + public function __call($method, $parameters) + { + if (! static::hasMacro($method)) { + throw new BadMethodCallException(sprintf( + 'Method %s::%s does not exist.', static::class, $method + )); + } + + $macro = static::$macros[$method]; + + if ($macro instanceof Closure) { + try { + $macro = $macro->bindTo($this, static::class) ?? throw new RuntimeException; + } catch (Throwable) { + $macro = $macro->bindTo(null, static::class); + } + } + + return $macro(...$parameters); + } +} diff --git a/src/Illuminate/Support/Traits/MacroableTrait.php b/src/Illuminate/Support/Traits/MacroableTrait.php index 12a91480..5b98bd1b 100644 --- a/src/Illuminate/Support/Traits/MacroableTrait.php +++ b/src/Illuminate/Support/Traits/MacroableTrait.php @@ -1,68 +1,11 @@ strtoupper($s)); + + $this->assertTrue($o::hasMacro('shout')); + $this->assertEquals('HI', $o->shout('hi')); + } + + public function testMacroableClosureBindsToInstance(): void + { + $o = new class { public $v = 41; use Macroable; }; + $o::macro('next', fn () => $this->v + 1); + + $this->assertEquals(42, $o->next()); + } + + public function testMacroableTraitAliasStillWorks(): void + { + $o = new class { use MacroableTrait; }; + $o::macro('ok', fn () => 'legacy'); + + $this->assertEquals('legacy', $o->ok()); + } + + public function testConditionableWhenAndUnless(): void + { + $o = new class { public $v = 0; use Conditionable; public function bump() { $this->v++; return $this; } }; + + $o->when(true, fn ($x) => $x->bump()) + ->when(false, fn ($x) => $x->bump()) + ->unless(false, fn ($x) => $x->bump()); + + $this->assertEquals(2, $o->v); + } + + public function testPipelinePassesThroughStack(): void + { + $result = (new Pipeline) + ->send('a') + ->through([ + fn ($passable, $next) => $next($passable . '-b'), + fn ($passable, $next) => $next($passable . '-c'), + ]) + ->then(fn ($passable) => $passable . '-end'); + + $this->assertInstanceOf(PipelineContract::class, new Pipeline); + $this->assertEquals('a-b-c-end', $result); + } +}