diff --git a/composer.json b/composer.json index fd0706b7..e2599cf1 100644 --- a/composer.json +++ b/composer.json @@ -87,6 +87,7 @@ "src/Functional/PartialRight.php", "src/Functional/Partition.php", "src/Functional/Pick.php", + "src/Functional/Pipe.php", "src/Functional/Pluck.php", "src/Functional/Poll.php", "src/Functional/Product.php", diff --git a/src/Functional/Functional.php b/src/Functional/Functional.php index 9795044c..5dbb8a29 100644 --- a/src/Functional/Functional.php +++ b/src/Functional/Functional.php @@ -14,7 +14,7 @@ final class Functional { /** - * @see \Function\ary + * @see \Functional\ary */ const ary = '\Functional\ary'; @@ -333,6 +333,11 @@ final class Functional */ const pick = '\Functional\pick'; + /** + * @see \Functional\pipe + */ + const pipe = '\Functional\pipe'; + /** * @see \Functional\pluck */ diff --git a/src/Functional/Pipe.php b/src/Functional/Pipe.php new file mode 100644 index 00000000..3bf13f12 --- /dev/null +++ b/src/Functional/Pipe.php @@ -0,0 +1,80 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +namespace Functional; + +use Functional\Exceptions\InvalidArgumentException; + +/** + * Provides a functor that applies the functions passed at construction + * from left to right, first function is able to admit several arguments + * at once. + * + * @link https://github.com/lstrojny/functional-php/issues/141 + * @param callable[] ...$functions functions to be composed + * @return callable + */ +function pipe(...$functions): callable +{ + return new Pipe($functions); +} + +class Pipe +{ + /** @var callable[] */ + protected $callables; + + protected $carry; + + protected $pipeLength = 0; + + public function __construct(array $functions) + { + $this->pipeLength = \count($functions); + if ($this->pipeLength < 2) { + throw new InvalidArgumentException( + 'You should pass at least 2 functions or functors to build a pipe' + ); + } + foreach ($functions as $index => $callable) { + InvalidArgumentException::assertCallback($callable, 'pipe', $index + 1); + $this->callables[] = $callable; + } + } + + public function __invoke() + { + $funArgs = \func_get_args(); + $this->carry = \call_user_func_array($this->callables[0], $funArgs); + + for ($index = 1; $index < $this->pipeLength; $index++) { + $this->carry = \call_user_func( + $this->callables[$index], + $this->carry + ); + } + + return $this->carry; + } +} diff --git a/tests/Functional/FunctionalTest.php b/tests/Functional/FunctionalTest.php index 85b9bfda..5f548ad7 100644 --- a/tests/Functional/FunctionalTest.php +++ b/tests/Functional/FunctionalTest.php @@ -24,7 +24,7 @@ public function testAllDefinedConstantsAreValidCallables() $functions = $functionalClass->getConstants(); foreach ($functions as $function) { - $this->assertInternalType('callable', $function); + $this->assertIsCallable($function); } } diff --git a/tests/Functional/PipeTest.php b/tests/Functional/PipeTest.php new file mode 100644 index 00000000..8c299cff --- /dev/null +++ b/tests/Functional/PipeTest.php @@ -0,0 +1,107 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +namespace Functional\Tests; + +use Functional\Exceptions\InvalidArgumentException; + +use function Functional\pipe; + +class PipeTest extends AbstractTestCase +{ + public function testPipeFunction() + { + $mockFirst = $this->getClosureMock(1, ['o', 'n', 'e'], 'one'); + $mockSecond = $this->getClosureMock(1, ['one'], 'one, two'); + $mockThird = $this->getClosureMock(1, ['one, two'], 'one, two, three'); + + $result = pipe( + $mockFirst, + $mockSecond, + $mockThird + )('o', 'n', 'e'); + + $this->assertEquals('one, two, three', $result); + } + + public function testShouldNotAcceptSingleFunction() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You should pass at least 2 functions or functors to build a pipe'); + pipe('strval')(); + } + + /** @dataProvider notQuiteFunctionsProvider */ + public function testExceptionNotCallable($maybeFun1, $maybeFun2, $expectedException) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedException); + pipe($maybeFun1, $maybeFun2)(); + } + + public function notQuiteFunctionsProvider() + { + return [ + [ + 'strval', + '__not', + 'pipe() expects parameter 2 to be a valid callback, ' . + 'function \'__not\' not found or invalid function name' + ], + [ + 'runabout', + 'intval', + 'pipe() expects parameter 1 to be a valid callback, ' . + 'function \'runabout\' not found or invalid function name' + ] + ]; + } + + private function getClosureMock( + int $invocations, + array $expectedArguments, + $mustReturnValue + ) { + $mock = $this->getMockBuilder(CustomTestClosure::class) + ->getMock(); + + $argsArray = []; + for ($index = 0; $index < count($expectedArguments); $index++) { + $argsArray[] = $this->equalTo($expectedArguments[$index]); + } + + $mock->expects($this->exactly($invocations)) + ->method('__invoke') + ->withConsecutive($argsArray) + ->willReturn($mustReturnValue); + return $mock; + } +} + +class CustomTestClosure +{ + public function __invoke() + { + } +}