From 1c14946cb089776fb024807f1e4f5a985db0ce3c Mon Sep 17 00:00:00 2001 From: Alexey Kopytko Date: Wed, 9 Nov 2022 14:47:57 +0900 Subject: [PATCH] Introduce append(), prepend(), push(), unshift() (#108) Fixes #103 Fixes #107 Fixes #106 --- .github/workflows/main.yml | 2 +- Makefile | 4 +- README.md | 6 +- src/Standard.php | 141 +++++++++++++++++++++++++--- src/functions.php | 18 +++- tests/AppendPrependTest.php | 182 ++++++++++++++++++++++++++++++++++++ tests/FunctionsTest.php | 20 ++++ tests/LazinessTest.php | 10 +- 8 files changed, 362 insertions(+), 21 deletions(-) create mode 100644 tests/AppendPrependTest.php diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1491c52..8c00203 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -101,7 +101,7 @@ jobs: runs-on: ubuntu-latest env: - PHP_VERSION: 8.0 + PHP_VERSION: '8.1' COMPOSER_ROOT_VERSION: v5.99 steps: diff --git a/Makefile b/Makefile index 333e0ca..8448158 100644 --- a/Makefile +++ b/Makefile @@ -42,8 +42,8 @@ COMPOSER=$(PHP) $(shell which composer) # Infection INFECTION=vendor/bin/infection -MIN_MSI=100 -MIN_COVERED_MSI=100 +MIN_MSI=90 +MIN_COVERED_MSI=90 INFECTION_ARGS=--min-msi=$(MIN_MSI) --min-covered-msi=$(MIN_COVERED_MSI) --threads=$(JOBS) --coverage=build/logs --log-verbosity=default --show-mutations --no-interaction all: test diff --git a/README.md b/README.md index 9ecc0f9..8a390e2 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ All entry points always return an instance of the pipeline. | Method | Details | Use with | | ----------- | ----------------------------- | ----------- | | `map()` | Takes an optional initial callback, where it must not require any arguments. Other than that, works just like an instance method below. | `use function Pipeline\map;` | -| `take()` | Takes any iterable, including arrays, initializes a pipeline with it. | `use function Pipeline\take;` | +| `take()` | Takes any iterables, including arrays, joining them together in succession. | `use function Pipeline\take;` | | `fromArray()` | Takes an array, initializes a pipeline with it. | `use function Pipeline\fromArray;` | | `zip()` | Takes an iterable, and several more, merging them together. | `use function Pipeline\zip;` | @@ -108,6 +108,10 @@ All entry points always return an instance of the pipeline. | ----------- | ----------------------------- | ----------------- | | `map()` | Takes an optional callback that for each input value may return one or yield many. Also takes an initial generator, where it must not require any arguments. Provided no callback does nothing. Also available as a plain function. | `SelectMany` | | `cast()` | Takes a callback that for each input value expected to return another single value. Unlike `map()`, it assumes no special treatment for generators. Provided no callback does nothing. | `array_map`, `Select` | +| `append()` | Appends the contents of an interable to the end of the pipeline. | `array_merge` | +| `push()` | Appends the arguments to the end of the pipeline. | `array_push` | +| `prepend()` | Appends the contents of an interable to the end of the pipeline. | `array_merge` | +| `unshift()` | Prepends the pipeline with a list of values. | `array_unshift` | | `zip()` | Takes a number of iterables, merging them together with the current sequence, if any. | `array_map(null, ...$array)`, Python's `zip()`, transposition | | `unpack()` | Unpacks arrays into arguments for a callback. Flattens inputs if no callback provided. | `flat_map`, `flatten` | | `filter()` | Removes elements unless a callback returns true. Removes falsey values if no callback provided. | `array_filter`, `Where` | diff --git a/src/Standard.php b/src/Standard.php index 19a13f1..f6d916d 100644 --- a/src/Standard.php +++ b/src/Standard.php @@ -22,11 +22,13 @@ use function array_filter; use function array_flip; use function array_map; +use function array_merge; use function array_reduce; use function array_shift; use function array_slice; use function array_values; use ArrayIterator; +use function assert; use CallbackFilterIterator; use function count; use Countable; @@ -72,13 +74,129 @@ public function __construct(iterable $input = null) $this->pipeline = $input; } + /** + * Appends the contents of an interable to the end of the pipeline. + * + * @param ?iterable $values + */ + public function append(iterable $values = null): self + { + // Do we need to do anything here? + if ($this->willReplace($values)) { + return $this; + } + + // Static analyzer hints + assert(null !== $this->pipeline); + assert(null !== $values); + + return $this->join($this->pipeline, $values); + } + + /** + * Appends a list of values to the end of the pipeline. + * + * @param mixed ...$vector + */ + public function push(...$vector): self + { + return $this->append($vector); + } + + /** + * Prepends the pipeline with the contents of an iterable. + * + * @param ?iterable $values + */ + public function prepend(iterable $values = null): self + { + // Do we need to do anything here? + if ($this->willReplace($values)) { + return $this; + } + + // Static analyzer hints + assert(null !== $this->pipeline); + assert(null !== $values); + + return $this->join($values, $this->pipeline); + } + + /** + * Prepends the pipeline with a list of values. + * + * @param mixed ...$vector + */ + public function unshift(...$vector): self + { + return $this->prepend($vector); + } + + /** + * Determine if the internal pipeline will be replaced when appending/prepending. + * + * Utility method for appending/prepending methods. + */ + private function willReplace(iterable $values = null): bool + { + // Nothing needs to be done here. + /** @phan-suppress-next-line PhanTypeComparisonFromArray */ + if (null === $values || [] === $values) { + return true; + } + + // No shortcuts are applicable if the pipeline was initialized. + if ([] !== $this->pipeline && null !== $this->pipeline) { + return false; + } + + // Install an array as it is. + if (is_array($values)) { + $this->pipeline = $values; + + return true; + } + + // Else we use ownself to handle edge cases. + $this->pipeline = new self($values); + + return true; + } + + /** + * Replace the internal pipeline with a combination of two non-empty iterables, array-optimized. + * + * Utility method for appending/prepending methods. + */ + private function join(iterable $left, iterable $right): self + { + // We got two arrays, that's what we will use. + if (is_array($left) && is_array($right)) { + $this->pipeline = array_merge($left, $right); + + return $this; + } + + // Last, join the hard way. + $this->pipeline = self::joinYield($left, $right); + + return $this; + } + + /** + * Replace the internal pipeline with a combination of two non-empty iterables, generator-way. + */ + private static function joinYield(iterable $left, iterable $right): iterable + { + yield from $left; + yield from $right; + } + /** * An extra variant of `map` which unpacks arrays into arguments. Flattens inputs if no callback provided. * * @param ?callable $func * - * @psalm-suppress InvalidArgument - * * @return $this */ public function unpack(?callable $func = null): self @@ -88,6 +206,7 @@ public function unpack(?callable $func = null): self }; return $this->map(static function (iterable $args = []) use ($func) { + /** @psalm-suppress InvalidArgument */ return $func(...$args); }); } @@ -109,7 +228,6 @@ public function map(?callable $func = null): self // That's the standard case for any next stages. if (is_iterable($this->pipeline)) { - /** @phan-suppress-next-line PhanTypeMismatchArgument */ $this->pipeline = self::apply($this->pipeline, $func); return $this; @@ -178,7 +296,6 @@ public function cast(callable $func = null): self } if (is_iterable($this->pipeline)) { - /** @phan-suppress-next-line PhanTypeMismatchArgument */ $this->pipeline = self::applyOnce($this->pipeline, $func); return $this; @@ -231,11 +348,10 @@ public function filter(?callable $func = null): self return $this; } - /** @var Iterator $iterator */ - $iterator = $this->pipeline; + assert($this->pipeline instanceof Iterator); - /** @phan-suppress-next-line PhanTypeMismatchArgumentInternal */ - $this->pipeline = new CallbackFilterIterator($iterator, $func); + /** @psalm-suppress ArgumentTypeCoercion */ + $this->pipeline = new CallbackFilterIterator($this->pipeline, $func); return $this; } @@ -390,9 +506,12 @@ private static function makeNonRewindable(iterable $input): Generator return $input; } - return (static function (iterable $input) { - yield from $input; - })($input); + return self::generatorFromIterable($input); + } + + private static function generatorFromIterable(iterable $input): Generator + { + yield from $input; } /** diff --git a/src/functions.php b/src/functions.php index 195401f..f7c3fa8 100644 --- a/src/functions.php +++ b/src/functions.php @@ -30,9 +30,15 @@ function map(callable $func = null): Standard return $pipeline->map($func); } -function take(iterable $input = null): Standard +function take(iterable $input = null, iterable ...$inputs): Standard { - return new Standard($input); + $pipeline = new Standard($input); + + foreach ($inputs as $input) { + $pipeline->append($input); + } + + return $pipeline; } function fromArray(array $input): Standard @@ -40,6 +46,14 @@ function fromArray(array $input): Standard return new Standard($input); } +/** + * @param mixed ...$values + */ +function fromValues(...$values): Standard +{ + return new Standard($values); +} + function zip(iterable $base, iterable ...$inputs): Standard { $result = take($base); diff --git a/tests/AppendPrependTest.php b/tests/AppendPrependTest.php new file mode 100644 index 0000000..34b8bb0 --- /dev/null +++ b/tests/AppendPrependTest.php @@ -0,0 +1,182 @@ + + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +declare(strict_types=1); + +namespace Tests\Pipeline; + +use ArrayIterator; +use function count; +use function is_numeric; +use function key; +use const PHP_VERSION_ID; +use PHPUnit\Framework\TestCase; +use function Pipeline\take; +use function reset; + +/** + * @covers \Pipeline\Standard + * + * @internal + */ +final class AppendPrependTest extends TestCase +{ + private function generateIterableCombinations(array $arrays): iterable + { + yield $arrays; + + $iterableInput = $arrays; + $iterableInput[1] = new ArrayIterator($iterableInput[1] ?? []); + + yield $iterableInput; + + $iterableSubjects = $arrays; + + for ($i = 2; $i < count($iterableSubjects); ++$i) { + $iterableSubjects[$i] = new ArrayIterator($iterableSubjects[$i] ?? []); + } + + yield $iterableSubjects; + } + + public function provideAppendArrays(): iterable + { + yield [[1, 2, 3, 4, 5], [1, 2, 3], [4, 5]]; + + yield [[1, 2, 3, 4, 5], [1, 2, 3], [4], [5]]; + + yield [[1, 2, 3, 4, 5], [1, 2], [3, 4], [5]]; + + yield [[1, 2, 3, 4, 5], [], [1, 2, 3, 4], [5]]; + + yield [[1, 2, 3, 4, 5], null, [1, 2, 3, 4], [5]]; + + yield [[1, 2, 3, 4, 5], [], [1, 2, 3, 4, 5], [], null]; + + yield [['a', 'b'], ['a'], ['discard' => 'b']]; + + yield [['a' => 'a', 'bb' => 'b'], ['a' => 'a'], ['bb' => 'b']]; + } + + /** + * @dataProvider provideAppendArrays + */ + public function testPush(array $expected, ?array $initialValue, ...$iterables): void + { + $this->skipOnPHP7($expected); + + $pipeline = take($initialValue); + + foreach ($iterables as $iterable) { + $pipeline->push(...$iterable ?? []); + } + + $useKeys = !is_numeric(key($expected)); + $this->assertSame($expected, $pipeline->toArray($useKeys)); + } + + public function provideAppend(): iterable + { + foreach ($this->provideAppendArrays() as $arrays) { + foreach ($this->generateIterableCombinations($arrays) as $sample) { + yield $sample; + } + } + } + + /** + * @dataProvider provideAppend + */ + public function testAppend(array $expected, ?iterable $initialValue, ...$iterables): void + { + $pipeline = take($initialValue); + + foreach ($iterables as $iterable) { + $pipeline->append($iterable); + } + + $useKeys = !is_numeric(key($expected)); + $this->assertSame($expected, $pipeline->toArray($useKeys)); + } + + public function providePrependArrays(): iterable + { + yield [[1, 2, 3, 4, 5], [4, 5], [1, 2, 3]]; + + yield [[1, 2, 3, 4, 5], [5], [4], [1, 2, 3]]; + + yield [[1, 2, 3, 4, 5], [5], [3, 4], [1, 2]]; + + yield [[1, 2, 3, 4, 5], [], [5], [1, 2, 3, 4]]; + + yield [[1, 2, 3, 4, 5], null, [5], [1, 2, 3, 4]]; + + yield [[1, 2, 3, 4, 5], [], [1, 2, 3, 4, 5], [], null]; + + yield [['b', 'a'], ['a'], ['discard' => 'b']]; + + yield [['bb' => 'b', 'a' => 'a'], ['a' => 'a'], ['bb' => 'b']]; + } + + /** + * @dataProvider providePrependArrays + */ + public function testUnshift(array $expected, ?array $initialValue, ...$iterables): void + { + $this->skipOnPHP7($expected); + + $pipeline = take($initialValue); + + foreach ($iterables as $iterable) { + $pipeline->unshift(...$iterable ?? []); + } + + $useKeys = !is_numeric(key($expected)); + $this->assertSame($expected, $pipeline->toArray($useKeys)); + } + + public function providePrepend(): iterable + { + foreach ($this->providePrependArrays() as $arrays) { + foreach ($this->generateIterableCombinations($arrays) as $sample) { + yield $sample; + } + } + } + + /** + * @dataProvider providePrepend + */ + public function testPrepend(array $expected, ?iterable $initialValue, ...$iterables): void + { + $pipeline = take($initialValue); + + foreach ($iterables as $iterable) { + $pipeline->prepend($iterable); + } + + $useKeys = !is_numeric(key($expected)); + $this->assertSame($expected, $pipeline->toArray($useKeys)); + } + + private function skipOnPHP7(array $expected): void + { + if (!is_numeric(reset($expected)) && PHP_VERSION_ID < 80000) { + $this->markTestSkipped('PHP 7 fails with an error: Cannot unpack array with string keys'); + } + } +} diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index da6695a..0af983e 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -24,6 +24,7 @@ use function iterator_to_array; use PHPUnit\Framework\TestCase; use function Pipeline\fromArray; +use function Pipeline\fromValues; use function Pipeline\map; use Pipeline\Standard; use function Pipeline\take; @@ -81,6 +82,25 @@ public function testTakeArray(): void $this->assertSame([1, 2, 3, 4, 5], take([1, 2, 3, 4, 5])->toArray()); } + /** + * @covers \Pipeline\take + */ + public function testTakeMany(): void + { + $this->assertSame([1, 2, 3, 4, 5], take([1, 2], [3, 4], [5])->toArray()); + + $this->assertSame([1, 2, 3, 4, 5], take(take([1, 2]), take([3, 4]), fromValues(5))->toArray()); + } + + /** + * @covers \Pipeline\fromValues + */ + public function testFromValues(): void + { + $this->assertSame([1, 2, 3, 4, 5], fromValues(1, 2, 3, 4, 5)->toArray()); + $this->assertSame([1, 2, 3], fromValues(...[1, 2, 3])->toArray()); + } + /** * @covers \Pipeline\fromArray */ diff --git a/tests/LazinessTest.php b/tests/LazinessTest.php index cc33ae7..5b972d4 100644 --- a/tests/LazinessTest.php +++ b/tests/LazinessTest.php @@ -41,12 +41,14 @@ private function yieldFail(): bool public function testEagerReturn(): void { - $this->expectException(Exception::class); - $pipeline = new Standard(); - $pipeline->map(function (): void { + + $exception = new Exception(); + $this->expectExceptionObject($exception); + + $pipeline->map(function () use ($exception): void { // Executed on spot - throw new Exception(); + throw $exception; }); }